@exreve/exk 1.0.6 → 1.0.8

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.
@@ -0,0 +1,1020 @@
1
+ import { query } from '@anthropic-ai/claude-agent-sdk';
2
+ import { execSync, spawn } from 'child_process';
3
+ import { existsSync, mkdirSync, readFileSync } from 'fs';
4
+ import { symlink as fsSymlink } from 'fs';
5
+ // Memory system removed for performance
6
+ import { getSkillContent } from './skills/index.js';
7
+ import { createModuleMcpServer } from './moduleMcpServer.js';
8
+ import path from 'path';
9
+ import os from 'os';
10
+ import { createRequire } from 'module';
11
+ import { promisify } from 'util';
12
+ /**
13
+ * Resolve path to the SDK's bundled cli.js.
14
+ * We resolve this ourselves so it works reliably on Windows when running from
15
+ * the PS1 install (ttc.cmd -> node ttc.js); the SDK's internal resolution via
16
+ * import.meta.url can fail or produce wrong paths in that context.
17
+ * CACHED: Path is resolved once at module load time for performance.
18
+ */
19
+ function resolveSdkCliPath() {
20
+ try {
21
+ const req = typeof globalThis.require === 'function'
22
+ ? globalThis.require
23
+ : createRequire(import.meta.url);
24
+ const pkgPath = req.resolve('@anthropic-ai/claude-agent-sdk/package.json');
25
+ const cliPath = path.join(path.dirname(pkgPath), 'cli.js');
26
+ return existsSync(cliPath) ? cliPath : undefined;
27
+ }
28
+ catch {
29
+ return undefined;
30
+ }
31
+ }
32
+ // Cache the resolved Claude executable path at module load time
33
+ const CACHED_CLAUDE_PATH = (() => {
34
+ const envPath = process.env.TTC_CLAUDE_PATH;
35
+ if (envPath)
36
+ return envPath;
37
+ const sdkPath = resolveSdkCliPath();
38
+ if (sdkPath)
39
+ return sdkPath;
40
+ const localPath = path.join(os.homedir(), '.local', 'bin', 'claude');
41
+ if (existsSync(localPath))
42
+ return localPath;
43
+ return undefined;
44
+ })();
45
+ // Promisify symlink for async use
46
+ const symlinkAsync = promisify(fsSymlink);
47
+ // Helper function to extract tool name from result structure
48
+ function extractToolName(toolResult) {
49
+ if (toolResult.name)
50
+ return toolResult.name;
51
+ if (toolResult.type === 'text' && toolResult.file)
52
+ return 'Read';
53
+ if (toolResult.file_path || toolResult.filePath) {
54
+ return (toolResult.content !== undefined || toolResult.type === 'create') ? 'Write' : 'Read';
55
+ }
56
+ if (toolResult.stdout !== undefined || toolResult.stderr !== undefined)
57
+ return 'Bash';
58
+ return 'unknown';
59
+ }
60
+ // AI config - loaded from server after registration, stored in ~/.talk-to-code/ai-config.json
61
+ // (Do not read ANTHROPIC_* / CLAUDE_MODEL from the host environment — only this file + code default model.)
62
+ const AI_CONFIG_PATH = path.join(os.homedir(), '.talk-to-code', 'ai-config.json');
63
+ const DEFAULT_AI_MODEL = 'glm-5.1';
64
+ function loadAiConfig() {
65
+ try {
66
+ const data = readFileSync(AI_CONFIG_PATH, 'utf-8');
67
+ const config = JSON.parse(data);
68
+ const apiKey = typeof config.authToken === 'string' ? config.authToken.trim() : '';
69
+ const baseUrl = typeof config.baseUrl === 'string' ? config.baseUrl.trim() : '';
70
+ const model = typeof config.model === 'string' && config.model.trim() ? config.model.trim() : DEFAULT_AI_MODEL;
71
+ return { apiKey, baseUrl, model };
72
+ }
73
+ catch {
74
+ return { apiKey: '', baseUrl: '', model: DEFAULT_AI_MODEL };
75
+ }
76
+ }
77
+ /** Env for the Claude Code child: copy of host env with host ANTHROPIC_* stripped, then inject from ai-config only. */
78
+ function envForClaudeCodeChild() {
79
+ const env = { ...process.env };
80
+ delete env.ANTHROPIC_API_KEY;
81
+ delete env.ANTHROPIC_BASE_URL;
82
+ delete env.ANTHROPIC_AUTH_TOKEN;
83
+ const { apiKey, baseUrl } = loadAiConfig();
84
+ if (apiKey)
85
+ env.ANTHROPIC_API_KEY = apiKey;
86
+ if (baseUrl)
87
+ env.ANTHROPIC_BASE_URL = baseUrl;
88
+ return env;
89
+ }
90
+ // Lazy config getter - reloads from file each time (so daemon picks up changes without restart)
91
+ const CLAUDE_CONFIG = {
92
+ get apiKey() { return loadAiConfig().apiKey; },
93
+ get baseUrl() { return loadAiConfig().baseUrl; },
94
+ get model() { return loadAiConfig().model; },
95
+ };
96
+ export class AgentSessionManager {
97
+ sessions = new Map();
98
+ promptAbortControllers = new Map(); // Map promptId -> AbortController for cancellation
99
+ emergencyStopInProgress = new Set(); // Track sessions being emergency stopped
100
+ sessionHandlers = new Map(); // Track handlers for each session
101
+ async createSession(handler) {
102
+ const { sessionId, projectPath, model } = handler;
103
+ const sessionModel = model || CLAUDE_CONFIG.model;
104
+ // Ensure project directory exists - prevents ENOENT errors when SDK spawns process
105
+ if (!existsSync(projectPath)) {
106
+ try {
107
+ mkdirSync(projectPath, { recursive: true });
108
+ console.log(`[AgentSessionManager] Created project directory: ${projectPath}`);
109
+ }
110
+ catch (error) {
111
+ console.error(`[AgentSessionManager] Failed to create project directory ${projectPath}:`, error.message);
112
+ // Fallback for /home/abc - try to create symlink to /tmp/abc
113
+ if (projectPath === '/home/abc') {
114
+ const fallbackPath = '/tmp/abc';
115
+ try {
116
+ mkdirSync(fallbackPath, { recursive: true });
117
+ await symlinkAsync(fallbackPath, projectPath, 'dir');
118
+ console.log(`[AgentSessionManager] Created symlink: ${projectPath} -> ${fallbackPath}`);
119
+ }
120
+ catch (symlinkError) {
121
+ console.log(`[AgentSessionManager] Symlink creation failed: ${symlinkError.message}`);
122
+ }
123
+ }
124
+ }
125
+ }
126
+ // If session already exists, update the model if provided
127
+ if (this.sessions.has(sessionId)) {
128
+ const existingSession = this.sessions.get(sessionId);
129
+ // Update model if a new one is provided
130
+ if (model && model !== existingSession.model) {
131
+ existingSession.model = model;
132
+ // DISABLED: File logging removed for performance
133
+ // if (existingSession.logger) {
134
+ // await existingSession.logger.info(`Model updated to: ${model}`)
135
+ // }
136
+ }
137
+ // DISABLED: File logging removed for performance
138
+ // if (existingSession.logger) {
139
+ // await existingSession.logger.info('Session already exists, reusing context')
140
+ // }
141
+ // Just ensure abort controller is fresh for new queries
142
+ existingSession.abortController = new AbortController();
143
+ return;
144
+ }
145
+ // DISABLED: File logging removed for performance
146
+ // const logger = new AgentLogger(sessionId)
147
+ // await logger.logSessionCreated(projectPath)
148
+ // await logger.logModelConfig(sessionModel, CLAUDE_CONFIG.baseUrl)
149
+ const logger = undefined;
150
+ // Store the handler for this session
151
+ this.sessionHandlers.set(sessionId, handler);
152
+ const abortController = new AbortController();
153
+ // Create MCP server for modules if enabled modules are provided
154
+ let mcpServer;
155
+ const enabledModules = handler.enabledModules || [];
156
+ const moduleSettings = handler.moduleSettings || {};
157
+ if (enabledModules.length > 0) {
158
+ mcpServer = createModuleMcpServer({
159
+ enabledModules,
160
+ moduleSettings,
161
+ onChoiceRequest: handler.onChoiceRequest
162
+ ? async (request) => {
163
+ return new Promise((resolve) => {
164
+ const session = this.sessions.get(sessionId);
165
+ if (session) {
166
+ session.pendingChoice = { request, resolve };
167
+ handler.onChoiceRequest(request);
168
+ }
169
+ else {
170
+ resolve({ choiceId: request.choiceId, selectedValue: null });
171
+ }
172
+ });
173
+ }
174
+ : undefined
175
+ });
176
+ // DISABLED: File logging removed for performance
177
+ // if (logger) {
178
+ // await logger.info(`MCP server created with modules: ${enabledModules.join(', ')}`)
179
+ // }
180
+ }
181
+ this.sessions.set(sessionId, {
182
+ abortController,
183
+ messages: [],
184
+ totalInputTokens: 0,
185
+ totalOutputTokens: 0,
186
+ totalCostUsd: 0,
187
+ promptQueue: [],
188
+ isProcessingQueue: false,
189
+ claudeSessionId: undefined, // Will be set when Claude SDK returns session ID
190
+ // logger, // DISABLED: File logging removed for performance
191
+ // Memory system removed for performance
192
+ childProcesses: new Set(),
193
+ claudeProcessGroupId: undefined,
194
+ currentPromptId: undefined,
195
+ model: sessionModel,
196
+ userChoiceEnabled: handler.userChoiceEnabled || false,
197
+ enabledModules,
198
+ moduleSettings,
199
+ mcpServer
200
+ });
201
+ // Auto-regenerate CLAUDE.md for fresh project context
202
+ // DISABLED: File logging removed for performance
203
+ await this.regenerateClaudeMd(projectPath, undefined);
204
+ }
205
+ /**
206
+ * Regenerate CLAUDE.md for fresh project context
207
+ */
208
+ async regenerateClaudeMd(projectPath, logger) {
209
+ try {
210
+ const scriptPath = path.join(projectPath, 'scripts', 'generate-claude-md.js');
211
+ if (existsSync(scriptPath)) {
212
+ const startTime = Date.now();
213
+ execSync(`node "${scriptPath}"`, { cwd: projectPath, stdio: 'ignore' });
214
+ const duration = Date.now() - startTime;
215
+ if (logger) {
216
+ await logger.info(`CLAUDE.md regenerated in ${duration}ms`);
217
+ }
218
+ }
219
+ }
220
+ catch (error) {
221
+ // Don't fail session if CLAUDE.md generation fails
222
+ console.error('[AgentSessionManager] Failed to regenerate CLAUDE.md:', error);
223
+ }
224
+ }
225
+ // Memory system removed for performance - no loadMemoryContext or storeConversationInMemory
226
+ async sendPrompt(sessionId, prompt, enhancers = [], handler) {
227
+ // Ensure session exists
228
+ if (!this.sessions.has(sessionId)) {
229
+ await this.createSession(handler);
230
+ }
231
+ const session = this.sessions.get(sessionId);
232
+ // Update session model if provided in handler
233
+ if (handler.model && handler.model !== session.model) {
234
+ session.model = handler.model;
235
+ // DISABLED: File logging removed for performance
236
+ // if (session.logger) {
237
+ // await session.logger.info(`Model updated to: ${handler.model}`)
238
+ // }
239
+ }
240
+ // Add prompt to queue - store promptId for cancellation
241
+ session.promptQueue.push({
242
+ prompt,
243
+ enhancers,
244
+ handler,
245
+ timestamp: Date.now(),
246
+ promptId: handler.promptId,
247
+ abortController: new AbortController(), // Pre-create for queued cancellation
248
+ model: handler.model || session.model // Use handler model or fall back to session model
249
+ });
250
+ // Start processing queue if not already processing
251
+ if (!session.isProcessingQueue) {
252
+ this.processPromptQueue(sessionId);
253
+ }
254
+ }
255
+ async processPromptQueue(sessionId) {
256
+ const session = this.sessions.get(sessionId);
257
+ if (!session)
258
+ return;
259
+ if (session.isProcessingQueue)
260
+ return;
261
+ session.isProcessingQueue = true;
262
+ while (session.promptQueue.length > 0 && !this.emergencyStopInProgress.has(sessionId)) {
263
+ const queuedPrompt = session.promptQueue.shift();
264
+ const { prompt, enhancers, handler, promptId: queuedPromptId, abortController: queuedAbortController } = queuedPrompt;
265
+ const { projectPath, promptId, onOutput, onError, onComplete, onStatusUpdate } = handler;
266
+ const promptStartTime = Date.now();
267
+ try {
268
+ // Verify promptId is present in handler
269
+ if (!promptId) {
270
+ console.error(`[agentSession] Missing promptId in handler for prompt: ${prompt.substring(0, 50)}...`);
271
+ onError?.('Missing promptId in handler');
272
+ continue;
273
+ }
274
+ // Check if this queued prompt was cancelled before processing started
275
+ if (queuedAbortController?.signal.aborted) {
276
+ console.log(`[agentSession] Queued prompt ${promptId} was cancelled before processing`);
277
+ onStatusUpdate?.('cancelled');
278
+ onComplete(null);
279
+ continue;
280
+ }
281
+ // Use the queued promptId and abortController, or fallback to handler values
282
+ const effectivePromptId = queuedPromptId || promptId;
283
+ const abortController = queuedAbortController || new AbortController();
284
+ session.abortController = abortController;
285
+ session.currentPromptId = effectivePromptId; // Track current prompt for emergency stop
286
+ this.promptAbortControllers.set(effectivePromptId, abortController);
287
+ // Emit status update: CLI is starting to process the prompt (IMMEDIATELY)
288
+ // This ensures real-time status updates before any async operations
289
+ onStatusUpdate?.('running');
290
+ // DISABLED: File logging removed for performance
291
+ // // Log prompt start
292
+ // if (session.logger) {
293
+ // await session.logger.logPromptStart(prompt, projectPath)
294
+ // }
295
+ // Wait for current query to finish before starting next prompt
296
+ // REMOVED: 200ms artificial delay - stream draining is sufficient
297
+ if (session.activeQueryStream !== undefined) {
298
+ try {
299
+ for await (const _ of session.activeQueryStream) { }
300
+ }
301
+ catch { }
302
+ // REMOVED: await new Promise(resolve => setTimeout(resolve, 200))
303
+ session.activeQueryStream = undefined;
304
+ }
305
+ session.activeQueryStream = undefined;
306
+ // Build final prompt with enhancers
307
+ let finalPrompt = prompt;
308
+ if (enhancers && enhancers.length > 0) {
309
+ const skillContent = getSkillContent(enhancers);
310
+ if (skillContent) {
311
+ finalPrompt = `${skillContent}\n\n${prompt}`;
312
+ // DISABLED: File logging removed for performance
313
+ // // Log that enhancers are being used
314
+ // if (session.logger) {
315
+ // await session.logger.info(`Using enhancers: ${enhancers.join(', ')}`)
316
+ // }
317
+ }
318
+ }
319
+ // Add user message to history
320
+ session.messages.push({
321
+ role: 'user',
322
+ content: finalPrompt,
323
+ timestamp: Date.now()
324
+ });
325
+ // Emit context info
326
+ onOutput({
327
+ type: 'system',
328
+ data: {
329
+ message: `Context: ${session.messages.length} messages, ${session.totalInputTokens + session.totalOutputTokens} total tokens`,
330
+ contextInfo: {
331
+ messageCount: session.messages.length,
332
+ totalInputTokens: session.totalInputTokens,
333
+ totalOutputTokens: session.totalOutputTokens,
334
+ totalTokens: session.totalInputTokens + session.totalOutputTokens,
335
+ totalCostUsd: session.totalCostUsd,
336
+ lastUsage: session.lastUsage
337
+ }
338
+ },
339
+ timestamp: Date.now(),
340
+ metadata: {
341
+ subtype: 'context_info',
342
+ contextSize: session.messages.length,
343
+ totalTokens: session.totalInputTokens + session.totalOutputTokens
344
+ }
345
+ });
346
+ // Use cached Claude executable path (resolved at module load time for performance)
347
+ const pathToClaudeCodeExecutable = CACHED_CLAUDE_PATH;
348
+ // DISABLED: File logging removed for performance
349
+ // if (session.logger) {
350
+ // if (pathToClaudeCodeExecutable) {
351
+ // session.logger.info(`Using Claude (cli.js): ${pathToClaudeCodeExecutable}`).catch(() => {})
352
+ // } else {
353
+ // session.logger.info('Using SDK built-in Claude (cli.js)').catch(() => {})
354
+ // }
355
+ // }
356
+ // Build query options - include abort signal for cancellation
357
+ const queryOptions = {
358
+ signal: abortController.signal, // Pass abort signal to SDK for interruption
359
+ cwd: projectPath,
360
+ apiKey: CLAUDE_CONFIG.apiKey,
361
+ model: CLAUDE_CONFIG.model,
362
+ tools: { type: 'preset', preset: 'claude_code' },
363
+ disallowedTools: ['AskUserQuestion'],
364
+ settingSources: ['project'], // Enable CLAUDE.md loading
365
+ permissionMode: 'bypassPermissions',
366
+ allowDangerouslySkipPermissions: true,
367
+ // Add MCP server for modules if configured
368
+ ...(session.mcpServer ? { mcpServers: [session.mcpServer] } : {}),
369
+ ...(pathToClaudeCodeExecutable ? { pathToClaudeCodeExecutable } : {}),
370
+ spawnClaudeCodeProcess: (spawnOptions) => {
371
+ const { command, args, cwd: cwd2, env, signal } = spawnOptions;
372
+ // Only check file existence when command is a path (not a bare name like "claude" from PATH)
373
+ const hasPathSep = command.includes(path.sep) || command.includes('/') || command.includes('\\');
374
+ if (hasPathSep && !existsSync(command)) {
375
+ throw new Error(`Executable not found at ${command}. Set path with: ttc config --claude-path "<path>" (or TTC_CLAUDE_PATH)`);
376
+ }
377
+ try {
378
+ if (cwd2 && !existsSync(cwd2)) {
379
+ mkdirSync(cwd2, { recursive: true });
380
+ }
381
+ }
382
+ catch { }
383
+ const isWin = process.platform === 'win32';
384
+ // Ensure PATH includes common node locations, especially in containers
385
+ const defaultPath = isWin
386
+ ? (process.env.Path || process.env.PATH || '')
387
+ : '/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin';
388
+ const spawnEnv = {
389
+ ...env,
390
+ PATH: env.PATH || process.env.PATH || defaultPath,
391
+ ...(isWin
392
+ ? {
393
+ USERPROFILE: env.USERPROFILE || process.env.USERPROFILE || os.homedir(),
394
+ USERNAME: env.USERNAME || process.env.USERNAME || 'user',
395
+ HOME: env.USERPROFILE || process.env.USERPROFILE || os.homedir(), // Windows: use Windows home, not /home/user
396
+ }
397
+ : { HOME: env.HOME || process.env.HOME || os.homedir(), USER: env.USER || process.env.USER || 'user' }),
398
+ };
399
+ // If command is 'node' and not found, try to resolve it
400
+ if (command === 'node' && !hasPathSep) {
401
+ try {
402
+ const nodePath = execSync('which node', { encoding: 'utf-8', env: spawnEnv }).trim();
403
+ if (nodePath) {
404
+ const child = spawn(nodePath, args, {
405
+ cwd: cwd2 || process.cwd(),
406
+ stdio: ["pipe", "pipe", env.DEBUG_CLAUDE_AGENT_SDK ? "pipe" : "ignore"],
407
+ signal,
408
+ env: spawnEnv,
409
+ windowsHide: true,
410
+ detached: !isWin // Create process group on Unix for tree-killing
411
+ });
412
+ // Track child process for force-kill
413
+ if (!session.childProcesses)
414
+ session.childProcesses = new Set();
415
+ session.childProcesses.add(child);
416
+ // Store process group ID for Unix (negative PID kills entire group)
417
+ if (!isWin && child.pid) {
418
+ session.claudeProcessGroupId = child.pid;
419
+ }
420
+ // Clean up when process exits
421
+ child.on('exit', () => {
422
+ session.childProcesses.delete(child);
423
+ });
424
+ child.on('error', () => {
425
+ session.childProcesses.delete(child);
426
+ });
427
+ return child;
428
+ }
429
+ }
430
+ catch {
431
+ // Fall through to original spawn
432
+ }
433
+ }
434
+ const child = spawn(command, args, {
435
+ cwd: cwd2 || process.cwd(),
436
+ stdio: ["pipe", "pipe", env.DEBUG_CLAUDE_AGENT_SDK ? "pipe" : "ignore"],
437
+ signal,
438
+ env: spawnEnv,
439
+ windowsHide: true,
440
+ detached: !isWin // Create process group on Unix for tree-killing
441
+ });
442
+ // Track child process for force-kill
443
+ if (!session.childProcesses)
444
+ session.childProcesses = new Set();
445
+ session.childProcesses.add(child);
446
+ // Store process group ID for Unix (negative PID kills entire group)
447
+ if (!isWin && child.pid) {
448
+ session.claudeProcessGroupId = child.pid;
449
+ }
450
+ // Clean up when process exits
451
+ child.on('exit', () => {
452
+ session.childProcesses.delete(child);
453
+ });
454
+ child.on('error', () => {
455
+ session.childProcesses.delete(child);
456
+ });
457
+ return child;
458
+ },
459
+ env: envForClaudeCodeChild(),
460
+ hooks: {
461
+ // PostToolUse hook DISABLED for tool_result emission.
462
+ // tool_result events are already emitted via the user message handler (line ~752)
463
+ // which correctly extracts toolUseId from the SDK's user message structure.
464
+ // Sending duplicate events from here caused:
465
+ // 1. Frontend merging conflicts (two results for same tool)
466
+ // 2. Tools stuck showing "Running..." when the second event fails to merge
467
+ // 3. Missing toolUseId in hook events (not always available here)
468
+ PostToolUse: [(_toolResult) => {
469
+ // Tool result is handled by the user message handler below
470
+ }],
471
+ Notification: [(notification) => {
472
+ onOutput({
473
+ type: 'progress',
474
+ data: notification,
475
+ timestamp: Date.now(),
476
+ metadata: {
477
+ progress: {
478
+ message: typeof notification === 'string' ? notification : JSON.stringify(notification)
479
+ }
480
+ }
481
+ });
482
+ }]
483
+ }
484
+ };
485
+ // Log model being used for debugging
486
+ const sessionModel = session.model || CLAUDE_CONFIG.model;
487
+ // DISABLED: File logging removed for performance
488
+ // if (session.logger) {
489
+ // await session.logger.logModelConfig(sessionModel, CLAUDE_CONFIG.baseUrl)
490
+ // }
491
+ // Create query stream - resume session if we have a Claude session ID
492
+ // Always explicitly set model even when resuming to ensure we use the session's model
493
+ const queryStream = query({
494
+ prompt,
495
+ options: {
496
+ ...queryOptions,
497
+ model: sessionModel, // Use session-specific model (supports switching between prompts)
498
+ ...(session.claudeSessionId ? { resume: session.claudeSessionId } : {})
499
+ }
500
+ });
501
+ session.activeQueryStream = queryStream;
502
+ // Process messages with enhanced abort checking
503
+ // Create a wrapped stream that checks abort status more frequently
504
+ const abortCheckInterval = 200; // Check every 200ms
505
+ let lastAbortCheck = Date.now();
506
+ try {
507
+ for await (const message of queryStream) {
508
+ // Check abort on each message (existing behavior)
509
+ if (session.abortController.signal.aborted || this.emergencyStopInProgress.has(sessionId)) {
510
+ console.log(`[agentSession] Aborting prompt ${effectivePromptId} - abort signal received`);
511
+ break;
512
+ }
513
+ // Periodic check for long-running operations
514
+ const now = Date.now();
515
+ if (now - lastAbortCheck > abortCheckInterval) {
516
+ lastAbortCheck = now;
517
+ if (session.abortController.signal.aborted || this.emergencyStopInProgress.has(sessionId)) {
518
+ console.log(`[agentSession] Aborting prompt ${effectivePromptId} - periodic check detected abort`);
519
+ break;
520
+ }
521
+ }
522
+ // Capture Claude SDK session ID from system init message
523
+ if (message.type === 'system' && message.subtype === 'init') {
524
+ const systemMsg = message;
525
+ if (systemMsg.session_id && !session.claudeSessionId) {
526
+ session.claudeSessionId = systemMsg.session_id;
527
+ // DISABLED: File logging removed for performance
528
+ // if (session.logger) {
529
+ // await session.logger.logClaudeSessionId(systemMsg.session_id)
530
+ // }
531
+ }
532
+ }
533
+ if (message.type === 'assistant') {
534
+ const msg = message;
535
+ // Capture Claude session ID from assistant message if not already set
536
+ if (msg.session_id && !session.claudeSessionId) {
537
+ session.claudeSessionId = msg.session_id;
538
+ // DISABLED: File logging removed for performance
539
+ // if (session.logger) {
540
+ // await session.logger.logClaudeSessionId(msg.session_id)
541
+ // }
542
+ }
543
+ session.messages.push({
544
+ role: 'assistant',
545
+ content: msg.message,
546
+ timestamp: Date.now()
547
+ });
548
+ // DISABLED: File logging removed for performance
549
+ // // Log agent response
550
+ // const responseText = typeof msg.message === 'string' ? msg.message : JSON.stringify(msg.message)
551
+ // if (session.logger) {
552
+ // await session.logger.logAgentResponse(responseText, {
553
+ // uuid: msg.uuid,
554
+ // sessionId: msg.session_id,
555
+ // error: msg.error,
556
+ // contextSize: session.messages.length
557
+ // })
558
+ // }
559
+ onOutput({
560
+ type: 'assistant',
561
+ data: msg.message,
562
+ timestamp: Date.now(),
563
+ metadata: {
564
+ parentToolUseId: msg.parent_tool_use_id,
565
+ uuid: msg.uuid,
566
+ sessionId: msg.session_id,
567
+ error: msg.error,
568
+ contextSize: session.messages.length
569
+ }
570
+ });
571
+ }
572
+ else if (message.type === 'result') {
573
+ const msg = message;
574
+ // Update usage tracking
575
+ if (msg.usage) {
576
+ const usage = msg.usage;
577
+ const inputTokens = usage.input_tokens || usage.inputTokens || 0;
578
+ const outputTokens = usage.output_tokens || usage.outputTokens || 0;
579
+ session.totalInputTokens += inputTokens;
580
+ session.totalOutputTokens += outputTokens;
581
+ session.totalCostUsd += msg.total_cost_usd || 0;
582
+ session.lastUsage = {
583
+ inputTokens,
584
+ outputTokens,
585
+ totalTokens: inputTokens + outputTokens
586
+ };
587
+ // DISABLED: File logging removed for performance
588
+ // // Log token usage
589
+ // if (session.logger) {
590
+ // await session.logger.logTokenUsage({
591
+ // inputTokens: session.totalInputTokens,
592
+ // outputTokens: session.totalOutputTokens,
593
+ // totalTokens: session.totalInputTokens + session.totalOutputTokens,
594
+ // costUsd: session.totalCostUsd
595
+ // })
596
+ // }
597
+ }
598
+ const promptDuration = Date.now() - promptStartTime;
599
+ // DISABLED: File logging removed for performance
600
+ // // Log prompt completion
601
+ // if (session.logger) {
602
+ // await session.logger.logPromptEnd(
603
+ // msg.is_error ? 1 : 0,
604
+ // promptDuration,
605
+ // session.lastUsage ? {
606
+ // inputTokens: session.lastUsage.inputTokens,
607
+ // outputTokens: session.lastUsage.outputTokens,
608
+ // costUsd: msg.total_cost_usd || 0
609
+ // } : undefined
610
+ // )
611
+ // }
612
+ onOutput({
613
+ type: 'result',
614
+ data: msg,
615
+ timestamp: Date.now(),
616
+ metadata: {
617
+ subtype: msg.subtype,
618
+ isError: msg.is_error,
619
+ exitCode: msg.is_error ? 1 : 0,
620
+ durationMs: msg.duration_ms,
621
+ totalCostUsd: msg.total_cost_usd,
622
+ usage: msg.usage,
623
+ contextInfo: {
624
+ messageCount: session.messages.length,
625
+ totalInputTokens: session.totalInputTokens,
626
+ totalOutputTokens: session.totalOutputTokens,
627
+ totalTokens: session.totalInputTokens + session.totalOutputTokens,
628
+ totalCostUsd: session.totalCostUsd,
629
+ lastUsage: session.lastUsage
630
+ }
631
+ }
632
+ });
633
+ // Emit status update: CLI finished processing (real-time)
634
+ const exitCode = msg.is_error ? 1 : 0;
635
+ // Clean up abort controller
636
+ this.promptAbortControllers.delete(promptId);
637
+ // Update status immediately based on exit code
638
+ onStatusUpdate?.(exitCode === 0 ? 'completed' : 'error');
639
+ onComplete(exitCode);
640
+ session.activeQueryStream = undefined;
641
+ // Memory system removed for performance
642
+ break; // Prompt complete, continue to next in queue
643
+ }
644
+ else if (message.type === 'user') {
645
+ const msg = message;
646
+ if (msg.tool_use_result) {
647
+ const toolResult = msg.tool_use_result;
648
+ let toolUseId = msg.parent_tool_use_id;
649
+ if (!toolUseId && Array.isArray(msg.message.content)) {
650
+ const toolResultContent = msg.message.content.find((c) => c.type === 'tool_result' && c.tool_use_id);
651
+ toolUseId = toolResultContent?.tool_use_id || null;
652
+ }
653
+ onOutput({
654
+ type: 'tool_result',
655
+ data: toolResult,
656
+ timestamp: Date.now(),
657
+ metadata: {
658
+ toolName: extractToolName(toolResult),
659
+ toolResult: toolResult,
660
+ toolUseId: toolUseId || undefined,
661
+ parentToolUseId: msg.parent_tool_use_id,
662
+ isSynthetic: msg.isSynthetic
663
+ }
664
+ });
665
+ }
666
+ else {
667
+ onOutput({
668
+ type: 'user',
669
+ data: msg.message,
670
+ timestamp: Date.now(),
671
+ metadata: {
672
+ parentToolUseId: null,
673
+ isSynthetic: msg.isSynthetic
674
+ }
675
+ });
676
+ }
677
+ }
678
+ else if (message.type === 'system') {
679
+ onOutput({
680
+ type: 'system',
681
+ data: message,
682
+ timestamp: Date.now(),
683
+ metadata: {
684
+ subtype: message.subtype,
685
+ messageType: message.subtype || 'system'
686
+ }
687
+ });
688
+ }
689
+ else if (message.type === 'tool_progress') {
690
+ const msg = message;
691
+ onOutput({
692
+ type: 'tool_progress',
693
+ data: msg,
694
+ timestamp: Date.now(),
695
+ metadata: {
696
+ toolName: msg.tool_name,
697
+ toolUseId: msg.tool_use_id,
698
+ elapsedTimeSeconds: msg.elapsed_time_seconds,
699
+ parentToolUseId: msg.parent_tool_use_id
700
+ }
701
+ });
702
+ }
703
+ else if (message.type === 'auth_status') {
704
+ onOutput({
705
+ type: 'auth_status',
706
+ data: message,
707
+ timestamp: Date.now(),
708
+ metadata: {
709
+ isAuthenticating: message.isAuthenticating,
710
+ error: message.error
711
+ }
712
+ });
713
+ }
714
+ else if (message.type === 'stream_event') {
715
+ onOutput({
716
+ type: 'stream_event',
717
+ data: message.event || message,
718
+ timestamp: Date.now(),
719
+ metadata: {
720
+ parentToolUseId: message.parent_tool_use_id
721
+ }
722
+ });
723
+ }
724
+ else {
725
+ onOutput({
726
+ type: 'stdout',
727
+ data: JSON.stringify(message, null, 2),
728
+ timestamp: Date.now()
729
+ });
730
+ }
731
+ }
732
+ }
733
+ catch (streamError) {
734
+ // Check if this was an abort-related error
735
+ if (session.abortController.signal.aborted || this.emergencyStopInProgress.has(sessionId)) {
736
+ console.log(`[agentSession] Stream aborted for prompt ${effectivePromptId}`);
737
+ // Handle abort gracefully
738
+ onStatusUpdate?.('cancelled');
739
+ onComplete(null);
740
+ session.activeQueryStream = undefined;
741
+ session.currentPromptId = undefined;
742
+ return;
743
+ }
744
+ // Re-throw non-abort errors
745
+ throw streamError;
746
+ }
747
+ session.activeQueryStream = undefined;
748
+ session.currentPromptId = undefined; // Clear current prompt when done
749
+ }
750
+ catch (error) {
751
+ const currentSession = this.sessions.get(sessionId);
752
+ if (currentSession) {
753
+ currentSession.activeQueryStream = undefined;
754
+ // DISABLED: File logging removed for performance
755
+ // // Log error
756
+ // if (currentSession.logger) {
757
+ // await currentSession.logger.logSessionError(error, {
758
+ // prompt: prompt.substring(0, 200),
759
+ // projectPath
760
+ // })
761
+ // }
762
+ }
763
+ // Clean up abort controller (promptId is guaranteed to exist here due to check at line 204)
764
+ if (promptId) {
765
+ this.promptAbortControllers.delete(promptId);
766
+ }
767
+ // Emit status update: CLI encountered an error (real-time)
768
+ onStatusUpdate?.('error');
769
+ onError(error.message || 'Unknown error');
770
+ onComplete(null);
771
+ }
772
+ }
773
+ session.isProcessingQueue = false;
774
+ // Continue processing queue if more prompts are waiting
775
+ if (session.promptQueue.length > 0) {
776
+ this.processPromptQueue(sessionId);
777
+ }
778
+ }
779
+ getContextInfo(sessionId) {
780
+ const session = this.sessions.get(sessionId);
781
+ if (!session)
782
+ return null;
783
+ return {
784
+ messageCount: session.messages.length,
785
+ totalInputTokens: session.totalInputTokens,
786
+ totalOutputTokens: session.totalOutputTokens,
787
+ totalTokens: session.totalInputTokens + session.totalOutputTokens,
788
+ totalCostUsd: session.totalCostUsd,
789
+ lastUsage: session.lastUsage
790
+ };
791
+ }
792
+ clearContext(sessionId) {
793
+ const session = this.sessions.get(sessionId);
794
+ if (session) {
795
+ session.messages = [];
796
+ session.totalInputTokens = 0;
797
+ session.totalOutputTokens = 0;
798
+ session.totalCostUsd = 0;
799
+ // Clear Claude session ID to start fresh
800
+ session.claudeSessionId = undefined;
801
+ session.lastUsage = undefined;
802
+ }
803
+ }
804
+ async deleteSession(sessionId) {
805
+ const session = this.sessions.get(sessionId);
806
+ if (session) {
807
+ session.abortController.abort();
808
+ session.activeQueryStream = undefined;
809
+ this.sessions.delete(sessionId);
810
+ }
811
+ }
812
+ /**
813
+ * Cancel a running or queued prompt by promptId
814
+ */
815
+ async cancelPrompt(promptId, sessionId, onStatusUpdate) {
816
+ const session = this.sessions.get(sessionId);
817
+ if (!session) {
818
+ return false; // Session not found
819
+ }
820
+ // First, check if prompt is in the queue (not yet started)
821
+ const queuedIndex = session.promptQueue.findIndex(p => p.promptId === promptId);
822
+ if (queuedIndex !== -1) {
823
+ // Found in queue - abort it and remove from queue
824
+ const queuedPrompt = session.promptQueue[queuedIndex];
825
+ if (queuedPrompt.abortController) {
826
+ queuedPrompt.abortController.abort();
827
+ }
828
+ // Remove from queue
829
+ session.promptQueue.splice(queuedIndex, 1);
830
+ // Emit status update: prompt was cancelled (real-time)
831
+ onStatusUpdate?.('cancelled');
832
+ console.log(`[agentSession] Cancelled queued prompt: ${promptId}`);
833
+ return true;
834
+ }
835
+ // Not in queue, check if it's currently running
836
+ const abortController = this.promptAbortControllers.get(promptId);
837
+ if (!abortController) {
838
+ return false; // Prompt not found or already completed
839
+ }
840
+ // Abort the running prompt
841
+ abortController.abort();
842
+ // Clean up
843
+ this.promptAbortControllers.delete(promptId);
844
+ // Emit status update: prompt was cancelled (real-time)
845
+ onStatusUpdate?.('cancelled');
846
+ console.log(`[agentSession] Cancelled running prompt: ${promptId}`);
847
+ return true;
848
+ }
849
+ /**
850
+ * Kill the entire process tree for a session (including grandchildren)
851
+ * Uses process group killing on Unix-like systems
852
+ */
853
+ async killProcessTree(sessionId) {
854
+ const session = this.sessions.get(sessionId);
855
+ if (!session)
856
+ return;
857
+ const isWin = process.platform === 'win32';
858
+ // 1. Kill all tracked child processes
859
+ if (session.childProcesses && session.childProcesses.size > 0) {
860
+ console.log(`[agentSession] Killing ${session.childProcesses.size} tracked child processes`);
861
+ for (const child of session.childProcesses) {
862
+ if (!child.killed) {
863
+ try {
864
+ if (isWin) {
865
+ // Windows: use taskkill to force kill
866
+ if (child.pid) {
867
+ spawn('taskkill', ['/pid', child.pid.toString(), '/f', '/t'], {
868
+ stdio: 'ignore',
869
+ windowsHide: true
870
+ });
871
+ }
872
+ }
873
+ else {
874
+ // Unix: try graceful SIGTERM first, then SIGKILL
875
+ child.kill('SIGTERM');
876
+ }
877
+ }
878
+ catch (e) {
879
+ // Process may already be dead
880
+ }
881
+ }
882
+ }
883
+ // Wait a bit for graceful shutdown, then force kill
884
+ await new Promise(resolve => setTimeout(resolve, 500));
885
+ for (const child of session.childProcesses) {
886
+ if (!child.killed) {
887
+ try {
888
+ if (!isWin && child.pid) {
889
+ // Unix: force kill with SIGKILL
890
+ child.kill('SIGKILL');
891
+ }
892
+ }
893
+ catch (e) {
894
+ // Already dead
895
+ }
896
+ }
897
+ }
898
+ session.childProcesses.clear();
899
+ }
900
+ // 2. Kill the entire process group on Unix-like systems
901
+ if (!isWin && session.claudeProcessGroupId) {
902
+ try {
903
+ console.log(`[agentSession] Killing process group ${session.claudeProcessGroupId}`);
904
+ // Kill entire process group using negative PID
905
+ process.kill(-session.claudeProcessGroupId, 'SIGKILL');
906
+ }
907
+ catch (e) {
908
+ // Process group may already be dead
909
+ }
910
+ session.claudeProcessGroupId = undefined;
911
+ }
912
+ }
913
+ /**
914
+ * Emergency stop - immediately halt all activity in a session
915
+ * This is a forceful stop that kills all processes and clears state
916
+ */
917
+ async emergencyStop(sessionId) {
918
+ // Prevent concurrent emergency stops
919
+ if (this.emergencyStopInProgress.has(sessionId)) {
920
+ return { success: false, message: 'Emergency stop already in progress' };
921
+ }
922
+ this.emergencyStopInProgress.add(sessionId);
923
+ console.log(`[agentSession] EMERGENCY STOP triggered for session ${sessionId}`);
924
+ try {
925
+ const session = this.sessions.get(sessionId);
926
+ if (!session) {
927
+ return { success: false, message: 'Session not found' };
928
+ }
929
+ // 1. Abort all controllers (session level + all prompt-level)
930
+ session.abortController.abort();
931
+ for (const controller of this.promptAbortControllers.values()) {
932
+ controller.abort();
933
+ }
934
+ // 2. Kill the entire process tree
935
+ await this.killProcessTree(sessionId);
936
+ // 3. Clear the prompt queue
937
+ const queueSize = session.promptQueue.length;
938
+ session.promptQueue = [];
939
+ // 4. Clear active stream
940
+ session.activeQueryStream = undefined;
941
+ // 5. Reset processing state
942
+ session.isProcessingQueue = false;
943
+ // 6. Clear current prompt tracking
944
+ const currentPromptId = session.currentPromptId;
945
+ // 7. Clean up abort controllers map
946
+ this.promptAbortControllers.clear();
947
+ // 8. Remove from emergency stop tracking
948
+ this.emergencyStopInProgress.delete(sessionId);
949
+ // 9. Resolve any pending choice request with null (cancelled)
950
+ if (session.pendingChoice) {
951
+ session.pendingChoice.resolve({ choiceId: session.pendingChoice.request.choiceId, selectedValue: null });
952
+ session.pendingChoice = undefined;
953
+ }
954
+ const message = currentPromptId
955
+ ? `Emergency stop: Cancelled prompt '${currentPromptId}' and cleared ${queueSize} queued prompts`
956
+ : `Emergency stop: Cleared ${queueSize} queued prompts`;
957
+ console.log(`[agentSession] ${message}`);
958
+ return { success: true, message };
959
+ }
960
+ catch (error) {
961
+ this.emergencyStopInProgress.delete(sessionId);
962
+ console.error(`[agentSession] Emergency stop error:`, error);
963
+ return { success: false, message: error.message || 'Emergency stop failed' };
964
+ }
965
+ }
966
+ /**
967
+ * Handle user choice response from frontend
968
+ */
969
+ async handleChoiceResponse(sessionId, response) {
970
+ const session = this.sessions.get(sessionId);
971
+ if (!session) {
972
+ console.error(`[agentSession] Session ${sessionId} not found for choice response`);
973
+ return;
974
+ }
975
+ if (!session.pendingChoice) {
976
+ console.warn(`[agentSession] No pending choice for session ${sessionId}`);
977
+ return;
978
+ }
979
+ if (session.pendingChoice.request.choiceId !== response.choiceId) {
980
+ console.warn(`[agentSession] Choice ID mismatch: expected ${session.pendingChoice.request.choiceId}, got ${response.choiceId}`);
981
+ return;
982
+ }
983
+ // Resolve the pending choice promise
984
+ session.pendingChoice.resolve(response);
985
+ session.pendingChoice = undefined;
986
+ }
987
+ /**
988
+ * Request user choice during agent execution
989
+ */
990
+ async requestUserChoice(sessionId, request) {
991
+ const session = this.sessions.get(sessionId);
992
+ if (!session) {
993
+ throw new Error(`Session ${sessionId} not found`);
994
+ }
995
+ // Check if user choice is enabled for this session
996
+ if (!session.userChoiceEnabled) {
997
+ throw new Error('User choice is not enabled for this session');
998
+ }
999
+ // Create a promise that will be resolved when the user responds
1000
+ return new Promise((resolve) => {
1001
+ session.pendingChoice = { request, resolve };
1002
+ // Emit the choice request through the onOutput callback
1003
+ // This will be picked up by the CLI and sent to the frontend
1004
+ const handler = this.sessionHandlers.get(sessionId);
1005
+ if (handler?.onChoiceRequest) {
1006
+ handler.onChoiceRequest(request);
1007
+ }
1008
+ // Set timeout if specified
1009
+ if (request.timeout) {
1010
+ setTimeout(() => {
1011
+ if (session.pendingChoice?.request.choiceId === request.choiceId) {
1012
+ session.pendingChoice.resolve({ choiceId: request.choiceId, selectedValue: null });
1013
+ session.pendingChoice = undefined;
1014
+ }
1015
+ }, request.timeout);
1016
+ }
1017
+ });
1018
+ }
1019
+ }
1020
+ export const agentSessionManager = new AgentSessionManager();