openvibers/openvibe.today Commits

Agent Streams S7: the Agents live block, schedules and the digest card (#101)

OP OpenVibers committed 7 days ago · b7d815b7f4 · 7 files, +287 −7

Changes

ddiff --git a/public/css/today.css b/public/css/today.css
iindex f8d439d..d6d854f 100644
---- a/public/css/today.css
++++ b/public/css/today.css
@@@ -325,3 +325,35 @@ html[data-ov-motion=reduced] .td-demo-toast, html[data-ov-motion=reduced] .td-ri
.table-wrap > table:has(thead th:nth-child(3)):has(td[data-label]) td[data-label]:not(:first-child)::before { content: attr(data-label); display: block; margin-bottom: 2px; font-size: 11px; font-weight: 700; letter-spacing: .05em; text-transform: uppercase; color: var(--text-muted, #7386a3); }
.table-wrap > table:has(thead th:nth-child(3)):has(td[data-label]) td:empty { display: none; }
}
++
++/* Agents live and the agent digest (Agent Streams S7). Their own block: never mixed with people's streams. */
++.td-agents-live { margin-top: clamp(28px, 5vw, 48px); }
++.td-agents { list-style: none; margin: 0; padding: 0; display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 14px; }
++.td-agent-link { display: flex; flex-direction: column; height: 100%; border: 1px solid var(--ov-border, #2a3140); border-radius: 14px; overflow: hidden; color: inherit; text-decoration: none; background: var(--ov-surface, #121722); }
++.td-agent-media { position: relative; display: block; aspect-ratio: 16 / 9; background: #0b0f16; }
++.td-agent-media img { width: 100%; height: 100%; object-fit: cover; display: block; }
++.td-agent-noimg { display: flex; align-items: center; justify-content: center; width: 100%; height: 100%; color: #8b93a7; font-size: .82rem; }
++.td-agent-label { position: absolute; left: 8px; bottom: 8px; padding: 2px 7px; border-radius: 999px; background: rgba(4, 10, 20, .8); color: #fff; font-size: .7rem; text-transform: uppercase; letter-spacing: .05em; }
++.td-agent-viewers { position: absolute; right: 8px; bottom: 8px; padding: 2px 7px; border-radius: 999px; background: rgba(4, 10, 20, .8); color: #fff; font-size: .7rem; }
++.td-agent-body { display: flex; flex-direction: column; gap: 4px; padding: 10px 12px 12px; }
++.td-agent-title { font-size: .98rem; line-height: 1.25; }
++.td-agent-task, .td-agent-step { font-size: .8rem; opacity: .8; }
++.td-agent-step { color: var(--ov-accent, #6ea8fe); }
++.td-agent-fold { margin-top: 10px; }
++.td-agent-fold > summary { cursor: pointer; padding: 8px 0; color: var(--ov-accent, #6ea8fe); }
++.td-agent-planned { margin-top: 18px; }
++.td-agent-planned h3, .td-agent-digest h3 { margin: 0 0 8px; font-size: 1rem; }
++.td-agent-planned ul, .td-agent-digest ul { list-style: none; margin: 0; padding: 0; display: grid; gap: 6px; }
++.td-agent-planned a { display: flex; flex-wrap: wrap; gap: 8px; align-items: baseline; color: inherit; text-decoration: none; padding: 8px 10px; border: 1px solid var(--ov-border, #2a3140); border-radius: 10px; }
++.td-agent-planned time { font-variant-numeric: tabular-nums; font-size: .78rem; opacity: .8; min-width: 8.5rem; }
++.td-agent-planned b { font-size: .9rem; }
++.td-agent-planned span { font-size: .78rem; opacity: .75; }
++.td-agent-digest { margin-top: 18px; padding: 12px 14px; border: 1px solid var(--ov-border, #2a3140); border-radius: 12px; background: var(--ov-surface, #121722); }
++.td-agent-digest-counts { margin: 0 0 8px; font-size: .85rem; opacity: .85; }
++.td-agent-digest li { display: flex; justify-content: space-between; gap: 10px; font-size: .85rem; }
++.td-agent-digest span { opacity: .7; }
++@media (max-width: 640px) {
++ .td-agents { grid-template-columns: 1fr; }
++ .td-agent-planned time { min-width: 0; }
++ .td-agent-link, .td-agent-planned a { min-height: 44px; }
++}
ddiff --git a/server/agents.js b/server/agents.js
nnew file mode 100644
iindex 0000000..b33cca9
---- /dev/null
++++ b/server/agents.js
@@@ -0,0 +1,94 @@
++'use strict';
++/**
++ * Agent streams on Today (Agent Streams S7, design 2026-11-06 §1 and §3).
++ *
++ * - Agents live: the public agent streams that are live now, one card each, followed channels first and then the
++ * most watched. They are their own section, never mixed into people's streams or the calendar rows.
++ * - Scheduled: public agent streams that start in the next 24 hours (the design's window).
++ * - Your agents this week: the person's own session reports from OpenVibe.Report, private to them.
++ *
++ * The public reads never take a person's token: Live's agent list is public. The followed-first order uses Network's
++ * follow graph with the person's own token (server/identity/follows.js), and a Network failure only loses the order,
++ * never the block. A failure from Live answers empty and `unavailable: true`, so the page still renders.
++ */
++const DAY = 24 * 60 * 60_000;
++
++/** Followed first, then the most watched, then the newest. Never a person's stream: only agent cards get here. */
++function sortAgents(agents, followed = []) {
++ const set = new Set(followed || []);
++ return [...(Array.isArray(agents) ? agents : [])]
++ .filter((a) => a && a.state === 'live' && a.visibility === 'public')
++ .sort((a, b) => (set.has(b.owner) - set.has(a.owner))
++ || (Number(b.viewers) || 0) - (Number(a.viewers) || 0)
++ || Date.parse(b.started_at || 0) - Date.parse(a.started_at || 0));
++}
++
++function createAgents({ config, follows, fetchImpl = globalThis.fetch, log = console, now = () => Date.now() }) {
++ const liveBase = String((config.imports && config.imports.live && config.imports.live.url) || config.live.internalUrl || 'https://openvibe.live').replace(/\/+$/, '');
++ const reportBase = String((config.report && config.report.url) || 'https://openvibe.report').replace(/\/+$/, '');
++ const timeout = (config.live && config.live.timeoutMs) || 2500;
++ const reportTimeout = (config.report && config.report.timeoutMs) || 2500;
++
++ async function getJson(url, token, ms) {
++ const res = await fetchImpl(url, {
++ headers: { Accept: 'application/json', ...(token ? { Authorization: `Bearer ${token}` } : {}) },
++ signal: AbortSignal.timeout(ms),
++ });
++ if (!res.ok) throw new Error(`${url} ${res.status}`);
++ return await res.json().catch(() => null);
++ }
++
++ /** The live public agent streams, followed first. */
++ async function live({ subject = null, token = null } = {}) {
++ let followed = [];
++ let followsUnavailable = false;
++ if (subject && token) {
++ try {
++ const r = await follows.forSubject(subject, token);
++ followed = r.follows;
++ followsUnavailable = r.unavailable;
++ } catch { followsUnavailable = true; }
++ }
++ try {
++ const data = await getJson(`${liveBase}/api/streams/agent?live=1`, null, timeout);
++ if (!data || !Array.isArray(data.agents)) throw new Error('agents: no list');
++ return { agents: sortAgents(data.agents, followed), unavailable: false, follows_unavailable: followsUnavailable };
++ } catch (err) {
++ log.warn(`[OpenVibe.Today] agents unavailable: ${(err && err.message) || err}`);
++ return { agents: [], unavailable: true, follows_unavailable: followsUnavailable };
++ }
++ }
++
++ /** The public agent schedules that start in the next 24 hours. */
++ async function schedules() {
++ try {
++ const data = await getJson(`${liveBase}/api/streams/agent/schedules?public=1`, null, timeout);
++ if (!data || !Array.isArray(data.schedules)) throw new Error('schedules: no list');
++ const until = now() + DAY;
++ return {
++ schedules: data.schedules.filter((s) => s && s.state === 'scheduled' && Date.parse(s.starts_at) <= until && Date.parse(s.starts_at) >= now() - 60_000),
++ unavailable: false,
++ };
++ } catch (err) {
++ log.warn(`[OpenVibe.Today] agent schedules unavailable: ${(err && err.message) || err}`);
++ return { schedules: [], unavailable: true };
++ }
++ }
++
++ /** The person's own session reports of the last seven days (never anyone else's). */
++ async function digest(token, { days = 7 } = {}) {
++ if (!token) return { reports: [], unavailable: false };
++ try {
++ const data = await getJson(`${reportBase}/api/v1/me/session-reports?days=${Math.min(30, Math.max(1, Number(days) || 7))}`, token, reportTimeout);
++ if (!data || !Array.isArray(data.reports)) throw new Error('reports: no list');
++ return { reports: data.reports, unavailable: false };
++ } catch (err) {
++ log.warn(`[OpenVibe.Today] agent digest unavailable: ${(err && err.message) || err}`);
++ return { reports: [], unavailable: true };
++ }
++ }
++
++ return { live, schedules, digest };
++}
++
++module.exports = { createAgents, sortAgents, DAY };
ddiff --git a/server/app.js b/server/app.js
iindex 1bd21d3..98dc3e9 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -39,6 +39,7 @@ const { createPublisher } = require('./events/publisher');
const { createFeeds } = require('./feeds');
const { createFollows } = require('./identity/follows');
const { createAgenda } = require('./agenda');
++const { createAgents } = require('./agents');
const { createBrief } = require('./brief');
const { createImports } = require('./imports');
const { createNotifier } = require('./notifications');
@@@ -68,6 +69,7 @@ async function createApp(opts = {}) {
const feeds = createFeeds({ s });
const follows = createFollows({ config, fetchImpl, now: s.now, log });
const agenda = createAgenda({ s, follows });
++ const agents = createAgents({ config, follows, fetchImpl, now: s.now, log });
const brief = createBrief({ config, s, agenda, fetchImpl, log });
const notifier = createNotifier({ config, s, fetchImpl, log });
const mine = require('./my-reminders').createMyReminders({ s });
@@@ -85,7 +87,7 @@ async function createApp(opts = {}) {
const consumer = createEventsConsumer({ config, s, accountData, accountSend, now: s.now, log });
const ctx = {
-- config, s, keys, sso, principal, events, feeds, follows, agenda, brief, notifier, reminders, mine, publisher,
++ config, s, keys, sso, principal, events, feeds, follows, agenda, agents, brief, notifier, reminders, mine, publisher,
imports, calendarImports, accountData, accountSend, consumer, log,
};
ddiff --git a/server/config.js b/server/config.js
iindex 39a0114..981867c 100644
---- a/server/config.js
++++ b/server/config.js
@@@ -65,6 +65,12 @@ function load(env = process.env) {
scope: env.TODAY_EVENTS_SCOPE || 'events.event.publish',
},
++ // OpenVibe.Report's public API, for "Your agents this week" (the S3 digest card).
++ report: {
++ url: trim(env.OV_REPORT_URL || 'https://openvibe.report'),
++ timeoutMs: Math.max(300, int(env.TODAY_REPORT_TIMEOUT_MS, 2500)),
++ },
++
// OpenVibe.Live's public Rundown API, for "Yesterday on OpenVibe" in the brief.
live: {
internalUrl: trim(env.OV_LIVE_INTERNAL_URL || 'http://127.0.0.1:3000'),
ddiff --git a/server/http/pages.js b/server/http/pages.js
iindex 2791f96..49e5ce5 100644
---- a/server/http/pages.js
++++ b/server/http/pages.js
@@@ -67,18 +67,26 @@ function eventJsonLd(e, config) {
* the Link header advertise it. Nothing here is drawn for a signed-in person.
*/
function createPublicPageViews(ctx) {
-- const { config, s, events } = ctx;
++ const { config, s, events, agents } = ctx;
const base = () => String(config.baseUrl).replace(/\/+$/, '');
-- async function home() {
++ async function home(req) {
const now = s.now();
const HOLIDAYS = ['holiday', 'observance'];
++ const viewer = req && req.viewer && req.viewer.kind === 'user' && req.viewer.subject ? req.viewer : null;
const rows = await events.listPublic({ from: new Date(now - 2 * 60 * 60_000).toISOString(), to: new Date(now + 60 * DAY).toISOString(), limit: 20, exceptKinds: HOLIDAYS });
const holidays = await events.listPublic({ from: new Date(now - DAY).toISOString(), to: new Date(now + 8 * DAY).toISOString(), limit: 200, kinds: ['holiday'] });
++ // S7: the agents' own block, never mixed into the calendar rows. A signed-in reader gets their follows'
++ // order and their own digest; everyone else gets the same public streams by viewers.
++ const [liveAgents, plannedAgents, digest] = await Promise.all([
++ agents ? agents.live({ subject: viewer ? viewer.subject : null, token: viewer ? viewer.token : null }) : { agents: [] },
++ agents ? agents.schedules() : { schedules: [] },
++ viewer && agents ? agents.digest(viewer.token) : { reports: [] },
++ ]);
return {
index: true, cache: PUBLIC_CACHE, agent: true,
title: null,
-- body: raw(views.home({ upcoming: rows, holidays, now })),
++ body: raw(views.home({ upcoming: rows, holidays, now, agents: liveAgents, schedules: plannedAgents, digest })),
description: 'Streams, events and reminders across OpenVibe on one calendar: schedule a stream, get reminded, and subscribe in your own calendar app.',
// Every row on the page is a dated, public event the reader can open: that is exactly one Event node.
jsonLd: oneGraph([...homeJsonLd(config), ...rows.map((e) => eventJsonLd(e, config)), seo.jsonLd.organization()]),
@@@ -126,7 +134,7 @@ function createPageRoutes(ctx) {
// ── Home ─────────────────────────────────────────────────
const publicViews = createPublicPageViews(ctx);
-- r.get('/', async (req, res) => page(req, res, await publicViews.home()));
++ r.get('/', async (req, res) => page(req, res, await publicViews.home(req)));
// ── A channel's schedule ────────────────────────────────
r.get('/@:username', async (req, res) => {
ddiff --git a/server/views/index.js b/server/views/index.js
iindex 1b37cab..08948a2 100644
---- a/server/views/index.js
++++ b/server/views/index.js
@@@ -130,7 +130,44 @@ function holidaysWeek(rows, now) {
}).join('')}</ul></li>`).join('')}</ol></section>`;
}
--function home({ upcoming = [], holidays = [], now = Date.now() } = {}) {
++
++/** One live agent card: its last frame, the task and the current step (S7). Every value is text. */
++function agentCard(a) {
++ const step = a.current_step && a.current_step.title ? `<span class="td-agent-step">${esc(a.current_step.title)}</span>` : '';
++ const viewers = a.viewers ? `<span class="td-agent-viewers">${esc(String(a.viewers))} watching</span>` : '';
++ return `<li class="td-agent"><a class="td-agent-link" href="${esc(a.page_url || '#')}">
++<span class="td-agent-media">${a.last_frame_url ? `<img src="${esc(a.last_frame_url)}" alt="" loading="lazy">` : '<span class="td-agent-noimg">No frame yet</span>'}<span class="td-agent-label">AI agent</span>${viewers}</span>
++<span class="td-agent-body"><b class="td-agent-title">${esc(a.title || 'An AI agent stream')}</b>
++${a.task && a.task.title ? `<span class="td-agent-task">${esc(a.task.title)}</span>` : ''}${step}</span></a></li>`;
++}
++
++/** The Agents live block: public live agent streams (followed first, then most watched), the next 24 hours of
++ * schedules, and — for the person signed in — the digest card of their own agents. Never mixed with people's streams. */
++function agentsLive({ agents = null, schedules = null, digest = null, now = Date.now() } = {}) {
++ const live = (agents && Array.isArray(agents.agents)) ? agents.agents : [];
++ const planned = (schedules && Array.isArray(schedules.schedules)) ? schedules.schedules : [];
++ const reports = (digest && Array.isArray(digest.reports)) ? digest.reports : [];
++ if (!live.length && !planned.length && !reports.length) return '';
++ const shown = live.slice(0, 3);
++ const rest = live.slice(3);
++ const cards = shown.length ? `<ul class="td-agents">${shown.map(agentCard).join('')}</ul>` : '';
++ // The counted fold: never a silent cut, and it works without JavaScript.
++ const more = rest.length ? `<details class="td-agent-fold"><summary>More (${rest.length})</summary><ul class="td-agents">${rest.map(agentCard).join('')}</ul></details>` : '';
++ const plannedRows = planned.slice(0, 4).map((s) => `<li><a href="${esc(s.page_url || '#')}"><time datetime="${esc(s.starts_at)}" data-local="time">${esc(parts(s.starts_at, 'UTC').month)} ${esc(parts(s.starts_at, 'UTC').day)}, ${esc(timeText(parts(s.starts_at, 'UTC')))}</time><b>${esc(s.title)}</b><span>@${esc(s.channel)}${s.waits_for_go ? ' · waits for Go' : ''}</span></a></li>`).join('');
++ const plannedBlock = plannedRows ? `<div class="td-agent-planned"><h3>Scheduled · next 24 hours</h3><ul>${plannedRows}</ul></div>` : '';
++ let digestBlock = '';
++ if (reports.length) {
++ const done = reports.filter((r) => r.outcome === 'done').length;
++ const failed = reports.filter((r) => r.outcome === 'failed').length;
++ const links = reports.slice(0, 3).map((r) => `<li><a href="https://openvibe.report/s/${encodeURIComponent(r.id)}">${esc((r.task && r.task.title) || r.summary || 'A session')}</a><span>${esc(r.outcome || 'done')}</span></li>`).join('');
++ digestBlock = `<aside class="td-agent-digest" aria-labelledby="td-agent-digest-h"><h3 id="td-agent-digest-h">Your agents this week</h3>
++<p class="td-agent-digest-counts"><b>${reports.length}</b> session${reports.length === 1 ? '' : 's'}${done ? ` · ${done} done` : ''}${failed ? ` · ${failed} failed` : ''}</p><ul>${links}</ul></aside>`;
++ }
++ return `<section class="td-agents-live" id="td-agents" aria-labelledby="td-agents-h"><div class="td-head-row"><h2 id="td-agents-h">${kit.icon('live')}Agents live</h2><a class="td-more" href="https://openvibe.live/">On Live${kit.icon('chevron')}</a></div>
++${cards}${more}${plannedBlock}${digestBlock}</section>`;
++}
++
++function home({ upcoming = [], holidays = [], agents = null, schedules = null, digest = null, now = Date.now() } = {}) {
const channels = new Set(upcoming.map((e) => e.channel).filter(Boolean));
const live = upcoming.filter((e) => e.status === 'live');
const scenes = [
@@@ -182,7 +219,8 @@ ${days(upcoming, { now })}</section>` : '';
['Can I see this in my own calendar app?', 'Yes. Every channel has a public calendar feed, and your agenda has a private one. Subscribe in any app that reads iCalendar (.ics); updates and cancellations follow.'],
['Who sees what I schedule?', 'A public stream is on your channel, here and in feeds. A followers-only one is seen only by the people who follow you. Followers hear about at most three new schedules from you a day.'],
] });
-- const body = hero.replace(/<\/div>\s*$/, `${coming}${holidaysWeek(holidays, now)}${scenesHTML}${beFirst}${explore}${faq}</div>`);
++ const agentsHTML = agentsLive({ agents, schedules, digest, now });
++ const body = hero.replace(/<\/div>\s*$/, `${agentsHTML}${coming}${holidaysWeek(holidays, now)}${scenesHTML}${beFirst}${explore}${faq}</div>`);
return `<div class="td-wrap" id="td-home">${body}</div>`;
}
ddiff --git a/test/agents-home.test.js b/test/agents-home.test.js
nnew file mode 100644
iindex 0000000..5141d5f
---- /dev/null
++++ b/test/agents-home.test.js
@@@ -0,0 +1,100 @@
++'use strict';
++// Agent streams on Today (S7): only public live agent streams, followed first, never mixed with people's streams,
++// the counted fold after three, the next 24 hours of schedules, and the digest card of the person's own agents.
++const assert = require('assert');
++const { createAgents, sortAgents } = require('../server/agents');
++const views = require('../server/views');
++
++let failures = 0;
++function check(name, fn) {
++ try { fn(); console.log(' ✓', name); }
++ catch (e) { failures++; console.log(' ✗', name, '\n ', (e.stack || String(e)).split('\n').slice(0, 6).join('\n ')); }
++}
++
++const A = {
++ id: 'ags_01JAB2C3D4E5F6G7H8J9K0MNA1', owner: 'usr_01JAB2C3D4E5F6G7H8J9K0MNA1', channel: 'ann',
++ title: 'Followed agent', state: 'live', visibility: 'public', viewers: 2,
++ task: { source: 'codes', ref: 'tsk_1', title: 'Fix the login page' }, current_step: { title: 'Run the tests', status: 'started' },
++ started_at: '2026-11-07T01:00:00Z', page_url: 'https://openvibe.live/@ann/agents/ags_01JAB2C3D4E5F6G7H8J9K0MNA1',
++};
++const B = { ...A, id: 'ags_01JAB2C3D4E5F6G7H8J9K0MNB2', owner: 'usr_01JAB2C3D4E5F6G7H8J9K0MNB2', channel: 'bob', title: 'Watched agent', viewers: 40, started_at: '2026-11-07T00:00:00Z' };
++const C = { ...A, id: 'ags_01JAB2C3D4E5F6G7H8J9K0MNC3', owner: 'usr_01JAB2C3D4E5F6G7H8J9K0MNC3', channel: 'cat', title: 'A small agent', viewers: 1, started_at: '2026-11-07T02:00:00Z' };
++const D = { ...A, id: 'ags_01JAB2C3D4E5F6G7H8J9K0MND4', title: 'A private agent', visibility: 'private' };
++const E = { ...A, id: 'ags_01JAB2C3D4E5F6G7H8J9K0MNE5', title: 'An ended agent', state: 'ended' };
++
++check('only public live streams, followed first and then the most watched', () => {
++ const out = sortAgents([B, C, A, D, E], [A.owner]);
++ assert.deepStrictEqual(out.map((a) => a.title), ['Followed agent', 'Watched agent', 'A small agent']);
++});
++
++check('the Agents live block is its own section, with a counted fold after three', () => {
++ const html = views.home({
++ now: Date.now(),
++ agents: { agents: [A, B, C, { ...D, title: 'Fourth', visibility: 'public', viewers: 0 }, { ...E, title: 'Fifth', state: 'live', viewers: 0 }] },
++ schedules: { schedules: [] },
++ digest: { reports: [] },
++ });
++ assert.match(html, /id="td-agents"/);
++ assert.match(html, /Agents live/);
++ assert.match(html, /More \(2\)/);
++ assert.match(html, /Followed agent/);
++ assert.match(html, /AI agent/);
++ // The calendar's own rows never carry an agent: the block is added beside them, not mixed into them.
++ assert.ok(!/<li class="td-ev[^"]*">[^]*?Followed agent/.test(html), 'an agent is not an event row');
++});
++
++check('a schedule in the next 24 hours shows with its time; the digest is the person\'s own', () => {
++ const html = views.home({
++ now: Date.now(),
++ agents: { agents: [] },
++ schedules: { schedules: [{ id: 'scd_01JAB2C3D4E5F6G7H8J9K0MNPS', title: 'Nightly update', channel: 'ann', starts_at: new Date(Date.now() + 3600_000).toISOString(), state: 'scheduled', waits_for_go: false, page_url: 'https://openvibe.live/@ann/agents' }] },
++ digest: { reports: [{ id: 'rps_01JAB2C3D4E5F6G7H8J9K0MNPS', task: { title: 'My agent fixed the login page' }, outcome: 'done' }] },
++ });
++ assert.match(html, /Scheduled · next 24 hours/);
++ assert.match(html, /Nightly update/);
++ assert.match(html, /Your agents this week/);
++ assert.match(html, /My agent fixed the login page/);
++});
++
++check('createAgents filters to 24 hours and followed first, and a Live failure is an empty block', async () => {
++ const calls = [];
++ const fetchImpl = async (url) => {
++ calls.push(url);
++ if (String(url).includes('/api/streams/agent?live=1')) return { ok: true, json: async () => ({ agents: [B, A, D] }) };
++ if (String(url).includes('/schedules?public=1')) {
++ return { ok: true, json: async () => ({ schedules: [
++ { id: 'scd_1', title: 'Soon', state: 'scheduled', starts_at: new Date(Date.now() + 3600_000).toISOString() },
++ { id: 'scd_2', title: 'Later', state: 'scheduled', starts_at: new Date(Date.now() + 25 * 3600_000).toISOString() },
++ ] }) };
++ }
++ throw new Error('unexpected ' + url);
++ };
++ const agents = createAgents({
++ config: { imports: { live: { url: 'https://live.test' } }, live: { timeoutMs: 1000, internalUrl: 'https://live.test' }, report: { url: 'https://report.test', timeoutMs: 1000 } },
++ follows: { forSubject: async () => ({ follows: [A.owner], unavailable: false }) },
++ fetchImpl, log: { warn() {} },
++ });
++ const live = await agents.live({ subject: A.owner, token: 'person-token' });
++ assert.deepStrictEqual(live.agents.map((a) => a.title), ['Followed agent', 'Watched agent']);
++ assert.strictEqual(live.agents.length, 2, 'the private one is not on Today');
++ const scheduled = await agents.schedules();
++ assert.deepStrictEqual(scheduled.schedules.map((s) => s.title), ['Soon'], 'only the next 24 hours');
++
++ const down = createAgents({
++ config: { imports: { live: { url: 'https://live.test' } }, live: { timeoutMs: 100, internalUrl: 'https://live.test' }, report: { url: 'https://report.test', timeoutMs: 100 } },
++ follows: { forSubject: async () => ({ follows: [], unavailable: true }) },
++ fetchImpl: async () => { throw new Error('down'); }, log: { warn() {} },
++ });
++ assert.deepStrictEqual(await down.live(), { agents: [], unavailable: true, follows_unavailable: false });
++});
++
++check('the phone layout keeps the cards one column and the tap targets 44 px', () => {
++ const fs = require('node:fs');
++ const path = require('node:path');
++ const css = fs.readFileSync(path.join(__dirname, '../public/css/today.css'), 'utf8');
++ assert.match(css, /@media \(max-width: 640px\)[\s\S]*td-agents \{ grid-template-columns: 1fr/);
++ assert.match(css, /min-height: 44px/);
++});
++
++console.log(failures ? `\n${failures} check(s) failed` : '\nAll Today S7 checks passed');
++process.exit(failures ? 1 : 0);