@somewhatintelligent/cc-ws-client 0.1.0 → 0.1.4

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/README.md CHANGED
@@ -12,5 +12,3 @@ session.sendMessage("Hello");
12
12
  // - @somewhatintelligent/cc-ws-react
13
13
  // - @somewhatintelligent/cc-ws-svelte (workspace-only for now)
14
14
  ```
15
-
16
- See `LIB-DESIGN.md` in the source repo for the wire protocol.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@somewhatintelligent/cc-ws-client",
3
- "version": "0.1.0",
3
+ "version": "0.1.4",
4
4
  "description": "Framework-agnostic reactive WebSocket client for the cc-ws Claude Code bridge protocol.",
5
5
  "license": "MIT",
6
6
  "type": "module",
package/src/controls.ts CHANGED
@@ -1,8 +1,3 @@
1
- // Control-request layer. Tracks every outbound control_request by request_id
2
- // and resolves a Promise when its matching control_response arrives. Higher
3
- // layers (session.ts) compose this for set_permission_mode / set_model /
4
- // get_settings / file_suggestions / interrupt / end_session.
5
-
6
1
  import {
7
2
  isControlResponse,
8
3
  makeRequestId,
@@ -11,9 +6,7 @@ import {
11
6
  } from "./protocol";
12
7
  import type { WsClient } from "./ws";
13
8
 
14
- // The wrapper carries the canonical control_response payload (we hand
15
- // both back so callers that need the request_id or subtype can dig in;
16
- // most just read .inner).
9
+ // Callers that need request_id/subtype use .wrapper; most just read .inner
17
10
  export type ControlResponseResult = {
18
11
  inner: unknown;
19
12
  wrapper: { subtype: "success"; request_id: string; response?: unknown };
@@ -30,14 +23,9 @@ export type ControlsClient = {
30
23
  request: OutboundControlRequestSubtype,
31
24
  opts?: { timeoutMs?: number },
32
25
  ) => Promise<ControlResponseResult>;
33
- // For frames we DIDN'T initiate (can_use_tool comes IN as control_request).
34
- // Returns true if the frame was handled (we matched it to an in-flight
35
- // request).
36
26
  ingest: (frame: InboundFrame) => boolean;
37
- // Reject every in-flight request with `reason`. Called from session
38
- // respawn/disconnect the bridge kills the old claude child the
39
- // moment we send respawn, so any outstanding control_request would
40
- // otherwise hang against a dead pipe until its timeoutMs fires.
27
+ // Bridge kills the claude child on respawn, so in-flight requests would
28
+ // otherwise hang against a dead pipe until timeoutMs fires
41
29
  abortAll: (reason: string) => void;
42
30
  };
43
31
 
@@ -67,8 +55,7 @@ export function createControlsClient(ws: WsClient): ControlsClient {
67
55
 
68
56
  function ingest(frame: InboundFrame): boolean {
69
57
  if (!isControlResponse(frame)) return false;
70
- // Real binary wire format may put request_id either at the outer
71
- // envelope OR inside response. Check both before giving up.
58
+ // Real binary may put request_id on outer envelope OR inside response
72
59
  const id = frame.response.request_id ?? frame.request_id;
73
60
  if (!id) return false;
74
61
  const r = inflight.get(id);
package/src/messages.ts CHANGED
@@ -1,12 +1,6 @@
1
- // Conversation log. Holds:
2
- // - Local user echoes (sent prompts mirrored into the chat).
3
- // - Raw inbound frames (assistant, user, result, control_*, etc).
4
- // - In-flight assistant messages reconstructed from `stream_event` deltas.
5
- //
6
- // The `messages` atom is the canonical timeline. Streaming messages live as
7
- // entries with kind:"streaming"; when the canonical `assistant` envelope
8
- // arrives, the streaming entry is removed and replaced by the final
9
- // `frame` entry (the renderer treats the assistant frame as authoritative).
1
+ // Streaming entries (rebuilt from stream_event deltas) are replaced by
2
+ // the canonical `frame` entry once the `assistant` envelope arrives —
3
+ // the envelope is authoritative.
10
4
 
11
5
  import { atom, type WritableAtom } from "nanostores";
12
6
  import type { InboundFrame, StreamEvent } from "./protocol";
@@ -19,11 +13,10 @@ export type ToolUseBlock = {
19
13
  id: string;
20
14
  name: string;
21
15
  input: unknown;
22
- // partial_json deltas accumulate here, parsed into `input` on
23
- // content_block_stop. _parsed flips true once parsed (or on empty input
24
- // close).
25
- _partialJson: string;
26
- _parsed: boolean;
16
+ // Renderers read partialJson before content_block_stop to show
17
+ // streaming tool inputs while the JSON is still open
18
+ partialJson: string;
19
+ parsed: boolean;
27
20
  };
28
21
  export type ThinkingBlock = { type: "thinking"; thinking: string };
29
22
  export type StreamingBlock = TextBlock | ToolUseBlock | ThinkingBlock;
@@ -65,21 +58,16 @@ export type MessageEntry = LocalUserEntry | FrameEntry | StreamingEntry;
65
58
  export type MessagesController = {
66
59
  messages: WritableAtom<MessageEntry[]>;
67
60
  activeStreamId: WritableAtom<string | null>;
68
- // Bumps on every non-streaming change (pushFrame / pushLocalUser /
69
- // dropStreaming / reset / hydrate). Stays still during streaming
70
- // deltas. Persistence and other "save on stable change" consumers
71
- // should subscribe to this rather than `messages` to avoid paying
72
- // a serialization tax once per 250ms while a turn is streaming.
61
+ // Bumps on non-streaming changes only; subscribe to this (not
62
+ // `messages`) for save-on-stable-change to skip the per-token churn
73
63
  revision: WritableAtom<number>;
74
64
  pushLocalUser: (text: string) => void;
75
65
  pushFrame: (frame: InboundFrame) => void;
76
- // Returns true if the frame was consumed by the streaming machinery and
77
- // should NOT be appended to the timeline as a frame entry.
66
+ // Returns true when the streaming machinery consumed the frame; the
67
+ // caller MUST NOT also append it as a frame entry
78
68
  ingest: (frame: InboundFrame) => boolean;
79
69
  reset: () => void;
80
- // Replace the timeline with a saved snapshot (used by persistence on
81
- // reload). Streaming entries are dropped — they're transient and don't
82
- // round-trip through serialization.
70
+ // Streaming entries are dropped they don't round-trip through serialization
83
71
  hydrate: (entries: MessageEntry[]) => void;
84
72
  };
85
73
 
@@ -90,11 +78,9 @@ export function createMessagesController(): MessagesController {
90
78
  function bumpRevision() {
91
79
  revision.set(revision.get() + 1);
92
80
  }
93
- // Streaming reconstructions, keyed by message id. Mutated in place; each
94
- // delta bumps the messages atom by emitting a new array reference so
95
- // subscribers re-render. We accept the array-replacement cost for the
96
- // simpler reactivity contract — there's typically ≤1 active streaming
97
- // message at a time.
81
+ // Mutated in place there's typically ≤1 active streaming message,
82
+ // so the array-replace-per-delta cost beats a more complex reactivity
83
+ // contract
98
84
  const streaming = new Map<string, InFlightMessage>();
99
85
  let arrivalCounter = 0;
100
86
 
@@ -138,10 +124,9 @@ export function createMessagesController(): MessagesController {
138
124
  }
139
125
 
140
126
  function bumpStreaming(id: string) {
141
- // Per-token rebump. Replace just the streaming entry's identity so
142
- // subscribers re-render the streaming entry always lives at the
143
- // tail (we never push frames after a stream starts until message_stop)
144
- // so we can mutate the array in place rather than scan with .map.
127
+ // The streaming entry lives at the tail until message_stop (no
128
+ // frames are pushed during a stream), so we can mutate the array
129
+ // in place rather than scan with .map
145
130
  const msg = streaming.get(id);
146
131
  if (!msg) return;
147
132
  const arr = messages.get();
@@ -194,8 +179,8 @@ export function createMessagesController(): MessagesController {
194
179
  id: cb.id ?? "",
195
180
  name: cb.name ?? "",
196
181
  input: cb.input ?? {},
197
- _partialJson: "",
198
- _parsed: false,
182
+ partialJson: "",
183
+ parsed: false,
199
184
  };
200
185
  } else if (cb.type === "thinking") {
201
186
  block = { type: "thinking", thinking: cb.thinking ?? "" };
@@ -219,7 +204,7 @@ export function createMessagesController(): MessagesController {
219
204
  delta.type === "input_json_delta" &&
220
205
  typeof delta.partial_json === "string"
221
206
  ) {
222
- block._partialJson += delta.partial_json;
207
+ block.partialJson += delta.partial_json;
223
208
  } else if (
224
209
  block?.type === "thinking" &&
225
210
  delta.type === "thinking_delta" &&
@@ -235,16 +220,16 @@ export function createMessagesController(): MessagesController {
235
220
  const msg = id ? streaming.get(id) : null;
236
221
  if (!msg) return;
237
222
  const block = msg.content[ev.index];
238
- if (block?.type === "tool_use" && !block._parsed) {
239
- if (block._partialJson) {
223
+ if (block?.type === "tool_use" && !block.parsed) {
224
+ if (block.partialJson) {
240
225
  try {
241
- block.input = JSON.parse(block._partialJson);
242
- block._parsed = true;
226
+ block.input = JSON.parse(block.partialJson);
227
+ block.parsed = true;
243
228
  } catch (err) {
244
- console.warn("[stream_event] tool_use partial_json parse failed", err, block._partialJson);
229
+ console.warn("[stream_event] tool_use partial_json parse failed", err, block.partialJson);
245
230
  }
246
231
  } else {
247
- block._parsed = true;
232
+ block.parsed = true;
248
233
  }
249
234
  }
250
235
  bumpStreaming(id!);
@@ -260,13 +245,10 @@ export function createMessagesController(): MessagesController {
260
245
 
261
246
  function ingest(frame: InboundFrame): boolean {
262
247
  if (!("type" in frame)) return false;
263
- // Streaming deltas: consume and never append the raw frame.
264
248
  if (frame.type === "stream_event" && frame.event) {
265
249
  applyStreamEvent(frame.event);
266
250
  return true;
267
251
  }
268
- // Canonical assistant envelope: drop any matching streaming reconstruction
269
- // (the envelope is authoritative), then append the frame.
270
252
  if (frame.type === "assistant" && frame.message?.id) {
271
253
  const id = frame.message.id;
272
254
  if (streaming.has(id)) dropStreaming(id);
@@ -282,8 +264,8 @@ export function createMessagesController(): MessagesController {
282
264
  const filtered = entries.filter(
283
265
  (e): e is LocalUserEntry | FrameEntry => e.kind === "frame" || e.kind === "local_user",
284
266
  );
285
- // Reseat arrivalCounter past the last hydrated entry so subsequent
286
- // pushes don't collide with restored ids in render order.
267
+ // Reseat past the last hydrated entry so subsequent pushes don't
268
+ // collide with restored ids in render order
287
269
  arrivalCounter = filtered.length > 0
288
270
  ? Math.max(...filtered.map((e) => e.arrivalIdx)) + 1
289
271
  : 0;
@@ -1,15 +1,7 @@
1
- // can_use_tool gate. The binary sends INBOUND control_request frames with
2
- // subtype:"can_use_tool" when --permission-prompt-tool=stdio is set. We must
3
- // reply with a CanUseToolResponseFrame (nested envelope) carrying either an
4
- // allow or a deny.
5
- //
6
- // Two consumer surfaces share the same machinery:
7
- // 1. promise callback — pass `onCanUseTool` to createCcSession; we resolve
8
- // it for each request and dispatch the result.
9
- // 2. queue + respondToPermission — subscribe to the `pendingPermissions`
10
- // atom, render UI, call respondToPermission(id, decision).
11
- // If `onCanUseTool` is provided it wins; the queue is then transient and
12
- // callers should not consume it from UI.
1
+ // With --permission-prompt-tool=stdio the binary sends inbound
2
+ // control_request{subtype:"can_use_tool"} and waits for a nested
3
+ // control_response. If onCanUseTool is provided it wins; the queue
4
+ // is then transient and callers should not consume it from UI.
13
5
 
14
6
  import { atom, type WritableAtom } from "nanostores";
15
7
  import {
@@ -24,7 +16,7 @@ export type PermissionDecision =
24
16
  | { behavior: "deny"; message?: string };
25
17
 
26
18
  export type PendingPermission = {
27
- id: string; // request_id we'll reply to
19
+ id: string;
28
20
  toolName: string;
29
21
  input: unknown;
30
22
  raw: CanUseToolRequest;
@@ -38,9 +30,8 @@ export type PermissionsController = {
38
30
  pendingPermissions: WritableAtom<PendingPermission[]>;
39
31
  ingest: (frame: InboundFrame) => boolean;
40
32
  respond: (id: string, decision: PermissionDecision) => void;
41
- // Drop every queued can_use_tool request without replying. Called by
42
- // session respawn/disconnect the issuing claude is dead, so any reply
43
- // would go nowhere; the queue is stale UI clutter.
33
+ // After respawn/disconnect the issuing claude is dead, so replies
34
+ // would go nowhere; queue is stale UI clutter
44
35
  clearQueue: () => void;
45
36
  };
46
37
 
@@ -83,7 +74,6 @@ export function createPermissionsController(opts: {
83
74
  raw: cr,
84
75
  };
85
76
  if (opts.onCanUseTool) {
86
- // Promise-style: resolve immediately, never enqueue.
87
77
  Promise.resolve(opts.onCanUseTool(entry))
88
78
  .then((decision) => reply(entry.id, decision, entry.input))
89
79
  .catch((err) => {
@@ -91,7 +81,6 @@ export function createPermissionsController(opts: {
91
81
  reply(entry.id, { behavior: "deny", message: "handler error" }, entry.input);
92
82
  });
93
83
  } else {
94
- // Queue-style: append for the consumer to handle via respond().
95
84
  pendingPermissions.set([...pendingPermissions.get(), entry]);
96
85
  }
97
86
  return true;
@@ -1,7 +1,6 @@
1
- // Local-storage persistence: sessionId + visible message timeline + active
2
- // mode/model/effort. Without this, --continue restores claude's internal
3
- // context but doesn't restream prior turns over stream-json — refresh would
4
- // land on an empty bubble list.
1
+ // --continue restores claude's internal context but doesn't restream
2
+ // prior turns over stream-json, so without local persistence a refresh
3
+ // lands on an empty bubble list.
5
4
 
6
5
  import type { MessagesController, MessageEntry } from "./messages";
7
6
  import type { Effort, PermissionMode } from "./modes";
@@ -17,8 +16,6 @@ export type CcPersistenceOptions = {
17
16
  enabled?: boolean;
18
17
  storage?: StorageLike;
19
18
  key?: string;
20
- // Cap on serialized message entries. Streaming entries are never
21
- // persisted (transient by definition).
22
19
  maxMessages?: number;
23
20
  };
24
21
 
@@ -40,8 +37,7 @@ export function resolvePersistence(
40
37
  opt: CcPersistenceOptions | false | undefined,
41
38
  ): PersistenceConfig | null {
42
39
  if (opt === false) return null;
43
- // Default to localStorage in browsers; null on the server so SSR doesn't
44
- // crash on missing globals.
40
+ // Null on server keeps SSR off the missing-globals cliff
45
41
  const g = globalThis as { localStorage?: StorageLike };
46
42
  const defaultStorage: StorageLike | null = g.localStorage ?? null;
47
43
  const storage = opt?.storage ?? defaultStorage;
@@ -68,8 +64,8 @@ export function loadPersisted(cfg: PersistenceConfig): PersistedShape | null {
68
64
 
69
65
  export function savePersisted(cfg: PersistenceConfig, payload: PersistedShape): void {
70
66
  try {
71
- // Streaming entries are transient; serializing would resurrect a
72
- // half-decoded message on reload. Keep frame + local_user only.
67
+ // Serializing streaming entries would resurrect a half-decoded
68
+ // message on reload; keep frame + local_user only
73
69
  const trimmed: PersistedShape = { ...payload };
74
70
  if (Array.isArray(payload.messages)) {
75
71
  const filtered = payload.messages.filter(
@@ -79,16 +75,14 @@ export function savePersisted(cfg: PersistenceConfig, payload: PersistedShape):
79
75
  }
80
76
  cfg.storage.setItem(cfg.key, JSON.stringify(trimmed));
81
77
  } catch {
82
- // Persistence is best-effort UX, not a correctness requirement.
78
+ // Best-effort UX, not a correctness requirement
83
79
  }
84
80
  }
85
81
 
86
- // Wires up debounced save-on-change for the relevant atoms. We subscribe
87
- // to messagesCtrl.revision (only bumps on non-streaming changes) rather
88
- // than `messages` directly otherwise every streaming token kicks the
89
- // 250ms timer and we serialize the entire timeline every 250ms during a
90
- // turn for changes that are about to be discarded by the streaming-entry
91
- // filter in savePersisted anyway.
82
+ // Subscribe to messagesCtrl.revision (bumps only on non-streaming changes),
83
+ // not `messages` directly otherwise every streaming token kicks the
84
+ // 250ms timer to re-serialize a timeline that the streaming-entry filter
85
+ // in savePersisted would discard anyway.
92
86
  export function installPersistenceWriter(args: {
93
87
  persistence: PersistenceConfig;
94
88
  init: ReadableAtom<{ sessionId: string | null }>;
package/src/protocol.ts CHANGED
@@ -1,15 +1,10 @@
1
- // Wire protocol every JSON shape exchanged between the browser and the
2
- // bridge. Two channels share the same WS:
3
- // 1. Claude Code stream-json frames (forwarded verbatim to/from the child).
4
- // 2. _local frames (intercepted by the bridge, never reach the child).
5
- //
6
- // Field sets are pulled from the binary's own runtime Zod schemas. Run
7
- // `bun packages/client/scripts/extract-claude-schemas.ts extract` against
8
- // the active `claude` binary to reproduce — see scripts/README.md for the
9
- // runbook + drift-detection workflow. Variants we don't actively consume
10
- // still ride the same discriminated union so consumers can narrow
11
- // without `as any` escape hatches. Unknown system subtypes fall through
12
- // to UnknownSystemFrame. Pinned to v2.1.129 (2026-05-05).
1
+ // Field sets pulled from the binary's runtime Zod schemas via
2
+ // scripts/extract-claude-schemas.ts (see scripts/README.md). Pinned to
3
+ // v2.1.129 (2026-05-05). Unknown system subtypes fall through to
4
+ // UnknownSystemFrame so unmodelled variants don't break narrowing.
5
+ // Two channels share the WS: Claude Code stream-json frames forwarded
6
+ // verbatim to/from the child, and `_local` frames intercepted by the
7
+ // bridge.
13
8
 
14
9
  // ---------- shared ----------
15
10
 
@@ -184,8 +179,7 @@ export type SystemTaskNotification = {
184
179
  session_id?: string;
185
180
  };
186
181
 
187
- // task_updated.patch is a wire-safe subset of TaskState fields that changed.
188
- // Mergeable into the local task map. Excludes abortController/messages/result.
182
+ // Wire-safe subset of TaskState; excludes abortController/messages/result
189
183
  export type TaskUpdatedPatch = {
190
184
  status?: "running" | "completed" | "failed" | "killed" | "stopped" | string;
191
185
  description?: string;
@@ -258,10 +252,8 @@ export type SystemFrame =
258
252
  | SystemLocalCommandOutput
259
253
  | SystemCompactBoundary;
260
254
 
261
- // Catch-all for system subtypes the binary may emit but the lib doesn't
262
- // model. Sits OUTSIDE SystemFrame so narrowing on a known subtype yields
263
- // a single specific variant; consumers that need the broad case (e.g.
264
- // renderers showing every system frame) accept SystemFrame | UnknownSystemFrame.
255
+ // Outside SystemFrame so narrowing on a known subtype yields a single
256
+ // specific variant; broad consumers accept SystemFrame | UnknownSystemFrame
265
257
  export type UnknownSystemFrame = {
266
258
  type: "system";
267
259
  subtype: string;
@@ -341,8 +333,6 @@ export type CanUseToolControlRequest = {
341
333
  permission_suggestions?: unknown[];
342
334
  };
343
335
 
344
- // Other inbound control_request subtypes the lib may receive but doesn't
345
- // drive UI off of. We accept them with subtype + an opaque payload.
346
336
  export type UnknownControlRequest = {
347
337
  subtype: string;
348
338
  };
@@ -372,8 +362,7 @@ export type ControlResponseError = {
372
362
  export type ControlResponseFrame = {
373
363
  type: "control_response";
374
364
  response: ControlResponseSuccess | ControlResponseError;
375
- // Some bridge variants put request_id at the outer level. Keep optional
376
- // for forward compatibility; consumers should prefer response.request_id.
365
+ // Some bridges put request_id outside; prefer response.request_id
377
366
  request_id?: string;
378
367
  };
379
368
 
@@ -412,7 +401,6 @@ export type BashCommandFrame = {
412
401
  command: string;
413
402
  };
414
403
 
415
- // Outbound control_request subtypes we use. Nested envelope.
416
404
  export type OutboundControlRequestSubtype =
417
405
  | { subtype: "interrupt" }
418
406
  | { subtype: "set_permission_mode"; mode: string }
@@ -429,7 +417,6 @@ export type OutboundControlRequestFrame = {
429
417
  request: OutboundControlRequestSubtype;
430
418
  };
431
419
 
432
- // Reply to a can_use_tool control_request. Nested envelope.
433
420
  export type CanUseToolResponseFrame = {
434
421
  type: "control_response";
435
422
  response: {
@@ -460,10 +447,8 @@ export function makeRequestId(): string {
460
447
  return crypto.randomUUID();
461
448
  }
462
449
 
463
- // Translate a SessionMode + extras into the CLI flag list for the spawn
464
- // (consumed by the bridge via _local:respawn). Effort and model are spawn-
465
- // time only at present (no set_effort control_request exists; --model picks
466
- // the launch model and set_model can change it later).
450
+ // Effort is spawn-time only (no set_effort control_request); --model
451
+ // picks the launch model and set_model can change it later
467
452
  export function buildSpawnArgs(opts: {
468
453
  mode: SessionMode;
469
454
  effort?: string;
@@ -485,8 +470,6 @@ export function buildSpawnArgs(opts: {
485
470
  return args;
486
471
  }
487
472
 
488
- // Discriminator helpers — narrow once, reuse the type guard.
489
-
490
473
  export function isSystemFrame(f: InboundFrame): f is SystemFrame | UnknownSystemFrame {
491
474
  return (f as { type?: unknown }).type === "system";
492
475
  }
@@ -519,7 +502,6 @@ export function isLocalRespawnResult(f: InboundFrame): f is LocalRespawnResultFr
519
502
  return (f as { _local?: unknown })._local === "respawnResult";
520
503
  }
521
504
 
522
- // Backwards-compat: existing call sites expect this name.
523
505
  export type CanUseToolRequest = ControlRequestFrame & {
524
506
  request: CanUseToolControlRequest;
525
507
  };
package/src/session.ts CHANGED
@@ -1,6 +1,3 @@
1
- // Session controller — composes ws + messages + controls + permissions into a
2
- // single reactive client. See LIB-DESIGN.md for the full API contract.
3
-
4
1
  import { atom, type ReadableAtom } from "nanostores";
5
2
  import { createControlsClient } from "./controls";
6
3
  import { createMessagesController, type MessageEntry } from "./messages";
@@ -48,16 +45,14 @@ export type HookEntry = {
48
45
  export type { ShellEntry, ShellSource } from "./shell";
49
46
  export type { TaskEntry, TaskStatus, TaskUsage } from "./tasks";
50
47
 
51
- // hookEvents is plain FIFO drop-oldest — long-running sessions with chatty
52
- // hooks accumulate thousands of entries otherwise.
48
+ // Long-running sessions with chatty hooks would otherwise accumulate
49
+ // thousands of entries
53
50
  const HOOK_EVENTS_CAP = 200;
54
51
 
55
52
  function capRingFifo<T>(arr: T[], cap: number): T[] {
56
53
  return arr.length > cap ? arr.slice(arr.length - cap) : arr;
57
54
  }
58
55
 
59
- // session_state_changed: tracks whether claude is mid-turn or idle. Useful
60
- // for UI affordances (show / hide spinner; prevent send while busy).
61
56
  export type SessionState = "idle" | "running" | "requires_action" | "unknown";
62
57
 
63
58
  export type InitData = {
@@ -107,9 +102,7 @@ export type CcSessionOptions = {
107
102
  onCanUseTool?: OnCanUseTool;
108
103
  onTrace?: (dir: "in" | "out", line: string) => void;
109
104
  persistence?: CcPersistenceOptions | false;
110
- // Test seam: inject a pre-built WS client (e.g. an in-memory fake) instead
111
- // of constructing one from `url`. The injected client must satisfy the
112
- // same WsClient contract.
105
+ // Test seam injected client must satisfy the WsClient contract
113
106
  wsClient?: WsClient;
114
107
  };
115
108
 
@@ -130,9 +123,7 @@ export type CcSession = {
130
123
  newSession: () => Promise<void>;
131
124
  continueSession: () => Promise<void>;
132
125
  resumeSession: (sessionId: string) => Promise<void>;
133
- // Stop a single task by id (any type bash, agent, teammate). Maps to
134
- // the stop_task control_request. Use over interrupt() when you want to
135
- // kill a specific bg task without ending the whole turn.
126
+ // Use over interrupt() to kill one bg task without ending the whole turn
136
127
  stopTask: (taskId: string) => Promise<void>;
137
128
  respondToPermission: (id: string, decision: PermissionDecision) => void;
138
129
  fetchFileSuggestions: (query: string) => Promise<Array<{ path: string; score?: number }>>;
@@ -147,27 +138,22 @@ export function createCcSession(opts: CcSessionOptions): CcSession {
147
138
  const messagesCtrl = createMessagesController();
148
139
  const permissions = createPermissionsController({ ws, onCanUseTool: opts.onCanUseTool });
149
140
  const tasksCtrl = createTasksController();
150
- // Shell controller takes a thunk for sendMessage because both the
151
- // controller and sendMessage live inside this factory; the thunk lets
152
- // the controller's queued follow-up text fire sendMessage() after the
153
- // bash exchange XML lands in the buffer.
141
+ // Thunk because sendMessage is defined later in this factory; the
142
+ // controller's queued follow-up needs to fire sendMessage() after the
143
+ // bash exchange XML lands in the buffer
154
144
  const shellCtrl = createShellController({
155
145
  ws,
156
146
  sendMessage: (text: string) => sendMessage(text),
157
147
  });
158
148
 
159
149
  // ---- persistence ----
160
- // We hydrate from storage BEFORE constructing initial state so saved
161
- // values feed the atoms' initial values rather than overwriting them
162
- // after subscribers have already rendered.
150
+ // Hydrate BEFORE constructing initial atom state so saved values feed
151
+ // the initial values, not overwrite them post-render
163
152
  const persistence = resolvePersistence(opts.persistence);
164
153
  const persisted = persistence ? loadPersisted(persistence) : null;
165
154
 
166
- // Spawn args, kept up-to-date as the user changes mode/model/effort. Used
167
- // when respawning (effort change, session lifecycle change) so the new
168
- // child inherits the user's selections. If persistence has a stored
169
- // sessionId, the initial mode becomes resume(stored) — this is the
170
- // post-refresh path that brings the user back to their last thread.
155
+ // Persisted sessionId routes the initial spawn through resume(stored)
156
+ // this is the post-refresh path back to the user's last thread
171
157
  const initialMode: SessionMode = opts.args?.mode
172
158
  ?? (persisted?.sessionId ? { kind: "resume", sessionId: persisted.sessionId } : { kind: "continue" });
173
159
  let currentArgs: NonNullable<CcSessionOptions["args"]> = {
@@ -203,9 +189,7 @@ export function createCcSession(opts: CcSessionOptions): CcSession {
203
189
  const tasks = tasksCtrl.tasks;
204
190
  const sessionState = atom<SessionState>("unknown");
205
191
 
206
- // Hydrate the message timeline from persisted snapshot if any. Doing
207
- // this BEFORE wiring the atom listener avoids a feedback loop where
208
- // hydration triggers a save (it's idempotent but wasteful).
192
+ // BEFORE installPersistenceWriter so hydration doesn't kick a save
209
193
  if (persisted?.messages && Array.isArray(persisted.messages)) {
210
194
  messagesCtrl.hydrate(persisted.messages);
211
195
  }
@@ -221,8 +205,8 @@ export function createCcSession(opts: CcSessionOptions): CcSession {
221
205
  });
222
206
  }
223
207
 
224
- // Transient errors auto-clear after 5s. Each pending update cancels the
225
- // previous timer so back-to-back failures don't get prematurely cleared.
208
+ // Each new message cancels the previous timer so back-to-back failures
209
+ // don't get prematurely cleared
226
210
  function makeAutoClear(target: { set: (v: string | null) => void }, ttlMs = 5000) {
227
211
  let timer: ReturnType<typeof setTimeout> | null = null;
228
212
  return (msg: string | null) => {
@@ -239,46 +223,48 @@ export function createCcSession(opts: CcSessionOptions): CcSession {
239
223
  let initSeen = false;
240
224
 
241
225
  ws.onFrame((frame) => {
242
- // 1. _local frames (respawn ack).
226
+ // _local frames have no `type`, so check before any type-based dispatch
243
227
  if (handleLocalFrame(frame)) return;
244
228
 
245
- // 2. controls layer (control_response resolve in-flight requests).
246
- if (controls.ingest(frame)) return;
229
+ // Branch system vs non-system once: the hot path (stream_event /
230
+ // assistant at token rate) is non-system, so this avoids walking
231
+ // ~5 system handlers before reaching messagesCtrl.ingest
232
+ if (isSystemFrame(frame)) {
233
+ switch (frame.subtype) {
234
+ case "init":
235
+ if (handleSystemInit(frame)) return;
236
+ break;
237
+ case "local_command_output":
238
+ if (shellCtrl.handleLocalCommandOutput(frame)) return;
239
+ break;
240
+ case "hook_started":
241
+ case "hook_progress":
242
+ case "hook_response":
243
+ if (handleHookEvent(frame)) return;
244
+ break;
245
+ case "task_started":
246
+ case "task_progress":
247
+ case "task_updated":
248
+ case "task_notification":
249
+ if (tasksCtrl.handleTaskEvent(frame)) return;
250
+ break;
251
+ case "session_state_changed":
252
+ if (handleSessionStateChanged(frame)) return;
253
+ break;
254
+ }
255
+ // Unknown / opted-out system subtypes still land on the timeline
256
+ messagesCtrl.pushFrame(frame);
257
+ return;
258
+ }
247
259
 
248
- // 3. permissions gate (inbound control_request:can_use_tool).
260
+ if (controls.ingest(frame)) return;
249
261
  if (permissions.ingest(frame)) return;
250
-
251
- // 4. system:init — capture once per spawn.
252
- if (handleSystemInit(frame)) return;
253
-
254
- // 5. user/isReplay shell echo / output. Side-effect-only: builds bash
255
- // XML for next-send buffer, falls through so the frame still lands
256
- // in the chat as a normal user bubble.
257
262
  shellCtrl.handleShellReplay(frame);
258
-
259
- // 6. system:local_command_output (rare).
260
- if (shellCtrl.handleLocalCommandOutput(frame)) return;
261
-
262
- // 7. hooks.
263
- if (handleHookEvent(frame)) return;
264
-
265
- // 8. task lifecycle (system:task_started / task_progress / task_notification).
266
- if (tasksCtrl.handleTaskEvent(frame)) return;
267
-
268
- // 9. session_state_changed.
269
- if (handleSessionStateChanged(frame)) return;
270
-
271
- // 10. Sub-agent frames (parent_tool_use_id set) MUST run BEFORE
272
- // messagesCtrl — the messages controller eats stream_event /
273
- // assistant unconditionally, which would otherwise pollute the
274
- // main timeline with sub-agent bubbles and starve the per-task
275
- // transcript.
263
+ // MUST run before messagesCtrl — messages eats stream_event/assistant
264
+ // unconditionally and would pollute the main timeline with sub-agent
265
+ // bubbles, starving the per-task transcript
276
266
  if (tasksCtrl.handleSubAgentFrame(frame)) return;
277
-
278
- // 11. streaming + canonical assistant — let messages controller decide.
279
267
  if (messagesCtrl.ingest(frame)) return;
280
-
281
- // 12. fall-through: drop noisy frames; otherwise append to timeline.
282
268
  if ("type" in frame && frame.type === "rate_limit_event") return;
283
269
  messagesCtrl.pushFrame(frame);
284
270
  });
@@ -297,7 +283,6 @@ export function createCcSession(opts: CcSessionOptions): CcSession {
297
283
 
298
284
  function handleSystemInit(frame: InboundFrame): boolean {
299
285
  if (initSeen) return false;
300
- if (!isSystemFrame(frame) || frame.subtype !== "init") return false;
301
286
  initSeen = true;
302
287
  const f = frame as SystemInit;
303
288
  const m = f.permissionMode ?? f.permission_mode;
@@ -318,47 +303,55 @@ export function createCcSession(opts: CcSessionOptions): CcSession {
318
303
  slashCommands: f.slash_commands ?? [],
319
304
  skills: f.skills ?? [],
320
305
  });
321
- void controls
322
- .request({ subtype: "get_settings" }, { timeoutMs: 10_000 })
323
- .then(({ inner }) => {
324
- const probes: unknown[] = [
325
- inner,
326
- (inner as Record<string, unknown> | null)?.applied,
327
- (inner as Record<string, unknown> | null)?.effective,
328
- (inner as Record<string, unknown> | null)?.settings,
329
- (inner as Record<string, unknown> | null)?.permissions,
330
- (inner as Record<string, unknown> | null)?.inferenceConfig,
331
- ];
332
- let found: string | undefined;
333
- for (const p of probes) {
334
- if (!p || typeof p !== "object") continue;
335
- const r = p as Record<string, unknown>;
336
- const cand =
337
- (typeof r.effortLevel === "string" && r.effortLevel) ||
338
- (typeof r.effort_level === "string" && r.effort_level) ||
339
- (typeof r.effort === "string" && r.effort) ||
340
- undefined;
341
- if (cand) { found = cand; break; }
342
- }
343
- if (found && (KNOWN_EFFORTS as readonly string[]).includes(found)) {
344
- activeEffort.set(found as Effort);
345
- currentArgs.effort = found as Effort;
346
- }
347
- })
348
- .catch(() => { /* silent */ });
306
+ // system:init doesn't carry effort. Skip the probe if we already
307
+ // know it (we spawned with --effort or restored from persistence)
308
+ // the binary honours the spawn flag, so currentArgs.effort is the
309
+ // ground truth in that case.
310
+ if (!currentArgs.effort) {
311
+ void controls
312
+ .request({ subtype: "get_settings" }, { timeoutMs: 10_000 })
313
+ .then(({ inner }) => {
314
+ const probes: unknown[] = [
315
+ inner,
316
+ (inner as Record<string, unknown> | null)?.applied,
317
+ (inner as Record<string, unknown> | null)?.effective,
318
+ (inner as Record<string, unknown> | null)?.settings,
319
+ (inner as Record<string, unknown> | null)?.permissions,
320
+ (inner as Record<string, unknown> | null)?.inferenceConfig,
321
+ ];
322
+ let found: string | undefined;
323
+ for (const p of probes) {
324
+ if (!p || typeof p !== "object") continue;
325
+ const r = p as Record<string, unknown>;
326
+ const cand =
327
+ (typeof r.effortLevel === "string" && r.effortLevel) ||
328
+ (typeof r.effort_level === "string" && r.effort_level) ||
329
+ (typeof r.effort === "string" && r.effort) ||
330
+ undefined;
331
+ if (cand) { found = cand; break; }
332
+ }
333
+ if (found && (KNOWN_EFFORTS as readonly string[]).includes(found)) {
334
+ activeEffort.set(found as Effort);
335
+ currentArgs.effort = found as Effort;
336
+ }
337
+ })
338
+ .catch((err) => {
339
+ // Surface the probe failure so the UI shows we don't actually
340
+ // know what effort the binary picked, instead of silently
341
+ // displaying "default".
342
+ setEffortErr(`could not read effort from get_settings: ${String(err)}`);
343
+ });
344
+ }
349
345
  messagesCtrl.pushFrame(frame);
350
346
  return true;
351
347
  }
352
348
 
353
349
  function handleHookEvent(frame: InboundFrame): boolean {
354
- if (!isSystemFrame(frame)) return false;
355
- const sub = frame.subtype;
356
- if (sub !== "hook_started" && sub !== "hook_progress" && sub !== "hook_response") return false;
357
- const hookFrame = frame as Extract<typeof frame, { subtype: typeof sub }>;
350
+ const hookFrame = frame as SystemHook;
358
351
  const entry: HookEntry = {
359
352
  id: crypto.randomUUID(),
360
353
  ts: Date.now(),
361
- subtype: sub,
354
+ subtype: hookFrame.subtype,
362
355
  hookName: hookFrame.hook_event_name ?? hookFrame.hookEventName,
363
356
  raw: hookFrame,
364
357
  };
@@ -367,7 +360,6 @@ export function createCcSession(opts: CcSessionOptions): CcSession {
367
360
  }
368
361
 
369
362
  function handleSessionStateChanged(frame: InboundFrame): boolean {
370
- if (!isSystemFrame(frame) || frame.subtype !== "session_state_changed") return false;
371
363
  const f = frame as SystemSessionStateChanged;
372
364
  if (f.state === "idle" || f.state === "running" || f.state === "requires_action") {
373
365
  sessionState.set(f.state);
@@ -390,16 +382,13 @@ export function createCcSession(opts: CcSessionOptions): CcSession {
390
382
  effort: currentArgs.effort,
391
383
  model: currentArgs.model,
392
384
  });
393
- // Reset BEFORE the wire send so the new claude's system:init is
394
- // accepted no matter whether respawnResult or system:init lands first.
395
- // (Prior bug: setEffort relied on respawnResult to clear initSeen, but
396
- // the bridge spawns the new child synchronously, so its first stdout
397
- // line could beat the local respawnResult on the wire.)
385
+ // Reset before the wire send: the bridge spawns the new child
386
+ // synchronously, so the new system:init can beat the local
387
+ // respawnResult on the wire (prior bug: setEffort relied on
388
+ // respawnResult to clear this)
398
389
  initSeen = false;
399
- // Cancel any control_request promises that were in flight against the
400
- // about-to-die child. The bridge kills the old child the moment it
401
- // receives _local:respawn; outstanding requests would otherwise hang
402
- // until their timeouts fire against a dead pipe.
390
+ // Bridge kills the old child the instant it receives _local:respawn;
391
+ // outstanding controls would otherwise hang against a dead pipe
403
392
  controls.abortAll("respawn");
404
393
  permissions.clearQueue();
405
394
  return new Promise<void>((resolve, reject) => {
@@ -415,12 +404,17 @@ export function createCcSession(opts: CcSessionOptions): CcSession {
415
404
 
416
405
  // ---- public methods ----
417
406
 
407
+ // One initial respawn per connect/disconnect cycle. Repeat connect()
408
+ // calls without an intervening disconnect() are no-ops; otherwise a
409
+ // duplicate connect would re-spawn against an already-running child.
410
+ let connectStarted = false;
418
411
  function connect() {
412
+ if (connectStarted) return;
413
+ connectStarted = true;
419
414
  ws.connect();
420
- // nanostores subscribe() fires synchronously with the current value
421
- // BEFORE returning the unsubscribe using `const off = subscribe(...)`
422
- // and referencing `off` inside the callback TDZ-throws if status is
423
- // already "open" at subscribe time. `let` + null-guard handles it.
415
+ // nanostores subscribe fires synchronously with the current value
416
+ // before returning the unsubscribe; `let` + null-guard avoids the
417
+ // TDZ throw if status is already "open" at subscribe time
424
418
  let offStatus: (() => void) | null = null;
425
419
  let fired = false;
426
420
  offStatus = ws.status.subscribe((s) => {
@@ -433,13 +427,11 @@ export function createCcSession(opts: CcSessionOptions): CcSession {
433
427
  }
434
428
 
435
429
  function disconnect() {
436
- // Reject in-flight controls and clear the permission queue before
437
- // closing the socket so consumers don't see UI buttons hang for
438
- // 30 seconds against a torn-down connection.
430
+ connectStarted = false;
431
+ // Reject before close so UI buttons don't hang 30s on a torn-down conn
439
432
  controls.abortAll("disconnected");
440
433
  permissions.clearQueue();
441
- // Reject any pending respawn, too a respawn issued just before
442
- // disconnect would otherwise sit until its 60s timeout.
434
+ // A respawn issued just before disconnect would otherwise sit 60s
443
435
  for (const [id, r] of pendingRespawns) {
444
436
  clearTimeout(r.timeoutId);
445
437
  r.reject("disconnected");
@@ -482,12 +474,11 @@ export function createCcSession(opts: CcSessionOptions): CcSession {
482
474
  } catch (err) {
483
475
  pendingMode.set(null);
484
476
  const msg = String(err);
485
- // Auto-skip: if the failed mode is in the cycle, jump to the next
486
- // cycle slot so cycling doesn't get stuck on a forbidden mode.
477
+ // Skip a forbidden mode in the cycle so cycling doesn't get stuck
487
478
  if (CYCLE_ORDER.includes(next)) {
488
479
  const skip = nextCycleMode(next);
489
480
  setModeErr(`${next} not allowed (${msg}) — skipping to ${skip}`);
490
- // Defer one tick so atom subscribers commit pendingMode=null first.
481
+ // Defer so atom subscribers commit pendingMode=null first
491
482
  setTimeout(() => { void setPermissionMode(skip); }, 0);
492
483
  } else {
493
484
  setModeErr(`could not change to ${next}: ${msg}`);
@@ -538,17 +529,15 @@ export function createCcSession(opts: CcSessionOptions): CcSession {
538
529
  async function changeSession(mode: SessionMode, opts: { resetTimeline: boolean }) {
539
530
  currentArgs.mode = mode;
540
531
  if (opts.resetTimeline) {
541
- // Switching to a different conversation thread wipe local state so
542
- // stale bubbles from the previous session don't bleed into the new one.
532
+ // Different thread; previous-session bubbles must not bleed in
543
533
  messagesCtrl.reset();
544
534
  hookEvents.set([]);
545
535
  shellCtrl.reset();
546
536
  tasksCtrl.reset();
547
- // Clear init too, otherwise the OLD sessionId / cwd / agents stay
548
- // visible in the UI until the new system:init lands (the wire round-
549
- // trip can be tens of ms but the visual flicker is jarring). Effort/
550
- // model respawns intentionally don't clear init — the same session
551
- // is preserved by --continue and gets the same sessionId back.
537
+ // Otherwise old sessionId/cwd/agents stay visible until the new
538
+ // system:init lands (jarring tens-of-ms flicker). Effort/model
539
+ // respawns intentionally don't clear init --continue preserves
540
+ // the session and reuses the same sessionId
552
541
  init.set({
553
542
  sessionId: null,
554
543
  model: null,
@@ -557,31 +546,25 @@ export function createCcSession(opts: CcSessionOptions): CcSession {
557
546
  slashCommands: [],
558
547
  skills: [],
559
548
  });
560
- // Also clear the persisted snapshot so a refresh after New/Resume
561
- // doesn't fall back to the previous session's saved sessionId. The
562
- // post-respawn system:init will write the new id back in.
549
+ // Otherwise a refresh after New/Resume falls back to the previous
550
+ // session's saved sessionId; post-respawn system:init writes the
551
+ // new id back in
563
552
  if (persistence) {
564
553
  try { persistence.storage.removeItem(persistence.key); } catch {}
565
554
  }
566
555
  }
567
- // respawn() resets initSeen + aborts in-flight controls/permissions.
568
556
  await respawn();
569
557
  }
570
558
 
571
- // newSession = brand-new conversation, wipe.
572
559
  async function newSession() {
573
560
  await changeSession({ kind: "new" }, { resetTimeline: true });
574
561
  }
575
- // continueSession = pick up the most recent thread. Don't wipe local
576
- // state: the CLI's --continue restores claude's internal context but
577
- // does NOT restream prior turns over stream-json, so wiping would leave
578
- // a permanently empty timeline. Calling continue when you're already on
579
- // the current session means "stay here," and the user's bubbles stay.
562
+ // --continue restores claude's internal context but does NOT restream
563
+ // prior turns, so wiping would leave a permanently empty timeline.
564
+ // Calling continue on the current session means "stay here"
580
565
  async function continueSession() {
581
566
  await changeSession({ kind: "continue" }, { resetTimeline: false });
582
567
  }
583
- // resumeSession = jump to a specific session by id; almost always means
584
- // a different thread.
585
568
  async function resumeSession(sessionId: string) {
586
569
  await changeSession({ kind: "resume", sessionId }, { resetTimeline: true });
587
570
  }
@@ -606,7 +589,7 @@ export function createCcSession(opts: CcSessionOptions): CcSession {
606
589
  }
607
590
 
608
591
  async function stopTask(taskId: string) {
609
- // stop_task is decorative for local_agent — only interrupt halts it.
592
+ // For local_agent stop_task is a no-op; only interrupt actually halts it
610
593
  const task = tasks.get().find((t) => t.taskId === taskId);
611
594
  if (task?.taskType === "local_agent") return interrupt();
612
595
  await controls.request({ subtype: "stop_task", task_id: taskId }, { timeoutMs: 10_000 });
package/src/shell.ts CHANGED
@@ -1,12 +1,7 @@
1
- // Bash exchange XML single source of truth for the format the bridge
2
- // replays back when a `bash_command` frame runs. Three shapes ride the
3
- // `user` content channel:
1
+ // Bash-replay XML on the user content channel. Three shapes:
4
2
  // input-only <bash-input>cmd</bash-input>
5
3
  // output-only <bash-stdout>…</bash-stdout><bash-stderr>…</bash-stderr><bash-exit-code>0</bash-exit-code>
6
4
  // merged both, plus arbitrary trailing user text (drain-synthesis)
7
- // Both the lib's outbound writer (handleShellReplay) and any UI that
8
- // renders bash exchanges parse the same shape — keep them in lockstep
9
- // here.
10
5
 
11
6
  import { atom, type WritableAtom } from "nanostores";
12
7
  import { isSystemFrame, isUserFrame, type InboundFrame } from "./protocol";
@@ -65,9 +60,7 @@ export function parseBashFrame(text: string): BashFrame | null {
65
60
  };
66
61
  }
67
62
 
68
- // Reconstruct the merged-frame XML the lib drains into the next user
69
- // message. Encoding mirrors what the binary sends back so the round-trip
70
- // is lossless.
63
+ // Encoding mirrors what the binary emits so the round-trip is lossless
71
64
  export function buildBashXml(command: string, stdoutXml: string, stderrXml: string): string {
72
65
  return (
73
66
  `<bash-input>${escapeXml(command)}</bash-input>\n` +
@@ -85,25 +78,19 @@ export type ShellEntry = {
85
78
  command: string;
86
79
  source: ShellSource;
87
80
  chunks: string[];
88
- // For context-shell: the exchange has run on the bridge but the
89
- // <bash-input>/<bash-stdout> XML hasn't been sent to claude yet — it
90
- // gets prepended to the user's next sendMessage() (matches the TUI's
91
- // shouldQuery:false flow). Cleared when drained.
81
+ // True between bridge-run and the drain into the next sendMessage
82
+ // matches the TUI's shouldQuery:false flow
92
83
  pending?: boolean;
93
84
  };
94
85
 
95
86
  export type ShellController = {
96
87
  shellEntries: WritableAtom<ShellEntry[]>;
97
- // Frame ingestion side-effects (return false intentionally for the
98
- // user-replay path — see handleShellReplay below).
88
+ // Returns false on the replay path so the frame still flows into messagesCtrl
99
89
  handleShellReplay: (frame: InboundFrame) => boolean;
100
90
  handleLocalCommandOutput: (frame: InboundFrame) => boolean;
101
- // Public ops.
102
91
  sendShellContext: (command: string, followUp?: string) => void;
103
92
  sendBashSideChannel: (command: string) => void;
104
93
  dismissShellEntry: (id: string) => void;
105
- // Drain pending bash exchanges into outbound text + clear their entries.
106
- // Returns the rewritten payload for sendMessage to wire-send.
107
94
  drainPending: (text: string) => string;
108
95
  hasPending: () => boolean;
109
96
  reset: () => void;
@@ -111,42 +98,32 @@ export type ShellController = {
111
98
 
112
99
  export function createShellController(args: {
113
100
  ws: WsClient;
114
- // sendMessage is back-injected because a queued follow-up text fires
115
- // sendMessage(followUp) after the bash exchange's XML is buffered, and
116
- // sendMessage in turn drains via drainPending().
101
+ // Back-injected: queued follow-ups fire sendMessage after buffering,
102
+ // and sendMessage in turn drains via drainPending
117
103
  sendMessage: (text: string) => void;
118
104
  }): ShellController {
119
105
  const { ws, sendMessage } = args;
120
106
  const shellEntries = atom<ShellEntry[]>([]);
121
107
 
122
- // bash_command replay frames don't carry a correlation id, but the
123
- // binary processes them in the order received and replies in the same
124
- // order. So we keep a FIFO of in-flight captures: each call to
125
- // sendShellContext / sendBashSideChannel pushes; each output-replay
126
- // frame shifts. Two bash sends in quick succession used to clobber
127
- // each other when we tracked a single `activeShellId` global.
108
+ // bash_command replays carry no correlation id, but the binary replies
109
+ // in send-order so we FIFO-track in-flight captures. Two bash sends
110
+ // in quick succession used to clobber each other when we tracked a
111
+ // single `activeShellId` global.
128
112
  const captureQueue: {
129
113
  entryId: string;
130
114
  command: string;
131
115
  source: ShellSource;
132
116
  sawInputEcho: boolean;
133
117
  }[] = [];
134
- // Buffered <bash-input>/<bash-stdout>/<bash-stderr> XML, FIFO. Filled
135
- // when a context-shell capture's output replay arrives; drained by
136
- // sendMessage() which prepends to the user's next prompt. The TUI-`!cmd`
137
- // parity workaround: bash_command's replay frames are NOT injected
138
- // into claude's transcript by the binary (verified empirically).
118
+ // Empirically the binary does NOT auto-inject bash_command replay
119
+ // frames into claude's transcript, so we buffer the XML here and
120
+ // prepend it to the user's next sendMessage to achieve TUI-`!cmd` parity
139
121
  const pendingBashExchanges: { entryId: string; xml: string }[] = [];
140
- // Optional follow-up user prompts queued from sendShellContext (the
141
- // multi-line `!cmd\n<followup>` form). Fires sendMessage(followUp)
142
- // after the bash exchange's XML is buffered, so the followUp goes out
143
- // as the user message that drains the buffer.
122
+ // Multi-line `!cmd\n<followup>` form: the followUp must go out as the
123
+ // user message that drains the buffer, not before it
144
124
  const pendingFollowUps = new Map<string, string>();
145
125
 
146
- // Side-effect-only: scrape bash-* replay frames for shellEntries chunks
147
- // + build the buffered XML that rides out on the next sendMessage.
148
- // Always returns false so the frame still flows into messagesCtrl for
149
- // chat-side rendering.
126
+ // Always returns false so the frame still reaches messagesCtrl for chat rendering
150
127
  function handleShellReplay(frame: InboundFrame): boolean {
151
128
  if (!isUserFrame(frame) || !frame.isReplay) return false;
152
129
  const c = frame.message.content;
@@ -155,14 +132,11 @@ export function createShellController(args: {
155
132
  if (!parsed) return false;
156
133
 
157
134
  if (parsed.kind === "input") {
158
- // First replay frame for the head capture: the binary acked the
159
- // command. Output is still pending.
160
135
  const head = captureQueue[0];
161
136
  if (head) head.sawInputEcho = true;
162
137
  return false;
163
138
  }
164
- // Both "output" (output-only replay) and "merged" (some bridges emit
165
- // input + output in one frame) close the head capture in FIFO order.
139
+ // Both "output" and "merged" close the head capture in FIFO order
166
140
  const cap = captureQueue.shift();
167
141
  if (!cap) return false;
168
142
  const parts: string[] = [];
@@ -174,8 +148,7 @@ export function createShellController(args: {
174
148
  shellEntries.get().map((s) => (s.id === cap.entryId ? { ...s, chunks: [...s.chunks, chunk] } : s)),
175
149
  );
176
150
  if (cap.source === "context") {
177
- // Re-encode for the buffered XML parseBashFrame decodes the
178
- // entities; the outbound payload needs them re-escaped.
151
+ // parseBashFrame decoded the entities; the outbound payload needs them re-escaped
179
152
  const stdoutXml = escapeXml(parsed.stdout);
180
153
  const stderrXml = escapeXml(parsed.stderr);
181
154
  pendingBashExchanges.push({
@@ -203,19 +176,10 @@ export function createShellController(args: {
203
176
  }
204
177
 
205
178
  function sendShellContext(command: string, followUp = "") {
206
- // Route `!cmd` to the binary via the bash_command wire frame: the
207
- // binary runs it (with its own bounds, sandboxing, etc) and replays
208
- // <bash-input>/<bash-stdout>/<bash-stderr>/<bash-exit-code> as
209
- // user/isReplay frames over the wire. Empirically these replay
210
- // frames are NOT auto-injected into claude's transcript — we have
211
- // to ride them out ourselves on the next user message.
212
179
  const id = crypto.randomUUID();
213
180
  shellEntries.set([...shellEntries.get(), { id, command, source: "context", chunks: [], pending: true }]);
214
181
  captureQueue.push({ entryId: id, command, source: "context", sawInputEcho: false });
215
182
  ws.send({ type: "bash_command", command });
216
- // If the user typed a follow-up prompt on subsequent lines of the
217
- // !cmd input, fire it right after the bash exchange completes — the
218
- // drain in sendMessage will prepend the XML to the followUp text.
219
183
  if (followUp.trim()) {
220
184
  pendingFollowUps.set(id, followUp);
221
185
  }
@@ -236,10 +200,8 @@ export function createShellController(args: {
236
200
  return pendingBashExchanges.length > 0;
237
201
  }
238
202
 
239
- // Drain buffered context-shell exchanges. Drained shell entries get
240
- // removed from shellEntries the panel acts as a "queued exchange"
241
- // indicator that empties when the user fires the message that flushes
242
- // the buffer. The exchange remains visible in the chat scrollback.
203
+ // shellEntries empties on drain (it's a "queued exchange" indicator);
204
+ // the exchange itself remains visible in chat scrollback
243
205
  function drainPending(text: string): string {
244
206
  if (pendingBashExchanges.length === 0) return text;
245
207
  const xml = pendingBashExchanges.map((p) => p.xml).join("\n");
package/src/tasks.ts CHANGED
@@ -1,7 +1,5 @@
1
- // Task lifecycle: system:task_started / task_progress / task_updated /
2
- // task_notification, plus sub-agent fan-out (frames carrying a non-null
3
- // parent_tool_use_id are routed to the matching task's transcript instead
4
- // of the main timeline).
1
+ // Frames carrying a non-null parent_tool_use_id are routed to the
2
+ // matching task's transcript instead of the main timeline.
5
3
 
6
4
  import { atom, type WritableAtom } from "nanostores";
7
5
  import type { MessageEntry } from "./messages";
@@ -15,10 +13,9 @@ import {
15
13
  type TaskUsageBlock,
16
14
  } from "./protocol";
17
15
 
18
- // task_id is the binary's local registry id and the value to pass to
19
- // stopTask(). tool_use_id ties the task back to the tool_use block that
20
- // spawned it (Bash, Agent, Task, etc) — used to render task progress
21
- // alongside / inside the parent's tool_use card.
16
+ // task_id is the value stopTask() takes; tool_use_id matches the
17
+ // spawning tool_use block (Bash, Agent, Task) so UI can nest progress
18
+ // inside the parent card
22
19
  export type TaskStatus = "running" | "completed" | "failed" | "stopped";
23
20
 
24
21
  export type TaskUsage = {
@@ -41,9 +38,8 @@ export type TaskEntry = {
41
38
  summary?: string;
42
39
  outputFile?: string;
43
40
  usage?: TaskUsage;
44
- // Sub-agent transcript (frames received with parent_tool_use_id matching
45
- // toolUseId). Populated only for tasks that emit their own frames
46
- // bash tasks won't have these.
41
+ // Frames whose parent_tool_use_id matches toolUseId; bash tasks
42
+ // emit none and stay empty
47
43
  transcript: MessageEntry[];
48
44
  };
49
45
 
@@ -51,7 +47,7 @@ const TASKS_CAP = 100;
51
47
 
52
48
  export function capTasksKeepRunning<T extends { status: string }>(arr: T[], cap: number): T[] {
53
49
  if (arr.length <= cap) return arr;
54
- // Evict oldest non-running entries first; if all are running, keep all.
50
+ // Evict oldest non-running first; if all are running, exceed the cap
55
51
  const overflow = arr.length - cap;
56
52
  const out: T[] = [];
57
53
  let evictBudget = overflow;
@@ -81,8 +77,55 @@ export type TasksController = {
81
77
  reset: () => void;
82
78
  };
83
79
 
80
+ // Real binary ordering puts task_started ahead of fan-out frames,
81
+ // so anything still orphaned past the TTL is a wire-protocol bug
82
+ // worth surfacing in the console
83
+ const ORPHAN_TTL_MS = 5000;
84
+
85
+ type PendingFrame = { frame: InboundFrame; addedAt: number };
86
+
84
87
  export function createTasksController(): TasksController {
85
88
  const tasks = atom<TaskEntry[]>([]);
89
+ const pendingByParent = new Map<string, PendingFrame[]>();
90
+
91
+ function gcPending(now: number) {
92
+ for (const [parent, buf] of pendingByParent) {
93
+ const kept = buf.filter((p) => now - p.addedAt < ORPHAN_TTL_MS);
94
+ if (kept.length === buf.length) continue;
95
+ const dropped = buf.length - kept.length;
96
+ if (dropped > 0) {
97
+ console.warn("[tasks] orphan sub-agent frame dropped after TTL", parent, dropped);
98
+ }
99
+ if (kept.length === 0) {
100
+ pendingByParent.delete(parent);
101
+ } else {
102
+ pendingByParent.set(parent, kept);
103
+ }
104
+ }
105
+ }
106
+
107
+ function flushPendingFor(parent: string) {
108
+ const buf = pendingByParent.get(parent);
109
+ if (!buf || buf.length === 0) return;
110
+ pendingByParent.delete(parent);
111
+ const arr = tasks.get();
112
+ const idx = arr.findIndex((t) => t.toolUseId === parent);
113
+ if (idx === -1) return;
114
+ const next = arr.slice();
115
+ const cur = arr[idx]!;
116
+ const appended: MessageEntry[] = buf.map((p, i) => ({
117
+ kind: "frame" as const,
118
+ id: crypto.randomUUID(),
119
+ frame: p.frame,
120
+ arrivalIdx: cur.transcript.length + i,
121
+ }));
122
+ next[idx] = {
123
+ ...cur,
124
+ transcript: [...cur.transcript, ...appended],
125
+ lastUpdate: Date.now(),
126
+ };
127
+ tasks.set(next);
128
+ }
86
129
 
87
130
  function upsertTask(taskId: string, mut: (t: TaskEntry) => TaskEntry, init?: () => TaskEntry) {
88
131
  const arr = tasks.get();
@@ -97,8 +140,6 @@ export function createTasksController(): TasksController {
97
140
  }
98
141
  }
99
142
 
100
- // task_started bookend opens a task; progress updates the running entry;
101
- // task_notification closes it with terminal status.
102
143
  function handleTaskEvent(frame: InboundFrame): boolean {
103
144
  if (!isSystemFrame(frame)) return false;
104
145
 
@@ -131,6 +172,7 @@ export function createTasksController(): TasksController {
131
172
  transcript: [],
132
173
  }),
133
174
  );
175
+ if (f.tool_use_id) flushPendingFor(f.tool_use_id);
134
176
  return true;
135
177
  }
136
178
 
@@ -148,9 +190,9 @@ export function createTasksController(): TasksController {
148
190
  return true;
149
191
  }
150
192
 
151
- // task_updated is the binary's immediate state-change frame (stop_task
152
- // status:"killed"). The bookend task_notification arrives later;
153
- // flip status NOW so a killed task doesn't render as still-running.
193
+ // task_updated lands immediately on stop_task; the closing
194
+ // task_notification arrives later, so we must flip status now
195
+ // to avoid rendering a killed task as still-running
154
196
  if (frame.subtype === "task_updated") {
155
197
  const f = frame as SystemTaskUpdated;
156
198
  if (!f.task_id || !f.patch) return false;
@@ -199,21 +241,28 @@ export function createTasksController(): TasksController {
199
241
  return false;
200
242
  }
201
243
 
202
- // Sub-agent frames must be checked BEFORE messagesCtrl.ingest in the
203
- // dispatch chain the messages controller eats stream_event / assistant
204
- // unconditionally, which would otherwise pollute the main timeline with
205
- // sub-agent bubbles and starve the per-task transcript.
244
+ // MUST run before messagesCtrl.ingest in the dispatch chain — messages
245
+ // eats stream_event/assistant unconditionally and would pollute the
246
+ // main timeline with sub-agent bubbles. Returning true claims the
247
+ // frame: appended to the task transcript, buffered until task_started
248
+ // arrives, or dropped after ORPHAN_TTL_MS.
206
249
  function handleSubAgentFrame(frame: InboundFrame): boolean {
207
250
  const parent =
208
251
  "parent_tool_use_id" in frame && typeof frame.parent_tool_use_id === "string"
209
252
  ? frame.parent_tool_use_id
210
253
  : null;
211
254
  if (!parent) return false;
255
+ const now = Date.now();
256
+ gcPending(now);
212
257
  const arr = tasks.get();
213
258
  const idx = arr.findIndex((t) => t.toolUseId === parent);
214
- // Race: task_started not yet observed. Fall through so the frame still
215
- // appears somewhere visible-but-misplaced beats dropped.
216
- if (idx === -1) return false;
259
+ if (idx === -1) {
260
+ // task_started not yet observed; buffer until it lands
261
+ const buf = pendingByParent.get(parent) ?? [];
262
+ buf.push({ frame, addedAt: now });
263
+ pendingByParent.set(parent, buf);
264
+ return true;
265
+ }
217
266
  const next = arr.slice();
218
267
  const cur = arr[idx]!;
219
268
  next[idx] = {
@@ -227,7 +276,7 @@ export function createTasksController(): TasksController {
227
276
  arrivalIdx: cur.transcript.length,
228
277
  },
229
278
  ],
230
- lastUpdate: Date.now(),
279
+ lastUpdate: now,
231
280
  };
232
281
  tasks.set(next);
233
282
  return true;
@@ -235,6 +284,7 @@ export function createTasksController(): TasksController {
235
284
 
236
285
  function reset() {
237
286
  tasks.set([]);
287
+ pendingByParent.clear();
238
288
  }
239
289
 
240
290
  return {
package/src/ws.ts CHANGED
@@ -1,6 +1,5 @@
1
- // Thin WS wrapper. Does NOT auto-reconnect (deferred per LIB-DESIGN). One
2
- // connection per createCcSession. NDJSON framing on the wire — every send is
3
- // already a single JSON object; the bridge splits inbound on `\n`.
1
+ // Does NOT auto-reconnect. NDJSON on the wire: each send is a single JSON
2
+ // object; bridge splits inbound on `\n`.
4
3
 
5
4
  import { atom, type WritableAtom } from "nanostores";
6
5
  import type { InboundFrame, OutboundFrame } from "./protocol";
@@ -19,9 +18,7 @@ export type WsClient = {
19
18
  connect: () => void;
20
19
  disconnect: () => void;
21
20
  send: (frame: OutboundFrame) => void;
22
- // Internal: subscribers register here to receive parsed inbound frames.
23
- // Avoids forcing a fan-out atom that would re-render every subscriber on
24
- // every frame.
21
+ // Direct fan-out instead of atom avoids re-rendering every subscriber per frame
25
22
  onFrame: (handler: (frame: InboundFrame) => void) => () => void;
26
23
  };
27
24
 
@@ -58,14 +55,14 @@ export function createWsClient(opts: {
58
55
  try {
59
56
  parsed = JSON.parse(text);
60
57
  } catch {
61
- // Bad frame — drop. We don't want one bad frame to take down the session.
58
+ // One bad frame must not take down the session
62
59
  return;
63
60
  }
64
61
  for (const h of handlers) {
65
62
  try {
66
63
  h(parsed);
67
64
  } catch (err) {
68
- // A handler throwing should not stop other handlers.
65
+ // One handler throwing must not stop the others
69
66
  console.error("[ws] handler error", err);
70
67
  }
71
68
  }