@1agh/maude 0.55.0 → 0.57.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (85) hide show
  1. package/apps/studio/acp/bridge.ts +385 -26
  2. package/apps/studio/acp/index.ts +498 -102
  3. package/apps/studio/acp/running.ts +97 -0
  4. package/apps/studio/acp/transcript.ts +64 -0
  5. package/apps/studio/acp/write-scope.ts +459 -0
  6. package/apps/studio/bin/_smart-frames.mjs +187 -20
  7. package/apps/studio/bin/_smart-frames.test.mjs +59 -4
  8. package/apps/studio/bin/_transcribe.mjs +40 -3
  9. package/apps/studio/bin/smoke.sh +7 -1
  10. package/apps/studio/build.ts +28 -1
  11. package/apps/studio/client/app.jsx +78 -33
  12. package/apps/studio/client/panels/ChatPanel.jsx +19 -1
  13. package/apps/studio/client/panels/CloudBar.jsx +104 -6
  14. package/apps/studio/client/panels/GitPanel.jsx +38 -25
  15. package/apps/studio/client/panels/PermissionPrompt.jsx +146 -6
  16. package/apps/studio/client/panels/RepoBranchSwitcher.jsx +145 -6
  17. package/apps/studio/client/panels/SettingsPanel.jsx +239 -27
  18. package/apps/studio/client/panels/acp-runtime.js +96 -5
  19. package/apps/studio/client/styles/3-shell-maude.css +11 -2
  20. package/apps/studio/client/styles/4-components.css +72 -0
  21. package/apps/studio/client/styles/6-acp-chat.css +51 -0
  22. package/apps/studio/cloud/endpoints.ts +56 -3
  23. package/apps/studio/collab/persistence.ts +29 -2
  24. package/apps/studio/config.schema.json +3 -3
  25. package/apps/studio/context.ts +41 -0
  26. package/apps/studio/dist/client.bundle.js +1330 -1330
  27. package/apps/studio/dist/comment-mount.js +2 -2
  28. package/apps/studio/dist/styles.css +1 -1
  29. package/apps/studio/generation/gemma-models.ts +312 -14
  30. package/apps/studio/generation/prefs.ts +7 -2
  31. package/apps/studio/generation/runtime-probe.ts +50 -0
  32. package/apps/studio/generation/whisper-models.ts +124 -0
  33. package/apps/studio/hmr-broadcast.ts +67 -0
  34. package/apps/studio/http.ts +247 -111
  35. package/apps/studio/input-router.tsx +55 -2
  36. package/apps/studio/server.ts +22 -9
  37. package/apps/studio/sync/autocommit.ts +61 -2
  38. package/apps/studio/sync/cell-pairing.ts +174 -0
  39. package/apps/studio/sync/codec.ts +11 -5
  40. package/apps/studio/sync/connection-state.ts +116 -6
  41. package/apps/studio/sync/index.ts +288 -26
  42. package/apps/studio/sync/limits.ts +49 -0
  43. package/apps/studio/sync/loopback.ts +21 -0
  44. package/apps/studio/sync/presentation.ts +273 -0
  45. package/apps/studio/sync/projection.ts +47 -12
  46. package/apps/studio/sync/remote-docs.ts +191 -0
  47. package/apps/studio/sync/status.ts +4 -1
  48. package/apps/studio/sync/supervisor.ts +178 -0
  49. package/apps/studio/test/acp-activity-endpoint.test.ts +183 -0
  50. package/apps/studio/test/acp-branch-guard.test.ts +123 -0
  51. package/apps/studio/test/acp-bridge-lifetime.test.ts +369 -0
  52. package/apps/studio/test/acp-caps-bridge.test.ts +5 -0
  53. package/apps/studio/test/acp-commands.test.ts +5 -0
  54. package/apps/studio/test/acp-elicitation-bridge.test.ts +5 -0
  55. package/apps/studio/test/acp-permission-prompt.test.ts +175 -1
  56. package/apps/studio/test/acp-permission.test.ts +18 -2
  57. package/apps/studio/test/acp-session-allowed-tools.test.ts +62 -14
  58. package/apps/studio/test/acp-usage-bridge.test.ts +5 -0
  59. package/apps/studio/test/acp-write-gate.test.ts +323 -0
  60. package/apps/studio/test/acp-write-scope.test.ts +533 -0
  61. package/apps/studio/test/bundle-smoke.test.ts +35 -18
  62. package/apps/studio/test/canvas-origin-gate.test.ts +7 -0
  63. package/apps/studio/test/cloud-connect-note.test.ts +135 -0
  64. package/apps/studio/test/cloud-endpoints.test.ts +102 -0
  65. package/apps/studio/test/cloud-shell-surfaces.test.ts +5 -2
  66. package/apps/studio/test/csrf-write-guard.test.ts +19 -2
  67. package/apps/studio/test/fixtures/mock-acp-agent-slow.mjs +45 -0
  68. package/apps/studio/test/fixtures/mock-acp-agent-write.mjs +118 -0
  69. package/apps/studio/test/gemma-models.test.ts +245 -0
  70. package/apps/studio/test/hmr-broadcast.test.ts +57 -1
  71. package/apps/studio/test/input-router.test.ts +95 -0
  72. package/apps/studio/test/shared-doc-cell-pairing.test.ts +639 -0
  73. package/apps/studio/test/sync-autocommit.test.ts +47 -0
  74. package/apps/studio/test/sync-connect-honesty.test.ts +114 -0
  75. package/apps/studio/test/sync-connection-state.test.ts +45 -1
  76. package/apps/studio/test/sync-presentation.test.ts +185 -0
  77. package/apps/studio/test/sync-remote-docs.test.ts +171 -0
  78. package/apps/studio/test/sync-supervisor.test.ts +212 -0
  79. package/apps/studio/test/trusted-request-host.test.ts +66 -0
  80. package/apps/studio/test/whisper-setup.test.ts +97 -0
  81. package/apps/studio/whats-new.json +45 -0
  82. package/apps/studio/ws.ts +9 -1
  83. package/cli/commands/kg.mjs +9 -2
  84. package/package.json +8 -8
  85. package/plugins/design/dependencies.json +21 -3
@@ -21,10 +21,15 @@ import { buildStudioBrief } from './bootstrap-brief.ts';
21
21
  import { AcpBridge, type BridgeUsage } from './bridge.ts';
22
22
  import { isNativePluginContext, resolveSessionPlugins } from './plugin-bootstrap.ts';
23
23
  import { probeAcpAvailability } from './probe.ts';
24
+ import { registerActivityProbe, registerRunningChatsProbe } from './running.ts';
25
+ import { readChatLinesAfter } from './transcript.ts';
24
26
 
25
27
  /**
26
- * Browser → server frames: `{ t: 'prompt', text, chat?, model?, effort?, mode? }`,
27
- * `{ t: 'cancel' }`, `{ t: 'warm', chat?, model?, effort?, mode? }` (spawn +
28
+ * Browser → server frames: `{ t: 'attach', chat, seq }` (Addendum Task 8 — bind
29
+ * this socket to the chat's possibly-already-running bridge and replay the
30
+ * transcript lines after `seq`, which is what the client hydrated over HTTP),
31
+ * `{ t: 'prompt', text, chat?, model?, effort?, mode? }`,
32
+ * `{ t: 'cancel', chat? }`, `{ t: 'warm', chat?, model?, effort?, mode? }` (spawn +
28
33
  * create the session so the agent publishes its slash-command catalogue — no
29
34
  * prompt sent), `{ t: 'set-mode', chat?, modeId }`, `{ t: 'set-config', chat?,
30
35
  * configId, value }` (live change on an already-established session —
@@ -42,15 +47,41 @@ import { probeAcpAvailability } from './probe.ts';
42
47
  * (a pending elicitation settled via ANY path, including one the client never
43
48
  * initiated — timeout/cancel/stop — so the client can drop a now-dead pending
44
49
  * card instead of leaving it stuck), `usage` (context-window + cost +
45
- * rate-limit, Milestone D — cached + replayed on open), `turn-end`,
46
- * `permission`, `error`.
50
+ * rate-limit, Milestone D — cached + replayed on open), `attached` (the answer
51
+ * to `attach`: `{ chat, running }` — `running` tells a reloaded client it
52
+ * re-joined a LIVE turn, so it restores the busy state instead of looking idle
53
+ * while the agent keeps working), `turn-end`, `permission`, `error`.
54
+ * Every `update` frame carries a `seq` — its transcript line — so a client can
55
+ * join the live stream to the history it hydrated without duplicating or
56
+ * dropping output (see `transcript.ts`'s "re-attach seam").
47
57
  */
48
58
  export interface Acp {
49
59
  onOpen(ws: ServerWebSocket<WsData>): void;
50
60
  onMessage(ws: ServerWebSocket<WsData>, raw: string | Uint8Array): void;
51
61
  onClose(ws: ServerWebSocket<WsData>): void;
52
- /** Live bridge count — for diagnostics / teardown assertions. */
62
+ /** Live bridge count — for diagnostics / teardown assertions. Counts
63
+ * DETACHED bridges too, since those are exactly what needs watching now
64
+ * (Addendum Task 8). */
53
65
  size(): number;
66
+ /**
67
+ * Chat ids with a turn in flight right now (Addendum Task 9).
68
+ *
69
+ * Drives the branch-switch warning. A `git checkout` moves the worktree under
70
+ * a running agent — it read `foo.tsx` on `draft-a` and writes it back after
71
+ * the checkout to `main`, a silent cross-branch clobber — and this plan's own
72
+ * premise leans on `_history/` rollback, whose snapshot stack is per-canvas-
73
+ * slug with NO branch awareness. So the "it's reversible" argument degrades
74
+ * exactly here. Task 8 means a reload no longer KILLS the chat, which makes
75
+ * the warning MORE necessary, not less: the turn now survives into a worktree
76
+ * that is no longer the one it was reasoning about.
77
+ */
78
+ runningChats(): string[];
79
+ /** Tear every bridge down, attached or not. The process is going away
80
+ * (dev-server shutdown / app quit), which is the ONE case the detached
81
+ * lifetime deliberately does not survive — see DDR-166's SIGTERM-first
82
+ * path. Extending a session across a *switch* is the whole point; extending
83
+ * it across a quit is not. */
84
+ stopAll(): void;
54
85
  }
55
86
 
56
87
  // RC5 (rca/issue-canvas-hmr-optimistic-update-consistency) — the ACP chat agent
@@ -134,10 +165,76 @@ export function createAgentActivityTracker(ctx: Context, ai: AiActivity): AgentA
134
165
  return { onUpdate, endTurn };
135
166
  }
136
167
 
168
+ // ── Detached bridge lifetime (feature-acp-write-path-scope Addendum, Task 8) ──
169
+ //
170
+ // A bridge used to be keyed by `ws.data.id` and stopped the instant its socket
171
+ // closed. That made a PAGE RELOAD a turn-killer: `RepoBranchSwitcher.jsx` calls
172
+ // `window.location.reload()` unconditionally on a branch switch, so switching
173
+ // drafts mid-turn silently killed the running agent — no warning, and the work
174
+ // in flight simply gone (the conversation survived, because the transcript and
175
+ // the ACP sessionId are both persisted, which is what made it LOOK like it had
176
+ // merely lost its place).
177
+ //
178
+ // Bridges are now keyed by CHAT and outlive their socket. The client already
179
+ // opens one WebSocket per open chat, so this is a lifetime change, not a
180
+ // multiplexing one — each bridge still has exactly one chat and one in-flight
181
+ // turn, and `AcpBridge.scopeRoot` stays one-to-one with a project.
182
+ //
183
+ // A detached bridge is a live agent with NOBODY watching — a state this system
184
+ // has never had before, so the reapers below are load-bearing rather than
185
+ // polish (DDR-125 already books "N processes" as the cost of parallel chats,
186
+ // and detaching removes the natural reaper that socket-close used to be).
187
+ /** How long a bridge survives with no socket attached before it is torn down. */
188
+ export const DETACHED_TTL_MS = 5 * 60_000;
189
+ /** Hard ceiling on a detached bridge's total lifetime. A detached bridge whose
190
+ * turn is still running gets its TTL extended (killing a running turn because
191
+ * the user reloaded is the bug we are fixing) — but not forever: a wedged turn
192
+ * must not become an immortal subprocess. */
193
+ export const MAX_DETACHED_LIFETIME_MS = 30 * 60_000;
194
+ /** How many DETACHED bridges may exist at once. Past this, the
195
+ * least-recently-active detached bridge is reaped immediately. */
196
+ export const MAX_DETACHED_BRIDGES = 3;
197
+
198
+ /**
199
+ * Hard ceiling on TOTAL bridges (attached + detached).
200
+ *
201
+ * SECURITY (security-auditor A3) — the first cut capped only the detached ones,
202
+ * reasoning that "attached bridges are bounded by how many chats the user has
203
+ * open, i.e. by an explicit human action". That was TRUE while bridges were
204
+ * keyed by `ws.data.id` (one socket, one bridge, and opening a socket is a human
205
+ * act) and stopped being true the moment they were keyed by CHAT: one socket can
206
+ * now mint an unbounded number of bridges just by sending `prompt`/`warm` frames
207
+ * with distinct `chat` values, each spawning its own adapter + `claude` process
208
+ * stack. The premise died with the re-keying and the comment outlived it.
209
+ *
210
+ * Generous — well past any real "how many chats do I have open" — because
211
+ * tripping it means something is wrong, not that someone is working hard.
212
+ */
213
+ export const MAX_BRIDGES = 12;
214
+
215
+ interface BridgeEntry {
216
+ chatId: string;
217
+ bridge: AcpBridge;
218
+ tracker: AgentActivityTracker | null;
219
+ /** Sockets currently receiving this chat's frames. Empty ⇒ detached. */
220
+ sinks: Set<ServerWebSocket<WsData>>;
221
+ /** True between prompt-start and turn-end. A detached bridge with a live turn
222
+ * is the case this whole change exists to keep alive. */
223
+ turnActive: boolean;
224
+ /** When the last socket detached (0 while attached) — drives the TTL. */
225
+ detachedSince: number;
226
+ /** Last prompt/attach, for least-recently-active eviction. */
227
+ lastActive: number;
228
+ reapTimer: ReturnType<typeof setTimeout> | null;
229
+ }
230
+
137
231
  export function createAcp(ctx: Context, aiActivity?: AiActivity): Acp {
138
- const bridges = new Map<string, AcpBridge>();
139
- // RC5 — per-socket agent-activity tracker (see createAgentActivityTracker).
140
- const trackers = new Map<string, AgentActivityTracker>();
232
+ /** chatId → entry. Keyed by CHAT, not by socket — see the block comment above. */
233
+ const bridges = new Map<string, BridgeEntry>();
234
+ /** ws.data.id → the chat ids that socket is attached to (one, in practice —
235
+ * the client opens a socket per chat — but modelled as a set so a future
236
+ * multiplexing client can't silently corrupt the detach bookkeeping). */
237
+ const wsChats = new Map<string, Set<string>>();
141
238
  // Latest slash-command catalogue seen from ANY bridge this process lifetime.
142
239
  // Replayed to a freshly-opened socket so the composer autocomplete is instant
143
240
  // on the second panel-open without re-warming. Not persisted (static list in
@@ -156,79 +253,260 @@ export function createAcp(ctx: Context, aiActivity?: AiActivity): Acp {
156
253
  }
157
254
  }
158
255
 
159
- /** Get-or-create the per-socket bridge, wiring its update/permission/command sinks. */
160
- function getOrCreateBridge(ws: ServerWebSocket<WsData>): AcpBridge {
161
- let bridge = bridges.get(ws.data.id);
162
- if (!bridge) {
163
- const tracker = aiActivity ? createAgentActivityTracker(ctx, aiActivity) : null;
164
- if (tracker) trackers.set(ws.data.id, tracker);
165
- bridge = new AcpBridge({
166
- repoRoot: ctx.paths.repoRoot,
167
- // Static, config-derived environment brief for every new session
168
- // (feature-acp-context-hardening; see bootstrap-brief.ts guardrails).
169
- studioBrief: buildStudioBrief({
170
- designRel: ctx.paths.designRel,
171
- projectLabel: ctx.projectLabel,
172
- // DDR-143 — on the native/desktop path the `/design:*` commands are
173
- // present in the session (auto-loaded or installed), so the brief states
174
- // that plainly instead of hedging. (`/flow:*` is intentionally excluded
175
- // from the chat for now — 2026-07-03.)
176
- commandsAvailable: isNativePluginContext(),
256
+ /**
257
+ * Fan a frame out to every socket attached to this chat.
258
+ *
259
+ * An entry with NO sinks is the detached case, and dropping the frame there
260
+ * is correct, not a gap: every agent update is already on disk in the
261
+ * transcript, and a re-attaching client replays exactly what it missed via
262
+ * the `attach` frame's seq. The transcript is the durable record; this is
263
+ * just the live tail.
264
+ */
265
+ function broadcast(entry: BridgeEntry, payload: unknown): void {
266
+ for (const ws of entry.sinks) send(ws, payload);
267
+ }
268
+
269
+ /** Tear a bridge down for good. Idempotent. */
270
+ function reap(entry: BridgeEntry): void {
271
+ if (entry.reapTimer) clearTimeout(entry.reapTimer);
272
+ entry.reapTimer = null;
273
+ bridges.delete(entry.chatId);
274
+ // The agent really is gone now, so any "Claude is editing …" banner it
275
+ // raised must clear — unlike on a mere detach, where the banner is still
276
+ // TRUE (a detached-but-running turn is still editing files).
277
+ entry.tracker?.endTurn();
278
+ void entry.bridge.stop();
279
+ }
280
+
281
+ /**
282
+ * Arm (or re-arm) the detached reaper. A detached bridge whose turn is still
283
+ * running gets extensions up to MAX_DETACHED_LIFETIME_MS — reloading a page
284
+ * must not kill a running turn — but a genuinely abandoned one is torn down
285
+ * on the first TTL. Re-attaching cancels the timer (see `attach`).
286
+ */
287
+ function armReap(entry: BridgeEntry): void {
288
+ if (entry.reapTimer) clearTimeout(entry.reapTimer);
289
+ entry.reapTimer = setTimeout(() => {
290
+ entry.reapTimer = null;
291
+ if (entry.sinks.size > 0) return; // re-attached in the meantime
292
+ const detachedFor = Date.now() - entry.detachedSince;
293
+ if (entry.turnActive && detachedFor < MAX_DETACHED_LIFETIME_MS) {
294
+ armReap(entry);
295
+ return;
296
+ }
297
+ reap(entry);
298
+ }, DETACHED_TTL_MS);
299
+ }
300
+
301
+ /** Keep the number of DETACHED bridges under the ceiling by reaping the
302
+ * least-recently-active one. Idle bridges are sacrificed before busy ones —
303
+ * a detached bridge with a live turn is the thing we are trying to protect,
304
+ * so it is only evicted when nothing idle is left to take instead. */
305
+ function enforceDetachedCeiling(max: number = MAX_DETACHED_BRIDGES): void {
306
+ for (;;) {
307
+ const detached = [...bridges.values()].filter((e) => e.sinks.size === 0);
308
+ if (detached.length <= max) return;
309
+ detached.sort(
310
+ (a, b) => Number(a.turnActive) - Number(b.turnActive) || a.lastActive - b.lastActive
311
+ );
312
+ reap(detached[0]);
313
+ }
314
+ }
315
+
316
+ /** Get-or-create the per-CHAT bridge, wiring its update/permission/command sinks. */
317
+ function getOrCreateEntry(chatId: string): BridgeEntry | null {
318
+ const existing = bridges.get(chatId);
319
+ if (existing) return existing;
320
+ // A3 — before minting a new bridge (and with it a `claude` process stack),
321
+ // make room by reaping detached ones; refuse outright if everything live is
322
+ // attached. Refusing is the safe direction: the user re-sends, versus the
323
+ // process table filling up.
324
+ if (bridges.size >= MAX_BRIDGES) {
325
+ enforceDetachedCeiling(0);
326
+ if (bridges.size >= MAX_BRIDGES) {
327
+ console.error(`[acp] refusing to create bridge for "${chatId}" — ${MAX_BRIDGES} live`);
328
+ return null;
329
+ }
330
+ }
331
+ const tracker = aiActivity ? createAgentActivityTracker(ctx, aiActivity) : null;
332
+ // Declared before the bridge so the callbacks below can close over it —
333
+ // they fan out to `entry.sinks`, which changes as sockets come and go,
334
+ // rather than capturing one socket the way the per-socket design did.
335
+ const entry: BridgeEntry = {
336
+ chatId,
337
+ // Filled in on the very next statement. The two-step exists because the
338
+ // bridge's callbacks close over `entry` (to reach `entry.sinks`, which
339
+ // changes as sockets attach and detach) — a chicken-and-egg the old
340
+ // per-socket design didn't have, since it captured one fixed `ws`.
341
+ // Safe: `new AcpBridge()` spawns nothing and fires no callback, so no
342
+ // reader can observe the placeholder.
343
+ bridge: null as unknown as AcpBridge,
344
+ tracker,
345
+ sinks: new Set(),
346
+ turnActive: false,
347
+ detachedSince: 0,
348
+ lastActive: Date.now(),
349
+ reapTimer: null,
350
+ };
351
+ entry.bridge = new AcpBridge({
352
+ repoRoot: ctx.paths.repoRoot,
353
+ // Static, config-derived environment brief for every new session
354
+ // (feature-acp-context-hardening; see bootstrap-brief.ts guardrails).
355
+ studioBrief: buildStudioBrief({
356
+ designRel: ctx.paths.designRel,
357
+ projectLabel: ctx.projectLabel,
358
+ // DDR-143 — on the native/desktop path the `/design:*` commands are
359
+ // present in the session (auto-loaded or installed), so the brief states
360
+ // that plainly instead of hedging. (`/flow:*` is intentionally excluded
361
+ // from the chat for now — 2026-07-03.)
362
+ commandsAvailable: isNativePluginContext(),
363
+ }),
364
+ // DDR-143 — session-scoped `design` auto-load for the zero-install desktop
365
+ // path (`/flow` auto-load disabled for now — 2026-07-03). Empty on the
366
+ // power-user (already-installed) + web-serve no-op paths. Computed once
367
+ // here; carried on the readonly bridge options so it survives an adapter
368
+ // re-spawn (model/effort change).
369
+ plugins: resolveSessionPlugins(),
370
+ onUpdate: (update, seq) => {
371
+ tracker?.onUpdate(update);
372
+ // `seq` is the update's transcript line — the re-attach seam. The
373
+ // client drops anything it already hydrated and keeps the rest, so a
374
+ // reload mid-stream neither duplicates nor drops output.
375
+ broadcast(entry, { t: 'update', update, seq });
376
+ },
377
+ onPermission: (req) => broadcast(entry, { t: 'permission', toolCall: req.toolCall }),
378
+ onPermissionRequest: (id, req, scope) =>
379
+ // NOTE (Addendum hazard): if this chat is DETACHED, `broadcast` is a
380
+ // no-op — nobody sees the request, and the bridge's own 120 s timeout
381
+ // denies it (`PERMISSION_TIMEOUT_MS` → `'cancelled'`). That is the
382
+ // intended outcome and it is load-bearing: "no UI attached" must
383
+ // never read as consent. Making detachment auto-approve would reopen,
384
+ // from a new direction, exactly what the write gate closes.
385
+ broadcast(entry, {
386
+ t: 'permission-request',
387
+ id,
388
+ // Echoed so the client can address its response back to the right
389
+ // chat now that a response frame has to name one.
390
+ chat: chatId,
391
+ toolCall: req.toolCall,
392
+ // Already filtered bridge-side for an out-of-project write (no
393
+ // `allow_always` — feature-acp-write-path-scope Decision D); the
394
+ // bridge validates a response against this SAME set, so the client
395
+ // is being shown the real option set, not a cosmetic subset.
396
+ options: req.options,
397
+ // Present ONLY when the write-path gate judged a write tool and
398
+ // declined it — carries the RESOLVED absolute path(s) so the card
399
+ // can name what will actually be written rather than echoing the
400
+ // model's own (possibly `../`-laden) string.
401
+ scope,
177
402
  }),
178
- // DDR-143 — session-scoped `design` auto-load for the zero-install desktop
179
- // path (`/flow` auto-load disabled for now — 2026-07-03). Empty on the
180
- // power-user (already-installed) + web-serve no-op paths. Computed once
181
- // here; carried on the readonly bridge options so it survives an adapter
182
- // re-spawn (model/effort change).
183
- plugins: resolveSessionPlugins(),
184
- onUpdate: (update) => {
185
- tracker?.onUpdate(update);
186
- send(ws, { t: 'update', update });
187
- },
188
- onPermission: (req) => send(ws, { t: 'permission', toolCall: req.toolCall }),
189
- onPermissionRequest: (id, req) =>
190
- send(ws, { t: 'permission-request', id, toolCall: req.toolCall, options: req.options }),
191
- onElicitationRequest: (id, req: CreateElicitationRequest) =>
192
- send(ws, {
193
- t: 'elicitation-request',
194
- id,
195
- message: req.message,
196
- mode: req.mode,
197
- // `req.mode === 'form'` is guaranteed by the bridge (it declines
198
- // any other mode before ever calling onElicitationRequest — see
199
- // the SECURITY comment in bridge.ts) — this ternary exists for
200
- // TYPE narrowing (requestedSchema only exists on the form variant
201
- // of the CreateElicitationRequest union), not as a runtime guard.
202
- requestedSchema: req.mode === 'form' ? req.requestedSchema : undefined,
203
- // Forwarded so the client can attribute the request to the actual
204
- // tool call it's scoped to (when present) instead of a blanket
205
- // "from Claude" label — ethical-hacker finding: `toolCallId` is
206
- // present on `ElicitationSessionScope` and was being silently
207
- // dropped, even though ElicitationPrompt.jsx had nothing else to
208
- // disambiguate a built-in AskUserQuestion form from an arbitrary
209
- // connected MCP server's form.
210
- toolCallId: 'toolCallId' in req ? req.toolCallId : undefined,
211
- }),
212
- // A pending elicitation settled via ANY path, including one the
213
- // client never initiated (a bridge-side timeout, cancel(), stop()) —
214
- // tells the client to drop it from its own pending list even though
215
- // it didn't send the `elicitation-response` itself. See the doc
216
- // comment on `onElicitationSettled` in bridge.ts.
217
- onElicitationSettled: (id) => send(ws, { t: 'elicitation-resolved', id }),
218
- onCommands: (commands) => {
219
- latestCommands = commands;
220
- send(ws, { t: 'commands', commands });
221
- },
222
- onCaps: (modes, configOptions) => send(ws, { t: 'caps', modes, configOptions }),
223
- onSessionInfo: (info) => send(ws, { t: 'session-info', ...info }),
224
- onUsage: (usage) => {
225
- latestUsage = usage;
226
- send(ws, { t: 'usage', usage });
227
- },
228
- });
229
- bridges.set(ws.data.id, bridge);
403
+ onElicitationRequest: (id, req: CreateElicitationRequest) =>
404
+ // Same detached-bridge note as onPermissionRequest above: nobody
405
+ // watching ⇒ the bridge's own timeout declines it.
406
+ broadcast(entry, {
407
+ t: 'elicitation-request',
408
+ id,
409
+ chat: chatId,
410
+ message: req.message,
411
+ mode: req.mode,
412
+ // `req.mode === 'form'` is guaranteed by the bridge (it declines
413
+ // any other mode before ever calling onElicitationRequest — see
414
+ // the SECURITY comment in bridge.ts) — this ternary exists for
415
+ // TYPE narrowing (requestedSchema only exists on the form variant
416
+ // of the CreateElicitationRequest union), not as a runtime guard.
417
+ requestedSchema: req.mode === 'form' ? req.requestedSchema : undefined,
418
+ // Forwarded so the client can attribute the request to the actual
419
+ // tool call it's scoped to (when present) instead of a blanket
420
+ // "from Claude" label — ethical-hacker finding: `toolCallId` is
421
+ // present on `ElicitationSessionScope` and was being silently
422
+ // dropped, even though ElicitationPrompt.jsx had nothing else to
423
+ // disambiguate a built-in AskUserQuestion form from an arbitrary
424
+ // connected MCP server's form.
425
+ toolCallId: 'toolCallId' in req ? req.toolCallId : undefined,
426
+ }),
427
+ // A pending elicitation settled via ANY path, including one the
428
+ // client never initiated (a bridge-side timeout, cancel(), stop()) —
429
+ // tells the client to drop it from its own pending list even though
430
+ // it didn't send the `elicitation-response` itself. See the doc
431
+ // comment on `onElicitationSettled` in bridge.ts.
432
+ onElicitationSettled: (id) => broadcast(entry, { t: 'elicitation-resolved', id }),
433
+ onCommands: (commands) => {
434
+ latestCommands = commands;
435
+ broadcast(entry, { t: 'commands', commands });
436
+ },
437
+ onCaps: (modes, configOptions) => broadcast(entry, { t: 'caps', modes, configOptions }),
438
+ onSessionInfo: (info) => broadcast(entry, { t: 'session-info', ...info }),
439
+ onUsage: (usage) => {
440
+ latestUsage = usage;
441
+ broadcast(entry, { t: 'usage', usage });
442
+ },
443
+ });
444
+ bridges.set(chatId, entry);
445
+ return entry;
446
+ }
447
+
448
+ /**
449
+ * Bind `ws` to a chat's (possibly already-running) bridge.
450
+ *
451
+ * Called for every chat-addressed frame, not just the explicit `attach` — a
452
+ * client that sends `prompt` straight after connecting must not be treated as
453
+ * detached. Re-attaching cancels the reaper, which is what makes a page
454
+ * reload a no-op for a running turn instead of a kill.
455
+ */
456
+ function attach(ws: ServerWebSocket<WsData>, chatId: string): BridgeEntry | null {
457
+ const entry = getOrCreateEntry(chatId);
458
+ if (!entry) return null;
459
+ entry.sinks.add(ws);
460
+ entry.detachedSince = 0;
461
+ entry.lastActive = Date.now();
462
+ if (entry.reapTimer) {
463
+ clearTimeout(entry.reapTimer);
464
+ entry.reapTimer = null;
465
+ }
466
+ let chats = wsChats.get(ws.data.id);
467
+ if (!chats) {
468
+ chats = new Set();
469
+ wsChats.set(ws.data.id, chats);
470
+ }
471
+ chats.add(chatId);
472
+ return entry;
473
+ }
474
+
475
+ /**
476
+ * The entry a control frame (`cancel`, a permission/elicitation response,
477
+ * `set-mode`, `set-config`) is allowed to act on.
478
+ *
479
+ * SECURITY — the socket may only address a chat it is ATTACHED to. This is not
480
+ * incidental: before bridges were re-keyed by chat, every lookup was
481
+ * `bridges.get(ws.data.id)`, so a socket was *structurally* incapable of
482
+ * naming another socket's bridge. Re-keying moved the key into the FRAME, and
483
+ * a first cut resolved `chat` against the whole `bridges` map — which silently
484
+ * widened the surface so any socket could cancel another chat's turn, flip its
485
+ * mode/model, or answer its pending permission. The nonce ids make the last one
486
+ * impractical to hit blind, but "you'd have to guess a UUID" is a weaker
487
+ * guarantee than "the frame cannot name that bridge at all", and DDR-125 F1's
488
+ * posture is explicitly the latter: a loopback frame must not be able to pin a
489
+ * value it was never offered. Restoring the attachment requirement gets the
490
+ * pre-re-keying property back without giving up chat addressing.
491
+ *
492
+ * `prompt`/`warm` deliberately do NOT go through here — opening a chat IS an
493
+ * attach, and that is the feature.
494
+ *
495
+ * Falls back to the single attached chat when the frame names none (the
496
+ * real-world case: the client opens one socket per chat, and older clients
497
+ * send `cancel`/responses without a `chat`). Ambiguity ⇒ null, never a guess.
498
+ */
499
+ function entryForSocket(ws: ServerWebSocket<WsData>, chat?: string): BridgeEntry | null {
500
+ const chats = wsChats.get(ws.data.id);
501
+ if (!chats || chats.size === 0) return null;
502
+ if (chat) {
503
+ const id = sanitizeChatId(chat);
504
+ if (!chats.has(id)) return null; // not this socket's chat — refuse
505
+ return bridges.get(id) ?? null;
230
506
  }
231
- return bridge;
507
+ if (chats.size !== 1) return null;
508
+ const [only] = chats;
509
+ return bridges.get(only) ?? null;
232
510
  }
233
511
 
234
512
  // Chats are repo-level (NOT per-canvas) — `_chat/<chatId>.jsonl`. The id is
@@ -257,22 +535,43 @@ export function createAcp(ctx: Context, aiActivity?: AiActivity): Acp {
257
535
  effort: string | null,
258
536
  modeId: string | null
259
537
  ): Promise<void> {
260
- const bridge = getOrCreateBridge(ws);
538
+ const entry = attach(ws, sanitizeChatId(chatId));
539
+ if (!entry) {
540
+ send(ws, { t: 'error', message: 'Too many chats are open. Close one and try again.' });
541
+ return;
542
+ }
543
+ const { bridge } = entry;
261
544
  bridge.setTranscriptPath(transcriptPathFor(chatId));
262
545
  bridge.setSessionStorePath(sessionStorePathFor(chatId));
263
546
  bridge.setConfig(model, effort, modeId);
547
+ // Marks this bridge as worth keeping alive through a detach. Set BEFORE the
548
+ // first await, so a socket that closes during the spawn still counts as a
549
+ // live turn rather than an abandoned bridge.
550
+ entry.turnActive = true;
264
551
  try {
265
552
  await bridge.ensureStarted();
266
553
  const { stopReason } = await bridge.prompt(text, sanitizeChatId(chatId));
267
- send(ws, { t: 'connected', sessionId: bridge.sessionId });
268
- send(ws, { t: 'turn-end', stopReason });
554
+ broadcast(entry, { t: 'connected', sessionId: bridge.sessionId });
555
+ broadcast(entry, { t: 'turn-end', stopReason });
269
556
  } catch (err) {
270
- send(ws, { t: 'error', message: err instanceof Error ? err.message : String(err) });
557
+ broadcast(entry, {
558
+ t: 'error',
559
+ message: err instanceof Error ? err.message : String(err),
560
+ });
271
561
  } finally {
562
+ entry.turnActive = false;
563
+ entry.lastActive = Date.now();
272
564
  // RC5 — the turn is over (success, error, or cancel-induced stop): clear
273
565
  // every "Claude is editing …" banner this turn raised. The 30 s heartbeat
274
566
  // grace still covers a crashed dev-server round-trip.
275
- trackers.get(ws.data.id)?.endTurn();
567
+ entry.tracker?.endTurn();
568
+ // The turn was the only reason a detached bridge was being kept alive; now
569
+ // that it's done, let the ordinary TTL run out rather than holding the
570
+ // subprocess indefinitely for a client that never came back.
571
+ if (entry.sinks.size === 0) {
572
+ if (!entry.detachedSince) entry.detachedSince = Date.now();
573
+ armReap(entry);
574
+ }
276
575
  }
277
576
  }
278
577
 
@@ -288,7 +587,9 @@ export function createAcp(ctx: Context, aiActivity?: AiActivity): Acp {
288
587
  effort: string | null,
289
588
  modeId: string | null
290
589
  ): Promise<void> {
291
- const bridge = getOrCreateBridge(ws);
590
+ const entry = attach(ws, sanitizeChatId(chatId));
591
+ if (!entry) return; // at the ceiling — warm-up is best-effort, stay silent
592
+ const { bridge } = entry;
292
593
  bridge.setSessionStorePath(sessionStorePathFor(chatId));
293
594
  bridge.setConfig(model, effort, modeId);
294
595
  try {
@@ -319,14 +620,24 @@ export function createAcp(ctx: Context, aiActivity?: AiActivity): Acp {
319
620
  /** Live mode change on an already-established session (Task A2/A4). */
320
621
  async function handleSetMode(
321
622
  ws: ServerWebSocket<WsData>,
322
- chatId: string,
623
+ chatId: string | undefined,
323
624
  modeId: string
324
625
  ): Promise<void> {
325
- const bridge = bridges.get(ws.data.id);
326
- if (!bridge) return;
626
+ // SECURITY (ethical-hacker A2) — via `entryForSocket`, NOT a raw
627
+ // `bridges.get`. This was the one control frame that escaped the
628
+ // attachment-scoping sweep, and it is the highest-value one to miss:
629
+ // `bypassPermissions` is an advertised mode, and per the plan's F5 it
630
+ // short-circuits adapter-side so `requestPermission` — and therefore the
631
+ // entire write gate — never runs. A raw lookup let any main-origin socket
632
+ // send `{t:'set-mode', chat:'<other-chat>', modeId:'bypassPermissions'}` and
633
+ // switch the gate off for a chat it was never attached to, including a
634
+ // DETACHED one with a live turn and no client watching.
635
+ const entry = entryForSocket(ws, chatId);
636
+ const bridge = entry?.bridge;
637
+ if (!entry || !bridge) return;
327
638
  if (!bridge.modes?.availableModes.some((m) => m.id === modeId)) return; // not advertised — reject silently
328
639
  try {
329
- await bridge.setMode(sanitizeChatId(chatId), modeId);
640
+ await bridge.setMode(entry.chatId, modeId);
330
641
  } catch {
331
642
  /* best-effort — the picker just keeps showing the last-confirmed caps frame */
332
643
  }
@@ -339,7 +650,8 @@ export function createAcp(ctx: Context, aiActivity?: AiActivity): Acp {
339
650
  configId: string,
340
651
  value: string
341
652
  ): Promise<void> {
342
- const bridge = bridges.get(ws.data.id);
653
+ // Attachment-scoped via entryForSocket, same as handleSetMode above.
654
+ const bridge = entryForSocket(ws, chatId)?.bridge;
343
655
  if (!bridge) return;
344
656
  if (!optionOffers(bridge, configId, value)) return; // not advertised — reject silently
345
657
  try {
@@ -349,6 +661,24 @@ export function createAcp(ctx: Context, aiActivity?: AiActivity): Acp {
349
661
  }
350
662
  }
351
663
 
664
+ // Task 9 — let the HTTP layer ask whether a turn is in flight (the branch-
665
+ // switch warning). Registered as a PULL so the answer is always current; see
666
+ // `running.ts` for why this isn't a `createHttp` parameter or client state.
667
+ registerRunningChatsProbe(() =>
668
+ [...bridges.values()].filter((e) => e.turnActive).map((e) => e.chatId)
669
+ );
670
+
671
+ // feature-acp-turn-notifications Task 2 — the richer per-chat snapshot the
672
+ // native shell's poller (notify.rs) reads. `awaiting-input` wins over
673
+ // `running`: a turn blocked on a permission/elicitation prompt is the more
674
+ // actionable of the two, and the one racing `PERMISSION_TIMEOUT_MS`.
675
+ registerActivityProbe(() =>
676
+ [...bridges.values()].map((e) => ({
677
+ chatId: e.chatId,
678
+ state: e.bridge.awaitingInputCount > 0 ? 'awaiting-input' : e.turnActive ? 'running' : 'idle',
679
+ }))
680
+ );
681
+
352
682
  return {
353
683
  onOpen(ws) {
354
684
  const probe = probeAcpAvailability();
@@ -398,16 +728,52 @@ export function createAcp(ctx: Context, aiActivity?: AiActivity): Acp {
398
728
  const effort = typeof frame.effort === 'string' && frame.effort ? frame.effort : null;
399
729
  const modeId = typeof frame.mode === 'string' && frame.mode ? frame.mode : null;
400
730
 
401
- if (frame.t === 'prompt' && typeof frame.text === 'string') {
731
+ if (frame.t === 'attach') {
732
+ // Addendum Task 8 — bind this socket to the chat's (possibly still
733
+ // running) bridge and close the re-attach seam.
734
+ //
735
+ // `frame.seq` is the transcript line the client hydrated up to over
736
+ // HTTP. Everything after it is what the client missed while it had no
737
+ // socket — a page reload's own load→connect gap, or a whole detached
738
+ // stretch. Replaying exactly that range is why a reload neither
739
+ // duplicates the last few seconds nor leaves a hole in them.
740
+ const safeChat = sanitizeChatId(chatId);
741
+ const entry = attach(ws, safeChat);
742
+ if (!entry) {
743
+ send(ws, { t: 'error', message: 'Too many chats are open. Close one and try again.' });
744
+ return;
745
+ }
746
+ const from = typeof frame.seq === 'number' && frame.seq >= 0 ? Math.floor(frame.seq) : 0;
747
+ for (const line of readChatLinesAfter(ctx.paths.designRoot, safeChat, from)) {
748
+ if (line.entry.role === 'agent' && line.entry.update) {
749
+ send(ws, { t: 'update', update: line.entry.update, seq: line.seq });
750
+ }
751
+ }
752
+ // Tells the client whether it re-joined a LIVE turn (so it can restore
753
+ // the busy state) rather than a finished one. Without this a reload
754
+ // mid-turn would look idle while the agent kept working.
755
+ send(ws, { t: 'attached', chat: safeChat, running: entry.turnActive });
756
+ } else if (frame.t === 'prompt' && typeof frame.text === 'string') {
402
757
  void handlePrompt(ws, frame.text, chatId, model, effort, modeId);
403
758
  } else if (frame.t === 'warm') {
404
759
  void handleWarm(ws, chatId, model, effort, modeId);
405
760
  } else if (frame.t === 'cancel') {
406
761
  // RC5 — a cancelled turn may never resolve prompt(); clear banners now.
407
- trackers.get(ws.data.id)?.endTurn();
408
- void bridges.get(ws.data.id)?.cancel();
762
+ const entry = entryForSocket(ws, typeof frame.chat === 'string' ? frame.chat : undefined);
763
+ entry?.tracker?.endTurn();
764
+ void entry?.bridge.cancel();
409
765
  } else if (frame.t === 'set-mode' && typeof frame.modeId === 'string' && frame.modeId) {
410
- void handleSetMode(ws, chatId, frame.modeId);
766
+ // Pass the RAW frame.chat (undefined when absent), not the
767
+ // `'default'`-defaulted `chatId` — otherwise `entryForSocket` takes its
768
+ // named branch and requires `chats.has('default')`, never reaching the
769
+ // "fall back to the single attached chat" path its siblings use. Fails
770
+ // closed either way, but it silently broke mode-switching for a client
771
+ // that omits `chat`. Parity with cancel/permission-response.
772
+ void handleSetMode(
773
+ ws,
774
+ typeof frame.chat === 'string' ? frame.chat : undefined,
775
+ frame.modeId
776
+ );
411
777
  } else if (
412
778
  frame.t === 'set-config' &&
413
779
  typeof frame.configId === 'string' &&
@@ -424,7 +790,10 @@ export function createAcp(ctx: Context, aiActivity?: AiActivity): Acp {
424
790
  // id, so a malformed decision here just denies, never allows blind.
425
791
  const decision =
426
792
  typeof frame.decision === 'string' && frame.decision ? frame.decision : 'cancelled';
427
- bridges.get(ws.data.id)?.resolvePermission(frame.id, decision);
793
+ entryForSocket(
794
+ ws,
795
+ typeof frame.chat === 'string' ? frame.chat : undefined
796
+ )?.bridge.resolvePermission(frame.id, decision);
428
797
  } else if (frame.t === 'elicitation-response' && typeof frame.id === 'string' && frame.id) {
429
798
  // feature-acp-ask-user-question — the human's answer/skip/cancel for a
430
799
  // pending `elicitation-request`. `action`/`content` are forwarded
@@ -433,22 +802,49 @@ export function createAcp(ctx: Context, aiActivity?: AiActivity): Acp {
433
802
  const action = typeof frame.action === 'string' ? frame.action : 'decline';
434
803
  const content =
435
804
  frame.content && typeof frame.content === 'object' ? frame.content : undefined;
436
- bridges.get(ws.data.id)?.resolveElicitation(frame.id, { action, content });
805
+ entryForSocket(
806
+ ws,
807
+ typeof frame.chat === 'string' ? frame.chat : undefined
808
+ )?.bridge.resolveElicitation(frame.id, { action, content });
437
809
  }
438
810
  },
439
811
 
812
+ /**
813
+ * DETACH — no longer a teardown (Addendum Task 8).
814
+ *
815
+ * This used to call `bridge.stop()` immediately, which is what made a page
816
+ * reload kill a running turn. Now the socket is simply unsubscribed; the
817
+ * bridge keeps running and the reaper decides its fate. Deliberately NOT
818
+ * calling `tracker.endTurn()` either: a detached-but-running turn is still
819
+ * genuinely editing files, so clearing its "Claude is editing …" banner
820
+ * would be a lie. `reap()` clears it when the agent actually stops, and
821
+ * ai-activity's own 30 s heartbeat grace covers a hard crash.
822
+ */
440
823
  onClose(ws) {
441
- trackers.get(ws.data.id)?.endTurn();
442
- trackers.delete(ws.data.id);
443
- const bridge = bridges.get(ws.data.id);
444
- if (bridge) {
445
- bridges.delete(ws.data.id);
446
- void bridge.stop();
824
+ const chats = wsChats.get(ws.data.id);
825
+ wsChats.delete(ws.data.id);
826
+ if (!chats) return;
827
+ for (const chatId of chats) {
828
+ const entry = bridges.get(chatId);
829
+ if (!entry) continue;
830
+ entry.sinks.delete(ws);
831
+ if (entry.sinks.size > 0) continue; // still open in another window
832
+ entry.detachedSince = Date.now();
833
+ armReap(entry);
447
834
  }
835
+ enforceDetachedCeiling();
448
836
  },
449
837
 
450
838
  size() {
451
839
  return bridges.size;
452
840
  },
841
+
842
+ runningChats() {
843
+ return [...bridges.values()].filter((e) => e.turnActive).map((e) => e.chatId);
844
+ },
845
+
846
+ stopAll() {
847
+ for (const entry of [...bridges.values()]) reap(entry);
848
+ },
453
849
  };
454
850
  }