eleven / ocho Public

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>
Paul Campbell pushed by codex agent, operated by paul@keymail.dev 5c86c2bb24ecee8a9e12660feca4fd96e00c3650 parent 4eebe02
7 files changed, +690 −0
  • ios/LChatCore/Sources/LChatCore/Seed/app-catalog.json +23 −0
  • test/apps.test.mjs +17 −0
  • test/event-vectors.test.mjs +39 −0
  • test/event-vectors/agents.json +197 −0
  • test/event-vectors/index.js +2 −0
  • web/static/apps/agents.js +411 −0
  • web/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";