@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.
Files changed (50) 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 +65 -32
  14. package/dist/ai/claudeCodeService.d.ts.map +1 -1
  15. package/dist/ai/claudeCodeService.js +534 -267
  16. package/dist/ai/claudeCodeService.js.map +1 -1
  17. package/dist/connection/connection.d.ts +24 -1
  18. package/dist/connection/connection.d.ts.map +1 -1
  19. package/dist/connection/connection.js +92 -6
  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/connection/relay.d.ts +4 -0
  30. package/dist/connection/relay.d.ts.map +1 -1
  31. package/dist/connection/relay.js +30 -0
  32. package/dist/connection/relay.js.map +1 -1
  33. package/dist/handlers/messageHandler.d.ts +22 -2
  34. package/dist/handlers/messageHandler.d.ts.map +1 -1
  35. package/dist/handlers/messageHandler.js +207 -16
  36. package/dist/handlers/messageHandler.js.map +1 -1
  37. package/dist/handlers/messageHandler.test.js +1 -1
  38. package/dist/index.js +192 -4
  39. package/dist/index.js.map +1 -1
  40. package/dist/service/ipc.test.js +6 -6
  41. package/dist/service/ipcClient.js +1 -1
  42. package/dist/service/run.d.ts.map +1 -1
  43. package/dist/service/run.js +125 -13
  44. package/dist/service/run.js.map +1 -1
  45. package/dist/service/singleton.test.js +1 -1
  46. package/dist/service/types.d.ts +29 -0
  47. package/dist/service/types.d.ts.map +1 -1
  48. package/dist/service/types.js +11 -0
  49. package/dist/service/types.js.map +1 -1
  50. package/package.json +2 -2
@@ -1,7 +1,8 @@
1
1
  import { EventEmitter } from 'events';
2
- import { unstable_v2_createSession, unstable_v2_resumeSession, listSessions, getSessionMessages, } from '@anthropic-ai/claude-agent-sdk';
3
- const TOOL_RESULT_TRUNCATE_LENGTH = 500;
4
- 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';
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
- // If not already known as pending from memory, check JSONL tail
80
- 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) {
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 in the SDK JSONL is an unanswered tool_use.
107
- * 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.
108
192
  */
109
193
  async detectPendingFromJSONL(sessionId, cwd) {
110
- try {
111
- const allMessages = await getSessionMessages(sessionId, { dir: cwd });
112
- if (allMessages.length === 0)
194
+ const hasPendingToolUse = (msgs, label = 'parent') => {
195
+ if (msgs.length === 0)
113
196
  return false;
114
- const last = allMessages[allMessages.length - 1];
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
- 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;
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
- 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';
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
- 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
+ }
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 this.waitForUserInput(requestId, request, options.signal);
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
- this.emit('stream', {
314
- sessionId: '', streamId: opts.streamId,
315
- eventType: 'user_message',
316
- content: opts.prompt,
317
- });
318
- const sessionId = await new Promise((resolve) => {
319
- 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);
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
- resolve(id);
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
- console.log(`[v2] hot resume session=${opts.sessionId}`);
338
- existing.streaming = true;
339
- this.emitSessionUpdate(opts.sessionId);
340
- this.emit('stream', {
341
- sessionId: opts.sessionId, streamId: opts.streamId,
342
- eventType: 'user_message',
343
- content: opts.prompt,
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
- const actualSessionId = await new Promise((resolve) => {
362
- 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;
363
512
  if (id !== opts.sessionId) {
364
- this.emit('stream', {
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
- cancelStreaming: null,
376
- permissionLevel: 'acceptEdits',
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
- resolve(id);
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
- // Stop streaming but keep the session process alive
389
- if (ps.cancelStreaming) {
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 (!ps)
398
- return false;
399
- ps.permissionLevel = level;
400
- this.emitSessionUpdate(sessionId);
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
- // Stop streaming if active
411
- if (ps.cancelStreaming) {
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
- async consumeStream(session, streamId, onSessionId) {
446
- let textBuffer = '';
447
- let bufferTimer = null;
448
- let capturedSessionId = null;
449
- let cancelled = false;
450
- const emitStream = (event) => {
451
- this.emit('stream', { ...event, sessionId: capturedSessionId ?? '', streamId });
452
- };
453
- const flushText = () => {
454
- if (textBuffer) {
455
- emitStream({ eventType: 'assistant_text', content: textBuffer });
456
- textBuffer = '';
457
- }
458
- if (bufferTimer) {
459
- clearTimeout(bufferTimer);
460
- bufferTimer = null;
461
- }
462
- };
463
- const markStreamingDone = () => {
464
- if (capturedSessionId) {
465
- const ps = this.sessions.get(capturedSessionId);
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
- // Wire up cancel function for this streaming turn
483
- const setCancelFn = () => {
484
- if (capturedSessionId) {
485
- const ps = this.sessions.get(capturedSessionId);
486
- if (ps) {
487
- 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;
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
- for await (const message of session.stream()) {
493
- if (cancelled)
494
- break;
495
- // Capture session ID from init message
496
- if (message.type === 'system' && message.subtype === 'init') {
497
- capturedSessionId = message.session_id;
498
- console.log(`[stream] init session=${message.session_id}`);
499
- onSessionId(message.session_id);
500
- setCancelFn();
501
- 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;
502
679
  }
503
- // Session state changed 'idle' means turn is over
504
- if (message.type === 'system' && message.subtype === 'session_state_changed') {
505
- const state = message.state;
506
- 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) {
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
- 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();
512
706
  }
513
- // Streaming partial events
514
- if (message.type === 'stream_event') {
515
- const event = message.event;
516
- if (event?.type === 'content_block_delta') {
517
- const delta = event.delta;
518
- if (delta?.type === 'text_delta' && delta.text) {
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
- // Complete assistant messages
525
- if (message.type === 'assistant') {
526
- flushText();
527
- const betaMessage = message.message;
528
- if (betaMessage?.content) {
529
- for (const block of betaMessage.content) {
530
- if (block.type === 'text' && block.text) {
531
- emitStream({
532
- eventType: 'assistant_text',
533
- content: block.text,
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 (block.type === 'tool_use') {
537
- const toolInput = JSON.stringify(block.input);
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
- continue;
548
- }
549
- // User messages contain tool results
550
- if (message.type === 'user') {
551
- const userMsg = message.message;
552
- if (userMsg?.content && Array.isArray(userMsg.content)) {
553
- for (const block of userMsg.content) {
554
- if (block.type === 'tool_result') {
555
- const resultContent = extractToolResultText(block.content);
556
- const truncated = resultContent.length > TOOL_RESULT_TRUNCATE_LENGTH
557
- ? resultContent.slice(0, TOOL_RESULT_TRUNCATE_LENGTH) + ' [truncated]'
558
- : resultContent;
559
- emitStream({
560
- eventType: 'tool_result',
561
- content: truncated,
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
- // Final result — turn is complete but session stays alive
569
- if (message.type === 'result') {
850
+ catch (error) {
570
851
  flushText();
571
- const result = message;
572
- console.log(`[stream] result session=${capturedSessionId} subtype=${result.subtype} cost=$${result.total_cost_usd?.toFixed(4) ?? '?'}`);
573
- markStreamingDone();
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
- catch (error) {
594
- flushText();
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
  }