openvibers/openvibe.live Commits

Agent Streams S2: steps, the overlay, public streams and chapters (#630)

OP OpenVibers committed 7 days ago · 351a9f78a0 · 27 files, +1479 −37

* Agent Streams S2: steps, the overlay, public streams and chapters

live.agent-stream-step@1 is released (Contracts 0.299.0). The desk, the person and a
first-party service with live.agent_stream.publish post steps; Live keeps one a second and
2,000 a stream, filters the text, and stores the steps with the stream, not the session.

The player, the embed and the recording view draw the overlay (task, current step, the last
twelve ticks, tests and cost, worked time) from the steps feed — never into the video. Public
streams appear on the channel's Agents tab, in their own Agents live row on the home after
people's streams, and in sitemap.xml and llms.txt while live. Each started step is a chapter,
and a failed step or the final passed one clips in one tap through Media.

Tests: test/agent-streams-s2.test.js (steps, limits, filter, visibility, chapters, clips,
overlay markup, sitemap/llms), and the S1 public switch test now expects Public enabled.

* Merge remote-tracking branch 'origin/main' into feat/live-agent-streams-s2

* The overlay's worked time follows the page's poll

* A public agent stream keeps its sitemap entry after it ends, with its recording

* Agents live uses the home's section header; a card says Passed/Failed in words as well as colour

* Merge remote-tracking branch 'origin/main' into review-630

# Conflicts:
#	STATUS.json
#	scripts/perf/check-budgets.js

Changes

ddiff --git a/STATUS.json b/STATUS.json
iindex 9e8e3637..9c33d582 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -5,7 +5,7 @@
"runtime": "node 22, express, PostgreSQL, node-media-server, mediasoup; no build step",
"port": 3000,
"domain": "openvibe.live (www, ingest, whip)",
-- "contracts": "openvibe-contracts v0.298.0",
++ "contracts": "openvibe-contracts v0.299.0",
"sdk": "openvibe-sdk v0.91.0",
"shared": "openvibe-shared v4.118.0",
"capabilities": [
ddiff --git a/docs/agent-streams.md b/docs/agent-streams.md
iindex b713efa2..7a8b1876 100644
---- a/docs/agent-streams.md
++++ b/docs/agent-streams.md
@@@ -110,16 +110,56 @@ detectors report clear for two seconds, or from the owner's own button.
- **The owner's page:** `/@person/agents/<ags_id>` — the player with the `AI agent` label on it, one status line
(Live · N watching · worked X min, or Waiting for the desk, or Ended), the task and its link, the visibility switch
-- (Public is disabled: public agent streams come with the Agents tab), the Share sheet, Reset link (the old links stop
-- working), Curtain now (Lift the curtain while it is up) and End (which asks in the page, never a browser dialog).
-- Others see the player and the label.
++ (Private, Unlisted, or Public — a public stream appears on the channel's Agents tab and in the Agents live row), the
++ Share sheet, Reset link (the old links stop working), Curtain now (Lift the curtain while it is up) and End (which
++ asks in the page, never a browser dialog). Others see the player and the label.
++ While the work is live the page draws the overlay (below) from the steps feed, and once it has ended it lists the
++ recording's chapters, with a one-tap Clip it on each failed step and on the final passed one.
- **The embed:** `/embed/agent/<ags_id>?vt=wvt_…` — the picture fills the frame (contained, centred), with the
`AI agent` label, the curtain state and a small viewer count on it. Its
CSP `frame-ancestors` is the first-party origins only (Live, Network, Codes, Actor, Node, Tools), never `*`. A
third-party iframe has no Live cookies, so Codes, Actor or the desk mints the view ticket server-side with the person's
own token and puts it in the address.
--## In S1
++## S2: steps, the overlay, public streams
--There is no chat, no voice, no Agents tab and no directory yet (S2 and later), and public streams do not appear anywhere
--but their own page. The visibility switch offers Public, and the page disables it until S2.
++**Steps.** The agent posts what it is doing as it works:
++
++```
++POST /api/streams/agent/<ags_id>/steps (Authorization: Bearer wpt_…, or the person's session,
++ or a first-party service token with live.agent_stream.publish)
++{ "title": "Run the checkout tests", "status": "failed", "detail": "1 of 2 failed",
++ "tests": { "passed": 1, "failed": 1 }, "cost_usd": 0.18, "at": "2026-11-06T10:03:00Z" }
++```
++
++`title` is 1–120 characters, `detail` at most 500, both plain text that the house text filters must accept; `at`
++defaults to now. Live keeps at most one step a second and 2,000 per stream, and answers `429 steps_too_fast` /
++`429 too_many_steps` otherwise. The steps are stored with the stream (`agent_stream_steps`), not with one WHIP session,
++so they outlive a reconnect and travel with the recording. `GET /api/streams/agent/<ags_id>/steps` answers the newest
++20 oldest first, or only what is newer with `?after=<id>`; the same view rule as the stream applies. The contract is
++`live.agent-stream-step@1` (Contracts 0.299.0).
++
++**The overlay.** The player draws the steps — the task, the current step with a blue/green/red/grey status dot, a
++timeline of the last twelve ticks, "1 of 2 passing" and the cost when reported, and "worked 42 min". It is never burned
++into the video: it stays sharp at any size, screen readers can read it, and the page, the embed and the recording view
++draw it from the same feed. Viewers can fold it to one line (the choice is remembered); on a phone it is one line and
++tapping opens the timeline sheet. The overlay stays up while the curtain is, so viewers still see what the agent is
++doing, only not the screen.
++
++**Public streams.** The owner's Public switch turns on. A public stream appears on the channel's Agents tab, in a
++separate "Agents live" row on Live's home after people's streams (never mixed into them), and in `/sitemap.xml` and
++`/llms.txt` while it is live, with its recording after. Private and unlisted streams never appear in any of them.
++One live agent stream per person (five for Live's owner), unchanged from S1; public costs nothing extra.
++
++**Chapters and clips.** Each step whose status is `started` is a chapter at its offset from the stream's start:
++`GET /api/streams/agent/<ags_id>/chapters` answers `{ vod_id, chapters: [{ at, title, step_id }] }`. A failed step and
++the final passed step can be clipped in one tap (`POST /api/streams/agent/<ags_id>/clips { step_id }`), which cuts five
++seconds before the step and fifteen after through OpenVibe.Media's own clip tools.
++
++**The Agents tab.** `/@person` has an Agents tab beside Videos, shown when the channel has agent streams this viewer may
++see: live cards carry the last frame, the label, the task, the current step and the viewer count; ended cards carry the
++last frame, the task, how long it worked, passed or failed by the last step and the recording. Newest first, with a
++counted "More (N)" after twelve.
++
++**Not in S2:** the agent's own chat room (`agent:<ags_id>`), which needs a Chat-side room kind, and the voice work of
++S4. Chat is for people; the agent reading it is S5.
ddiff --git a/migrations/0028_agent_stream_steps.sql b/migrations/0028_agent_stream_steps.sql
nnew file mode 100644
iindex 00000000..b8c99ac6
---- /dev/null
++++ b/migrations/0028_agent_stream_steps.sql
@@@ -0,0 +1,27 @@
++-- phase: expand
++-- Agent streams, S2 (design 2026-11-06, plan-state-2026-10-22/AGENT-STREAMS-S2-DESIGN-2026-11-06.md): the steps an
++-- agent posts while it works, so the player can draw the overlay (the task, the current step and a timeline) and the
++-- recording can carry chapters.
++--
++-- A step belongs to the agent stream (managed_streams.id), not to one live session: the steps outlive the WHIP session
++-- and travel with the VOD. `at` is when the step began (the agent may say so; the server's own clock is used when it
++-- does not), `created_at` is when Live stored it — that is the clock the one-a-second limit reads, so a desk that
++-- backdates a step cannot post faster than a person can read it.
++--
++-- ADD-only: an older release ignores the table; nothing else changes.
++CREATE TABLE IF NOT EXISTS agent_stream_steps (
++ id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
++ managed_stream_id bigint NOT NULL REFERENCES managed_streams(id) ON DELETE CASCADE,
++ at timestamptz NOT NULL,
++ title text NOT NULL,
++ status text NOT NULL CHECK (status IN ('started', 'passed', 'failed', 'skipped')),
++ detail text,
++ tests_passed integer,
++ tests_failed integer,
++ cost_usd numeric(12, 6),
++ created_at timestamptz NOT NULL DEFAULT now()
++);
++-- The page's "steps after this id" poll and the card's "last N, oldest first" read.
++CREATE INDEX IF NOT EXISTS agent_stream_steps_stream ON agent_stream_steps (managed_stream_id, id);
++-- A chapter is a step whose status is 'started'; its time is `at`.
++CREATE INDEX IF NOT EXISTS agent_stream_steps_started ON agent_stream_steps (managed_stream_id, at) WHERE status = 'started';
ddiff --git a/package-lock.json b/package-lock.json
iindex 35ad42a6..2fbc4f78 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -23,7 +23,7 @@
"multer": "^1.4.5-lts.1",
"node-media-server": "^2.7.1",
"onnxruntime-node": "^1.20.0",
-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.298.0",
++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.299.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.91.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.118.0",
"pg": "^8.23.1",
@@@ -2840,9 +2840,9 @@
}
},
"node_modules/openvibe-contracts": {
-- "version": "0.298.0",
-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.298.0",
-- "integrity": "sha512-G6JmHxYQOKQ7kjjufsGBD1X9QeSgfHZT/UuDRbHPvJ8+FFHZ95nzlNLtbRkgBcW5AlgmmxBOq/1Tj80XbmlmAg==",
++ "version": "0.299.0",
++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.299.0",
++ "integrity": "sha512-KoJW79nApPmE7Yoi1kFVDH5gabUwcDmwvtWaPBdM8nHqU2+gaOd7BkkvfYBbtbCR0nwf0nuY7qFTiKGXdme+rg==",
"license": "AGPL-3.0-only",
"dependencies": {
"ajv": "^8.17.1",
ddiff --git a/package.json b/package.json
iindex 93400b75..6286a440 100644
---- a/package.json
++++ b/package.json
@@@ -42,7 +42,7 @@
"multer": "^1.4.5-lts.1",
"node-media-server": "^2.7.1",
"onnxruntime-node": "^1.20.0",
-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.298.0",
++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.299.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.91.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.118.0",
"pg": "^8.23.1",
ddiff --git a/public/css/features/agent-stream.css b/public/css/features/agent-stream.css
iindex 196bc598..4c8880aa 100644
---- a/public/css/features/agent-stream.css
++++ b/public/css/features/agent-stream.css
@@@ -54,3 +54,99 @@
.agent-embed .agent-embed-state[hidden] { display: none; }
.agent-embed .agent-embed-curtain { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; background: #07080a; color: #cfd3d8; font-weight: 600; z-index: 4; }
.agent-embed .agent-embed-curtain[hidden] { display: none; }
++
++/* ── The overlay (S2): the band the player draws from the steps feed, never burned into the video ───────────────── */
++.agent-overlay { position: absolute; left: 0; right: 0; bottom: 0; z-index: 6; display: flex; flex-direction: column; gap: 2px; padding: 8px 12px 9px; color: #f1f3f5; background: linear-gradient(to top, rgba(5, 6, 8, 0.94), rgba(5, 6, 8, 0.78) 70%, rgba(5, 6, 8, 0)); font-size: 0.82rem; line-height: 1.35; text-shadow: 0 1px 2px rgba(0, 0, 0, 0.7); }
++.agent-overlay[hidden] { display: none; }
++.agent-overlay-body { display: flex; flex-direction: column; gap: 3px; min-width: 0; padding-right: 26px; }
++.agent-overlay-task { margin: 0; font-weight: 700; font-size: 0.8rem; color: #d9dde2; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
++.agent-overlay-step { margin: 0; display: flex; align-items: center; gap: 7px; min-width: 0; }
++.agent-overlay-step-title { font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
++.agent-overlay-dot { flex: 0 0 auto; width: 8px; height: 8px; border-radius: 50%; background: #6b7280; box-shadow: 0 0 0 2px rgba(255, 255, 255, 0.12); }
++.agent-overlay-dot.is-started { background: #3b82f6; animation: agent-overlay-pulse 1.6s ease-in-out infinite; }
++.agent-overlay-dot.is-passed { background: #22c55e; }
++.agent-overlay-dot.is-failed { background: #ef4444; }
++.agent-overlay-dot.is-skipped { background: #94a3b8; }
++@keyframes agent-overlay-pulse { 50% { opacity: 0.45; } }
++@media (prefers-reduced-motion: reduce) { .agent-overlay-dot.is-started { animation: none; } }
++.agent-overlay-ticks { display: flex; gap: 3px; align-items: center; margin-top: 3px; min-height: 10px; }
++.agent-overlay-ticks[hidden] { display: none; }
++.agent-overlay-tick { width: 12px; height: 8px; padding: 0; border: 0; border-radius: 2px; background: #4b5563; cursor: pointer; opacity: 0.85; }
++.agent-overlay-tick.is-started { background: #3b82f6; }
++.agent-overlay-tick.is-passed { background: #22c55e; }
++.agent-overlay-tick.is-failed { background: #ef4444; }
++.agent-overlay-tick.is-skipped { background: #94a3b8; }
++.agent-overlay-tick:hover, .agent-overlay-tick.is-open { opacity: 1; transform: scaleY(1.35); }
++.agent-overlay-foot { margin: 1px 0 0; display: flex; flex-wrap: wrap; gap: 4px 12px; color: #c3c8ce; font-size: 0.74rem; }
++.agent-overlay-foot span[hidden] { display: none; }
++.agent-overlay-tests { color: #e5e7eb; }
++.agent-overlay-fold { position: absolute; right: 6px; bottom: 8px; width: 26px; height: 26px; padding: 0; border: 1px solid rgba(255, 255, 255, 0.22); border-radius: 7px; background: rgba(10, 12, 16, 0.8); color: #e5e7eb; cursor: pointer; font-size: 0.7rem; }
++.agent-overlay.is-folded { padding-top: 6px; }
++.agent-overlay.is-folded .agent-overlay-task,
++.agent-overlay.is-folded .agent-overlay-ticks,
++.agent-overlay.is-folded .agent-overlay-foot { display: none; }
++.agent-overlay.is-folded .agent-overlay-body { padding-right: 32px; }
++.agent-overlay-sheet { position: absolute; left: 8px; right: 8px; bottom: 42px; max-height: 50vh; overflow: auto; padding: 8px 10px; border: 1px solid rgba(255, 255, 255, 0.2); border-radius: 10px; background: rgba(8, 10, 14, 0.97); }
++.agent-overlay-sheet[hidden] { display: none; }
++.agent-overlay-sheet-list { margin: 0; padding: 0; list-style: none; display: flex; flex-direction: column; gap: 6px; }
++.agent-overlay-sheet-row { display: grid; grid-template-columns: 1fr auto; gap: 2px 10px; padding-left: 8px; border-left: 3px solid #6b7280; }
++.agent-overlay-sheet-row.is-started { border-left-color: #3b82f6; }
++.agent-overlay-sheet-row.is-passed { border-left-color: #22c55e; }
++.agent-overlay-sheet-row.is-failed { border-left-color: #ef4444; }
++.agent-overlay-sheet-title { font-weight: 600; }
++.agent-overlay-sheet-status { color: #9aa1a9; font-size: 0.72rem; text-transform: uppercase; letter-spacing: 0.05em; }
++.agent-overlay-sheet-detail { grid-column: 1 / -1; color: #aeb4bb; font-size: 0.76rem; }
++/* Phone: the band is one line; tapping it opens the sheet (the design's rule). */
++@media (max-width: 640px) {
++ .agent-overlay { padding: 6px 8px; font-size: 0.76rem; }
++ .agent-overlay-task, .agent-overlay-ticks, .agent-overlay-foot { display: none; }
++ .agent-overlay-body { padding-right: 30px; }
++}
++
++/* ── Chapters and highlights under the player (S2) ─────────────────────────────────────────────── */
++.agent-chapters { margin: 16px 0 0; padding: 14px 16px; border: 1px solid var(--border); border-radius: var(--radius); background: var(--bg-card); }
++.agent-chapters[hidden] { display: none; }
++.agent-chapters-title { margin: 0 0 4px; font-size: 0.95rem; font-weight: 700; }
++.agent-chapters-hint { margin: 0 0 10px; color: var(--text-muted); font-size: 0.8rem; }
++.agent-chapter-list { margin: 0; padding: 0; list-style: none; display: flex; flex-direction: column; gap: 2px; max-height: 320px; overflow: auto; }
++.agent-chapter { display: flex; align-items: baseline; gap: 10px; padding: 6px 4px; border-radius: 6px; }
++.agent-chapter:hover { background: var(--bg-secondary); }
++.agent-chapter-time { flex: 0 0 auto; color: var(--accent); font-variant-numeric: tabular-nums; font-size: 0.8rem; font-weight: 700; }
++.agent-chapter-title { min-width: 0; overflow-wrap: anywhere; font-size: 0.86rem; }
++.agent-highlights { margin: 12px 0 0; }
++.agent-highlights-title { margin: 0 0 6px; font-size: 0.85rem; font-weight: 700; }
++.agent-highlight { display: flex; align-items: center; gap: 8px; padding: 4px 0; }
++.agent-highlight-title { flex: 1 1 auto; min-width: 0; overflow-wrap: anywhere; font-size: 0.84rem; }
++.agent-highlight-status { font-size: 0.7rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.05em; }
++.agent-highlight-status.is-failed { color: #ef4444; }
++.agent-highlight-status.is-passed { color: #22c55e; }
++.agent-clip-btn { flex: 0 0 auto; }
++.agent-recording { margin: 10px 0 0; font-size: 0.84rem; }
++.agent-recording a { color: var(--accent); }
++
++/* ── Cards (the Agents tab and the "Agents live" row) ─────────────────────────────────────────── */
++.agent-card { display: flex; flex-direction: column; border: 1px solid var(--border); border-radius: var(--radius); background: var(--bg-card); overflow: hidden; color: var(--text-primary); text-decoration: none; }
++.agent-card:hover { border-color: var(--border-light); }
++.agent-card-media { position: relative; aspect-ratio: 16 / 9; background: #0b0d10; }
++.agent-card-media img { width: 100%; height: 100%; object-fit: cover; display: block; }
++.agent-card-media .agent-card-noimg { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; color: #7c848d; font-size: 0.78rem; }
++.agent-card-media .agent-label { position: absolute; top: 8px; left: 8px; background: rgba(7, 8, 10, 0.82); color: #cfc7ff; }
++.agent-card-live { position: absolute; top: 8px; right: 8px; padding: 1px 8px; border-radius: 999px; background: var(--live-red, #e0245e); color: #fff; font-size: 0.66rem; font-weight: 800; text-transform: uppercase; letter-spacing: 0.05em; }
++.agent-card-body { display: flex; flex-direction: column; gap: 4px; padding: 10px 12px 12px; }
++.agent-card-title { margin: 0; font-size: 0.92rem; font-weight: 700; overflow-wrap: anywhere; }
++.agent-card-task { margin: 0; color: var(--text-muted); font-size: 0.78rem; overflow-wrap: anywhere; }
++.agent-card-meta { margin: 0; color: var(--text-secondary); font-size: 0.76rem; display: flex; flex-wrap: wrap; gap: 2px 8px; }
++.agent-card-meta .is-failed { color: #ef4444; }
++.agent-card-meta .is-passed { color: #22c55e; }
++.agent-card-link { margin-top: 2px; font-size: 0.78rem; color: var(--accent); }
++.agent-cards { display: grid; grid-template-columns: repeat(auto-fill, minmax(230px, 1fr)); gap: 14px; }
++.agent-more { margin-top: 12px; }
++.agent-empty { margin: 0; color: var(--text-muted); font-size: 0.88rem; }
++
++/* The "Agents live" row on Live's home: its own row, after people's streams, never mixed into them. */
++.home-agents { margin-top: 28px; }
++.home-agents[hidden] { display: none; }
++.home-agents-head { display: flex; align-items: baseline; justify-content: space-between; gap: 10px; margin-bottom: 10px; }
++.home-agents-title { margin: 0; font-size: 1.05rem; font-weight: 800; }
++.home-agents-note { color: var(--text-muted); font-size: 0.78rem; }
++.home-agents .agent-cards { grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); }
ddiff --git a/public/css/features/home.css b/public/css/features/home.css
iindex 02ad54ad..e6f1bb6a 100644
---- a/public/css/features/home.css
++++ b/public/css/features/home.css
@@@ -1390,3 +1390,15 @@ body.egg-rainbow { animation: eggRainbow 2.6s linear; }
}
.home-tabbox[data-tab=""] .home-tabs { margin-bottom: 0; }
}
++
++/* The Agents live row (Agent Streams S2): public AI agent streams, their own row after people's streams. */
++.home-agents { margin: 26px 0 0; }
++.home-agents .agent-cards { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 14px; }
++.home-agents .agent-card { display: flex; flex-direction: column; overflow: hidden; border: 1px solid var(--border); border-radius: var(--radius); background: var(--bg-card); text-decoration: none; color: inherit; }
++.home-agents .agent-card-media { position: relative; aspect-ratio: 16 / 9; background: #0b0d10; }
++.home-agents .agent-card-media img { display: block; width: 100%; height: 100%; object-fit: cover; }
++.home-agents .agent-card-media .agent-label { position: absolute; top: 8px; left: 8px; background: rgba(7, 8, 10, 0.82); color: #cfc7ff; }
++.home-agents .agent-card-live { position: absolute; top: 8px; right: 8px; padding: 1px 8px; border-radius: 999px; background: var(--live-red, #e0245e); color: #fff; font-size: 0.66rem; font-weight: 800; text-transform: uppercase; letter-spacing: 0.05em; }
++.home-agents .agent-card-body { display: flex; flex-direction: column; gap: 4px; padding: 10px 12px 12px; }
++.home-agents .agent-card-title { margin: 0; font-size: 0.92rem; font-weight: 700; overflow-wrap: anywhere; }
++.home-agents .agent-card-meta { margin: 0; color: var(--text-secondary); font-size: 0.76rem; }
ddiff --git a/public/features.json b/public/features.json
iindex 98fbac06..e80ef817 100644
---- a/public/features.json
++++ b/public/features.json
@@@ -104,7 +104,8 @@
"fragment": "channel",
"css": [
"/css/features/channel.css",
-- "/shared/kit.css"
++ "/shared/kit.css",
++ "/css/features/agent-stream.css"
],
"deps": [
"player",
@@@ -118,7 +119,8 @@
"/js/channel-chat-resize.js",
"/js/channel-support.js",
"/js/app-channel.js",
-- "/js/bench-stream-watch.js"
++ "/js/bench-stream-watch.js",
++ "/js/agent-cards.js"
],
"stubs": [
"toggleChannelChat"
@@@ -318,6 +320,7 @@
],
"js": [
"/js/agent-player.js",
++ "/js/agent-overlay.js",
"/js/agent-stream.js",
"/shared/ov-share.js"
]
ddiff --git a/public/fragments/agent-stream.html b/public/fragments/agent-stream.html
iindex 5b295d98..59b3744a 100644
---- a/public/fragments/agent-stream.html
++++ b/public/fragments/agent-stream.html
@@@ -12,8 +12,19 @@
<span class="agent-label agent-player-label">AI agent</span>
<div class="agent-state" id="agent-state">Loading…</div>
<div class="agent-curtain" id="agent-curtain" hidden>Private screen</div>
++ <div id="agent-overlay"></div>
</div>
<p class="agent-status-line" id="agent-status-line">Loading…</p>
++ <section class="agent-chapters" id="agent-chapters" hidden aria-label="The recording's chapters">
++ <h2 class="agent-chapters-title" id="agent-chapters-title">Chapters</h2>
++ <p class="agent-chapters-hint" id="agent-chapters-hint"></p>
++ <p class="agent-recording" id="agent-recording" hidden></p>
++ <ol class="agent-chapter-list" id="agent-chapter-list"></ol>
++ <div class="agent-highlights" id="agent-highlights" hidden>
++ <h3 class="agent-highlights-title">Highlights you can clip</h3>
++ <div id="agent-highlight-list"></div>
++ </div>
++ </section>
</div>
<aside class="agent-side" id="agent-owner" hidden>
<h2 class="agent-side-title">This stream</h2>
@@@ -22,9 +33,9 @@
<select id="agent-visibility" class="agent-select">
<option value="private">Only me and people I share it with</option>
<option value="unlisted">Anyone with the link</option>
-- <option value="public" disabled>Everyone (coming soon)</option>
++ <option value="public">Everyone — the Agents tab and Agents live</option>
</select>
-- <p class="agent-note">Public agent streams come with the Agents tab.</p>
++ <p class="agent-note">A public stream appears on your channel's Agents tab and in the Agents live row. Public costs nothing extra.</p>
</div>
<div class="agent-actions">
<button type="button" class="btn btn-outline" data-ov-share-sheet id="agent-share" data-title="An AI agent's screen">Share</button>
ddiff --git a/public/fragments/channel.html b/public/fragments/channel.html
iindex 867167ee..52de3221 100644
---- a/public/fragments/channel.html
++++ b/public/fragments/channel.html
@@@ -366,6 +366,7 @@
<div class="ch-tabs" id="ch-tabs" role="tablist" aria-label="Channel sections">
<button class="ch-tab" id="ch-tab-btn-about" role="tab" aria-selected="false" data-tab="about" onclick="switchChannelTab('about', this)" style="display:none"><i class="fa-solid fa-circle-info"></i> About<span class="ch-tab-edit" id="ch-tab-about-edit" role="button" tabindex="0" title="Edit About" onclick="event.stopPropagation(); editAboutFromTab();" style="display:none"><i class="fa-solid fa-pen"></i></span></button>
<button class="ch-tab active" id="ch-tab-btn-videos" role="tab" aria-selected="true" data-tab="videos" onclick="switchChannelTab('videos', this)"><i class="fa-solid fa-video"></i> Videos<span class="ch-tab-badge" id="ch-tab-badge-videos" hidden></span></button>
++ <button class="ch-tab" id="ch-tab-btn-agents" role="tab" aria-selected="false" data-tab="agents" onclick="switchChannelTab('agents', this)" style="display:none"><i class="fa-solid fa-robot"></i> Agents<span class="ch-tab-badge" id="ch-tab-badge-agents" hidden></span></button>
<button class="ch-tab" id="ch-tab-btn-clips" role="tab" aria-selected="false" data-tab="clips" onclick="switchChannelTab('clips', this)"><i class="fa-solid fa-scissors"></i> Clips<span class="ch-tab-badge" id="ch-tab-badge-clips" hidden></span></button>
<button class="ch-tab" id="ch-tab-btn-clips-taken" role="tab" aria-selected="false" data-tab="clips-taken" onclick="switchChannelTab('clips-taken', this)"><i class="fa-solid fa-film"></i> Clips Taken<span class="ch-tab-badge" id="ch-tab-badge-clips-taken" hidden></span></button>
<button class="ch-tab" id="ch-tab-btn-pastes" role="tab" aria-selected="false" data-tab="pastes" onclick="switchChannelTab('pastes', this)"><i class="fa-solid fa-paste"></i> Pastes<span class="ch-tab-badge" id="ch-tab-badge-pastes" hidden></span></button>
@@@ -374,6 +375,13 @@
<button class="ch-tab" role="tab" aria-selected="false" data-tab="analytics" onclick="switchChannelTab('analytics', this)"><i class="fa-solid fa-chart-line"></i> Analytics</button>
</div>
++ <!-- Agents (Agent Streams S2): this channel's AI agent streams, live and past. -->
++ <div class="ch-tab-panel" id="ch-panel-agents" role="tabpanel">
++ <p class="ch-panel-hint"><i class="fa-solid fa-robot"></i> AI agents working on this channel — live now, and what they finished.</p>
++ <div class="agent-cards" id="ch-agents-grid"></div>
++ <button class="btn btn-small btn-outline agent-more" id="ch-agents-more" type="button" hidden onclick="loadMoreChannelAgents(this)">More</button>
++ </div>
++
<!-- Videos -->
<div class="ch-tab-panel active" id="ch-panel-videos" role="tabpanel">
<div class="ch-panel-bar">
ddiff --git a/public/fragments/home.html b/public/fragments/home.html
iindex 082a8b24..dc8e64c3 100644
---- a/public/fragments/home.html
++++ b/public/fragments/home.html
@@@ -104,6 +104,13 @@
</div>
</div>
++ <div class="home-agents" id="home-agents" role="region" aria-label="AI agents live now" hidden>
++ <div class="section-header" style="--sc:#8b5cf6">
++ <h2><i class="fa-solid fa-robot"></i> Agents live</h2>
++ </div>
++ <div class="agent-cards" id="home-agents-grid"></div>
++ </div>
++
<!-- Around OpenVibe: the digest, the star spotlight and the pulse, every one shown on every screen (the owner,
2026-10-31: "don't reduce size by removing features"). -->
<div class="home-around" id="home-around">
ddiff --git a/public/js/agent-cards.js b/public/js/agent-cards.js
nnew file mode 100644
iindex 00000000..a85bf850
---- /dev/null
++++ b/public/js/agent-cards.js
@@@ -0,0 +1,97 @@
++/* Agent stream cards (Agent Streams S2): one card for the Agents tab on a channel, one row on Live's home.
++ *
++ * The same card in both places: the last frame, the "AI agent" label, the task, the current (or last) step, the viewer
++ * count while live and "worked 42 min" offline. Every value is a text node, so a title cannot become markup; the card's
++ * link is the stream's own page (a private or unlisted stream never reaches this code — the API filters it).
++ */
++/* global window, document */
++(function () {
++ 'use strict';
++
++ function el(tag, className, text) {
++ const node = document.createElement(tag);
++ if (className) node.className = className;
++ if (text !== undefined && text !== null) node.textContent = String(text);
++ return node;
++ }
++
++ function minutes(seconds) {
++ const s = Math.max(0, Number(seconds) || 0);
++ if (s < 60) return 'under a minute';
++ const m = Math.round(s / 60);
++ if (m < 90) return `${m} min`;
++ const h = Math.floor(m / 60);
++ return `${h} h ${m % 60} min`;
++ }
++
++ function where(task) {
++ if (!task) return '';
++ const source = { codes: 'Codes', actor: 'Actor', desk: 'Desk', node: 'Node' }[task.source] || task.source || '';
++ return task.title ? (source ? `${source} · ${task.title}` : task.title) : source;
++ }
++
++ /** One card as a DOM node. The whole card is the link; the media keeps its own alt text. */
++ function card(entry) {
++ const a = el('a', 'agent-card');
++ a.href = entry.page_url || '#';
++ const media = el('div', 'agent-card-media');
++ const frame = entry.last_frame_url
++ ? el('img')
++ : el('div', 'agent-card-noimg', 'No frame yet');
++ if (entry.last_frame_url) {
++ frame.src = entry.last_frame_url;
++ frame.alt = '';
++ frame.loading = 'lazy';
++ }
++ media.appendChild(frame);
++ media.appendChild(el('span', 'agent-label', 'AI agent'));
++ if (entry.state === 'live') {
++ const live = el('span', 'agent-card-live', 'live');
++ if (entry.viewers) live.textContent = `live · ${entry.viewers}`;
++ media.appendChild(live);
++ }
++ const body = el('div', 'agent-card-body');
++ body.appendChild(el('h3', 'agent-card-title', entry.title || 'An AI agent'));
++ const task = where(entry.task);
++ if (task) body.appendChild(el('p', 'agent-card-task', task));
++ const meta = el('p', 'agent-card-meta');
++ if (entry.current_step && entry.current_step.title) {
++ // The status in words as well as colour: "Failed: Run the parser tests" reads without the red.
++ const st = entry.current_step.status || 'started';
++ const word = { passed: 'Passed', failed: 'Failed', skipped: 'Skipped' }[st];
++ meta.appendChild(el('span', `is-${st}`, word ? `${word}: ${entry.current_step.title}` : entry.current_step.title));
++ }
++ if (entry.state === 'ended') meta.appendChild(el('span', '', `${meta.childNodes.length ? '· ' : ''}worked ${minutes(entry.worked_seconds)}`));
++ if (meta.childNodes.length) body.appendChild(meta);
++ a.appendChild(media);
++ a.appendChild(body);
++ return a;
++ }
++
++ /**
++ * Fill a grid with the first `perPage` cards and keep the rest behind the design's counted fold.
++ * Returns { total, shown, more() } so a page can wire its own "More (N)" button.
++ */
++ function fill(grid, entries, { perPage = 12, moreButton = null } = {}) {
++ const all = Array.isArray(entries) ? entries : [];
++ let shown = 0;
++ const render = () => {
++ const next = all.slice(shown, shown + perPage);
++ for (const entry of next) grid.appendChild(card(entry));
++ shown += next.length;
++ if (moreButton) {
++ const left = all.length - shown;
++ moreButton.hidden = left <= 0;
++ moreButton.textContent = `More (${left})`;
++ }
++ };
++ render();
++ return {
++ total: all.length,
++ get shown() { return shown; },
++ more() { render(); return shown; },
++ };
++ }
++
++ window.AgentCards = { card, fill, minutes };
++})();
ddiff --git a/public/js/agent-embed.js b/public/js/agent-embed.js
iindex 73d94932..a18f38a3 100644
---- a/public/js/agent-embed.js
++++ b/public/js/agent-embed.js
@@@ -4,7 +4,7 @@
* cookies), and this page joins the stream with it. It shows the player, the "AI agent" label, the curtain state and the
* viewer count; no chat in S1. It renews its own ticket while it stays open, up to the server's four-hour ceiling.
*/
--/* global window, document, location, URLSearchParams, setTimeout, AgentPlayer */
++/* global window, document, location, URLSearchParams, setTimeout, clearTimeout, AgentPlayer, AgentOverlay */
(function () {
'use strict';
const body = document.body;
@@@ -14,6 +14,11 @@
let ticket = new URLSearchParams(location.search).get('vt') || '';
let player = null;
let renewTimer = null;
++ let stepsTimer = null;
++ let steps = [];
++ let overlay = null;
++ let streamState = null;
++ let streamView = null;
async function json(path, options) {
const res = await fetch(path, { credentials: 'omit', headers: { 'Content-Type': 'application/json' }, ...options });
@@@ -34,11 +39,38 @@
}, 8 * 60 * 1000);
}
++ /** The overlay (S2): the same band the page draws, from the same steps feed, never burned into the video. */
++ function drawOverlay(stream) {
++ const host = $('agent-embed-overlay');
++ if (!host || !window.AgentOverlay) return;
++ if (!overlay) overlay = window.AgentOverlay.create({ host, title: stream.title, initial: steps });
++ overlay.update({ steps, task: stream.task, workedSeconds: stream.worked_seconds, state: stream.state });
++ }
++
++ function pollSteps() {
++ if (stepsTimer) { clearTimeout(stepsTimer); stepsTimer = null; }
++ if (streamState !== 'live') return;
++ stepsTimer = setTimeout(async () => {
++ try {
++ const after = steps.length ? steps[steps.length - 1].id : null;
++ const q = after ? `?after=${after}` : '';
++ const out = await json(`/api/streams/agent/${encodeURIComponent(id)}/steps${q}`);
++ const known = new Set(steps.map((step) => step.id));
++ for (const step of (out.steps || [])) if (!known.has(step.id)) steps.push(step);
++ if (steps.length > 200) steps = steps.slice(-200);
++ if (streamView) drawOverlay(streamView);
++ } catch { /* the next turn tries again */ }
++ pollSteps();
++ }, 2500);
++ }
++
async function load() {
const query = ticket ? `?vt=${encodeURIComponent(ticket)}` : '';
const data = await json(`/api/streams/agent/${encodeURIComponent(id)}${query}`);
const stream = data.stream;
ticket = data.view_ticket || ticket;
++ streamState = stream.state;
++ streamView = stream;
const title = $('agent-embed-title');
if (title) title.textContent = stream.title || '';
@@@ -68,6 +100,12 @@
stateEl.textContent = stream.state === 'ended' ? 'This agent’s work has ended.' : "This agent isn't streaming right now.";
}
}
++ drawOverlay(stream);
++ if (!steps.length) {
++ try { const out = await json(`/api/streams/agent/${encodeURIComponent(id)}/steps`); steps = out.steps || []; } catch { /* */ }
++ drawOverlay(stream);
++ }
++ pollSteps();
renewSoon();
// The state changes on the server (the desk starts publishing, or the work ends); look again in a minute.
setTimeout(() => { if (!player && document.visibilityState !== 'hidden') load().catch(() => {}); }, 60000);
ddiff --git a/public/js/agent-overlay.js b/public/js/agent-overlay.js
nnew file mode 100644
iindex 00000000..6f73d866
---- /dev/null
++++ b/public/js/agent-overlay.js
@@@ -0,0 +1,193 @@
++/* Agent stream overlay (Agent Streams S2): the work an agent is doing, drawn by the page — never burned into video.
++ *
++ * The page, the embed and the VOD all draw the same band from the same steps feed (live.agent-stream-step@1). It stays
++ * sharp at any size, screen readers and translation can read it, and it is the one place a step's title is shown.
++ *
++ * top: the current step, with a status dot (blue running, green passed, red failed) and the task;
++ * ticks: the last twelve steps, tappable (their titles);
++ * foot: "1 of 2 passing", the cost and "worked 42 min";
++ * fold: one line, remembered per browser; on a phone the band is one line and tapping opens the timeline sheet.
++ *
++ * Every value is text: the overlay builds nodes with textContent, so a title can never become markup.
++ */
++/* global window, document, localStorage */
++(function () {
++ 'use strict';
++ const FOLD_KEY = 'ov:agent-overlay:folded';
++ const PHONE = 640;
++
++ function el(tag, className, text) {
++ const node = document.createElement(tag);
++ if (className) node.className = className;
++ if (text !== undefined && text !== null) node.textContent = String(text);
++ return node;
++ }
++
++ function minutes(seconds) {
++ const s = Math.max(0, Number(seconds) || 0);
++ if (s < 60) return 'under a minute';
++ const m = Math.round(s / 60);
++ if (m < 90) return `${m} min`;
++ const h = Math.floor(m / 60);
++ return `${h} h ${m % 60} min`;
++ }
++
++ /** The step the band shows: the newest one (the API answers oldest first). */
++ function current(steps) {
++ return steps && steps.length ? steps[steps.length - 1] : null;
++ }
++
++ /** The latest step that reported tests, so "1 of 2 passing" survives a later step without a report. */
++ function lastTests(steps) {
++ for (let i = (steps || []).length - 1; i >= 0; i--) {
++ if (steps[i] && steps[i].tests) return steps[i].tests;
++ }
++ return null;
++ }
++
++ function lastCost(steps) {
++ for (let i = (steps || []).length - 1; i >= 0; i--) {
++ if (steps[i] && typeof steps[i].cost_usd === 'number') return steps[i].cost_usd;
++ }
++ return null;
++ }
++
++ function statusWord(status) {
++ return { started: 'running', passed: 'passed', failed: 'failed', skipped: 'skipped' }[status] || 'running';
++ }
++
++ /**
++ * create({ host, title, initial }) → { update({ steps, workedSeconds, state }), destroy() }
++ * `host` is an element inside the player's stage. The band is appended there and stays above a curtain.
++ */
++ function create(options) {
++ const host = options && options.host;
++ if (!host) return { update() {}, destroy() {} };
++ const root = el('div', 'agent-overlay');
++ root.setAttribute('role', 'status');
++ root.setAttribute('aria-live', 'polite');
++
++ const foldBtn = el('button', 'agent-overlay-fold');
++ foldBtn.type = 'button';
++ foldBtn.setAttribute('aria-label', 'Fold the agent overlay');
++ const foldIcon = el('i', 'fa-solid fa-chevron-down');
++ foldBtn.appendChild(foldIcon);
++
++ const body = el('div', 'agent-overlay-body');
++ const taskLine = el('p', 'agent-overlay-task');
++ const stepLine = el('p', 'agent-overlay-step');
++ const dot = el('span', 'agent-overlay-dot');
++ const stepTitle = el('span', 'agent-overlay-step-title');
++ stepLine.append(dot, stepTitle);
++
++ const ticks = el('div', 'agent-overlay-ticks');
++ ticks.setAttribute('role', 'list');
++ ticks.setAttribute('aria-label', 'The last steps');
++
++ const foot = el('p', 'agent-overlay-foot');
++ const testsLine = el('span', 'agent-overlay-tests');
++ const costLine = el('span', 'agent-overlay-cost');
++ const workedLine = el('span', 'agent-overlay-worked');
++ foot.append(testsLine, costLine, workedLine);
++
++ const sheet = el('div', 'agent-overlay-sheet');
++ sheet.hidden = true;
++ const sheetList = el('ol', 'agent-overlay-sheet-list');
++ sheet.appendChild(sheetList);
++
++ body.append(taskLine, stepLine, ticks, foot);
++ root.append(foldBtn, body, sheet);
++ host.appendChild(root);
++
++ let folded = false;
++ try { folded = localStorage.getItem(FOLD_KEY) === '1'; } catch { /* private mode */ }
++ const applyFold = () => {
++ root.classList.toggle('is-folded', folded);
++ foldIcon.className = `fa-solid fa-chevron-${folded ? 'up' : 'down'}`;
++ foldBtn.setAttribute('aria-label', folded ? 'Unfold the agent overlay' : 'Fold the agent overlay');
++ body.hidden = false; // CSS shows the current step only when folded
++ };
++ applyFold();
++
++ foldBtn.addEventListener('click', (event) => {
++ event.stopPropagation();
++ folded = !folded;
++ try { localStorage.setItem(FOLD_KEY, folded ? '1' : '0'); } catch { /* */ }
++ applyFold();
++ });
++
++ // Tapping the band on a phone opens the timeline sheet (the CSS hides the inline details there).
++ root.addEventListener('click', () => {
++ if (window.innerWidth > PHONE) return;
++ sheet.hidden = !sheet.hidden;
++ });
++
++ let lastSteps = [];
++
++ function renderTicks(steps) {
++ const shown = (steps || []).slice(-12);
++ ticks.replaceChildren();
++ shown.forEach((step) => {
++ const tick = el('button', `agent-overlay-tick is-${step.status}`);
++ tick.type = 'button';
++ tick.setAttribute('role', 'listitem');
++ tick.title = `${step.title} — ${statusWord(step.status)}`;
++ tick.setAttribute('aria-label', tick.title);
++ tick.addEventListener('click', (event) => {
++ event.stopPropagation();
++ tick.classList.toggle('is-open');
++ tick.title = `${step.title} — ${statusWord(step.status)}`;
++ });
++ ticks.appendChild(tick);
++ });
++ if (!shown.length) ticks.hidden = true;
++ else ticks.hidden = false;
++ }
++
++ function renderSheet(steps) {
++ sheetList.replaceChildren();
++ (steps || []).slice().reverse().forEach((step) => {
++ const li = el('li', `agent-overlay-sheet-row is-${step.status}`);
++ li.append(el('span', 'agent-overlay-sheet-title', step.title), el('span', 'agent-overlay-sheet-status', statusWord(step.status)));
++ if (step.detail) li.appendChild(el('span', 'agent-overlay-sheet-detail', step.detail));
++ sheetList.appendChild(li);
++ });
++ }
++
++ function update(next) {
++ const data = next || {};
++ const steps = Array.isArray(data.steps) ? data.steps : lastSteps;
++ lastSteps = steps;
++ const cur = current(steps);
++ const task = data.task && data.task.title ? data.task.title : (options.title || 'An AI agent is working');
++ taskLine.textContent = task;
++ if (cur) {
++ dot.className = `agent-overlay-dot is-${cur.status}`;
++ stepTitle.textContent = cur.title;
++ stepLine.title = cur.detail || cur.title;
++ } else {
++ dot.className = 'agent-overlay-dot is-idle';
++ stepTitle.textContent = data.state === 'live' ? 'Waiting for the first step' : '';
++ }
++ renderTicks(steps);
++ renderSheet(steps);
++ const tests = lastTests(steps);
++ testsLine.textContent = tests ? `${tests.passed} of ${tests.passed + tests.failed} passing` : '';
++ testsLine.hidden = !tests;
++ const cost = lastCost(steps);
++ costLine.textContent = typeof cost === 'number' ? `${cost.toFixed(2)} USD` : '';
++ costLine.hidden = typeof cost !== 'number';
++ workedLine.textContent = data.workedSeconds != null ? `worked ${minutes(data.workedSeconds)}` : '';
++ root.hidden = !(steps.length || (data.state === 'live' && task));
++ }
++
++ update(options.initial || {});
++
++ return {
++ update,
++ destroy() { try { root.remove(); } catch { /* */ } },
++ };
++ }
++
++ window.AgentOverlay = { create, minutes, current };
++})();
ddiff --git a/public/js/agent-stream.js b/public/js/agent-stream.js
iindex 80edfee3..38e50318 100644
---- a/public/js/agent-stream.js
++++ b/public/js/agent-stream.js
@@@ -18,6 +18,10 @@
let liveViewers = null;
let renewTimer = null;
let pollTimer = null;
++ let stepsTimer = null;
++ let steps = []; // the overlay's feed, oldest first (at most the last 200)
++ let overlay = null;
++ let chaptersFor = null; // the stream id whose chapters are rendered (they never change while the page lives)
let generation = 0; // bumped on every page leave, so a slow request from the last page changes nothing
function $(id) { return document.getElementById(id); }
@@@ -29,6 +33,10 @@
liveViewers = null;
if (renewTimer) { clearTimeout(renewTimer); renewTimer = null; }
if (pollTimer) { clearTimeout(pollTimer); pollTimer = null; }
++ if (stepsTimer) { clearTimeout(stepsTimer); stepsTimer = null; }
++ if (overlay) { try { overlay.destroy(); } catch { /* */ } overlay = null; }
++ steps = [];
++ chaptersFor = null;
state = null;
ticket = null;
}
@@@ -115,6 +123,143 @@
}
}
++ /** The overlay exists once per page; each render only hands it the newest steps and the clock. */
++ function ensureOverlay(data) {
++ const host = $('agent-overlay');
++ if (!host || !window.AgentOverlay) return;
++ if (!overlay) {
++ overlay = window.AgentOverlay.create({ host, title: data.stream.title, initial: steps });
++ }
++ overlay.update({
++ steps,
++ task: data.stream.task,
++ workedSeconds: data.stream.worked_seconds,
++ state: data.stream.state,
++ });
++ }
++
++ /** Keep the overlay's feed current while the work is live: only the steps after the newest one are fetched. */
++ function scheduleSteps(data) {
++ if (stepsTimer) { clearTimeout(stepsTimer); stepsTimer = null; }
++ if (data.stream.state !== 'live') return;
++ const gen = generation;
++ const id = data.stream.id;
++ stepsTimer = setTimeout(async () => {
++ if (gen !== generation) return;
++ try {
++ const after = steps.length ? steps[steps.length - 1].id : null;
++ const next = await api(`/streams/agent/${encodeURIComponent(id)}/steps${after ? `?after=${after}` : ''}`);
++ if (gen !== generation) return;
++ if (next && Array.isArray(next.steps) && next.steps.length) {
++ const known = new Set(steps.map((step) => step.id));
++ for (const step of next.steps) if (!known.has(step.id)) steps.push(step);
++ if (steps.length > 200) steps = steps.slice(-200);
++ if (state && state.stream) ensureOverlay(state);
++ }
++ } catch { /* the next turn tries again */ }
++ if (gen === generation) scheduleSteps(state);
++ }, 2500);
++ }
++
++ /** Chapters come from the steps that started; a failed step and the final passed one can be clipped in one tap. */
++ async function renderRecording(data) {
++ const section = $('agent-chapters');
++ if (!section) return;
++ const stream = data.stream;
++ if (stream.state !== 'ended') { section.hidden = true; return; }
++ if (chaptersFor === stream.id) return;
++ try {
++ const out = await api(`/streams/agent/${encodeURIComponent(stream.id)}/chapters`);
++ if (!out || !out.chapters || !out.chapters.length) { section.hidden = true; chaptersFor = stream.id; return; }
++ section.hidden = false;
++ const list = $('agent-chapter-list');
++ list.replaceChildren();
++ const vodBase = out.vod_id ? `https://openvibe.video/v/${encodeURIComponent(out.vod_id)}` : null;
++ for (const chapter of out.chapters) {
++ const li = document.createElement('li');
++ li.className = 'agent-chapter';
++ const time = document.createElement('a');
++ time.className = 'agent-chapter-time';
++ time.textContent = clock(chapter.at);
++ time.href = vodBase ? `${vodBase}?t=${chapter.at}` : '#';
++ if (!vodBase) time.removeAttribute('href');
++ const title = document.createElement('span');
++ title.className = 'agent-chapter-title';
++ title.textContent = chapter.title;
++ li.append(time, title);
++ list.appendChild(li);
++ }
++ const hint = $('agent-chapters-hint');
++ if (hint) hint.textContent = 'Each step that started became a chapter of the recording.';
++ const recording = $('agent-recording');
++ if (recording) {
++ recording.replaceChildren();
++ if (vodBase) {
++ const a = document.createElement('a');
++ a.href = vodBase;
++ a.textContent = 'Watch the recording on OpenVibe.Video';
++ recording.appendChild(a);
++ recording.hidden = false;
++ } else recording.hidden = true;
++ }
++ renderHighlights(stream);
++ chaptersFor = stream.id;
++ } catch { section.hidden = true; }
++ }
++
++ function clock(seconds) {
++ const s = Math.max(0, Math.round(Number(seconds) || 0));
++ const h = Math.floor(s / 3600), m = Math.floor((s % 3600) / 60), sec = s % 60;
++ return `${h ? `${h}:${String(m).padStart(2, '0')}` : m}:${String(sec).padStart(2, '0')}`;
++ }
++
++ function renderHighlights(stream) {
++ const wrap = $('agent-highlights');
++ const list = $('agent-highlight-list');
++ if (!wrap || !list) return;
++ const failed = steps.filter((step) => step.status === 'failed');
++ const passed = steps.filter((step) => step.status === 'passed');
++ const finalPassed = passed.length && (!steps.length || steps[steps.length - 1].status === 'passed') ? passed[passed.length - 1] : null;
++ const wanted = [...failed, ...(finalPassed ? [finalPassed] : [])];
++ list.replaceChildren();
++ if (!wanted.length || !stream.vod_id) { wrap.hidden = true; return; }
++ wrap.hidden = false;
++ wanted.forEach((step) => {
++ const row = document.createElement('div');
++ row.className = 'agent-highlight';
++ const badge = document.createElement('span');
++ badge.className = `agent-highlight-status is-${step.status}`;
++ badge.textContent = step.status;
++ const title = document.createElement('span');
++ title.className = 'agent-highlight-title';
++ title.textContent = step.title;
++ const button = document.createElement('button');
++ button.type = 'button';
++ button.className = 'btn btn-small btn-outline agent-clip-btn';
++ button.textContent = 'Clip it';
++ button.addEventListener('click', async () => {
++ button.disabled = true;
++ try {
++ const made = await api(`/streams/agent/${encodeURIComponent(stream.id)}/clips`, { method: 'POST', body: { step_id: step.id } });
++ const clipId = made && made.clip ? (made.clip.clip_id || made.clip.id) : null;
++ button.textContent = clipId ? 'Clip made' : 'Clip queued';
++ if (clipId) {
++ const a = document.createElement('a');
++ a.href = `https://openvibe.video/c/${encodeURIComponent(clipId)}`;
++ a.textContent = 'Open the clip';
++ a.style.marginLeft = '8px';
++ row.appendChild(a);
++ }
++ } catch (err) {
++ button.disabled = false;
++ button.textContent = (err && err.message) || 'Try again';
++ }
++ });
++ row.append(badge, title, button);
++ list.appendChild(row);
++ });
++ }
++
function render(data) {
state = data;
ticket = data.view_ticket || ticket;
@@@ -141,9 +286,13 @@
renderCurtain(!!(stream.curtain && stream.curtain.up));
renderStatus(stream, liveViewers);
mountPlays(data);
++ ensureOverlay(data);
renderOwner(data);
scheduleRenew(data);
schedulePoll(stream.id);
++ if (!steps.length) loadInitialSteps(stream.id).then(() => { if (state && state.stream) { ensureOverlay(state); if (state.stream.state === 'ended') renderRecording(state); } });
++ scheduleSteps(data);
++ if (stream.state === 'ended') renderRecording(data);
}
function renderOwner(data) {
@@@ -215,6 +364,7 @@
if (structural) { render(next); return; }
state = next;
ticket = next.view_ticket || ticket;
++ ensureOverlay(next);
renderStatus(next.stream, liveViewers);
renderOwner(next);
schedulePoll(id);
@@@ -302,6 +452,15 @@
});
}
++ /** The newest 20 steps, once per page (and after the state poll finds the desk went live). */
++ async function loadInitialSteps(id) {
++ if (steps.length) return;
++ try {
++ const out = await api(`/streams/agent/${encodeURIComponent(id)}/steps`);
++ if (out && Array.isArray(out.steps)) steps = out.steps;
++ } catch { /* the overlay shows "waiting for the first step" */ }
++ }
++
async function loadAgentStreamPage(username, publicId) {
stop();
const owner = $('agent-owner');
ddiff --git a/public/js/app-channel.js b/public/js/app-channel.js
iindex ff09d063..b4e9af7d 100644
---- a/public/js/app-channel.js
++++ b/public/js/app-channel.js
@@@ -818,6 +818,10 @@ async function loadChannelPage(username, managedStreamRef = null, legacySessionI
_applyBotEmbed(data.bot_embed, username);
_offerBotBind(username);
_applyChannelHashTab(); // deep-link: #ai-timeline / #about / #videos … opens that tab
++ // The Agents tab is revealed only when the channel has agent streams this viewer may see (S2).
++ _agentsPaging = { username: null, grid: null };
++ try { loadChannelAgents(username); } catch {}
++ try { loadMoreChannelAgents = window.loadMoreChannelAgents; } catch {}
// Reveal the Media Request tab if the streamer has it enabled (non-blocking).
_initMediaRequestTab(username);
@@@ -1973,6 +1977,49 @@ function _renderChStreamAi(overview, short) {
// ── Channel below-fold tabs ──────────────────────────────────
let _channelTab = 'videos';
++// ── Agents tab (Agent Streams S2): this channel's AI agent streams, live and past ─────────────
++// The API answers only what this viewer may see (public ones for everyone, everything for the owner). The tab stays
++// hidden when there is nothing to show, so a channel without agents looks exactly as it did.
++let _agentsPaging = { username: null, grid: null };
++
++async function loadChannelAgents(username, { force = false } = {}) {
++ if (!username) return;
++ if (!force && _agentsPaging.username === username && _agentsPaging.grid) return;
++ const grid = document.getElementById('ch-agents-grid');
++ const more = document.getElementById('ch-agents-more');
++ if (!grid) return;
++ let data = null;
++ try { data = await api(`/streams/agent?channel=${encodeURIComponent(username)}`); } catch { data = null; }
++ if (username !== currentChannelUsername) return; // the page moved on while this was in flight
++ const cards = (data && Array.isArray(data.agents)) ? data.agents : [];
++ const tab = document.getElementById('ch-tab-btn-agents');
++ const badge = document.getElementById('ch-tab-badge-agents');
++ if (tab) tab.style.display = cards.length ? '' : 'none';
++ if (badge) {
++ const live = cards.filter((entry) => entry.state === 'live').length;
++ badge.hidden = !live;
++ badge.textContent = live ? String(live) : '';
++ }
++ grid.replaceChildren();
++ _agentsPaging = { username, grid: null };
++ if (!cards.length) {
++ const empty = document.createElement('p');
++ empty.className = 'agent-empty';
++ empty.textContent = 'No AI agent streams on this channel yet.';
++ grid.appendChild(empty);
++ if (more) more.hidden = true;
++ return;
++ }
++ if (!window.AgentCards) return;
++ _agentsPaging.grid = window.AgentCards.fill(grid, cards, { perPage: 12, moreButton: more });
++}
++
++function loadMoreChannelAgents(button) {
++ if (!_agentsPaging.grid) return;
++ if (button) button.disabled = true;
++ try { _agentsPaging.grid.more(); } finally { if (button) button.disabled = false; }
++}
++
function switchChannelTab(tab, btn) {
_channelTab = tab;
document.querySelectorAll('#ch-tabs .ch-tab').forEach(b => {
@@@ -1996,6 +2043,9 @@ function switchChannelTab(tab, btn) {
_chTabLoaded.clipsTaken = true;
try { loadClipsTaken(currentChannelUsername, { reset: true }); } catch {}
}
++ if (tab === 'agents' && currentChannelUsername) {
++ try { loadChannelAgents(currentChannelUsername); } catch {}
++ }
if (tab === 'ai-timeline' && currentChannelUsername && !_chTabLoaded.aiTimeline) {
_chTabLoaded.aiTimeline = true;
try { loadChannelAiTimeline(currentChannelUsername); } catch {}
@@@ -2030,7 +2080,7 @@ async function _loadGameBadge(username) {
// A URL hash like #ai-timeline / #about / #videos auto-opens that channel tab on load and
// scrolls the tab content into view (used by "view full AI timeline" links, deep links, etc.).
--const CHANNEL_TAB_HASHES = ['about', 'videos', 'clips', 'clips-taken', 'pastes', 'media', 'analytics', 'ai-timeline'];
++const CHANNEL_TAB_HASHES = ['about', 'videos', 'agents', 'clips', 'clips-taken', 'pastes', 'media', 'analytics', 'ai-timeline'];
function _applyChannelHashTab() {
const h = (location.hash || '').replace(/^#/, '').toLowerCase();
if (!h || !CHANNEL_TAB_HASHES.includes(h)) return;
ddiff --git a/public/js/app-home.js b/public/js/app-home.js
iindex e030c5ca..a54ff2ab 100644
---- a/public/js/app-home.js
++++ b/public/js/app-home.js
@@@ -2092,8 +2092,9 @@ async function refreshHomeLive() {
// A background tab has nobody to show a refreshed grid to. refreshHomeSections() already
// skips in that case; this one polled /api/streams forever regardless.
if (document.hidden) return;
-- let streams;
-- try { const d = await api('/streams'); streams = d.streams || []; } catch { return; }
++ let streams, agents;
++ try { const d = await api('/streams'); streams = d.streams || []; agents = d.agents || []; } catch { return; }
++ renderHomeAgents(agents);
const grid = document.getElementById('stream-grid-live');
if (!grid) return;
const countEl = document.getElementById('live-count');
@@@ -2129,6 +2130,46 @@ async function refreshHomeLive() {
_homeTopicsSync(streams);
}
++/** The Agents live row: public AI agent streams in their own row after people's streams (S2). */
++function renderHomeAgents(agents) {
++ const section = document.getElementById('home-agents');
++ const grid = document.getElementById('home-agents-grid');
++ if (!section || !grid) return;
++ const list = Array.isArray(agents) ? agents : [];
++ if (!list.length) { section.hidden = true; grid.replaceChildren(); return; }
++ grid.replaceChildren();
++ for (const a of list.slice(0, 12)) {
++ const card = document.createElement('a');
++ card.className = 'agent-card';
++ card.href = a.page_url || '#';
++ const media = document.createElement('div');
++ media.className = 'agent-card-media';
++ if (a.last_frame_url) {
++ const img = document.createElement('img');
++ img.src = a.last_frame_url; img.alt = ''; img.loading = 'lazy';
++ media.appendChild(img);
++ }
++ const label = document.createElement('span');
++ label.className = 'agent-label'; label.textContent = 'AI agent';
++ media.appendChild(label);
++ if (a.state === 'live') {
++ const live = document.createElement('span');
++ live.className = 'agent-card-live'; live.textContent = a.viewers ? `live · ${a.viewers}` : 'live';
++ media.appendChild(live);
++ }
++ card.appendChild(media);
++ const body = document.createElement('div');
++ body.className = 'agent-card-body';
++ const title = document.createElement('h3');
++ title.className = 'agent-card-title'; title.textContent = a.title || 'An AI agent';
++ const meta = document.createElement('p');
++ meta.className = 'agent-card-meta';
++ meta.textContent = [a.state === 'live' ? `${a.viewers || 0} watching` : `worked ${Math.round((a.worked_seconds || 0) / 60)} min`, (a.task && a.task.title) || ''].filter(Boolean).join(' · ');
++ body.append(title, meta); card.appendChild(body); grid.appendChild(card);
++ }
++ section.hidden = false;
++}
++
// Refresh the paginated home sections (only at page 1, only when visible — so we
// never disrupt someone paging through or reading below the fold).
function refreshHomeSections() {
ddiff --git a/scripts/perf/check-budgets.js b/scripts/perf/check-budgets.js
iindex bc5fce86..f18492d9 100644
---- a/scripts/perf/check-budgets.js
++++ b/scripts/perf/check-budgets.js
@@@ -19,11 +19,11 @@ const ROOT = path.join(__dirname, '..', '..');
// openvibe-shared/perf-budget names; "js" is the eager scripts (incl. route scripts for "/"),
// "css" the render-blocking stylesheets.
const BUDGETS = {
-- htmlRawKB: 129, // 128.3 with the agent stream's route (2026-11-06: its placeholder in the HTML and its asset manifest entries, Live #622); 119.3 with the icons drawn on the server (2026-11-01, plan L54 K4: the OpenVibe glyphs in the HTML, 20.8 KB brotli); measured 94.9 before
++ htmlRawKB: 129.5, // 129.2 with the Agents live row on the home (2026-11-06, Agent Streams S2: one section and its manifest entry); 128.3 with the agent stream's route (2026-11-06: its placeholder in the HTML and its asset manifest entries, Live #622); 119.3 with the icons drawn on the server (2026-11-01, plan L54 K4: the OpenVibe glyphs in the HTML, 20.8 KB brotli); measured 94.9 before
htmlBrotliKB: 30,
jsFiles: 26, // measured 23 (incl. route scripts for "/")
-- jsRawKB: 1291.2, // 1291.2 with openvibe-shared 4.118.0 (2026-11-06: the Locker re-auth link, the 44 px footer and TLD targets); 1280.1 (2026-11-03: the chat name card shows at once, the logs give up after ten seconds; headroom for openvibe-shared 4.107.3); 1279.2 (2026-11-03: openvibe-shared 4.107.0, open.<tld> brands; the home's reveal scroll listener hooked once); 1278.3 (2026-11-02: openvibe-shared 4.106.2, the balances stack so the site's name reads whole on a phone); 1277.1 (2026-11-02: the home's money four as one 'None yet' chip while all four are zero, open item 135); 1275.2 (2026-11-02: route placeholders in ov-skeleton.js, L52 step 6: a loading route shows its shape, not an empty box); 1268.4 (2026-11-02: openvibe-shared 4.103.0, the puzzle glyph, two-line sidebar rows, boost's fix and eight solids; the 404 offers the channel for a bare /name); 1266.3 (2026-11-02: openvibe-shared 4.95.0, keyboard-reachable scrollers; the phone Pastes rail); 1262.1 (2026-11-02: openvibe-shared 4.94.0, the Audio chip, the go-live card's labels); 1259.8 (2026-11-02: content labels on cards and the player, L1); 1257.5 (2026-11-01: the Catch up rails load by themselves as you swipe, no Load more on a phone); 1254.2 (2026-11-01: openvibe-shared 4.87.0), about 1255 with #534 (feed and card avatars at their size); 1253.8 (2026-11-01: avatars ask Media for their drawn width, ovImgW); 1252.3 (2026-11-01: Following in the topics row, the picked topic remembered); 1250.2 (2026-11-01: topics live now, readable chat names on light themes); 1247.7 openvibe-shared 4.70.0 (2026-11-01: the app bar, busy(), skeletons, view transitions); 1244.3 with openvibe-shared 4.66.0: the mark with depth and motion, ov-fx loaded on request (2026-10-31); 1243.5 with openvibe-shared 4.64.0: the look travels between sites, the Look panel in the menu (2026-10-31); 1242.7 with openvibe-shared 4.59.0: the Pair mark beside the name, set in the brand face, and the ring on page moves (2026-10-31); 1238.8 with the home's featured stream once and the intro closed on phones (2026-10-31); 1237.4 with the home's two more tab blocks on phones (2026-10-30); 1233.9 with the home's Catch up tabs on phones (2026-10-30); 1230.8 with openvibe-shared 4.53.0: the footer's columns fold on phones (2026-10-30); 1227.8 with openvibe-shared 4.52.0: the Dock's loader and its Chats tab (2026-10-30); 1223.5 with openvibe-shared 4.48.0 (the site switcher on the mark, the menu's Start tab and quick tools; Kit's touch hit areas and the phone navbar fix, 4.44-4.47.3); 1215.4 with openvibe-shared 4.43.0 (room.css carries the kit tokens, 4.39.1; the kit's 4.39.0 rules); measured 1203.9 (2026-10-28: openvibe-shared 4.30.0, the bell center: tabs with counts, the Unread filter, thumbnails, swipe, the phone sheet; 4.31.0's ✦ adds about 3 KB); 1187.2 (2026-10-28: openvibe-shared 4.27.0, Notifications 2.0: opaque themed toasts held while a menu is open, grouping, priority, System cards and the rich bell panel); 1158.8 (2026-10-28: openvibe-shared 4.25.0, the menu's orb, messages, the phone sheet and auto-hide in the navbar; the messenger opens from the bar)
-- jsBrotliKB: 302.5, // 302.5 with openvibe-shared 4.118.0 (2026-11-06: the Locker re-auth link, the 44 px footer and TLD targets); 302.1 (2026-11-06: TTS speaks emotes by name, ttsSpeakableEmotes in chat-shared.js, Live #626); 301.7 with the agent stream's route (2026-11-06: the route in app.js and its shape in ov-skeleton.js; the agent's own scripts stay route-loaded, Live #622); 301.6 (2026-11-06: the Share button sweep — the shared kit's Share button markup in app.js, Live #617); 301.2 with openvibe-shared 4.114.0 (2026-11-06: the one Share sheet in the navbar (ov-share.js and ov-qr.js load on the first press); navbar.js gains its click handler); 300.1 (2026-11-03: every api() read gives up after 20 s, Live #589; openvibe-shared 4.107.3); 299.3 (2026-11-02: openvibe-shared 4.106.2, the balances stack); 298.4 (2026-11-02: route placeholders, L52 step 6); 296.7 (2026-11-02: openvibe-shared 4.103.0 and the 404's channel offer); 296.2 (2026-11-02: openvibe-shared 4.95.0, keyboard-reachable scrollers; the phone Pastes rail); 295.1 (2026-11-02: openvibe-shared 4.94.0, the Audio chip, the go-live card's labels); 294.6 (2026-11-02: content labels on cards and the player, L1); 294.1 (2026-11-01: the Catch up rails load by themselves as you swipe); 292.6 openvibe-shared 4.70.0 (2026-11-01: the app bar, busy(), skeletons, view transitions); 291.1 with openvibe-shared 4.57.1 (the Dock's async token and scope-aware controls, 4.57.0; the tool frame's facts, 4.56.0) (2026-10-31); 290.6 with the home's two more tab blocks on phones (2026-10-30); 289.8 with the home's Catch up tabs on phones (2026-10-30); 288.2 with openvibe-shared 4.52.0: the Dock's loader and its Chats tab (2026-10-30); 287.4 with openvibe-shared 4.48.0 (the site switcher on the mark, the menu's Start tab and quick tools; Kit's touch hit areas and the phone navbar fix, 4.44-4.47.3); measured 283.2 (2026-10-28: openvibe-shared 4.30.0, the bell center as above); 279.6 (2026-10-28: openvibe-shared 4.27.0, Notifications 2.0 as above); 273.1 (2026-10-28: openvibe-shared 4.25.0 as above); 270.5 (2026-10-28: openvibe-shared 4.22.0, the navbar's search magnifier with / and Ctrl+K and the More menu; search.js and loading.js load only when used); 269.6 (2026-10-28: openvibe-shared 4.19.0, the account menu's gear and people-group icons inline; 268 on 2026-10-27: openvibe-shared 4.16.0, the folded balance chip, Across OpenVibe with site marks and the icon preload in the navbar); 265.3 (2026-10-27: openvibe-shared 4.15.0, the compact footer opens into the whole one, and the pulse line); 263.3 on 10-23 (openvibe-shared 4.9.0, status text twins in every theme); 262.5 on 10-17 (openvibe-shared 4.4.0, every site in the launcher)
++ jsRawKB: 1293.3, // 1293.3 with the Agents live row (Agent Streams S2) on openvibe-shared 4.118.0; 1291.2 with openvibe-shared 4.118.0 (2026-11-06: the Locker re-auth link, the 44 px footer and TLD targets); 1280.1 (2026-11-03: the chat name card shows at once, the logs give up after ten seconds; headroom for openvibe-shared 4.107.3); 1279.2 (2026-11-03: openvibe-shared 4.107.0, open.<tld> brands; the home's reveal scroll listener hooked once); 1278.3 (2026-11-02: openvibe-shared 4.106.2, the balances stack so the site's name reads whole on a phone); 1277.1 (2026-11-02: the home's money four as one 'None yet' chip while all four are zero, open item 135); 1275.2 (2026-11-02: route placeholders in ov-skeleton.js, L52 step 6: a loading route shows its shape, not an empty box); 1268.4 (2026-11-02: openvibe-shared 4.103.0, the puzzle glyph, two-line sidebar rows, boost's fix and eight solids; the 404 offers the channel for a bare /name); 1266.3 (2026-11-02: openvibe-shared 4.95.0, keyboard-reachable scrollers; the phone Pastes rail); 1262.1 (2026-11-02: openvibe-shared 4.94.0, the Audio chip, the go-live card's labels); 1259.8 (2026-11-02: content labels on cards and the player, L1); 1257.5 (2026-11-01: the Catch up rails load by themselves as you swipe, no Load more on a phone); 1254.2 (2026-11-01: openvibe-shared 4.87.0), about 1255 with #534 (feed and card avatars at their size); 1253.8 (2026-11-01: avatars ask Media for their drawn width, ovImgW); 1252.3 (2026-11-01: Following in the topics row, the picked topic remembered); 1250.2 (2026-11-01: topics live now, readable chat names on light themes); 1247.7 openvibe-shared 4.70.0 (2026-11-01: the app bar, busy(), skeletons, view transitions); 1244.3 with openvibe-shared 4.66.0: the mark with depth and motion, ov-fx loaded on request (2026-10-31); 1243.5 with openvibe-shared 4.64.0: the look travels between sites, the Look panel in the menu (2026-10-31); 1242.7 with openvibe-shared 4.59.0: the Pair mark beside the name, set in the brand face, and the ring on page moves (2026-10-31); 1238.8 with the home's featured stream once and the intro closed on phones (2026-10-31); 1237.4 with the home's two more tab blocks on phones (2026-10-30); 1233.9 with the home's Catch up tabs on phones (2026-10-30); 1230.8 with openvibe-shared 4.53.0: the footer's columns fold on phones (2026-10-30); 1227.8 with openvibe-shared 4.52.0: the Dock's loader and its Chats tab (2026-10-30); 1223.5 with openvibe-shared 4.48.0 (the site switcher on the mark, the menu's Start tab and quick tools; Kit's touch hit areas and the phone navbar fix, 4.44-4.47.3); 1215.4 with openvibe-shared 4.43.0 (room.css carries the kit tokens, 4.39.1; the kit's 4.39.0 rules); measured 1203.9 (2026-10-28: openvibe-shared 4.30.0, the bell center: tabs with counts, the Unread filter, thumbnails, swipe, the phone sheet; 4.31.0's ✦ adds about 3 KB); 1187.2 (2026-10-28: openvibe-shared 4.27.0, Notifications 2.0: opaque themed toasts held while a menu is open, grouping, priority, System cards and the rich bell panel); 1158.8 (2026-10-28: openvibe-shared 4.25.0, the menu's orb, messages, the phone sheet and auto-hide in the navbar; the messenger opens from the bar)
++ jsBrotliKB: 302.9, // 302.9 with the Agents live row (Agent Streams S2) on openvibe-shared 4.118.0; 302.5 with openvibe-shared 4.118.0 (2026-11-06: the Locker re-auth link, the 44 px footer and TLD targets); 302.1 (2026-11-06: TTS speaks emotes by name, ttsSpeakableEmotes in chat-shared.js, Live #626); 301.7 with the agent stream's route (2026-11-06: the route in app.js and its shape in ov-skeleton.js; the agent's own scripts stay route-loaded, Live #622); 301.6 (2026-11-06: the Share button sweep — the shared kit's Share button markup in app.js, Live #617); 301.2 with openvibe-shared 4.114.0 (2026-11-06: the one Share sheet in the navbar (ov-share.js and ov-qr.js load on the first press); navbar.js gains its click handler); 300.1 (2026-11-03: every api() read gives up after 20 s, Live #589; openvibe-shared 4.107.3); 299.3 (2026-11-02: openvibe-shared 4.106.2, the balances stack); 298.4 (2026-11-02: route placeholders, L52 step 6); 296.7 (2026-11-02: openvibe-shared 4.103.0 and the 404's channel offer); 296.2 (2026-11-02: openvibe-shared 4.95.0, keyboard-reachable scrollers; the phone Pastes rail); 295.1 (2026-11-02: openvibe-shared 4.94.0, the Audio chip, the go-live card's labels); 294.6 (2026-11-02: content labels on cards and the player, L1); 294.1 (2026-11-01: the Catch up rails load by themselves as you swipe); 292.6 openvibe-shared 4.70.0 (2026-11-01: the app bar, busy(), skeletons, view transitions); 291.1 with openvibe-shared 4.57.1 (the Dock's async token and scope-aware controls, 4.57.0; the tool frame's facts, 4.56.0) (2026-10-31); 290.6 with the home's two more tab blocks on phones (2026-10-30); 289.8 with the home's Catch up tabs on phones (2026-10-30); 288.2 with openvibe-shared 4.52.0: the Dock's loader and its Chats tab (2026-10-30); 287.4 with openvibe-shared 4.48.0 (the site switcher on the mark, the menu's Start tab and quick tools; Kit's touch hit areas and the phone navbar fix, 4.44-4.47.3); measured 283.2 (2026-10-28: openvibe-shared 4.30.0, the bell center as above); 279.6 (2026-10-28: openvibe-shared 4.27.0, Notifications 2.0 as above); 273.1 (2026-10-28: openvibe-shared 4.25.0 as above); 270.5 (2026-10-28: openvibe-shared 4.22.0, the navbar's search magnifier with / and Ctrl+K and the More menu; search.js and loading.js load only when used); 269.6 (2026-10-28: openvibe-shared 4.19.0, the account menu's gear and people-group icons inline; 268 on 2026-10-27: openvibe-shared 4.16.0, the folded balance chip, Across OpenVibe with site marks and the icon preload in the navbar); 265.3 (2026-10-27: openvibe-shared 4.15.0, the compact footer opens into the whole one, and the pulse line); 263.3 on 10-23 (openvibe-shared 4.9.0, status text twins in every theme); 262.5 on 10-17 (openvibe-shared 4.4.0, every site in the launcher)
cssRawKB: 270, // measured ~243 since the first-paint subset (style.core.css) replaced style.css (~717 before)
cssBrotliKB: 55, // measured ~47.5 (was ~130)
};
ddiff --git a/server/agent-streams/pages.js b/server/agent-streams/pages.js
iindex cdfeff31..2f0ae3d5 100644
---- a/server/agent-streams/pages.js
++++ b/server/agent-streams/pages.js
@@@ -64,10 +64,12 @@ function html(publicId) {
<span class="agent-embed-viewers" id="agent-embed-viewers" hidden></span>
<div class="agent-embed-state" id="agent-embed-state">Connecting…</div>
<div class="agent-embed-curtain" id="agent-embed-curtain" hidden>Private screen</div>
++ <div id="agent-embed-overlay"></div>
</div>
</div>
</div>
<script src="/js/agent-player.js" defer></script>
++ <script src="/js/agent-overlay.js" defer></script>
<script src="/js/agent-embed.js" defer></script>
</body>
</html>`;
ddiff --git a/server/agent-streams/routes.js b/server/agent-streams/routes.js
iindex fbb626c7..3aadadb2 100644
---- a/server/agent-streams/routes.js
++++ b/server/agent-streams/routes.js
@@@ -23,6 +23,7 @@ const auth = require('./auth');
const service = require('./service');
const tickets = require('./tickets');
const frames = require('./frames');
++const steps = require('./steps');
const router = express.Router();
@@@ -99,6 +100,26 @@ async function deskOrOwner(req, res, next) {
}
}
++// ── The public list (the Agents tab and the home row) ─────────
++// Public agent streams are open; a channel's own page also shows its private and unlisted cards to the owner. This is
++// the S2 list, never mixed into people's streams: the "Agents live" row calls ?live=1.
++
++router.get('/', auth.viewer, async (req, res) => {
++ try {
++ res.set('Cache-Control', 'no-store');
++ const channel = typeof req.query.channel === 'string' ? req.query.channel.trim().slice(0, 64) : null;
++ if (channel) {
++ const rows = await service.listForChannel(channel, req.viewer ? req.viewer.id : null);
++ return res.json({ agents: rows.map(service.cardView) });
++ }
++ const rows = await service.listPublic({ liveOnly: req.query.live === '1' });
++ res.json({ agents: rows.map(service.cardView) });
++ } catch (err) {
++ console.error('[AgentStream] list failed:', err.message);
++ fail(res, 500, 'list_failed', 'The agent streams could not be listed');
++ }
++});
++
// ── Create ─────────────────────────────────────────────────────
router.post('/', auth.publisher, async (req, res) => {
@@@ -125,6 +146,19 @@ router.post('/', auth.publisher, async (req, res) => {
}
});
++/**
++ * Whether this request may see this stream: the owner, a valid view ticket, the unlisted link's secret, a public
++ * stream, or the sharing system's answer for a private one. The same rule the page's GET /:id applies.
++ */
++async function watchable(req, row) {
++ if (req.viewer && Number(req.viewer.id) === Number(row.user_id)) return true;
++ if (typeof req.query.vt === 'string' && await tickets.findViewById(req.query.vt, row.managed_stream_id)) return true;
++ return await service.mayView(row, {
++ viewer: req.viewer,
++ secret: (req.body && typeof req.body.k === 'string') ? req.body.k : (typeof req.query.k === 'string' ? req.query.k : null),
++ });
++}
++
// ── Read ───────────────────────────────────────────────────────
router.get('/:id', auth.viewer, async (req, res) => {
@@@ -306,6 +340,89 @@ router.post('/:id/end', deskOrOwner, async (req, res) => {
}
});
++// ── Steps, chapters and one-tap clips (S2) ─────────────────────
++
++// The steps the overlay draws. `after=<id>` returns only what is newer (the page's live poll); otherwise the newest 20
++// are answered oldest first. A viewer follows the same rule as the page, so a private stream's steps stay private.
++router.get('/:id/steps', auth.viewer, async (req, res) => {
++ try {
++ const row = await load(req, res);
++ if (!row) return;
++ if (!await watchable(req, row)) return fail(res, 404, 'agent_stream_not_found', 'No such agent stream');
++ const after = req.query.after;
++ const stepsOut = after !== undefined
++ ? await steps.after(row.managed_stream_id, after, 50)
++ : await steps.recent(row.managed_stream_id, req.query.limit || 20);
++ res.set('Cache-Control', 'no-store');
++ res.json({ steps: stepsOut });
++ } catch (err) {
++ console.error('[AgentStream] steps read failed:', err.message);
++ fail(res, 500, 'steps_failed', 'The steps could not be read');
++ }
++});
++
++// A step is posted by the desk (publish ticket) or by the person's own session; Codes and Actor use their service
++// token with live.agent_stream.publish, which auth.publisher already accepts. The limits are the design's.
++router.post('/:id/steps', deskOrOwner, async (req, res) => {
++ try {
++ const step = await steps.add(req.agent, req.body || {});
++ res.status(201).json({ step });
++ } catch (err) {
++ if (err && err.status) return fail(res, err.status, err.code, err.message);
++ console.error('[AgentStream] step failed:', err.message);
++ fail(res, 500, 'step_failed', 'The step could not be stored');
++ }
++});
++
++// The recording's chapters: every step that started, at its offset from the stream's start.
++router.get('/:id/chapters', auth.viewer, async (req, res) => {
++ try {
++ const row = await load(req, res);
++ if (!row) return;
++ if (!await watchable(req, row)) return fail(res, 404, 'agent_stream_not_found', 'No such agent stream');
++ const out = await steps.chapters(row);
++ res.set('Cache-Control', 'no-store');
++ res.json(out);
++ } catch (err) {
++ console.error('[AgentStream] chapters failed:', err.message);
++ fail(res, 500, 'chapters_failed', 'The chapters could not be read');
++ }
++});
++
++// One tap: a failed step, or the final passed one, becomes a clip through Media's own clip tools. Only a signed-in
++// viewer who may watch the recording can ask, and the window comes from the step (five seconds before, fifteen after).
++router.post('/:id/clips', auth.viewer, async (req, res) => {
++ try {
++ const row = await load(req, res);
++ if (!row) return;
++ if (!req.viewer) return fail(res, 401, 'authentication_required', 'Sign in to clip a step');
++ if (!await watchable(req, row)) return fail(res, 404, 'agent_stream_not_found', 'No such agent stream');
++ if (!row.vod_id) return fail(res, 409, 'no_recording', 'This stream has no recording to clip');
++ const window = await steps.clipWindow(row, req.body && req.body.step_id);
++ const step = window.step;
++ if (step.status !== 'failed' && step.status !== 'passed') {
++ return fail(res, 409, 'not_clippable', 'Only a failed step or the final passed one can be clipped');
++ }
++ if (step.status === 'passed') {
++ const later = await require('../db/database').get('SELECT id FROM agent_stream_steps WHERE managed_stream_id = ? AND id > ? LIMIT 1', [row.managed_stream_id, step.id]);
++ if (later) return fail(res, 409, 'not_final', 'Only the final passed step can be clipped');
++ }
++ const media = require('../media-client');
++ const made = await media.createClip({
++ vod_id: row.vod_id,
++ start_s: window.start_s,
++ end_s: window.end_s,
++ title: String(step.title).slice(0, 120),
++ user_id: req.viewer.id,
++ }).catch((err) => { throw Object.assign(new Error(err.message || 'Media refused the clip'), { status: 502, code: 'clip_failed' }); });
++ res.status(201).json({ clip: made });
++ } catch (err) {
++ if (err && err.status) return fail(res, err.status, err.code, err.message);
++ console.error('[AgentStream] clip failed:', err.message);
++ fail(res, 500, 'clip_failed', 'The clip could not be made');
++ }
++});
++
// ── View tickets ───────────────────────────────────────────────
router.post('/:id/view-ticket', auth.viewer, async (req, res) => {
ddiff --git a/server/agent-streams/service.js b/server/agent-streams/service.js
iindex 55f7fe46..4bf4c27b 100644
---- a/server/agent-streams/service.js
++++ b/server/agent-streams/service.js
@@@ -76,6 +76,8 @@ const SELECT = `
a.started_at, a.ended_at, a.worked_seconds, a.last_frame_path, a.last_frame_at, a.vod_id, a.allow_sites,
a.unlisted_secret_hash,
(SELECT s.id FROM streams s WHERE s.managed_stream_id = ms.id AND s.is_live = 1 ORDER BY s.started_at DESC LIMIT 1) AS live_session_id,
++ (SELECT st.title FROM agent_stream_steps st WHERE st.managed_stream_id = ms.id ORDER BY st.id DESC LIMIT 1) AS latest_step_title,
++ (SELECT st.status FROM agent_stream_steps st WHERE st.managed_stream_id = ms.id ORDER BY st.id DESC LIMIT 1) AS latest_step_status,
(SELECT la.subject_id FROM linked_accounts la WHERE la.service = 'network' AND la.user_id = ms.user_id) AS owner_subject,
u.username, u.display_name, u.avatar_url, u.profile_color, u.is_banned, u.is_owner
FROM managed_streams ms
@@@ -159,9 +161,53 @@ function publicView(row) {
vod_id: row.vod_id || null,
page_url: pageUrl(row),
label: 'AI agent',
++ // The chat room a public or unlisted stream carries (S2): people only, the agent reads it in S5.
++ chat_room: (row.visibility === 'public' || row.visibility === 'unlisted') ? `agent:${row.public_id}` : null,
};
}
++/**
++ * The compact card the Agents tab and the "Agents live" row draw. It carries the facts a card needs and nothing that
++ * belongs to the owner alone (no reason, no link secret, no stream key — an agent row never has one).
++ */
++function cardView(row) {
++ const stream = publicView(row);
++ return {
++ id: stream.id,
++ channel: stream.channel,
++ title: stream.title,
++ task: stream.task,
++ visibility: stream.visibility,
++ state: stream.state,
++ viewers: stream.viewers,
++ started_at: stream.started_at,
++ ended_at: stream.ended_at,
++ worked_seconds: stream.worked_seconds,
++ last_frame_url: stream.last_frame_url,
++ vod_id: stream.vod_id,
++ page_url: stream.page_url,
++ label: stream.label,
++ chat_room: stream.chat_room,
++ current_step: row.latest_step_title ? { title: row.latest_step_title, status: row.latest_step_status || 'started' } : null,
++ };
++}
++
++/** Public agent streams (live or ended), newest first, for the home row and a channel's Agents tab. */
++async function listPublic({ channel = null, liveOnly = false, limit = 60 } = {}) {
++ const params = [];
++ let where = "a.visibility = 'public'";
++ if (liveOnly) where += " AND a.state = 'live'";
++ if (channel) { where += ' AND LOWER(u.username) = LOWER(?)'; params.push(String(channel)); }
++ params.push(Math.min(100, Math.max(1, Number(limit) || 60)));
++ return await db.all(`${SELECT} WHERE ${where} ORDER BY (a.state = 'live') DESC, COALESCE(a.started_at, a.created_at) DESC LIMIT ?`, params);
++}
++
++/** One channel's agent streams for its owner: public ones, and every visibility for the owner. */
++async function listForChannel(username, viewerId, limit = 60) {
++ const rows = await db.all(`${SELECT} WHERE LOWER(u.username) = LOWER(?) ORDER BY (a.state = 'live') DESC, COALESCE(a.started_at, a.created_at) DESC LIMIT ?`, [String(username), Math.min(100, Math.max(1, Number(limit) || 60))]);
++ return rows.filter((row) => row.visibility === 'public' || Number(row.user_id) === Number(viewerId || 0));
++}
++
// ── Creating ───────────────────────────────────────────────────
/**
@@@ -349,7 +395,7 @@ module.exports = {
PUBLISH_TICKET_TTL_MS, VIEW_TICKET_TTL_MS, MAX_FRAME_BYTES,
sha256, token, agentId, iso, pageUrl, whipUrl, shareRef,
getByPublicId, getByManagedStreamId, liveSession, listByUser, countLiveByUser, liveLimit, ownerSubject,
-- viewersNow, workedSeconds, publicView,
++ viewersNow, workedSeconds, publicView, cardView, listPublic, listForChannel,
create, update, markPublishing, end, setVisibility, rotateLink, setCurtain, setFrame, setVod,
mayView,
};
ddiff --git a/server/agent-streams/steps.js b/server/agent-streams/steps.js
nnew file mode 100644
iindex 00000000..6ef72f5d
---- /dev/null
++++ b/server/agent-streams/steps.js
@@@ -0,0 +1,180 @@
++'use strict';
++/**
++ * Agent stream steps (Agent Streams S2, design 2026-11-06): the lines an agent posts as it works, the overlay the
++ * player draws from them and the chapters a recording keeps.
++ *
++ * A step is `{ at, title, status, detail?, tests?: { passed, failed }, cost_usd? }` (live.agent-stream-step@1). Live
++ * stores them with the stream (agent_stream_steps), not with one WHIP session, so they outlive a reconnect and travel
++ * with the VOD. The rules are the design's:
++ *
++ * - at most one step a second, and at most 2,000 per stream;
++ * - title ≤120 and detail ≤500, plain text, and the house text filters must accept it before it is shown;
++ * - a chapter is each step whose status is 'started' (its title at its time);
++ * - a failed step and the final passed step can be clipped in one tap through Media's clip tools.
++ *
++ * Everything here is plain text: the overlay builds DOM nodes, never HTML, so a title cannot carry markup.
++ */
++const db = require('../db/database');
++const wordFilter = require('../chat/word-filter');
++const moderation = require('../chat/moderation-utils');
++
++const STATUSES = ['started', 'passed', 'failed', 'skipped'];
++const MAX_TITLE = 120;
++const MAX_DETAIL = 500;
++const MAX_STEPS = 2000;
++const MIN_INTERVAL_MS = 1000;
++const MAX_LIST = 50;
++
++class StepError extends Error {
++ constructor(status, code, message) {
++ super(message);
++ this.status = status;
++ this.code = code;
++ }
++}
++
++/** Plain text: control characters out, whitespace collapsed, length kept in Unicode code points. */
++function plain(value, max) {
++ if (typeof value !== 'string') return null;
++ const text = value.replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/g, '').replace(/\s+/g, ' ').trim();
++ if (!text || Array.from(text).length > max) return null;
++ return text;
++}
++
++/** Whether the house text filters accept this line. Both are the filter chat runs, so a title cannot say what chat refuses. */
++function allowed(text) {
++ if (!text) return true;
++ try {
++ if (!wordFilter.check(text).safe) return false;
++ } catch { return false; }
++ try {
++ if (moderation.containsCoreSlur(text)) return false;
++ } catch { return false; }
++ return true;
++}
++
++function cleanTests(tests) {
++ if (tests === undefined || tests === null) return { passed: null, failed: null };
++ if (typeof tests !== 'object' || Array.isArray(tests)) throw new StepError(400, 'invalid_tests', 'tests is { passed, failed }');
++ const ok = (n) => Number.isInteger(n) && n >= 0 && n <= 1_000_000;
++ if (!ok(tests.passed) || !ok(tests.failed)) throw new StepError(400, 'invalid_tests', 'tests.passed and tests.failed are whole numbers, zero or more');
++ return { passed: tests.passed, failed: tests.failed };
++}
++
++function cleanCost(value) {
++ if (value === undefined || value === null) return null;
++ const n = Number(value);
++ if (!Number.isFinite(n) || n < 0 || n > 1_000_000) throw new StepError(400, 'invalid_cost', 'cost_usd is a number, zero or more');
++ return Math.round(n * 1e6) / 1e6;
++}
++
++/** "now" or the agent's own time, never in the future by more than a minute (a wrong clock must not push a chapter past the VOD). */
++function cleanAt(value) {
++ if (value === undefined || value === null) return new Date();
++ const at = new Date(String(value));
++ if (Number.isNaN(at.getTime())) throw new StepError(400, 'invalid_at', 'at is a date-time');
++ const now = Date.now();
++ if (at.getTime() > now + 60_000) return new Date(now);
++ if (at.getTime() < now - 7 * 24 * 60 * 60 * 1000) throw new StepError(400, 'invalid_at', 'at is more than seven days old');
++ return at;
++}
++
++/** The body of live.agent-stream-step-request@1, cleaned, or a StepError. */
++function clean(body) {
++ const input = body && typeof body === 'object' ? body : {};
++ const title = plain(input.title, MAX_TITLE);
++ if (!title) throw new StepError(400, 'invalid_title', 'A title of 1–120 characters is required');
++ if (!STATUSES.includes(input.status)) throw new StepError(400, 'invalid_status', 'status is started, passed, failed or skipped');
++ const detail = input.detail === undefined || input.detail === null ? null : plain(input.detail, MAX_DETAIL);
++ if (input.detail !== undefined && input.detail !== null && !detail) throw new StepError(400, 'invalid_detail', 'The detail is at most 500 characters');
++ const tests = cleanTests(input.tests);
++ const cost = cleanCost(input.cost_usd);
++ if (!allowed(title) || (detail && !allowed(detail))) throw new StepError(422, 'text_rejected', 'That text is not allowed');
++ return { at: cleanAt(input.at), title, status: input.status, detail, tests, cost };
++}
++
++function stepView(row) {
++ const out = {
++ id: Number(row.id),
++ at: row.at instanceof Date ? row.at.toISOString() : new Date(String(row.at).replace(' ', 'T') + (String(row.at).includes('T') ? '' : 'Z')).toISOString(),
++ title: row.title,
++ status: row.status,
++ };
++ if (row.detail) out.detail = row.detail;
++ if (row.tests_passed != null || row.tests_failed != null) {
++ out.tests = { passed: Number(row.tests_passed) || 0, failed: Number(row.tests_failed) || 0 };
++ }
++ if (row.cost_usd != null) out.cost_usd = Number(row.cost_usd);
++ return out;
++}
++
++/**
++ * Store one step for a stream. Refuses a second within a second, the 2,001st, and a stream that has ended.
++ * Returns the step as the contract has it.
++ */
++async function add(row, body) {
++ if (row.state === 'ended') throw new StepError(409, 'agent_stream_ended', 'This agent stream has ended');
++ const step = clean(body);
++ const last = await db.get('SELECT created_at, COUNT(*) OVER () AS total FROM agent_stream_steps WHERE managed_stream_id = ? ORDER BY id DESC LIMIT 1', [row.managed_stream_id]);
++ if (last) {
++ // COUNT(*) OVER () in the same read keeps the cap check off a second query on the hot path.
++ if (Number(last.total) >= MAX_STEPS) throw new StepError(429, 'too_many_steps', 'A stream keeps at most 2,000 steps');
++ const at = last.created_at instanceof Date ? last.created_at.getTime() : Date.parse(String(last.created_at).includes('T') ? last.created_at : `${String(last.created_at).replace(' ', 'T')}Z`);
++ if (Number.isFinite(at) && Date.now() - at < MIN_INTERVAL_MS) throw new StepError(429, 'steps_too_fast', 'One step a second');
++ }
++ const inserted = await db.get(
++ `INSERT INTO agent_stream_steps (managed_stream_id, at, title, status, detail, tests_passed, tests_failed, cost_usd)
++ VALUES (?, ?, ?, ?, ?, ?, ?, ?) RETURNING id`,
++ [row.managed_stream_id, step.at, step.title, step.status, step.detail, step.tests.passed, step.tests.failed, step.cost]
++ );
++ return stepView(await db.get('SELECT * FROM agent_stream_steps WHERE id = ?', [inserted.id]));
++}
++
++/** The newest `limit` steps, oldest first. */
++async function recent(managedStreamId, limit = 20) {
++ const n = Math.min(MAX_LIST, Math.max(1, Number(limit) || 20));
++ const rows = await db.all('SELECT * FROM agent_stream_steps WHERE managed_stream_id = ? ORDER BY id DESC LIMIT ?', [managedStreamId, n]);
++ return rows.reverse().map(stepView);
++}
++
++/** Steps stored after `after`, oldest first (the page's live poll). */
++async function after(managedStreamId, afterId, limit = MAX_LIST) {
++ const n = Math.min(MAX_LIST, Math.max(1, Number(limit) || MAX_LIST));
++ const rows = await db.all('SELECT * FROM agent_stream_steps WHERE managed_stream_id = ? AND id > ? ORDER BY id ASC LIMIT ?', [managedStreamId, Number(afterId) || 0, n]);
++ return rows.map(stepView);
++}
++
++/**
++ * The chapters of the recording: every step that started, at its offset from the stream's start.
++ * `{ vod_id, chapters: [{ at, title, step_id }] }`; empty before a stream starts.
++ */
++async function chapters(row) {
++ const started = row.started_at ? new Date(String(row.started_at instanceof Date ? row.started_at.toISOString() : String(row.started_at).replace(' ', 'T') + (String(row.started_at).includes('T') ? '' : 'Z'))).getTime() : null;
++ const rows = await db.all("SELECT * FROM agent_stream_steps WHERE managed_stream_id = ? AND status = 'started' ORDER BY at ASC, id ASC", [row.managed_stream_id]);
++ const out = [];
++ for (const r of rows) {
++ const at = new Date(String(r.at instanceof Date ? r.at.toISOString() : String(r.at).replace(' ', 'T') + (String(r.at).includes('T') ? '' : 'Z'))).getTime();
++ if (!Number.isFinite(at)) continue;
++ const offset = started && Number.isFinite(started) ? Math.max(0, Math.round((at - started) / 1000)) : 0;
++ out.push({ at: offset, title: r.title, step_id: Number(r.id) });
++ }
++ return { vod_id: row.vod_id || null, chapters: out };
++}
++
++/**
++ * The window around a step for a one-tap clip: five seconds before it began and fifteen after, or the whole gap to the
++ * next step when that is shorter. Clamped to zero, never negative.
++ */
++async function clipWindow(row, stepId) {
++ const step = await db.get('SELECT * FROM agent_stream_steps WHERE id = ? AND managed_stream_id = ?', [Number(stepId) || 0, row.managed_stream_id]);
++ if (!step) throw new StepError(404, 'step_not_found', 'No such step');
++ const started = row.started_at ? new Date(String(row.started_at instanceof Date ? row.started_at.toISOString() : String(row.started_at).replace(' ', 'T') + (String(row.started_at).includes('T') ? '' : 'Z'))).getTime() : null;
++ const at = new Date(String(step.at instanceof Date ? step.at.toISOString() : String(step.at).replace(' ', 'T') + (String(step.at).includes('T') ? '' : 'Z'))).getTime();
++ const offset = started && Number.isFinite(started) && Number.isFinite(at) ? Math.max(0, Math.round((at - started) / 1000)) : 0;
++ return { step: stepView(step), start_s: Math.max(0, offset - 5), end_s: Math.max(1, offset + 15) };
++}
++
++module.exports = {
++ STATUSES, MAX_TITLE, MAX_DETAIL, MAX_STEPS, MIN_INTERVAL_MS, StepError,
++ plain, allowed, clean, stepView, add, recent, after, chapters, clipWindow,
++};
ddiff --git a/server/db/database.js b/server/db/database.js
iindex b20747e5..b0e0c611 100644
---- a/server/db/database.js
++++ b/server/db/database.js
@@@ -213,7 +213,7 @@ async function getLiveStreams() {
FROM streams s
JOIN users u ON s.user_id = u.id
LEFT JOIN managed_streams ms ON s.managed_stream_id = ms.id
-- WHERE s.is_live = 1
++ WHERE s.is_live = 1 AND COALESCE(ms.kind, 'normal') <> 'agent'
ORDER BY s.viewer_count DESC, s.started_at DESC
`);
}
@@@ -265,9 +265,12 @@ async function getStreamById(id) {
}
async function getStreamByUserId(userId) {
++ // An agent stream is not the channel's live stream: it has its own page and its own slot kind.
return await get(`
-- SELECT * FROM streams WHERE user_id = ? AND is_live = 1
-- ORDER BY started_at DESC LIMIT 1
++ SELECT s.* FROM streams s
++ LEFT JOIN managed_streams ms ON s.managed_stream_id = ms.id
++ WHERE s.user_id = ? AND s.is_live = 1 AND COALESCE(ms.kind, 'normal') <> 'agent'
++ ORDER BY s.started_at DESC LIMIT 1
`, [userId]);
}
@@@ -296,7 +299,7 @@ async function getLiveStreamsByUserId(userId) {
FROM streams s
JOIN users u ON s.user_id = u.id
LEFT JOIN managed_streams ms ON s.managed_stream_id = ms.id
-- WHERE s.user_id = ? AND s.is_live = 1
++ WHERE s.user_id = ? AND s.is_live = 1 AND COALESCE(ms.kind, 'normal') <> 'agent'
ORDER BY s.started_at DESC
`, [userId]);
}
ddiff --git a/server/seo/discovery.js b/server/seo/discovery.js
iindex 3f9abe8b..367389cc 100644
---- a/server/seo/discovery.js
++++ b/server/seo/discovery.js
@@@ -102,6 +102,19 @@ async function buildSitemap(liveStreams = []) {
if (onAir.has(u)) row.videos = [onAir.get(u)];
urls.push(row);
}
++ // Agent streams are not channels and are never in the on-air list above. A public one gets its own page while it
++ // is live, and keeps it afterwards when its recording exists (the design's "with its VOD after"). A private or
++ // unlisted stream is never listed, whatever else is true of it.
++ try {
++ // A public stream is listed while live and kept afterwards when its recording exists (the design's "with its
++ // VOD after"); one that ended without a recording leaves the sitemap.
++ const agents = await require('../agent-streams/service').listPublic({ limit: 100 });
++ for (const a of agents) {
++ if (!a.username || !a.public_id) continue;
++ if (a.state !== 'live' && !a.vod_id) continue;
++ entry(`/@${encodeURIComponent(a.username)}/agents/${a.public_id}`, (a.state === 'ended' ? isoTime(a.ended_at) : isoTime(a.started_at)) || releasedAt, 'hourly', 0.5);
++ }
++ } catch { /* an agent list failure never takes the sitemap down */ }
return seo.sitemapXml(urls);
}
async function sitemapHandler(req, res) {
@@@ -140,8 +153,12 @@ const _docsPresent = () => LLMS_DOCS.filter(([name]) => { try { return fs.exists
// Each of Live's own pages is linked at its Markdown twin (AGT-5; openvibe-shared 4.108.0 leaves files and other sites'
// links as they are). The broadcaster is an app with no twin, so it is linked as HTML.
--function llmsTxt() {
++async function llmsTxt() {
const b = baseUrl();
++ // Agent streams while they are live: their own page, never described as a person's stream. A failure here leaves
++ // the rest of llms.txt intact.
++ let agents = [];
++ try { agents = await require('../agent-streams/service').listPublic({ liveOnly: true, limit: 20 }); } catch { agents = []; }
return seo.llmsTxt({
name: SITE_NAME,
markdown: true,
@@@ -168,6 +185,11 @@ function llmsTxt() {
{ title: 'What the AI made (AI Moments)', links: [
{ title: 'AI Moments', url: `${b}/moments`, note: `auto-clips cut when chat reacted, frames the AI picked from streams, and AI-written after-show recaps. JSON: ${b}/api/content/moments. AI Moments pages are noindex; the /moments collection is their indexable form. They are never listed in the sitemap. An AI clip says "AI clip · from <streamer>'s stream"; it is never presented as something the streamer or a viewer clipped. A streamer can turn AI Moments off for their channel; the AI then makes no new ones from their streams.` },
] },
++ ...(agents.length ? [{ title: 'AI agents live now', links: agents.filter((a) => a.username).map((a) => ({
++ title: `${a.title} — an AI agent's screen`,
++ url: `${b}/@${encodeURIComponent(a.username)}/agents/${a.public_id}`,
++ note: `${a.task && a.task.title ? a.task.title : 'a task'} · labelled AI agent · people chat beside it`,
++ })) }] : []),
{ title: 'API and docs', links: [
{ title: 'API docs', url: `${b}/documentation`, note: 'every feature has an open API (chat, streams, clips, overlays, robots, sound commands)' },
{ title: 'Docs index', url: `${b}/docs` },
@@@ -203,10 +225,16 @@ function llmsFullTxt() {
return _llmsFull;
}
--const textHandler = (build) => (req, res) => {
-- res.set('Content-Type', 'text/plain; charset=utf-8');
-- res.set('Cache-Control', cache.htmlHeaders({ maxAge: DISCOVERY_MAX_AGE }));
-- res.send(build());
++const textHandler = (build) => async (req, res) => {
++ try {
++ const body = await build();
++ res.set('Content-Type', 'text/plain; charset=utf-8');
++ res.set('Cache-Control', cache.htmlHeaders({ maxAge: DISCOVERY_MAX_AGE }));
++ res.send(body);
++ } catch (err) {
++ console.warn('[SEO] discovery file failed:', err.message);
++ res.status(500).type('text/plain').send('The file could not be built');
++ }
};
/** Mounted before express.static, so /robots.txt here wins over any file in public/. */
ddiff --git a/server/streaming/routes.js b/server/streaming/routes.js
iindex 892e6bf1..6d40353a 100644
---- a/server/streaming/routes.js
++++ b/server/streaming/routes.js
@@@ -1218,7 +1218,14 @@ router.get('/', optionalAuth, async (req, res) => {
total_viewer_count: (s.viewer_count || 0) + externalTotal,
});
}));
-- res.json({ streams: enriched });
++ // Agent streams are their own row (the design's "Agents live", after people's streams). They are never in
++ // `streams`: an agent's screen is not a person's channel going live.
++ let agents = [];
++ try {
++ const rows = await require('../agent-streams/service').listPublic({ liveOnly: true, limit: 24 });
++ agents = rows.map(require('../agent-streams/service').cardView);
++ } catch { agents = []; }
++ res.json({ streams: enriched, agents });
} catch (err) {
console.error('[Streams] List error:', err.message);
res.status(500).json({ error: 'Failed to list streams' });
ddiff --git a/test/agent-streams-s2.test.js b/test/agent-streams-s2.test.js
nnew file mode 100644
iindex 00000000..aaac9a2a
---- /dev/null
++++ b/test/agent-streams-s2.test.js
@@@ -0,0 +1,276 @@
++'use strict';
++/**
++ * Agent streams S2, Live's part (design 2026-11-06).
++ *
++ * - a publish ticket posts steps; the person's session does too, and a service needs live.agent_stream.publish;
++ * - one step a second, 2,000 per stream, a 120-character title, a 500-character detail, and the house text filters;
++ * - a public stream is in the agents list and the Agents live row; a private one is not; neither is in people's
++ * streams;
++ * - the Agents tab shows the owner their private and unlisted cards, and everyone else only public ones;
++ * - chapters are the steps that started, at their offset; a failed step (and the final passed one) can be clipped;
++ * - the overlay is drawn by the pages (never burned into video), and the phone layout is one line.
++ *
++ * Sign-in is stubbed with an x-test-user header (as agent-streams.test.js does); everything after that is production.
++ */
++const assert = require('assert');
++const fs = require('node:fs');
++const path = require('node:path');
++const http = require('http');
++const express = require('express');
++
++process.env.NODE_ENV = 'test';
++const quiet = console.log;
++console.log = (...a) => { if (!/^\[/.test(String(a[0]))) quiet(...a); };
++console.warn = () => {};
++console.error = () => {};
++
++const SID = {
++ ann: 'usr_01JAB2C3D4E5F6G7H8J9K0MNA1',
++ bob: 'usr_01JAB2C3D4E5F6G7H8J9K0MNB2',
++};
++
++let failures = 0;
++async function check(name, fn) {
++ try { await fn(); quiet(' ✓', name); }
++ catch (e) { failures++; quiet(' ✗', name, '\n ', (e.stack || String(e)).split('\n').slice(0, 8).join('\n ')); }
++}
++
++(async () => {
++ const db = require('../server/db/database');
++ await db.initDb();
++ const raw = db.getDb();
++ const addUser = async (id, username, role, extra = {}) => await raw.prepare(
++ `INSERT INTO users (id, username, display_name, email, password_hash, role, is_owner) OVERRIDING SYSTEM VALUE
++ VALUES (?, ?, ?, ?, 'x', ?, ?)`).run(id, username, username, `${username}@x`, role, extra.is_owner ? 1 : 0);
++ await addUser(1, 'ann', 'streamer');
++ await addUser(2, 'bob', 'user');
++ await raw.prepare("INSERT INTO linked_accounts (user_id, service, service_user_id, subject_id) VALUES (1, 'network', '101', ?)").run(SID.ann);
++ await raw.prepare("INSERT INTO linked_accounts (user_id, service, service_user_id, subject_id) VALUES (2, 'network', '102', ?)").run(SID.bob);
++
++ const config = require('../server/config');
++ config.baseUrl = 'https://openvibe.live';
++ const service = require('../server/agent-streams/service');
++ // The sharing system asks Network who a subject is; a private stream's own guests are not part of this test.
++ require('../server/agent-streams/sharing')._setNetwork({ json: async () => ({}) });
++ const tickets = require('../server/agent-streams/tickets');
++ const steps = require('../server/agent-streams/steps');
++ const auth = require('../server/agent-streams/auth');
++ const ann = await db.getUserById(1);
++ const bob = await db.getUserById(2);
++
++ const streamOf = (visibility, title) => service.create({
++ user: ann,
++ title,
++ task: { source: 'codes', ref: 'tsk_1', title: 'Run the checkout tests', url: 'https://openvibe.codes/tasks/1' },
++ visibility,
++ });
++ const publicStream = await streamOf('public', 'Fixing the checkout tests');
++ const privateStream = await streamOf('private', 'A private job');
++
++ const app = express();
++ app.use(express.json());
++ app.use((req, res, next) => { req.user = { 1: ann, 2: bob }[req.get('x-test-user')] || null; next(); });
++ app.use('/api/streams/agent', require('../server/agent-streams/routes'));
++ const server = await new Promise((ok) => { const s = http.createServer(app).listen(0, '127.0.0.1', () => ok(s)); });
++ const base = `http://127.0.0.1:${server.address().port}`;
++ const call = async (method, url, { as = 1, body, headers = {} } = {}) => {
++ const res = await fetch(`${base}${url}`, {
++ method,
++ headers: { 'Content-Type': 'application/json', 'x-test-user': String(as), ...headers },
++ body: body === undefined ? undefined : JSON.stringify(body),
++ });
++ const text = await res.text();
++ let data = {};
++ try { data = text ? JSON.parse(text) : {}; } catch { data = { text }; }
++ return { status: res.status, data };
++ };
++ const slack = async () => await db.run("UPDATE agent_stream_steps SET created_at = now() - interval '2 seconds'");
++
++ quiet('agent streams S2: the steps');
++ const publish = await tickets.issuePublish(publicStream.managed_stream_id);
++ await service.markPublishing(publicStream);
++ await check('a publish ticket posts a step and the contract answers it', async () => {
++ const r = await call('POST', `/api/streams/agent/${publicStream.public_id}/steps`, {
++ as: 2, headers: { Authorization: `Bearer ${publish.ticket}` },
++ body: { title: 'Run the checkout tests', status: 'started' },
++ });
++ assert.strictEqual(r.status, 201, JSON.stringify(r.data));
++ assert.strictEqual(r.data.step.title, 'Run the checkout tests');
++ assert.strictEqual(r.data.step.status, 'started');
++ assert.ok(r.data.step.at, 'the step carries its time');
++ });
++ await check('the person posts their own step, with tests and cost', async () => {
++ await slack();
++ const r = await call('POST', `/api/streams/agent/${publicStream.public_id}/steps`, {
++ body: { title: 'Checkout tests pass', status: 'passed', tests: { passed: 12, failed: 0 }, cost_usd: 0.42, detail: 'All 12 green' },
++ });
++ assert.strictEqual(r.status, 201, JSON.stringify(r.data));
++ assert.deepStrictEqual(r.data.step.tests, { passed: 12, failed: 0 });
++ assert.strictEqual(r.data.step.cost_usd, 0.42);
++ });
++ await check('a service needs live.agent_stream.publish; another stream is refused', async () => {
++ const anonymous = await call('POST', `/api/streams/agent/${publicStream.public_id}/steps`, {
++ as: 0, headers: { Authorization: 'Bearer not-a-service-token' }, body: { title: 'x', status: 'started' },
++ });
++ assert.ok(anonymous.status === 401 || anonymous.status === 403, JSON.stringify(anonymous.data));
++ const other = await call('POST', `/api/streams/agent/${publicStream.public_id}/steps`, { as: 2, body: { title: 'x', status: 'started' } });
++ assert.strictEqual(other.status, 403, JSON.stringify(other.data));
++ assert.strictEqual(other.data.code, 'not_your_stream');
++ assert.strictEqual(auth.CAPABILITY, 'live.agent_stream.publish');
++ assert.strictEqual(auth.holdsCapability({ sub: 'svc:codes', cap: ['chat.message.write'] }), false);
++ });
++ await check('a second step in the same second is refused', async () => {
++ const r = await call('POST', `/api/streams/agent/${publicStream.public_id}/steps`, { body: { title: 'Too fast', status: 'started' } });
++ assert.strictEqual(r.status, 429, JSON.stringify(r.data));
++ assert.strictEqual(r.data.code, 'steps_too_fast');
++ });
++ await check('the title and detail caps are enforced', async () => {
++ await slack();
++ const longTitle = await call('POST', `/api/streams/agent/${publicStream.public_id}/steps`, { body: { title: 't'.repeat(121), status: 'started' } });
++ assert.strictEqual(longTitle.status, 400);
++ assert.strictEqual(longTitle.data.code, 'invalid_title');
++ await slack();
++ const longDetail = await call('POST', `/api/streams/agent/${publicStream.public_id}/steps`, { body: { title: 'A step', status: 'started', detail: 'd'.repeat(501) } });
++ assert.strictEqual(longDetail.status, 400);
++ assert.strictEqual(longDetail.data.code, 'invalid_detail');
++ assert.strictEqual(steps.plain('a'.repeat(120), 120).length, 120);
++ assert.strictEqual(steps.plain('a'.repeat(121), 120), null);
++ });
++ await check('the house text filters refuse a step', async () => {
++ await slack();
++ const r = await call('POST', `/api/streams/agent/${publicStream.public_id}/steps`, { body: { title: 'my exact location', status: 'started' } });
++ assert.strictEqual(r.status, 422, JSON.stringify(r.data));
++ assert.strictEqual(r.data.code, 'text_rejected');
++ });
++ await check('2,000 steps is the cap', async () => {
++ await db.run(`INSERT INTO agent_stream_steps (managed_stream_id, at, title, status)
++ SELECT ?, now(), 'step ' || n, 'passed' FROM generate_series(1, 2000) AS n`, [privateStream.managed_stream_id]);
++ await service.markPublishing(privateStream);
++ const r = await call('POST', `/api/streams/agent/${privateStream.public_id}/steps`, { body: { title: 'One too many', status: 'started' } });
++ assert.strictEqual(r.status, 429, JSON.stringify(r.data));
++ assert.strictEqual(r.data.code, 'too_many_steps');
++ });
++ await check('an ended stream takes no more steps', async () => {
++ const ended = await streamOf('private', 'A finished job');
++ await service.markPublishing(ended);
++ await service.end(ended);
++ const r = await call('POST', `/api/streams/agent/${ended.public_id}/steps`, { body: { title: 'After the end', status: 'passed' } });
++ assert.strictEqual(r.status, 409, JSON.stringify(r.data));
++ });
++
++ quiet('agent streams S2: the overlay and the chapters');
++ await check('the overlay is drawn by the page from the steps feed, never burned in', async () => {
++ const overlay = fs.readFileSync(path.join(__dirname, '../public/js/agent-overlay.js'), 'utf8');
++ assert.match(overlay, /agent-overlay-dot is-\$\{cur\.status\}/);
++ assert.match(overlay, /textContent = cur\.title/);
++ assert.match(overlay, /slice\(-12\)/, 'the timeline is the last twelve steps');
++ assert.match(overlay, /FOLD_KEY/, 'the fold is remembered');
++ const fragment = fs.readFileSync(path.join(__dirname, '../public/fragments/agent-stream.html'), 'utf8');
++ assert.match(fragment, /id="agent-overlay"/);
++ const embed = fs.readFileSync(path.join(__dirname, '../server/agent-streams/pages.js'), 'utf8');
++ assert.match(embed, /agent-overlay\.js/);
++ const css = fs.readFileSync(path.join(__dirname, '../public/css/features/agent-stream.css'), 'utf8');
++ assert.match(css, /@media \(max-width: 640px\)/, 'the phone layout is one line');
++ });
++ await check('chapters are the steps that started, at their offset', async () => {
++ const chapters = await service.create({
++ user: ann,
++ title: 'A chaptered run',
++ task: { source: 'actor', ref: 'run_1', title: 'Chaptered' },
++ visibility: 'public',
++ });
++ const startedAt = new Date(Date.now() - 10 * 60 * 1000);
++ await db.run('UPDATE agent_streams SET started_at = ?, state = ? WHERE managed_stream_id = ?', [startedAt, 'ended', chapters.managed_stream_id]);
++ await db.run('UPDATE agent_streams SET vod_id = ? WHERE managed_stream_id = ?', ['512', chapters.managed_stream_id]);
++ await db.run(`INSERT INTO agent_stream_steps (managed_stream_id, at, title, status) VALUES
++ (?, ?, 'Install dependencies', 'started'),
++ (?, ?, 'Run the tests', 'started'),
++ (?, ?, 'Run the tests', 'passed')`,
++ [chapters.managed_stream_id, new Date(startedAt.getTime() + 60000), chapters.managed_stream_id,
++ new Date(startedAt.getTime() + 180000), chapters.managed_stream_id, new Date(startedAt.getTime() + 240000)]);
++ const r = await call('GET', `/api/streams/agent/${chapters.public_id}/chapters`);
++ assert.strictEqual(r.status, 200, JSON.stringify(r.data));
++ assert.deepStrictEqual(r.data.chapters.map((c) => c.at), [60, 180]);
++ assert.deepStrictEqual(r.data.chapters.map((c) => c.title), ['Install dependencies', 'Run the tests']);
++ assert.strictEqual(r.data.vod_id, '512');
++ });
++ await check('a failed step and the final passed step can be clipped, through Media', async () => {
++ const media = require('../server/media-client');
++ const original = media.createClip;
++ const asked = [];
++ media.createClip = async (args) => { asked.push(args); return { id: 77, status: 'queued' }; };
++ try {
++ const chapters = await db.get("SELECT ms.public_id, ms.id AS managed_stream_id FROM managed_streams ms JOIN agent_streams a ON a.managed_stream_id = ms.id WHERE a.vod_id = '512'");
++ const rows = await db.all('SELECT id, status FROM agent_stream_steps WHERE managed_stream_id = ? ORDER BY id', [chapters.managed_stream_id]);
++ const started = rows.find((r) => r.status === 'started');
++ const passed = rows.find((r) => r.status === 'passed');
++ const refused = await call('POST', `/api/streams/agent/${chapters.public_id}/clips`, { body: { step_id: started.id } });
++ assert.strictEqual(refused.status, 409, JSON.stringify(refused.data));
++ assert.strictEqual(refused.data.code, 'not_clippable');
++ const made = await call('POST', `/api/streams/agent/${chapters.public_id}/clips`, { body: { step_id: passed.id } });
++ assert.strictEqual(made.status, 201, JSON.stringify(made.data));
++ assert.strictEqual(asked.length, 1);
++ assert.strictEqual(asked[0].vod_id, '512');
++ assert.ok(asked[0].end_s > asked[0].start_s);
++ } finally { media.createClip = original; }
++ });
++
++ quiet('agent streams S2: public streams and the Agents tab');
++ await check('a public stream is listed, a private one is not, and neither is in people\'s streams', async () => {
++ const list = await call('GET', '/api/streams/agent?live=1', { as: 2 });
++ assert.strictEqual(list.status, 200, JSON.stringify(list.data));
++ const ids = list.data.agents.map((a) => a.id);
++ assert.ok(ids.includes(publicStream.public_id), 'the public live stream is listed');
++ assert.ok(!ids.includes(privateStream.public_id), 'the private one is not');
++ const card = list.data.agents.find((a) => a.id === publicStream.public_id);
++ assert.strictEqual(card.label, 'AI agent');
++ assert.strictEqual(card.chat_room, `agent:${publicStream.public_id}`, 'a public stream names its chat room');
++ const people = await db.getLiveStreams();
++ assert.ok(!people.some((s) => s.managed_stream_id === publicStream.managed_stream_id), 'never mixed into people\'s streams');
++ });
++ await check('the channel Agents tab shows the owner everything, everyone else only public cards', async () => {
++ const own = await call('GET', `/api/streams/agent?channel=ann`, { as: 1 });
++ const ids = own.data.agents.map((a) => a.id);
++ assert.ok(ids.includes(publicStream.public_id) && ids.includes(privateStream.public_id), 'the owner sees both');
++ const other = await call('GET', `/api/streams/agent?channel=ann`, { as: 2 });
++ const otherIds = other.data.agents.map((a) => a.id);
++ assert.ok(otherIds.includes(publicStream.public_id));
++ assert.ok(!otherIds.includes(privateStream.public_id), 'a private card is not shown to a stranger');
++ });
++ await check('a private stream\'s steps stay private', async () => {
++ const r = await call('GET', `/api/streams/agent/${privateStream.public_id}/steps`, { as: 2 });
++ assert.strictEqual(r.status, 404);
++ const own = await call('GET', `/api/streams/agent/${privateStream.public_id}/steps`, { as: 1 });
++ assert.strictEqual(own.status, 200);
++ assert.ok(own.data.steps.length === 20, 'the newest 20');
++ });
++ await check('the home row and the channel tab exist in the markup and the scripts', async () => {
++ const home = fs.readFileSync(path.join(__dirname, '../public/fragments/home.html'), 'utf8');
++ assert.match(home, /id="home-agents"/);
++ assert.match(home, /Agents live/);
++ const channel = fs.readFileSync(path.join(__dirname, '../public/fragments/channel.html'), 'utf8');
++ assert.match(channel, /data-tab="agents"/);
++ assert.match(channel, /id="ch-panel-agents"/);
++ const cards = fs.readFileSync(path.join(__dirname, '../public/js/agent-cards.js'), 'utf8');
++ assert.match(cards, /chat_room|current_step/);
++ const homeJs = fs.readFileSync(path.join(__dirname, '../public/js/app-home.js'), 'utf8');
++ assert.match(homeJs, /function renderHomeAgents/);
++ const features = JSON.parse(fs.readFileSync(path.join(__dirname, '../public/features.json'), 'utf8'));
++ assert.ok(features.features.channel.js.includes('/js/agent-cards.js'));
++ });
++ await check('the sitemap and llms.txt name a public agent stream while it is live', async () => {
++ const discovery = require('../server/seo/discovery');
++ const xml = await discovery.buildSitemap([]);
++ assert.ok(String(xml).includes(`/@ann/agents/${publicStream.public_id}`), 'the live public agent page is in the sitemap');
++ assert.ok(!String(xml).includes(`/@ann/agents/${privateStream.public_id}`), 'the private one is not');
++ const llms = await discovery.llmsTxt();
++ assert.ok(String(llms).includes(`/@ann/agents/${publicStream.public_id}`));
++ const after = await discovery.buildSitemap([]);
++ assert.ok(String(after).includes('/agents/') && String(after).includes(`/@ann/agents/`), 'an ended public stream with a recording stays');
++ });
++
++ server.close();
++ await db.closeDb?.();
++ quiet(failures ? `\n${failures} check(s) failed` : '\nAll agent stream S2 checks passed');
++ process.exit(failures ? 1 : 0);
++})().catch((e) => { quiet(e.stack || String(e)); process.exit(1); });
ddiff --git a/test/agent-streams.test.js b/test/agent-streams.test.js
iindex a3fc3bfe..9794b1a6 100644
---- a/test/agent-streams.test.js
++++ b/test/agent-streams.test.js
@@@ -321,9 +321,10 @@ async function check(name, fn) {
// End asks in the page; a browser dialog is never used.
assert.match(fragment, /id="agent-end-confirm"/);
assert.ok(!/\bconfirm\(/.test(page), 'no confirm()');
-- // Public is disabled, with the note that says where public agent streams come from.
-- assert.match(fragment, /<option value="public" disabled/);
-- assert.match(fragment, /Public agent streams come with the Agents tab\./);
++ // S2 turns Public on: the owner's switch lists the Agents tab and the Agents live row as its home.
++ assert.match(fragment, /<option value="public">Everyone/);
++ assert.doesNotMatch(fragment, /<option value="public"[^>]*disabled/);
++ assert.match(fragment, /Agents tab and Agents live/);
// The Reset link hint says the old links stop working.
assert.match(page, /old links stop working/);
});