openvibers/openvibe.live Commits
Agent Streams S2: steps, the overlay, public streams and chapters (#630)
Changes
@@@ -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": [@@@ -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. ItsCSP `frame-ancestors` is the first-party origins only (Live, Network, Codes, Actor, Node, Tools), never `*`. Athird-party iframe has no Live cookies, so Codes, Actor or the desk mints the view ticket server-side with the person'sown 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.nnew file mode 100644@@@ -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';@@@ -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",@@@ -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",@@@ -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)); }@@@ -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; }@@@ -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"]@@@ -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>@@@ -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">@@@ -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">nnew file mode 100644@@@ -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 };++})();@@@ -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);nnew file mode 100644@@@ -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 };++})();@@@ -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 nothingfunction $(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');@@@ -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;@@@ -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() {@@@ -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 beforehtmlBrotliKB: 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)};@@@ -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>`;@@@ -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) => {@@@ -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_ownerFROM 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,};nnew file mode 100644@@@ -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,++};@@@ -213,7 +213,7 @@ async function getLiveStreams() {FROM streams sJOIN users u ON s.user_id = u.idLEFT 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 sJOIN users u ON s.user_id = u.idLEFT 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]);}@@@ -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/. */@@@ -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' });nnew file mode 100644@@@ -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); });@@@ -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/);});
@@ -5,7 +5,7 @@
@@ -5,7 +5,7 @@
"runtime": "node 22, express, PostgreSQL, node-media-server, mediasoup; no build step",
"runtime": "node 22, express, PostgreSQL, node-media-server, mediasoup; no build step",
"port": 3000,
"port": 3000,
"domain": "openvibe.live (www, ingest, whip)",
"domain": "openvibe.live (www, ingest, whip)",
- "contracts": "openvibe-contracts v0.298.0",
+ "contracts": "openvibe-contracts v0.299.0",
"sdk": "openvibe-sdk v0.91.0",
"sdk": "openvibe-sdk v0.91.0",
"shared": "openvibe-shared v4.118.0",
"shared": "openvibe-shared v4.118.0",
"capabilities": [
"capabilities": [
@@ -110,16 +110,56 @@ detectors report clear for two seconds, or from the owner's own button.
@@ -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
- **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
(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
- **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
`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
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
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.
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.
new file mode 100644
new file mode 100644
@@ -0,0 +1,27 @@
@@ -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';
@@ -23,7 +23,7 @@
@@ -23,7 +23,7 @@
"multer": "^1.4.5-lts.1",
"multer": "^1.4.5-lts.1",
"node-media-server": "^2.7.1",
"node-media-server": "^2.7.1",
"onnxruntime-node": "^1.20.0",
"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-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",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.118.0",
"pg": "^8.23.1",
"pg": "^8.23.1",
@@ -2840,9 +2840,9 @@
@@ -2840,9 +2840,9 @@
}
}
},
},
"node_modules/openvibe-contracts": {
"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",
"license": "AGPL-3.0-only",
"dependencies": {
"dependencies": {
"ajv": "^8.17.1",
"ajv": "^8.17.1",
@@ -42,7 +42,7 @@
@@ -42,7 +42,7 @@
"multer": "^1.4.5-lts.1",
"multer": "^1.4.5-lts.1",
"node-media-server": "^2.7.1",
"node-media-server": "^2.7.1",
"onnxruntime-node": "^1.20.0",
"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-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",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.118.0",
"pg": "^8.23.1",
"pg": "^8.23.1",
@@ -54,3 +54,99 @@
@@ -54,3 +54,99 @@
.agent-embed .agent-embed-state[hidden] { display: none; }
.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 { 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; }
.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)); }
@@ -1390,3 +1390,15 @@ body.egg-rainbow { animation: eggRainbow 2.6s linear; }
@@ -1390,3 +1390,15 @@ body.egg-rainbow { animation: eggRainbow 2.6s linear; }
}
}
.home-tabbox[data-tab=""] .home-tabs { margin-bottom: 0; }
.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; }
@@ -104,7 +104,8 @@
@@ -104,7 +104,8 @@
"fragment": "channel",
"fragment": "channel",
"css": [
"css": [
"/css/features/channel.css",
"/css/features/channel.css",
- "/shared/kit.css"
+ "/shared/kit.css",
+ "/css/features/agent-stream.css"
],
],
"deps": [
"deps": [
"player",
"player",
@@ -118,7 +119,8 @@
@@ -118,7 +119,8 @@
"/js/channel-chat-resize.js",
"/js/channel-chat-resize.js",
"/js/channel-support.js",
"/js/channel-support.js",
"/js/app-channel.js",
"/js/app-channel.js",
- "/js/bench-stream-watch.js"
+ "/js/bench-stream-watch.js",
+ "/js/agent-cards.js"
],
],
"stubs": [
"stubs": [
"toggleChannelChat"
"toggleChannelChat"
@@ -318,6 +320,7 @@
@@ -318,6 +320,7 @@
],
],
"js": [
"js": [
"/js/agent-player.js",
"/js/agent-player.js",
+ "/js/agent-overlay.js",
"/js/agent-stream.js",
"/js/agent-stream.js",
"/shared/ov-share.js"
"/shared/ov-share.js"
]
]
@@ -12,8 +12,19 @@
@@ -12,8 +12,19 @@
<span class="agent-label agent-player-label">AI agent</span>
<span class="agent-label agent-player-label">AI agent</span>
<div class="agent-state" id="agent-state">Loading…</div>
<div class="agent-state" id="agent-state">Loading…</div>
<div class="agent-curtain" id="agent-curtain" hidden>Private screen</div>
<div class="agent-curtain" id="agent-curtain" hidden>Private screen</div>
+ <div id="agent-overlay"></div>
</div>
</div>
<p class="agent-status-line" id="agent-status-line">Loading…</p>
<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>
</div>
<aside class="agent-side" id="agent-owner" hidden>
<aside class="agent-side" id="agent-owner" hidden>
<h2 class="agent-side-title">This stream</h2>
<h2 class="agent-side-title">This stream</h2>
@@ -22,9 +33,9 @@
@@ -22,9 +33,9 @@
<select id="agent-visibility" class="agent-select">
<select id="agent-visibility" class="agent-select">
<option value="private">Only me and people I share it with</option>
<option value="private">Only me and people I share it with</option>
<option value="unlisted">Anyone with the link</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>
</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>
<div class="agent-actions">
<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>
<button type="button" class="btn btn-outline" data-ov-share-sheet id="agent-share" data-title="An AI agent's screen">Share</button>
@@ -366,6 +366,7 @@
@@ -366,6 +366,7 @@
<div class="ch-tabs" id="ch-tabs" role="tablist" aria-label="Channel sections">
<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" 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 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" 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-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>
<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 @@
@@ -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>
<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>
</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 -->
<!-- Videos -->
<div class="ch-tab-panel active" id="ch-panel-videos" role="tabpanel">
<div class="ch-tab-panel active" id="ch-panel-videos" role="tabpanel">
<div class="ch-panel-bar">
<div class="ch-panel-bar">
@@ -104,6 +104,13 @@
@@ -104,6 +104,13 @@
</div>
</div>
</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,
<!-- 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"). -->
2026-10-31: "don't reduce size by removing features"). -->
<div class="home-around" id="home-around">
<div class="home-around" id="home-around">
new file mode 100644
new file mode 100644
@@ -0,0 +1,97 @@
@@ -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 };
+})();
@@ -4,7 +4,7 @@
@@ -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
* 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.
* 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 () {
(function () {
'use strict';
'use strict';
const body = document.body;
const body = document.body;
@@ -14,6 +14,11 @@
@@ -14,6 +14,11 @@
let ticket = new URLSearchParams(location.search).get('vt') || '';
let ticket = new URLSearchParams(location.search).get('vt') || '';
let player = null;
let player = null;
let renewTimer = null;
let renewTimer = null;
+ let stepsTimer = null;
+ let steps = [];
+ let overlay = null;
+ let streamState = null;
+ let streamView = null;
async function json(path, options) {
async function json(path, options) {
const res = await fetch(path, { credentials: 'omit', headers: { 'Content-Type': 'application/json' }, ...options });
const res = await fetch(path, { credentials: 'omit', headers: { 'Content-Type': 'application/json' }, ...options });
@@ -34,11 +39,38 @@
@@ -34,11 +39,38 @@
}, 8 * 60 * 1000);
}, 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() {
async function load() {
const query = ticket ? `?vt=${encodeURIComponent(ticket)}` : '';
const query = ticket ? `?vt=${encodeURIComponent(ticket)}` : '';
const data = await json(`/api/streams/agent/${encodeURIComponent(id)}${query}`);
const data = await json(`/api/streams/agent/${encodeURIComponent(id)}${query}`);
const stream = data.stream;
const stream = data.stream;
ticket = data.view_ticket || ticket;
ticket = data.view_ticket || ticket;
+ streamState = stream.state;
+ streamView = stream;
const title = $('agent-embed-title');
const title = $('agent-embed-title');
if (title) title.textContent = stream.title || '';
if (title) title.textContent = stream.title || '';
@@ -68,6 +100,12 @@
@@ -68,6 +100,12 @@
stateEl.textContent = stream.state === 'ended' ? 'This agent’s work has ended.' : "This agent isn't streaming right now.";
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();
renewSoon();
// The state changes on the server (the desk starts publishing, or the work ends); look again in a minute.
// 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);
setTimeout(() => { if (!player && document.visibilityState !== 'hidden') load().catch(() => {}); }, 60000);
new file mode 100644
new file mode 100644
@@ -0,0 +1,193 @@
@@ -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 };
+})();
@@ -18,6 +18,10 @@
@@ -18,6 +18,10 @@
let liveViewers = null;
let liveViewers = null;
let renewTimer = null;
let renewTimer = null;
let pollTimer = 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
let generation = 0; // bumped on every page leave, so a slow request from the last page changes nothing
function $(id) { return document.getElementById(id); }
function $(id) { return document.getElementById(id); }
@@ -29,6 +33,10 @@
@@ -29,6 +33,10 @@
liveViewers = null;
liveViewers = null;
if (renewTimer) { clearTimeout(renewTimer); renewTimer = null; }
if (renewTimer) { clearTimeout(renewTimer); renewTimer = null; }
if (pollTimer) { clearTimeout(pollTimer); pollTimer = 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;
state = null;
ticket = null;
ticket = null;
}
}
@@ -115,6 +123,143 @@
@@ -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) {
function render(data) {
state = data;
state = data;
ticket = data.view_ticket || ticket;
ticket = data.view_ticket || ticket;
@@ -141,9 +286,13 @@
@@ -141,9 +286,13 @@
renderCurtain(!!(stream.curtain && stream.curtain.up));
renderCurtain(!!(stream.curtain && stream.curtain.up));
renderStatus(stream, liveViewers);
renderStatus(stream, liveViewers);
mountPlays(data);
mountPlays(data);
+ ensureOverlay(data);
renderOwner(data);
renderOwner(data);
scheduleRenew(data);
scheduleRenew(data);
schedulePoll(stream.id);
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) {
function renderOwner(data) {
@@ -215,6 +364,7 @@
@@ -215,6 +364,7 @@
if (structural) { render(next); return; }
if (structural) { render(next); return; }
state = next;
state = next;
ticket = next.view_ticket || ticket;
ticket = next.view_ticket || ticket;
+ ensureOverlay(next);
renderStatus(next.stream, liveViewers);
renderStatus(next.stream, liveViewers);
renderOwner(next);
renderOwner(next);
schedulePoll(id);
schedulePoll(id);
@@ -302,6 +452,15 @@
@@ -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) {
async function loadAgentStreamPage(username, publicId) {
stop();
stop();
const owner = $('agent-owner');
const owner = $('agent-owner');
@@ -818,6 +818,10 @@ async function loadChannelPage(username, managedStreamRef = null, legacySessionI
@@ -818,6 +818,10 @@ async function loadChannelPage(username, managedStreamRef = null, legacySessionI
_applyBotEmbed(data.bot_embed, username);
_applyBotEmbed(data.bot_embed, username);
_offerBotBind(username);
_offerBotBind(username);
_applyChannelHashTab(); // deep-link: #ai-timeline / #about / #videos … opens that tab
_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).
// Reveal the Media Request tab if the streamer has it enabled (non-blocking).
_initMediaRequestTab(username);
_initMediaRequestTab(username);
@@ -1973,6 +1977,49 @@ function _renderChStreamAi(overview, short) {
@@ -1973,6 +1977,49 @@ function _renderChStreamAi(overview, short) {
// ── Channel below-fold tabs ──────────────────────────────────
// ── Channel below-fold tabs ──────────────────────────────────
let _channelTab = 'videos';
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) {
function switchChannelTab(tab, btn) {
_channelTab = tab;
_channelTab = tab;
document.querySelectorAll('#ch-tabs .ch-tab').forEach(b => {
document.querySelectorAll('#ch-tabs .ch-tab').forEach(b => {
@@ -1996,6 +2043,9 @@ function switchChannelTab(tab, btn) {
@@ -1996,6 +2043,9 @@ function switchChannelTab(tab, btn) {
_chTabLoaded.clipsTaken = true;
_chTabLoaded.clipsTaken = true;
try { loadClipsTaken(currentChannelUsername, { reset: true }); } catch {}
try { loadClipsTaken(currentChannelUsername, { reset: true }); } catch {}
}
}
+ if (tab === 'agents' && currentChannelUsername) {
+ try { loadChannelAgents(currentChannelUsername); } catch {}
+ }
if (tab === 'ai-timeline' && currentChannelUsername && !_chTabLoaded.aiTimeline) {
if (tab === 'ai-timeline' && currentChannelUsername && !_chTabLoaded.aiTimeline) {
_chTabLoaded.aiTimeline = true;
_chTabLoaded.aiTimeline = true;
try { loadChannelAiTimeline(currentChannelUsername); } catch {}
try { loadChannelAiTimeline(currentChannelUsername); } catch {}
@@ -2030,7 +2080,7 @@ async function _loadGameBadge(username) {
@@ -2030,7 +2080,7 @@ async function _loadGameBadge(username) {
// A URL hash like #ai-timeline / #about / #videos auto-opens that channel tab on load and
// 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.).
// 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() {
function _applyChannelHashTab() {
const h = (location.hash || '').replace(/^#/, '').toLowerCase();
const h = (location.hash || '').replace(/^#/, '').toLowerCase();
if (!h || !CHANNEL_TAB_HASHES.includes(h)) return;
if (!h || !CHANNEL_TAB_HASHES.includes(h)) return;
@@ -2092,8 +2092,9 @@ async function refreshHomeLive() {
@@ -2092,8 +2092,9 @@ async function refreshHomeLive() {
// A background tab has nobody to show a refreshed grid to. refreshHomeSections() already
// A background tab has nobody to show a refreshed grid to. refreshHomeSections() already
// skips in that case; this one polled /api/streams forever regardless.
// skips in that case; this one polled /api/streams forever regardless.
if (document.hidden) return;
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');
const grid = document.getElementById('stream-grid-live');
if (!grid) return;
if (!grid) return;
const countEl = document.getElementById('live-count');
const countEl = document.getElementById('live-count');
@@ -2129,6 +2130,46 @@ async function refreshHomeLive() {
@@ -2129,6 +2130,46 @@ async function refreshHomeLive() {
_homeTopicsSync(streams);
_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
// Refresh the paginated home sections (only at page 1, only when visible — so we
// never disrupt someone paging through or reading below the fold).
// never disrupt someone paging through or reading below the fold).
function refreshHomeSections() {
function refreshHomeSections() {
@@ -19,11 +19,11 @@ const ROOT = path.join(__dirname, '..', '..');
@@ -19,11 +19,11 @@ const ROOT = path.join(__dirname, '..', '..');
// openvibe-shared/perf-budget names; "js" is the eager scripts (incl. route scripts for "/"),
// openvibe-shared/perf-budget names; "js" is the eager scripts (incl. route scripts for "/"),
// "css" the render-blocking stylesheets.
// "css" the render-blocking stylesheets.
const BUDGETS = {
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,
htmlBrotliKB: 30,
jsFiles: 26, // measured 23 (incl. route scripts for "/")
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)
cssRawKB: 270, // measured ~243 since the first-paint subset (style.core.css) replaced style.css (~717 before)
cssBrotliKB: 55, // measured ~47.5 (was ~130)
cssBrotliKB: 55, // measured ~47.5 (was ~130)
};
};
@@ -64,10 +64,12 @@ function html(publicId) {
@@ -64,10 +64,12 @@ function html(publicId) {
<span class="agent-embed-viewers" id="agent-embed-viewers" hidden></span>
<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-state" id="agent-embed-state">Connecting…</div>
<div class="agent-embed-curtain" id="agent-embed-curtain" hidden>Private screen</div>
<div class="agent-embed-curtain" id="agent-embed-curtain" hidden>Private screen</div>
+ <div id="agent-embed-overlay"></div>
</div>
</div>
</div>
</div>
</div>
</div>
<script src="/js/agent-player.js" defer></script>
<script src="/js/agent-player.js" defer></script>
+ <script src="/js/agent-overlay.js" defer></script>
<script src="/js/agent-embed.js" defer></script>
<script src="/js/agent-embed.js" defer></script>
</body>
</body>
</html>`;
</html>`;
@@ -23,6 +23,7 @@ const auth = require('./auth');
@@ -23,6 +23,7 @@ const auth = require('./auth');
const service = require('./service');
const service = require('./service');
const tickets = require('./tickets');
const tickets = require('./tickets');
const frames = require('./frames');
const frames = require('./frames');
+const steps = require('./steps');
const router = express.Router();
const router = express.Router();
@@ -99,6 +100,26 @@ async function deskOrOwner(req, res, next) {
@@ -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 ─────────────────────────────────────────────────────
// ── Create ─────────────────────────────────────────────────────
router.post('/', auth.publisher, async (req, res) => {
router.post('/', auth.publisher, async (req, res) => {
@@ -125,6 +146,19 @@ 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 ───────────────────────────────────────────────────────
// ── Read ───────────────────────────────────────────────────────
router.get('/:id', auth.viewer, async (req, res) => {
router.get('/:id', auth.viewer, async (req, res) => {
@@ -306,6 +340,89 @@ router.post('/:id/end', deskOrOwner, 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 ───────────────────────────────────────────────
// ── View tickets ───────────────────────────────────────────────
router.post('/:id/view-ticket', auth.viewer, async (req, res) => {
router.post('/:id/view-ticket', auth.viewer, async (req, res) => {
@@ -76,6 +76,8 @@ const SELECT = `
@@ -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.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,
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 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,
(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
u.username, u.display_name, u.avatar_url, u.profile_color, u.is_banned, u.is_owner
FROM managed_streams ms
FROM managed_streams ms
@@ -159,9 +161,53 @@ function publicView(row) {
@@ -159,9 +161,53 @@ function publicView(row) {
vod_id: row.vod_id || null,
vod_id: row.vod_id || null,
page_url: pageUrl(row),
page_url: pageUrl(row),
label: 'AI agent',
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 ───────────────────────────────────────────────────
// ── Creating ───────────────────────────────────────────────────
/**
/**
@@ -349,7 +395,7 @@ module.exports = {
@@ -349,7 +395,7 @@ module.exports = {
PUBLISH_TICKET_TTL_MS, VIEW_TICKET_TTL_MS, MAX_FRAME_BYTES,
PUBLISH_TICKET_TTL_MS, VIEW_TICKET_TTL_MS, MAX_FRAME_BYTES,
sha256, token, agentId, iso, pageUrl, whipUrl, shareRef,
sha256, token, agentId, iso, pageUrl, whipUrl, shareRef,
getByPublicId, getByManagedStreamId, liveSession, listByUser, countLiveByUser, liveLimit, ownerSubject,
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,
create, update, markPublishing, end, setVisibility, rotateLink, setCurtain, setFrame, setVod,
mayView,
mayView,
};
};
new file mode 100644
new file mode 100644
@@ -0,0 +1,180 @@
@@ -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,
+};
@@ -213,7 +213,7 @@ async function getLiveStreams() {
@@ -213,7 +213,7 @@ async function getLiveStreams() {
FROM streams s
FROM streams s
JOIN users u ON s.user_id = u.id
JOIN users u ON s.user_id = u.id
LEFT JOIN managed_streams ms ON s.managed_stream_id = ms.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
ORDER BY s.viewer_count DESC, s.started_at DESC
`);
`);
}
}
@@ -265,9 +265,12 @@ async function getStreamById(id) {
@@ -265,9 +265,12 @@ async function getStreamById(id) {
}
}
async function getStreamByUserId(userId) {
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(`
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]);
`, [userId]);
}
}
@@ -296,7 +299,7 @@ async function getLiveStreamsByUserId(userId) {
@@ -296,7 +299,7 @@ async function getLiveStreamsByUserId(userId) {
FROM streams s
FROM streams s
JOIN users u ON s.user_id = u.id
JOIN users u ON s.user_id = u.id
LEFT JOIN managed_streams ms ON s.managed_stream_id = ms.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
ORDER BY s.started_at DESC
`, [userId]);
`, [userId]);
}
}
@@ -102,6 +102,19 @@ async function buildSitemap(liveStreams = []) {
@@ -102,6 +102,19 @@ async function buildSitemap(liveStreams = []) {
if (onAir.has(u)) row.videos = [onAir.get(u)];
if (onAir.has(u)) row.videos = [onAir.get(u)];
urls.push(row);
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);
return seo.sitemapXml(urls);
}
}
async function sitemapHandler(req, res) {
async function sitemapHandler(req, res) {
@@ -140,8 +153,12 @@ const _docsPresent = () => LLMS_DOCS.filter(([name]) => { try { return fs.exists
@@ -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'
// 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.
// 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();
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({
return seo.llmsTxt({
name: SITE_NAME,
name: SITE_NAME,
markdown: true,
markdown: true,
@@ -168,6 +185,11 @@ function llmsTxt() {
@@ -168,6 +185,11 @@ function llmsTxt() {
{ title: 'What the AI made (AI Moments)', links: [
{ 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.` },
{ 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 and docs', links: [
{ title: 'API docs', url: `${b}/documentation`, note: 'every feature has an open API (chat, streams, clips, overlays, robots, sound commands)' },
{ 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` },
{ title: 'Docs index', url: `${b}/docs` },
@@ -203,10 +225,16 @@ function llmsFullTxt() {
@@ -203,10 +225,16 @@ function llmsFullTxt() {
return _llmsFull;
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/. */
/** Mounted before express.static, so /robots.txt here wins over any file in public/. */
@@ -1218,7 +1218,14 @@ router.get('/', optionalAuth, async (req, res) => {
@@ -1218,7 +1218,14 @@ router.get('/', optionalAuth, async (req, res) => {
total_viewer_count: (s.viewer_count || 0) + externalTotal,
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) {
} catch (err) {
console.error('[Streams] List error:', err.message);
console.error('[Streams] List error:', err.message);
res.status(500).json({ error: 'Failed to list streams' });
res.status(500).json({ error: 'Failed to list streams' });
new file mode 100644
new file mode 100644
@@ -0,0 +1,276 @@
@@ -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); });
@@ -321,9 +321,10 @@ async function check(name, fn) {
@@ -321,9 +321,10 @@ async function check(name, fn) {
// End asks in the page; a browser dialog is never used.
// End asks in the page; a browser dialog is never used.
assert.match(fragment, /id="agent-end-confirm"/);
assert.match(fragment, /id="agent-end-confirm"/);
assert.ok(!/\bconfirm\(/.test(page), 'no 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.
// The Reset link hint says the old links stop working.
assert.match(page, /old links stop working/);
assert.match(page, /old links stop working/);
});
});