@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.
Files changed (46) hide show
  1. package/dist/ai/asyncQueue.d.ts +16 -0
  2. package/dist/ai/asyncQueue.d.ts.map +1 -0
  3. package/dist/ai/asyncQueue.js +39 -0
  4. package/dist/ai/asyncQueue.js.map +1 -0
  5. package/dist/ai/asyncQueue.test.d.ts +2 -0
  6. package/dist/ai/asyncQueue.test.d.ts.map +1 -0
  7. package/dist/ai/asyncQueue.test.js +56 -0
  8. package/dist/ai/asyncQueue.test.js.map +1 -0
  9. package/dist/ai/cardBuilder.d.ts +64 -0
  10. package/dist/ai/cardBuilder.d.ts.map +1 -0
  11. package/dist/ai/cardBuilder.js +503 -0
  12. package/dist/ai/cardBuilder.js.map +1 -0
  13. package/dist/ai/claudeCodeService.d.ts +47 -36
  14. package/dist/ai/claudeCodeService.d.ts.map +1 -1
  15. package/dist/ai/claudeCodeService.js +472 -367
  16. package/dist/ai/claudeCodeService.js.map +1 -1
  17. package/dist/connection/connection.d.ts +24 -6
  18. package/dist/connection/connection.d.ts.map +1 -1
  19. package/dist/connection/connection.js +85 -13
  20. package/dist/connection/connection.js.map +1 -1
  21. package/dist/connection/pubsub.d.ts +41 -0
  22. package/dist/connection/pubsub.d.ts.map +1 -0
  23. package/dist/connection/pubsub.js +101 -0
  24. package/dist/connection/pubsub.js.map +1 -0
  25. package/dist/connection/pubsub.test.d.ts +2 -0
  26. package/dist/connection/pubsub.test.d.ts.map +1 -0
  27. package/dist/connection/pubsub.test.js +96 -0
  28. package/dist/connection/pubsub.test.js.map +1 -0
  29. package/dist/handlers/messageHandler.d.ts +17 -2
  30. package/dist/handlers/messageHandler.d.ts.map +1 -1
  31. package/dist/handlers/messageHandler.js +171 -18
  32. package/dist/handlers/messageHandler.js.map +1 -1
  33. package/dist/handlers/messageHandler.test.js +1 -1
  34. package/dist/index.js +191 -3
  35. package/dist/index.js.map +1 -1
  36. package/dist/service/ipc.test.js +6 -6
  37. package/dist/service/ipcClient.js +1 -1
  38. package/dist/service/run.d.ts.map +1 -1
  39. package/dist/service/run.js +114 -12
  40. package/dist/service/run.js.map +1 -1
  41. package/dist/service/singleton.test.js +1 -1
  42. package/dist/service/types.d.ts +29 -0
  43. package/dist/service/types.d.ts.map +1 -1
  44. package/dist/service/types.js +11 -0
  45. package/dist/service/types.js.map +1 -1
  46. package/package.json +2 -2
@@ -1,12 +1,8 @@
1
1
  import { EventEmitter } from 'events';
2
- import { createReadStream } from 'fs';
3
- import { readdir, stat } from 'fs/promises';
4
- import { createInterface } from 'readline';
5
- import { join } from 'path';
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's JSONL.
111
- * Reads the last assistant message's model field.
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 claudeDir = join(homedir(), '.claude', 'projects');
116
- const projectDirs = await readdir(claudeDir);
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
- const assistantEntries = await new Promise((resolve, reject) => {
139
- const results = [];
140
- const rl = createInterface({ input: createReadStream(mostRecentFile), crlfDelay: Infinity });
141
- rl.on('line', (line) => {
142
- if (!line.trim())
143
- return;
144
- try {
145
- const obj = JSON.parse(line);
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
- // If not already known as pending from memory, check JSONL tail
207
- if (!hasPendingInput) {
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 in the SDK JSONL is an unanswered tool_use.
235
- * Reads only the last few messages to keep it fast.
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
- try {
239
- const allMessages = READ_THROUGH_COMPACT_BOUNDARY
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 = allMessages[allMessages.length - 1];
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
- return content.some((block) => block.type === 'tool_use');
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
- const resolvedSessionId = sessionId ?? 'unknown';
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
- return { behavior: 'allow', updatedInput: input };
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 this.waitForUserInput(requestId, request, options.signal);
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
- this.emit('stream', {
452
- sessionId: '', streamId: opts.streamId,
453
- eventType: 'user_message',
454
- content: opts.prompt,
455
- });
456
- const sessionId = await new Promise((resolve) => {
457
- this.consumeStream(session, opts.streamId, (id) => {
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
- resolve(id);
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
- console.log(`[v2] hot resume session=${opts.sessionId}`);
477
- existing.streaming = true;
478
- this.emitSessionUpdate(opts.sessionId);
479
- this.emit('stream', {
480
- sessionId: opts.sessionId, streamId: opts.streamId,
481
- eventType: 'user_message',
482
- content: opts.prompt,
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
- const actualSessionId = await new Promise((resolve) => {
501
- this.consumeStream(session, opts.streamId, (id) => {
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
- this.emit('stream', {
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
- const restoredLevel = this.sessionPermissions.get(opts.sessionId) ?? 'acceptEdits';
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
- resolve(id);
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
- // Stop streaming but keep the session process alive
530
- if (ps.cancelStreaming) {
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
- // Stop streaming if active
571
- if (ps.cancelStreaming) {
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
- async consumeStream(session, streamId, onSessionId) {
606
- let textBuffer = '';
607
- let bufferTimer = null;
608
- let capturedSessionId = null;
609
- let cancelled = false;
610
- const emitStream = (event) => {
611
- this.emit('stream', { ...event, sessionId: capturedSessionId ?? '', streamId });
612
- };
613
- const flushText = () => {
614
- if (textBuffer) {
615
- emitStream({ eventType: 'assistant_text', content: textBuffer });
616
- textBuffer = '';
617
- }
618
- if (bufferTimer) {
619
- clearTimeout(bufferTimer);
620
- bufferTimer = null;
621
- }
622
- };
623
- const markStreamingDone = () => {
624
- if (capturedSessionId) {
625
- const ps = this.sessions.get(capturedSessionId);
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
- // Wire up cancel function for this streaming turn
643
- const setCancelFn = () => {
644
- if (capturedSessionId) {
645
- const ps = this.sessions.get(capturedSessionId);
646
- if (ps) {
647
- ps.cancelStreaming = () => { cancelled = true; };
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
- for await (const message of session.stream()) {
653
- if (cancelled)
654
- break;
655
- // Capture session ID from init message
656
- if (message.type === 'system' && message.subtype === 'init') {
657
- capturedSessionId = message.session_id;
658
- console.log(`[stream] init session=${message.session_id}`);
659
- onSessionId(message.session_id);
660
- setCancelFn();
661
- continue;
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
- // Session state changed 'idle' means turn is over
664
- if (message.type === 'system' && message.subtype === 'session_state_changed') {
665
- const state = message.state;
666
- if (state === 'idle') {
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
- continue;
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
- // Streaming partial events
674
- if (message.type === 'stream_event') {
675
- const event = message.event;
676
- if (event?.type === 'content_block_delta') {
677
- const delta = event.delta;
678
- if (delta?.type === 'text_delta' && delta.text) {
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
- // Complete assistant messages
685
- if (message.type === 'assistant') {
686
- flushText();
687
- const betaMessage = message.message;
688
- if (betaMessage?.content) {
689
- for (const block of betaMessage.content) {
690
- if (block.type === 'text' && block.text) {
691
- emitStream({
692
- eventType: 'assistant_text',
693
- content: block.text,
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 (block.type === 'tool_use') {
697
- const toolInput = JSON.stringify(block.input);
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
- continue;
709
- }
710
- // User messages contain tool results
711
- if (message.type === 'user') {
712
- const userMsg = message.message;
713
- if (userMsg?.content && Array.isArray(userMsg.content)) {
714
- for (const block of userMsg.content) {
715
- if (block.type === 'tool_result') {
716
- const resultContent = extractToolResultText(block.content);
717
- const truncated = resultContent.length > TOOL_RESULT_TRUNCATE_LENGTH
718
- ? resultContent.slice(0, TOOL_RESULT_TRUNCATE_LENGTH) + ' [truncated]'
719
- : resultContent;
720
- emitStream({
721
- eventType: 'tool_result',
722
- content: truncated,
723
- toolResultForId: block.tool_use_id,
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
- // Final result — turn is complete but session stays alive
731
- if (message.type === 'result') {
850
+ catch (error) {
732
851
  flushText();
733
- const result = message;
734
- console.log(`[stream] result session=${capturedSessionId} subtype=${result.subtype} cost=$${result.total_cost_usd?.toFixed(4) ?? '?'}`);
735
- markStreamingDone();
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
- catch (error) {
756
- flushText();
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
  }