@sumicom/quicksave 0.5.1 → 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 +65 -32
- package/dist/ai/claudeCodeService.d.ts.map +1 -1
- package/dist/ai/claudeCodeService.js +534 -267
- package/dist/ai/claudeCodeService.js.map +1 -1
- package/dist/connection/connection.d.ts +24 -1
- package/dist/connection/connection.d.ts.map +1 -1
- package/dist/connection/connection.js +92 -6
- 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/connection/relay.d.ts +4 -0
- package/dist/connection/relay.d.ts.map +1 -1
- package/dist/connection/relay.js +30 -0
- package/dist/connection/relay.js.map +1 -1
- package/dist/handlers/messageHandler.d.ts +22 -2
- package/dist/handlers/messageHandler.d.ts.map +1 -1
- package/dist/handlers/messageHandler.js +207 -16
- package/dist/handlers/messageHandler.js.map +1 -1
- package/dist/handlers/messageHandler.test.js +1 -1
- package/dist/index.js +192 -4
- 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 +125 -13
- 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,7 +1,8 @@
|
|
|
1
1
|
import { EventEmitter } from 'events';
|
|
2
|
-
import {
|
|
3
|
-
|
|
4
|
-
|
|
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';
|
|
5
6
|
/** Tools auto-approved at each permission level (no user prompt).
|
|
6
7
|
* Read/Glob/Grep are always auto-approved at SDK level (allowedTools).
|
|
7
8
|
* Tools NOT listed here go through canUseTool → permission prompt.
|
|
@@ -36,6 +37,11 @@ const AUTO_APPROVE = {
|
|
|
36
37
|
default: new Set(['TodoWrite', 'EnterWorktree', 'ExitWorktree', 'Agent']),
|
|
37
38
|
plan: new Set(),
|
|
38
39
|
};
|
|
40
|
+
function createDeferred() {
|
|
41
|
+
let resolve;
|
|
42
|
+
const promise = new Promise((r) => { resolve = r; });
|
|
43
|
+
return { promise, resolve };
|
|
44
|
+
}
|
|
39
45
|
/** Extract readable text from tool_result content (which may be a string or array of blocks). */
|
|
40
46
|
function extractToolResultText(content) {
|
|
41
47
|
if (typeof content === 'string')
|
|
@@ -50,11 +56,85 @@ function extractToolResultText(content) {
|
|
|
50
56
|
}
|
|
51
57
|
export class ClaudeCodeService extends EventEmitter {
|
|
52
58
|
sessions = new Map();
|
|
59
|
+
sessionPermissions = new Map(); // persists across active/inactive
|
|
60
|
+
sessionConfigs = new Map(); // generic per-session config
|
|
53
61
|
pendingInputRequests = new Map();
|
|
54
62
|
requestCounter = 0;
|
|
63
|
+
preferences = {
|
|
64
|
+
model: DEFAULT_MODEL,
|
|
65
|
+
};
|
|
55
66
|
constructor() {
|
|
56
67
|
super();
|
|
57
68
|
}
|
|
69
|
+
/**
|
|
70
|
+
* Initialize preferences from the most recently used session.
|
|
71
|
+
* Uses SDK listSessions + getSessionMessages to read the last assistant message's model field.
|
|
72
|
+
*/
|
|
73
|
+
async initPreferences() {
|
|
74
|
+
try {
|
|
75
|
+
const sessions = await listSessions();
|
|
76
|
+
if (sessions.length === 0)
|
|
77
|
+
return;
|
|
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') {
|
|
86
|
+
this.preferences.model = model;
|
|
87
|
+
break;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
catch { /* use defaults */ }
|
|
92
|
+
}
|
|
93
|
+
getPreferences() {
|
|
94
|
+
return { ...this.preferences };
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* Update preferences. Returns the applied preferences (may differ if validation fails).
|
|
98
|
+
* Emits 'preferences-updated' if any value actually changed.
|
|
99
|
+
*/
|
|
100
|
+
setPreferences(prefs) {
|
|
101
|
+
const next = { ...this.preferences };
|
|
102
|
+
let changed = false;
|
|
103
|
+
if (prefs.model !== undefined && prefs.model !== this.preferences.model) {
|
|
104
|
+
next.model = prefs.model;
|
|
105
|
+
changed = true;
|
|
106
|
+
}
|
|
107
|
+
if (changed) {
|
|
108
|
+
this.preferences = next;
|
|
109
|
+
this.emit('preferences-updated', this.preferences);
|
|
110
|
+
}
|
|
111
|
+
return { ...this.preferences };
|
|
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
|
+
}
|
|
58
138
|
/** Build and emit a session-updated event with current state. */
|
|
59
139
|
emitSessionUpdate(sessionId) {
|
|
60
140
|
const ps = this.sessions.get(sessionId);
|
|
@@ -65,6 +145,7 @@ export class ClaudeCodeService extends EventEmitter {
|
|
|
65
145
|
isActive: !!ps,
|
|
66
146
|
isStreaming: ps?.streaming ?? false,
|
|
67
147
|
hasPendingInput,
|
|
148
|
+
permissionMode: ps?.permissionLevel,
|
|
68
149
|
});
|
|
69
150
|
}
|
|
70
151
|
async listAvailableSessions(cwd) {
|
|
@@ -76,8 +157,10 @@ export class ClaudeCodeService extends EventEmitter {
|
|
|
76
157
|
const isActive = this.sessions.has(s.sessionId);
|
|
77
158
|
const isStreaming = this.sessions.get(s.sessionId)?.streaming ?? false;
|
|
78
159
|
let hasPendingInput = pendingSessionIds.has(s.sessionId);
|
|
79
|
-
//
|
|
80
|
-
|
|
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) {
|
|
81
164
|
hasPendingInput = await this.detectPendingFromJSONL(s.sessionId, cwd);
|
|
82
165
|
}
|
|
83
166
|
return {
|
|
@@ -90,6 +173,7 @@ export class ClaudeCodeService extends EventEmitter {
|
|
|
90
173
|
isActive,
|
|
91
174
|
isStreaming,
|
|
92
175
|
hasPendingInput,
|
|
176
|
+
permissionMode: this.sessions.get(s.sessionId)?.permissionLevel ?? this.sessionPermissions.get(s.sessionId),
|
|
93
177
|
};
|
|
94
178
|
}));
|
|
95
179
|
// Sort: pending first, then active, then by lastModified
|
|
@@ -103,90 +187,61 @@ export class ClaudeCodeService extends EventEmitter {
|
|
|
103
187
|
return enriched;
|
|
104
188
|
}
|
|
105
189
|
/**
|
|
106
|
-
* Check if a session's last message
|
|
107
|
-
*
|
|
190
|
+
* Check if a session's last message is an unanswered tool_use.
|
|
191
|
+
* Uses SDK getSessionMessages + listSubagents/getSubagentMessages.
|
|
108
192
|
*/
|
|
109
193
|
async detectPendingFromJSONL(sessionId, cwd) {
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
if (allMessages.length === 0)
|
|
194
|
+
const hasPendingToolUse = (msgs, label = 'parent') => {
|
|
195
|
+
if (msgs.length === 0)
|
|
113
196
|
return false;
|
|
114
|
-
const last =
|
|
115
|
-
// Last message is assistant with a tool_use block and no following user/tool_result
|
|
197
|
+
const last = msgs[msgs.length - 1];
|
|
116
198
|
if (last.type !== 'assistant')
|
|
117
199
|
return false;
|
|
118
200
|
const content = last.message?.content;
|
|
119
201
|
if (!Array.isArray(content))
|
|
120
202
|
return false;
|
|
121
|
-
|
|
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;
|
|
122
240
|
}
|
|
123
241
|
catch {
|
|
124
242
|
return false;
|
|
125
243
|
}
|
|
126
244
|
}
|
|
127
|
-
async getMessages(sessionId, cwd, offset = 0, limit = 50) {
|
|
128
|
-
// Read directly from SDK JSONL — the single source of truth.
|
|
129
|
-
// SDK writes messages immediately and retains full, un-truncated data.
|
|
130
|
-
// Messages are chronological (old→new). We paginate from the tail:
|
|
131
|
-
// offset=0 → last `limit` messages (most recent)
|
|
132
|
-
// offset=50 → the `limit` messages before those, etc.
|
|
133
|
-
const allMessages = await getSessionMessages(sessionId, { dir: cwd });
|
|
134
|
-
const total = allMessages.length;
|
|
135
|
-
const tailStart = Math.max(0, total - offset - limit);
|
|
136
|
-
const tailEnd = Math.max(0, total - offset);
|
|
137
|
-
const sliced = allMessages.slice(tailStart, tailEnd);
|
|
138
|
-
const messages = sliced.map((msg, i) => {
|
|
139
|
-
const role = msg.type;
|
|
140
|
-
let content = '';
|
|
141
|
-
let toolName;
|
|
142
|
-
let toolInput;
|
|
143
|
-
let toolResult;
|
|
144
|
-
let truncated = false;
|
|
145
|
-
const rawMessage = msg.message;
|
|
146
|
-
if (rawMessage?.content) {
|
|
147
|
-
if (typeof rawMessage.content === 'string') {
|
|
148
|
-
content = rawMessage.content;
|
|
149
|
-
}
|
|
150
|
-
else if (Array.isArray(rawMessage.content)) {
|
|
151
|
-
const parts = [];
|
|
152
|
-
for (const block of rawMessage.content) {
|
|
153
|
-
if (block.type === 'text') {
|
|
154
|
-
parts.push(block.text);
|
|
155
|
-
}
|
|
156
|
-
else if (block.type === 'tool_use') {
|
|
157
|
-
toolName = block.name;
|
|
158
|
-
toolInput = JSON.stringify(block.input);
|
|
159
|
-
}
|
|
160
|
-
else if (block.type === 'tool_result') {
|
|
161
|
-
const resultStr = extractToolResultText(block.content);
|
|
162
|
-
if (resultStr.length > TOOL_RESULT_TRUNCATE_LENGTH) {
|
|
163
|
-
toolResult = resultStr.slice(0, TOOL_RESULT_TRUNCATE_LENGTH) + ' [truncated]';
|
|
164
|
-
truncated = true;
|
|
165
|
-
}
|
|
166
|
-
else {
|
|
167
|
-
toolResult = resultStr;
|
|
168
|
-
}
|
|
169
|
-
}
|
|
170
|
-
}
|
|
171
|
-
content = parts.join('\n');
|
|
172
|
-
}
|
|
173
|
-
}
|
|
174
|
-
return {
|
|
175
|
-
index: tailStart + i,
|
|
176
|
-
role,
|
|
177
|
-
content,
|
|
178
|
-
toolName,
|
|
179
|
-
toolInput,
|
|
180
|
-
toolResult,
|
|
181
|
-
truncated,
|
|
182
|
-
};
|
|
183
|
-
});
|
|
184
|
-
return {
|
|
185
|
-
messages,
|
|
186
|
-
total,
|
|
187
|
-
hasMore: tailStart > 0,
|
|
188
|
-
};
|
|
189
|
-
}
|
|
190
245
|
/**
|
|
191
246
|
* Create a V2 session with the given cwd.
|
|
192
247
|
* V2 SDKSessionOptions doesn't expose `cwd`, so we temporarily change
|
|
@@ -203,14 +258,42 @@ export class ClaudeCodeService extends EventEmitter {
|
|
|
203
258
|
model: opts.model ?? DEFAULT_MODEL,
|
|
204
259
|
allowedTools: opts.allowedTools ?? ['Read', 'Glob', 'Grep'],
|
|
205
260
|
permissionMode: 'default',
|
|
261
|
+
includePartialMessages: true,
|
|
206
262
|
settingSources: ['user', 'project', 'local'],
|
|
207
263
|
canUseTool: async (toolName, input, options) => {
|
|
208
|
-
|
|
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';
|
|
209
271
|
// Check runtime permission level — auto-approve if tool is in the allow set
|
|
210
272
|
const ps = resolvedSessionId !== 'unknown' ? this.sessions.get(resolvedSessionId) : undefined;
|
|
211
|
-
const level = ps?.permissionLevel ?? 'acceptEdits';
|
|
273
|
+
const level = ps?.permissionLevel ?? this.sessionPermissions.get(resolvedSessionId) ?? 'acceptEdits';
|
|
212
274
|
if (AUTO_APPROVE[level].has(toolName)) {
|
|
213
|
-
|
|
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
|
+
}
|
|
214
297
|
}
|
|
215
298
|
const requestId = `perm-${++this.requestCounter}`;
|
|
216
299
|
// AskUserQuestion: forward as question type with options
|
|
@@ -229,6 +312,8 @@ export class ClaudeCodeService extends EventEmitter {
|
|
|
229
312
|
: (options.description ?? JSON.stringify(input).slice(0, 500)),
|
|
230
313
|
toolName,
|
|
231
314
|
toolInput: input,
|
|
315
|
+
toolUseId: options.toolUseID,
|
|
316
|
+
...(options.agentID ? { agentId: options.agentID } : {}),
|
|
232
317
|
// Include structured options for question type
|
|
233
318
|
...(isQuestion && questions ? {
|
|
234
319
|
options: questions.flatMap((q) => (q.options ?? []).map((opt) => ({
|
|
@@ -238,10 +323,30 @@ export class ClaudeCodeService extends EventEmitter {
|
|
|
238
323
|
}))),
|
|
239
324
|
} : {}),
|
|
240
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
|
+
}
|
|
241
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);
|
|
242
347
|
this.emitSessionUpdate(resolvedSessionId);
|
|
243
348
|
// Wait for explicit user response (no timeout — user must act)
|
|
244
|
-
const response = await
|
|
349
|
+
const response = await responsePromise;
|
|
245
350
|
if (response.action === 'deny') {
|
|
246
351
|
return { behavior: 'deny', message: 'User denied permission' };
|
|
247
352
|
}
|
|
@@ -287,6 +392,13 @@ export class ClaudeCodeService extends EventEmitter {
|
|
|
287
392
|
return false;
|
|
288
393
|
this.pendingInputRequests.delete(response.requestId);
|
|
289
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
|
+
}
|
|
290
402
|
this.emit('user-input-resolved', { requestId: response.requestId, sessionId: pending.request.sessionId });
|
|
291
403
|
this.emitSessionUpdate(pending.request.sessionId);
|
|
292
404
|
return true;
|
|
@@ -297,121 +409,180 @@ export class ClaudeCodeService extends EventEmitter {
|
|
|
297
409
|
getPendingInputRequests() {
|
|
298
410
|
return Array.from(this.pendingInputRequests.values()).map((p) => p.request);
|
|
299
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
|
+
}
|
|
300
423
|
async startSession(opts) {
|
|
301
424
|
const validModes = ['default', 'acceptEdits', 'bypassPermissions', 'plan'];
|
|
302
425
|
const level = validModes.includes(opts.permissionMode)
|
|
303
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 };
|
|
304
430
|
const session = this.createSessionWithCwd(opts.cwd, null, {
|
|
305
431
|
allowedTools: opts.allowedTools,
|
|
306
432
|
model: opts.model,
|
|
307
433
|
permissionMode: opts.permissionMode,
|
|
434
|
+
sessionIdRef,
|
|
308
435
|
});
|
|
309
436
|
const prompt = opts.systemPrompt
|
|
310
437
|
? `[System context: ${opts.systemPrompt}]\n\n${opts.prompt}`
|
|
311
438
|
: opts.prompt;
|
|
312
439
|
await session.send(prompt);
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
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);
|
|
320
455
|
this.sessions.set(id, {
|
|
321
456
|
session,
|
|
322
457
|
sessionId: id,
|
|
458
|
+
sessionIdRef,
|
|
323
459
|
cwd: opts.cwd,
|
|
324
460
|
streaming: true,
|
|
325
|
-
cancelStreaming: null,
|
|
326
461
|
permissionLevel: level,
|
|
462
|
+
cardBuilder: null,
|
|
463
|
+
_promptQueue: [],
|
|
464
|
+
_cancelled: false,
|
|
465
|
+
_loopRunning: false,
|
|
466
|
+
_streamGenerator: streamGen,
|
|
327
467
|
});
|
|
328
468
|
this.emitSessionUpdate(id);
|
|
329
|
-
|
|
330
|
-
}
|
|
331
|
-
}
|
|
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);
|
|
332
477
|
return sessionId;
|
|
333
478
|
}
|
|
334
479
|
async resumeSession(opts) {
|
|
335
480
|
const existing = this.sessions.get(opts.sessionId);
|
|
336
481
|
if (existing) {
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
});
|
|
345
|
-
await existing.session.send(opts.prompt);
|
|
346
|
-
this.consumeStream(existing.session, opts.streamId, () => {
|
|
347
|
-
// Session ID already captured
|
|
348
|
-
});
|
|
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
|
+
}
|
|
349
489
|
return opts.sessionId;
|
|
350
490
|
}
|
|
491
|
+
// Cold resume: create new session with resumeSessionId
|
|
351
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';
|
|
352
497
|
const session = this.createSessionWithCwd(opts.cwd, opts.sessionId, {
|
|
353
498
|
resumeSessionId: opts.sessionId,
|
|
354
|
-
|
|
355
|
-
this.emit('stream', {
|
|
356
|
-
sessionId: opts.sessionId, streamId: opts.streamId,
|
|
357
|
-
eventType: 'user_message',
|
|
358
|
-
content: opts.prompt,
|
|
499
|
+
sessionIdRef,
|
|
359
500
|
});
|
|
360
501
|
await session.send(opts.prompt);
|
|
361
|
-
|
|
362
|
-
|
|
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;
|
|
363
512
|
if (id !== opts.sessionId) {
|
|
364
|
-
|
|
365
|
-
sessionId: id, streamId: opts.streamId,
|
|
366
|
-
eventType: 'system',
|
|
367
|
-
content: `Warning: SDK created new session ${id} instead of resuming ${opts.sessionId}`,
|
|
368
|
-
});
|
|
513
|
+
console.warn(`[v2] SDK created new session ${id} instead of resuming ${opts.sessionId}`);
|
|
369
514
|
}
|
|
515
|
+
sessionIdRef.current = id;
|
|
516
|
+
this.sessionPermissions.set(id, restoredLevel);
|
|
370
517
|
this.sessions.set(id, {
|
|
371
518
|
session,
|
|
372
519
|
sessionId: id,
|
|
520
|
+
sessionIdRef,
|
|
373
521
|
cwd: opts.cwd,
|
|
374
522
|
streaming: true,
|
|
375
|
-
|
|
376
|
-
|
|
523
|
+
permissionLevel: restoredLevel,
|
|
524
|
+
cardBuilder: null,
|
|
525
|
+
_promptQueue: [],
|
|
526
|
+
_cancelled: false,
|
|
527
|
+
_loopRunning: false,
|
|
528
|
+
_streamGenerator: streamGen,
|
|
377
529
|
});
|
|
378
530
|
this.emitSessionUpdate(id);
|
|
379
|
-
|
|
380
|
-
}
|
|
381
|
-
}
|
|
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);
|
|
382
539
|
return actualSessionId;
|
|
383
540
|
}
|
|
384
541
|
cancelSession(sessionId) {
|
|
385
542
|
const ps = this.sessions.get(sessionId);
|
|
386
543
|
if (!ps)
|
|
387
544
|
return false;
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
ps.cancelStreaming();
|
|
391
|
-
}
|
|
545
|
+
ps._cancelled = true;
|
|
546
|
+
ps._promptQueue.length = 0; // drain queue
|
|
392
547
|
ps.streaming = false;
|
|
393
548
|
return true;
|
|
394
549
|
}
|
|
395
550
|
setPermissionLevel(sessionId, level) {
|
|
396
551
|
const ps = this.sessions.get(sessionId);
|
|
397
|
-
if (
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
552
|
+
if (ps) {
|
|
553
|
+
ps.permissionLevel = level;
|
|
554
|
+
}
|
|
555
|
+
// Persist across active/inactive lifecycle
|
|
556
|
+
this.sessionPermissions.set(sessionId, level);
|
|
557
|
+
// Always broadcast so all clients sync, even for inactive sessions
|
|
558
|
+
this.emit('session-updated', {
|
|
559
|
+
sessionId,
|
|
560
|
+
isActive: !!ps,
|
|
561
|
+
isStreaming: ps?.streaming ?? false,
|
|
562
|
+
hasPendingInput: ps ? Array.from(this.pendingInputRequests.values()).some((p) => p.request.sessionId === sessionId) : false,
|
|
563
|
+
permissionMode: level,
|
|
564
|
+
});
|
|
401
565
|
return true;
|
|
402
566
|
}
|
|
403
567
|
getPermissionLevel(sessionId) {
|
|
404
568
|
return this.sessions.get(sessionId)?.permissionLevel ?? 'acceptEdits';
|
|
405
569
|
}
|
|
570
|
+
getActiveSessions() {
|
|
571
|
+
const pendingSessionIds = new Set(Array.from(this.pendingInputRequests.values()).map((p) => p.request.sessionId));
|
|
572
|
+
return Array.from(this.sessions.entries()).map(([id, ps]) => ({
|
|
573
|
+
sessionId: id,
|
|
574
|
+
cwd: ps.cwd,
|
|
575
|
+
isStreaming: ps.streaming,
|
|
576
|
+
hasPendingInput: pendingSessionIds.has(id),
|
|
577
|
+
permissionMode: ps.permissionLevel,
|
|
578
|
+
}));
|
|
579
|
+
}
|
|
406
580
|
closeSession(sessionId) {
|
|
407
581
|
const ps = this.sessions.get(sessionId);
|
|
408
582
|
if (!ps)
|
|
409
583
|
return false;
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
ps.cancelStreaming();
|
|
413
|
-
}
|
|
414
|
-
// Terminate the subprocess
|
|
584
|
+
ps._cancelled = true;
|
|
585
|
+
ps._promptQueue.length = 0;
|
|
415
586
|
ps.session.close();
|
|
416
587
|
this.sessions.delete(sessionId);
|
|
417
588
|
this.emitSessionUpdate(sessionId);
|
|
@@ -442,160 +613,256 @@ export class ClaudeCodeService extends EventEmitter {
|
|
|
442
613
|
}
|
|
443
614
|
this.sessions.clear();
|
|
444
615
|
}
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
const
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
if (ps) {
|
|
467
|
-
ps.streaming = false;
|
|
468
|
-
ps.cancelStreaming = null;
|
|
469
|
-
}
|
|
470
|
-
this.emitSessionUpdate(capturedSessionId);
|
|
471
|
-
}
|
|
472
|
-
};
|
|
473
|
-
const bufferText = (text) => {
|
|
474
|
-
textBuffer += text;
|
|
475
|
-
if (!bufferTimer) {
|
|
476
|
-
bufferTimer = setTimeout(flushText, 150);
|
|
477
|
-
}
|
|
478
|
-
if (textBuffer.length > 2048) {
|
|
479
|
-
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
|
+
});
|
|
480
637
|
}
|
|
481
|
-
}
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
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;
|
|
488
645
|
}
|
|
489
646
|
}
|
|
490
|
-
}
|
|
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
|
+
}
|
|
491
666
|
try {
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
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;
|
|
502
679
|
}
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
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) {
|
|
507
697
|
flushText();
|
|
508
|
-
console.log(`[stream] session_state_changed=idle session=${capturedSessionId}`);
|
|
509
|
-
// Don't call onEnd here — the result message will follow
|
|
510
698
|
}
|
|
511
|
-
|
|
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();
|
|
512
706
|
}
|
|
513
|
-
//
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
bufferText(delta.text);
|
|
520
|
-
}
|
|
521
|
-
}
|
|
522
|
-
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;
|
|
523
713
|
}
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
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
|
+
}
|
|
535
760
|
}
|
|
536
|
-
else if (
|
|
537
|
-
|
|
538
|
-
emitStream({
|
|
539
|
-
eventType: 'tool_use',
|
|
540
|
-
content: '',
|
|
541
|
-
toolName: block.name,
|
|
542
|
-
toolInput,
|
|
543
|
-
});
|
|
761
|
+
else if (!event?.type?.includes('delta')) {
|
|
762
|
+
console.log(`[stream] stream_event type=${event?.type} session=${sessionId.slice(0, 8)}`);
|
|
544
763
|
}
|
|
764
|
+
continue;
|
|
545
765
|
}
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
}
|
|
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
|
+
}
|
|
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;
|
|
563
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
|
|
564
847
|
}
|
|
565
848
|
}
|
|
566
|
-
continue;
|
|
567
849
|
}
|
|
568
|
-
|
|
569
|
-
if (message.type === 'result') {
|
|
850
|
+
catch (error) {
|
|
570
851
|
flushText();
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
this.emit('stream:end', {
|
|
575
|
-
sessionId: capturedSessionId ?? '', streamId,
|
|
576
|
-
success: result.subtype === 'success',
|
|
577
|
-
error: result.subtype !== 'success'
|
|
578
|
-
? (result.errors?.join('; ') || `Session ended: ${result.subtype}`)
|
|
579
|
-
: undefined,
|
|
580
|
-
totalCostUsd: result.total_cost_usd,
|
|
581
|
-
tokenUsage: result.usage
|
|
582
|
-
? { input: result.usage.input_tokens, output: result.usage.output_tokens }
|
|
583
|
-
: undefined,
|
|
584
|
-
});
|
|
585
|
-
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 });
|
|
586
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);
|
|
587
862
|
}
|
|
588
|
-
flushText();
|
|
589
|
-
console.log(`[stream] ended without result session=${capturedSessionId} (cancel or unexpected)`);
|
|
590
|
-
markStreamingDone();
|
|
591
|
-
this.emit('stream:end', { sessionId: capturedSessionId ?? '', streamId, success: true });
|
|
592
863
|
}
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
console.error(`[stream] error session=${capturedSessionId}:`, error);
|
|
596
|
-
markStreamingDone();
|
|
597
|
-
const msg = error instanceof Error ? error.message : 'Unknown error';
|
|
598
|
-
this.emit('stream:end', { sessionId: capturedSessionId ?? '', streamId, success: false, error: msg });
|
|
864
|
+
finally {
|
|
865
|
+
ps._loopRunning = false;
|
|
599
866
|
}
|
|
600
867
|
}
|
|
601
868
|
}
|