@somewhatintelligent/cc-ws-client 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/session.ts ADDED
@@ -0,0 +1,662 @@
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
+ import { atom, type ReadableAtom } from "nanostores";
5
+ import { createControlsClient } from "./controls";
6
+ import { createMessagesController, type MessageEntry } from "./messages";
7
+ import { createTasksController, type TaskEntry } from "./tasks";
8
+ import {
9
+ KNOWN_PERMISSION_MODES,
10
+ KNOWN_EFFORTS,
11
+ CYCLE_ORDER,
12
+ nextCycleMode,
13
+ type Effort,
14
+ type Model,
15
+ type PermissionMode,
16
+ } from "./modes";
17
+ import { createPermissionsController, type OnCanUseTool, type PendingPermission, type PermissionDecision } from "./permissions";
18
+ import {
19
+ installPersistenceWriter,
20
+ loadPersisted,
21
+ resolvePersistence,
22
+ type CcPersistenceOptions,
23
+ } from "./persistence";
24
+ import {
25
+ buildSpawnArgs,
26
+ isLocalRespawnResult,
27
+ isSystemFrame,
28
+ makeRequestId,
29
+ type InboundFrame,
30
+ type SessionMode,
31
+ type SystemInit,
32
+ type SystemSessionStateChanged,
33
+ type SystemHook,
34
+ } from "./protocol";
35
+ import { createShellController, type ShellEntry } from "./shell";
36
+ import { createWsClient, type WsClient, type WsStatus } from "./ws";
37
+
38
+ // ---------- public types ----------
39
+
40
+ export type HookEntry = {
41
+ id: string;
42
+ ts: number;
43
+ subtype: "hook_started" | "hook_progress" | "hook_response";
44
+ hookName?: string;
45
+ raw: SystemHook;
46
+ };
47
+
48
+ export type { ShellEntry, ShellSource } from "./shell";
49
+ export type { TaskEntry, TaskStatus, TaskUsage } from "./tasks";
50
+
51
+ // hookEvents is plain FIFO drop-oldest — long-running sessions with chatty
52
+ // hooks accumulate thousands of entries otherwise.
53
+ const HOOK_EVENTS_CAP = 200;
54
+
55
+ function capRingFifo<T>(arr: T[], cap: number): T[] {
56
+ return arr.length > cap ? arr.slice(arr.length - cap) : arr;
57
+ }
58
+
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
+ export type SessionState = "idle" | "running" | "requires_action" | "unknown";
62
+
63
+ export type InitData = {
64
+ sessionId: string | null;
65
+ model: string | null;
66
+ cwd: string | null;
67
+ agents: string[];
68
+ slashCommands: string[];
69
+ skills: string[];
70
+ };
71
+
72
+ export type CcAtoms = {
73
+ status: ReadableAtom<WsStatus>;
74
+ lastError: ReadableAtom<string | null>;
75
+ init: ReadableAtom<InitData>;
76
+ messages: ReadableAtom<MessageEntry[]>;
77
+ activeStreamId: ReadableAtom<string | null>;
78
+ activeMode: ReadableAtom<PermissionMode>;
79
+ pendingMode: ReadableAtom<PermissionMode | null>;
80
+ modeError: ReadableAtom<string | null>;
81
+ activeModel: ReadableAtom<string>;
82
+ pendingModel: ReadableAtom<string | null>;
83
+ modelError: ReadableAtom<string | null>;
84
+ activeEffort: ReadableAtom<Effort | "">;
85
+ pendingEffort: ReadableAtom<Effort | null>;
86
+ effortError: ReadableAtom<string | null>;
87
+ pendingPermissions: ReadableAtom<PendingPermission[]>;
88
+ hookEvents: ReadableAtom<HookEntry[]>;
89
+ shellEntries: ReadableAtom<ShellEntry[]>;
90
+ tasks: ReadableAtom<TaskEntry[]>;
91
+ sessionState: ReadableAtom<SessionState>;
92
+ };
93
+
94
+ export type { CcPersistenceOptions, StorageLike } from "./persistence";
95
+
96
+ export type CcSessionOptions = {
97
+ url: string;
98
+ args?: {
99
+ mode?: SessionMode;
100
+ permissionMode?: PermissionMode;
101
+ permissionPromptTool?: string | null;
102
+ includePartialMessages?: boolean;
103
+ includeHookEvents?: boolean;
104
+ effort?: Effort;
105
+ model?: Model | string;
106
+ };
107
+ onCanUseTool?: OnCanUseTool;
108
+ onTrace?: (dir: "in" | "out", line: string) => void;
109
+ 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.
113
+ wsClient?: WsClient;
114
+ };
115
+
116
+ export type CcSession = {
117
+ atoms: CcAtoms;
118
+ connect: () => void;
119
+ disconnect: () => void;
120
+ sendMessage: (text: string) => void;
121
+ sendShellContext: (command: string, followUp?: string) => void;
122
+ sendBashSideChannel: (command: string) => void;
123
+ interrupt: () => Promise<void>;
124
+ endSession: () => Promise<void>;
125
+ setPermissionMode: (mode: PermissionMode) => Promise<void>;
126
+ cyclePermissionMode: () => Promise<void>;
127
+ setModel: (model: string) => Promise<void>;
128
+ setEffort: (effort: Effort) => Promise<void>;
129
+ setMaxThinkingTokens: (n: number) => Promise<void>;
130
+ newSession: () => Promise<void>;
131
+ continueSession: () => Promise<void>;
132
+ 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.
136
+ stopTask: (taskId: string) => Promise<void>;
137
+ respondToPermission: (id: string, decision: PermissionDecision) => void;
138
+ fetchFileSuggestions: (query: string) => Promise<Array<{ path: string; score?: number }>>;
139
+ dismissShellEntry: (id: string) => void;
140
+ };
141
+
142
+ // ---------- factory ----------
143
+
144
+ export function createCcSession(opts: CcSessionOptions): CcSession {
145
+ const ws = opts.wsClient ?? createWsClient({ url: opts.url, onTrace: opts.onTrace });
146
+ const controls = createControlsClient(ws);
147
+ const messagesCtrl = createMessagesController();
148
+ const permissions = createPermissionsController({ ws, onCanUseTool: opts.onCanUseTool });
149
+ 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.
154
+ const shellCtrl = createShellController({
155
+ ws,
156
+ sendMessage: (text: string) => sendMessage(text),
157
+ });
158
+
159
+ // ---- 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.
163
+ const persistence = resolvePersistence(opts.persistence);
164
+ const persisted = persistence ? loadPersisted(persistence) : null;
165
+
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.
171
+ const initialMode: SessionMode = opts.args?.mode
172
+ ?? (persisted?.sessionId ? { kind: "resume", sessionId: persisted.sessionId } : { kind: "continue" });
173
+ let currentArgs: NonNullable<CcSessionOptions["args"]> = {
174
+ mode: initialMode,
175
+ permissionMode: opts.args?.permissionMode ?? persisted?.permissionMode ?? "default",
176
+ permissionPromptTool: opts.args?.permissionPromptTool ?? "stdio",
177
+ includePartialMessages: opts.args?.includePartialMessages ?? true,
178
+ includeHookEvents: opts.args?.includeHookEvents ?? true,
179
+ effort: opts.args?.effort ?? persisted?.effort,
180
+ model: opts.args?.model ?? persisted?.model,
181
+ };
182
+
183
+ // ---- atoms ----
184
+ const init = atom<InitData>({
185
+ sessionId: null,
186
+ model: null,
187
+ cwd: null,
188
+ agents: [],
189
+ slashCommands: [],
190
+ skills: [],
191
+ });
192
+ const activeMode = atom<PermissionMode>(currentArgs.permissionMode ?? "default");
193
+ const pendingMode = atom<PermissionMode | null>(null);
194
+ const modeError = atom<string | null>(null);
195
+ const activeModel = atom<string>(currentArgs.model ?? "");
196
+ const pendingModel = atom<string | null>(null);
197
+ const modelError = atom<string | null>(null);
198
+ const activeEffort = atom<Effort | "">((currentArgs.effort as Effort | undefined) ?? "");
199
+ const pendingEffort = atom<Effort | null>(null);
200
+ const effortError = atom<string | null>(null);
201
+ const hookEvents = atom<HookEntry[]>([]);
202
+ const shellEntries = shellCtrl.shellEntries;
203
+ const tasks = tasksCtrl.tasks;
204
+ const sessionState = atom<SessionState>("unknown");
205
+
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).
209
+ if (persisted?.messages && Array.isArray(persisted.messages)) {
210
+ messagesCtrl.hydrate(persisted.messages);
211
+ }
212
+
213
+ if (persistence) {
214
+ installPersistenceWriter({
215
+ persistence,
216
+ init,
217
+ messagesCtrl,
218
+ activeMode,
219
+ activeModel,
220
+ activeEffort,
221
+ });
222
+ }
223
+
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.
226
+ function makeAutoClear(target: { set: (v: string | null) => void }, ttlMs = 5000) {
227
+ let timer: ReturnType<typeof setTimeout> | null = null;
228
+ return (msg: string | null) => {
229
+ if (timer) clearTimeout(timer);
230
+ target.set(msg);
231
+ timer = msg ? setTimeout(() => target.set(null), ttlMs) : null;
232
+ };
233
+ }
234
+ const setModeErr = makeAutoClear(modeError);
235
+ const setModelErr = makeAutoClear(modelError);
236
+ const setEffortErr = makeAutoClear(effortError);
237
+
238
+ // ---- frame ingestion ----
239
+ let initSeen = false;
240
+
241
+ ws.onFrame((frame) => {
242
+ // 1. _local frames (respawn ack).
243
+ if (handleLocalFrame(frame)) return;
244
+
245
+ // 2. controls layer (control_response → resolve in-flight requests).
246
+ if (controls.ingest(frame)) return;
247
+
248
+ // 3. permissions gate (inbound control_request:can_use_tool).
249
+ 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
+ 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.
276
+ if (tasksCtrl.handleSubAgentFrame(frame)) return;
277
+
278
+ // 11. streaming + canonical assistant — let messages controller decide.
279
+ if (messagesCtrl.ingest(frame)) return;
280
+
281
+ // 12. fall-through: drop noisy frames; otherwise append to timeline.
282
+ if ("type" in frame && frame.type === "rate_limit_event") return;
283
+ messagesCtrl.pushFrame(frame);
284
+ });
285
+
286
+ function handleLocalFrame(frame: InboundFrame): boolean {
287
+ if (!isLocalRespawnResult(frame)) return false;
288
+ const resolver = pendingRespawns.get(frame.requestId);
289
+ if (resolver) {
290
+ pendingRespawns.delete(frame.requestId);
291
+ clearTimeout(resolver.timeoutId);
292
+ if (frame.ok) resolver.resolve();
293
+ else resolver.reject(frame.error ?? "respawn failed");
294
+ }
295
+ return true;
296
+ }
297
+
298
+ function handleSystemInit(frame: InboundFrame): boolean {
299
+ if (initSeen) return false;
300
+ if (!isSystemFrame(frame) || frame.subtype !== "init") return false;
301
+ initSeen = true;
302
+ const f = frame as SystemInit;
303
+ const m = f.permissionMode ?? f.permission_mode;
304
+ if (typeof m === "string" && (KNOWN_PERMISSION_MODES as readonly string[]).includes(m)) {
305
+ activeMode.set(m as PermissionMode);
306
+ currentArgs.permissionMode = m as PermissionMode;
307
+ }
308
+ const im = f.model ?? "";
309
+ if (im) {
310
+ activeModel.set(im);
311
+ currentArgs.model = im;
312
+ }
313
+ init.set({
314
+ sessionId: f.session_id ?? null,
315
+ model: im || null,
316
+ cwd: f.cwd ?? null,
317
+ agents: f.agents ?? [],
318
+ slashCommands: f.slash_commands ?? [],
319
+ skills: f.skills ?? [],
320
+ });
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 */ });
349
+ messagesCtrl.pushFrame(frame);
350
+ return true;
351
+ }
352
+
353
+ 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 }>;
358
+ const entry: HookEntry = {
359
+ id: crypto.randomUUID(),
360
+ ts: Date.now(),
361
+ subtype: sub,
362
+ hookName: hookFrame.hook_event_name ?? hookFrame.hookEventName,
363
+ raw: hookFrame,
364
+ };
365
+ hookEvents.set(capRingFifo([...hookEvents.get(), entry], HOOK_EVENTS_CAP));
366
+ return true;
367
+ }
368
+
369
+ function handleSessionStateChanged(frame: InboundFrame): boolean {
370
+ if (!isSystemFrame(frame) || frame.subtype !== "session_state_changed") return false;
371
+ const f = frame as SystemSessionStateChanged;
372
+ if (f.state === "idle" || f.state === "running" || f.state === "requires_action") {
373
+ sessionState.set(f.state);
374
+ }
375
+ return true;
376
+ }
377
+
378
+ // ---- respawn ----
379
+ type RespawnResolver = { resolve: () => void; reject: (err: string) => void; timeoutId: ReturnType<typeof setTimeout> };
380
+ const pendingRespawns = new Map<string, RespawnResolver>();
381
+
382
+ function respawn(timeoutMs = 60_000): Promise<void> {
383
+ const requestId = makeRequestId();
384
+ const args = buildSpawnArgs({
385
+ mode: currentArgs.mode!,
386
+ permissionMode: currentArgs.permissionMode,
387
+ permissionPromptTool: currentArgs.permissionPromptTool,
388
+ includePartialMessages: currentArgs.includePartialMessages,
389
+ includeHookEvents: currentArgs.includeHookEvents,
390
+ effort: currentArgs.effort,
391
+ model: currentArgs.model,
392
+ });
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.)
398
+ 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.
403
+ controls.abortAll("respawn");
404
+ permissions.clearQueue();
405
+ return new Promise<void>((resolve, reject) => {
406
+ const timeoutId = setTimeout(() => {
407
+ if (!pendingRespawns.has(requestId)) return;
408
+ pendingRespawns.delete(requestId);
409
+ reject(`respawn timed out after ${timeoutMs}ms`);
410
+ }, timeoutMs);
411
+ pendingRespawns.set(requestId, { resolve, reject, timeoutId });
412
+ ws.send({ _local: "respawn", args, requestId });
413
+ });
414
+ }
415
+
416
+ // ---- public methods ----
417
+
418
+ function connect() {
419
+ 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.
424
+ let offStatus: (() => void) | null = null;
425
+ let fired = false;
426
+ offStatus = ws.status.subscribe((s) => {
427
+ if (s !== "open" || fired) return;
428
+ fired = true;
429
+ offStatus?.();
430
+ offStatus = null;
431
+ void respawn().catch((err) => console.error("[session] initial respawn failed", err));
432
+ });
433
+ }
434
+
435
+ 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.
439
+ controls.abortAll("disconnected");
440
+ permissions.clearQueue();
441
+ // Reject any pending respawn, too — a respawn issued just before
442
+ // disconnect would otherwise sit until its 60s timeout.
443
+ for (const [id, r] of pendingRespawns) {
444
+ clearTimeout(r.timeoutId);
445
+ r.reject("disconnected");
446
+ pendingRespawns.delete(id);
447
+ }
448
+ ws.disconnect();
449
+ }
450
+
451
+ function sendMessage(text: string) {
452
+ if (!text.trim() && !shellCtrl.hasPending()) return;
453
+ const payload = shellCtrl.drainPending(text);
454
+ messagesCtrl.pushLocalUser(text);
455
+ ws.send({ type: "user", message: { role: "user", content: payload } });
456
+ }
457
+
458
+ async function interrupt() {
459
+ await controls.request({ subtype: "interrupt" }, { timeoutMs: 10_000 });
460
+ }
461
+
462
+ async function endSession() {
463
+ await controls.request({ subtype: "end_session" }, { timeoutMs: 10_000 });
464
+ }
465
+
466
+ async function setMaxThinkingTokens(n: number) {
467
+ await controls.request({ subtype: "set_max_thinking_tokens", max_tokens: n });
468
+ }
469
+
470
+ async function setPermissionMode(next: PermissionMode) {
471
+ if (next === activeMode.get() || pendingMode.get() != null) return;
472
+ pendingMode.set(next);
473
+ setModeErr(null);
474
+ try {
475
+ await controls.request(
476
+ { subtype: "set_permission_mode", mode: next },
477
+ { timeoutMs: 10_000 },
478
+ );
479
+ activeMode.set(next);
480
+ currentArgs.permissionMode = next;
481
+ pendingMode.set(null);
482
+ } catch (err) {
483
+ pendingMode.set(null);
484
+ 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.
487
+ if (CYCLE_ORDER.includes(next)) {
488
+ const skip = nextCycleMode(next);
489
+ setModeErr(`${next} not allowed (${msg}) — skipping to ${skip}`);
490
+ // Defer one tick so atom subscribers commit pendingMode=null first.
491
+ setTimeout(() => { void setPermissionMode(skip); }, 0);
492
+ } else {
493
+ setModeErr(`could not change to ${next}: ${msg}`);
494
+ }
495
+ }
496
+ }
497
+
498
+ async function cyclePermissionMode() {
499
+ const cur = activeMode.get();
500
+ if (pendingMode.get() != null) return;
501
+ await setPermissionMode(nextCycleMode(cur));
502
+ }
503
+
504
+ async function setModel(model: string) {
505
+ if (!model) return;
506
+ if (model === activeModel.get() || pendingModel.get() != null) return;
507
+ pendingModel.set(model);
508
+ setModelErr(null);
509
+ try {
510
+ await controls.request({ subtype: "set_model", model }, { timeoutMs: 30_000 });
511
+ activeModel.set(model);
512
+ currentArgs.model = model;
513
+ pendingModel.set(null);
514
+ } catch (err) {
515
+ pendingModel.set(null);
516
+ setModelErr(`could not change model to ${model}: ${err}`);
517
+ }
518
+ }
519
+
520
+ async function setEffort(effort: Effort) {
521
+ if (!effort) return;
522
+ if (effort === activeEffort.get() || pendingEffort.get() != null) return;
523
+ pendingEffort.set(effort);
524
+ setEffortErr(null);
525
+ const prev = currentArgs.effort;
526
+ currentArgs.effort = effort;
527
+ try {
528
+ await respawn();
529
+ activeEffort.set(effort);
530
+ pendingEffort.set(null);
531
+ } catch (err) {
532
+ currentArgs.effort = prev;
533
+ pendingEffort.set(null);
534
+ setEffortErr(`could not change effort to ${effort}: ${err}`);
535
+ }
536
+ }
537
+
538
+ async function changeSession(mode: SessionMode, opts: { resetTimeline: boolean }) {
539
+ currentArgs.mode = mode;
540
+ 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.
543
+ messagesCtrl.reset();
544
+ hookEvents.set([]);
545
+ shellCtrl.reset();
546
+ 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.
552
+ init.set({
553
+ sessionId: null,
554
+ model: null,
555
+ cwd: null,
556
+ agents: [],
557
+ slashCommands: [],
558
+ skills: [],
559
+ });
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.
563
+ if (persistence) {
564
+ try { persistence.storage.removeItem(persistence.key); } catch {}
565
+ }
566
+ }
567
+ // respawn() resets initSeen + aborts in-flight controls/permissions.
568
+ await respawn();
569
+ }
570
+
571
+ // newSession = brand-new conversation, wipe.
572
+ async function newSession() {
573
+ await changeSession({ kind: "new" }, { resetTimeline: true });
574
+ }
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.
580
+ async function continueSession() {
581
+ await changeSession({ kind: "continue" }, { resetTimeline: false });
582
+ }
583
+ // resumeSession = jump to a specific session by id; almost always means
584
+ // a different thread.
585
+ async function resumeSession(sessionId: string) {
586
+ await changeSession({ kind: "resume", sessionId }, { resetTimeline: true });
587
+ }
588
+
589
+ async function fetchFileSuggestions(query: string): Promise<Array<{ path: string; score?: number }>> {
590
+ try {
591
+ const { inner } = await controls.request(
592
+ { subtype: "file_suggestions", query },
593
+ { timeoutMs: 5_000 },
594
+ );
595
+ const sugg = (inner as { suggestions?: unknown } | null)?.suggestions;
596
+ if (!Array.isArray(sugg)) return [];
597
+ return sugg.flatMap((s) => {
598
+ if (!s || typeof s !== "object") return [];
599
+ const r = s as { path?: unknown; score?: unknown };
600
+ if (typeof r.path !== "string") return [];
601
+ return [{ path: r.path, score: typeof r.score === "number" ? r.score : undefined }];
602
+ });
603
+ } catch {
604
+ return [];
605
+ }
606
+ }
607
+
608
+ async function stopTask(taskId: string) {
609
+ // stop_task is decorative for local_agent — only interrupt halts it.
610
+ const task = tasks.get().find((t) => t.taskId === taskId);
611
+ if (task?.taskType === "local_agent") return interrupt();
612
+ await controls.request({ subtype: "stop_task", task_id: taskId }, { timeoutMs: 10_000 });
613
+ }
614
+
615
+ // ---- assemble ----
616
+
617
+ const atoms: CcAtoms = {
618
+ status: ws.status,
619
+ lastError: ws.lastError,
620
+ init,
621
+ messages: messagesCtrl.messages,
622
+ activeStreamId: messagesCtrl.activeStreamId,
623
+ activeMode,
624
+ pendingMode,
625
+ modeError,
626
+ activeModel,
627
+ pendingModel,
628
+ modelError,
629
+ activeEffort,
630
+ pendingEffort,
631
+ effortError,
632
+ pendingPermissions: permissions.pendingPermissions,
633
+ hookEvents,
634
+ shellEntries,
635
+ tasks,
636
+ sessionState,
637
+ };
638
+
639
+ return {
640
+ atoms,
641
+ connect,
642
+ disconnect,
643
+ sendMessage,
644
+ sendShellContext: shellCtrl.sendShellContext,
645
+ sendBashSideChannel: shellCtrl.sendBashSideChannel,
646
+ interrupt,
647
+ endSession,
648
+ setPermissionMode,
649
+ cyclePermissionMode,
650
+ setModel,
651
+ setEffort,
652
+ setMaxThinkingTokens,
653
+ newSession,
654
+ continueSession,
655
+ resumeSession,
656
+ stopTask,
657
+ respondToPermission: permissions.respond,
658
+ fetchFileSuggestions,
659
+ dismissShellEntry: shellCtrl.dismissShellEntry,
660
+ };
661
+ }
662
+