eleven / ocho Public

Clone
git clone https://amadan.net/eleven/ocho

Plain git — no account needed to clone.

Download

Download this file

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.
16import { 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").
21const 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.
26const MAX_SESSIONS = 50; // per thread; oldest ended evicted first
27const MAX_APPROVALS = 20; // per session
28const ID_RE = /^[A-Za-z0-9_-]{1,64}$/;
29const STATES = ["running", "idle", "waiting", "dead"];
30
31const 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.
35function 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
63function 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
74export 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
311function 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