// 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; }