@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 +0 -2
- package/package.json +1 -1
- package/src/controls.ts +4 -17
- package/src/messages.ts +29 -47
- package/src/permissions.ts +7 -18
- package/src/persistence.ts +11 -17
- package/src/protocol.ts +13 -31
- package/src/session.ts +122 -139
- package/src/shell.ts +21 -59
- package/src/tasks.ts +75 -25
- package/src/ws.ts +5 -8
package/README.md
CHANGED
package/package.json
CHANGED
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
|
-
//
|
|
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
|
-
//
|
|
38
|
-
//
|
|
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
|
|
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
|
-
//
|
|
2
|
-
//
|
|
3
|
-
//
|
|
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
|
-
//
|
|
23
|
-
//
|
|
24
|
-
|
|
25
|
-
|
|
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
|
|
69
|
-
//
|
|
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
|
|
77
|
-
//
|
|
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
|
-
//
|
|
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
|
-
//
|
|
94
|
-
//
|
|
95
|
-
//
|
|
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
|
-
//
|
|
142
|
-
//
|
|
143
|
-
//
|
|
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
|
-
|
|
198
|
-
|
|
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.
|
|
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.
|
|
239
|
-
if (block.
|
|
223
|
+
if (block?.type === "tool_use" && !block.parsed) {
|
|
224
|
+
if (block.partialJson) {
|
|
240
225
|
try {
|
|
241
|
-
block.input = JSON.parse(block.
|
|
242
|
-
block.
|
|
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.
|
|
229
|
+
console.warn("[stream_event] tool_use partial_json parse failed", err, block.partialJson);
|
|
245
230
|
}
|
|
246
231
|
} else {
|
|
247
|
-
block.
|
|
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
|
|
286
|
-
//
|
|
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;
|
package/src/permissions.ts
CHANGED
|
@@ -1,15 +1,7 @@
|
|
|
1
|
-
//
|
|
2
|
-
// subtype:"can_use_tool"
|
|
3
|
-
//
|
|
4
|
-
//
|
|
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;
|
|
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
|
-
//
|
|
42
|
-
//
|
|
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;
|
package/src/persistence.ts
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
|
-
//
|
|
2
|
-
//
|
|
3
|
-
//
|
|
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
|
-
//
|
|
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
|
-
//
|
|
72
|
-
//
|
|
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
|
-
//
|
|
78
|
+
// Best-effort UX, not a correctness requirement
|
|
83
79
|
}
|
|
84
80
|
}
|
|
85
81
|
|
|
86
|
-
//
|
|
87
|
-
//
|
|
88
|
-
//
|
|
89
|
-
//
|
|
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
|
-
//
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
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
|
-
//
|
|
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
|
-
//
|
|
262
|
-
//
|
|
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
|
|
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
|
-
//
|
|
464
|
-
//
|
|
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
|
-
//
|
|
52
|
-
//
|
|
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
|
|
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
|
-
//
|
|
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
|
-
//
|
|
151
|
-
// controller
|
|
152
|
-
//
|
|
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
|
-
//
|
|
161
|
-
//
|
|
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
|
-
//
|
|
167
|
-
//
|
|
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
|
-
//
|
|
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
|
-
//
|
|
225
|
-
//
|
|
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
|
-
//
|
|
226
|
+
// _local frames have no `type`, so check before any type-based dispatch
|
|
243
227
|
if (handleLocalFrame(frame)) return;
|
|
244
228
|
|
|
245
|
-
//
|
|
246
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
260
|
-
|
|
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
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
(typeof
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
|
394
|
-
//
|
|
395
|
-
// (
|
|
396
|
-
//
|
|
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
|
-
//
|
|
400
|
-
//
|
|
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
|
|
421
|
-
//
|
|
422
|
-
//
|
|
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
|
-
|
|
437
|
-
//
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
|
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
|
-
//
|
|
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
|
-
//
|
|
548
|
-
//
|
|
549
|
-
//
|
|
550
|
-
//
|
|
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
|
-
//
|
|
561
|
-
//
|
|
562
|
-
//
|
|
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
|
-
//
|
|
576
|
-
//
|
|
577
|
-
//
|
|
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
|
|
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
|
|
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
|
-
//
|
|
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
|
-
//
|
|
89
|
-
//
|
|
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
|
-
//
|
|
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
|
-
//
|
|
115
|
-
// sendMessage
|
|
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
|
|
123
|
-
//
|
|
124
|
-
//
|
|
125
|
-
//
|
|
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
|
-
//
|
|
135
|
-
//
|
|
136
|
-
//
|
|
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
|
-
//
|
|
141
|
-
//
|
|
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
|
-
//
|
|
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"
|
|
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
|
-
//
|
|
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
|
-
//
|
|
240
|
-
//
|
|
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
|
-
//
|
|
2
|
-
//
|
|
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
|
|
19
|
-
//
|
|
20
|
-
//
|
|
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
|
-
//
|
|
45
|
-
//
|
|
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
|
|
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
|
|
152
|
-
//
|
|
153
|
-
//
|
|
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
|
-
//
|
|
203
|
-
//
|
|
204
|
-
//
|
|
205
|
-
//
|
|
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
|
-
|
|
215
|
-
|
|
216
|
-
|
|
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:
|
|
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
|
-
//
|
|
2
|
-
//
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
-
//
|
|
65
|
+
// One handler throwing must not stop the others
|
|
69
66
|
console.error("[ws] handler error", err);
|
|
70
67
|
}
|
|
71
68
|
}
|