@1agh/maude 0.56.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.
- package/apps/studio/acp/bridge.ts +385 -26
- package/apps/studio/acp/index.ts +498 -102
- package/apps/studio/acp/running.ts +97 -0
- package/apps/studio/acp/transcript.ts +64 -0
- package/apps/studio/acp/write-scope.ts +459 -0
- package/apps/studio/build.ts +28 -1
- package/apps/studio/client/app.jsx +47 -30
- package/apps/studio/client/panels/ChatPanel.jsx +19 -1
- package/apps/studio/client/panels/CloudBar.jsx +72 -13
- package/apps/studio/client/panels/PermissionPrompt.jsx +146 -6
- package/apps/studio/client/panels/RepoBranchSwitcher.jsx +145 -6
- package/apps/studio/client/panels/acp-runtime.js +96 -5
- package/apps/studio/client/styles/3-shell-maude.css +11 -2
- package/apps/studio/client/styles/6-acp-chat.css +51 -0
- package/apps/studio/dist/client.bundle.js +1278 -1278
- package/apps/studio/dist/styles.css +1 -1
- package/apps/studio/http.ts +51 -2
- package/apps/studio/server.ts +11 -0
- package/apps/studio/sync/connection-state.ts +116 -6
- package/apps/studio/sync/index.ts +50 -1
- package/apps/studio/sync/presentation.ts +273 -0
- package/apps/studio/sync/remote-docs.ts +191 -0
- package/apps/studio/sync/status.ts +4 -1
- package/apps/studio/test/acp-activity-endpoint.test.ts +183 -0
- package/apps/studio/test/acp-branch-guard.test.ts +123 -0
- package/apps/studio/test/acp-bridge-lifetime.test.ts +369 -0
- package/apps/studio/test/acp-caps-bridge.test.ts +5 -0
- package/apps/studio/test/acp-commands.test.ts +5 -0
- package/apps/studio/test/acp-elicitation-bridge.test.ts +5 -0
- package/apps/studio/test/acp-permission-prompt.test.ts +175 -1
- package/apps/studio/test/acp-permission.test.ts +18 -2
- package/apps/studio/test/acp-session-allowed-tools.test.ts +62 -14
- package/apps/studio/test/acp-usage-bridge.test.ts +5 -0
- package/apps/studio/test/acp-write-gate.test.ts +323 -0
- package/apps/studio/test/acp-write-scope.test.ts +533 -0
- package/apps/studio/test/bundle-smoke.test.ts +35 -18
- package/apps/studio/test/canvas-origin-gate.test.ts +7 -0
- package/apps/studio/test/cloud-connect-note.test.ts +135 -0
- package/apps/studio/test/cloud-endpoints.test.ts +11 -4
- package/apps/studio/test/cloud-shell-surfaces.test.ts +5 -2
- package/apps/studio/test/fixtures/mock-acp-agent-slow.mjs +45 -0
- package/apps/studio/test/fixtures/mock-acp-agent-write.mjs +118 -0
- package/apps/studio/test/sync-connect-honesty.test.ts +114 -0
- package/apps/studio/test/sync-connection-state.test.ts +45 -1
- package/apps/studio/test/sync-presentation.test.ts +185 -0
- package/apps/studio/test/sync-remote-docs.test.ts +171 -0
- package/apps/studio/whats-new.json +18 -0
- package/package.json +8 -8
package/apps/studio/acp/index.ts
CHANGED
|
@@ -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: '
|
|
27
|
-
*
|
|
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), `
|
|
46
|
-
* `
|
|
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
|
-
|
|
139
|
-
|
|
140
|
-
|
|
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
|
-
/**
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
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
|
-
|
|
179
|
-
//
|
|
180
|
-
//
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
268
|
-
|
|
554
|
+
broadcast(entry, { t: 'connected', sessionId: bridge.sessionId });
|
|
555
|
+
broadcast(entry, { t: 'turn-end', stopReason });
|
|
269
556
|
} catch (err) {
|
|
270
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
326
|
-
|
|
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(
|
|
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
|
-
|
|
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 === '
|
|
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
|
-
|
|
408
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
bridges.
|
|
446
|
-
|
|
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
|
}
|