@sumicom/quicksave 0.6.0 → 0.6.1

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 (71) hide show
  1. package/dist/ai/asyncQueue.d.ts +7 -6
  2. package/dist/ai/asyncQueue.d.ts.map +1 -1
  3. package/dist/ai/asyncQueue.js +31 -21
  4. package/dist/ai/asyncQueue.js.map +1 -1
  5. package/dist/ai/asyncQueue.test.js +18 -18
  6. package/dist/ai/asyncQueue.test.js.map +1 -1
  7. package/dist/ai/cardBuilder.d.ts +11 -2
  8. package/dist/ai/cardBuilder.d.ts.map +1 -1
  9. package/dist/ai/cardBuilder.js +113 -21
  10. package/dist/ai/cardBuilder.js.map +1 -1
  11. package/dist/ai/claudeCliProvider.d.ts +19 -0
  12. package/dist/ai/claudeCliProvider.d.ts.map +1 -0
  13. package/dist/ai/claudeCliProvider.js +466 -0
  14. package/dist/ai/claudeCliProvider.js.map +1 -0
  15. package/dist/ai/claudeSdkProvider.d.ts +18 -0
  16. package/dist/ai/claudeSdkProvider.d.ts.map +1 -0
  17. package/dist/ai/claudeSdkProvider.js +418 -0
  18. package/dist/ai/claudeSdkProvider.js.map +1 -0
  19. package/dist/ai/commitSummary.d.ts +4 -0
  20. package/dist/ai/commitSummary.d.ts.map +1 -1
  21. package/dist/ai/commitSummary.js +80 -40
  22. package/dist/ai/commitSummary.js.map +1 -1
  23. package/dist/ai/provider.d.ts +54 -0
  24. package/dist/ai/provider.d.ts.map +1 -0
  25. package/dist/ai/provider.js +2 -0
  26. package/dist/ai/provider.js.map +1 -0
  27. package/dist/ai/sandboxMcp.d.ts +12 -0
  28. package/dist/ai/sandboxMcp.d.ts.map +1 -0
  29. package/dist/ai/sandboxMcp.js +12 -0
  30. package/dist/ai/sandboxMcp.js.map +1 -0
  31. package/dist/ai/sandboxMcpStdio.d.ts +16 -0
  32. package/dist/ai/sandboxMcpStdio.d.ts.map +1 -0
  33. package/dist/ai/sandboxMcpStdio.js +127 -0
  34. package/dist/ai/sandboxMcpStdio.js.map +1 -0
  35. package/dist/ai/sessionManager.d.ts +100 -0
  36. package/dist/ai/sessionManager.d.ts.map +1 -0
  37. package/dist/ai/sessionManager.js +595 -0
  38. package/dist/ai/sessionManager.js.map +1 -0
  39. package/dist/ai/sessionRegistry.d.ts +28 -0
  40. package/dist/ai/sessionRegistry.d.ts.map +1 -0
  41. package/dist/ai/sessionRegistry.js +148 -0
  42. package/dist/ai/sessionRegistry.js.map +1 -0
  43. package/dist/git/operations.d.ts +6 -1
  44. package/dist/git/operations.d.ts.map +1 -1
  45. package/dist/git/operations.js +31 -2
  46. package/dist/git/operations.js.map +1 -1
  47. package/dist/handlers/messageHandler.d.ts +8 -3
  48. package/dist/handlers/messageHandler.d.ts.map +1 -1
  49. package/dist/handlers/messageHandler.js +96 -12
  50. package/dist/handlers/messageHandler.js.map +1 -1
  51. package/dist/handlers/messageHandler.test.js +1 -1
  52. package/dist/index.js +3 -40
  53. package/dist/index.js.map +1 -1
  54. package/dist/service/ensureDaemon.d.ts.map +1 -1
  55. package/dist/service/ensureDaemon.js +15 -2
  56. package/dist/service/ensureDaemon.js.map +1 -1
  57. package/dist/service/ipc.test.js +6 -6
  58. package/dist/service/ipcClient.js +1 -1
  59. package/dist/service/run.d.ts.map +1 -1
  60. package/dist/service/run.js +22 -15
  61. package/dist/service/run.js.map +1 -1
  62. package/dist/service/singleton.d.ts +4 -0
  63. package/dist/service/singleton.d.ts.map +1 -1
  64. package/dist/service/singleton.js +28 -2
  65. package/dist/service/singleton.js.map +1 -1
  66. package/dist/service/singleton.test.js +1 -1
  67. package/package.json +5 -3
  68. package/dist/ai/claudeCodeService.d.ts +0 -122
  69. package/dist/ai/claudeCodeService.d.ts.map +0 -1
  70. package/dist/ai/claudeCodeService.js +0 -869
  71. package/dist/ai/claudeCodeService.js.map +0 -1
@@ -1,869 +0,0 @@
1
- import { EventEmitter } from 'events';
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';
6
- /** Tools auto-approved at each permission level (no user prompt).
7
- * Read/Glob/Grep are always auto-approved at SDK level (allowedTools).
8
- * Tools NOT listed here go through canUseTool → permission prompt.
9
- *
10
- * Risk tiers:
11
- * - Safe: Edit, Write, NotebookEdit, TodoWrite, Agent, EnterWorktree, ExitWorktree
12
- * - Network: WebFetch, WebSearch
13
- * - Execute: Bash
14
- * - Code/Control: Skill, ToolSearch, Config
15
- * - Schedule: CronCreate, CronDelete (arbitrary scheduled execution)
16
- * - Remote: RemoteTrigger (triggers remote agents)
17
- * - Workflow: EnterPlanMode, ExitPlanMode (ExitPlanMode has its own interactive UI)
18
- */
19
- const AUTO_APPROVE = {
20
- bypassPermissions: new Set([
21
- // Safe
22
- 'Edit', 'Write', 'NotebookEdit', 'TodoWrite', 'Agent', 'EnterWorktree', 'ExitWorktree',
23
- // Network
24
- 'WebFetch', 'WebSearch',
25
- // Execute
26
- 'Bash',
27
- // Code/Control
28
- 'Skill', 'ToolSearch', 'Config',
29
- // Schedule + Remote
30
- 'CronCreate', 'CronDelete', 'CronList', 'RemoteTrigger',
31
- // Workflow
32
- 'EnterPlanMode', 'ExitPlanMode',
33
- // Background tasks
34
- 'TaskOutput', 'TaskStop',
35
- ]),
36
- acceptEdits: new Set(['Edit', 'Write', 'NotebookEdit', 'TodoWrite', 'Agent', 'EnterWorktree', 'ExitWorktree']),
37
- default: new Set(['TodoWrite', 'EnterWorktree', 'ExitWorktree', 'Agent']),
38
- plan: new Set(),
39
- };
40
- function createDeferred() {
41
- let resolve;
42
- const promise = new Promise((r) => { resolve = r; });
43
- return { promise, resolve };
44
- }
45
- /** Extract readable text from tool_result content (which may be a string or array of blocks). */
46
- function extractToolResultText(content) {
47
- if (typeof content === 'string')
48
- return content;
49
- if (Array.isArray(content)) {
50
- return content
51
- .filter((b) => b.type === 'text')
52
- .map((b) => b.text || '')
53
- .join('\n');
54
- }
55
- return JSON.stringify(content);
56
- }
57
- export class ClaudeCodeService extends EventEmitter {
58
- sessions = new Map();
59
- sessionPermissions = new Map(); // persists across active/inactive
60
- sessionConfigs = new Map(); // generic per-session config
61
- pendingInputRequests = new Map();
62
- requestCounter = 0;
63
- preferences = {
64
- model: DEFAULT_MODEL,
65
- };
66
- constructor() {
67
- super();
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
- }
138
- /** Build and emit a session-updated event with current state. */
139
- emitSessionUpdate(sessionId) {
140
- const ps = this.sessions.get(sessionId);
141
- const hasPendingInput = Array.from(this.pendingInputRequests.values())
142
- .some((p) => p.request.sessionId === sessionId);
143
- this.emit('session-updated', {
144
- sessionId,
145
- isActive: !!ps,
146
- isStreaming: ps?.streaming ?? false,
147
- hasPendingInput,
148
- permissionMode: ps?.permissionLevel,
149
- });
150
- }
151
- async listAvailableSessions(cwd) {
152
- const sessions = await listSessions({ dir: cwd, limit: 50 });
153
- // Enrich with live state + detect pending from JSONL
154
- const pendingSessionIds = new Set(Array.from(this.pendingInputRequests.values()).map((p) => p.request.sessionId));
155
- // Check JSONL for sessions not in memory (cold pending detection)
156
- const enriched = await Promise.all(sessions.map(async (s) => {
157
- const isActive = this.sessions.has(s.sessionId);
158
- const isStreaming = this.sessions.get(s.sessionId)?.streaming ?? false;
159
- let hasPendingInput = pendingSessionIds.has(s.sessionId);
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) {
164
- hasPendingInput = await this.detectPendingFromJSONL(s.sessionId, cwd);
165
- }
166
- return {
167
- sessionId: s.sessionId,
168
- summary: s.summary,
169
- lastModified: s.lastModified,
170
- createdAt: s.createdAt,
171
- cwd: s.cwd,
172
- gitBranch: s.gitBranch,
173
- isActive,
174
- isStreaming,
175
- hasPendingInput,
176
- permissionMode: this.sessions.get(s.sessionId)?.permissionLevel ?? this.sessionPermissions.get(s.sessionId),
177
- };
178
- }));
179
- // Sort: pending first, then active, then by lastModified
180
- enriched.sort((a, b) => {
181
- if (a.hasPendingInput !== b.hasPendingInput)
182
- return a.hasPendingInput ? -1 : 1;
183
- if (a.isActive !== b.isActive)
184
- return a.isActive ? -1 : 1;
185
- return b.lastModified - a.lastModified;
186
- });
187
- return enriched;
188
- }
189
- /**
190
- * Check if a session's last message is an unanswered tool_use.
191
- * Uses SDK getSessionMessages + listSubagents/getSubagentMessages.
192
- */
193
- async detectPendingFromJSONL(sessionId, cwd) {
194
- const hasPendingToolUse = (msgs, label = 'parent') => {
195
- if (msgs.length === 0)
196
- return false;
197
- const last = msgs[msgs.length - 1];
198
- if (last.type !== 'assistant')
199
- return false;
200
- const content = last.message?.content;
201
- if (!Array.isArray(content))
202
- return false;
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;
240
- }
241
- catch {
242
- return false;
243
- }
244
- }
245
- /**
246
- * Create a V2 session with the given cwd.
247
- * V2 SDKSessionOptions doesn't expose `cwd`, so we temporarily change
248
- * process.cwd() around the synchronous createSession() call.
249
- */
250
- createSessionWithCwd(cwd, sessionId, opts) {
251
- const originalCwd = process.cwd();
252
- try {
253
- process.chdir(cwd);
254
- // allowedTools = SDK auto-approve list (bypasses canUseTool entirely).
255
- // Only list tools that should NEVER prompt at ANY permission level.
256
- // Everything else goes through permissionMode → canUseTool for dynamic control.
257
- const sessionOpts = {
258
- model: opts.model ?? DEFAULT_MODEL,
259
- allowedTools: opts.allowedTools ?? ['Read', 'Glob', 'Grep'],
260
- permissionMode: 'default',
261
- includePartialMessages: true,
262
- settingSources: ['user', 'project', 'local'],
263
- canUseTool: async (toolName, input, options) => {
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';
271
- // Check runtime permission level — auto-approve if tool is in the allow set
272
- const ps = resolvedSessionId !== 'unknown' ? this.sessions.get(resolvedSessionId) : undefined;
273
- const level = ps?.permissionLevel ?? this.sessionPermissions.get(resolvedSessionId) ?? 'acceptEdits';
274
- if (AUTO_APPROVE[level].has(toolName)) {
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
- }
297
- }
298
- const requestId = `perm-${++this.requestCounter}`;
299
- // AskUserQuestion: forward as question type with options
300
- const isQuestion = toolName === 'AskUserQuestion';
301
- const questions = isQuestion ? input.questions : undefined;
302
- // Forward request to PWA
303
- const request = {
304
- sessionId: resolvedSessionId,
305
- requestId,
306
- inputType: isQuestion ? 'question' : 'permission',
307
- title: isQuestion
308
- ? questions?.[0]?.question ?? 'Question from Claude'
309
- : (options.title ?? `Allow ${toolName}?`),
310
- message: isQuestion
311
- ? undefined
312
- : (options.description ?? JSON.stringify(input).slice(0, 500)),
313
- toolName,
314
- toolInput: input,
315
- toolUseId: options.toolUseID,
316
- ...(options.agentID ? { agentId: options.agentID } : {}),
317
- // Include structured options for question type
318
- ...(isQuestion && questions ? {
319
- options: questions.flatMap((q) => (q.options ?? []).map((opt) => ({
320
- key: opt.label,
321
- label: opt.label,
322
- description: opt.description,
323
- }))),
324
- } : {}),
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
- }
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);
347
- this.emitSessionUpdate(resolvedSessionId);
348
- // Wait for explicit user response (no timeout — user must act)
349
- const response = await responsePromise;
350
- if (response.action === 'deny') {
351
- return { behavior: 'deny', message: 'User denied permission' };
352
- }
353
- // For AskUserQuestion, inject user's answer into the tool input
354
- if (isQuestion && response.response) {
355
- const answers = {};
356
- if (questions?.[0]?.question) {
357
- answers[questions[0].question] = response.response;
358
- }
359
- return {
360
- behavior: 'allow',
361
- updatedInput: { ...input, answers },
362
- };
363
- }
364
- return { behavior: 'allow', updatedInput: input };
365
- },
366
- };
367
- if (opts.resumeSessionId) {
368
- return unstable_v2_resumeSession(opts.resumeSessionId, sessionOpts);
369
- }
370
- return unstable_v2_createSession(sessionOpts);
371
- }
372
- finally {
373
- process.chdir(originalCwd);
374
- }
375
- }
376
- waitForUserInput(requestId, request, signal) {
377
- return new Promise((resolve) => {
378
- this.pendingInputRequests.set(requestId, { resolve, request });
379
- // Only auto-resolve if the SDK itself aborts (e.g. session closed)
380
- signal?.addEventListener('abort', () => {
381
- this.pendingInputRequests.delete(requestId);
382
- resolve({ sessionId: '', requestId, action: 'allow' });
383
- }, { once: true });
384
- });
385
- }
386
- /**
387
- * Called by message handler when PWA sends a user input response.
388
- */
389
- resolveUserInput(response) {
390
- const pending = this.pendingInputRequests.get(response.requestId);
391
- if (!pending)
392
- return false;
393
- this.pendingInputRequests.delete(response.requestId);
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
- }
402
- this.emit('user-input-resolved', { requestId: response.requestId, sessionId: pending.request.sessionId });
403
- this.emitSessionUpdate(pending.request.sessionId);
404
- return true;
405
- }
406
- /**
407
- * Get all pending user input requests (for re-sending to a reconnected client).
408
- */
409
- getPendingInputRequests() {
410
- return Array.from(this.pendingInputRequests.values()).map((p) => p.request);
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
- }
423
- async startSession(opts) {
424
- const validModes = ['default', 'acceptEdits', 'bypassPermissions', 'plan'];
425
- const level = validModes.includes(opts.permissionMode)
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 };
430
- const session = this.createSessionWithCwd(opts.cwd, null, {
431
- allowedTools: opts.allowedTools,
432
- model: opts.model,
433
- permissionMode: opts.permissionMode,
434
- sessionIdRef,
435
- });
436
- const prompt = opts.systemPrompt
437
- ? `[System context: ${opts.systemPrompt}]\n\n${opts.prompt}`
438
- : opts.prompt;
439
- await session.send(prompt);
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);
455
- this.sessions.set(id, {
456
- session,
457
- sessionId: id,
458
- sessionIdRef,
459
- cwd: opts.cwd,
460
- streaming: true,
461
- permissionLevel: level,
462
- cardBuilder: null,
463
- _promptQueue: [],
464
- _cancelled: false,
465
- _loopRunning: false,
466
- _streamGenerator: streamGen,
467
- });
468
- this.emitSessionUpdate(id);
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);
477
- return sessionId;
478
- }
479
- async resumeSession(opts) {
480
- const existing = this.sessions.get(opts.sessionId);
481
- if (existing) {
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
- }
489
- return opts.sessionId;
490
- }
491
- // Cold resume: create new session with resumeSessionId
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';
497
- const session = this.createSessionWithCwd(opts.cwd, opts.sessionId, {
498
- resumeSessionId: opts.sessionId,
499
- sessionIdRef,
500
- });
501
- await session.send(opts.prompt);
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;
512
- if (id !== opts.sessionId) {
513
- console.warn(`[v2] SDK created new session ${id} instead of resuming ${opts.sessionId}`);
514
- }
515
- sessionIdRef.current = id;
516
- this.sessionPermissions.set(id, restoredLevel);
517
- this.sessions.set(id, {
518
- session,
519
- sessionId: id,
520
- sessionIdRef,
521
- cwd: opts.cwd,
522
- streaming: true,
523
- permissionLevel: restoredLevel,
524
- cardBuilder: null,
525
- _promptQueue: [],
526
- _cancelled: false,
527
- _loopRunning: false,
528
- _streamGenerator: streamGen,
529
- });
530
- this.emitSessionUpdate(id);
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);
539
- return actualSessionId;
540
- }
541
- cancelSession(sessionId) {
542
- const ps = this.sessions.get(sessionId);
543
- if (!ps)
544
- return false;
545
- ps._cancelled = true;
546
- ps._promptQueue.length = 0; // drain queue
547
- ps.streaming = false;
548
- return true;
549
- }
550
- setPermissionLevel(sessionId, level) {
551
- const ps = this.sessions.get(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
- });
565
- return true;
566
- }
567
- getPermissionLevel(sessionId) {
568
- return this.sessions.get(sessionId)?.permissionLevel ?? 'acceptEdits';
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
- }
580
- closeSession(sessionId) {
581
- const ps = this.sessions.get(sessionId);
582
- if (!ps)
583
- return false;
584
- ps._cancelled = true;
585
- ps._promptQueue.length = 0;
586
- ps.session.close();
587
- this.sessions.delete(sessionId);
588
- this.emitSessionUpdate(sessionId);
589
- return true;
590
- }
591
- isStreaming(sessionId) {
592
- return this.sessions.get(sessionId)?.streaming ?? false;
593
- }
594
- isOpen(sessionId) {
595
- return this.sessions.has(sessionId);
596
- }
597
- getActiveSessionCount() {
598
- return this.sessions.size;
599
- }
600
- cleanup() {
601
- // Force-resolve any pending inputs on daemon shutdown
602
- for (const [requestId, pending] of this.pendingInputRequests) {
603
- pending.resolve({ sessionId: '', requestId, action: 'allow' });
604
- }
605
- this.pendingInputRequests.clear();
606
- for (const [, ps] of this.sessions) {
607
- try {
608
- ps.session.close();
609
- }
610
- catch {
611
- // Ignore errors during cleanup
612
- }
613
- }
614
- this.sessions.clear();
615
- }
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
- });
637
- }
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;
645
- }
646
- }
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
- }
666
- try {
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;
679
- }
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) {
697
- flushText();
698
- }
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();
706
- }
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;
713
- }
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
- }
760
- }
761
- else if (!event?.type?.includes('delta')) {
762
- console.log(`[stream] stream_event type=${event?.type} session=${sessionId.slice(0, 8)}`);
763
- }
764
- continue;
765
- }
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;
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
847
- }
848
- }
849
- }
850
- catch (error) {
851
- flushText();
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 });
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);
862
- }
863
- }
864
- finally {
865
- ps._loopRunning = false;
866
- }
867
- }
868
- }
869
- //# sourceMappingURL=claudeCodeService.js.map