@sumicom/quicksave 0.5.2 → 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 (89) hide show
  1. package/dist/ai/asyncQueue.d.ts +17 -0
  2. package/dist/ai/asyncQueue.d.ts.map +1 -0
  3. package/dist/ai/asyncQueue.js +49 -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 +73 -0
  10. package/dist/ai/cardBuilder.d.ts.map +1 -0
  11. package/dist/ai/cardBuilder.js +595 -0
  12. package/dist/ai/cardBuilder.js.map +1 -0
  13. package/dist/ai/claudeCliProvider.d.ts +19 -0
  14. package/dist/ai/claudeCliProvider.d.ts.map +1 -0
  15. package/dist/ai/claudeCliProvider.js +466 -0
  16. package/dist/ai/claudeCliProvider.js.map +1 -0
  17. package/dist/ai/claudeSdkProvider.d.ts +18 -0
  18. package/dist/ai/claudeSdkProvider.d.ts.map +1 -0
  19. package/dist/ai/claudeSdkProvider.js +418 -0
  20. package/dist/ai/claudeSdkProvider.js.map +1 -0
  21. package/dist/ai/commitSummary.d.ts +4 -0
  22. package/dist/ai/commitSummary.d.ts.map +1 -1
  23. package/dist/ai/commitSummary.js +80 -40
  24. package/dist/ai/commitSummary.js.map +1 -1
  25. package/dist/ai/provider.d.ts +54 -0
  26. package/dist/ai/provider.d.ts.map +1 -0
  27. package/dist/ai/provider.js +2 -0
  28. package/dist/ai/provider.js.map +1 -0
  29. package/dist/ai/sandboxMcp.d.ts +12 -0
  30. package/dist/ai/sandboxMcp.d.ts.map +1 -0
  31. package/dist/ai/sandboxMcp.js +12 -0
  32. package/dist/ai/sandboxMcp.js.map +1 -0
  33. package/dist/ai/sandboxMcpStdio.d.ts +16 -0
  34. package/dist/ai/sandboxMcpStdio.d.ts.map +1 -0
  35. package/dist/ai/sandboxMcpStdio.js +127 -0
  36. package/dist/ai/sandboxMcpStdio.js.map +1 -0
  37. package/dist/ai/sessionManager.d.ts +100 -0
  38. package/dist/ai/sessionManager.d.ts.map +1 -0
  39. package/dist/ai/sessionManager.js +595 -0
  40. package/dist/ai/sessionManager.js.map +1 -0
  41. package/dist/ai/sessionRegistry.d.ts +28 -0
  42. package/dist/ai/sessionRegistry.d.ts.map +1 -0
  43. package/dist/ai/sessionRegistry.js +148 -0
  44. package/dist/ai/sessionRegistry.js.map +1 -0
  45. package/dist/connection/connection.d.ts +24 -6
  46. package/dist/connection/connection.d.ts.map +1 -1
  47. package/dist/connection/connection.js +85 -13
  48. package/dist/connection/connection.js.map +1 -1
  49. package/dist/connection/pubsub.d.ts +41 -0
  50. package/dist/connection/pubsub.d.ts.map +1 -0
  51. package/dist/connection/pubsub.js +101 -0
  52. package/dist/connection/pubsub.js.map +1 -0
  53. package/dist/connection/pubsub.test.d.ts +2 -0
  54. package/dist/connection/pubsub.test.d.ts.map +1 -0
  55. package/dist/connection/pubsub.test.js +96 -0
  56. package/dist/connection/pubsub.test.js.map +1 -0
  57. package/dist/git/operations.d.ts +6 -1
  58. package/dist/git/operations.d.ts.map +1 -1
  59. package/dist/git/operations.js +31 -2
  60. package/dist/git/operations.js.map +1 -1
  61. package/dist/handlers/messageHandler.d.ts +25 -5
  62. package/dist/handlers/messageHandler.d.ts.map +1 -1
  63. package/dist/handlers/messageHandler.js +266 -29
  64. package/dist/handlers/messageHandler.js.map +1 -1
  65. package/dist/handlers/messageHandler.test.js +1 -1
  66. package/dist/index.js +192 -41
  67. package/dist/index.js.map +1 -1
  68. package/dist/service/ensureDaemon.d.ts.map +1 -1
  69. package/dist/service/ensureDaemon.js +15 -2
  70. package/dist/service/ensureDaemon.js.map +1 -1
  71. package/dist/service/ipc.test.js +6 -6
  72. package/dist/service/ipcClient.js +1 -1
  73. package/dist/service/run.d.ts.map +1 -1
  74. package/dist/service/run.js +120 -11
  75. package/dist/service/run.js.map +1 -1
  76. package/dist/service/singleton.d.ts +4 -0
  77. package/dist/service/singleton.d.ts.map +1 -1
  78. package/dist/service/singleton.js +28 -2
  79. package/dist/service/singleton.js.map +1 -1
  80. package/dist/service/singleton.test.js +1 -1
  81. package/dist/service/types.d.ts +29 -0
  82. package/dist/service/types.d.ts.map +1 -1
  83. package/dist/service/types.js +11 -0
  84. package/dist/service/types.js.map +1 -1
  85. package/package.json +5 -3
  86. package/dist/ai/claudeCodeService.d.ts +0 -111
  87. package/dist/ai/claudeCodeService.d.ts.map +0 -1
  88. package/dist/ai/claudeCodeService.js +0 -764
  89. package/dist/ai/claudeCodeService.js.map +0 -1
@@ -1,764 +0,0 @@
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';
10
- /** Tools auto-approved at each permission level (no user prompt).
11
- * Read/Glob/Grep are always auto-approved at SDK level (allowedTools).
12
- * Tools NOT listed here go through canUseTool → permission prompt.
13
- *
14
- * Risk tiers:
15
- * - Safe: Edit, Write, NotebookEdit, TodoWrite, Agent, EnterWorktree, ExitWorktree
16
- * - Network: WebFetch, WebSearch
17
- * - Execute: Bash
18
- * - Code/Control: Skill, ToolSearch, Config
19
- * - Schedule: CronCreate, CronDelete (arbitrary scheduled execution)
20
- * - Remote: RemoteTrigger (triggers remote agents)
21
- * - Workflow: EnterPlanMode, ExitPlanMode (ExitPlanMode has its own interactive UI)
22
- */
23
- const AUTO_APPROVE = {
24
- bypassPermissions: new Set([
25
- // Safe
26
- 'Edit', 'Write', 'NotebookEdit', 'TodoWrite', 'Agent', 'EnterWorktree', 'ExitWorktree',
27
- // Network
28
- 'WebFetch', 'WebSearch',
29
- // Execute
30
- 'Bash',
31
- // Code/Control
32
- 'Skill', 'ToolSearch', 'Config',
33
- // Schedule + Remote
34
- 'CronCreate', 'CronDelete', 'CronList', 'RemoteTrigger',
35
- // Workflow
36
- 'EnterPlanMode', 'ExitPlanMode',
37
- // Background tasks
38
- 'TaskOutput', 'TaskStop',
39
- ]),
40
- acceptEdits: new Set(['Edit', 'Write', 'NotebookEdit', 'TodoWrite', 'Agent', 'EnterWorktree', 'ExitWorktree']),
41
- default: new Set(['TodoWrite', 'EnterWorktree', 'ExitWorktree', 'Agent']),
42
- plan: new Set(),
43
- };
44
- /** Extract readable text from tool_result content (which may be a string or array of blocks). */
45
- function extractToolResultText(content) {
46
- if (typeof content === 'string')
47
- return content;
48
- if (Array.isArray(content)) {
49
- return content
50
- .filter((b) => b.type === 'text')
51
- .map((b) => b.text || '')
52
- .join('\n');
53
- }
54
- return JSON.stringify(content);
55
- }
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
- export class ClaudeCodeService extends EventEmitter {
99
- sessions = new Map();
100
- sessionPermissions = new Map(); // persists across active/inactive
101
- pendingInputRequests = new Map();
102
- requestCounter = 0;
103
- preferences = {
104
- model: DEFAULT_MODEL,
105
- };
106
- constructor() {
107
- super();
108
- }
109
- /**
110
- * Initialize preferences from the most recently used session's JSONL.
111
- * Reads the last assistant message's model field.
112
- */
113
- async initPreferences() {
114
- 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)
137
- 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) {
157
- this.preferences.model = model;
158
- break;
159
- }
160
- }
161
- }
162
- catch { /* use defaults */ }
163
- }
164
- getPreferences() {
165
- return { ...this.preferences };
166
- }
167
- /**
168
- * Update preferences. Returns the applied preferences (may differ if validation fails).
169
- * Emits 'preferences-updated' if any value actually changed.
170
- */
171
- setPreferences(prefs) {
172
- const next = { ...this.preferences };
173
- let changed = false;
174
- if (prefs.model !== undefined && prefs.model !== this.preferences.model) {
175
- next.model = prefs.model;
176
- changed = true;
177
- }
178
- if (changed) {
179
- this.preferences = next;
180
- this.emit('preferences-updated', this.preferences);
181
- }
182
- return { ...this.preferences };
183
- }
184
- /** Build and emit a session-updated event with current state. */
185
- emitSessionUpdate(sessionId) {
186
- const ps = this.sessions.get(sessionId);
187
- const hasPendingInput = Array.from(this.pendingInputRequests.values())
188
- .some((p) => p.request.sessionId === sessionId);
189
- this.emit('session-updated', {
190
- sessionId,
191
- isActive: !!ps,
192
- isStreaming: ps?.streaming ?? false,
193
- hasPendingInput,
194
- permissionMode: ps?.permissionLevel,
195
- });
196
- }
197
- async listAvailableSessions(cwd) {
198
- const sessions = await listSessions({ dir: cwd, limit: 50 });
199
- // Enrich with live state + detect pending from JSONL
200
- const pendingSessionIds = new Set(Array.from(this.pendingInputRequests.values()).map((p) => p.request.sessionId));
201
- // Check JSONL for sessions not in memory (cold pending detection)
202
- const enriched = await Promise.all(sessions.map(async (s) => {
203
- const isActive = this.sessions.has(s.sessionId);
204
- const isStreaming = this.sessions.get(s.sessionId)?.streaming ?? false;
205
- let hasPendingInput = pendingSessionIds.has(s.sessionId);
206
- // If not already known as pending from memory, check JSONL tail
207
- if (!hasPendingInput) {
208
- hasPendingInput = await this.detectPendingFromJSONL(s.sessionId, cwd);
209
- }
210
- return {
211
- sessionId: s.sessionId,
212
- summary: s.summary,
213
- lastModified: s.lastModified,
214
- createdAt: s.createdAt,
215
- cwd: s.cwd,
216
- gitBranch: s.gitBranch,
217
- isActive,
218
- isStreaming,
219
- hasPendingInput,
220
- permissionMode: this.sessions.get(s.sessionId)?.permissionLevel ?? this.sessionPermissions.get(s.sessionId),
221
- };
222
- }));
223
- // Sort: pending first, then active, then by lastModified
224
- enriched.sort((a, b) => {
225
- if (a.hasPendingInput !== b.hasPendingInput)
226
- return a.hasPendingInput ? -1 : 1;
227
- if (a.isActive !== b.isActive)
228
- return a.isActive ? -1 : 1;
229
- return b.lastModified - a.lastModified;
230
- });
231
- return enriched;
232
- }
233
- /**
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.
236
- */
237
- 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)
243
- 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
246
- if (last.type !== 'assistant')
247
- return false;
248
- const content = last.message?.content;
249
- if (!Array.isArray(content))
250
- return false;
251
- return content.some((block) => block.type === 'tool_use');
252
- }
253
- catch {
254
- return false;
255
- }
256
- }
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
- /**
329
- * Create a V2 session with the given cwd.
330
- * V2 SDKSessionOptions doesn't expose `cwd`, so we temporarily change
331
- * process.cwd() around the synchronous createSession() call.
332
- */
333
- createSessionWithCwd(cwd, sessionId, opts) {
334
- const originalCwd = process.cwd();
335
- try {
336
- process.chdir(cwd);
337
- // allowedTools = SDK auto-approve list (bypasses canUseTool entirely).
338
- // Only list tools that should NEVER prompt at ANY permission level.
339
- // Everything else goes through permissionMode → canUseTool for dynamic control.
340
- const sessionOpts = {
341
- model: opts.model ?? DEFAULT_MODEL,
342
- allowedTools: opts.allowedTools ?? ['Read', 'Glob', 'Grep'],
343
- permissionMode: 'default',
344
- settingSources: ['user', 'project', 'local'],
345
- canUseTool: async (toolName, input, options) => {
346
- const resolvedSessionId = sessionId ?? 'unknown';
347
- // Check runtime permission level — auto-approve if tool is in the allow set
348
- const ps = resolvedSessionId !== 'unknown' ? this.sessions.get(resolvedSessionId) : undefined;
349
- const level = ps?.permissionLevel ?? 'acceptEdits';
350
- if (AUTO_APPROVE[level].has(toolName)) {
351
- return { behavior: 'allow', updatedInput: input };
352
- }
353
- const requestId = `perm-${++this.requestCounter}`;
354
- // AskUserQuestion: forward as question type with options
355
- const isQuestion = toolName === 'AskUserQuestion';
356
- const questions = isQuestion ? input.questions : undefined;
357
- // Forward request to PWA
358
- const request = {
359
- sessionId: resolvedSessionId,
360
- requestId,
361
- inputType: isQuestion ? 'question' : 'permission',
362
- title: isQuestion
363
- ? questions?.[0]?.question ?? 'Question from Claude'
364
- : (options.title ?? `Allow ${toolName}?`),
365
- message: isQuestion
366
- ? undefined
367
- : (options.description ?? JSON.stringify(input).slice(0, 500)),
368
- toolName,
369
- toolInput: input,
370
- // Include structured options for question type
371
- ...(isQuestion && questions ? {
372
- options: questions.flatMap((q) => (q.options ?? []).map((opt) => ({
373
- key: opt.label,
374
- label: opt.label,
375
- description: opt.description,
376
- }))),
377
- } : {}),
378
- };
379
- this.emit('user-input-request', request);
380
- this.emitSessionUpdate(resolvedSessionId);
381
- // Wait for explicit user response (no timeout — user must act)
382
- const response = await this.waitForUserInput(requestId, request, options.signal);
383
- if (response.action === 'deny') {
384
- return { behavior: 'deny', message: 'User denied permission' };
385
- }
386
- // For AskUserQuestion, inject user's answer into the tool input
387
- if (isQuestion && response.response) {
388
- const answers = {};
389
- if (questions?.[0]?.question) {
390
- answers[questions[0].question] = response.response;
391
- }
392
- return {
393
- behavior: 'allow',
394
- updatedInput: { ...input, answers },
395
- };
396
- }
397
- return { behavior: 'allow', updatedInput: input };
398
- },
399
- };
400
- if (opts.resumeSessionId) {
401
- return unstable_v2_resumeSession(opts.resumeSessionId, sessionOpts);
402
- }
403
- return unstable_v2_createSession(sessionOpts);
404
- }
405
- finally {
406
- process.chdir(originalCwd);
407
- }
408
- }
409
- waitForUserInput(requestId, request, signal) {
410
- return new Promise((resolve) => {
411
- this.pendingInputRequests.set(requestId, { resolve, request });
412
- // Only auto-resolve if the SDK itself aborts (e.g. session closed)
413
- signal?.addEventListener('abort', () => {
414
- this.pendingInputRequests.delete(requestId);
415
- resolve({ sessionId: '', requestId, action: 'allow' });
416
- }, { once: true });
417
- });
418
- }
419
- /**
420
- * Called by message handler when PWA sends a user input response.
421
- */
422
- resolveUserInput(response) {
423
- const pending = this.pendingInputRequests.get(response.requestId);
424
- if (!pending)
425
- return false;
426
- this.pendingInputRequests.delete(response.requestId);
427
- pending.resolve(response);
428
- this.emit('user-input-resolved', { requestId: response.requestId, sessionId: pending.request.sessionId });
429
- this.emitSessionUpdate(pending.request.sessionId);
430
- return true;
431
- }
432
- /**
433
- * Get all pending user input requests (for re-sending to a reconnected client).
434
- */
435
- getPendingInputRequests() {
436
- return Array.from(this.pendingInputRequests.values()).map((p) => p.request);
437
- }
438
- async startSession(opts) {
439
- const validModes = ['default', 'acceptEdits', 'bypassPermissions', 'plan'];
440
- const level = validModes.includes(opts.permissionMode)
441
- ? opts.permissionMode : 'acceptEdits';
442
- const session = this.createSessionWithCwd(opts.cwd, null, {
443
- allowedTools: opts.allowedTools,
444
- model: opts.model,
445
- permissionMode: opts.permissionMode,
446
- });
447
- const prompt = opts.systemPrompt
448
- ? `[System context: ${opts.systemPrompt}]\n\n${opts.prompt}`
449
- : opts.prompt;
450
- 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) => {
458
- this.sessions.set(id, {
459
- session,
460
- sessionId: id,
461
- cwd: opts.cwd,
462
- streaming: true,
463
- cancelStreaming: null,
464
- permissionLevel: level,
465
- });
466
- this.sessionPermissions.set(id, level);
467
- this.emitSessionUpdate(id);
468
- resolve(id);
469
- });
470
- });
471
- return sessionId;
472
- }
473
- async resumeSession(opts) {
474
- const existing = this.sessions.get(opts.sessionId);
475
- 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
- });
488
- return opts.sessionId;
489
- }
490
- console.log(`[v2] cold resume session=${opts.sessionId}`);
491
- const session = this.createSessionWithCwd(opts.cwd, opts.sessionId, {
492
- resumeSessionId: opts.sessionId,
493
- });
494
- this.emit('stream', {
495
- sessionId: opts.sessionId, streamId: opts.streamId,
496
- eventType: 'user_message',
497
- content: opts.prompt,
498
- });
499
- await session.send(opts.prompt);
500
- const actualSessionId = await new Promise((resolve) => {
501
- this.consumeStream(session, opts.streamId, (id) => {
502
- 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
- });
508
- }
509
- const restoredLevel = this.sessionPermissions.get(opts.sessionId) ?? 'acceptEdits';
510
- this.sessions.set(id, {
511
- session,
512
- sessionId: id,
513
- cwd: opts.cwd,
514
- streaming: true,
515
- cancelStreaming: null,
516
- permissionLevel: restoredLevel,
517
- });
518
- this.sessionPermissions.set(id, restoredLevel);
519
- this.emitSessionUpdate(id);
520
- resolve(id);
521
- });
522
- });
523
- return actualSessionId;
524
- }
525
- cancelSession(sessionId) {
526
- const ps = this.sessions.get(sessionId);
527
- if (!ps)
528
- return false;
529
- // Stop streaming but keep the session process alive
530
- if (ps.cancelStreaming) {
531
- ps.cancelStreaming();
532
- }
533
- ps.streaming = false;
534
- return true;
535
- }
536
- setPermissionLevel(sessionId, level) {
537
- const ps = this.sessions.get(sessionId);
538
- if (ps) {
539
- ps.permissionLevel = level;
540
- }
541
- // Persist across active/inactive lifecycle
542
- this.sessionPermissions.set(sessionId, level);
543
- // Always broadcast so all clients sync, even for inactive sessions
544
- this.emit('session-updated', {
545
- sessionId,
546
- isActive: !!ps,
547
- isStreaming: ps?.streaming ?? false,
548
- hasPendingInput: ps ? Array.from(this.pendingInputRequests.values()).some((p) => p.request.sessionId === sessionId) : false,
549
- permissionMode: level,
550
- });
551
- return true;
552
- }
553
- getPermissionLevel(sessionId) {
554
- return this.sessions.get(sessionId)?.permissionLevel ?? 'acceptEdits';
555
- }
556
- getActiveSessions() {
557
- const pendingSessionIds = new Set(Array.from(this.pendingInputRequests.values()).map((p) => p.request.sessionId));
558
- return Array.from(this.sessions.entries()).map(([id, ps]) => ({
559
- sessionId: id,
560
- cwd: ps.cwd,
561
- isStreaming: ps.streaming,
562
- hasPendingInput: pendingSessionIds.has(id),
563
- permissionMode: ps.permissionLevel,
564
- }));
565
- }
566
- closeSession(sessionId) {
567
- const ps = this.sessions.get(sessionId);
568
- if (!ps)
569
- return false;
570
- // Stop streaming if active
571
- if (ps.cancelStreaming) {
572
- ps.cancelStreaming();
573
- }
574
- // Terminate the subprocess
575
- ps.session.close();
576
- this.sessions.delete(sessionId);
577
- this.emitSessionUpdate(sessionId);
578
- return true;
579
- }
580
- isStreaming(sessionId) {
581
- return this.sessions.get(sessionId)?.streaming ?? false;
582
- }
583
- isOpen(sessionId) {
584
- return this.sessions.has(sessionId);
585
- }
586
- getActiveSessionCount() {
587
- return this.sessions.size;
588
- }
589
- cleanup() {
590
- // Force-resolve any pending inputs on daemon shutdown
591
- for (const [requestId, pending] of this.pendingInputRequests) {
592
- pending.resolve({ sessionId: '', requestId, action: 'allow' });
593
- }
594
- this.pendingInputRequests.clear();
595
- for (const [, ps] of this.sessions) {
596
- try {
597
- ps.session.close();
598
- }
599
- catch {
600
- // Ignore errors during cleanup
601
- }
602
- }
603
- this.sessions.clear();
604
- }
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();
640
- }
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; };
648
- }
649
- }
650
- };
651
- 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;
662
- }
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') {
667
- flushText();
668
- console.log(`[stream] session_state_changed=idle session=${capturedSessionId}`);
669
- // Don't call onEnd here — the result message will follow
670
- }
671
- continue;
672
- }
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;
683
- }
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
- });
695
- }
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
- });
705
- }
706
- }
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
- });
725
- }
726
- }
727
- }
728
- continue;
729
- }
730
- // Final result — turn is complete but session stays alive
731
- if (message.type === 'result') {
732
- 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;
748
- }
749
- }
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
- }
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 });
761
- }
762
- }
763
- }
764
- //# sourceMappingURL=claudeCodeService.js.map