@sumicom/quicksave 0.5.2 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/ai/asyncQueue.d.ts +16 -0
- package/dist/ai/asyncQueue.d.ts.map +1 -0
- package/dist/ai/asyncQueue.js +39 -0
- package/dist/ai/asyncQueue.js.map +1 -0
- package/dist/ai/asyncQueue.test.d.ts +2 -0
- package/dist/ai/asyncQueue.test.d.ts.map +1 -0
- package/dist/ai/asyncQueue.test.js +56 -0
- package/dist/ai/asyncQueue.test.js.map +1 -0
- package/dist/ai/cardBuilder.d.ts +64 -0
- package/dist/ai/cardBuilder.d.ts.map +1 -0
- package/dist/ai/cardBuilder.js +503 -0
- package/dist/ai/cardBuilder.js.map +1 -0
- package/dist/ai/claudeCodeService.d.ts +47 -36
- package/dist/ai/claudeCodeService.d.ts.map +1 -1
- package/dist/ai/claudeCodeService.js +472 -367
- package/dist/ai/claudeCodeService.js.map +1 -1
- package/dist/connection/connection.d.ts +24 -6
- package/dist/connection/connection.d.ts.map +1 -1
- package/dist/connection/connection.js +85 -13
- package/dist/connection/connection.js.map +1 -1
- package/dist/connection/pubsub.d.ts +41 -0
- package/dist/connection/pubsub.d.ts.map +1 -0
- package/dist/connection/pubsub.js +101 -0
- package/dist/connection/pubsub.js.map +1 -0
- package/dist/connection/pubsub.test.d.ts +2 -0
- package/dist/connection/pubsub.test.d.ts.map +1 -0
- package/dist/connection/pubsub.test.js +96 -0
- package/dist/connection/pubsub.test.js.map +1 -0
- package/dist/handlers/messageHandler.d.ts +17 -2
- package/dist/handlers/messageHandler.d.ts.map +1 -1
- package/dist/handlers/messageHandler.js +171 -18
- package/dist/handlers/messageHandler.js.map +1 -1
- package/dist/handlers/messageHandler.test.js +1 -1
- package/dist/index.js +191 -3
- package/dist/index.js.map +1 -1
- package/dist/service/ipc.test.js +6 -6
- package/dist/service/ipcClient.js +1 -1
- package/dist/service/run.d.ts.map +1 -1
- package/dist/service/run.js +114 -12
- package/dist/service/run.js.map +1 -1
- package/dist/service/singleton.test.js +1 -1
- package/dist/service/types.d.ts +29 -0
- package/dist/service/types.d.ts.map +1 -1
- package/dist/service/types.js +11 -0
- package/dist/service/types.js.map +1 -1
- package/package.json +2 -2
|
@@ -1,12 +1,8 @@
|
|
|
1
1
|
import { EventEmitter } from 'events';
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
6
|
-
import { homedir } from 'os';
|
|
7
|
-
import { unstable_v2_createSession, unstable_v2_resumeSession, listSessions, getSessionMessages, } from '@anthropic-ai/claude-agent-sdk';
|
|
8
|
-
const TOOL_RESULT_TRUNCATE_LENGTH = 500;
|
|
9
|
-
const DEFAULT_MODEL = 'claude-sonnet-4-6';
|
|
2
|
+
import { resolve } from 'path';
|
|
3
|
+
import { unstable_v2_createSession, unstable_v2_resumeSession, listSessions, getSessionMessages, listSubagents, getSubagentMessages, } from '@anthropic-ai/claude-agent-sdk';
|
|
4
|
+
import { DEFAULT_MODEL } from '@sumicom/quicksave-shared';
|
|
5
|
+
import { StreamCardBuilder, buildCardsFromHistory } from './cardBuilder.js';
|
|
10
6
|
/** Tools auto-approved at each permission level (no user prompt).
|
|
11
7
|
* Read/Glob/Grep are always auto-approved at SDK level (allowedTools).
|
|
12
8
|
* Tools NOT listed here go through canUseTool → permission prompt.
|
|
@@ -41,6 +37,11 @@ const AUTO_APPROVE = {
|
|
|
41
37
|
default: new Set(['TodoWrite', 'EnterWorktree', 'ExitWorktree', 'Agent']),
|
|
42
38
|
plan: new Set(),
|
|
43
39
|
};
|
|
40
|
+
function createDeferred() {
|
|
41
|
+
let resolve;
|
|
42
|
+
const promise = new Promise((r) => { resolve = r; });
|
|
43
|
+
return { promise, resolve };
|
|
44
|
+
}
|
|
44
45
|
/** Extract readable text from tool_result content (which may be a string or array of blocks). */
|
|
45
46
|
function extractToolResultText(content) {
|
|
46
47
|
if (typeof content === 'string')
|
|
@@ -53,51 +54,10 @@ function extractToolResultText(content) {
|
|
|
53
54
|
}
|
|
54
55
|
return JSON.stringify(content);
|
|
55
56
|
}
|
|
56
|
-
// ─── Direct JSONL reader ──────────────────────────────────────────────────────
|
|
57
|
-
// getSessionMessages() from the SDK follows the parentUuid chain, which stops
|
|
58
|
-
// at each compact_boundary. Reading the file directly lets us surface all
|
|
59
|
-
// messages across every compaction epoch in a single flat list.
|
|
60
|
-
//
|
|
61
|
-
// Set to false to fall back to the SDK reader (only shows current epoch).
|
|
62
|
-
const READ_THROUGH_COMPACT_BOUNDARY = false;
|
|
63
|
-
function projectDirName(cwd) {
|
|
64
|
-
// Mirrors SDK logic: replace non-alphanumeric chars with '-'.
|
|
65
|
-
// Paths under 200 chars use the raw replacement; longer ones get a hash
|
|
66
|
-
// suffix — but that edge case is unlikely for normal project paths.
|
|
67
|
-
return cwd.replace(/[^a-zA-Z0-9]/g, '-');
|
|
68
|
-
}
|
|
69
|
-
function jsonlPath(sessionId, cwd) {
|
|
70
|
-
return join(homedir(), '.claude', 'projects', projectDirName(cwd), `${sessionId}.jsonl`);
|
|
71
|
-
}
|
|
72
|
-
async function readAllJSONLMessages(sessionId, cwd) {
|
|
73
|
-
const filePath = jsonlPath(sessionId, cwd);
|
|
74
|
-
const entries = [];
|
|
75
|
-
await new Promise((resolve, reject) => {
|
|
76
|
-
const rl = createInterface({
|
|
77
|
-
input: createReadStream(filePath),
|
|
78
|
-
crlfDelay: Infinity,
|
|
79
|
-
});
|
|
80
|
-
rl.on('line', (line) => {
|
|
81
|
-
if (!line.trim())
|
|
82
|
-
return;
|
|
83
|
-
try {
|
|
84
|
-
const obj = JSON.parse(line);
|
|
85
|
-
const t = obj.type;
|
|
86
|
-
// Keep user/assistant turns and compact_boundary markers; skip progress/queue-op
|
|
87
|
-
if (t === 'user' || t === 'assistant' || (t === 'system' && obj.subtype === 'compact_boundary')) {
|
|
88
|
-
entries.push(obj);
|
|
89
|
-
}
|
|
90
|
-
}
|
|
91
|
-
catch { /* malformed line — skip */ }
|
|
92
|
-
});
|
|
93
|
-
rl.on('close', resolve);
|
|
94
|
-
rl.on('error', reject);
|
|
95
|
-
});
|
|
96
|
-
return entries;
|
|
97
|
-
}
|
|
98
57
|
export class ClaudeCodeService extends EventEmitter {
|
|
99
58
|
sessions = new Map();
|
|
100
59
|
sessionPermissions = new Map(); // persists across active/inactive
|
|
60
|
+
sessionConfigs = new Map(); // generic per-session config
|
|
101
61
|
pendingInputRequests = new Map();
|
|
102
62
|
requestCounter = 0;
|
|
103
63
|
preferences = {
|
|
@@ -107,53 +67,22 @@ export class ClaudeCodeService extends EventEmitter {
|
|
|
107
67
|
super();
|
|
108
68
|
}
|
|
109
69
|
/**
|
|
110
|
-
* Initialize preferences from the most recently used session
|
|
111
|
-
*
|
|
70
|
+
* Initialize preferences from the most recently used session.
|
|
71
|
+
* Uses SDK listSessions + getSessionMessages to read the last assistant message's model field.
|
|
112
72
|
*/
|
|
113
73
|
async initPreferences() {
|
|
114
74
|
try {
|
|
115
|
-
const
|
|
116
|
-
|
|
117
|
-
let mostRecentFile = null;
|
|
118
|
-
let mostRecentMtime = 0;
|
|
119
|
-
for (const projectDir of projectDirs) {
|
|
120
|
-
const projectPath = join(claudeDir, projectDir);
|
|
121
|
-
try {
|
|
122
|
-
const files = await readdir(projectPath);
|
|
123
|
-
for (const file of files) {
|
|
124
|
-
if (!file.endsWith('.jsonl'))
|
|
125
|
-
continue;
|
|
126
|
-
const filePath = join(projectPath, file);
|
|
127
|
-
const fileStat = await stat(filePath);
|
|
128
|
-
if (fileStat.mtimeMs > mostRecentMtime) {
|
|
129
|
-
mostRecentMtime = fileStat.mtimeMs;
|
|
130
|
-
mostRecentFile = filePath;
|
|
131
|
-
}
|
|
132
|
-
}
|
|
133
|
-
}
|
|
134
|
-
catch { /* skip unreadable dirs */ }
|
|
135
|
-
}
|
|
136
|
-
if (!mostRecentFile)
|
|
75
|
+
const sessions = await listSessions();
|
|
76
|
+
if (sessions.length === 0)
|
|
137
77
|
return;
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
if (obj.type === 'assistant')
|
|
147
|
-
results.push(obj);
|
|
148
|
-
}
|
|
149
|
-
catch { /* malformed line */ }
|
|
150
|
-
});
|
|
151
|
-
rl.on('close', () => resolve(results));
|
|
152
|
-
rl.on('error', reject);
|
|
153
|
-
});
|
|
154
|
-
for (let i = assistantEntries.length - 1; i >= 0; i--) {
|
|
155
|
-
const model = assistantEntries[i].message?.model;
|
|
156
|
-
if (model) {
|
|
78
|
+
// Sessions are sorted by lastModified desc by the SDK
|
|
79
|
+
const latest = sessions[0];
|
|
80
|
+
const sessionId = latest.sessionId;
|
|
81
|
+
const cwd = latest.cwd;
|
|
82
|
+
const msgs = await getSessionMessages(sessionId, { dir: cwd });
|
|
83
|
+
for (let i = msgs.length - 1; i >= 0; i--) {
|
|
84
|
+
const model = msgs[i].message?.model;
|
|
85
|
+
if (model && msgs[i].type === 'assistant') {
|
|
157
86
|
this.preferences.model = model;
|
|
158
87
|
break;
|
|
159
88
|
}
|
|
@@ -181,6 +110,31 @@ export class ClaudeCodeService extends EventEmitter {
|
|
|
181
110
|
}
|
|
182
111
|
return { ...this.preferences };
|
|
183
112
|
}
|
|
113
|
+
getSessionConfig(sessionId) {
|
|
114
|
+
return { ...this.sessionConfigs.get(sessionId) };
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* Set a single key on a session's config. Applies known keys immediately:
|
|
118
|
+
* model / reasoningEffort → updates global preferences (takes effect next turn)
|
|
119
|
+
* permissionMode → calls setPermissionLevel (immediate)
|
|
120
|
+
*/
|
|
121
|
+
setSessionConfig(sessionId, key, value) {
|
|
122
|
+
const prev = this.sessionConfigs.get(sessionId) ?? {};
|
|
123
|
+
const next = { ...prev, [key]: value };
|
|
124
|
+
this.sessionConfigs.set(sessionId, next);
|
|
125
|
+
// Apply known keys
|
|
126
|
+
if (key === 'model' && typeof value === 'string') {
|
|
127
|
+
this.setPreferences({ model: value });
|
|
128
|
+
}
|
|
129
|
+
else if (key === 'reasoningEffort' && (typeof value === 'string' || value === null)) {
|
|
130
|
+
this.setPreferences({ reasoningEffort: value });
|
|
131
|
+
}
|
|
132
|
+
else if (key === 'permissionMode' && typeof value === 'string') {
|
|
133
|
+
this.setPermissionLevel(sessionId, value);
|
|
134
|
+
}
|
|
135
|
+
this.emit('session-config-updated', { sessionId, config: next });
|
|
136
|
+
return next;
|
|
137
|
+
}
|
|
184
138
|
/** Build and emit a session-updated event with current state. */
|
|
185
139
|
emitSessionUpdate(sessionId) {
|
|
186
140
|
const ps = this.sessions.get(sessionId);
|
|
@@ -203,8 +157,10 @@ export class ClaudeCodeService extends EventEmitter {
|
|
|
203
157
|
const isActive = this.sessions.has(s.sessionId);
|
|
204
158
|
const isStreaming = this.sessions.get(s.sessionId)?.streaming ?? false;
|
|
205
159
|
let hasPendingInput = pendingSessionIds.has(s.sessionId);
|
|
206
|
-
//
|
|
207
|
-
|
|
160
|
+
// Only check JSONL for sessions NOT in memory — in-memory sessions use
|
|
161
|
+
// pendingInputRequests as the authoritative source. JSONL may have mid-flight
|
|
162
|
+
// tool_use blocks that haven't received tool_result yet (false positive).
|
|
163
|
+
if (!hasPendingInput && !isActive) {
|
|
208
164
|
hasPendingInput = await this.detectPendingFromJSONL(s.sessionId, cwd);
|
|
209
165
|
}
|
|
210
166
|
return {
|
|
@@ -231,100 +187,61 @@ export class ClaudeCodeService extends EventEmitter {
|
|
|
231
187
|
return enriched;
|
|
232
188
|
}
|
|
233
189
|
/**
|
|
234
|
-
* Check if a session's last message
|
|
235
|
-
*
|
|
190
|
+
* Check if a session's last message is an unanswered tool_use.
|
|
191
|
+
* Uses SDK getSessionMessages + listSubagents/getSubagentMessages.
|
|
236
192
|
*/
|
|
237
193
|
async detectPendingFromJSONL(sessionId, cwd) {
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
? await readAllJSONLMessages(sessionId, cwd)
|
|
241
|
-
: await getSessionMessages(sessionId, { dir: cwd });
|
|
242
|
-
if (allMessages.length === 0)
|
|
194
|
+
const hasPendingToolUse = (msgs, label = 'parent') => {
|
|
195
|
+
if (msgs.length === 0)
|
|
243
196
|
return false;
|
|
244
|
-
const last =
|
|
245
|
-
// Last message is assistant with a tool_use block and no following user/tool_result
|
|
197
|
+
const last = msgs[msgs.length - 1];
|
|
246
198
|
if (last.type !== 'assistant')
|
|
247
199
|
return false;
|
|
248
200
|
const content = last.message?.content;
|
|
249
201
|
if (!Array.isArray(content))
|
|
250
202
|
return false;
|
|
251
|
-
|
|
203
|
+
const toolUseIds = content.filter((b) => b.type === 'tool_use').map((b) => b.id);
|
|
204
|
+
if (toolUseIds.length === 0)
|
|
205
|
+
return false;
|
|
206
|
+
// Check if all tool_use blocks already have a corresponding tool_result
|
|
207
|
+
const resolvedIds = new Set(msgs
|
|
208
|
+
.filter((m) => m.type === 'user')
|
|
209
|
+
.flatMap((m) => {
|
|
210
|
+
const c = m.message?.content;
|
|
211
|
+
return Array.isArray(c)
|
|
212
|
+
? c.filter((b) => b.type === 'tool_result').map((b) => b.tool_use_id)
|
|
213
|
+
: [];
|
|
214
|
+
}));
|
|
215
|
+
const unresolvedIds = toolUseIds.filter((id) => !resolvedIds.has(id));
|
|
216
|
+
if (unresolvedIds.length > 0) {
|
|
217
|
+
const toolNames = content
|
|
218
|
+
.filter((b) => b.type === 'tool_use' && unresolvedIds.includes(b.id))
|
|
219
|
+
.map((b) => b.name);
|
|
220
|
+
console.log(`[detectPending] ${label} session=${sessionId.slice(0, 8)}: unresolved tool_use: ${toolNames.join(', ')} (${unresolvedIds.length}/${toolUseIds.length})`);
|
|
221
|
+
}
|
|
222
|
+
return unresolvedIds.length > 0;
|
|
223
|
+
};
|
|
224
|
+
try {
|
|
225
|
+
// Check parent session
|
|
226
|
+
const allMessages = await getSessionMessages(sessionId, { dir: cwd });
|
|
227
|
+
if (hasPendingToolUse(allMessages))
|
|
228
|
+
return true;
|
|
229
|
+
// Check subagents — a subagent may be waiting for permission
|
|
230
|
+
try {
|
|
231
|
+
const agentIds = await listSubagents(sessionId, { dir: cwd });
|
|
232
|
+
for (const agentId of agentIds) {
|
|
233
|
+
const subMsgs = await getSubagentMessages(sessionId, agentId, { dir: cwd });
|
|
234
|
+
if (hasPendingToolUse(subMsgs, `subagent:${agentId.slice(0, 8)}`))
|
|
235
|
+
return true;
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
catch { /* no subagents */ }
|
|
239
|
+
return false;
|
|
252
240
|
}
|
|
253
241
|
catch {
|
|
254
242
|
return false;
|
|
255
243
|
}
|
|
256
244
|
}
|
|
257
|
-
async getMessages(sessionId, cwd, offset = 0, limit = 50) {
|
|
258
|
-
const allMessages = READ_THROUGH_COMPACT_BOUNDARY
|
|
259
|
-
? await readAllJSONLMessages(sessionId, cwd)
|
|
260
|
-
: await getSessionMessages(sessionId, { dir: cwd });
|
|
261
|
-
const total = allMessages.length;
|
|
262
|
-
const tailStart = Math.max(0, total - offset - limit);
|
|
263
|
-
const tailEnd = Math.max(0, total - offset);
|
|
264
|
-
const sliced = allMessages.slice(tailStart, tailEnd);
|
|
265
|
-
const messages = sliced.flatMap((msg, i) => {
|
|
266
|
-
// compact_boundary system entries — render as a divider
|
|
267
|
-
if (msg.type === 'system' && msg.subtype === 'compact_boundary') {
|
|
268
|
-
return [{
|
|
269
|
-
index: tailStart + i,
|
|
270
|
-
role: 'system',
|
|
271
|
-
content: 'Context compacted',
|
|
272
|
-
}];
|
|
273
|
-
}
|
|
274
|
-
const role = msg.type;
|
|
275
|
-
const rawMessage = msg.message;
|
|
276
|
-
const expanded = [];
|
|
277
|
-
if (rawMessage?.content) {
|
|
278
|
-
if (typeof rawMessage.content === 'string') {
|
|
279
|
-
expanded.push({ index: tailStart + i, role, content: rawMessage.content });
|
|
280
|
-
}
|
|
281
|
-
else if (Array.isArray(rawMessage.content)) {
|
|
282
|
-
const textParts = [];
|
|
283
|
-
for (const block of rawMessage.content) {
|
|
284
|
-
if (block.type === 'text') {
|
|
285
|
-
textParts.push(block.text);
|
|
286
|
-
}
|
|
287
|
-
else if (block.type === 'tool_use') {
|
|
288
|
-
expanded.push({
|
|
289
|
-
index: tailStart + i,
|
|
290
|
-
role,
|
|
291
|
-
content: '',
|
|
292
|
-
toolName: block.name,
|
|
293
|
-
toolInput: JSON.stringify(block.input),
|
|
294
|
-
toolUseId: block.id,
|
|
295
|
-
});
|
|
296
|
-
}
|
|
297
|
-
else if (block.type === 'tool_result') {
|
|
298
|
-
const resultStr = extractToolResultText(block.content);
|
|
299
|
-
const truncated = resultStr.length > TOOL_RESULT_TRUNCATE_LENGTH;
|
|
300
|
-
expanded.push({
|
|
301
|
-
index: tailStart + i,
|
|
302
|
-
role,
|
|
303
|
-
content: '',
|
|
304
|
-
toolResult: truncated
|
|
305
|
-
? resultStr.slice(0, TOOL_RESULT_TRUNCATE_LENGTH) + ' [truncated]'
|
|
306
|
-
: resultStr,
|
|
307
|
-
toolResultForId: block.tool_use_id,
|
|
308
|
-
truncated,
|
|
309
|
-
});
|
|
310
|
-
}
|
|
311
|
-
}
|
|
312
|
-
if (textParts.length > 0) {
|
|
313
|
-
expanded.unshift({ index: tailStart + i, role, content: textParts.join('\n') });
|
|
314
|
-
}
|
|
315
|
-
}
|
|
316
|
-
}
|
|
317
|
-
if (expanded.length === 0) {
|
|
318
|
-
expanded.push({ index: tailStart + i, role, content: '' });
|
|
319
|
-
}
|
|
320
|
-
return expanded;
|
|
321
|
-
});
|
|
322
|
-
return {
|
|
323
|
-
messages,
|
|
324
|
-
total,
|
|
325
|
-
hasMore: tailStart > 0,
|
|
326
|
-
};
|
|
327
|
-
}
|
|
328
245
|
/**
|
|
329
246
|
* Create a V2 session with the given cwd.
|
|
330
247
|
* V2 SDKSessionOptions doesn't expose `cwd`, so we temporarily change
|
|
@@ -341,14 +258,42 @@ export class ClaudeCodeService extends EventEmitter {
|
|
|
341
258
|
model: opts.model ?? DEFAULT_MODEL,
|
|
342
259
|
allowedTools: opts.allowedTools ?? ['Read', 'Glob', 'Grep'],
|
|
343
260
|
permissionMode: 'default',
|
|
261
|
+
includePartialMessages: true,
|
|
344
262
|
settingSources: ['user', 'project', 'local'],
|
|
345
263
|
canUseTool: async (toolName, input, options) => {
|
|
346
|
-
|
|
264
|
+
// Resolve session ID: use mutable ref (updated when init arrives),
|
|
265
|
+
// fall back to static closure, await deferred if still unknown.
|
|
266
|
+
let resolvedSessionId = opts.sessionIdRef?.current ?? sessionId;
|
|
267
|
+
if (!resolvedSessionId && opts.sessionIdRef?.promise) {
|
|
268
|
+
resolvedSessionId = await opts.sessionIdRef.promise;
|
|
269
|
+
}
|
|
270
|
+
resolvedSessionId = resolvedSessionId ?? 'unknown';
|
|
347
271
|
// Check runtime permission level — auto-approve if tool is in the allow set
|
|
348
272
|
const ps = resolvedSessionId !== 'unknown' ? this.sessions.get(resolvedSessionId) : undefined;
|
|
349
|
-
const level = ps?.permissionLevel ?? 'acceptEdits';
|
|
273
|
+
const level = ps?.permissionLevel ?? this.sessionPermissions.get(resolvedSessionId) ?? 'acceptEdits';
|
|
350
274
|
if (AUTO_APPROVE[level].has(toolName)) {
|
|
351
|
-
|
|
275
|
+
// For file-writing tools, restrict auto-approval to paths inside the project cwd
|
|
276
|
+
const FILE_WRITE_TOOLS = new Set(['Write', 'Edit', 'NotebookEdit']);
|
|
277
|
+
if (FILE_WRITE_TOOLS.has(toolName)) {
|
|
278
|
+
const filePath = (input.file_path ?? input.path);
|
|
279
|
+
if (filePath) {
|
|
280
|
+
const resolvedFile = resolve(filePath);
|
|
281
|
+
const resolvedCwd = resolve(cwd);
|
|
282
|
+
const inScope = resolvedFile === resolvedCwd || resolvedFile.startsWith(resolvedCwd + '/');
|
|
283
|
+
if (!inScope) {
|
|
284
|
+
// Fall through to permission prompt below
|
|
285
|
+
}
|
|
286
|
+
else {
|
|
287
|
+
return { behavior: 'allow', updatedInput: input };
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
else {
|
|
291
|
+
return { behavior: 'allow', updatedInput: input };
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
else {
|
|
295
|
+
return { behavior: 'allow', updatedInput: input };
|
|
296
|
+
}
|
|
352
297
|
}
|
|
353
298
|
const requestId = `perm-${++this.requestCounter}`;
|
|
354
299
|
// AskUserQuestion: forward as question type with options
|
|
@@ -367,6 +312,8 @@ export class ClaudeCodeService extends EventEmitter {
|
|
|
367
312
|
: (options.description ?? JSON.stringify(input).slice(0, 500)),
|
|
368
313
|
toolName,
|
|
369
314
|
toolInput: input,
|
|
315
|
+
toolUseId: options.toolUseID,
|
|
316
|
+
...(options.agentID ? { agentId: options.agentID } : {}),
|
|
370
317
|
// Include structured options for question type
|
|
371
318
|
...(isQuestion && questions ? {
|
|
372
319
|
options: questions.flatMap((q) => (q.options ?? []).map((opt) => ({
|
|
@@ -376,10 +323,30 @@ export class ClaudeCodeService extends EventEmitter {
|
|
|
376
323
|
}))),
|
|
377
324
|
} : {}),
|
|
378
325
|
};
|
|
326
|
+
// Card-based: create/attach pending input on the correct card
|
|
327
|
+
const builder = ps?.cardBuilder;
|
|
328
|
+
if (builder) {
|
|
329
|
+
const pendingAttachment = {
|
|
330
|
+
sessionId: resolvedSessionId,
|
|
331
|
+
requestId,
|
|
332
|
+
inputType: request.inputType,
|
|
333
|
+
title: request.title,
|
|
334
|
+
message: request.message,
|
|
335
|
+
options: request.options,
|
|
336
|
+
};
|
|
337
|
+
// Every permission gets its own ToolCallCard.
|
|
338
|
+
// Subagent permissions are ephemeral — removed after resolution
|
|
339
|
+
// (the tool runs in the sidechain, so no result will arrive).
|
|
340
|
+
const ephemeral = !!options.agentID;
|
|
341
|
+
const cardEvt = builder.toolCallFromPermission(toolName, input, options.toolUseID, pendingAttachment, ephemeral);
|
|
342
|
+
this.emit('card-event', cardEvt);
|
|
343
|
+
}
|
|
379
344
|
this.emit('user-input-request', request);
|
|
345
|
+
// Register pending BEFORE emitting session-updated so hasPendingInput is true
|
|
346
|
+
const responsePromise = this.waitForUserInput(requestId, request, options.signal);
|
|
380
347
|
this.emitSessionUpdate(resolvedSessionId);
|
|
381
348
|
// Wait for explicit user response (no timeout — user must act)
|
|
382
|
-
const response = await
|
|
349
|
+
const response = await responsePromise;
|
|
383
350
|
if (response.action === 'deny') {
|
|
384
351
|
return { behavior: 'deny', message: 'User denied permission' };
|
|
385
352
|
}
|
|
@@ -425,6 +392,13 @@ export class ClaudeCodeService extends EventEmitter {
|
|
|
425
392
|
return false;
|
|
426
393
|
this.pendingInputRequests.delete(response.requestId);
|
|
427
394
|
pending.resolve(response);
|
|
395
|
+
// Clear pending input on the card
|
|
396
|
+
const ps = this.sessions.get(pending.request.sessionId);
|
|
397
|
+
if (ps?.cardBuilder) {
|
|
398
|
+
const cardEvt = ps.cardBuilder.clearPendingInput(response.requestId);
|
|
399
|
+
if (cardEvt)
|
|
400
|
+
this.emit('card-event', cardEvt);
|
|
401
|
+
}
|
|
428
402
|
this.emit('user-input-resolved', { requestId: response.requestId, sessionId: pending.request.sessionId });
|
|
429
403
|
this.emitSessionUpdate(pending.request.sessionId);
|
|
430
404
|
return true;
|
|
@@ -435,101 +409,141 @@ export class ClaudeCodeService extends EventEmitter {
|
|
|
435
409
|
getPendingInputRequests() {
|
|
436
410
|
return Array.from(this.pendingInputRequests.values()).map((p) => p.request);
|
|
437
411
|
}
|
|
412
|
+
/** Debug snapshot of pending inputs and active sessions. */
|
|
413
|
+
getDebugState() {
|
|
414
|
+
const pendingInputs = Array.from(this.pendingInputRequests.values()).map((p) => ({
|
|
415
|
+
requestId: p.request.requestId,
|
|
416
|
+
sessionId: p.request.sessionId,
|
|
417
|
+
toolName: p.request.toolName,
|
|
418
|
+
agentId: p.request.agentId,
|
|
419
|
+
inputType: p.request.inputType,
|
|
420
|
+
}));
|
|
421
|
+
return { pendingInputs, activeSessions: this.getActiveSessions() };
|
|
422
|
+
}
|
|
438
423
|
async startSession(opts) {
|
|
439
424
|
const validModes = ['default', 'acceptEdits', 'bypassPermissions', 'plan'];
|
|
440
425
|
const level = validModes.includes(opts.permissionMode)
|
|
441
426
|
? opts.permissionMode : 'acceptEdits';
|
|
427
|
+
// Mutable ref so canUseTool can access the real sessionId once init arrives
|
|
428
|
+
const deferred = createDeferred();
|
|
429
|
+
const sessionIdRef = { current: null, promise: deferred.promise };
|
|
442
430
|
const session = this.createSessionWithCwd(opts.cwd, null, {
|
|
443
431
|
allowedTools: opts.allowedTools,
|
|
444
432
|
model: opts.model,
|
|
445
433
|
permissionMode: opts.permissionMode,
|
|
434
|
+
sessionIdRef,
|
|
446
435
|
});
|
|
447
436
|
const prompt = opts.systemPrompt
|
|
448
437
|
? `[System context: ${opts.systemPrompt}]\n\n${opts.prompt}`
|
|
449
438
|
: opts.prompt;
|
|
450
439
|
await session.send(prompt);
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
440
|
+
// Create the stream generator ONCE — reused by processTurnLoop.
|
|
441
|
+
// IMPORTANT: Do NOT use for-await+break to consume init, because break
|
|
442
|
+
// calls generator.return() which closes the generator permanently.
|
|
443
|
+
const streamGen = session.stream();
|
|
444
|
+
// Pull messages manually until we get the init message
|
|
445
|
+
let sessionId;
|
|
446
|
+
while (!sessionId) {
|
|
447
|
+
const { value: message, done } = await streamGen.next();
|
|
448
|
+
if (done)
|
|
449
|
+
throw new Error('Stream ended before init message');
|
|
450
|
+
if (message.type === 'system' && message.subtype === 'init') {
|
|
451
|
+
const id = message.session_id;
|
|
452
|
+
sessionIdRef.current = id;
|
|
453
|
+
deferred.resolve(id);
|
|
454
|
+
this.sessionPermissions.set(id, level);
|
|
458
455
|
this.sessions.set(id, {
|
|
459
456
|
session,
|
|
460
457
|
sessionId: id,
|
|
458
|
+
sessionIdRef,
|
|
461
459
|
cwd: opts.cwd,
|
|
462
460
|
streaming: true,
|
|
463
|
-
cancelStreaming: null,
|
|
464
461
|
permissionLevel: level,
|
|
462
|
+
cardBuilder: null,
|
|
463
|
+
_promptQueue: [],
|
|
464
|
+
_cancelled: false,
|
|
465
|
+
_loopRunning: false,
|
|
466
|
+
_streamGenerator: streamGen,
|
|
465
467
|
});
|
|
466
|
-
this.sessionPermissions.set(id, level);
|
|
467
468
|
this.emitSessionUpdate(id);
|
|
468
|
-
|
|
469
|
-
}
|
|
470
|
-
}
|
|
469
|
+
sessionId = id;
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
// Seed the queue with the initial streamId (prompt already sent via session.send())
|
|
473
|
+
const ps = this.sessions.get(sessionId);
|
|
474
|
+
ps._promptQueue.push({ prompt: '', streamId: opts.streamId });
|
|
475
|
+
// Start the turn loop (reuses the same streamGen — picks up remaining messages)
|
|
476
|
+
this.processTurnLoop(sessionId);
|
|
471
477
|
return sessionId;
|
|
472
478
|
}
|
|
473
479
|
async resumeSession(opts) {
|
|
474
480
|
const existing = this.sessions.get(opts.sessionId);
|
|
475
481
|
if (existing) {
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
});
|
|
484
|
-
await existing.session.send(opts.prompt);
|
|
485
|
-
this.consumeStream(existing.session, opts.streamId, () => {
|
|
486
|
-
// Session ID already captured
|
|
487
|
-
});
|
|
482
|
+
// Hot resume — push to queue
|
|
483
|
+
console.log(`[v2] hot resume (enqueue) session=${opts.sessionId}`);
|
|
484
|
+
existing._promptQueue.push({ prompt: opts.prompt, streamId: opts.streamId });
|
|
485
|
+
// If not looping, start the loop
|
|
486
|
+
if (!existing._loopRunning) {
|
|
487
|
+
this.processTurnLoop(opts.sessionId);
|
|
488
|
+
}
|
|
488
489
|
return opts.sessionId;
|
|
489
490
|
}
|
|
491
|
+
// Cold resume: create new session with resumeSessionId
|
|
490
492
|
console.log(`[v2] cold resume session=${opts.sessionId}`);
|
|
493
|
+
const deferred = createDeferred();
|
|
494
|
+
const sessionIdRef = { current: opts.sessionId, promise: deferred.promise };
|
|
495
|
+
deferred.resolve(opts.sessionId); // already known for resume
|
|
496
|
+
const restoredLevel = this.sessionPermissions.get(opts.sessionId) ?? 'acceptEdits';
|
|
491
497
|
const session = this.createSessionWithCwd(opts.cwd, opts.sessionId, {
|
|
492
498
|
resumeSessionId: opts.sessionId,
|
|
493
|
-
|
|
494
|
-
this.emit('stream', {
|
|
495
|
-
sessionId: opts.sessionId, streamId: opts.streamId,
|
|
496
|
-
eventType: 'user_message',
|
|
497
|
-
content: opts.prompt,
|
|
499
|
+
sessionIdRef,
|
|
498
500
|
});
|
|
499
501
|
await session.send(opts.prompt);
|
|
500
|
-
|
|
501
|
-
|
|
502
|
+
// Create the stream generator ONCE — reused by processTurnLoop.
|
|
503
|
+
// Use manual .next() to avoid for-await+break closing the generator.
|
|
504
|
+
const streamGen = session.stream();
|
|
505
|
+
let actualSessionId;
|
|
506
|
+
while (!actualSessionId) {
|
|
507
|
+
const { value: message, done } = await streamGen.next();
|
|
508
|
+
if (done)
|
|
509
|
+
throw new Error('Stream ended before init message');
|
|
510
|
+
if (message.type === 'system' && message.subtype === 'init') {
|
|
511
|
+
const id = message.session_id;
|
|
502
512
|
if (id !== opts.sessionId) {
|
|
503
|
-
|
|
504
|
-
sessionId: id, streamId: opts.streamId,
|
|
505
|
-
eventType: 'system',
|
|
506
|
-
content: `Warning: SDK created new session ${id} instead of resuming ${opts.sessionId}`,
|
|
507
|
-
});
|
|
513
|
+
console.warn(`[v2] SDK created new session ${id} instead of resuming ${opts.sessionId}`);
|
|
508
514
|
}
|
|
509
|
-
|
|
515
|
+
sessionIdRef.current = id;
|
|
516
|
+
this.sessionPermissions.set(id, restoredLevel);
|
|
510
517
|
this.sessions.set(id, {
|
|
511
518
|
session,
|
|
512
519
|
sessionId: id,
|
|
520
|
+
sessionIdRef,
|
|
513
521
|
cwd: opts.cwd,
|
|
514
522
|
streaming: true,
|
|
515
|
-
cancelStreaming: null,
|
|
516
523
|
permissionLevel: restoredLevel,
|
|
524
|
+
cardBuilder: null,
|
|
525
|
+
_promptQueue: [],
|
|
526
|
+
_cancelled: false,
|
|
527
|
+
_loopRunning: false,
|
|
528
|
+
_streamGenerator: streamGen,
|
|
517
529
|
});
|
|
518
|
-
this.sessionPermissions.set(id, restoredLevel);
|
|
519
530
|
this.emitSessionUpdate(id);
|
|
520
|
-
|
|
521
|
-
}
|
|
522
|
-
}
|
|
531
|
+
actualSessionId = id;
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
// Seed the queue (prompt already sent)
|
|
535
|
+
const ps = this.sessions.get(actualSessionId);
|
|
536
|
+
ps._promptQueue.push({ prompt: '', streamId: opts.streamId });
|
|
537
|
+
// Start the turn loop (reuses same streamGen)
|
|
538
|
+
this.processTurnLoop(actualSessionId);
|
|
523
539
|
return actualSessionId;
|
|
524
540
|
}
|
|
525
541
|
cancelSession(sessionId) {
|
|
526
542
|
const ps = this.sessions.get(sessionId);
|
|
527
543
|
if (!ps)
|
|
528
544
|
return false;
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
ps.cancelStreaming();
|
|
532
|
-
}
|
|
545
|
+
ps._cancelled = true;
|
|
546
|
+
ps._promptQueue.length = 0; // drain queue
|
|
533
547
|
ps.streaming = false;
|
|
534
548
|
return true;
|
|
535
549
|
}
|
|
@@ -567,11 +581,8 @@ export class ClaudeCodeService extends EventEmitter {
|
|
|
567
581
|
const ps = this.sessions.get(sessionId);
|
|
568
582
|
if (!ps)
|
|
569
583
|
return false;
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
ps.cancelStreaming();
|
|
573
|
-
}
|
|
574
|
-
// Terminate the subprocess
|
|
584
|
+
ps._cancelled = true;
|
|
585
|
+
ps._promptQueue.length = 0;
|
|
575
586
|
ps.session.close();
|
|
576
587
|
this.sessions.delete(sessionId);
|
|
577
588
|
this.emitSessionUpdate(sessionId);
|
|
@@ -602,162 +613,256 @@ export class ClaudeCodeService extends EventEmitter {
|
|
|
602
613
|
}
|
|
603
614
|
this.sessions.clear();
|
|
604
615
|
}
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
const
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
if (ps) {
|
|
627
|
-
ps.streaming = false;
|
|
628
|
-
ps.cancelStreaming = null;
|
|
629
|
-
}
|
|
630
|
-
this.emitSessionUpdate(capturedSessionId);
|
|
631
|
-
}
|
|
632
|
-
};
|
|
633
|
-
const bufferText = (text) => {
|
|
634
|
-
textBuffer += text;
|
|
635
|
-
if (!bufferTimer) {
|
|
636
|
-
bufferTimer = setTimeout(flushText, 150);
|
|
637
|
-
}
|
|
638
|
-
if (textBuffer.length > 2048) {
|
|
639
|
-
flushText();
|
|
616
|
+
/** Get the CardBuilder for a session (if streaming). Used by canUseTool. */
|
|
617
|
+
getCardBuilder(sessionId) {
|
|
618
|
+
return this.sessions.get(sessionId)?.cardBuilder ?? null;
|
|
619
|
+
}
|
|
620
|
+
/** Get card-based history for a session. */
|
|
621
|
+
async getCards(sessionId, cwd, offset = 0, limit = 50) {
|
|
622
|
+
// Always use JSONL as the source of truth for history.
|
|
623
|
+
// Overlay pendingInput from in-memory state (only matters during permission prompts).
|
|
624
|
+
const result = await buildCardsFromHistory(sessionId, cwd, offset, limit);
|
|
625
|
+
// Attach pendingInput to matching tool cards
|
|
626
|
+
const pendingByToolUseId = new Map();
|
|
627
|
+
for (const [, pending] of this.pendingInputRequests) {
|
|
628
|
+
if (pending.request.sessionId === sessionId && pending.request.toolUseId) {
|
|
629
|
+
pendingByToolUseId.set(pending.request.toolUseId, {
|
|
630
|
+
sessionId: pending.request.sessionId,
|
|
631
|
+
requestId: pending.request.requestId,
|
|
632
|
+
inputType: pending.request.inputType,
|
|
633
|
+
title: pending.request.title,
|
|
634
|
+
message: pending.request.message,
|
|
635
|
+
options: pending.request.options,
|
|
636
|
+
});
|
|
640
637
|
}
|
|
641
|
-
}
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
638
|
+
}
|
|
639
|
+
if (pendingByToolUseId.size > 0) {
|
|
640
|
+
for (const card of result.cards) {
|
|
641
|
+
if (card.type === 'tool_call' && card.toolUseId) {
|
|
642
|
+
const pending = pendingByToolUseId.get(card.toolUseId);
|
|
643
|
+
if (pending)
|
|
644
|
+
card.pendingInput = pending;
|
|
648
645
|
}
|
|
649
646
|
}
|
|
650
|
-
}
|
|
647
|
+
}
|
|
648
|
+
return result;
|
|
649
|
+
}
|
|
650
|
+
/**
|
|
651
|
+
* Turn loop: dequeue prompt → send() → consume stream → on result, check queue → repeat.
|
|
652
|
+
* The first entry may have an empty prompt (already sent by startSession/resumeSession).
|
|
653
|
+
*/
|
|
654
|
+
async processTurnLoop(sessionId) {
|
|
655
|
+
const ps = this.sessions.get(sessionId);
|
|
656
|
+
if (!ps || ps._loopRunning)
|
|
657
|
+
return;
|
|
658
|
+
ps._loopRunning = true;
|
|
659
|
+
let textBuffer = '';
|
|
660
|
+
let bufferTimer = null;
|
|
661
|
+
const emitCard = (event) => { this.emit('card-event', event); };
|
|
662
|
+
// Create cardBuilder once, reuse across turns (accumulates all cards)
|
|
663
|
+
if (!ps.cardBuilder) {
|
|
664
|
+
ps.cardBuilder = new StreamCardBuilder(sessionId, '');
|
|
665
|
+
}
|
|
651
666
|
try {
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
667
|
+
while (ps._promptQueue.length > 0) {
|
|
668
|
+
const { prompt, streamId } = ps._promptQueue.shift();
|
|
669
|
+
ps._cancelled = false;
|
|
670
|
+
ps.streaming = true;
|
|
671
|
+
const cb = ps.cardBuilder;
|
|
672
|
+
cb.startNewTurn(streamId);
|
|
673
|
+
this.emitSessionUpdate(sessionId);
|
|
674
|
+
// Reset text buffering for this turn
|
|
675
|
+
textBuffer = '';
|
|
676
|
+
if (bufferTimer) {
|
|
677
|
+
clearTimeout(bufferTimer);
|
|
678
|
+
bufferTimer = null;
|
|
662
679
|
}
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
680
|
+
const flushText = () => {
|
|
681
|
+
if (textBuffer) {
|
|
682
|
+
console.log(`[stream] flushText len=${textBuffer.length} session=${sessionId.slice(0, 8)}`);
|
|
683
|
+
emitCard(cb.assistantText(textBuffer));
|
|
684
|
+
textBuffer = '';
|
|
685
|
+
}
|
|
686
|
+
if (bufferTimer) {
|
|
687
|
+
clearTimeout(bufferTimer);
|
|
688
|
+
bufferTimer = null;
|
|
689
|
+
}
|
|
690
|
+
};
|
|
691
|
+
const bufferText = (text) => {
|
|
692
|
+
textBuffer += text;
|
|
693
|
+
if (!bufferTimer) {
|
|
694
|
+
bufferTimer = setTimeout(flushText, 150);
|
|
695
|
+
}
|
|
696
|
+
if (textBuffer.length > 2048) {
|
|
667
697
|
flushText();
|
|
668
|
-
console.log(`[stream] session_state_changed=idle session=${capturedSessionId}`);
|
|
669
|
-
// Don't call onEnd here — the result message will follow
|
|
670
698
|
}
|
|
671
|
-
|
|
699
|
+
};
|
|
700
|
+
// Send prompt if non-empty (empty means prompt was already sent before loop started)
|
|
701
|
+
if (prompt) {
|
|
702
|
+
emitCard(cb.userMessage(prompt));
|
|
703
|
+
await ps.session.send(prompt);
|
|
704
|
+
// New turn = new generator (v2 API: one stream() call per send())
|
|
705
|
+
ps._streamGenerator = ps.session.stream();
|
|
672
706
|
}
|
|
673
|
-
//
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
bufferText(delta.text);
|
|
680
|
-
}
|
|
681
|
-
}
|
|
682
|
-
continue;
|
|
707
|
+
// Consume this turn's stream using manual .next() to avoid
|
|
708
|
+
// for-await+break calling generator.return() which kills the stream.
|
|
709
|
+
const gen = ps._streamGenerator;
|
|
710
|
+
if (!gen) {
|
|
711
|
+
console.error(`[stream] no generator for session=${sessionId}`);
|
|
712
|
+
break;
|
|
683
713
|
}
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
714
|
+
let turnDone = false;
|
|
715
|
+
try {
|
|
716
|
+
while (!turnDone && !ps._cancelled) {
|
|
717
|
+
const { value: message, done } = await gen.next();
|
|
718
|
+
if (done) {
|
|
719
|
+
turnDone = true;
|
|
720
|
+
break;
|
|
721
|
+
}
|
|
722
|
+
// Subagent lifecycle events
|
|
723
|
+
if (message.type === 'system' && message.subtype === 'task_started') {
|
|
724
|
+
const m = message;
|
|
725
|
+
flushText();
|
|
726
|
+
emitCard(cb.subagentStart(m.description ?? '', m.task_id, m.tool_use_id));
|
|
727
|
+
continue;
|
|
728
|
+
}
|
|
729
|
+
if (message.type === 'system' && message.subtype === 'task_progress') {
|
|
730
|
+
const m = message;
|
|
731
|
+
const cardEvt = cb.subagentProgress(m.task_id, m.tool_use_id, m.usage?.tool_uses, m.last_tool_name);
|
|
732
|
+
if (cardEvt)
|
|
733
|
+
emitCard(cardEvt);
|
|
734
|
+
continue;
|
|
735
|
+
}
|
|
736
|
+
if (message.type === 'system' && message.subtype === 'task_notification') {
|
|
737
|
+
const m = message;
|
|
738
|
+
const cardEvt = cb.subagentEnd(m.task_id, m.tool_use_id, m.status, m.summary);
|
|
739
|
+
if (cardEvt)
|
|
740
|
+
emitCard(cardEvt);
|
|
741
|
+
continue;
|
|
742
|
+
}
|
|
743
|
+
// Session state changed — 'idle' means turn is over
|
|
744
|
+
if (message.type === 'system' && message.subtype === 'session_state_changed') {
|
|
745
|
+
const state = message.state;
|
|
746
|
+
if (state === 'idle') {
|
|
747
|
+
flushText();
|
|
748
|
+
console.log(`[stream] session_state_changed=idle session=${sessionId}`);
|
|
749
|
+
}
|
|
750
|
+
continue;
|
|
751
|
+
}
|
|
752
|
+
// Streaming partial events
|
|
753
|
+
if (message.type === 'stream_event') {
|
|
754
|
+
const event = message.event;
|
|
755
|
+
if (event?.type === 'content_block_delta') {
|
|
756
|
+
const delta = event.delta;
|
|
757
|
+
if (delta?.type === 'text_delta' && delta.text) {
|
|
758
|
+
bufferText(delta.text);
|
|
759
|
+
}
|
|
695
760
|
}
|
|
696
|
-
else if (
|
|
697
|
-
|
|
698
|
-
emitStream({
|
|
699
|
-
eventType: 'tool_use',
|
|
700
|
-
content: '',
|
|
701
|
-
toolName: block.name,
|
|
702
|
-
toolInput,
|
|
703
|
-
toolUseId: block.id,
|
|
704
|
-
});
|
|
761
|
+
else if (!event?.type?.includes('delta')) {
|
|
762
|
+
console.log(`[stream] stream_event type=${event?.type} session=${sessionId.slice(0, 8)}`);
|
|
705
763
|
}
|
|
764
|
+
continue;
|
|
706
765
|
}
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
766
|
+
// Complete assistant messages — skip sidechain (subagent) messages.
|
|
767
|
+
if (message.type === 'assistant') {
|
|
768
|
+
if (message.agentId)
|
|
769
|
+
continue; // sidechain message — ignore
|
|
770
|
+
flushText();
|
|
771
|
+
const betaMessage = message.message;
|
|
772
|
+
const blocks = betaMessage?.content ?? [];
|
|
773
|
+
for (const block of blocks) {
|
|
774
|
+
if (block.type === 'thinking' && block.thinking) {
|
|
775
|
+
emitCard(cb.thinkingBlock(block.thinking));
|
|
776
|
+
}
|
|
777
|
+
else if (block.type === 'redacted_thinking') {
|
|
778
|
+
emitCard(cb.thinkingBlock('[Redacted thinking]'));
|
|
779
|
+
}
|
|
780
|
+
else if (block.type === 'text' && block.text) {
|
|
781
|
+
emitCard(cb.assistantText(block.text));
|
|
782
|
+
}
|
|
783
|
+
else if (block.type === 'tool_use') {
|
|
784
|
+
// Agent tool calls are represented by SubagentCards (via task_started).
|
|
785
|
+
if (block.name !== 'Agent') {
|
|
786
|
+
emitCard(cb.toolUse(block.name, block.input ?? {}, block.id));
|
|
787
|
+
}
|
|
788
|
+
}
|
|
789
|
+
else if (block.type === 'server_tool_use' || block.type === 'mcp_tool_use') {
|
|
790
|
+
emitCard(cb.toolUse(block.name ?? block.type, block.input ?? {}, block.id ?? ''));
|
|
791
|
+
}
|
|
725
792
|
}
|
|
793
|
+
continue;
|
|
794
|
+
}
|
|
795
|
+
// User messages contain tool results — skip sidechain messages.
|
|
796
|
+
if (message.type === 'user') {
|
|
797
|
+
if (message.agentId)
|
|
798
|
+
continue; // sidechain message — ignore
|
|
799
|
+
const userMsg = message.message;
|
|
800
|
+
if (userMsg?.content && Array.isArray(userMsg.content)) {
|
|
801
|
+
for (const block of userMsg.content) {
|
|
802
|
+
if (block.type === 'tool_result') {
|
|
803
|
+
const resultContent = extractToolResultText(block.content);
|
|
804
|
+
const cardEvt = cb.toolResult(block.tool_use_id, resultContent, !!block.is_error);
|
|
805
|
+
if (cardEvt)
|
|
806
|
+
emitCard(cardEvt);
|
|
807
|
+
}
|
|
808
|
+
// Handle server/MCP tool results in streaming
|
|
809
|
+
if (block.type === 'web_search_tool_result' || block.type === 'web_fetch_tool_result' ||
|
|
810
|
+
block.type === 'mcp_tool_result' || block.type === 'code_execution_tool_result' ||
|
|
811
|
+
block.type === 'tool_search_tool_result') {
|
|
812
|
+
const resultContent = extractToolResultText(block.content ?? block.text ?? '');
|
|
813
|
+
const parentId = block.tool_use_id;
|
|
814
|
+
if (parentId) {
|
|
815
|
+
const cardEvt = cb.toolResult(parentId, resultContent, !!block.is_error);
|
|
816
|
+
if (cardEvt)
|
|
817
|
+
emitCard(cardEvt);
|
|
818
|
+
}
|
|
819
|
+
}
|
|
820
|
+
}
|
|
821
|
+
}
|
|
822
|
+
continue;
|
|
823
|
+
}
|
|
824
|
+
// ── Result — exit this turn's stream, loop will check queue ──
|
|
825
|
+
if (message.type === 'result') {
|
|
826
|
+
if (message.session_id !== sessionId) {
|
|
827
|
+
console.log(`[stream] skip subagent result session=${message.session_id?.slice(0, 8)}`);
|
|
828
|
+
continue;
|
|
829
|
+
}
|
|
830
|
+
flushText();
|
|
831
|
+
const result = message;
|
|
832
|
+
console.log(`[stream] result session=${sessionId} subtype=${result.subtype} cost=$${result.total_cost_usd?.toFixed(4) ?? '?'}`);
|
|
833
|
+
const streamEnd = {
|
|
834
|
+
streamId,
|
|
835
|
+
sessionId,
|
|
836
|
+
success: result.subtype === 'success',
|
|
837
|
+
error: result.subtype !== 'success'
|
|
838
|
+
? (result.errors?.join('; ') || `Session ended: ${result.subtype}`)
|
|
839
|
+
: undefined,
|
|
840
|
+
totalCostUsd: result.total_cost_usd,
|
|
841
|
+
tokenUsage: result.usage
|
|
842
|
+
? { input: result.usage.input_tokens, output: result.usage.output_tokens }
|
|
843
|
+
: undefined,
|
|
844
|
+
};
|
|
845
|
+
this.emit('card-stream-end', streamEnd);
|
|
846
|
+
turnDone = true; // exit this turn's stream without closing generator
|
|
726
847
|
}
|
|
727
848
|
}
|
|
728
|
-
continue;
|
|
729
849
|
}
|
|
730
|
-
|
|
731
|
-
if (message.type === 'result') {
|
|
850
|
+
catch (error) {
|
|
732
851
|
flushText();
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
this.emit('stream:end', {
|
|
737
|
-
sessionId: capturedSessionId ?? '', streamId,
|
|
738
|
-
success: result.subtype === 'success',
|
|
739
|
-
error: result.subtype !== 'success'
|
|
740
|
-
? (result.errors?.join('; ') || `Session ended: ${result.subtype}`)
|
|
741
|
-
: undefined,
|
|
742
|
-
totalCostUsd: result.total_cost_usd,
|
|
743
|
-
tokenUsage: result.usage
|
|
744
|
-
? { input: result.usage.input_tokens, output: result.usage.output_tokens }
|
|
745
|
-
: undefined,
|
|
746
|
-
});
|
|
747
|
-
return;
|
|
852
|
+
console.error(`[stream] error session=${sessionId}:`, error);
|
|
853
|
+
const msg = error instanceof Error ? error.message : 'Unknown error';
|
|
854
|
+
this.emit('card-stream-end', { streamId, sessionId, success: false, error: msg });
|
|
748
855
|
}
|
|
856
|
+
// Turn done — finalize text, keep cardBuilder (accumulates across turns)
|
|
857
|
+
const finalizeEvent = cb.finalizeAssistantText();
|
|
858
|
+
if (finalizeEvent)
|
|
859
|
+
emitCard(finalizeEvent);
|
|
860
|
+
ps.streaming = false;
|
|
861
|
+
this.emitSessionUpdate(sessionId);
|
|
749
862
|
}
|
|
750
|
-
flushText();
|
|
751
|
-
console.log(`[stream] ended without result session=${capturedSessionId} (cancel or unexpected)`);
|
|
752
|
-
markStreamingDone();
|
|
753
|
-
this.emit('stream:end', { sessionId: capturedSessionId ?? '', streamId, success: true });
|
|
754
863
|
}
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
console.error(`[stream] error session=${capturedSessionId}:`, error);
|
|
758
|
-
markStreamingDone();
|
|
759
|
-
const msg = error instanceof Error ? error.message : 'Unknown error';
|
|
760
|
-
this.emit('stream:end', { sessionId: capturedSessionId ?? '', streamId, success: false, error: msg });
|
|
864
|
+
finally {
|
|
865
|
+
ps._loopRunning = false;
|
|
761
866
|
}
|
|
762
867
|
}
|
|
763
868
|
}
|