web: the agents app β session.* event contract + fleet board (dark until the bridge ships) (#1452)
The contract half of shared remote Claude Code sessions (docs/superpowers/ specs/2026-08-05-eleven-claude-agents-design.md): a chat-placed mini-app whose reducer folds the durable session.* vocabulary (spawn/status/prompt/ turn/result/approval/end, plus the ephemeral tool/stream types) into the fleet board, pinned by test/event-vectors/agents.json. listed:false until lchat agents run exists β the bridge PR flips it and carries the changelog. Claude-Session: https://claude.ai/code/session_01Xxn758bWfMnEe9xZZuk5Mx
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
7 files changed,
+690
−0
ios/LChatCore/Sources/LChatCore/Seed/app-catalog.json+23 −0test/apps.test.mjs+17 −0test/event-vectors.test.mjs+39 −0test/event-vectors/agents.json+197 −0test/event-vectors/index.js+2 −0web/static/apps/agents.js+411 −0web/static/shell.js+1 −0
diff --git a/ios/LChatCore/Sources/LChatCore/Seed/app-catalog.json b/ios/LChatCore/Sources/LChatCore/Seed/app-catalog.json| index 6c56d16..fa7d143 100644 |
| --- a/ios/LChatCore/Sources/LChatCore/Seed/app-catalog.json |
| +++ b/ios/LChatCore/Sources/LChatCore/Seed/app-catalog.json |
| @@ -20,6 +20,29 @@ |
| "defaultCommand": "", |
| "commands": [] |
| }, |
| + { |
| + "name": "agents", |
| + "title": "Agents", |
| + "glyph": "π οΈ", |
| + "blurb": "Steer Claude Code sessions on a shared workstation", |
| + "places": [ |
| + "chat" |
| + ], |
| + "listed": false, |
| + "quiet": false, |
| + "member": false, |
| + "symbol": "terminal", |
| + "mark": "", |
| + "openEvent": "", |
| + "requiresService": "", |
| + "surface": true, |
| + "companion": false, |
| + "chatOnly": false, |
| + "chatReadOnly": false, |
| + "defaultView": "split", |
| + "defaultCommand": "", |
| + "commands": [] |
| + }, |
| { |
| "name": "board", |
| "title": "Whiteboard", |
diff --git a/test/apps.test.mjs b/test/apps.test.mjs| index 5ee1a15..f952529 100644 |
| --- a/test/apps.test.mjs |
| +++ b/test/apps.test.mjs |
| @@ -95,6 +95,7 @@ import { app as vaultApp } from "../web/static/apps/vault.js"; // the Vault app |
| import { app as emojiApp, searchEmoji } from "../web/static/apps/emoji.js"; // registers /emoji (the command-panel reference app) |
| import { app as gifApp, pickRendition, renditionCandidates, gifItems, GIF_MAX_BYTES } from "../web/static/apps/gif.js"; // registers /gif |
| import { app as diceApp, parseSides } from "../web/static/disabled-apps/dice.js"; // DISABLED quiet-app POC (issue #227) β not wired into shell.js |
| +import { app as agentsApp } from "../web/static/apps/agents.js"; // the agents fleet board (session.* β remote Claude Code sessions) |
| // Each app's fold is synthesised from its `events` map by defineApp β the same |
| // (state, event) β state reducer the framework folds with. Test it directly. |
| @@ -2878,5 +2879,21 @@ await test("newlyDirtyMentionSids: matches, cap, and the already-dirty gate (the |
| assert.equal(marked[99], 250); |
| }); |
| +await test("agents: the session cap evicts oldest ended sessions first, then oldest of all", () => { |
| + // Vectors (test/event-vectors/agents.json) pin the per-event folds; the cap |
| + // is a cross-event property so it lives here. 50 live sessions + one more: |
| + // with an ended one present, the ended one goes; with none, the oldest goes. |
| + let s; |
| + for (let i = 0; i < 50; i++) s = agentsApp.reduce(s, { type: "session.spawn", id: "s" + i, session: "s" + i, ts: i }); |
| + s = agentsApp.reduce(s, { type: "session.end", id: "s7", ts: 100 }); |
| + s = agentsApp.reduce(s, { type: "session.spawn", id: "fresh", session: "fresh", ts: 200 }); |
| + assert.equal(s.sessions.length, 50); |
| + assert.ok(!s.sessions.some((x) => x.id === "s7"), "the ended session is the one evicted"); |
| + assert.ok(s.sessions.some((x) => x.id === "fresh")); |
| + s = agentsApp.reduce(s, { type: "session.spawn", id: "fresh2", session: "fresh2", ts: 300 }); |
| + assert.equal(s.sessions.length, 50); |
| + assert.ok(!s.sessions.some((x) => x.id === "s0"), "with no ended sessions, the oldest goes"); |
| +}); |
| + |
| console.log(failures === 0 ? "\nALL PASSED" : `\n${failures} FAILED`); |
| process.exit(failures === 0 ? 0 : 1); |
diff --git a/test/event-vectors.test.mjs b/test/event-vectors.test.mjs| index eb997ae..bd16382 100644 |
| --- a/test/event-vectors.test.mjs |
| +++ b/test/event-vectors.test.mjs |
| @@ -15,6 +15,9 @@ import { app as vaultApp } from "../web/static/apps/vault.js"; |
| // dispatch gate, with the shipped webhooks app loaded (it must claim nothing). |
| import { isAppType, foldEvent } from "../web/static/apps.js"; |
| import "../web/static/apps/webhooks.js"; |
| +// π€ Agents fold against the REAL reducer too β the session.* vocabulary is |
| +// the contract the workstation bridge (`lchat agents run`) builds against. |
| +import { app as agentsApp } from "../web/static/apps/agents.js"; |
| // Import chat.js fold handlers β the ones we're testing. |
| // The app.hostEvents from web/static/apps/chat.js. |
| @@ -707,4 +710,40 @@ allVectors.vault.forEach((vector) => { |
| }); |
| }); |
| +// π€ Agents vectors (docs/superpowers/specs/2026-08-05-eleven-claude-agents-design.md) |
| +// β folded by the REAL reducer: the fleet-board fold's whole job is clamping |
| +// what an unsigned group-thread payload claims, so a reconstruction would test |
| +// the wrong thing. The bridge (`lchat agents run`) builds against this file. |
| +allVectors.agents.forEach((vector) => { |
| + test(`agents: ${vector.name} β folds to the expected state`, () => { |
| + let state; |
| + if (vector.event_sequence) { |
| + for (const event of vector.event_sequence) state = agentsApp.reduce(state, event); |
| + assert.deepStrictEqual(state.sessions, vector.expected_state.sessions, "agents state should match the vector exactly"); |
| + } else { |
| + state = { sessions: (vector.initial_state && vector.initial_state.sessions) || [] }; |
| + const fold = agentsApp.events[vector.event.type]; |
| + assert.strictEqual(typeof fold, "function", `agents app owns ${vector.event.type}`); |
| + const result = fold(state, vector.event); |
| + assert.deepStrictEqual(result.sessions, vector.expected_state_update.sessions, "agents state should match the vector exactly"); |
| + } |
| + }); |
| + |
| + if ("expected_group_chat_projection" in vector) { |
| + test(`agents: ${vector.name} β group feed projection matches`, () => { |
| + const event = vector.expected_group_chat_projection_of || vector.event; |
| + const line = agentsApp.groupFeedMessage(event); |
| + const expected = vector.expected_group_chat_projection; |
| + if (expected === null) { |
| + assert.strictEqual(line, null, "projection should be silent"); |
| + return; |
| + } |
| + const text = typeof line === "string" ? line : line && line.text; |
| + const action = typeof line === "object" && line !== null && !!line.action; |
| + assert.strictEqual(text, expected.text, "projection text should match"); |
| + assert.strictEqual(action, !!expected.action, "action-vs-bubble should match"); |
| + }); |
| + } |
| +}); |
| + |
| if (fails) process.exit(1); |
diff --git a/test/event-vectors/agents.json b/test/event-vectors/agents.json| new file mode 100644 |
| index 0000000..9ddf6fc |
| --- /dev/null |
| +++ b/test/event-vectors/agents.json |
| @@ -0,0 +1,197 @@ |
| +{ |
| + "description": "Agents vectors: shared remote Claude Code sessions fronted by an Eleven group (docs/superpowers/specs/2026-08-05-eleven-claude-agents-design.md). The workstation bridge posts durable session.* events; every member's fold converges on the same fleet board. Payloads aren't sender-signed, so the fold re-validates everything regardless of what the bridge checked. Source of truth: web/static/apps/agents.js.", |
| + "app": "agents", |
| + "source": "web/static/apps/agents.js", |
| + "state_shape": { "sessions": [{ "id": "string (^[A-Za-z0-9_-]{1,64}$)", "name": "string (β€80, from the wire field `session` β `name` is transport-injected)", "flavor": "\"scoped\" | \"plain\" (anything else folds to scoped)", "root": "string (β€300)", "worktree": "string (β€300)", "by": "string (β€80, who asked)", "ts": "number (epoch ms)", "state": "running | idle | waiting | dead | ended", "activity": "string (β€200)", "statusTs": "number", "prompts": "number", "turns": "number", "result": "string (β€500)", "approvals": [{ "rid": "string", "tool": "string (β€120)", "digest": "string (β€200)", "state": "pending | allowed | denied", "by": "string (decider, from transport name)" }], "endedAt": "number", "reason": "string (β€120)" }] }, |
| + "notes": [ |
| + "The session's human name rides the wire as `session`, never `name` β `name`/`ts`/`uid` are transport-injected (emitter identity), so the bridge cannot use them for payload.", |
| + "session.spawn is idempotent by id and never replaces: an existing id is a no-op (a re-spawn is a new id). Max 50 sessions per thread, oldest ended evicted first.", |
| + "session.status on an unknown session, with an unknown state, or after session.end is a no-op β ended is terminal; a late status frame can't resurrect a session.", |
| + "session.approval: the bridge posts the pending request; a member's decision folds onto it. First decision wins β a decided approval never flips, and a decision for an unposted rid is a no-op.", |
| + "session.end drops pending approvals (they die with the session) and is the bridge's cue to remove a scoped session's worktree (close-session-closes-worktree, decided 2026-08-28; committed branches survive).", |
| + "session.tool / session.stream are ephemeral (relay-only) types claimed with no-op folds β a hostile stored copy folds harmlessly.", |
| + "Per the accepted design, session.prompt IS the member's chat message (one event, two projections); board state keeps only a count." |
| + ], |
| + "vectors": [ |
| + { |
| + "name": "spawn", |
| + "description": "The bridge announces a new scoped session; every canonical field materialises.", |
| + "initial_state": { "sessions": [] }, |
| + "event": { |
| + "type": "session.spawn", |
| + "id": "s1", |
| + "session": "fix-login", |
| + "flavor": "scoped", |
| + "root": "~/github.com/elevenmessenger/messenger", |
| + "worktree": "wt-fix-login", |
| + "by": "Paul", |
| + "name": "Claude", |
| + "ts": 1700000000000 |
| + }, |
| + "expected_state_update": { |
| + "sessions": [ |
| + { "id": "s1", "name": "fix-login", "flavor": "scoped", "root": "~/github.com/elevenmessenger/messenger", "worktree": "wt-fix-login", "by": "Paul", "ts": 1700000000000, "state": "running", "activity": "", "statusTs": 1700000000000, "prompts": 0, "turns": 0, "result": "", "approvals": [], "endedAt": 0, "reason": "" } |
| + ] |
| + }, |
| + "expected_group_chat_projection": { "text": "started session βfix-loginβ on ~/github.com/elevenmessenger/messenger", "action": true } |
| + }, |
| + { |
| + "name": "spawn_duplicate_id_noop", |
| + "description": "A second spawn with an existing id never replaces β history can't be rewritten by a later event.", |
| + "initial_state": { |
| + "sessions": [ |
| + { "id": "s1", "name": "fix-login", "flavor": "scoped", "root": "r", "worktree": "", "by": "Paul", "ts": 1700000000000, "state": "running", "activity": "", "statusTs": 1700000000000, "prompts": 0, "turns": 0, "result": "", "approvals": [], "endedAt": 0, "reason": "" } |
| + ] |
| + }, |
| + "event": { "type": "session.spawn", "id": "s1", "session": "impostor", "flavor": "plain", "name": "Mallory", "ts": 1700000009000 }, |
| + "expected_state_update": { |
| + "sessions": [ |
| + { "id": "s1", "name": "fix-login", "flavor": "scoped", "root": "r", "worktree": "", "by": "Paul", "ts": 1700000000000, "state": "running", "activity": "", "statusTs": 1700000000000, "prompts": 0, "turns": 0, "result": "", "approvals": [], "endedAt": 0, "reason": "" } |
| + ] |
| + } |
| + }, |
| + { |
| + "name": "spawn_bad_flavor_folds_scoped", |
| + "description": "A hostile or future flavor value folds to the safe reading: scoped.", |
| + "initial_state": { "sessions": [] }, |
| + "event": { "type": "session.spawn", "id": "s2", "session": "ops", "flavor": "root-of-everything", "name": "Claude", "ts": 1700000001000 }, |
| + "expected_state_update": { |
| + "sessions": [ |
| + { "id": "s2", "name": "ops", "flavor": "scoped", "root": "", "worktree": "", "by": "", "ts": 1700000001000, "state": "running", "activity": "", "statusTs": 1700000001000, "prompts": 0, "turns": 0, "result": "", "approvals": [], "endedAt": 0, "reason": "" } |
| + ] |
| + } |
| + }, |
| + { |
| + "name": "spawn_missing_id_noop", |
| + "description": "An event without a usable id is hopeless and folds to a no-op.", |
| + "initial_state": { "sessions": [] }, |
| + "event": { "type": "session.spawn", "session": "ghost", "name": "Mallory", "ts": 1700000002000 }, |
| + "expected_state_update": { "sessions": [] } |
| + }, |
| + { |
| + "name": "status_updates_board", |
| + "description": "session.status is board truth: state + the one-line activity + its timestamp.", |
| + "initial_state": { |
| + "sessions": [ |
| + { "id": "s1", "name": "fix-login", "flavor": "scoped", "root": "r", "worktree": "", "by": "Paul", "ts": 1700000000000, "state": "running", "activity": "", "statusTs": 1700000000000, "prompts": 0, "turns": 0, "result": "", "approvals": [], "endedAt": 0, "reason": "" } |
| + ] |
| + }, |
| + "event": { "type": "session.status", "id": "s1", "state": "waiting", "activity": "go test ./internal/bot", "name": "Claude", "ts": 1700000003000 }, |
| + "expected_state_update": { |
| + "sessions": [ |
| + { "id": "s1", "name": "fix-login", "flavor": "scoped", "root": "r", "worktree": "", "by": "Paul", "ts": 1700000000000, "state": "waiting", "activity": "go test ./internal/bot", "statusTs": 1700000003000, "prompts": 0, "turns": 0, "result": "", "approvals": [], "endedAt": 0, "reason": "" } |
| + ] |
| + }, |
| + "expected_group_chat_projection": null |
| + }, |
| + { |
| + "name": "status_unknown_session_or_state_noop", |
| + "description": "A status for an unknown session id folds to a no-op (and an unknown state value would too).", |
| + "initial_state": { "sessions": [] }, |
| + "event": { "type": "session.status", "id": "s9", "state": "running", "activity": "x", "name": "Claude", "ts": 1700000003000 }, |
| + "expected_state_update": { "sessions": [] } |
| + }, |
| + { |
| + "name": "prompt_counts_and_projects", |
| + "description": "A member steers the session: the prompt IS their chat message (action-line projection for now); the board counts it.", |
| + "initial_state": { |
| + "sessions": [ |
| + { "id": "s1", "name": "fix-login", "flavor": "scoped", "root": "r", "worktree": "", "by": "Paul", "ts": 1700000000000, "state": "running", "activity": "", "statusTs": 1700000000000, "prompts": 0, "turns": 0, "result": "", "approvals": [], "endedAt": 0, "reason": "" } |
| + ] |
| + }, |
| + "event": { "type": "session.prompt", "id": "s1", "session": "fix-login", "text": "fix the flaky reminder test", "name": "Paul", "uid": "u_paul", "ts": 1700000004000 }, |
| + "expected_state_update": { |
| + "sessions": [ |
| + { "id": "s1", "name": "fix-login", "flavor": "scoped", "root": "r", "worktree": "", "by": "Paul", "ts": 1700000000000, "state": "running", "activity": "", "statusTs": 1700000000000, "prompts": 1, "turns": 0, "result": "", "approvals": [], "endedAt": 0, "reason": "" } |
| + ] |
| + }, |
| + "expected_group_chat_projection": { "text": "asked fix-login: βfix the flaky reminder testβ", "action": true } |
| + }, |
| + { |
| + "name": "turn_counts_and_projects", |
| + "description": "The assistant's completed turn: a bubble in the app's voice, tagged with the session; the board counts it.", |
| + "initial_state": { |
| + "sessions": [ |
| + { "id": "s1", "name": "fix-login", "flavor": "scoped", "root": "r", "worktree": "", "by": "Paul", "ts": 1700000000000, "state": "running", "activity": "", "statusTs": 1700000000000, "prompts": 1, "turns": 0, "result": "", "approvals": [], "endedAt": 0, "reason": "" } |
| + ] |
| + }, |
| + "event": { "type": "session.turn", "id": "s1", "session": "fix-login", "text": "Found it β the ticker fires before the store hydrates.", "name": "Claude", "ts": 1700000005000 }, |
| + "expected_state_update": { |
| + "sessions": [ |
| + { "id": "s1", "name": "fix-login", "flavor": "scoped", "root": "r", "worktree": "", "by": "Paul", "ts": 1700000000000, "state": "running", "activity": "", "statusTs": 1700000000000, "prompts": 1, "turns": 1, "result": "", "approvals": [], "endedAt": 0, "reason": "" } |
| + ] |
| + }, |
| + "expected_group_chat_projection": { "text": "[fix-login] Found it β the ticker fires before the store hydrates.", "action": false } |
| + }, |
| + { |
| + "name": "approval_first_decision_wins", |
| + "description": "The bridge posts a pending approval; Ana allows it; Bob's later deny is a no-op β first decision wins, attributed by the transport name.", |
| + "event_sequence": [ |
| + { "type": "session.spawn", "id": "s1", "session": "fix-login", "flavor": "scoped", "root": "r", "by": "Paul", "name": "Claude", "ts": 1700000000000 }, |
| + { "type": "session.approval", "id": "s1", "rid": "a1", "session": "fix-login", "tool": "git push", "digest": "origin main", "state": "pending", "name": "Claude", "ts": 1700000006000 }, |
| + { "type": "session.approval", "id": "s1", "rid": "a1", "session": "fix-login", "tool": "git push", "state": "allowed", "name": "Ana", "uid": "u_ana", "ts": 1700000007000 }, |
| + { "type": "session.approval", "id": "s1", "rid": "a1", "session": "fix-login", "tool": "git push", "state": "denied", "name": "Bob", "uid": "u_bob", "ts": 1700000008000 } |
| + ], |
| + "expected_state": { |
| + "sessions": [ |
| + { "id": "s1", "name": "fix-login", "flavor": "scoped", "root": "r", "worktree": "", "by": "Paul", "ts": 1700000000000, "state": "running", "activity": "", "statusTs": 1700000000000, "prompts": 0, "turns": 0, "result": "", "approvals": [{ "rid": "a1", "tool": "git push", "digest": "origin main", "state": "allowed", "by": "Ana" }], "endedAt": 0, "reason": "" } |
| + ] |
| + } |
| + }, |
| + { |
| + "name": "approval_decision_without_pending_noop", |
| + "description": "A decision for a request the log never saw folds to a no-op β approvals exist only once the bridge posts them.", |
| + "initial_state": { |
| + "sessions": [ |
| + { "id": "s1", "name": "fix-login", "flavor": "scoped", "root": "r", "worktree": "", "by": "Paul", "ts": 1700000000000, "state": "running", "activity": "", "statusTs": 1700000000000, "prompts": 0, "turns": 0, "result": "", "approvals": [], "endedAt": 0, "reason": "" } |
| + ] |
| + }, |
| + "event": { "type": "session.approval", "id": "s1", "rid": "a9", "state": "allowed", "name": "Mallory", "ts": 1700000008000 }, |
| + "expected_state_update": { |
| + "sessions": [ |
| + { "id": "s1", "name": "fix-login", "flavor": "scoped", "root": "r", "worktree": "", "by": "Paul", "ts": 1700000000000, "state": "running", "activity": "", "statusTs": 1700000000000, "prompts": 0, "turns": 0, "result": "", "approvals": [], "endedAt": 0, "reason": "" } |
| + ] |
| + } |
| + }, |
| + { |
| + "name": "end_is_terminal", |
| + "description": "Closing a session: pending approvals die with it, and a late status frame can't resurrect it.", |
| + "event_sequence": [ |
| + { "type": "session.spawn", "id": "s1", "session": "fix-login", "flavor": "scoped", "root": "r", "by": "Paul", "name": "Claude", "ts": 1700000000000 }, |
| + { "type": "session.approval", "id": "s1", "rid": "a1", "session": "fix-login", "tool": "git push", "state": "pending", "name": "Claude", "ts": 1700000006000 }, |
| + { "type": "session.end", "id": "s1", "session": "fix-login", "reason": "closed", "name": "Paul", "uid": "u_paul", "ts": 1700000009000 }, |
| + { "type": "session.status", "id": "s1", "state": "running", "activity": "zombie", "name": "Claude", "ts": 1700000010000 } |
| + ], |
| + "expected_state": { |
| + "sessions": [ |
| + { "id": "s1", "name": "fix-login", "flavor": "scoped", "root": "r", "worktree": "", "by": "Paul", "ts": 1700000000000, "state": "ended", "activity": "", "statusTs": 1700000000000, "prompts": 0, "turns": 0, "result": "", "approvals": [], "endedAt": 1700000009000, "reason": "closed" } |
| + ] |
| + }, |
| + "expected_group_chat_projection_of": { "type": "session.end", "id": "s1", "session": "fix-login", "reason": "closed", "name": "Paul", "uid": "u_paul", "ts": 1700000009000 }, |
| + "expected_group_chat_projection": { "text": "closed session βfix-loginβ", "action": true } |
| + }, |
| + { |
| + "name": "result_keeps_latest_summary", |
| + "description": "A milestone summary lands on the board (latest wins) and speaks as a bubble.", |
| + "initial_state": { |
| + "sessions": [ |
| + { "id": "s1", "name": "fix-login", "flavor": "scoped", "root": "r", "worktree": "", "by": "Paul", "ts": 1700000000000, "state": "running", "activity": "", "statusTs": 1700000000000, "prompts": 1, "turns": 2, "result": "", "approvals": [], "endedAt": 0, "reason": "" } |
| + ] |
| + }, |
| + "event": { "type": "session.result", "id": "s1", "session": "fix-login", "summary": "2 files changed, go test green", "name": "Claude", "ts": 1700000011000 }, |
| + "expected_state_update": { |
| + "sessions": [ |
| + { "id": "s1", "name": "fix-login", "flavor": "scoped", "root": "r", "worktree": "", "by": "Paul", "ts": 1700000000000, "state": "running", "activity": "", "statusTs": 1700000000000, "prompts": 1, "turns": 2, "result": "2 files changed, go test green", "approvals": [], "endedAt": 0, "reason": "" } |
| + ] |
| + }, |
| + "expected_group_chat_projection": { "text": "[fix-login] β 2 files changed, go test green", "action": false } |
| + }, |
| + { |
| + "name": "ephemeral_types_fold_to_noop", |
| + "description": "A hostile client storing a session.stream frame as a real message folds harmlessly β the type is claimed with a no-op fold.", |
| + "initial_state": { "sessions": [] }, |
| + "event": { "type": "session.stream", "id": "s1", "text": "partialβ¦", "name": "Mallory", "ts": 1700000012000 }, |
| + "expected_state_update": { "sessions": [] } |
| + } |
| + ] |
| +} |
diff --git a/test/event-vectors/index.js b/test/event-vectors/index.js| index b08e840..d96305b 100644 |
| --- a/test/event-vectors/index.js |
| +++ b/test/event-vectors/index.js |
| @@ -13,6 +13,7 @@ export const adminVectors = JSON.parse(readFileSync(join(__dirname, "admin.json" |
| export const viewsVectors = JSON.parse(readFileSync(join(__dirname, "views.json"), "utf8")); |
| export const vaultVectors = JSON.parse(readFileSync(join(__dirname, "vault.json"), "utf8")); |
| export const webhooksVectors = JSON.parse(readFileSync(join(__dirname, "webhooks.json"), "utf8")); |
| +export const agentsVectors = JSON.parse(readFileSync(join(__dirname, "agents.json"), "utf8")); |
| // Flatten all vectors for easy iteration in tests |
| export const allVectors = { |
| @@ -25,4 +26,5 @@ export const allVectors = { |
| views: viewsVectors.vectors || [], |
| vault: vaultVectors.vectors || [], |
| webhooks: webhooksVectors.vectors || [], |
| + agents: agentsVectors.vectors || [], |
| }; |
diff --git a/web/static/apps/agents.js b/web/static/apps/agents.js| new file mode 100644 |
| index 0000000..2bd8699 |
| --- /dev/null |
| +++ b/web/static/apps/agents.js |
| @@ -0,0 +1,411 @@ |
| +// The agents mini-app: a group chat fronting shared remote Claude Code |
| +// sessions (docs/superpowers/specs/2026-08-05-eleven-claude-agents-design.md). |
| +// The workstation bridge (`lchat agents run`) is an ordinary E2EE bot member |
| +// that posts durable session.* events into the group thread; this app folds |
| +// them into the fleet board every member converges on. The server only ever |
| +// relays ciphertext β it never learns a session, a repo, or a box exists. |
| +// |
| +// This is the CONTRACT half: the event vocabulary + fold live here (pinned by |
| +// test/event-vectors/agents.json) so the bridge and any native surface build |
| +// against a tested spec. The reducer validates everything β strangers' events |
| +// are first-class inputs in a group thread, and the bridge's own checks count |
| +// for nothing here (payloads aren't sender-signed). |
| +// |
| +// Relative import (not "/static/apps.js") so the reducer is exercisable in |
| +// Node tests while still resolving to /static/apps.js in the browser. |
| +import { defineApp, namespace } from "../apps.js"; |
| + |
| +// The wire vocabulary is session.* (the spec's naming, decided 2026-08-28) β |
| +// the app's NAME stays "agents", so the namespace is declared separately. |
| +// N.cls still derives CSS classes from the same prefix ("session-card"). |
| +const N = namespace("session"); |
| + |
| +// Clamps β mirrored by the bridge (which refuses rather than truncates, the |
| +// sidecar convention); the fold clamps silently so a hostile event can't |
| +// bloat state. Keep the two sides in step. |
| +const MAX_SESSIONS = 50; // per thread; oldest ended evicted first |
| +const MAX_APPROVALS = 20; // per session |
| +const ID_RE = /^[A-Za-z0-9_-]{1,64}$/; |
| +const STATES = ["running", "idle", "waiting", "dead"]; |
| + |
| +const str = (v, max) => (typeof v === "string" ? v.slice(0, max) : ""); |
| + |
| +// One canonical session shape β every field always present so snapshots, |
| +// vectors, and any future native fold compare byte-for-byte. |
| +function canonSession(e) { |
| + return { |
| + id: e.id, |
| + // The human name rides as `session` on the wire β `name` is transport- |
| + // injected (the emitter's member name) on every event, so it can't carry |
| + // payload (README invariant #3). |
| + name: str(e.session, 80), |
| + // Two flavors, chosen at spawn and visible everywhere (the spec's |
| + // "nobody wonders which kind of hands are on the box"): scoped = pinned |
| + // to a root/worktree; plain = the whole workstation. Anything else is a |
| + // hostile or future value and folds to the safe reading: scoped. |
| + flavor: e.flavor === "plain" ? "plain" : "scoped", |
| + root: str(e.root, 300), |
| + worktree: str(e.worktree, 300), |
| + by: str(e.by, 80), // who asked for it (payload β the emitter is the bridge) |
| + ts: typeof e.ts === "number" ? e.ts : 0, |
| + state: "running", |
| + activity: "", |
| + statusTs: typeof e.ts === "number" ? e.ts : 0, |
| + prompts: 0, |
| + turns: 0, |
| + result: "", |
| + approvals: [], |
| + endedAt: 0, |
| + reason: "", |
| + }; |
| +} |
| + |
| +function withSession(s, id, fn) { |
| + if (typeof id !== "string" || !ID_RE.test(id)) return s; |
| + const i = (s.sessions || []).findIndex((x) => x.id === id); |
| + if (i < 0) return s; |
| + const next = fn(s.sessions[i]); |
| + if (!next || next === s.sessions[i]) return s; |
| + const sessions = [...s.sessions]; |
| + sessions[i] = next; |
| + return { ...s, sessions }; |
| +} |
| + |
| +export const app = defineApp({ |
| + name: "agents", |
| + namespace: "session", |
| + title: "Agents", |
| + blurb: "Steer Claude Code sessions on a shared workstation", // sidebar subtitle (sync w/ native) |
| + glyph: "π οΈ", |
| + symbol: "terminal", // native chrome: SF Symbols name (docs/icons.md) |
| + // Sessions are steered together β the app lives in a group chat beside the |
| + // bridge member. One shared instance; every member sees the same board. |
| + places: ["chat"], |
| + placeNote: "Agent sessions are steered together β the board lives in a group chat.", |
| + // Dark until the bridge ships: not user-instantiable yet, so the catalogue |
| + // doesn't offer an app with nothing behind it. The bridge PR flips this to |
| + // listed (the default) and carries the changelog entry. |
| + listed: false, |
| + initialState: () => ({ sessions: [] }), |
| + events: { |
| + // session.spawn β a new session exists. Emitted by the bridge; idempotent |
| + // by id (a re-spawn is a NEW id, so an existing id is a no-op, never a |
| + // replace β history can't be rewritten by a later event). |
| + [N.ev("spawn")]: (s, e) => { |
| + if (typeof e.id !== "string" || !ID_RE.test(e.id)) return s; |
| + const sessions = s.sessions || []; |
| + if (sessions.some((x) => x.id === e.id)) return s; |
| + const next = [...sessions, canonSession(e)]; |
| + // Cap the board: evict oldest ended sessions first, then oldest of all β |
| + // a runaway (or hostile) spawner can't grow every member's state forever. |
| + while (next.length > MAX_SESSIONS) { |
| + const endedIdx = next.findIndex((x) => x.state === "ended"); |
| + next.splice(endedIdx >= 0 ? endedIdx : 0, 1); |
| + } |
| + return { ...s, sessions: next }; |
| + }, |
| + // session.status β board truth: state + the one-line activity. Unknown |
| + // session or unknown state folds to a no-op; an ended session is terminal |
| + // (a late status frame can't resurrect it). |
| + [N.ev("status")]: (s, e) => |
| + withSession(s, e.id, (sess) => { |
| + if (sess.state === "ended" || !STATES.includes(e.state)) return null; |
| + return { |
| + ...sess, |
| + state: e.state, |
| + activity: str(e.activity, 200), |
| + statusTs: typeof e.ts === "number" ? e.ts : sess.statusTs, |
| + }; |
| + }), |
| + // session.prompt β a member steering the session. The steering history IS |
| + // the log (and, per the accepted design, the prompt IS the member's chat |
| + // message β one event, two projections); board state keeps only a count. |
| + [N.ev("prompt")]: (s, e) => |
| + withSession(s, e.id, (sess) => |
| + sess.state === "ended" ? null : { ...sess, prompts: sess.prompts + 1 }, |
| + ), |
| + // session.turn β the assistant's reply, one durable event per completed |
| + // turn. The text lives in the log/feed projection; the board counts it. |
| + [N.ev("turn")]: (s, e) => |
| + withSession(s, e.id, (sess) => ({ ...sess, turns: sess.turns + 1 })), |
| + // session.result β a milestone card (files changed, tests, links). The |
| + // board keeps the latest summary line. |
| + [N.ev("result")]: (s, e) => |
| + withSession(s, e.id, (sess) => { |
| + const summary = str(e.summary, 500); |
| + return summary ? { ...sess, result: summary } : null; |
| + }), |
| + // session.approval β the audit trail of authority used. The bridge posts |
| + // the pending request ({rid, tool, digest, state:"pending"}); a member's |
| + // decision ({rid, state:"allowed"|"denied"}) folds onto it. First decision |
| + // wins (flat authority): a decided approval never flips, and a decision |
| + // for a request the log never saw is a no-op. |
| + [N.ev("approval")]: (s, e) => |
| + withSession(s, e.id, (sess) => { |
| + const rid = typeof e.rid === "string" && ID_RE.test(e.rid) ? e.rid : ""; |
| + if (!rid) return null; |
| + const approvals = sess.approvals || []; |
| + const i = approvals.findIndex((a) => a.rid === rid); |
| + if (e.state === "pending") { |
| + if (i >= 0 || approvals.length >= MAX_APPROVALS) return null; |
| + return { |
| + ...sess, |
| + approvals: [ |
| + ...approvals, |
| + { rid, tool: str(e.tool, 120), digest: str(e.digest, 200), state: "pending", by: "" }, |
| + ], |
| + }; |
| + } |
| + if (e.state !== "allowed" && e.state !== "denied") return null; |
| + if (i < 0 || approvals[i].state !== "pending") return null; |
| + const next = [...approvals]; |
| + next[i] = { ...next[i], state: e.state, by: str(e.name, 80) }; |
| + return { ...sess, approvals: next }; |
| + }), |
| + // session.end β lifecycle honesty. Closing a session is a member (or |
| + // bridge) act; the bridge removes a scoped session's worktree on it (the |
| + // accepted design: close-session-closes-worktree; committed branches |
| + // survive). Pending approvals die with the session. |
| + [N.ev("end")]: (s, e) => |
| + withSession(s, e.id, (sess) => { |
| + if (sess.state === "ended") return null; |
| + return { |
| + ...sess, |
| + state: "ended", |
| + activity: "", |
| + endedAt: typeof e.ts === "number" ? e.ts : 0, |
| + reason: str(e.reason, 120), |
| + approvals: (sess.approvals || []).filter((a) => a.state !== "pending"), |
| + }; |
| + }), |
| + // Ephemeral tail β live tool chatter + text deltas (relay-only frames, |
| + // handled in the `ephemeral` hook). Claimed here with no-op folds so the |
| + // types route to this app and a hostile stored copy folds harmlessly. |
| + [N.ev("tool")]: (s) => s, |
| + [N.ev("stream")]: (s) => s, |
| + }, |
| + |
| + // Feed projections β how session.* events speak in the group conversation. |
| + // Action lines are actor-neutral (the host prefixes "You"/the emitter's |
| + // name); message bubbles render in the app's voice with the session tagged. |
| + groupFeedMessage(e) { |
| + switch (e.type) { |
| + case N.ev("spawn"): { |
| + const tag = str(e.session, 80) || str(e.id, 64); |
| + const where = e.flavor === "plain" ? "the full workstation" : str(e.root, 300) || "a project"; |
| + return { text: `started session β${tag}β on ${where}`, action: true }; |
| + } |
| + case N.ev("prompt"): { |
| + const text = str(e.text, 300); |
| + return text ? { text: `asked ${str(e.session, 80) || "the session"}: β${text}β`, action: true } : null; |
| + } |
| + case N.ev("turn"): { |
| + const text = str(e.text, 4000); |
| + return text ? `[${str(e.session, 80) || "session"}] ${text}` : null; |
| + } |
| + case N.ev("result"): { |
| + const summary = str(e.summary, 500); |
| + return summary ? `[${str(e.session, 80) || "session"}] β ${summary}` : null; |
| + } |
| + case N.ev("approval"): { |
| + if (e.state === "pending") { |
| + const tool = str(e.tool, 120); |
| + return tool ? `[${str(e.session, 80) || "session"}] wants to run ${tool} β open Agents to decide` : null; |
| + } |
| + if (e.state === "allowed" || e.state === "denied") { |
| + const tool = str(e.tool, 120) || "it"; |
| + return { text: `${e.state} ${tool} on ${str(e.session, 80) || "the session"}`, action: true }; |
| + } |
| + return null; |
| + } |
| + case N.ev("end"): |
| + return { text: `closed session β${str(e.session, 80) || str(e.id, 64)}β`, action: true }; |
| + default: |
| + return null; // status/tool/stream are board-only |
| + } |
| + }, |
| + |
| + // Sidebar presence: a spawn or a finished result is worth a line; the |
| + // steering churn stays quiet (the board updates live anyway). |
| + preview: (e) => { |
| + if (e.type === N.ev("spawn")) return `π οΈ session β${str(e.session, 80)}β started`; |
| + if (e.type === N.ev("result")) return `β ${str(e.summary, 100)}`; |
| + return null; |
| + }, |
| + unread: (e) => e.type === N.ev("spawn") || e.type === N.ev("result") || (e.type === N.ev("approval") && e.state === "pending"), |
| + |
| + // Pockets learn when a session is blocked on a human: a pending approval |
| + // notifies live (the SW path needs no hook β the bridge sets notice on the |
| + // event when it wants a push). |
| + notify(e, ctx) { |
| + if (e.type !== N.ev("approval") || e.state !== "pending") return null; |
| + return { title: "π οΈ Approval needed", body: `${str(e.session, 80) || "A session"} wants to run ${str(e.tool, 120) || "a tool"}` }; |
| + }, |
| + |
| + // Live tail: session.tool / session.stream frames update the focused card's |
| + // activity line in place. Transient by design β never folded, never stored; |
| + // devices that missed a frame lose nothing durable (the next session.status |
| + // event carries the board truth). |
| + ephemeral(e, ctx) { |
| + if (e.type !== N.ev("tool") && e.type !== N.ev("stream")) return; |
| + const id = typeof e.id === "string" && ID_RE.test(e.id) ? e.id : ""; |
| + if (!id) return; |
| + const line = document.querySelector(`.${N.cls("live")}[data-sid="${id}"]`); |
| + if (line) line.textContent = str(e.line || e.text, 200); |
| + }, |
| + |
| + styles: ` |
| +.session-board { display: flex; flex-direction: column; gap: 10px; padding: 4px 0; } |
| +.session-empty { color: var(--muted); font-size: 13px; padding: 18px 6px; text-align: center; } |
| +.session-card { |
| + border: 1px solid var(--border); border-radius: 12px; background: var(--panel); |
| + padding: 10px 12px; display: flex; flex-direction: column; gap: 6px; |
| +} |
| +.session-card.ended { opacity: 0.6; } |
| +.session-head { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; } |
| +.session-name { font-weight: 600; font-size: 14px; color: var(--ink); } |
| +.session-flavor { |
| + font-size: 11px; color: var(--muted); border: 1px solid var(--border); |
| + border-radius: 6px; padding: 1px 6px; white-space: nowrap; |
| +} |
| +.session-state { font-size: 12px; color: var(--muted); margin-left: auto; white-space: nowrap; } |
| +.session-state.waiting { color: var(--accent); } |
| +.session-activity, .session-live { font-size: 12px; color: var(--muted); overflow-wrap: anywhere; } |
| +.session-result { font-size: 12px; color: var(--ink); overflow-wrap: anywhere; } |
| +.session-meta { font-size: 11px; color: var(--muted); } |
| +.session-approval { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; font-size: 12px; color: var(--ink); } |
| +.session-approve-btn { |
| + border: 1px solid var(--border); background: transparent; color: var(--ink); |
| + border-radius: 8px; font: inherit; font-size: 12px; padding: 3px 10px; |
| + cursor: var(--ui-cursor, pointer); min-height: 24px; |
| +} |
| +.session-approve-btn.allow { border-color: var(--accent); } |
| +.session-end-btn { |
| + border: none; background: transparent; padding: 0; font: inherit; font-size: 12px; |
| + color: var(--link); cursor: var(--ui-cursor, pointer); text-decoration: underline; |
| + text-underline-offset: 2px; align-self: flex-start; |
| +} |
| +`, |
| + |
| + // The fleet board: one card per session, rebuilt from state every fold |
| + // (small surface β the default wipe-rebuild is right). Ended sessions sink |
| + // to the bottom, dimmed, so lifecycle stays honest without hiding history. |
| + render(state, el, ctx) { |
| + const board = document.createElement("div"); |
| + board.className = N.cls("board"); |
| + const sessions = (state && state.sessions) || []; |
| + if (!sessions.length) { |
| + const empty = document.createElement("div"); |
| + empty.className = N.cls("empty"); |
| + empty.textContent = "No sessions yet β a workstation bridge posts its Claude Code sessions here."; |
| + board.appendChild(empty); |
| + el.appendChild(board); |
| + return; |
| + } |
| + const order = [...sessions].sort((a, b) => (a.state === "ended") - (b.state === "ended") || a.ts - b.ts); |
| + for (const sess of order) board.appendChild(buildCard(sess, ctx)); |
| + el.appendChild(board); |
| + }, |
| +}); |
| + |
| +function buildCard(sess, ctx) { |
| + const card = document.createElement("div"); |
| + card.className = N.cls("card") + (sess.state === "ended" ? " ended" : ""); |
| + |
| + const head = document.createElement("div"); |
| + head.className = N.cls("head"); |
| + const name = document.createElement("span"); |
| + name.className = N.cls("name"); |
| + name.textContent = sess.name || sess.id; |
| + head.appendChild(name); |
| + const flavor = document.createElement("span"); |
| + flavor.className = N.cls("flavor"); |
| + flavor.textContent = sess.flavor === "plain" ? "π₯ full workstation" : `π ${sess.root || "project"}`; |
| + head.appendChild(flavor); |
| + const stateEl = document.createElement("span"); |
| + stateEl.className = N.cls("state") + (sess.state === "waiting" ? " waiting" : ""); |
| + stateEl.textContent = |
| + sess.state === "ended" |
| + ? sess.reason || "ended" |
| + : sess.state === "waiting" |
| + ? "waiting for approval" |
| + : `${sess.state} Β· turn ${sess.turns.toLocaleString()}`; |
| + head.appendChild(stateEl); |
| + card.appendChild(head); |
| + |
| + if (sess.activity) { |
| + const act = document.createElement("div"); |
| + act.className = N.cls("activity"); |
| + act.textContent = `β ${sess.activity}`; |
| + card.appendChild(act); |
| + } |
| + if (sess.state !== "ended") { |
| + // The ephemeral hook writes the live tool/stream tail into this line. |
| + const live = document.createElement("div"); |
| + live.className = N.cls("live"); |
| + live.dataset.sid = sess.id; |
| + card.appendChild(live); |
| + } |
| + if (sess.result) { |
| + const res = document.createElement("div"); |
| + res.className = N.cls("result"); |
| + res.textContent = `β ${sess.result}`; |
| + card.appendChild(res); |
| + } |
| + |
| + for (const a of sess.approvals || []) { |
| + if (a.state !== "pending") continue; |
| + const row = document.createElement("div"); |
| + row.className = N.cls("approval"); |
| + const label = document.createElement("span"); |
| + label.textContent = `wants to run ${a.tool || "a tool"}${a.digest ? ` β ${a.digest}` : ""}`; |
| + row.appendChild(label); |
| + for (const decision of ["allowed", "denied"]) { |
| + const btn = document.createElement("button"); |
| + btn.type = "button"; |
| + btn.className = N.cls("approve-btn") + (decision === "allowed" ? " allow" : ""); |
| + btn.textContent = decision === "allowed" ? "Allow" : "Deny"; |
| + btn.setAttribute("aria-label", `${decision === "allowed" ? "Allow" : "Deny"} ${a.tool || "the tool"} on ${sess.name || sess.id}`); |
| + btn.onclick = () => |
| + ctx.emit({ type: N.ev("approval"), id: sess.id, rid: a.rid, tool: a.tool, session: sess.name, state: decision }); |
| + row.appendChild(btn); |
| + } |
| + card.appendChild(row); |
| + } |
| + |
| + if (sess.state !== "ended") { |
| + // Closing is a member act, per the accepted design: the bridge sees the |
| + // event, ends the Claude session, and removes a scoped session's worktree |
| + // (committed branches survive). Two-step inline confirm, like the |
| + // whiteboard's Clear β confirm() is dead in embedded webviews. |
| + const end = document.createElement("button"); |
| + end.type = "button"; |
| + end.className = N.cls("end-btn"); |
| + end.textContent = "Close session"; |
| + end.onclick = () => { |
| + if (!end.dataset.armed) { |
| + end.dataset.armed = "1"; |
| + end.textContent = "Close for everyone? (removes its worktree)"; |
| + setTimeout(() => { |
| + delete end.dataset.armed; |
| + if (end.isConnected) end.textContent = "Close session"; |
| + }, 4000); |
| + return; |
| + } |
| + ctx.emit({ type: N.ev("end"), id: sess.id, session: sess.name, reason: "closed" }); |
| + }; |
| + card.appendChild(end); |
| + } |
| + |
| + const meta = document.createElement("div"); |
| + meta.className = N.cls("meta"); |
| + const bits = []; |
| + if (sess.by) bits.push(`for ${sess.by}`); |
| + if (sess.prompts) bits.push(`${sess.prompts.toLocaleString()} ${sess.prompts === 1 ? "prompt" : "prompts"}`); |
| + if (bits.length) { |
| + meta.textContent = bits.join(" Β· "); |
| + card.appendChild(meta); |
| + } |
| + |
| + return card; |
| +} |
diff --git a/web/static/shell.js b/web/static/shell.js| index 676a2f5..39617eb 100644 |
| --- a/web/static/shell.js |
| +++ b/web/static/shell.js |
| @@ -127,6 +127,7 @@ import "/static/apps/bookmarks.js"; |
| import "/static/apps/poll.js"; |
| import "/static/apps/vault.js"; |
| import "/static/apps/board.js"; |
| +import "/static/apps/agents.js"; // shared remote Claude Code sessions (fleet board; bridge = lchat agents run) |
| import "/static/apps/connect.js"; |
| import "/static/apps/dnsimple.js"; |
| import "/static/apps/stripe.js"; |