| 1 | // The agents mini-app: a group chat fronting shared remote Claude Code |
| 2 | // sessions (docs/superpowers/specs/2026-08-05-eleven-claude-agents-design.md). |
| 3 | // The workstation bridge (`lchat agents run`) is an ordinary E2EE bot member |
| 4 | // that posts durable session.* events into the group thread; this app folds |
| 5 | // them into the fleet board every member converges on. The server only ever |
| 6 | // relays ciphertext — it never learns a session, a repo, or a box exists. |
| 7 | // |
| 8 | // This is the CONTRACT half: the event vocabulary + fold live here (pinned by |
| 9 | // test/event-vectors/agents.json) so the bridge and any native surface build |
| 10 | // against a tested spec. The reducer validates everything — strangers' events |
| 11 | // are first-class inputs in a group thread, and the bridge's own checks count |
| 12 | // for nothing here (payloads aren't sender-signed). |
| 13 | // |
| 14 | // Relative import (not "/static/apps.js") so the reducer is exercisable in |
| 15 | // Node tests while still resolving to /static/apps.js in the browser. |
| 16 | import { defineApp, namespace } from "../apps.js"; |
| 17 | |
| 18 | // The wire vocabulary is session.* (the spec's naming, decided 2026-08-28) — |
| 19 | // the app's NAME stays "agents", so the namespace is declared separately. |
| 20 | // N.cls still derives CSS classes from the same prefix ("session-card"). |
| 21 | const N = namespace("session"); |
| 22 | |
| 23 | // Clamps — mirrored by the bridge (which refuses rather than truncates, the |
| 24 | // sidecar convention); the fold clamps silently so a hostile event can't |
| 25 | // bloat state. Keep the two sides in step. |
| 26 | const MAX_SESSIONS = 50; // per thread; oldest ended evicted first |
| 27 | const MAX_APPROVALS = 20; // per session |
| 28 | const ID_RE = /^[A-Za-z0-9_-]{1,64}$/; |
| 29 | const STATES = ["running", "idle", "waiting", "dead"]; |
| 30 | |
| 31 | const str = (v, max) => (typeof v === "string" ? v.slice(0, max) : ""); |
| 32 | |
| 33 | // One canonical session shape — every field always present so snapshots, |
| 34 | // vectors, and any future native fold compare byte-for-byte. |
| 35 | function canonSession(e) { |
| 36 | return { |
| 37 | id: e.id, |
| 38 | // The human name rides as `session` on the wire — `name` is transport- |
| 39 | // injected (the emitter's member name) on every event, so it can't carry |
| 40 | // payload (README invariant #3). |
| 41 | name: str(e.session, 80), |
| 42 | // Two flavors, chosen at spawn and visible everywhere (the spec's |
| 43 | // "nobody wonders which kind of hands are on the box"): scoped = pinned |
| 44 | // to a root/worktree; plain = the whole workstation. Anything else is a |
| 45 | // hostile or future value and folds to the safe reading: scoped. |
| 46 | flavor: e.flavor === "plain" ? "plain" : "scoped", |
| 47 | root: str(e.root, 300), |
| 48 | worktree: str(e.worktree, 300), |
| 49 | by: str(e.by, 80), // who asked for it (payload — the emitter is the bridge) |
| 50 | ts: typeof e.ts === "number" ? e.ts : 0, |
| 51 | state: "running", |
| 52 | activity: "", |
| 53 | statusTs: typeof e.ts === "number" ? e.ts : 0, |
| 54 | prompts: 0, |
| 55 | turns: 0, |
| 56 | result: "", |
| 57 | approvals: [], |
| 58 | endedAt: 0, |
| 59 | reason: "", |
| 60 | }; |
| 61 | } |
| 62 | |
| 63 | function withSession(s, id, fn) { |
| 64 | if (typeof id !== "string" || !ID_RE.test(id)) return s; |
| 65 | const i = (s.sessions || []).findIndex((x) => x.id === id); |
| 66 | if (i < 0) return s; |
| 67 | const next = fn(s.sessions[i]); |
| 68 | if (!next || next === s.sessions[i]) return s; |
| 69 | const sessions = [...s.sessions]; |
| 70 | sessions[i] = next; |
| 71 | return { ...s, sessions }; |
| 72 | } |
| 73 | |
| 74 | export const app = defineApp({ |
| 75 | name: "agents", |
| 76 | namespace: "session", |
| 77 | title: "Agents", |
| 78 | blurb: "Steer Claude Code sessions on a shared workstation", // sidebar subtitle (sync w/ native) |
| 79 | glyph: "🛠️", |
| 80 | symbol: "terminal", // native chrome: SF Symbols name (docs/icons.md) |
| 81 | // Sessions are steered together — the app lives in a group chat beside the |
| 82 | // bridge member. One shared instance; every member sees the same board. |
| 83 | places: ["chat"], |
| 84 | placeNote: "Agent sessions are steered together — the board lives in a group chat.", |
| 85 | // Dark until the bridge ships: not user-instantiable yet, so the catalogue |
| 86 | // doesn't offer an app with nothing behind it. The bridge PR flips this to |
| 87 | // listed (the default) and carries the changelog entry. |
| 88 | listed: false, |
| 89 | initialState: () => ({ sessions: [] }), |
| 90 | events: { |
| 91 | // session.spawn — a new session exists. Emitted by the bridge; idempotent |
| 92 | // by id (a re-spawn is a NEW id, so an existing id is a no-op, never a |
| 93 | // replace — history can't be rewritten by a later event). |
| 94 | [N.ev("spawn")]: (s, e) => { |
| 95 | if (typeof e.id !== "string" || !ID_RE.test(e.id)) return s; |
| 96 | const sessions = s.sessions || []; |
| 97 | if (sessions.some((x) => x.id === e.id)) return s; |
| 98 | const next = [...sessions, canonSession(e)]; |
| 99 | // Cap the board: evict oldest ended sessions first, then oldest of all — |
| 100 | // a runaway (or hostile) spawner can't grow every member's state forever. |
| 101 | while (next.length > MAX_SESSIONS) { |
| 102 | const endedIdx = next.findIndex((x) => x.state === "ended"); |
| 103 | next.splice(endedIdx >= 0 ? endedIdx : 0, 1); |
| 104 | } |
| 105 | return { ...s, sessions: next }; |
| 106 | }, |
| 107 | // session.status — board truth: state + the one-line activity. Unknown |
| 108 | // session or unknown state folds to a no-op; an ended session is terminal |
| 109 | // (a late status frame can't resurrect it). |
| 110 | [N.ev("status")]: (s, e) => |
| 111 | withSession(s, e.id, (sess) => { |
| 112 | if (sess.state === "ended" || !STATES.includes(e.state)) return null; |
| 113 | return { |
| 114 | ...sess, |
| 115 | state: e.state, |
| 116 | activity: str(e.activity, 200), |
| 117 | statusTs: typeof e.ts === "number" ? e.ts : sess.statusTs, |
| 118 | }; |
| 119 | }), |
| 120 | // session.prompt — a member steering the session. The steering history IS |
| 121 | // the log (and, per the accepted design, the prompt IS the member's chat |
| 122 | // message — one event, two projections); board state keeps only a count. |
| 123 | [N.ev("prompt")]: (s, e) => |
| 124 | withSession(s, e.id, (sess) => |
| 125 | sess.state === "ended" ? null : { ...sess, prompts: sess.prompts + 1 }, |
| 126 | ), |
| 127 | // session.turn — the assistant's reply, one durable event per completed |
| 128 | // turn. The text lives in the log/feed projection; the board counts it. |
| 129 | [N.ev("turn")]: (s, e) => |
| 130 | withSession(s, e.id, (sess) => ({ ...sess, turns: sess.turns + 1 })), |
| 131 | // session.result — a milestone card (files changed, tests, links). The |
| 132 | // board keeps the latest summary line. |
| 133 | [N.ev("result")]: (s, e) => |
| 134 | withSession(s, e.id, (sess) => { |
| 135 | const summary = str(e.summary, 500); |
| 136 | return summary ? { ...sess, result: summary } : null; |
| 137 | }), |
| 138 | // session.approval — the audit trail of authority used. The bridge posts |
| 139 | // the pending request ({rid, tool, digest, state:"pending"}); a member's |
| 140 | // decision ({rid, state:"allowed"|"denied"}) folds onto it. First decision |
| 141 | // wins (flat authority): a decided approval never flips, and a decision |
| 142 | // for a request the log never saw is a no-op. |
| 143 | [N.ev("approval")]: (s, e) => |
| 144 | withSession(s, e.id, (sess) => { |
| 145 | const rid = typeof e.rid === "string" && ID_RE.test(e.rid) ? e.rid : ""; |
| 146 | if (!rid) return null; |
| 147 | const approvals = sess.approvals || []; |
| 148 | const i = approvals.findIndex((a) => a.rid === rid); |
| 149 | if (e.state === "pending") { |
| 150 | if (i >= 0 || approvals.length >= MAX_APPROVALS) return null; |
| 151 | return { |
| 152 | ...sess, |
| 153 | approvals: [ |
| 154 | ...approvals, |
| 155 | { rid, tool: str(e.tool, 120), digest: str(e.digest, 200), state: "pending", by: "" }, |
| 156 | ], |
| 157 | }; |
| 158 | } |
| 159 | if (e.state !== "allowed" && e.state !== "denied") return null; |
| 160 | if (i < 0 || approvals[i].state !== "pending") return null; |
| 161 | const next = [...approvals]; |
| 162 | next[i] = { ...next[i], state: e.state, by: str(e.name, 80) }; |
| 163 | return { ...sess, approvals: next }; |
| 164 | }), |
| 165 | // session.end — lifecycle honesty. Closing a session is a member (or |
| 166 | // bridge) act; the bridge removes a scoped session's worktree on it (the |
| 167 | // accepted design: close-session-closes-worktree; committed branches |
| 168 | // survive). Pending approvals die with the session. |
| 169 | [N.ev("end")]: (s, e) => |
| 170 | withSession(s, e.id, (sess) => { |
| 171 | if (sess.state === "ended") return null; |
| 172 | return { |
| 173 | ...sess, |
| 174 | state: "ended", |
| 175 | activity: "", |
| 176 | endedAt: typeof e.ts === "number" ? e.ts : 0, |
| 177 | reason: str(e.reason, 120), |
| 178 | approvals: (sess.approvals || []).filter((a) => a.state !== "pending"), |
| 179 | }; |
| 180 | }), |
| 181 | // Ephemeral tail — live tool chatter + text deltas (relay-only frames, |
| 182 | // handled in the `ephemeral` hook). Claimed here with no-op folds so the |
| 183 | // types route to this app and a hostile stored copy folds harmlessly. |
| 184 | [N.ev("tool")]: (s) => s, |
| 185 | [N.ev("stream")]: (s) => s, |
| 186 | }, |
| 187 | |
| 188 | // Feed projections — how session.* events speak in the group conversation. |
| 189 | // Action lines are actor-neutral (the host prefixes "You"/the emitter's |
| 190 | // name); message bubbles render in the app's voice with the session tagged. |
| 191 | groupFeedMessage(e) { |
| 192 | switch (e.type) { |
| 193 | case N.ev("spawn"): { |
| 194 | const tag = str(e.session, 80) || str(e.id, 64); |
| 195 | const where = e.flavor === "plain" ? "the full workstation" : str(e.root, 300) || "a project"; |
| 196 | return { text: `started session “${tag}” on ${where}`, action: true }; |
| 197 | } |
| 198 | case N.ev("prompt"): { |
| 199 | const text = str(e.text, 300); |
| 200 | return text ? { text: `asked ${str(e.session, 80) || "the session"}: “${text}”`, action: true } : null; |
| 201 | } |
| 202 | case N.ev("turn"): { |
| 203 | const text = str(e.text, 4000); |
| 204 | return text ? `[${str(e.session, 80) || "session"}] ${text}` : null; |
| 205 | } |
| 206 | case N.ev("result"): { |
| 207 | const summary = str(e.summary, 500); |
| 208 | return summary ? `[${str(e.session, 80) || "session"}] ✅ ${summary}` : null; |
| 209 | } |
| 210 | case N.ev("approval"): { |
| 211 | if (e.state === "pending") { |
| 212 | const tool = str(e.tool, 120); |
| 213 | return tool ? `[${str(e.session, 80) || "session"}] wants to run ${tool} — open Agents to decide` : null; |
| 214 | } |
| 215 | if (e.state === "allowed" || e.state === "denied") { |
| 216 | const tool = str(e.tool, 120) || "it"; |
| 217 | return { text: `${e.state} ${tool} on ${str(e.session, 80) || "the session"}`, action: true }; |
| 218 | } |
| 219 | return null; |
| 220 | } |
| 221 | case N.ev("end"): |
| 222 | return { text: `closed session “${str(e.session, 80) || str(e.id, 64)}”`, action: true }; |
| 223 | default: |
| 224 | return null; // status/tool/stream are board-only |
| 225 | } |
| 226 | }, |
| 227 | |
| 228 | // Sidebar presence: a spawn or a finished result is worth a line; the |
| 229 | // steering churn stays quiet (the board updates live anyway). |
| 230 | preview: (e) => { |
| 231 | if (e.type === N.ev("spawn")) return `🛠️ session “${str(e.session, 80)}” started`; |
| 232 | if (e.type === N.ev("result")) return `✅ ${str(e.summary, 100)}`; |
| 233 | return null; |
| 234 | }, |
| 235 | unread: (e) => e.type === N.ev("spawn") || e.type === N.ev("result") || (e.type === N.ev("approval") && e.state === "pending"), |
| 236 | |
| 237 | // Pockets learn when a session is blocked on a human: a pending approval |
| 238 | // notifies live (the SW path needs no hook — the bridge sets notice on the |
| 239 | // event when it wants a push). |
| 240 | notify(e, ctx) { |
| 241 | if (e.type !== N.ev("approval") || e.state !== "pending") return null; |
| 242 | return { title: "🛠️ Approval needed", body: `${str(e.session, 80) || "A session"} wants to run ${str(e.tool, 120) || "a tool"}` }; |
| 243 | }, |
| 244 | |
| 245 | // Live tail: session.tool / session.stream frames update the focused card's |
| 246 | // activity line in place. Transient by design — never folded, never stored; |
| 247 | // devices that missed a frame lose nothing durable (the next session.status |
| 248 | // event carries the board truth). |
| 249 | ephemeral(e, ctx) { |
| 250 | if (e.type !== N.ev("tool") && e.type !== N.ev("stream")) return; |
| 251 | const id = typeof e.id === "string" && ID_RE.test(e.id) ? e.id : ""; |
| 252 | if (!id) return; |
| 253 | const line = document.querySelector(`.${N.cls("live")}[data-sid="${id}"]`); |
| 254 | if (line) line.textContent = str(e.line || e.text, 200); |
| 255 | }, |
| 256 | |
| 257 | styles: ` |
| 258 | .session-board { display: flex; flex-direction: column; gap: 10px; padding: 4px 0; } |
| 259 | .session-empty { color: var(--muted); font-size: 13px; padding: 18px 6px; text-align: center; } |
| 260 | .session-card { |
| 261 | border: 1px solid var(--border); border-radius: 12px; background: var(--panel); |
| 262 | padding: 10px 12px; display: flex; flex-direction: column; gap: 6px; |
| 263 | } |
| 264 | .session-card.ended { opacity: 0.6; } |
| 265 | .session-head { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; } |
| 266 | .session-name { font-weight: 600; font-size: 14px; color: var(--ink); } |
| 267 | .session-flavor { |
| 268 | font-size: 11px; color: var(--muted); border: 1px solid var(--border); |
| 269 | border-radius: 6px; padding: 1px 6px; white-space: nowrap; |
| 270 | } |
| 271 | .session-state { font-size: 12px; color: var(--muted); margin-left: auto; white-space: nowrap; } |
| 272 | .session-state.waiting { color: var(--accent); } |
| 273 | .session-activity, .session-live { font-size: 12px; color: var(--muted); overflow-wrap: anywhere; } |
| 274 | .session-result { font-size: 12px; color: var(--ink); overflow-wrap: anywhere; } |
| 275 | .session-meta { font-size: 11px; color: var(--muted); } |
| 276 | .session-approval { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; font-size: 12px; color: var(--ink); } |
| 277 | .session-approve-btn { |
| 278 | border: 1px solid var(--border); background: transparent; color: var(--ink); |
| 279 | border-radius: 8px; font: inherit; font-size: 12px; padding: 3px 10px; |
| 280 | cursor: var(--ui-cursor, pointer); min-height: 24px; |
| 281 | } |
| 282 | .session-approve-btn.allow { border-color: var(--accent); } |
| 283 | .session-end-btn { |
| 284 | border: none; background: transparent; padding: 0; font: inherit; font-size: 12px; |
| 285 | color: var(--link); cursor: var(--ui-cursor, pointer); text-decoration: underline; |
| 286 | text-underline-offset: 2px; align-self: flex-start; |
| 287 | } |
| 288 | `, |
| 289 | |
| 290 | // The fleet board: one card per session, rebuilt from state every fold |
| 291 | // (small surface — the default wipe-rebuild is right). Ended sessions sink |
| 292 | // to the bottom, dimmed, so lifecycle stays honest without hiding history. |
| 293 | render(state, el, ctx) { |
| 294 | const board = document.createElement("div"); |
| 295 | board.className = N.cls("board"); |
| 296 | const sessions = (state && state.sessions) || []; |
| 297 | if (!sessions.length) { |
| 298 | const empty = document.createElement("div"); |
| 299 | empty.className = N.cls("empty"); |
| 300 | empty.textContent = "No sessions yet — a workstation bridge posts its Claude Code sessions here."; |
| 301 | board.appendChild(empty); |
| 302 | el.appendChild(board); |
| 303 | return; |
| 304 | } |
| 305 | const order = [...sessions].sort((a, b) => (a.state === "ended") - (b.state === "ended") || a.ts - b.ts); |
| 306 | for (const sess of order) board.appendChild(buildCard(sess, ctx)); |
| 307 | el.appendChild(board); |
| 308 | }, |
| 309 | }); |
| 310 | |
| 311 | function buildCard(sess, ctx) { |
| 312 | const card = document.createElement("div"); |
| 313 | card.className = N.cls("card") + (sess.state === "ended" ? " ended" : ""); |
| 314 | |
| 315 | const head = document.createElement("div"); |
| 316 | head.className = N.cls("head"); |
| 317 | const name = document.createElement("span"); |
| 318 | name.className = N.cls("name"); |
| 319 | name.textContent = sess.name || sess.id; |
| 320 | head.appendChild(name); |
| 321 | const flavor = document.createElement("span"); |
| 322 | flavor.className = N.cls("flavor"); |
| 323 | flavor.textContent = sess.flavor === "plain" ? "🖥 full workstation" : `📁 ${sess.root || "project"}`; |
| 324 | head.appendChild(flavor); |
| 325 | const stateEl = document.createElement("span"); |
| 326 | stateEl.className = N.cls("state") + (sess.state === "waiting" ? " waiting" : ""); |
| 327 | stateEl.textContent = |
| 328 | sess.state === "ended" |
| 329 | ? sess.reason || "ended" |
| 330 | : sess.state === "waiting" |
| 331 | ? "waiting for approval" |
| 332 | : `${sess.state} · turn ${sess.turns.toLocaleString()}`; |
| 333 | head.appendChild(stateEl); |
| 334 | card.appendChild(head); |
| 335 | |
| 336 | if (sess.activity) { |
| 337 | const act = document.createElement("div"); |
| 338 | act.className = N.cls("activity"); |
| 339 | act.textContent = `⚙ ${sess.activity}`; |
| 340 | card.appendChild(act); |
| 341 | } |
| 342 | if (sess.state !== "ended") { |
| 343 | // The ephemeral hook writes the live tool/stream tail into this line. |
| 344 | const live = document.createElement("div"); |
| 345 | live.className = N.cls("live"); |
| 346 | live.dataset.sid = sess.id; |
| 347 | card.appendChild(live); |
| 348 | } |
| 349 | if (sess.result) { |
| 350 | const res = document.createElement("div"); |
| 351 | res.className = N.cls("result"); |
| 352 | res.textContent = `✅ ${sess.result}`; |
| 353 | card.appendChild(res); |
| 354 | } |
| 355 | |
| 356 | for (const a of sess.approvals || []) { |
| 357 | if (a.state !== "pending") continue; |
| 358 | const row = document.createElement("div"); |
| 359 | row.className = N.cls("approval"); |
| 360 | const label = document.createElement("span"); |
| 361 | label.textContent = `wants to run ${a.tool || "a tool"}${a.digest ? ` — ${a.digest}` : ""}`; |
| 362 | row.appendChild(label); |
| 363 | for (const decision of ["allowed", "denied"]) { |
| 364 | const btn = document.createElement("button"); |
| 365 | btn.type = "button"; |
| 366 | btn.className = N.cls("approve-btn") + (decision === "allowed" ? " allow" : ""); |
| 367 | btn.textContent = decision === "allowed" ? "Allow" : "Deny"; |
| 368 | btn.setAttribute("aria-label", `${decision === "allowed" ? "Allow" : "Deny"} ${a.tool || "the tool"} on ${sess.name || sess.id}`); |
| 369 | btn.onclick = () => |
| 370 | ctx.emit({ type: N.ev("approval"), id: sess.id, rid: a.rid, tool: a.tool, session: sess.name, state: decision }); |
| 371 | row.appendChild(btn); |
| 372 | } |
| 373 | card.appendChild(row); |
| 374 | } |
| 375 | |
| 376 | if (sess.state !== "ended") { |
| 377 | // Closing is a member act, per the accepted design: the bridge sees the |
| 378 | // event, ends the Claude session, and removes a scoped session's worktree |
| 379 | // (committed branches survive). Two-step inline confirm, like the |
| 380 | // whiteboard's Clear — confirm() is dead in embedded webviews. |
| 381 | const end = document.createElement("button"); |
| 382 | end.type = "button"; |
| 383 | end.className = N.cls("end-btn"); |
| 384 | end.textContent = "Close session"; |
| 385 | end.onclick = () => { |
| 386 | if (!end.dataset.armed) { |
| 387 | end.dataset.armed = "1"; |
| 388 | end.textContent = "Close for everyone? (removes its worktree)"; |
| 389 | setTimeout(() => { |
| 390 | delete end.dataset.armed; |
| 391 | if (end.isConnected) end.textContent = "Close session"; |
| 392 | }, 4000); |
| 393 | return; |
| 394 | } |
| 395 | ctx.emit({ type: N.ev("end"), id: sess.id, session: sess.name, reason: "closed" }); |
| 396 | }; |
| 397 | card.appendChild(end); |
| 398 | } |
| 399 | |
| 400 | const meta = document.createElement("div"); |
| 401 | meta.className = N.cls("meta"); |
| 402 | const bits = []; |
| 403 | if (sess.by) bits.push(`for ${sess.by}`); |
| 404 | if (sess.prompts) bits.push(`${sess.prompts.toLocaleString()} ${sess.prompts === 1 ? "prompt" : "prompts"}`); |
| 405 | if (bits.length) { |
| 406 | meta.textContent = bits.join(" · "); |
| 407 | card.appendChild(meta); |
| 408 | } |
| 409 | |
| 410 | return card; |
| 411 | } |
| 412 | |