openvibers/openvibe.codes Commits
The Dock's agent view: the person's desk stream, with the view ticket minted here (K-D3) (#289)
Changes
@@@ -47,6 +47,7 @@ const { createOutbox, createPublisher } = require('./events/outbox');const { createEventsConsumer } = require('./events-consumer');const { createApi } = require('./http/api');const { createMeApi } = require('./http/me-api');++const { createAgentView } = require('./http/agent-view');const { createKnowledgeApi } = require('./http/knowledge-api');const { createMovedRoutes } = require('./http/moved');const { createCodesReadiness } = require('./observability');@@@ -256,6 +257,8 @@ async function createApp(opts = {}) {app.use('/api/v1', createApi(ctx));app.use('/api/v1', createTasksApi({ config, store, keys, run, harnesses, tasks, fleet: ctx.fleet, actorLimits: ctx.actorLimits, log }));app.use('/api/v1', createMeApi({ config, store, sso, run, harnesses, tasks, github: ctx.github, actorLimits: ctx.actorLimits, log }));++ // The Dock's agent view (Dock K-D3, Agent Streams S1): the person's own desk stream with a view ticket minted here.++ app.use('/api/v1', createAgentView({ config, sso, actorLimits: ctx.actorLimits, log }));// Know K5: the person's own agents read and add workspace knowledge with their token (the ov-know MCP server).app.use('/api/v1', createKnowledgeApi({ config, store, knowledge: ctx.knowledge, actorLimits: ctx.actorLimits, log }));// A person's connections: their own provider keys (stored in OpenVibe.AI, never here) and their machines' plans.@@@ -60,6 +60,9 @@ function load(env = process.env) {clientSecret: env.CODES_FORGE_CLIENT_SECRET || '',sealKey: env.CODES_SEAL_KEY || '',},++ // OpenVibe.Live (Agent Streams S1, Dock K-D3): the person's own agent streams, read and steered with their own++ // token for the Dock's agent view (server/http/agent-view.js). Codes holds no token of its own here.++ live: { url: trim(env.CODES_LIVE_URL || 'https://openvibe.live') },// The audience of the Network service tokens a first-party service calls /api/v1/tasks with (codes.task.*).serviceAudience: env.CODES_AUDIENCE || 'openvibe.codes',oauth: {@@@ -40,6 +40,8 @@ const BUDGETS = {'codes.repo.search': { minute: 6, hour: 60 },// An agent adds what it learned through the ov-know MCP; a run may add a handful, not a flood.'codes.knowledge.write': { minute: 30, hour: 600 },++ // The Dock's agent view controls (K-D3): Curtain now and End. The owner presses them now and then, never in a burst.++ 'codes.agent_view.control': { minute: 20, hour: 120 },};/**nnew file mode 100644@@@ -0,0 +1,189 @@++'use strict';++++/**++ * The Dock's agent view (Dock plan 2026-10-30, K-D3; Agent Streams S1): the signed-in person's own agent desk stream,++ * for the Dock that Shared draws on every OpenVibe site.++ *++ * GET /api/v1/me/agent-view → { agent } the live stream, with a view ticket, or the last one as a still++ * GET /api/v1/me/agent-view/frame → the last frame (JPEG or WebP), the picture when nothing is live++ * POST /api/v1/me/agent-view/curtain → { agent } { up } → Live's curtain, raised as the owner's own button++ * POST /api/v1/me/agent-view/end → { agent } Live's end++ *++ * The person's own token does all of it, passed through to OpenVibe.Live: Codes mints the view ticket server-side++ * (Agent Streams S1: an iframe on another site has no Live cookie), and Live decides — the stream is theirs, so the++ * owner's rules apply and nobody else's stream is ever in an answer. Codes holds no token of its own here and keeps++ * nothing.++ *++ * These four routes are the only ones in this API that answer another site's page: the Dock loads from whichever++ * OpenVibe site the person is on. So they carry `Access-Control-Allow-Origin: *` — no credentials, no cookie, the++ * person's bearer is the key, and a page that does not hold it reads nothing.++ *++ * The step (S2's `live.agent-stream-step@1`) is not in S1: there is no `step` field here, and the Dock shows none++ * until Live serves them.++ */++const express = require('express');++const { http } = require('openvibe-contracts');++const { asyncRouter } = require('./router');++++const AGENT = /^ags_[0-9A-HJKMNP-TV-Z]{26}$/;++const CORS_HEADERS = 'authorization, content-type';++++function createAgentView({ config, sso, actorLimits, fetchImpl = (...a) => fetch(...a), log = console }) {++ const r = asyncRouter();++ const json = express.json({ limit: '4kb' });++ const problem = (req, res, status, code, detail) => http.sendProblem(res, status, code, { detail, ctx: req.ov });++ const signedIn = (req) => !!(req.viewer && req.viewer.kind === 'user' && req.viewer.subject);++ const liveBase = () => String((config.live && config.live.url) || 'https://openvibe.live').replace(/\/+$/, '');++++ /** One call to Live with the person's own token. Its own deadline: the Dock must not hang on a slow Live. */++ async function live(token, path, init = {}) {++ const res = await fetchImpl(`${liveBase()}${path}`, {++ ...init,++ headers: {++ accept: 'application/json',++ authorization: `Bearer ${token}`,++ ...(init.body ? { 'content-type': 'application/json' } : {}),++ ...(init.headers || {}),++ },++ signal: AbortSignal.timeout(8000),++ });++ return res;++ }++++ /** The live stream if one is up, else the newest one; null when the person has never published one. */++ async function streamOf(token) {++ let res;++ try {++ res = await live(token, '/api/streams/agent/mine');++ } catch (e) {++ throw Object.assign(new Error(`OpenVibe.Live did not answer: ${e.message}`), { status: 502, code: 'agent_view.unavailable' });++ }++ if (!res.ok) throw Object.assign(new Error(`OpenVibe.Live answered ${res.status}`), { status: res.status === 401 ? 401 : 502, code: 'agent_view.unavailable' });++ const body = await res.json().catch(() => null);++ const rows = body && Array.isArray(body.streams) ? body.streams : [];++ if (!rows.length) return null;++ return rows.find((s) => s && s.state === 'live') || rows[0];++ }++++ /** A view ticket while the stream is not ended; the embed needs one for a private or unlisted stream. */++ async function ticketOf(token, row) {++ if (!row || row.state === 'ended') return { ticket: null, expiresAt: null };++ try {++ const res = await live(token, `/api/streams/agent/${encodeURIComponent(row.id)}/view-ticket`, { method: 'POST', body: '{}' });++ if (!res.ok) return { ticket: null, expiresAt: null };++ const body = await res.json().catch(() => null);++ return { ticket: body && typeof body.ticket === 'string' ? body.ticket : null, expiresAt: (body && body.expires_at) || null };++ } catch { return { ticket: null, expiresAt: null }; }++ }++++ /**++ * What the Dock reads. The embed's address is built here from the stream's id and the ticket, never from a URL in++ * Live's answer, so a foreign address can never end up in the Dock's frame.++ */++ function present(row, { ticket, expiresAt }) {++ if (!row || !AGENT.test(String(row.id || ''))) return null;++ return {++ id: row.id,++ state: row.state,++ title: row.title,++ task: row.task && typeof row.task === 'object' ? row.task : null,++ curtain: { up: !!(row.curtain && row.curtain.up) },++ viewers: Number(row.viewers) || 0,++ worked_seconds: Number(row.worked_seconds) || 0,++ visibility: row.visibility,++ started_at: row.started_at || null,++ ended_at: row.ended_at || null,++ page_url: typeof row.page_url === 'string' && row.page_url.startsWith('https://openvibe.live/') ? row.page_url : `https://openvibe.live/@${encodeURIComponent(row.channel || 'you')}/agents/${row.id}`,++ // The player, with the view ticket Codes just minted. It is in the address and nowhere else in the answer.++ embed_url: ticket ? `https://openvibe.live/embed/agent/${row.id}?vt=${encodeURIComponent(ticket)}` : null,++ // The picture when nothing is live: Codes serves it, because an <img> cannot carry the person's token.++ last_frame_url: row.last_frame_url ? `${String(config.baseUrl).replace(/\/+$/, '')}/api/v1/me/agent-view/frame` : null,++ };++ }++++ async function viewOf(token) {++ const row = await streamOf(token);++ if (!row) return null;++ return present(row, await ticketOf(token, row));++ }++++ // ── CORS: this one part of the API answers every site's Dock ──++ r.use('/me/agent-view', (req, res, next) => {++ res.set('Access-Control-Allow-Origin', '*');++ res.set('Cache-Control', 'no-store');++ if (req.method === 'OPTIONS') {++ res.set('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');++ res.set('Access-Control-Allow-Headers', CORS_HEADERS);++ res.set('Access-Control-Max-Age', '600');++ return res.status(204).end();++ }++ return next();++ });++++ r.get('/me/agent-view', actorLimits.reads('codes.read'), async (req, res) => {++ if (!signedIn(req)) return problem(req, res, 401, 'auth.required', 'sign in to see your agent');++ try {++ const agent = await sso.asViewer(req, res, (token) => viewOf(token));++ return res.json({ agent });++ } catch (e) {++ if (e && e.status === 401) return problem(req, res, 401, 'auth.required', 'sign in to see your agent');++ log.warn(`[Codes] agent view: ${String(e && e.message).slice(0, 200)}`);++ return problem(req, res, 502, 'agent_view.unavailable', 'OpenVibe.Live did not answer. Try again in a moment.');++ }++ });++++ // The last frame: Live serves it to the owner only, so Codes asks with their token and passes the picture on.++ r.get('/me/agent-view/frame', actorLimits.reads('codes.read'), async (req, res) => {++ if (!signedIn(req)) return problem(req, res, 401, 'auth.required', 'sign in to see your agent');++ let frame = null;++ try {++ frame = await sso.asViewer(req, res, async (token) => {++ const row = await streamOf(token);++ if (!row || !row.last_frame_url) return null;++ // Built here from the stream's id and our own Live base: Codes never fetches an address Live names.++ const out = await fetchImpl(`${liveBase()}/api/streams/agent/${encodeURIComponent(row.id)}/frame`, {++ headers: { authorization: `Bearer ${token}`, accept: 'image/jpeg, image/webp' }, signal: AbortSignal.timeout(8000),++ });++ if (!out.ok) return null;++ const type = String(out.headers.get('content-type') || '');++ if (!/^image\/(jpeg|webp)$/.test(type)) return null;++ return { type, bytes: Buffer.from(await out.arrayBuffer()) };++ });++ } catch (e) {++ if (e && e.status === 401) return problem(req, res, 401, 'auth.required', 'sign in to see your agent');++ frame = null;++ }++ if (!frame) return problem(req, res, 404, 'agent_view.no_frame', 'There is no still of your agent yet');++ res.set('Content-Type', frame.type).set('Cache-Control', 'private, max-age=10').send(frame.bytes);++ return undefined;++ });++++ // Curtain now (the owner's own button: Live lifts it only from the desk, or from here while it is up) and End.++ const control = (name, run) => async (req, res) => {++ if (!signedIn(req)) return problem(req, res, 401, 'auth.required', `sign in to ${name} your agent stream`);++ try {++ const agent = await sso.asViewer(req, res, (token) => run(token));++ return res.json({ agent });++ } catch (e) {++ if (e && e.status === 401) return problem(req, res, 401, 'auth.required', `sign in to ${name} your agent stream`);++ log.warn(`[Codes] agent view ${name}: ${String(e && e.message).slice(0, 200)}`);++ return problem(req, res, 502, 'agent_view.unavailable', 'OpenVibe.Live did not answer. Try again in a moment.');++ }++ };++++ // The answer is the view read again, so the Dock paints the curtain Live now reports and, after End, the still.++ const act = (path, body) => async (token) => {++ const row = await streamOf(token);++ if (!row) return null;++ const res = await live(token, `/api/streams/agent/${encodeURIComponent(row.id)}${path}`, { method: 'POST', body: JSON.stringify(body) });++ if (!res.ok) throw Object.assign(new Error(`OpenVibe.Live answered ${res.status} for ${path}`), { status: 502, code: 'agent_view.unavailable' });++ return viewOf(token);++ };++++ r.post('/me/agent-view/curtain', actorLimits.budget('codes.agent_view.control'), json, control('curtain', act('/curtain', { up: true, reason: 'manual' })));++ r.post('/me/agent-view/end', actorLimits.budget('codes.agent_view.control'), json, control('end', act('/end', {})));++++ return r;++}++++module.exports = { createAgentView };nnew file mode 100644@@@ -0,0 +1,162 @@++'use strict';++/**++ * The Dock's agent view (Dock plan 2026-10-30 K-D3; Agent Streams S1): Codes mints the person's view ticket server-side++ * and hands the Dock the embed, with the person's own token passed through to OpenVibe.Live — never a service token,++ * never another person's stream. Curtain now and End are Live's own routes, reached the same way; the still comes back++ * through Codes because an <img> cannot carry a bearer.++ *++ * Live is a stand-in on a local port (CODES_LIVE_URL): the api/streams/agent list, the view ticket, the curtain, the++ * end and the frame.++ */++const assert = require('assert');++const http = require('http');++const { boot, check, done } = require('./helpers/boot');++++const LIVE_ID = 'ags_01JAB2C3D4E5F6G7H8J9K0MNPQ';++const FRAME = Buffer.from([0xff, 0xd8, 0xff, 0xe0, 0x00, 0x10, 0x4a, 0x46, 0x49, 0x46, 0xff, 0xd9]);++++const STREAM = (o = {}) => ({++ id: LIVE_ID, owner: 'usr_01JAB2C3D4E5F6G7H8J9K0MNPQ', channel: 'ada', title: 'Fix the flaky upload test',++ task: { source: 'codes', ref: 'tsk_1', title: 'Fix the flaky upload test', url: 'https://openvibe.codes/run/tasks/tsk_1' },++ visibility: 'private', mode: 'watch', state: 'live', curtain: { up: false }, viewers: 2,++ started_at: '2026-11-06T10:00:00.000Z', ended_at: null, worked_seconds: 720, last_frame_url: null, vod_id: null,++ page_url: `https://openvibe.live/@ada/agents/${LIVE_ID}`, label: 'AI agent', ...o,++});++++(async () => {++ // ── OpenVibe.Live's stand-in ────────────────────────────────++ const calls = [];++ const live = { streams: [], ticket: 'wvt_the_view_ticket', fail: false, authorizations: [] };++ const server = await new Promise((ok) => {++ const srv = http.createServer((req, res) => {++ const url = new URL(req.url, 'http://x');++ const json = (status, body) => { res.writeHead(status, { 'content-type': 'application/json' }); res.end(JSON.stringify(body)); };++ live.authorizations.push(String(req.headers.authorization || ''));++ let body = '';++ req.on('data', (c) => { body += c; });++ req.on('end', () => {++ calls.push({ method: req.method, path: url.pathname, body: body ? JSON.parse(body) : null });++ if (live.fail) return json(500, { error: 'Live is unwell', code: 'boom' });++ if (url.pathname === '/api/streams/agent/mine' && req.method === 'GET') {++ const onlyLive = url.searchParams.get('live') === '1';++ return json(200, { streams: live.streams.filter((s) => !onlyLive || s.state === 'live') });++ }++ const m = /^\/api\/streams\/agent\/(ags_[0-9A-Z]+)\/(view-ticket|curtain|end)$/.exec(url.pathname);++ if (m) {++ if (m[2] === 'view-ticket') return json(200, { ticket: live.ticket, expires_at: new Date(Date.now() + 600_000).toISOString() });++ if (m[2] === 'curtain') { live.streams = live.streams.map((s) => ({ ...s, curtain: { up: true } })); return json(200, { stream: STREAM({ curtain: { up: true } }) }); }++ if (m[2] === 'end') { live.streams = live.streams.map((s) => ({ ...s, state: 'ended', last_frame_url: 'https://openvibe.live/api/streams/agent/' + LIVE_ID + '/frame' })); return json(200, { stream: STREAM({ state: 'ended' }) }); }++ }++ if (url.pathname === `/api/streams/agent/${LIVE_ID}/frame`) {++ res.writeHead(200, { 'content-type': 'image/jpeg' });++ return res.end(FRAME);++ }++ return json(404, { error: 'not found' });++ });++ });++ srv.listen(0, '127.0.0.1', () => ok(srv));++ });++++ const t = await boot({ env: { CODES_LIVE_URL: `http://127.0.0.1:${server.address().port}` } });++ const ada = t.network.addUser('ada');++ const bo = t.network.addUser('bo');++++ try {++ await check('signed out, there is nothing to show', async () => {++ const res = await t.get('/api/v1/me/agent-view');++ assert.strictEqual(res.status, 401, res.text);++ assert.strictEqual(res.json().code, 'auth.required');++ });++++ await check('a person with no stream gets { agent: null }', async () => {++ const res = await t.get('/api/v1/me/agent-view', { bearer: t.network.userToken(ada) });++ assert.strictEqual(res.status, 200, res.text);++ assert.deepStrictEqual(res.json(), { agent: null });++ });++++ await check('the live stream comes with the embed, its ticket minted here, and the person\'s own token to Live', async () => {++ live.streams = [STREAM({ state: 'ended', last_frame_url: 'https://openvibe.live/x' }), STREAM()];++ const token = t.network.userToken(ada);++ const res = await t.get('/api/v1/me/agent-view', { bearer: token });++ assert.strictEqual(res.status, 200, res.text);++ const a = res.json().agent;++ assert.strictEqual(a.id, LIVE_ID);++ assert.strictEqual(a.state, 'live');++ assert.strictEqual(a.title, 'Fix the flaky upload test');++ assert.deepStrictEqual(a.task, STREAM().task);++ assert.deepStrictEqual([a.viewers, a.worked_seconds], [2, 720]);++ assert.strictEqual(a.embed_url, `https://openvibe.live/embed/agent/${LIVE_ID}?vt=wvt_the_view_ticket`);++ assert.strictEqual(a.page_url, `https://openvibe.live/@ada/agents/${LIVE_ID}`);++ assert.ok(!JSON.stringify(a).includes('wpt_'), 'no publish ticket anywhere');++ assert.ok(calls.some((c) => c.method === 'POST' && c.path === `/api/streams/agent/${LIVE_ID}/view-ticket`), 'the ticket was minted server-side');++ // Every call to Live carried the person's own token: Codes holds no token of its own here.++ for (const auth of live.authorizations) {++ assert.strictEqual(auth, `Bearer ${token}`, 'the person\'s own bearer, not a service token');++ }++ assert.notStrictEqual(ada.subject, bo.subject);++ });++++ await check('the Dock may read it from any site, and the preflight says so', async () => {++ const get = await t.get('/api/v1/me/agent-view', { bearer: t.network.userToken(ada) });++ assert.strictEqual(get.headers.get('access-control-allow-origin'), '*');++ assert.match(String(get.headers.get('cache-control')), /no-store/);++ const pre = await fetch(`${t.base}/api/v1/me/agent-view?x=1`, {++ method: 'OPTIONS', headers: { origin: 'https://openvibe.live', 'access-control-request-method': 'POST', 'access-control-request-headers': 'authorization' },++ });++ assert.strictEqual(pre.status, 204);++ assert.strictEqual(pre.headers.get('access-control-allow-origin'), '*');++ assert.match(String(pre.headers.get('access-control-allow-headers')), /authorization/);++ assert.match(String(pre.headers.get('access-control-allow-methods')), /POST/);++ });++++ await check('an ended stream is the still, with no embed and no ticket', async () => {++ live.streams = [STREAM({ state: 'ended', last_frame_url: 'https://openvibe.live/api/streams/agent/' + LIVE_ID + '/frame' })];++ const before = calls.filter((c) => c.path.endsWith('/view-ticket')).length;++ const res = await t.get('/api/v1/me/agent-view', { bearer: t.network.userToken(ada) });++ const a = res.json().agent;++ assert.strictEqual(a.state, 'ended');++ assert.strictEqual(a.embed_url, null);++ assert.strictEqual(a.last_frame_url, 'https://openvibe.codes/api/v1/me/agent-view/frame');++ assert.strictEqual(calls.filter((c) => c.path.endsWith('/view-ticket')).length, before, 'no ticket for an ended stream');++ });++++ await check('the still comes back through Codes, from Live\'s own frame', async () => {++ const res = await t.get('/api/v1/me/agent-view/frame', { bearer: t.network.userToken(ada) });++ assert.strictEqual(res.status, 200, res.text);++ assert.strictEqual(res.headers.get('content-type'), 'image/jpeg');++ assert.deepStrictEqual([...res.buffer], [...FRAME]);++ assert.match(String(res.headers.get('cache-control')), /private/);++ const anon = await t.get('/api/v1/me/agent-view/frame');++ assert.strictEqual(anon.status, 401);++ });++++ await check('Curtain now and End are Live\'s routes, and the answer is the view read again', async () => {++ live.streams = [STREAM()];++ const curtain = await t.get('/api/v1/me/agent-view/curtain', { bearer: t.network.userToken(ada), method: 'POST', json: {} });++ assert.strictEqual(curtain.status, 200, curtain.text);++ const sent = calls.filter((c) => c.path === `/api/streams/agent/${LIVE_ID}/curtain`).pop();++ assert.deepStrictEqual(sent.body, { up: true, reason: 'manual' });++ assert.strictEqual(curtain.json().agent.curtain.up, true, 'the Dock paints the curtain Live now reports');++++ const end = await t.get('/api/v1/me/agent-view/end', { bearer: t.network.userToken(ada), method: 'POST', json: {} });++ assert.strictEqual(end.status, 200, end.text);++ assert.ok(calls.some((c) => c.path === `/api/streams/agent/${LIVE_ID}/end`), 'End reached Live');++ assert.strictEqual(end.json().agent.state, 'ended');++++ const anon = await t.get('/api/v1/me/agent-view/end', { method: 'POST', json: {} });++ assert.strictEqual(anon.status, 401);++ });++++ await check('a Live that does not answer is a 502 problem the Dock can ignore', async () => {++ live.fail = true;++ const res = await t.get('/api/v1/me/agent-view', { bearer: t.network.userToken(ada) });++ assert.strictEqual(res.status, 502, res.text);++ assert.strictEqual(res.json().code, 'agent_view.unavailable');++ live.fail = false;++ });++ } finally {++ await t.close();++ server.close();++ }++ done();++})().catch((e) => { process.stdout.write(`${e && e.stack || e}\n`); process.exit(1); });@@@ -44,6 +44,7 @@ const INTERNAL = [`http://127.0.0.1:3000${PROBE}`, `http://2130706433${PROBE}`,'server/domain/forgejo.js': 'OpenVibe Git API (CODES_GIT_INTERNAL_URL, configured loopback Forgejo) with a service token; paths built from validated owner/name','server/domain/run-client.js': 'OpenVibe.Run (CODES_RUN_URL, configured) and the Network token endpoint; job ids only, never a URL from a person','server/domain/bench-publisher.js': 'OpenVibe.Bench (CODES_BENCH_URL, configured) run publishing and the Network token endpoint; artifact URLs use the configured render origin',++ 'server/http/agent-view.js': 'OpenVibe.Live (the configured Live base) with the signed-in person\'s own Network token: their own agent streams, a view ticket, the frame, curtain and end; stream ids from Live\'s own answer, URL-encoded; no URL from a person','server/http/run.js': 'Network projects API (configured) with the viewer\'s token, and the Run job stream URL run-client.js builds on its own base','server/http/home.js': 'OpenVibe Git\'s public repository search (CODES_GIT_INTERNAL_URL, configured loopback Forgejo) for the home page; no token','server/http/me-api.js': 'Network projects API (configured) with the viewer\'s token, for the user task API; no URL from a person',
@@ -47,6 +47,7 @@ const { createOutbox, createPublisher } = require('./events/outbox');
@@ -47,6 +47,7 @@ const { createOutbox, createPublisher } = require('./events/outbox');
const { createEventsConsumer } = require('./events-consumer');
const { createEventsConsumer } = require('./events-consumer');
const { createApi } = require('./http/api');
const { createApi } = require('./http/api');
const { createMeApi } = require('./http/me-api');
const { createMeApi } = require('./http/me-api');
+const { createAgentView } = require('./http/agent-view');
const { createKnowledgeApi } = require('./http/knowledge-api');
const { createKnowledgeApi } = require('./http/knowledge-api');
const { createMovedRoutes } = require('./http/moved');
const { createMovedRoutes } = require('./http/moved');
const { createCodesReadiness } = require('./observability');
const { createCodesReadiness } = require('./observability');
@@ -256,6 +257,8 @@ async function createApp(opts = {}) {
@@ -256,6 +257,8 @@ async function createApp(opts = {}) {
app.use('/api/v1', createApi(ctx));
app.use('/api/v1', createApi(ctx));
app.use('/api/v1', createTasksApi({ config, store, keys, run, harnesses, tasks, fleet: ctx.fleet, actorLimits: ctx.actorLimits, log }));
app.use('/api/v1', createTasksApi({ config, store, keys, run, harnesses, tasks, fleet: ctx.fleet, actorLimits: ctx.actorLimits, log }));
app.use('/api/v1', createMeApi({ config, store, sso, run, harnesses, tasks, github: ctx.github, actorLimits: ctx.actorLimits, log }));
app.use('/api/v1', createMeApi({ config, store, sso, run, harnesses, tasks, github: ctx.github, actorLimits: ctx.actorLimits, log }));
+ // The Dock's agent view (Dock K-D3, Agent Streams S1): the person's own desk stream with a view ticket minted here.
+ app.use('/api/v1', createAgentView({ config, sso, actorLimits: ctx.actorLimits, log }));
// Know K5: the person's own agents read and add workspace knowledge with their token (the ov-know MCP server).
// Know K5: the person's own agents read and add workspace knowledge with their token (the ov-know MCP server).
app.use('/api/v1', createKnowledgeApi({ config, store, knowledge: ctx.knowledge, actorLimits: ctx.actorLimits, log }));
app.use('/api/v1', createKnowledgeApi({ config, store, knowledge: ctx.knowledge, actorLimits: ctx.actorLimits, log }));
// A person's connections: their own provider keys (stored in OpenVibe.AI, never here) and their machines' plans.
// A person's connections: their own provider keys (stored in OpenVibe.AI, never here) and their machines' plans.
@@ -60,6 +60,9 @@ function load(env = process.env) {
@@ -60,6 +60,9 @@ function load(env = process.env) {
clientSecret: env.CODES_FORGE_CLIENT_SECRET || '',
clientSecret: env.CODES_FORGE_CLIENT_SECRET || '',
sealKey: env.CODES_SEAL_KEY || '',
sealKey: env.CODES_SEAL_KEY || '',
},
},
+ // OpenVibe.Live (Agent Streams S1, Dock K-D3): the person's own agent streams, read and steered with their own
+ // token for the Dock's agent view (server/http/agent-view.js). Codes holds no token of its own here.
+ live: { url: trim(env.CODES_LIVE_URL || 'https://openvibe.live') },
// The audience of the Network service tokens a first-party service calls /api/v1/tasks with (codes.task.*).
// The audience of the Network service tokens a first-party service calls /api/v1/tasks with (codes.task.*).
serviceAudience: env.CODES_AUDIENCE || 'openvibe.codes',
serviceAudience: env.CODES_AUDIENCE || 'openvibe.codes',
oauth: {
oauth: {
@@ -40,6 +40,8 @@ const BUDGETS = {
@@ -40,6 +40,8 @@ const BUDGETS = {
'codes.repo.search': { minute: 6, hour: 60 },
'codes.repo.search': { minute: 6, hour: 60 },
// An agent adds what it learned through the ov-know MCP; a run may add a handful, not a flood.
// An agent adds what it learned through the ov-know MCP; a run may add a handful, not a flood.
'codes.knowledge.write': { minute: 30, hour: 600 },
'codes.knowledge.write': { minute: 30, hour: 600 },
+ // The Dock's agent view controls (K-D3): Curtain now and End. The owner presses them now and then, never in a burst.
+ 'codes.agent_view.control': { minute: 20, hour: 120 },
};
};
/**
/**
new file mode 100644
new file mode 100644
@@ -0,0 +1,189 @@
@@ -0,0 +1,189 @@
+'use strict';
+
+/**
+ * The Dock's agent view (Dock plan 2026-10-30, K-D3; Agent Streams S1): the signed-in person's own agent desk stream,
+ * for the Dock that Shared draws on every OpenVibe site.
+ *
+ * GET /api/v1/me/agent-view → { agent } the live stream, with a view ticket, or the last one as a still
+ * GET /api/v1/me/agent-view/frame → the last frame (JPEG or WebP), the picture when nothing is live
+ * POST /api/v1/me/agent-view/curtain → { agent } { up } → Live's curtain, raised as the owner's own button
+ * POST /api/v1/me/agent-view/end → { agent } Live's end
+ *
+ * The person's own token does all of it, passed through to OpenVibe.Live: Codes mints the view ticket server-side
+ * (Agent Streams S1: an iframe on another site has no Live cookie), and Live decides — the stream is theirs, so the
+ * owner's rules apply and nobody else's stream is ever in an answer. Codes holds no token of its own here and keeps
+ * nothing.
+ *
+ * These four routes are the only ones in this API that answer another site's page: the Dock loads from whichever
+ * OpenVibe site the person is on. So they carry `Access-Control-Allow-Origin: *` — no credentials, no cookie, the
+ * person's bearer is the key, and a page that does not hold it reads nothing.
+ *
+ * The step (S2's `live.agent-stream-step@1`) is not in S1: there is no `step` field here, and the Dock shows none
+ * until Live serves them.
+ */
+const express = require('express');
+const { http } = require('openvibe-contracts');
+const { asyncRouter } = require('./router');
+
+const AGENT = /^ags_[0-9A-HJKMNP-TV-Z]{26}$/;
+const CORS_HEADERS = 'authorization, content-type';
+
+function createAgentView({ config, sso, actorLimits, fetchImpl = (...a) => fetch(...a), log = console }) {
+ const r = asyncRouter();
+ const json = express.json({ limit: '4kb' });
+ const problem = (req, res, status, code, detail) => http.sendProblem(res, status, code, { detail, ctx: req.ov });
+ const signedIn = (req) => !!(req.viewer && req.viewer.kind === 'user' && req.viewer.subject);
+ const liveBase = () => String((config.live && config.live.url) || 'https://openvibe.live').replace(/\/+$/, '');
+
+ /** One call to Live with the person's own token. Its own deadline: the Dock must not hang on a slow Live. */
+ async function live(token, path, init = {}) {
+ const res = await fetchImpl(`${liveBase()}${path}`, {
+ ...init,
+ headers: {
+ accept: 'application/json',
+ authorization: `Bearer ${token}`,
+ ...(init.body ? { 'content-type': 'application/json' } : {}),
+ ...(init.headers || {}),
+ },
+ signal: AbortSignal.timeout(8000),
+ });
+ return res;
+ }
+
+ /** The live stream if one is up, else the newest one; null when the person has never published one. */
+ async function streamOf(token) {
+ let res;
+ try {
+ res = await live(token, '/api/streams/agent/mine');
+ } catch (e) {
+ throw Object.assign(new Error(`OpenVibe.Live did not answer: ${e.message}`), { status: 502, code: 'agent_view.unavailable' });
+ }
+ if (!res.ok) throw Object.assign(new Error(`OpenVibe.Live answered ${res.status}`), { status: res.status === 401 ? 401 : 502, code: 'agent_view.unavailable' });
+ const body = await res.json().catch(() => null);
+ const rows = body && Array.isArray(body.streams) ? body.streams : [];
+ if (!rows.length) return null;
+ return rows.find((s) => s && s.state === 'live') || rows[0];
+ }
+
+ /** A view ticket while the stream is not ended; the embed needs one for a private or unlisted stream. */
+ async function ticketOf(token, row) {
+ if (!row || row.state === 'ended') return { ticket: null, expiresAt: null };
+ try {
+ const res = await live(token, `/api/streams/agent/${encodeURIComponent(row.id)}/view-ticket`, { method: 'POST', body: '{}' });
+ if (!res.ok) return { ticket: null, expiresAt: null };
+ const body = await res.json().catch(() => null);
+ return { ticket: body && typeof body.ticket === 'string' ? body.ticket : null, expiresAt: (body && body.expires_at) || null };
+ } catch { return { ticket: null, expiresAt: null }; }
+ }
+
+ /**
+ * What the Dock reads. The embed's address is built here from the stream's id and the ticket, never from a URL in
+ * Live's answer, so a foreign address can never end up in the Dock's frame.
+ */
+ function present(row, { ticket, expiresAt }) {
+ if (!row || !AGENT.test(String(row.id || ''))) return null;
+ return {
+ id: row.id,
+ state: row.state,
+ title: row.title,
+ task: row.task && typeof row.task === 'object' ? row.task : null,
+ curtain: { up: !!(row.curtain && row.curtain.up) },
+ viewers: Number(row.viewers) || 0,
+ worked_seconds: Number(row.worked_seconds) || 0,
+ visibility: row.visibility,
+ started_at: row.started_at || null,
+ ended_at: row.ended_at || null,
+ page_url: typeof row.page_url === 'string' && row.page_url.startsWith('https://openvibe.live/') ? row.page_url : `https://openvibe.live/@${encodeURIComponent(row.channel || 'you')}/agents/${row.id}`,
+ // The player, with the view ticket Codes just minted. It is in the address and nowhere else in the answer.
+ embed_url: ticket ? `https://openvibe.live/embed/agent/${row.id}?vt=${encodeURIComponent(ticket)}` : null,
+ // The picture when nothing is live: Codes serves it, because an <img> cannot carry the person's token.
+ last_frame_url: row.last_frame_url ? `${String(config.baseUrl).replace(/\/+$/, '')}/api/v1/me/agent-view/frame` : null,
+ };
+ }
+
+ async function viewOf(token) {
+ const row = await streamOf(token);
+ if (!row) return null;
+ return present(row, await ticketOf(token, row));
+ }
+
+ // ── CORS: this one part of the API answers every site's Dock ──
+ r.use('/me/agent-view', (req, res, next) => {
+ res.set('Access-Control-Allow-Origin', '*');
+ res.set('Cache-Control', 'no-store');
+ if (req.method === 'OPTIONS') {
+ res.set('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
+ res.set('Access-Control-Allow-Headers', CORS_HEADERS);
+ res.set('Access-Control-Max-Age', '600');
+ return res.status(204).end();
+ }
+ return next();
+ });
+
+ r.get('/me/agent-view', actorLimits.reads('codes.read'), async (req, res) => {
+ if (!signedIn(req)) return problem(req, res, 401, 'auth.required', 'sign in to see your agent');
+ try {
+ const agent = await sso.asViewer(req, res, (token) => viewOf(token));
+ return res.json({ agent });
+ } catch (e) {
+ if (e && e.status === 401) return problem(req, res, 401, 'auth.required', 'sign in to see your agent');
+ log.warn(`[Codes] agent view: ${String(e && e.message).slice(0, 200)}`);
+ return problem(req, res, 502, 'agent_view.unavailable', 'OpenVibe.Live did not answer. Try again in a moment.');
+ }
+ });
+
+ // The last frame: Live serves it to the owner only, so Codes asks with their token and passes the picture on.
+ r.get('/me/agent-view/frame', actorLimits.reads('codes.read'), async (req, res) => {
+ if (!signedIn(req)) return problem(req, res, 401, 'auth.required', 'sign in to see your agent');
+ let frame = null;
+ try {
+ frame = await sso.asViewer(req, res, async (token) => {
+ const row = await streamOf(token);
+ if (!row || !row.last_frame_url) return null;
+ // Built here from the stream's id and our own Live base: Codes never fetches an address Live names.
+ const out = await fetchImpl(`${liveBase()}/api/streams/agent/${encodeURIComponent(row.id)}/frame`, {
+ headers: { authorization: `Bearer ${token}`, accept: 'image/jpeg, image/webp' }, signal: AbortSignal.timeout(8000),
+ });
+ if (!out.ok) return null;
+ const type = String(out.headers.get('content-type') || '');
+ if (!/^image\/(jpeg|webp)$/.test(type)) return null;
+ return { type, bytes: Buffer.from(await out.arrayBuffer()) };
+ });
+ } catch (e) {
+ if (e && e.status === 401) return problem(req, res, 401, 'auth.required', 'sign in to see your agent');
+ frame = null;
+ }
+ if (!frame) return problem(req, res, 404, 'agent_view.no_frame', 'There is no still of your agent yet');
+ res.set('Content-Type', frame.type).set('Cache-Control', 'private, max-age=10').send(frame.bytes);
+ return undefined;
+ });
+
+ // Curtain now (the owner's own button: Live lifts it only from the desk, or from here while it is up) and End.
+ const control = (name, run) => async (req, res) => {
+ if (!signedIn(req)) return problem(req, res, 401, 'auth.required', `sign in to ${name} your agent stream`);
+ try {
+ const agent = await sso.asViewer(req, res, (token) => run(token));
+ return res.json({ agent });
+ } catch (e) {
+ if (e && e.status === 401) return problem(req, res, 401, 'auth.required', `sign in to ${name} your agent stream`);
+ log.warn(`[Codes] agent view ${name}: ${String(e && e.message).slice(0, 200)}`);
+ return problem(req, res, 502, 'agent_view.unavailable', 'OpenVibe.Live did not answer. Try again in a moment.');
+ }
+ };
+
+ // The answer is the view read again, so the Dock paints the curtain Live now reports and, after End, the still.
+ const act = (path, body) => async (token) => {
+ const row = await streamOf(token);
+ if (!row) return null;
+ const res = await live(token, `/api/streams/agent/${encodeURIComponent(row.id)}${path}`, { method: 'POST', body: JSON.stringify(body) });
+ if (!res.ok) throw Object.assign(new Error(`OpenVibe.Live answered ${res.status} for ${path}`), { status: 502, code: 'agent_view.unavailable' });
+ return viewOf(token);
+ };
+
+ r.post('/me/agent-view/curtain', actorLimits.budget('codes.agent_view.control'), json, control('curtain', act('/curtain', { up: true, reason: 'manual' })));
+ r.post('/me/agent-view/end', actorLimits.budget('codes.agent_view.control'), json, control('end', act('/end', {})));
+
+ return r;
+}
+
+module.exports = { createAgentView };
new file mode 100644
new file mode 100644
@@ -0,0 +1,162 @@
@@ -0,0 +1,162 @@
+'use strict';
+/**
+ * The Dock's agent view (Dock plan 2026-10-30 K-D3; Agent Streams S1): Codes mints the person's view ticket server-side
+ * and hands the Dock the embed, with the person's own token passed through to OpenVibe.Live — never a service token,
+ * never another person's stream. Curtain now and End are Live's own routes, reached the same way; the still comes back
+ * through Codes because an <img> cannot carry a bearer.
+ *
+ * Live is a stand-in on a local port (CODES_LIVE_URL): the api/streams/agent list, the view ticket, the curtain, the
+ * end and the frame.
+ */
+const assert = require('assert');
+const http = require('http');
+const { boot, check, done } = require('./helpers/boot');
+
+const LIVE_ID = 'ags_01JAB2C3D4E5F6G7H8J9K0MNPQ';
+const FRAME = Buffer.from([0xff, 0xd8, 0xff, 0xe0, 0x00, 0x10, 0x4a, 0x46, 0x49, 0x46, 0xff, 0xd9]);
+
+const STREAM = (o = {}) => ({
+ id: LIVE_ID, owner: 'usr_01JAB2C3D4E5F6G7H8J9K0MNPQ', channel: 'ada', title: 'Fix the flaky upload test',
+ task: { source: 'codes', ref: 'tsk_1', title: 'Fix the flaky upload test', url: 'https://openvibe.codes/run/tasks/tsk_1' },
+ visibility: 'private', mode: 'watch', state: 'live', curtain: { up: false }, viewers: 2,
+ started_at: '2026-11-06T10:00:00.000Z', ended_at: null, worked_seconds: 720, last_frame_url: null, vod_id: null,
+ page_url: `https://openvibe.live/@ada/agents/${LIVE_ID}`, label: 'AI agent', ...o,
+});
+
+(async () => {
+ // ── OpenVibe.Live's stand-in ────────────────────────────────
+ const calls = [];
+ const live = { streams: [], ticket: 'wvt_the_view_ticket', fail: false, authorizations: [] };
+ const server = await new Promise((ok) => {
+ const srv = http.createServer((req, res) => {
+ const url = new URL(req.url, 'http://x');
+ const json = (status, body) => { res.writeHead(status, { 'content-type': 'application/json' }); res.end(JSON.stringify(body)); };
+ live.authorizations.push(String(req.headers.authorization || ''));
+ let body = '';
+ req.on('data', (c) => { body += c; });
+ req.on('end', () => {
+ calls.push({ method: req.method, path: url.pathname, body: body ? JSON.parse(body) : null });
+ if (live.fail) return json(500, { error: 'Live is unwell', code: 'boom' });
+ if (url.pathname === '/api/streams/agent/mine' && req.method === 'GET') {
+ const onlyLive = url.searchParams.get('live') === '1';
+ return json(200, { streams: live.streams.filter((s) => !onlyLive || s.state === 'live') });
+ }
+ const m = /^\/api\/streams\/agent\/(ags_[0-9A-Z]+)\/(view-ticket|curtain|end)$/.exec(url.pathname);
+ if (m) {
+ if (m[2] === 'view-ticket') return json(200, { ticket: live.ticket, expires_at: new Date(Date.now() + 600_000).toISOString() });
+ if (m[2] === 'curtain') { live.streams = live.streams.map((s) => ({ ...s, curtain: { up: true } })); return json(200, { stream: STREAM({ curtain: { up: true } }) }); }
+ if (m[2] === 'end') { live.streams = live.streams.map((s) => ({ ...s, state: 'ended', last_frame_url: 'https://openvibe.live/api/streams/agent/' + LIVE_ID + '/frame' })); return json(200, { stream: STREAM({ state: 'ended' }) }); }
+ }
+ if (url.pathname === `/api/streams/agent/${LIVE_ID}/frame`) {
+ res.writeHead(200, { 'content-type': 'image/jpeg' });
+ return res.end(FRAME);
+ }
+ return json(404, { error: 'not found' });
+ });
+ });
+ srv.listen(0, '127.0.0.1', () => ok(srv));
+ });
+
+ const t = await boot({ env: { CODES_LIVE_URL: `http://127.0.0.1:${server.address().port}` } });
+ const ada = t.network.addUser('ada');
+ const bo = t.network.addUser('bo');
+
+ try {
+ await check('signed out, there is nothing to show', async () => {
+ const res = await t.get('/api/v1/me/agent-view');
+ assert.strictEqual(res.status, 401, res.text);
+ assert.strictEqual(res.json().code, 'auth.required');
+ });
+
+ await check('a person with no stream gets { agent: null }', async () => {
+ const res = await t.get('/api/v1/me/agent-view', { bearer: t.network.userToken(ada) });
+ assert.strictEqual(res.status, 200, res.text);
+ assert.deepStrictEqual(res.json(), { agent: null });
+ });
+
+ await check('the live stream comes with the embed, its ticket minted here, and the person\'s own token to Live', async () => {
+ live.streams = [STREAM({ state: 'ended', last_frame_url: 'https://openvibe.live/x' }), STREAM()];
+ const token = t.network.userToken(ada);
+ const res = await t.get('/api/v1/me/agent-view', { bearer: token });
+ assert.strictEqual(res.status, 200, res.text);
+ const a = res.json().agent;
+ assert.strictEqual(a.id, LIVE_ID);
+ assert.strictEqual(a.state, 'live');
+ assert.strictEqual(a.title, 'Fix the flaky upload test');
+ assert.deepStrictEqual(a.task, STREAM().task);
+ assert.deepStrictEqual([a.viewers, a.worked_seconds], [2, 720]);
+ assert.strictEqual(a.embed_url, `https://openvibe.live/embed/agent/${LIVE_ID}?vt=wvt_the_view_ticket`);
+ assert.strictEqual(a.page_url, `https://openvibe.live/@ada/agents/${LIVE_ID}`);
+ assert.ok(!JSON.stringify(a).includes('wpt_'), 'no publish ticket anywhere');
+ assert.ok(calls.some((c) => c.method === 'POST' && c.path === `/api/streams/agent/${LIVE_ID}/view-ticket`), 'the ticket was minted server-side');
+ // Every call to Live carried the person's own token: Codes holds no token of its own here.
+ for (const auth of live.authorizations) {
+ assert.strictEqual(auth, `Bearer ${token}`, 'the person\'s own bearer, not a service token');
+ }
+ assert.notStrictEqual(ada.subject, bo.subject);
+ });
+
+ await check('the Dock may read it from any site, and the preflight says so', async () => {
+ const get = await t.get('/api/v1/me/agent-view', { bearer: t.network.userToken(ada) });
+ assert.strictEqual(get.headers.get('access-control-allow-origin'), '*');
+ assert.match(String(get.headers.get('cache-control')), /no-store/);
+ const pre = await fetch(`${t.base}/api/v1/me/agent-view?x=1`, {
+ method: 'OPTIONS', headers: { origin: 'https://openvibe.live', 'access-control-request-method': 'POST', 'access-control-request-headers': 'authorization' },
+ });
+ assert.strictEqual(pre.status, 204);
+ assert.strictEqual(pre.headers.get('access-control-allow-origin'), '*');
+ assert.match(String(pre.headers.get('access-control-allow-headers')), /authorization/);
+ assert.match(String(pre.headers.get('access-control-allow-methods')), /POST/);
+ });
+
+ await check('an ended stream is the still, with no embed and no ticket', async () => {
+ live.streams = [STREAM({ state: 'ended', last_frame_url: 'https://openvibe.live/api/streams/agent/' + LIVE_ID + '/frame' })];
+ const before = calls.filter((c) => c.path.endsWith('/view-ticket')).length;
+ const res = await t.get('/api/v1/me/agent-view', { bearer: t.network.userToken(ada) });
+ const a = res.json().agent;
+ assert.strictEqual(a.state, 'ended');
+ assert.strictEqual(a.embed_url, null);
+ assert.strictEqual(a.last_frame_url, 'https://openvibe.codes/api/v1/me/agent-view/frame');
+ assert.strictEqual(calls.filter((c) => c.path.endsWith('/view-ticket')).length, before, 'no ticket for an ended stream');
+ });
+
+ await check('the still comes back through Codes, from Live\'s own frame', async () => {
+ const res = await t.get('/api/v1/me/agent-view/frame', { bearer: t.network.userToken(ada) });
+ assert.strictEqual(res.status, 200, res.text);
+ assert.strictEqual(res.headers.get('content-type'), 'image/jpeg');
+ assert.deepStrictEqual([...res.buffer], [...FRAME]);
+ assert.match(String(res.headers.get('cache-control')), /private/);
+ const anon = await t.get('/api/v1/me/agent-view/frame');
+ assert.strictEqual(anon.status, 401);
+ });
+
+ await check('Curtain now and End are Live\'s routes, and the answer is the view read again', async () => {
+ live.streams = [STREAM()];
+ const curtain = await t.get('/api/v1/me/agent-view/curtain', { bearer: t.network.userToken(ada), method: 'POST', json: {} });
+ assert.strictEqual(curtain.status, 200, curtain.text);
+ const sent = calls.filter((c) => c.path === `/api/streams/agent/${LIVE_ID}/curtain`).pop();
+ assert.deepStrictEqual(sent.body, { up: true, reason: 'manual' });
+ assert.strictEqual(curtain.json().agent.curtain.up, true, 'the Dock paints the curtain Live now reports');
+
+ const end = await t.get('/api/v1/me/agent-view/end', { bearer: t.network.userToken(ada), method: 'POST', json: {} });
+ assert.strictEqual(end.status, 200, end.text);
+ assert.ok(calls.some((c) => c.path === `/api/streams/agent/${LIVE_ID}/end`), 'End reached Live');
+ assert.strictEqual(end.json().agent.state, 'ended');
+
+ const anon = await t.get('/api/v1/me/agent-view/end', { method: 'POST', json: {} });
+ assert.strictEqual(anon.status, 401);
+ });
+
+ await check('a Live that does not answer is a 502 problem the Dock can ignore', async () => {
+ live.fail = true;
+ const res = await t.get('/api/v1/me/agent-view', { bearer: t.network.userToken(ada) });
+ assert.strictEqual(res.status, 502, res.text);
+ assert.strictEqual(res.json().code, 'agent_view.unavailable');
+ live.fail = false;
+ });
+ } finally {
+ await t.close();
+ server.close();
+ }
+ done();
+})().catch((e) => { process.stdout.write(`${e && e.stack || e}\n`); process.exit(1); });
@@ -44,6 +44,7 @@ const INTERNAL = [`http://127.0.0.1:3000${PROBE}`, `http://2130706433${PROBE}`,
@@ -44,6 +44,7 @@ const INTERNAL = [`http://127.0.0.1:3000${PROBE}`, `http://2130706433${PROBE}`,
'server/domain/forgejo.js': 'OpenVibe Git API (CODES_GIT_INTERNAL_URL, configured loopback Forgejo) with a service token; paths built from validated owner/name',
'server/domain/forgejo.js': 'OpenVibe Git API (CODES_GIT_INTERNAL_URL, configured loopback Forgejo) with a service token; paths built from validated owner/name',
'server/domain/run-client.js': 'OpenVibe.Run (CODES_RUN_URL, configured) and the Network token endpoint; job ids only, never a URL from a person',
'server/domain/run-client.js': 'OpenVibe.Run (CODES_RUN_URL, configured) and the Network token endpoint; job ids only, never a URL from a person',
'server/domain/bench-publisher.js': 'OpenVibe.Bench (CODES_BENCH_URL, configured) run publishing and the Network token endpoint; artifact URLs use the configured render origin',
'server/domain/bench-publisher.js': 'OpenVibe.Bench (CODES_BENCH_URL, configured) run publishing and the Network token endpoint; artifact URLs use the configured render origin',
+ 'server/http/agent-view.js': 'OpenVibe.Live (the configured Live base) with the signed-in person\'s own Network token: their own agent streams, a view ticket, the frame, curtain and end; stream ids from Live\'s own answer, URL-encoded; no URL from a person',
'server/http/run.js': 'Network projects API (configured) with the viewer\'s token, and the Run job stream URL run-client.js builds on its own base',
'server/http/run.js': 'Network projects API (configured) with the viewer\'s token, and the Run job stream URL run-client.js builds on its own base',
'server/http/home.js': 'OpenVibe Git\'s public repository search (CODES_GIT_INTERNAL_URL, configured loopback Forgejo) for the home page; no token',
'server/http/home.js': 'OpenVibe Git\'s public repository search (CODES_GIT_INTERNAL_URL, configured loopback Forgejo) for the home page; no token',
'server/http/me-api.js': 'Network projects API (configured) with the viewer\'s token, for the user task API; no URL from a person',
'server/http/me-api.js': 'Network projects API (configured) with the viewer\'s token, for the user task API; no URL from a person',