repoburg 1.3.196 → 1.3.197

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. package/agent-pane/backend/data.db-shm +0 -0
  2. package/agent-pane/backend/data.db-wal +0 -0
  3. package/agent-pane/backend/dist/index.js +13399 -10390
  4. package/agent-pane/backend/package-lock.json +162 -92
  5. package/agent-pane/backend/scripts/backfill-from-jsonl.ts +206 -0
  6. package/agent-pane/backend/scripts/import-session-from-jsonl.ts +264 -0
  7. package/agent-pane/backend/src/claude/claude-message-mapper.test.ts +116 -8
  8. package/agent-pane/backend/src/claude/claude-message-mapper.ts +114 -9
  9. package/agent-pane/backend/src/claude/claude-provider.ts +918 -84
  10. package/agent-pane/backend/src/claude/claude-types.ts +16 -4
  11. package/agent-pane/backend/src/codex/app-server-client.ts +22 -0
  12. package/agent-pane/backend/src/db/index.ts +102 -0
  13. package/agent-pane/backend/src/db/schema.ts +4 -0
  14. package/agent-pane/backend/src/index.ts +3 -1
  15. package/agent-pane/backend/src/integration/helpers/test-app.ts +2 -0
  16. package/agent-pane/backend/src/integration/session-operations.test.ts +18 -11
  17. package/agent-pane/backend/src/integration/threads.test.ts +309 -0
  18. package/agent-pane/backend/src/providers/runtime-provider.ts +19 -1
  19. package/agent-pane/backend/src/routes/providers.ts +11 -0
  20. package/agent-pane/backend/src/routes/sessions.test.ts +143 -9
  21. package/agent-pane/backend/src/routes/sessions.ts +133 -28
  22. package/agent-pane/backend/src/routes/threads.ts +308 -0
  23. package/agent-pane/backend/src/routes/turns.ts +32 -9
  24. package/agent-pane/frontend/dist/assets/index-BYo-lnxm.js +362 -0
  25. package/agent-pane/frontend/dist/assets/index-Dwwhx7YN.css +1 -0
  26. package/agent-pane/frontend/dist/index.html +2 -2
  27. package/agent-pane/frontend/package-lock.json +51 -9
  28. package/agent-pane/frontend/src/__tests__/ThreadsPanel.test.tsx +192 -0
  29. package/agent-pane/frontend/src/api/providers.ts +6 -1
  30. package/agent-pane/frontend/src/api/sessions.test.ts +4 -0
  31. package/agent-pane/frontend/src/api/sessions.ts +13 -5
  32. package/agent-pane/frontend/src/api/websocket.ts +65 -1
  33. package/agent-pane/frontend/src/components/CollapsibleToolCalls.test.tsx +4 -4
  34. package/agent-pane/frontend/src/components/CollapsibleToolCalls.tsx +13 -4
  35. package/agent-pane/frontend/src/components/CommandInputBar.test.tsx +2 -2
  36. package/agent-pane/frontend/src/components/CommandInputBar.tsx +292 -29
  37. package/agent-pane/frontend/src/components/CommandPalette.test.tsx +0 -4
  38. package/agent-pane/frontend/src/components/CommandPalette.tsx +1 -17
  39. package/agent-pane/frontend/src/components/CommentableText.tsx +291 -0
  40. package/agent-pane/frontend/src/components/RenderArea.tsx +9 -2
  41. package/agent-pane/frontend/src/components/RightPanel.test.tsx +5 -5
  42. package/agent-pane/frontend/src/components/RightPanel.tsx +20 -14
  43. package/agent-pane/frontend/src/components/SessionTabs.tsx +7 -2
  44. package/agent-pane/frontend/src/components/StatusBar.tsx +68 -1
  45. package/agent-pane/frontend/src/components/TurnContent.test.tsx +29 -18
  46. package/agent-pane/frontend/src/components/TurnContent.tsx +199 -28
  47. package/agent-pane/frontend/src/components/input/SlashPopup.tsx +24 -5
  48. package/agent-pane/frontend/src/components/panels/TasksPanel.test.tsx +97 -0
  49. package/agent-pane/frontend/src/components/panels/TasksPanel.tsx +208 -0
  50. package/agent-pane/frontend/src/components/panels/ThreadsPanel.tsx +350 -0
  51. package/agent-pane/frontend/src/components/panels/TurnsPanel.tsx +47 -25
  52. package/agent-pane/frontend/src/hooks/useSessionStatus.test.ts +2 -0
  53. package/agent-pane/frontend/src/store/sessionSlice.ts +29 -6
  54. package/agent-pane/frontend/src/store/store.test.ts +5 -5
  55. package/agent-pane/frontend/src/store/store.ts +4 -1
  56. package/agent-pane/frontend/src/store/threadSlice.ts +136 -0
  57. package/agent-pane/frontend/src/store/wsSlice.ts +72 -3
  58. package/agent-pane/frontend/src/styles/app.css +150 -19
  59. package/agent-pane/frontend/src/test/fixtures.ts +4 -0
  60. package/agent-pane/frontend/src/types/index.ts +5 -2
  61. package/agent-pane/frontend/src/utils/duration.test.ts +4 -0
  62. package/agent-pane/frontend/vite.config.ts +2 -2
  63. package/agent-pane/shared/provider-types.ts +14 -0
  64. package/agent-pane/shared/types.ts +60 -0
  65. package/package.json +1 -1
  66. package/agent-pane/frontend/dist/assets/index-Bg0PF2aV.css +0 -1
  67. package/agent-pane/frontend/dist/assets/index-BlybeX5e.js +0 -281
  68. package/agent-pane/frontend/src/components/panels/LogsPanel.test.tsx +0 -58
  69. package/agent-pane/frontend/src/components/panels/LogsPanel.tsx +0 -25
  70. package/agent-pane/frontend/src/components/panels/MetricsPanel.test.tsx +0 -71
  71. package/agent-pane/frontend/src/components/panels/MetricsPanel.tsx +0 -114
@@ -1,8 +1,10 @@
1
1
  /**
2
2
  * Claude provider — implements the RuntimeProvider interface.
3
3
  *
4
- * Uses `@anthropic-ai/claude-agent-sdk` `query()` function directly.
5
- * The SDK spawns the Claude CLI internally — no manual subprocess.
4
+ * Uses `@anthropic-ai/claude-agent-sdk` `query()` function in **streaming-input
5
+ * mode** — a single `query()` per session, fed by an async iterable of
6
+ * `SDKUserMessage` objects. This keeps the CLI process alive between turns,
7
+ * which is required for Monitor background tasks to survive turn boundaries.
6
8
  *
7
9
  * Same WebSocket event protocol as Codex:
8
10
  * turn:started, item:started, item:completed, item:delta,
@@ -11,18 +13,81 @@
11
13
  * Frontend needs zero changes.
12
14
  */
13
15
 
14
- import { query } from '@anthropic-ai/claude-agent-sdk';
15
- import type { SDKMessage, Options, ModelInfo as SdkModelInfo, Query } from '@anthropic-ai/claude-agent-sdk';
16
- import type { ModelInfo, TokenUsage } from '@agentpane/shared';
16
+ import { query, forkSession as sdkForkSession } from '@anthropic-ai/claude-agent-sdk';
17
+ import type { SDKMessage, SDKUserMessage, Options, ModelInfo as SdkModelInfo, Query, SlashCommand as SdkSlashCommand } from '@anthropic-ai/claude-agent-sdk';
18
+ import type { ModelInfo, TokenUsage, ProviderSlashCommand, Attachment, ContextUsage } from '@agentpane/shared';
17
19
  import { v4 as uuidv4 } from 'uuid';
20
+ import { readFileSync } from 'fs';
21
+ import { tmpdir } from 'os';
22
+ import { join } from 'path';
18
23
  import type Database from 'better-sqlite3';
19
24
  import { getDb } from '../db/index.ts';
20
25
  import { broadcast } from '../ws/index.ts';
21
- import { mapAssistantContent, mapPartialDelta, mapUsage, type ItemDescriptor } from './claude-message-mapper.ts';
26
+ import { mapAssistantContent, mapPartialDelta, mapUsage, applyToolResult, type ItemDescriptor, type ToolResultLike } from './claude-message-mapper.ts';
22
27
  import { EFFORT_LABELS, type ClaudeSessionEntry } from './claude-types.ts';
23
28
  import type { Session, Turn, Item, ItemStatus } from '../db/schema.ts';
24
29
  import type { RuntimeProvider } from '../providers/runtime-provider.ts';
25
30
 
31
+ // ---------------------------------------------------------------------------
32
+ // PromptQueue — async iterable that feeds user messages to the CLI
33
+ // ---------------------------------------------------------------------------
34
+
35
+ /** Async iterable queue of `SDKUserMessage` objects.
36
+ *
37
+ * The `query()` function receives this as its `prompt` argument. Each
38
+ * `push()` enqueues a message and resolves the pending `next()` call from
39
+ * the SDK's internal consumer. `close()` signals end-of-iteration, which
40
+ * causes the CLI process to exit gracefully.
41
+ *
42
+ * This is what keeps the CLI alive between turns: the SDK's for-await loop
43
+ * blocks on `next()` waiting for the next user message, so the process
44
+ * stays resident and background tasks (Monitor) keep running. */
45
+ class PromptQueue {
46
+ private buffer: SDKUserMessage[] = [];
47
+ private resolver: ((value: IteratorResult<SDKUserMessage>) => void) | null = null;
48
+ private done = false;
49
+
50
+ push(message: SDKUserMessage): void {
51
+ if (this.done) return;
52
+ if (this.resolver) {
53
+ const r = this.resolver;
54
+ this.resolver = null;
55
+ r({ value: message, done: false });
56
+ } else {
57
+ this.buffer.push(message);
58
+ }
59
+ }
60
+
61
+ close(): void {
62
+ this.done = true;
63
+ if (this.resolver) {
64
+ const r = this.resolver;
65
+ this.resolver = null;
66
+ r({ value: undefined, done: true });
67
+ }
68
+ }
69
+
70
+ [Symbol.asyncIterator](): AsyncIterator<SDKUserMessage> {
71
+ return {
72
+ next: (): Promise<IteratorResult<SDKUserMessage>> => {
73
+ if (this.buffer.length > 0) {
74
+ return Promise.resolve({ value: this.buffer.shift()!, done: false });
75
+ }
76
+ if (this.done) {
77
+ return Promise.resolve({ value: undefined, done: true });
78
+ }
79
+ return new Promise((resolve) => {
80
+ this.resolver = resolve;
81
+ });
82
+ },
83
+ };
84
+ }
85
+ }
86
+
87
+ // ---------------------------------------------------------------------------
88
+ // Per-session state
89
+ // ---------------------------------------------------------------------------
90
+
26
91
  /** Per-session Claude query instances. */
27
92
  const sessionMap = new Map<string, ClaudeSessionEntry>();
28
93
 
@@ -32,8 +97,26 @@ const titleGenerating = new Set<string>();
32
97
  /** Cached model list from the SDK. */
33
98
  let cachedModels: ModelInfo[] | null = null;
34
99
 
100
+ /** Cached provider-native slash commands from the SDK (e.g. /compact, /clear). */
101
+ let cachedCommands: ProviderSlashCommand[] | null = null;
102
+
103
+ /** Accumulated thinking_delta text per assistant message UUID → content block index → text.
104
+ * Stream events arrive before the final assistant message; the thinking block in the
105
+ * assistant message is signed-empty, so we accumulate the real text from thinking_delta
106
+ * events and feed it to mapAssistantContent. */
107
+ const thinkingAccumulator = new Map<string, Map<number, string>>();
108
+
109
+ /** Anthropic message ID captured from `message_start` stream events, keyed by turnId.
110
+ * Each SDKPartialAssistantMessage has a unique uuid (different per event), but the
111
+ * Anthropic message ID from `message_start` is stable across all stream events for one
112
+ * assistant message. We use it as the itemId for text delta broadcasts so the frontend
113
+ * accumulates into one item instead of creating N separate items. */
114
+ const streamingMessageIds = new Map<string, string>();
115
+
116
+
117
+
35
118
  // ---------------------------------------------------------------------------
36
- // DB helpers (same pattern as codex-provider)
119
+ // DB helpers
37
120
  // ---------------------------------------------------------------------------
38
121
 
39
122
  /** Update the running state and status of a session. */
@@ -57,20 +140,28 @@ function setSessionStatus(
57
140
  }
58
141
 
59
142
  // ---------------------------------------------------------------------------
60
- // startTurn
143
+ // ensureSessionStarted — create streaming-input query for a session
61
144
  // ---------------------------------------------------------------------------
62
145
 
63
- /** Start a Claude turn. Fire-and-forget — streams events via WS. */
64
- async function startTurn(session: Session, turn: Turn): Promise<void> {
146
+ /** Create (or reuse) a streaming-input query for a session.
147
+ *
148
+ * Builds options from the session's DB row (cwd, model, permissionMode, resume,
149
+ * etc.), creates a `PromptQueue`, calls `query({ prompt: queue, options })`,
150
+ * and starts a background message loop that dispatches SDK messages to
151
+ * `handleMessage` using the current `entry.turnId`.
152
+ *
153
+ * The CLI process stays alive as long as the PromptQueue is not closed —
154
+ * this is what allows Monitor background tasks to survive between turns. */
155
+ async function ensureSessionStarted(
156
+ session: Session,
157
+ mapKey?: string,
158
+ resumeSessionId?: string,
159
+ ): Promise<ClaudeSessionEntry> {
65
160
  const db = getDb();
66
- const now = new Date().toISOString();
161
+ const key = mapKey ?? session.id;
162
+ const resumeId = resumeSessionId ?? session.external_thread_id ?? undefined;
67
163
 
68
- // Update turn + session status
69
- db.prepare('UPDATE turns SET status = ?, updated_at = ? WHERE id = ?').run('running', now, turn.id);
70
- setSessionStatus(db, session.id, 1, 'running', now);
71
- broadcast('turn:started', { sessionId: session.id, turnId: turn.id });
72
-
73
- // Build options
164
+ // Build options — same as the old per-turn options, but applied once per session.
74
165
  const options: Options = {
75
166
  cwd: session.cwd ?? process.cwd(),
76
167
  model: session.model ?? undefined,
@@ -79,49 +170,204 @@ async function startTurn(session: Session, turn: Turn): Promise<void> {
79
170
  includePartialMessages: true,
80
171
  ...(process.env.CLAUDE_BIN ? { pathToClaudeCodeExecutable: process.env.CLAUDE_BIN } : {}),
81
172
  ...(session.model_reasoning_effort ? { effort: session.model_reasoning_effort as Options['effort'] } : {}),
82
- ...(session.external_thread_id ? { resume: session.external_thread_id } : {}),
173
+ ...(resumeId ? { resume: resumeId } : {}),
83
174
  };
84
175
 
85
- let q;
86
- try {
87
- q = query({ prompt: turn.user_prompt, options });
88
- } catch (err) {
89
- const errMsg = `Failed to start Claude query: ${(err as Error).message}`;
90
- const now2 = new Date().toISOString();
91
- db.prepare('UPDATE turns SET status = ?, error = ?, updated_at = ? WHERE id = ?').run('failed', errMsg, now2, turn.id);
92
- setSessionStatus(db, session.id, 0, 'failed', now2);
93
- broadcast('turn:failed', { sessionId: session.id, turnId: turn.id, error: errMsg });
94
- return;
95
- }
176
+ const promptQueue = new PromptQueue();
177
+ const q: Query = query({ prompt: promptQueue, options });
96
178
 
97
- const entry: ClaudeSessionEntry = { query: q, sessionId: null, turnId: turn.id };
98
- sessionMap.set(session.id, entry);
179
+ const entry: ClaudeSessionEntry = {
180
+ query: q,
181
+ sessionId: null,
182
+ turnId: null,
183
+ cwd: session.cwd ?? process.cwd(),
184
+ promptQueue,
185
+ messageLoop: null,
186
+ };
187
+ sessionMap.set(key, entry);
99
188
 
100
- // Iterate the async generator
101
- try {
102
- for await (const message of q) {
103
- await handleMessage(message, session.id, turn.id, entry, db);
189
+ // Background message loop — runs for the lifetime of the CLI process.
190
+ // Dispatches each SDK message to handleMessage using the current entry.turnId.
191
+ entry.messageLoop = (async () => {
192
+ try {
193
+ for await (const message of q) {
194
+ const currentTurnId = entry.turnId;
195
+ if (!currentTurnId) continue; // no active turn — skip (shouldn't happen normally)
196
+ await handleMessage(message, session.id, currentTurnId, entry, db);
197
+ }
198
+
199
+ // Generator ended (PromptQueue was closed via clearThread).
200
+ // Complete the current turn if it's still running.
201
+ if (entry.turnId) {
202
+ const currentTurn = db.prepare('SELECT status, token_usage FROM turns WHERE id = ?').get(entry.turnId) as
203
+ | { status: string; token_usage: string | null }
204
+ | undefined;
205
+ if (currentTurn && currentTurn.status === 'running') {
206
+ const usage = currentTurn.token_usage ? (JSON.parse(currentTurn.token_usage) as TokenUsage) : null;
207
+ completeTurn(db, session.id, entry.turnId, usage);
208
+ }
209
+ syncThreadTitle(session.id);
210
+ }
211
+ } catch (err) {
212
+ const errMsg = (err as Error).message;
213
+ console.error(`[claude] message loop error for session ${session.id}:`, errMsg);
214
+ if (entry.turnId) {
215
+ const now = new Date().toISOString();
216
+ // Mark ALL in_progress items for this session as failed — the CLI died so
217
+ // no background task (Monitor/Subagent) can still be running, even ones
218
+ // started in earlier completed turns.
219
+ const staleItems = db.prepare(
220
+ "UPDATE items SET status = 'failed', updated_at = ? WHERE status = 'in_progress' AND turn_id IN (SELECT id FROM turns WHERE session_id = ?)",
221
+ ).run(now, session.id) as { changes: number };
222
+
223
+ db.prepare('UPDATE turns SET status = ?, error = ?, updated_at = ? WHERE id = ?').run('failed', errMsg, now, entry.turnId);
224
+ setSessionStatus(db, session.id, 0, 'failed', now);
225
+ broadcast('turn:failed', { sessionId: session.id, turnId: entry.turnId, error: errMsg });
226
+
227
+ // Broadcast item:completed for each stale item so the frontend updates.
228
+ if (staleItems.changes > 0) {
229
+ const staleRows = db.prepare(
230
+ "SELECT id, turn_id, external_item_id, type, data, created_at FROM items WHERE turn_id IN (SELECT id FROM turns WHERE session_id = ?) AND status = 'failed' AND updated_at = ?",
231
+ ).all(session.id, now) as { id: string; turn_id: string; external_item_id: string; type: string; data: string | null; created_at: string }[];
232
+ for (const row of staleRows) {
233
+ broadcast('item:completed', {
234
+ sessionId: session.id,
235
+ turnId: row.turn_id,
236
+ item: {
237
+ id: row.id,
238
+ turn_id: row.turn_id,
239
+ external_item_id: row.external_item_id,
240
+ type: row.type,
241
+ data: row.data ? JSON.parse(row.data) : {},
242
+ status: 'failed',
243
+ created_at: row.created_at,
244
+ updated_at: now,
245
+ },
246
+ });
247
+ }
248
+ }
249
+ }
250
+ } finally {
251
+ // Clean up streaming state
252
+ if (entry.turnId) {
253
+ streamingMessageIds.delete(entry.turnId);
254
+ }
255
+ sessionMap.delete(key);
104
256
  }
257
+ })();
258
+
259
+ return entry;
260
+ }
261
+
262
+ // ---------------------------------------------------------------------------
263
+ // startTurn
264
+ // ---------------------------------------------------------------------------
265
+
266
+ /** Start a Claude turn using the streaming-input query.
267
+ *
268
+ * 1. Ensure the session's streaming query is running (create if needed).
269
+ * 2. Complete the previous turn if it's still running.
270
+ * 3. Set the new turn as active.
271
+ * 4. Push the user message to the PromptQueue — the SDK picks it up and
272
+ * starts generating. No new `query()` call is made. */
273
+ async function startTurn(session: Session, turn: Turn): Promise<void> {
274
+ const db = getDb();
275
+ const now = new Date().toISOString();
105
276
 
106
- // If turn is still running after generator ends, mark as completed
107
- const currentTurn = db.prepare('SELECT status FROM turns WHERE id = ?').get(turn.id) as { status: string } | undefined;
108
- if (currentTurn && currentTurn.status === 'running') {
109
- completeTurn(db, session.id, turn.id, null);
277
+ // Get or create the streaming-input session entry.
278
+ // Thread turns use a composite key so they get their own forked Claude session.
279
+ const mapKey = turn.thread_id ? `${session.id}:thread:${turn.thread_id}` : session.id;
280
+ let entry = sessionMap.get(mapKey);
281
+ if (!entry) {
282
+ try {
283
+ if (turn.thread_id) {
284
+ // Thread turn — resume from the thread's forked session ID (stored in discussion_threads)
285
+ const thread = db
286
+ .prepare('SELECT external_thread_id FROM discussion_threads WHERE id = ?')
287
+ .get(turn.thread_id) as { external_thread_id: string | null } | undefined;
288
+ const resumeId = thread?.external_thread_id ?? session.external_thread_id ?? undefined;
289
+ entry = await ensureSessionStarted(session, mapKey, resumeId);
290
+ } else {
291
+ entry = await ensureSessionStarted(session);
292
+ }
293
+ } catch (err) {
294
+ const errMsg = `Failed to start Claude query: ${(err as Error).message}`;
295
+ const now2 = new Date().toISOString();
296
+ db.prepare('UPDATE turns SET status = ?, error = ?, updated_at = ? WHERE id = ?').run('failed', errMsg, now2, turn.id);
297
+ setSessionStatus(db, session.id, 0, 'failed', now2);
298
+ broadcast('turn:failed', { sessionId: session.id, turnId: turn.id, error: errMsg });
299
+ return;
110
300
  }
111
- } catch (err) {
112
- const errMsg = (err as Error).message;
113
- const now2 = new Date().toISOString();
114
- db.prepare('UPDATE turns SET status = ?, error = ?, updated_at = ? WHERE id = ?').run('failed', errMsg, now2, turn.id);
115
- setSessionStatus(db, session.id, 0, 'failed', now2);
116
- broadcast('turn:failed', { sessionId: session.id, turnId: turn.id, error: errMsg });
117
- } finally {
118
- // Remove from session map if turn is done (not just idle)
119
- if (entry.turnId === null) {
120
- sessionMap.delete(session.id);
301
+ }
302
+
303
+ // Complete previous turn if still running (e.g. Monitor was active).
304
+ // All messages between user prompts belong to one AgentPane turn —
305
+ // Monitor-triggered internal turns do NOT create separate AgentPane turns.
306
+ if (entry.turnId) {
307
+ const prevTurn = db.prepare('SELECT status, token_usage FROM turns WHERE id = ?').get(entry.turnId) as
308
+ | { status: string; token_usage: string | null }
309
+ | undefined;
310
+ if (prevTurn && prevTurn.status === 'running') {
311
+ const usage = prevTurn.token_usage ? (JSON.parse(prevTurn.token_usage) as TokenUsage) : null;
312
+ completeTurn(db, session.id, entry.turnId, usage);
313
+ }
314
+ syncThreadTitle(session.id);
315
+ streamingMessageIds.delete(entry.turnId);
316
+ }
317
+ entry.turnId = turn.id;
318
+ db.prepare('UPDATE turns SET status = ?, updated_at = ? WHERE id = ?').run('running', now, turn.id);
319
+ setSessionStatus(db, session.id, 1, 'running', now);
320
+ broadcast('turn:started', { sessionId: session.id, turnId: turn.id });
321
+
322
+ // Push the user message to the PromptQueue — the SDK picks it up and starts generating.
323
+ // No new query() call — the CLI process is already alive.
324
+ // Build content blocks if attachments exist (images, PDFs, text files).
325
+ // turn.attachments is stored as a JSON string in the DB — parse it.
326
+ let content: string | SDKUserMessage['message']['content'];
327
+ const rawAttachments = turn.attachments as unknown as string | null;
328
+ let attachments: Attachment[] | null = null;
329
+ if (rawAttachments) {
330
+ try {
331
+ attachments = JSON.parse(rawAttachments) as Attachment[];
332
+ } catch {
333
+ attachments = null;
121
334
  }
122
335
  }
336
+ if (attachments && attachments.length > 0) {
337
+ const blocks: NonNullable<SDKUserMessage['message']['content']> = [{ type: 'text', text: turn.user_prompt }];
338
+ for (const att of attachments) {
339
+ if (att.media_type.startsWith('image/')) {
340
+ blocks.push({
341
+ type: 'image',
342
+ source: { type: 'base64', media_type: att.media_type as 'image/jpeg' | 'image/png' | 'image/gif' | 'image/webp', data: att.data },
343
+ });
344
+ } else if (att.media_type === 'application/pdf') {
345
+ blocks.push({
346
+ type: 'document',
347
+ source: { type: 'base64', media_type: att.media_type as 'application/pdf', data: att.data },
348
+ });
349
+ } else {
350
+ // Text files: inline as additional text blocks
351
+ const decoded = Buffer.from(att.data, 'base64').toString('utf-8');
352
+ blocks.push({ type: 'text', text: `\n--- ${att.name} ---\n${decoded}\n--- end ---` });
353
+ }
354
+ }
355
+ content = blocks;
356
+ } else {
357
+ content = turn.user_prompt; // plain string (backward compatible)
358
+ }
359
+
360
+ entry.promptQueue.push({
361
+ type: 'user',
362
+ message: { role: 'user', content },
363
+ parent_tool_use_id: null,
364
+ } as SDKUserMessage);
123
365
  }
124
366
 
367
+ // ---------------------------------------------------------------------------
368
+ // handleMessage — dispatch SDK messages to the correct handler
369
+ // ---------------------------------------------------------------------------
370
+
125
371
  /** Handle a single SDK message — dispatch to the correct handler. */
126
372
  async function handleMessage(
127
373
  message: SDKMessage,
@@ -132,13 +378,28 @@ async function handleMessage(
132
378
  ): Promise<void> {
133
379
  switch (message.type) {
134
380
  case 'system': {
135
- if ('subtype' in message && message.subtype === 'init') {
136
- // Save Claude session ID for resume
137
- entry.sessionId = message.session_id;
138
- const now = new Date().toISOString();
139
- db.prepare('UPDATE sessions SET external_thread_id = ?, updated_at = ? WHERE id = ?').run(
140
- message.session_id, now, sessionId,
141
- );
381
+ if ('subtype' in message) {
382
+ const sub = (message as { subtype: string }).subtype;
383
+ if (sub === 'init') {
384
+ // Save Claude session ID for resume (idempotent — Monitor triggers multiple inits)
385
+ entry.sessionId = (message as { session_id: string }).session_id;
386
+ const now = new Date().toISOString();
387
+ db.prepare('UPDATE sessions SET external_thread_id = ?, updated_at = ? WHERE id = ?').run(
388
+ (message as { session_id: string }).session_id, now, sessionId,
389
+ );
390
+ } else if (sub === 'compact_boundary') {
391
+ handleCompactBoundary(message as SDKMessage & { type: 'system'; subtype: 'compact_boundary' }, sessionId, turnId, db);
392
+ } else if (sub === 'task_started') {
393
+ handleTaskStarted(message as SDKMessage & { type: 'system'; subtype: 'task_started' }, sessionId, turnId, entry, db);
394
+ } else if (sub === 'task_progress') {
395
+ handleTaskProgress(message as SDKMessage & { type: 'system'; subtype: 'task_progress' }, sessionId, turnId, entry, db);
396
+ } else if (sub === 'task_updated') {
397
+ handleTaskUpdated(message as SDKMessage & { type: 'system'; subtype: 'task_updated' }, sessionId, turnId, db);
398
+ } else if (sub === 'task_notification') {
399
+ handleTaskNotification(message as SDKMessage & { type: 'system'; subtype: 'task_notification' }, sessionId, turnId, entry, db);
400
+ } else if (sub === 'background_tasks_changed') {
401
+ handleBackgroundTasksChanged(message as SDKMessage & { type: 'system'; subtype: 'background_tasks_changed' }, sessionId);
402
+ }
142
403
  }
143
404
  break;
144
405
  }
@@ -148,6 +409,11 @@ async function handleMessage(
148
409
  break;
149
410
  }
150
411
 
412
+ case 'user': {
413
+ handleUserMessage(message, sessionId, turnId, db);
414
+ break;
415
+ }
416
+
151
417
  case 'stream_event': {
152
418
  handleStreamEvent(message, sessionId, turnId);
153
419
  break;
@@ -158,85 +424,530 @@ async function handleMessage(
158
424
  break;
159
425
  }
160
426
 
161
- // Skip user messages — tool outputs not captured in initial implementation.
162
427
  default:
163
428
  break;
164
429
  }
165
430
  }
166
431
 
167
- /** Handle SDKAssistantMessage — map content blocks to items. */
432
+ // ---------------------------------------------------------------------------
433
+ // Item / message handlers (unchanged from one-shot mode)
434
+ // ---------------------------------------------------------------------------
435
+
436
+ /** Item types produced from a tool_use block — they start `in_progress` and only
437
+ * complete when the matching tool_result arrives (handled in handleUserMessage). */
438
+ const TOOL_CALL_TYPES = new Set(['command_execution', 'file_change', 'mcp_tool_call']);
439
+
440
+ /** Handle SDKAssistantMessage — map content blocks to items.
441
+ * Text/thinking blocks are complete on arrival; tool_use blocks are in_progress
442
+ * until their tool_result is delivered by a later SDKUserMessage. */
168
443
  function handleAssistantMessage(
169
444
  message: SDKMessage & { type: 'assistant' },
170
445
  sessionId: string,
171
446
  turnId: string,
172
447
  db: Database.Database,
173
448
  ): void {
174
- const content = message.message.content;
175
449
  const messageId = message.uuid;
176
- const descriptors = mapAssistantContent(content, messageId);
450
+ const parentToolUseId = (message as { parent_tool_use_id?: string | null }).parent_tool_use_id ?? null;
451
+
452
+ // Subagent assistant message — don't create items in the parent transcript.
453
+ // Subagent progress is shown via background_task items (task_started/progress/notification).
454
+ // t3code approach: early return, only update external_turn_id for session forking.
455
+ if (parentToolUseId) {
456
+ const thinkingTexts = thinkingAccumulator.get(messageId);
457
+ if (thinkingTexts) thinkingAccumulator.delete(messageId);
458
+ const nowExt = new Date().toISOString();
459
+ db.prepare('UPDATE turns SET external_turn_id = ?, updated_at = ? WHERE id = ?').run(messageId, nowExt, turnId);
460
+ return;
461
+ }
177
462
 
463
+ // Main agent — normal handling
464
+ const content = message.message.content;
465
+ const anthropicMsgId = message.message?.id ?? messageId;
466
+ const thinkingTexts = thinkingAccumulator.get(messageId);
467
+ const descriptors = mapAssistantContent(content, messageId, thinkingTexts);
468
+ // Override externalId for agent_message items to match the streaming itemId
469
+ // (Anthropic message ID captured from message_start event, not the per-event SDK uuid).
178
470
  for (const desc of descriptors) {
179
- upsertItem(db, desc, sessionId, turnId, 'completed', 'item:completed');
471
+ if (desc.type === 'agent_message') desc.externalId = anthropicMsgId;
472
+ }
473
+ if (thinkingTexts) thinkingAccumulator.delete(messageId);
474
+ streamingMessageIds.delete(turnId);
180
475
 
181
- // Update turn-level fields
182
- const now = new Date().toISOString();
183
- if (desc.type === 'agent_message') {
184
- const text = (desc.data as { text: string }).text;
185
- db.prepare('UPDATE turns SET agent_response = ?, updated_at = ? WHERE id = ?').run(text, now, turnId);
186
- }
187
- if (desc.type === 'reasoning') {
188
- const text = (desc.data as { text: string }).text;
189
- if (text) {
190
- db.prepare('UPDATE turns SET thoughts = ?, updated_at = ? WHERE id = ?').run(text, now, turnId);
476
+ for (const desc of descriptors) {
477
+ const isToolCall = TOOL_CALL_TYPES.has(desc.type);
478
+ const status: ItemStatus = isToolCall ? 'in_progress' : 'completed';
479
+ const broadcastEvent = isToolCall ? 'item:started' : 'item:completed';
480
+ upsertItem(db, desc, sessionId, turnId, status, broadcastEvent);
481
+
482
+ // Update turn-level fields only for completed content blocks from the main agent
483
+ if (!isToolCall) {
484
+ const now = new Date().toISOString();
485
+ if (desc.type === 'agent_message') {
486
+ const text = (desc.data as { text: string }).text;
487
+ db.prepare('UPDATE turns SET agent_response = ?, updated_at = ? WHERE id = ?').run(text, now, turnId);
488
+ }
489
+ if (desc.type === 'reasoning') {
490
+ const text = (desc.data as { text: string }).text;
491
+ if (text) {
492
+ db.prepare('UPDATE turns SET thoughts = ?, updated_at = ? WHERE id = ?').run(text, now, turnId);
493
+ }
191
494
  }
192
495
  }
193
496
  }
497
+
498
+ // Store the SDK assistant message UUID as external_turn_id — used as upToMessageId
499
+ // by forkSession() when branching from this turn.
500
+ const nowExt = new Date().toISOString();
501
+ db.prepare('UPDATE turns SET external_turn_id = ?, updated_at = ? WHERE id = ?').run(messageId, nowExt, turnId);
194
502
  }
195
503
 
196
- /** Handle SDKPartialAssistantMessage — broadcast streaming deltas. */
504
+ /** Handle SDKUserMessage — tool_result blocks carry the output of a prior tool_use.
505
+ * Match each tool_result to the existing item by external_item_id (tool_use_id),
506
+ * apply the result to the item's data, and mark it completed/failed. */
507
+ function handleUserMessage(
508
+ message: SDKMessage & { type: 'user' },
509
+ sessionId: string,
510
+ turnId: string,
511
+ db: Database.Database,
512
+ ): void {
513
+ const parentToolUseId = (message as { parent_tool_use_id?: string | null }).parent_tool_use_id ?? null;
514
+
515
+ // Subagent tool results — skip entirely. The matching tool_use item was never
516
+ // created (handleAssistantMessage early-returns for subagent messages), so
517
+ // there's nothing to update.
518
+ if (parentToolUseId) return;
519
+
520
+ const content = message.message.content;
521
+ if (!Array.isArray(content)) return;
522
+
523
+ for (const block of content) {
524
+ if (typeof block !== 'object' || block === null) continue;
525
+ const b = block as { type?: string; tool_use_id?: string; is_error?: boolean; content?: unknown };
526
+ if (b.type !== 'tool_result' || !b.tool_use_id) continue;
527
+
528
+ const toolUseId = b.tool_use_id;
529
+ const isError = !!b.is_error;
530
+
531
+ // Find the item created from the matching tool_use block.
532
+ const row = db
533
+ .prepare('SELECT type, data FROM items WHERE external_item_id = ? AND turn_id = ?')
534
+ .get(toolUseId, turnId) as { type: string; data: string | null } | undefined;
535
+ if (!row) continue;
536
+
537
+ const existing = row.data ? (JSON.parse(row.data) as Record<string, unknown>) : {};
538
+ const updatedData = applyToolResult(row.type, existing, b as ToolResultLike, message.tool_use_result, isError);
539
+
540
+ upsertItem(
541
+ db,
542
+ { type: row.type, data: updatedData, externalId: toolUseId },
543
+ sessionId,
544
+ turnId,
545
+ isError ? 'failed' : 'completed',
546
+ 'item:completed',
547
+ );
548
+ }
549
+ }
197
550
  function handleStreamEvent(
198
551
  message: SDKMessage & { type: 'stream_event' },
199
552
  sessionId: string,
200
553
  turnId: string,
201
554
  ): void {
555
+ const rawEvent = message.event as {
556
+ type: string;
557
+ message?: { id: string };
558
+ content_block?: { type: string; id?: string };
559
+ };
560
+
561
+ // t3code approach: SDK already provides parent_tool_use_id on subagent stream events.
562
+ // If this stream event belongs to a subagent, drop text/thinking deltas so they
563
+ // don't leak into the main session's agent_response.
564
+ const parentToolUseId = (message as { parent_tool_use_id?: string | null }).parent_tool_use_id ?? null;
565
+
566
+ // Capture the Anthropic message ID from message_start events (stable across all stream
567
+ // events for one assistant message, unlike SDKPartialAssistantMessage.uuid which is unique per event).
568
+ // Skip for subagent streams — we don't want to overwrite the main agent's streamingMessageId.
569
+ if (rawEvent.type === 'message_start' && rawEvent.message?.id && !parentToolUseId) {
570
+ streamingMessageIds.set(turnId, rawEvent.message.id);
571
+ }
572
+
202
573
  const delta = mapPartialDelta(message.event);
203
574
  if (!delta) return;
204
575
 
205
- // Broadcast text deltas to frontend — no DB write per delta (performance).
576
+ // Drop subagent text/thinking deltas — they must not write into the parent transcript.
577
+ if (parentToolUseId) return;
578
+
579
+ // Broadcast text deltas to frontend — use the stable Anthropic message ID as itemId
580
+ // so the frontend accumulates all deltas into one item.
206
581
  if (delta.kind === 'text') {
582
+ const msgId = streamingMessageIds.get(turnId) ?? `stream-text-${turnId}`;
207
583
  broadcast('item:delta', {
208
584
  sessionId,
209
585
  turnId,
210
- itemId: message.uuid,
586
+ itemId: msgId,
211
587
  delta: delta.text,
212
588
  });
213
589
  }
214
- // thinking_delta not broadcast as item:delta — reasoning captured from complete thinking block.
590
+ // Accumulate thinking_delta text for the final assistant message (which has signed-empty thinking).
591
+ if (delta.kind === 'thinking') {
592
+ let msgMap = thinkingAccumulator.get(message.uuid);
593
+ if (!msgMap) {
594
+ msgMap = new Map();
595
+ thinkingAccumulator.set(message.uuid, msgMap);
596
+ }
597
+ const existing = msgMap.get(delta.index) ?? '';
598
+ msgMap.set(delta.index, existing + delta.text);
599
+ }
215
600
  }
216
601
 
217
- /** Handle SDKResultMessage — complete the turn. */
602
+ /** Handle SDKCompactBoundaryMessage — context was compacted.
603
+ * Insert a `context_compaction` item so the UI can render a "Context compacted" row. */
604
+ function handleCompactBoundary(
605
+ message: SDKMessage & { type: 'system'; subtype: 'compact_boundary' },
606
+ sessionId: string,
607
+ turnId: string,
608
+ db: Database.Database,
609
+ ): void {
610
+ const meta = (message.compact_metadata ?? {}) as {
611
+ trigger?: string;
612
+ pre_tokens?: number;
613
+ post_tokens?: number;
614
+ };
615
+ const externalId = `compact-${message.uuid ?? Date.now()}`;
616
+ upsertItem(
617
+ db,
618
+ {
619
+ type: 'context_compaction',
620
+ data: {
621
+ trigger: meta.trigger ?? 'auto',
622
+ pre_tokens: meta.pre_tokens ?? 0,
623
+ post_tokens: meta.post_tokens ?? 0,
624
+ },
625
+ externalId,
626
+ },
627
+ sessionId,
628
+ turnId,
629
+ 'completed',
630
+ 'item:completed',
631
+ );
632
+ }
633
+
634
+ /** Construct the expected Monitor output file path for a given task.
635
+ *
636
+ * Monitor (task_type=local_bash) writes stdout to a file at a predictable path:
637
+ * {tmpdir}/claude-{uid}/{encoded-cwd}/{claude-session-id}/tasks/{task_id}.output
638
+ *
639
+ * The SDK only exposes `output_file` on `task_notification` (not on `task_started`
640
+ * or `task_progress`), so we construct the path ourselves to read live output
641
+ * during execution. Returns empty string if the Claude session ID is not yet
642
+ * known (entry.sessionId is set on the first `system/init` message). */
643
+ function constructMonitorOutputPath(entry: ClaudeSessionEntry, taskId: string): string {
644
+ if (!entry.sessionId) return '';
645
+ const uid = typeof process.getuid === 'function' ? process.getuid() : 0;
646
+ const encodedCwd = entry.cwd.replace(/\//g, '-');
647
+ return join(tmpdir(), `claude-${uid}`, encodedCwd, entry.sessionId, 'tasks', `${taskId}.output`);
648
+ }
649
+
650
+ /** Handle SDKTaskStartedMessage — a background task (Monitor or subagent) started.
651
+ * Insert a `background_task` item so the UI can show the task lifecycle. */
652
+ function handleTaskStarted(
653
+ message: SDKMessage & { type: 'system'; subtype: 'task_started' },
654
+ sessionId: string,
655
+ turnId: string,
656
+ entry: ClaudeSessionEntry,
657
+ db: Database.Database,
658
+ ): void {
659
+ const m = message as unknown as {
660
+ task_id: string;
661
+ description: string;
662
+ task_type?: string;
663
+ subagent_type?: string;
664
+ prompt?: string;
665
+ skip_transcript?: boolean;
666
+ };
667
+ // Respect skip_transcript — ambient tasks should not appear in the inline transcript.
668
+ if (m.skip_transcript) return;
669
+
670
+ // Construct the Monitor output file path for local_bash tasks so we can read
671
+ // live output on task_progress (the SDK only exposes output_file on task_notification).
672
+ const isMonitor = m.task_type === 'local_bash';
673
+ const outputFile = isMonitor ? constructMonitorOutputPath(entry, m.task_id) : null;
674
+
675
+ upsertItem(
676
+ db,
677
+ {
678
+ type: 'background_task',
679
+ data: {
680
+ task_id: m.task_id,
681
+ description: m.description,
682
+ task_type: m.task_type ?? 'unknown',
683
+ subagent_type: m.subagent_type ?? null,
684
+ prompt: m.prompt ?? null,
685
+ status: 'running',
686
+ summary: null,
687
+ output_file: outputFile,
688
+ output: '',
689
+ token_count: null,
690
+ last_tool_name: null,
691
+ },
692
+ externalId: m.task_id,
693
+ },
694
+ sessionId,
695
+ turnId,
696
+ 'in_progress',
697
+ 'item:started',
698
+ );
699
+ }
700
+
701
+ /** Handle SDKTaskProgressMessage — update the background_task item with progress info. */
702
+ function handleTaskProgress(
703
+ message: SDKMessage & { type: 'system'; subtype: 'task_progress' },
704
+ sessionId: string,
705
+ turnId: string,
706
+ entry: ClaudeSessionEntry,
707
+ db: Database.Database,
708
+ ): void {
709
+ const m = message as unknown as {
710
+ task_id: string;
711
+ description: string;
712
+ subagent_type?: string;
713
+ last_tool_name?: string;
714
+ summary?: string;
715
+ usage?: { total_tokens: number; tool_uses: number; duration_ms: number };
716
+ };
717
+ const row = db
718
+ .prepare('SELECT data FROM items WHERE external_item_id = ? AND turn_id = ?')
719
+ .get(m.task_id, turnId) as { data: string | null } | undefined;
720
+ if (!row) return;
721
+
722
+ const existing = row.data ? (JSON.parse(row.data) as Record<string, unknown>) : {};
723
+
724
+ // Read live Monitor output — only for local_bash tasks (subagent outputs are
725
+ // JSONL transcripts, too large and not meant for display).
726
+ let output = (existing.output as string) ?? '';
727
+ const taskType = (existing.task_type as string) ?? 'unknown';
728
+ if (taskType === 'local_bash') {
729
+ const outputPath = (existing.output_file as string) || constructMonitorOutputPath(entry, m.task_id);
730
+ if (outputPath) {
731
+ try {
732
+ output = readFileSync(outputPath, 'utf-8');
733
+ } catch {
734
+ // File may not exist yet or may be temporarily unavailable — keep previous output
735
+ }
736
+ }
737
+ }
738
+
739
+ const updatedData = {
740
+ ...existing,
741
+ description: m.description,
742
+ subagent_type: m.subagent_type ?? existing.subagent_type ?? null,
743
+ summary: m.summary ?? existing.summary ?? null,
744
+ last_tool_name: m.last_tool_name ?? null,
745
+ token_count: m.usage?.total_tokens ?? existing.token_count ?? null,
746
+ output,
747
+ };
748
+
749
+ upsertItem(
750
+ db,
751
+ { type: 'background_task', data: updatedData, externalId: m.task_id },
752
+ sessionId,
753
+ turnId,
754
+ 'in_progress',
755
+ 'item:updated',
756
+ );
757
+ }
758
+
759
+ /** Handle SDKTaskUpdatedMessage — patch the background_task item's status. */
760
+ function handleTaskUpdated(
761
+ message: SDKMessage & { type: 'system'; subtype: 'task_updated' },
762
+ sessionId: string,
763
+ turnId: string,
764
+ db: Database.Database,
765
+ ): void {
766
+ const m = message as unknown as {
767
+ task_id: string;
768
+ patch: { status?: string; description?: string; error?: string; is_backgrounded?: boolean };
769
+ };
770
+ const row = db
771
+ .prepare('SELECT data FROM items WHERE external_item_id = ? AND turn_id = ?')
772
+ .get(m.task_id, turnId) as { data: string | null } | undefined;
773
+ if (!row) return;
774
+
775
+ const existing = row.data ? (JSON.parse(row.data) as Record<string, unknown>) : {};
776
+ const updatedData = {
777
+ ...existing,
778
+ status: m.patch.status ?? existing.status,
779
+ description: m.patch.description ?? existing.description,
780
+ error: m.patch.error ?? existing.error ?? null,
781
+ };
782
+
783
+ upsertItem(
784
+ db,
785
+ { type: 'background_task', data: updatedData, externalId: m.task_id },
786
+ sessionId,
787
+ turnId,
788
+ 'in_progress',
789
+ 'item:updated',
790
+ );
791
+ }
792
+
793
+ /** Handle SDKTaskNotificationMessage — the background task completed/failed/stopped. */
794
+ function handleTaskNotification(
795
+ message: SDKMessage & { type: 'system'; subtype: 'task_notification' },
796
+ sessionId: string,
797
+ turnId: string,
798
+ entry: ClaudeSessionEntry,
799
+ db: Database.Database,
800
+ ): void {
801
+ const m = message as unknown as {
802
+ task_id: string;
803
+ status: 'completed' | 'failed' | 'stopped';
804
+ summary: string;
805
+ output_file: string;
806
+ usage?: { total_tokens: number; tool_uses: number; duration_ms: number };
807
+ };
808
+ const row = db
809
+ .prepare('SELECT data FROM items WHERE external_item_id = ? AND turn_id = ?')
810
+ .get(m.task_id, turnId) as { data: string | null } | undefined;
811
+ if (!row) return;
812
+
813
+ const existing = row.data ? (JSON.parse(row.data) as Record<string, unknown>) : {};
814
+
815
+ // Determine the output file path — prefer the SDK-provided path, fall back to
816
+ // the constructed path (for cases where the SDK doesn't provide it).
817
+ const taskType = (existing.task_type as string) ?? 'unknown';
818
+ const sdkOutputFile = m.output_file || null;
819
+ const constructedPath = taskType === 'local_bash' ? constructMonitorOutputPath(entry, m.task_id) : '';
820
+ const outputPath = sdkOutputFile || constructedPath;
821
+
822
+ // Read the raw stdout output from the Monitor's output file.
823
+ let output = '';
824
+ if (outputPath) {
825
+ try {
826
+ output = readFileSync(outputPath, 'utf-8');
827
+ } catch {
828
+ // File may have been cleaned up or is inaccessible — leave output empty
829
+ }
830
+ }
831
+
832
+ const updatedData = {
833
+ ...existing,
834
+ status: m.status,
835
+ summary: m.summary,
836
+ output_file: outputPath,
837
+ output,
838
+ token_count: m.usage?.total_tokens ?? existing.token_count ?? null,
839
+ };
840
+
841
+ const itemStatus: ItemStatus = m.status === 'completed' ? 'completed' : 'failed';
842
+
843
+ upsertItem(
844
+ db,
845
+ { type: 'background_task', data: updatedData, externalId: m.task_id },
846
+ sessionId,
847
+ turnId,
848
+ itemStatus,
849
+ 'item:completed',
850
+ );
851
+ }
852
+
853
+ /** Handle SDKBackgroundTasksChangedMessage — broadcast the full live task list.
854
+ * REPLACE semantics: frontend replaces its entire background task set. */
855
+ function handleBackgroundTasksChanged(
856
+ message: SDKMessage & { type: 'system'; subtype: 'background_tasks_changed' },
857
+ sessionId: string,
858
+ ): void {
859
+ const m = message as unknown as {
860
+ tasks: Array<{ task_id: string; task_type: string; description: string }>;
861
+ };
862
+ broadcast('background_tasks', { sessionId, tasks: m.tasks ?? [] });
863
+ }
864
+
865
+ /** Handle SDKResultMessage — accumulate usage across multi-result turns (Monitor).
866
+ * Does NOT complete the turn on intermediate results — the turn completes when
867
+ * the next user message is pushed (in startTurn) or when the PromptQueue is
868
+ * closed (in the message loop's generator-end block). Only error results abort early. */
218
869
  function handleResultMessage(
219
870
  message: SDKMessage & { type: 'result' },
220
871
  sessionId: string,
221
872
  turnId: string,
222
873
  db: Database.Database,
223
874
  ): void {
875
+ // Subagent result — skip entirely. Prevents subagent token/context data from
876
+ // overwriting the main session's token_usage and contextUsage.
877
+ // SDKResultSuccess doesn't declare parent_tool_use_id in its type, but the SDK
878
+ // may set it at runtime (same pattern as handleAssistantMessage/handleUserMessage).
879
+ const parentToolUseId = (message as { parent_tool_use_id?: string | null }).parent_tool_use_id ?? null;
880
+ if (parentToolUseId) return;
881
+
224
882
  if (message.subtype === 'success') {
883
+ // Update token usage and complete the turn immediately.
884
+ // The SDK emits exactly one result/success per turn — this is the turn-complete signal.
885
+ // The CLI process stays alive (PromptQueue not closed) so Monitor background tasks survive.
886
+ // entry.turnId is NOT set to null — Monitor-triggered internal turn messages still attach
887
+ // to this turn via entry.turnId. Multiple completeTurn calls are idempotent (status check).
225
888
  const usage = mapUsage(message.usage);
889
+ const now = new Date().toISOString();
890
+ db.prepare('UPDATE turns SET token_usage = ?, updated_at = ? WHERE id = ?').run(
891
+ JSON.stringify(usage), now, turnId,
892
+ );
893
+ broadcast('turn:tokenUsage', { sessionId, turnId, usage });
894
+
895
+ // Extract context window usage from modelUsage (cumulative, includes contextWindow per model).
896
+ // Total context ≈ input_tokens + cache_read + cache_creation for this turn.
897
+ const result = message as SDKMessage & { type: 'result'; subtype: 'success' };
898
+ const modelUsage = result.modelUsage;
899
+
900
+ // Look up the main agent's model in modelUsage to get the correct contextWindow.
901
+ // modelUsage includes ALL models (main + subagents + sidechains), so taking
902
+ // Object.values()[0] may pick a subagent model with a different contextWindow.
903
+ const session = db.prepare('SELECT model FROM sessions WHERE id = ?').get(sessionId) as
904
+ | { model: string | null }
905
+ | undefined;
906
+ const sessionModel = session?.model ?? null;
907
+
908
+ let modelEntry = undefined;
909
+ if (modelUsage) {
910
+ if (sessionModel && modelUsage[sessionModel]) {
911
+ modelEntry = modelUsage[sessionModel];
912
+ } else {
913
+ // Fallback: find an entry whose canonicalModel matches, else first entry
914
+ modelEntry = Object.values(modelUsage).find(
915
+ (e) => sessionModel && e.canonicalModel === sessionModel,
916
+ ) ?? Object.values(modelUsage)[0];
917
+ }
918
+ }
919
+
920
+ if (modelEntry && modelEntry.contextWindow > 0) {
921
+ const totalTokens =
922
+ result.usage.input_tokens +
923
+ (result.usage.cache_read_input_tokens ?? 0) +
924
+ (result.usage.cache_creation_input_tokens ?? 0);
925
+
926
+ if (totalTokens > 0) {
927
+ const contextUsage: ContextUsage = {
928
+ totalTokens,
929
+ contextWindow: modelEntry.contextWindow,
930
+ percentage: Math.round((totalTokens / modelEntry.contextWindow) * 100),
931
+ };
932
+ broadcast('turn:contextUsage', { sessionId, ...contextUsage });
933
+ }
934
+ }
935
+
226
936
  completeTurn(db, sessionId, turnId, usage);
227
937
  } else {
228
- // Error result
938
+ // Error result — abort the turn immediately.
229
939
  const errorMsg = message.errors?.join('; ') ?? 'Claude turn failed';
230
940
  const now = new Date().toISOString();
231
941
  db.prepare('UPDATE turns SET status = ?, error = ?, updated_at = ? WHERE id = ?').run('failed', errorMsg, now, turnId);
232
942
  setSessionStatus(db, sessionId, 0, 'failed', now);
233
943
  broadcast('turn:failed', { sessionId, turnId, error: errorMsg });
234
944
  }
235
-
236
- // Generate title after turn completes
237
- syncThreadTitle(sessionId);
238
945
  }
239
946
 
947
+ // ---------------------------------------------------------------------------
948
+ // completeTurn / upsertItem
949
+ // ---------------------------------------------------------------------------
950
+
240
951
  /** Complete a turn — set status, broadcast turn:completed. */
241
952
  function completeTurn(db: Database.Database, sessionId: string, turnId: string, usage: TokenUsage | null): void {
242
953
  const now = new Date().toISOString();
@@ -250,6 +961,7 @@ function completeTurn(db: Database.Database, sessionId: string, turnId: string,
250
961
  broadcast('turn:completed', { sessionId, turnId, hasUnreviewed: true });
251
962
  }
252
963
  setSessionStatus(db, sessionId, 0, 'active', now, true);
964
+ syncThreadTitle(sessionId);
253
965
  }
254
966
 
255
967
  /** Insert or update an item in the DB and broadcast. */
@@ -303,9 +1015,23 @@ function upsertItem(
303
1015
  // interruptTurn
304
1016
  // ---------------------------------------------------------------------------
305
1017
 
306
- /** Cancel a running turn by session ID. Returns true if a turn was cancelled. */
1018
+ /** Cancel a running turn by session ID. Returns true if a turn was cancelled.
1019
+ *
1020
+ * Calls `query.interrupt()` to abort the current generation, but does NOT
1021
+ * close the query or the PromptQueue — the CLI process stays alive so the
1022
+ * user can send the next message without spawning a new process. */
307
1023
  async function interruptTurn(sessionId: string): Promise<boolean> {
308
- const entry = sessionMap.get(sessionId);
1024
+ // Check main entry first, then thread entries
1025
+ let entry = sessionMap.get(sessionId);
1026
+ if (!entry || !entry.turnId) {
1027
+ // Search thread entries for this session
1028
+ for (const [key, threadEntry] of sessionMap) {
1029
+ if (key.startsWith(`${sessionId}:thread:`) && threadEntry.turnId) {
1030
+ entry = threadEntry;
1031
+ break;
1032
+ }
1033
+ }
1034
+ }
309
1035
  if (!entry || !entry.turnId) return false;
310
1036
 
311
1037
  try {
@@ -317,6 +1043,7 @@ async function interruptTurn(sessionId: string): Promise<boolean> {
317
1043
  setSessionStatus(db, sessionId, 0, 'active', now);
318
1044
  broadcast('turn:cancelled', { sessionId, turnId: entry.turnId });
319
1045
 
1046
+ streamingMessageIds.delete(entry.turnId);
320
1047
  entry.turnId = null;
321
1048
  return true;
322
1049
  } catch {
@@ -328,18 +1055,53 @@ async function interruptTurn(sessionId: string): Promise<boolean> {
328
1055
  // clearThread / clearAllThreads
329
1056
  // ---------------------------------------------------------------------------
330
1057
 
331
- /** Remove a session's Claude query from the in-memory map. */
1058
+ /** Remove a session's streaming query from the in-memory map.
1059
+ *
1060
+ * Closes the PromptQueue (signals end-of-iteration → CLI exits gracefully)
1061
+ * and closes the Query object (forceful cleanup). Used after rewind, branch,
1062
+ * provider change, or restore — the next startTurn will create a fresh query. */
332
1063
  function clearThread(sessionId: string): void {
1064
+ // Main entry
333
1065
  const entry = sessionMap.get(sessionId);
334
1066
  if (entry) {
1067
+ entry.promptQueue.close();
335
1068
  entry.query.close();
1069
+ if (entry.turnId) {
1070
+ streamingMessageIds.delete(entry.turnId);
1071
+ }
336
1072
  sessionMap.delete(sessionId);
337
1073
  }
1074
+ // All thread entries for this session
1075
+ for (const [key, threadEntry] of sessionMap) {
1076
+ if (key.startsWith(`${sessionId}:thread:`)) {
1077
+ threadEntry.promptQueue.close();
1078
+ threadEntry.query.close();
1079
+ if (threadEntry.turnId) {
1080
+ streamingMessageIds.delete(threadEntry.turnId);
1081
+ }
1082
+ sessionMap.delete(key);
1083
+ }
1084
+ }
1085
+ }
1086
+
1087
+ /** Remove a single thread's branched session entry. Used when a thread is deleted. */
1088
+ function clearThreadBranch(sessionId: string, threadId: string): void {
1089
+ const key = `${sessionId}:thread:${threadId}`;
1090
+ const entry = sessionMap.get(key);
1091
+ if (entry) {
1092
+ entry.promptQueue.close();
1093
+ entry.query.close();
1094
+ if (entry.turnId) {
1095
+ streamingMessageIds.delete(entry.turnId);
1096
+ }
1097
+ sessionMap.delete(key);
1098
+ }
338
1099
  }
339
1100
 
340
1101
  /** Clear all session entries. Used in test cleanup. */
341
1102
  export function clearAllThreads(): void {
342
1103
  for (const [, entry] of sessionMap) {
1104
+ entry.promptQueue.close();
343
1105
  entry.query.close();
344
1106
  }
345
1107
  sessionMap.clear();
@@ -347,7 +1109,32 @@ export function clearAllThreads(): void {
347
1109
  }
348
1110
 
349
1111
  // ---------------------------------------------------------------------------
350
- // getModels
1112
+ // setModel — change model mid-session without restarting the CLI
1113
+ // ---------------------------------------------------------------------------
1114
+
1115
+ /** Change the model for a running session. Only works in streaming-input mode.
1116
+ * If the session hasn't started yet (no entry in sessionMap), the new model
1117
+ * will be picked up when ensureSessionStarted creates the query. */
1118
+ async function setModel(sessionId: string, model: string): Promise<void> {
1119
+ const entry = sessionMap.get(sessionId);
1120
+ if (!entry) return; // session not started — model will be read from DB on next startTurn
1121
+ await entry.query.setModel(model);
1122
+ }
1123
+
1124
+ // ---------------------------------------------------------------------------
1125
+ // stopTask — kill a background task (Monitor/subagent) by task ID
1126
+ // ---------------------------------------------------------------------------
1127
+
1128
+ /** Stop a running background task by task ID. Emits a task_notification with
1129
+ * status 'stopped'. Enables a future "Kill Monitor" button in the UI. */
1130
+ async function stopTask(sessionId: string, taskId: string): Promise<void> {
1131
+ const entry = sessionMap.get(sessionId);
1132
+ if (!entry) return;
1133
+ await entry.query.stopTask(taskId);
1134
+ }
1135
+
1136
+ // ---------------------------------------------------------------------------
1137
+ // getModels / getCommands
351
1138
  // ---------------------------------------------------------------------------
352
1139
 
353
1140
  /** Fetch available models from the Claude SDK and map to AgentPane ModelInfo[]. */
@@ -367,6 +1154,9 @@ async function getModels(): Promise<ModelInfo[]> {
367
1154
  const initResult = await q.initializationResult();
368
1155
  q.close();
369
1156
 
1157
+ // Cache provider-native slash commands alongside models (single init call).
1158
+ cachedCommands = mapSdkCommands(initResult.commands ?? []);
1159
+
370
1160
  cachedModels = (initResult.models ?? []).map((m: SdkModelInfo) => ({
371
1161
  slug: m.value,
372
1162
  displayName: m.displayName,
@@ -384,6 +1174,24 @@ async function getModels(): Promise<ModelInfo[]> {
384
1174
  }
385
1175
  }
386
1176
 
1177
+ /** Fetch provider-native slash commands (cached from the models init call). */
1178
+ async function getCommands(): Promise<ProviderSlashCommand[]> {
1179
+ if (cachedCommands) return cachedCommands;
1180
+ // Trigger an init by fetching models (which caches commands as a side effect).
1181
+ await getModels();
1182
+ return cachedCommands ?? [];
1183
+ }
1184
+
1185
+ /** Map SDK SlashCommand[] to the shared ProviderSlashCommand shape. */
1186
+ function mapSdkCommands(commands: SdkSlashCommand[]): ProviderSlashCommand[] {
1187
+ return commands.map((c) => ({
1188
+ name: c.name,
1189
+ description: c.description,
1190
+ ...(c.argumentHint ? { argumentHint: c.argumentHint } : {}),
1191
+ ...(c.aliases?.length ? { aliases: c.aliases } : {}),
1192
+ }));
1193
+ }
1194
+
387
1195
  // ---------------------------------------------------------------------------
388
1196
  // shutdown
389
1197
  // ---------------------------------------------------------------------------
@@ -467,6 +1275,27 @@ async function generateTitleAsync(
467
1275
  }
468
1276
  }
469
1277
 
1278
+ // ---------------------------------------------------------------------------
1279
+ // forkSession
1280
+ // ---------------------------------------------------------------------------
1281
+
1282
+ /** Fork the Claude session at a branch point using the SDK's forkSession().
1283
+ * Copies the transcript up to the target turn's last assistant message,
1284
+ * returning a new Claude session ID that can be resumed with full context. */
1285
+ async function forkClaudeSession(session: Session, targetTurn: Turn): Promise<string | null> {
1286
+ if (!session.external_thread_id) return null;
1287
+ try {
1288
+ const result = await sdkForkSession(session.external_thread_id, {
1289
+ dir: session.cwd ?? process.cwd(),
1290
+ ...(targetTurn.external_turn_id ? { upToMessageId: targetTurn.external_turn_id } : {}),
1291
+ });
1292
+ return result.sessionId;
1293
+ } catch (err) {
1294
+ console.error('[claude] forkSession failed:', err);
1295
+ return null;
1296
+ }
1297
+ }
1298
+
470
1299
  // ---------------------------------------------------------------------------
471
1300
  // Provider export
472
1301
  // ---------------------------------------------------------------------------
@@ -476,7 +1305,12 @@ export const claudeProvider: RuntimeProvider = {
476
1305
  startTurn,
477
1306
  interruptTurn,
478
1307
  clearThread,
1308
+ clearThreadBranch,
479
1309
  clearAllThreads,
480
1310
  getModels,
1311
+ getCommands,
1312
+ forkSession: forkClaudeSession,
1313
+ setModel,
1314
+ stopTask,
481
1315
  shutdown,
482
1316
  };