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.
- package/agent-pane/backend/data.db-shm +0 -0
- package/agent-pane/backend/data.db-wal +0 -0
- package/agent-pane/backend/dist/index.js +13399 -10390
- package/agent-pane/backend/package-lock.json +162 -92
- package/agent-pane/backend/scripts/backfill-from-jsonl.ts +206 -0
- package/agent-pane/backend/scripts/import-session-from-jsonl.ts +264 -0
- package/agent-pane/backend/src/claude/claude-message-mapper.test.ts +116 -8
- package/agent-pane/backend/src/claude/claude-message-mapper.ts +114 -9
- package/agent-pane/backend/src/claude/claude-provider.ts +918 -84
- package/agent-pane/backend/src/claude/claude-types.ts +16 -4
- package/agent-pane/backend/src/codex/app-server-client.ts +22 -0
- package/agent-pane/backend/src/db/index.ts +102 -0
- package/agent-pane/backend/src/db/schema.ts +4 -0
- package/agent-pane/backend/src/index.ts +3 -1
- package/agent-pane/backend/src/integration/helpers/test-app.ts +2 -0
- package/agent-pane/backend/src/integration/session-operations.test.ts +18 -11
- package/agent-pane/backend/src/integration/threads.test.ts +309 -0
- package/agent-pane/backend/src/providers/runtime-provider.ts +19 -1
- package/agent-pane/backend/src/routes/providers.ts +11 -0
- package/agent-pane/backend/src/routes/sessions.test.ts +143 -9
- package/agent-pane/backend/src/routes/sessions.ts +133 -28
- package/agent-pane/backend/src/routes/threads.ts +308 -0
- package/agent-pane/backend/src/routes/turns.ts +32 -9
- package/agent-pane/frontend/dist/assets/index-BYo-lnxm.js +362 -0
- package/agent-pane/frontend/dist/assets/index-Dwwhx7YN.css +1 -0
- package/agent-pane/frontend/dist/index.html +2 -2
- package/agent-pane/frontend/package-lock.json +51 -9
- package/agent-pane/frontend/src/__tests__/ThreadsPanel.test.tsx +192 -0
- package/agent-pane/frontend/src/api/providers.ts +6 -1
- package/agent-pane/frontend/src/api/sessions.test.ts +4 -0
- package/agent-pane/frontend/src/api/sessions.ts +13 -5
- package/agent-pane/frontend/src/api/websocket.ts +65 -1
- package/agent-pane/frontend/src/components/CollapsibleToolCalls.test.tsx +4 -4
- package/agent-pane/frontend/src/components/CollapsibleToolCalls.tsx +13 -4
- package/agent-pane/frontend/src/components/CommandInputBar.test.tsx +2 -2
- package/agent-pane/frontend/src/components/CommandInputBar.tsx +292 -29
- package/agent-pane/frontend/src/components/CommandPalette.test.tsx +0 -4
- package/agent-pane/frontend/src/components/CommandPalette.tsx +1 -17
- package/agent-pane/frontend/src/components/CommentableText.tsx +291 -0
- package/agent-pane/frontend/src/components/RenderArea.tsx +9 -2
- package/agent-pane/frontend/src/components/RightPanel.test.tsx +5 -5
- package/agent-pane/frontend/src/components/RightPanel.tsx +20 -14
- package/agent-pane/frontend/src/components/SessionTabs.tsx +7 -2
- package/agent-pane/frontend/src/components/StatusBar.tsx +68 -1
- package/agent-pane/frontend/src/components/TurnContent.test.tsx +29 -18
- package/agent-pane/frontend/src/components/TurnContent.tsx +199 -28
- package/agent-pane/frontend/src/components/input/SlashPopup.tsx +24 -5
- package/agent-pane/frontend/src/components/panels/TasksPanel.test.tsx +97 -0
- package/agent-pane/frontend/src/components/panels/TasksPanel.tsx +208 -0
- package/agent-pane/frontend/src/components/panels/ThreadsPanel.tsx +350 -0
- package/agent-pane/frontend/src/components/panels/TurnsPanel.tsx +47 -25
- package/agent-pane/frontend/src/hooks/useSessionStatus.test.ts +2 -0
- package/agent-pane/frontend/src/store/sessionSlice.ts +29 -6
- package/agent-pane/frontend/src/store/store.test.ts +5 -5
- package/agent-pane/frontend/src/store/store.ts +4 -1
- package/agent-pane/frontend/src/store/threadSlice.ts +136 -0
- package/agent-pane/frontend/src/store/wsSlice.ts +72 -3
- package/agent-pane/frontend/src/styles/app.css +150 -19
- package/agent-pane/frontend/src/test/fixtures.ts +4 -0
- package/agent-pane/frontend/src/types/index.ts +5 -2
- package/agent-pane/frontend/src/utils/duration.test.ts +4 -0
- package/agent-pane/frontend/vite.config.ts +2 -2
- package/agent-pane/shared/provider-types.ts +14 -0
- package/agent-pane/shared/types.ts +60 -0
- package/package.json +1 -1
- package/agent-pane/frontend/dist/assets/index-Bg0PF2aV.css +0 -1
- package/agent-pane/frontend/dist/assets/index-BlybeX5e.js +0 -281
- package/agent-pane/frontend/src/components/panels/LogsPanel.test.tsx +0 -58
- package/agent-pane/frontend/src/components/panels/LogsPanel.tsx +0 -25
- package/agent-pane/frontend/src/components/panels/MetricsPanel.test.tsx +0 -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
|
|
5
|
-
*
|
|
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
|
|
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
|
-
//
|
|
143
|
+
// ensureSessionStarted — create streaming-input query for a session
|
|
61
144
|
// ---------------------------------------------------------------------------
|
|
62
145
|
|
|
63
|
-
/**
|
|
64
|
-
|
|
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
|
|
161
|
+
const key = mapKey ?? session.id;
|
|
162
|
+
const resumeId = resumeSessionId ?? session.external_thread_id ?? undefined;
|
|
67
163
|
|
|
68
|
-
//
|
|
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
|
-
...(
|
|
173
|
+
...(resumeId ? { resume: resumeId } : {}),
|
|
83
174
|
};
|
|
84
175
|
|
|
85
|
-
|
|
86
|
-
|
|
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 = {
|
|
98
|
-
|
|
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
|
-
//
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
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
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
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
|
-
}
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
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
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
471
|
+
if (desc.type === 'agent_message') desc.externalId = anthropicMsgId;
|
|
472
|
+
}
|
|
473
|
+
if (thinkingTexts) thinkingAccumulator.delete(messageId);
|
|
474
|
+
streamingMessageIds.delete(turnId);
|
|
180
475
|
|
|
181
|
-
|
|
182
|
-
const
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
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
|
|
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
|
-
//
|
|
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:
|
|
586
|
+
itemId: msgId,
|
|
211
587
|
delta: delta.text,
|
|
212
588
|
});
|
|
213
589
|
}
|
|
214
|
-
// thinking_delta
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
//
|
|
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
|
};
|