@exreve/exk 1.0.51 → 1.0.53

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,1458 @@
1
+ import { query } from '@anthropic-ai/claude-agent-sdk';
2
+ import { execSync, spawn } from 'child_process';
3
+ import { existsSync, mkdirSync, readFileSync, writeFileSync, unlinkSync } from 'fs';
4
+ import { symlink as fsSymlink } from 'fs';
5
+ import { getSkillContent } from './skills/index.js';
6
+ import { isLocalModel, unwrapModelName, startOpenAIAdapter, getAdapterConfig } from './openaiAdapter.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
+ // ============ Session State Persistence ============
13
+ // Persists claudeSessionId to disk so context survives CLI restarts.
14
+ // Files are stored in ~/.talk-to-code/session-state/<sessionId>.json
15
+ const SESSION_STATE_DIR = path.join(os.homedir(), '.talk-to-code', 'session-state');
16
+ function sessionStatePath(sessionId) {
17
+ return path.join(SESSION_STATE_DIR, `${sessionId}.json`);
18
+ }
19
+ function saveSessionState(sessionId, state) {
20
+ try {
21
+ if (!existsSync(SESSION_STATE_DIR)) {
22
+ mkdirSync(SESSION_STATE_DIR, { recursive: true });
23
+ }
24
+ writeFileSync(sessionStatePath(sessionId), JSON.stringify(state, null, 2));
25
+ }
26
+ catch (err) {
27
+ console.error(`[AgentSessionManager] Failed to persist session state for ${sessionId}:`, err);
28
+ }
29
+ }
30
+ function loadSessionState(sessionId) {
31
+ try {
32
+ const filePath = sessionStatePath(sessionId);
33
+ if (!existsSync(filePath))
34
+ return null;
35
+ const data = JSON.parse(readFileSync(filePath, 'utf-8'));
36
+ if (data && typeof data.claudeSessionId === 'string' && data.claudeSessionId) {
37
+ return data;
38
+ }
39
+ return null;
40
+ }
41
+ catch {
42
+ return null;
43
+ }
44
+ }
45
+ function deleteSessionState(sessionId) {
46
+ try {
47
+ const filePath = sessionStatePath(sessionId);
48
+ if (existsSync(filePath))
49
+ unlinkSync(filePath);
50
+ }
51
+ catch {
52
+ // Ignore cleanup errors
53
+ }
54
+ }
55
+ /**
56
+ * Resolve path to the SDK's bundled cli.js.
57
+ * We resolve this ourselves so it works reliably on Windows when running from
58
+ * the PS1 install (ttc.cmd -> node ttc.js); the SDK's internal resolution via
59
+ * import.meta.url can fail or produce wrong paths in that context.
60
+ * CACHED: Path is resolved once at module load time for performance.
61
+ */
62
+ function resolveSdkCliPath() {
63
+ try {
64
+ const req = typeof globalThis.require === 'function'
65
+ ? globalThis.require
66
+ : createRequire(import.meta.url);
67
+ const pkgPath = req.resolve('@anthropic-ai/claude-agent-sdk/package.json');
68
+ const cliPath = path.join(path.dirname(pkgPath), 'cli.js');
69
+ return existsSync(cliPath) ? cliPath : undefined;
70
+ }
71
+ catch {
72
+ return undefined;
73
+ }
74
+ }
75
+ // Cache the resolved Claude executable path at module load time
76
+ const CACHED_CLAUDE_PATH = (() => {
77
+ const envPath = process.env.TTC_CLAUDE_PATH;
78
+ if (envPath)
79
+ return envPath;
80
+ const sdkPath = resolveSdkCliPath();
81
+ if (sdkPath)
82
+ return sdkPath;
83
+ const localPath = path.join(os.homedir(), '.local', 'bin', 'claude');
84
+ if (existsSync(localPath))
85
+ return localPath;
86
+ return undefined;
87
+ })();
88
+ // Promisify symlink for async use
89
+ const symlinkAsync = promisify(fsSymlink);
90
+ // Helper function to extract tool name from result structure
91
+ /**
92
+ * Detect tool name from the shape of tool_use_result.
93
+ *
94
+ * Uses discriminating keys from the SDK's own type definitions
95
+ * (sdk-tools.d.ts: BashOutput, GrepOutput, GlobOutput, FileReadOutput,
96
+ * FileEditOutput, FileWriteOutput, TodoWriteOutput, etc.)
97
+ */
98
+ function extractToolName(toolResult) {
99
+ if (!toolResult || typeof toolResult !== 'object')
100
+ return 'unknown';
101
+ const r = toolResult;
102
+ // ── Read (FileReadOutput): {type: 'text'|'image'|'notebook'|'pdf'|'parts'|'file_unchanged', file: {...}}
103
+ if (r.file && typeof r.file === 'object'
104
+ && ['text', 'image', 'notebook', 'pdf', 'parts', 'file_unchanged'].includes(r.type)) {
105
+ return 'Read';
106
+ }
107
+ // ── Edit (FileEditOutput): {filePath, oldString, newString, structuredPatch}
108
+ if (r.filePath && r.oldString !== undefined && r.newString !== undefined
109
+ && Array.isArray(r.structuredPatch)) {
110
+ return 'Edit';
111
+ }
112
+ // ── Write (FileWriteOutput): {type: 'create'|'update', filePath, content, structuredPatch}
113
+ if (r.filePath && (r.type === 'create' || r.type === 'update')
114
+ && r.content !== undefined) {
115
+ return 'Write';
116
+ }
117
+ // ── Grep (GrepOutput): {mode, numFiles, filenames, content?, numLines?}
118
+ if (typeof r.numFiles === 'number' && Array.isArray(r.filenames)
119
+ && r.mode !== undefined) {
120
+ return 'Grep';
121
+ }
122
+ // ── Glob (GlobOutput): {durationMs, numFiles, filenames, truncated}
123
+ if (typeof r.numFiles === 'number' && Array.isArray(r.filenames)
124
+ && typeof r.durationMs === 'number' && r.truncated !== undefined) {
125
+ return 'Glob';
126
+ }
127
+ // ── TodoWrite (TodoWriteOutput): {oldTodos, newTodos}
128
+ if (Array.isArray(r.oldTodos) && Array.isArray(r.newTodos)) {
129
+ return 'TodoWrite';
130
+ }
131
+ // ── Bash (BashOutput): {stdout, stderr, interrupted, ...}
132
+ if (r.stdout !== undefined || r.stderr !== undefined) {
133
+ return 'Bash';
134
+ }
135
+ // ── WebSearch (WebSearchOutput): {query, results}
136
+ if (typeof r.query === 'string' && Array.isArray(r.results)) {
137
+ return 'WebSearch';
138
+ }
139
+ // ── WebFetch (WebFetchOutput): {url, result, code, bytes}
140
+ if (typeof r.url === 'string' && typeof r.result === 'string'
141
+ && typeof r.code === 'number') {
142
+ return 'WebFetch';
143
+ }
144
+ // ── send_file (custom MCP tool): content is JSON with _type marker
145
+ if (r.content && typeof r.content === 'string' && r.type === 'text') {
146
+ try {
147
+ const parsed = JSON.parse(r.content);
148
+ if (parsed._type === 'send_file')
149
+ return 'send_file';
150
+ }
151
+ catch { /* not JSON, fall through */ }
152
+ // SDK 0.2.x: content-only results from nested tool calls (no stdout/stderr wrapper)
153
+ return 'Bash';
154
+ }
155
+ // ── Agent/Task output: {agentId, content, status}
156
+ if (r.agentId && Array.isArray(r.content) && r.status) {
157
+ return 'Task';
158
+ }
159
+ return 'unknown';
160
+ }
161
+ // Look up tool name from the most recent assistant message's tool_use blocks by tool_use_id
162
+ function lookupToolNameFromHistory(messages, toolUseId) {
163
+ if (!toolUseId)
164
+ return null;
165
+ for (let i = messages.length - 1; i >= 0; i--) {
166
+ const msg = messages[i];
167
+ if (msg.role !== 'assistant')
168
+ continue;
169
+ // msg.content is the SDK message object: {role: 'assistant', content: [{type: 'text',...}, {type: 'tool_use',...}]}
170
+ let content = typeof msg.content === 'string' ? null : msg.content;
171
+ // Unwrap nested content: {content: [...]} → [...]
172
+ if (content && !Array.isArray(content) && Array.isArray(content.content)) {
173
+ content = content.content;
174
+ }
175
+ if (!Array.isArray(content))
176
+ continue;
177
+ const toolUse = content.find((c) => c.type === 'tool_use' && c.id === toolUseId);
178
+ if (toolUse?.name)
179
+ return toolUse.name;
180
+ }
181
+ return null;
182
+ }
183
+ // AI config - loaded from server after registration, stored in ~/.talk-to-code/ai-config.json
184
+ // (Do not read ANTHROPIC_* / CLAUDE_MODEL from the host environment — only this file + code default model.)
185
+ const AI_CONFIG_PATH = path.join(os.homedir(), '.talk-to-code', 'ai-config.json');
186
+ const DEFAULT_AI_MODEL = 'glm-5.1';
187
+ /** TTL cache for ai-config.json reads to avoid hitting disk on every call */
188
+ let _aiConfigCache = null;
189
+ const AI_CONFIG_TTL_MS = 5_000;
190
+ const PROVIDERS = {
191
+ zai: {
192
+ apiKey: process.env.ZHIPU_API_KEY || '',
193
+ baseUrl: process.env.CLI_AI_BASE_URL || 'https://api.z.ai/api/anthropic',
194
+ models: ['glm-5.1', 'glm-4.7', 'glm-4.5-air'],
195
+ },
196
+ minimax: {
197
+ apiKey: '', // Populated from ai-config.json (served by backend)
198
+ baseUrl: 'https://api.minimax.io/anthropic',
199
+ models: ['MiniMax-M2.7', 'MiniMax-M2.7-highspeed'],
200
+ },
201
+ openrouter: {
202
+ apiKey: '', // Populated from ai-config.json openrouterApiKey (served by backend)
203
+ baseUrl: 'https://openrouter.ai/api',
204
+ models: ['gpt-oss-120b:cerebras'],
205
+ },
206
+ };
207
+ /** Resolve which provider to use based on model name or explicit provider ID.
208
+ * 1. Populate provider API keys from ai-config.json (served by backend).
209
+ * 2. If explicit providerId is given and has an API key configured, use that provider.
210
+ * 3. Else if model name matches one of a provider's model list, use that provider.
211
+ * 4. Else fall back to zai (default). */
212
+ function resolveProvider(model, providerId) {
213
+ // Populate provider keys from ai-config.json
214
+ const aiConfig = loadAiConfig();
215
+ PROVIDERS.minimax.apiKey = aiConfig.minimaxApiKey || process.env.MINIMAX_API_KEY || '';
216
+ PROVIDERS.openrouter.apiKey = aiConfig.openrouterApiKey || process.env.OPENROUTER_API_KEY || '';
217
+ if (!PROVIDERS.zai.apiKey)
218
+ PROVIDERS.zai.apiKey = aiConfig.apiKey || '';
219
+ // 1. Explicit provider selection
220
+ if (providerId && PROVIDERS[providerId]?.apiKey) {
221
+ const provider = PROVIDERS[providerId];
222
+ return { provider: providerId, apiKey: provider.apiKey, baseUrl: provider.baseUrl, model };
223
+ }
224
+ // 2. Match model name to a provider
225
+ for (const [id, config] of Object.entries(PROVIDERS)) {
226
+ if (config.models.includes(model) && config.apiKey) {
227
+ return { provider: id, apiKey: config.apiKey, baseUrl: config.baseUrl, model };
228
+ }
229
+ }
230
+ // 3. Fallback: use ai-config.json credentials (z.ai default)
231
+ return {
232
+ provider: 'zai',
233
+ apiKey: aiConfig.apiKey,
234
+ baseUrl: aiConfig.baseUrl || PROVIDERS.zai.baseUrl,
235
+ model,
236
+ };
237
+ }
238
+ function loadAiConfig() {
239
+ const now = Date.now();
240
+ if (_aiConfigCache && (now - _aiConfigCache.ts) < AI_CONFIG_TTL_MS) {
241
+ return _aiConfigCache.data;
242
+ }
243
+ try {
244
+ const data = readFileSync(AI_CONFIG_PATH, 'utf-8');
245
+ const config = JSON.parse(data);
246
+ const apiKey = typeof config.authToken === 'string' ? config.authToken.trim() : '';
247
+ const baseUrl = typeof config.baseUrl === 'string' ? config.baseUrl.trim() : '';
248
+ const model = typeof config.model === 'string' && config.model.trim() ? config.model.trim() : DEFAULT_AI_MODEL;
249
+ const proxy = typeof config.proxy === 'string' ? config.proxy.trim() : '';
250
+ const minimaxApiKey = typeof config.minimaxApiKey === 'string' ? config.minimaxApiKey.trim() : '';
251
+ const openrouterApiKey = typeof config.openrouterApiKey === 'string' ? config.openrouterApiKey.trim() : '';
252
+ const result = { apiKey, baseUrl, model, proxy, minimaxApiKey, openrouterApiKey };
253
+ _aiConfigCache = { data: result, ts: now };
254
+ return result;
255
+ }
256
+ catch {
257
+ const fallback = { apiKey: '', baseUrl: '', model: DEFAULT_AI_MODEL, proxy: '', minimaxApiKey: '', openrouterApiKey: '' };
258
+ _aiConfigCache = { data: fallback, ts: now };
259
+ return fallback;
260
+ }
261
+ }
262
+ /** Get OpenRouter API key from ai-config.json (served by backend) */
263
+ export function getOpenrouterApiKey() {
264
+ return loadAiConfig().openrouterApiKey || process.env.OPENROUTER_API_KEY || '';
265
+ }
266
+ /** Get the backend API URL from config file */
267
+ export function getApiUrl() {
268
+ const configPath = path.join(os.homedir(), '.talk-to-code', 'config.json');
269
+ try {
270
+ const data = readFileSync(configPath, 'utf-8');
271
+ const config = JSON.parse(data);
272
+ return config.apiUrl || 'https://api.talk-to-code.com';
273
+ }
274
+ catch {
275
+ return process.env.API_URL || 'https://api.talk-to-code.com';
276
+ }
277
+ }
278
+ /** Create (or reuse) an empty directory to use as CLAUDE_CONFIG_DIR.
279
+ * Setting this prevents the spawned Claude CLI from reading ~/.claude/settings.json,
280
+ * which may contain env.ANTHROPIC_BASE_URL pointing to z.ai and would override our
281
+ * carefully configured base URL. */
282
+ function getEmptyConfigDir() {
283
+ const configDir = path.join(os.homedir(), '.talk-to-code', 'empty-config-dir');
284
+ if (!existsSync(configDir)) {
285
+ mkdirSync(configDir, { recursive: true });
286
+ }
287
+ return configDir;
288
+ }
289
+ const PROXY_CONFIG_PATH = path.join(os.homedir(), '.talk-to-code', 'proxy.json');
290
+ /** Read proxy toggle state from disk (synchronous) */
291
+ function readProxyToggle() {
292
+ try {
293
+ const data = readFileSync(PROXY_CONFIG_PATH, 'utf-8');
294
+ return JSON.parse(data);
295
+ }
296
+ catch {
297
+ return { enabled: false };
298
+ }
299
+ }
300
+ /** Env for the Claude Code child: copy of host env with host ANTHROPIC_* stripped, then inject from provider or ai-config.
301
+ * If a local model is provided, override baseUrl to point to the anthropic-proxy adapter.
302
+ * If resolvedProvider is provided, use its credentials instead of ai-config defaults. */
303
+ function envForClaudeCodeChild(_localModel, resolvedProvider) {
304
+ const env = { ...process.env };
305
+ // Strip any host ANTHROPIC_* vars to prevent leaking credentials or wrong URLs
306
+ delete env.ANTHROPIC_API_KEY;
307
+ delete env.ANTHROPIC_BASE_URL;
308
+ delete env.ANTHROPIC_AUTH_TOKEN;
309
+ // Also strip model alias env vars to prevent stale overrides
310
+ delete env.ANTHROPIC_MODEL;
311
+ delete env.ANTHROPIC_DEFAULT_SONNET_MODEL;
312
+ delete env.ANTHROPIC_DEFAULT_OPUS_MODEL;
313
+ delete env.ANTHROPIC_DEFAULT_HAIKU_MODEL;
314
+ // Determine credentials: use resolvedProvider if provided, else ai-config defaults
315
+ const { apiKey, baseUrl, proxy } = loadAiConfig();
316
+ const effectiveApiKey = resolvedProvider?.apiKey || apiKey;
317
+ const effectiveBaseUrl = resolvedProvider?.baseUrl || baseUrl;
318
+ if (effectiveApiKey)
319
+ env.ANTHROPIC_API_KEY = effectiveApiKey;
320
+ if (effectiveBaseUrl)
321
+ env.ANTHROPIC_BASE_URL = effectiveBaseUrl;
322
+ // For MiniMax specifically: override ALL model aliases so the SDK
323
+ // sends the correct model ID to the Anthropic-compatible endpoint
324
+ if (resolvedProvider?.provider === 'minimax') {
325
+ env.ANTHROPIC_MODEL = resolvedProvider.model;
326
+ env.ANTHROPIC_DEFAULT_SONNET_MODEL = resolvedProvider.model;
327
+ env.ANTHROPIC_DEFAULT_OPUS_MODEL = resolvedProvider.model;
328
+ env.ANTHROPIC_DEFAULT_HAIKU_MODEL = resolvedProvider.model;
329
+ env.CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC = '1';
330
+ }
331
+ // For OpenRouter specifically: override ALL model aliases so the SDK
332
+ // sends the correct model ID to the Anthropic-compatible endpoint
333
+ if (resolvedProvider?.provider === 'openrouter') {
334
+ env.ANTHROPIC_MODEL = resolvedProvider.model;
335
+ env.ANTHROPIC_DEFAULT_SONNET_MODEL = resolvedProvider.model;
336
+ env.ANTHROPIC_DEFAULT_OPUS_MODEL = resolvedProvider.model;
337
+ env.ANTHROPIC_DEFAULT_HAIKU_MODEL = resolvedProvider.model;
338
+ env.CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC = '1';
339
+ }
340
+ // Apply proxy if enabled
341
+ const proxyToggle = readProxyToggle();
342
+ if (proxyToggle.enabled && proxy) {
343
+ env.HTTPS_PROXY = proxy;
344
+ env.HTTP_PROXY = proxy;
345
+ }
346
+ else {
347
+ // Clear any inherited proxy env
348
+ delete env.HTTPS_PROXY;
349
+ delete env.HTTP_PROXY;
350
+ delete env.https_proxy;
351
+ delete env.http_proxy;
352
+ }
353
+ // Prevent ~/.claude/settings.json env section from overriding our base URL.
354
+ // This redirects the Claude config dir to an empty dir so that
355
+ // ~/.claude/settings.json (which may have ANTHROPIC_BASE_URL set to z.ai)
356
+ // is never read during the CLI process initialization.
357
+ env.CLAUDE_CONFIG_DIR = getEmptyConfigDir();
358
+ // Mark as sandbox environment so Claude Code skips root-user checks
359
+ env.IS_SANDBOX = '1';
360
+ return env;
361
+ }
362
+ /** Get env overrides for a local model (adapter proxy URL + dummy key). Returns null if not a local model. */
363
+ async function getLocalModelEnvOverrides(model) {
364
+ if (!isLocalModel(model))
365
+ return null;
366
+ const adapterConfig = getAdapterConfig(model);
367
+ if (!adapterConfig)
368
+ return null;
369
+ const proxyUrl = await startOpenAIAdapter({
370
+ targetBaseUrl: adapterConfig.targetBaseUrl,
371
+ model: unwrapModelName(model),
372
+ apiKey: adapterConfig.apiKey,
373
+ });
374
+ return { baseUrl: proxyUrl, apiKey: 'local-no-key-needed' };
375
+ }
376
+ // Lazy config getter - reloads from file each time (so daemon picks up changes without restart)
377
+ const CLAUDE_CONFIG = {
378
+ get apiKey() { return loadAiConfig().apiKey; },
379
+ get baseUrl() { return loadAiConfig().baseUrl; },
380
+ get model() { return loadAiConfig().model; },
381
+ };
382
+ export class AgentSessionManager {
383
+ sessions = new Map();
384
+ promptAbortControllers = new Map(); // Map promptId -> AbortController for cancellation
385
+ emergencyStopInProgress = new Set(); // Track sessions being emergency stopped
386
+ sessionHandlers = new Map(); // Track handlers for each session
387
+ socketRef = null; // Socket.IO reference for fetching session history from backend
388
+ /** Set the socket reference for backend communication (called from app-child.ts) */
389
+ setSocketRef(socket) {
390
+ this.socketRef = socket;
391
+ }
392
+ /**
393
+ * Fetch conversation history from the backend DB for a session.
394
+ * Returns array of { role, content } pairs (user prompts + assistant responses).
395
+ */
396
+ async fetchSessionHistory(sessionId) {
397
+ return new Promise((resolve) => {
398
+ if (!this.socketRef?.connected) {
399
+ console.log(`[AgentSessionManager] Cannot fetch history: socket not connected`);
400
+ resolve([]);
401
+ return;
402
+ }
403
+ const timeoutId = setTimeout(() => {
404
+ console.warn(`[AgentSessionManager] fetchSessionHistory timed out for ${sessionId}`);
405
+ resolve([]);
406
+ }, 5000);
407
+ this.socketRef.emit('session:history', { sessionId }, (response) => {
408
+ clearTimeout(timeoutId);
409
+ if (response?.history && Array.isArray(response.history)) {
410
+ console.log(`[AgentSessionManager] Fetched ${response.history.length} history entries for session ${sessionId}`);
411
+ resolve(response.history);
412
+ }
413
+ else {
414
+ resolve([]);
415
+ }
416
+ });
417
+ });
418
+ }
419
+ /**
420
+ * Format conversation history into a text block for injection into a prompt.
421
+ * Takes the last N exchanges to avoid token overflow.
422
+ */
423
+ formatHistoryForPrompt(history) {
424
+ if (!history.length)
425
+ return '';
426
+ // Take last N entries to stay within reasonable token limits
427
+ const maxEntries = 40;
428
+ const trimmed = history.slice(-maxEntries);
429
+ const lines = trimmed.map(m => {
430
+ const content = typeof m.content === 'string' ? m.content : JSON.stringify(m.content);
431
+ // Truncate very long individual messages
432
+ const maxLen = 2000;
433
+ const truncated = content.length > maxLen
434
+ ? content.slice(0, maxLen) + '...[truncated]'
435
+ : content;
436
+ return `<${m.role}>\n${truncated}\n</${m.role}>`;
437
+ });
438
+ return [
439
+ '[Previous Conversation Context]',
440
+ 'The following is conversation history from this session that was lost due to a session reset.',
441
+ 'Use it as context for the current request.',
442
+ '',
443
+ '<conversation>',
444
+ ...lines,
445
+ '</conversation>',
446
+ '',
447
+ '[End of Previous Context]',
448
+ '',
449
+ '',
450
+ ].join('\n');
451
+ }
452
+ async createSession(handler) {
453
+ const { sessionId, projectPath, model } = handler;
454
+ const sessionModel = model || CLAUDE_CONFIG.model;
455
+ // Ensure project directory exists - prevents ENOENT errors when SDK spawns process
456
+ if (!existsSync(projectPath)) {
457
+ try {
458
+ mkdirSync(projectPath, { recursive: true });
459
+ console.log(`[AgentSessionManager] Created project directory: ${projectPath}`);
460
+ }
461
+ catch (error) {
462
+ console.error(`[AgentSessionManager] Failed to create project directory ${projectPath}:`, error.message);
463
+ // Fallback for /home/abc - try to create symlink to /tmp/abc
464
+ if (projectPath === '/home/abc') {
465
+ const fallbackPath = '/tmp/abc';
466
+ try {
467
+ mkdirSync(fallbackPath, { recursive: true });
468
+ await symlinkAsync(fallbackPath, projectPath, 'dir');
469
+ console.log(`[AgentSessionManager] Created symlink: ${projectPath} -> ${fallbackPath}`);
470
+ }
471
+ catch (symlinkError) {
472
+ console.log(`[AgentSessionManager] Symlink creation failed: ${symlinkError.message}`);
473
+ }
474
+ }
475
+ }
476
+ }
477
+ // If session already exists, update mutable fields
478
+ if (this.sessions.has(sessionId)) {
479
+ const existingSession = this.sessions.get(sessionId);
480
+ // Update model if a new one is provided
481
+ if (model && model !== existingSession.model) {
482
+ existingSession.model = model;
483
+ }
484
+ // Update enabled modules and settings if provided
485
+ // Ensure abort controller is fresh for new queries
486
+ existingSession.abortController = new AbortController();
487
+ // Update handler reference
488
+ this.sessionHandlers.set(sessionId, handler);
489
+ return;
490
+ }
491
+ // Store the handler for this session
492
+ this.sessionHandlers.set(sessionId, handler);
493
+ const abortController = new AbortController();
494
+ // Restore claudeSessionId from disk (survives CLI restart)
495
+ const persistedState = loadSessionState(sessionId);
496
+ const restoredClaudeSessionId = persistedState?.claudeSessionId;
497
+ if (restoredClaudeSessionId) {
498
+ console.log(`[AgentSessionManager] Restored claudeSessionId for session ${sessionId}: ${restoredClaudeSessionId}`);
499
+ }
500
+ this.sessions.set(sessionId, {
501
+ abortController,
502
+ messages: [],
503
+ totalInputTokens: 0,
504
+ totalOutputTokens: 0,
505
+ totalCostUsd: 0,
506
+ promptQueue: [],
507
+ isProcessingQueue: false,
508
+ claudeSessionId: restoredClaudeSessionId, // Restored from disk or undefined
509
+ childProcesses: new Set(),
510
+ claudeProcessGroupId: undefined,
511
+ currentPromptId: undefined,
512
+ model: sessionModel,
513
+ });
514
+ // Auto-regenerate CLAUDE.md for fresh project context
515
+ await this.regenerateClaudeMd(projectPath);
516
+ }
517
+ /**
518
+ * Regenerate CLAUDE.md for fresh project context
519
+ */
520
+ async regenerateClaudeMd(projectPath) {
521
+ try {
522
+ const scriptPath = path.join(projectPath, 'scripts', 'generate-claude-md.js');
523
+ if (existsSync(scriptPath)) {
524
+ execSync(`node "${scriptPath}"`, { cwd: projectPath, stdio: 'ignore' });
525
+ }
526
+ }
527
+ catch (error) {
528
+ // Don't fail session if CLAUDE.md generation fails
529
+ console.error('[AgentSessionManager] Failed to regenerate CLAUDE.md:', error);
530
+ }
531
+ }
532
+ async sendPrompt(sessionId, prompt, enhancers = [], handler) {
533
+ // Ensure session exists
534
+ if (!this.sessions.has(sessionId)) {
535
+ await this.createSession(handler);
536
+ }
537
+ const session = this.sessions.get(sessionId);
538
+ // Update session model if provided in handler
539
+ if (handler.model && handler.model !== session.model) {
540
+ session.model = handler.model;
541
+ }
542
+ // Add prompt to queue - store promptId for cancellation
543
+ session.promptQueue.push({
544
+ prompt,
545
+ enhancers,
546
+ handler,
547
+ timestamp: Date.now(),
548
+ promptId: handler.promptId,
549
+ abortController: new AbortController(), // Pre-create for queued cancellation
550
+ model: handler.model || session.model, // Use handler model or fall back to session model
551
+ attachments: handler.attachments // Pass attachments through
552
+ });
553
+ // Start processing queue if not already processing
554
+ if (!session.isProcessingQueue) {
555
+ this.processPromptQueue(sessionId);
556
+ }
557
+ else if (session.isProcessingQueue && !session.activeQueryStream && !this.emergencyStopInProgress.has(sessionId)) {
558
+ // Safety: isProcessingQueue is true but there's no active stream and no emergency stop
559
+ // This means the queue got stuck (e.g. from a previous abort return that bypassed cleanup)
560
+ console.warn(`[agentSession] Queue stuck detected for session ${sessionId}, resetting isProcessingQueue`);
561
+ session.isProcessingQueue = false;
562
+ this.processPromptQueue(sessionId);
563
+ }
564
+ }
565
+ async processPromptQueue(sessionId) {
566
+ const session = this.sessions.get(sessionId);
567
+ if (!session)
568
+ return;
569
+ if (session.isProcessingQueue)
570
+ return;
571
+ session.isProcessingQueue = true;
572
+ while (session.promptQueue.length > 0 && !this.emergencyStopInProgress.has(sessionId)) {
573
+ const queuedPrompt = session.promptQueue.shift();
574
+ const { enhancers, handler, promptId: queuedPromptId, abortController: queuedAbortController } = queuedPrompt;
575
+ const { projectPath, promptId, onOutput: _onOutput, onError: _onError, onComplete: _onComplete, onStatusUpdate } = handler;
576
+ const onOutput = _onOutput;
577
+ const onError = _onError;
578
+ const onComplete = _onComplete;
579
+ // Write attachments to temp dir and inject paths into prompt
580
+ let effectivePrompt = queuedPrompt.prompt;
581
+ let attachmentDir;
582
+ if (queuedPrompt.attachments && queuedPrompt.attachments.length > 0) {
583
+ attachmentDir = path.join(os.tmpdir(), 'talk-to-code', 'attachments', sessionId, String(promptId || Date.now()));
584
+ mkdirSync(attachmentDir, { recursive: true });
585
+ const attachmentLines = [];
586
+ for (const att of queuedPrompt.attachments) {
587
+ const safeName = att.filename.replace(/[^a-zA-Z0-9._-]/g, '_');
588
+ const filePath = path.join(attachmentDir, safeName);
589
+ const buf = Buffer.from(att.content, 'base64');
590
+ writeFileSync(filePath, buf);
591
+ attachmentLines.push(`- ${safeName} (${att.mimeType}): path="${filePath}"`);
592
+ }
593
+ effectivePrompt += `\n\n[Attachments]\nThe following files are attached and available on disk. Use the analyze_image tool to examine images.\n${attachmentLines.join('\n')}`;
594
+ console.log(`[agentSession] Wrote ${queuedPrompt.attachments.length} attachment(s) to ${attachmentDir}`);
595
+ }
596
+ try {
597
+ // Verify promptId is present in handler
598
+ if (!promptId) {
599
+ console.error(`[agentSession] Missing promptId in handler for prompt: ${queuedPrompt.prompt.substring(0, 50)}...`);
600
+ onError?.('Missing promptId in handler');
601
+ continue;
602
+ }
603
+ // Check if this queued prompt was cancelled before processing started
604
+ if (queuedAbortController?.signal.aborted) {
605
+ console.log(`[agentSession] Queued prompt ${promptId} was cancelled before processing`);
606
+ onStatusUpdate?.('cancelled');
607
+ onComplete(null);
608
+ continue;
609
+ }
610
+ // Use the queued promptId and abortController, or fallback to handler values
611
+ const effectivePromptId = queuedPromptId || promptId;
612
+ const abortController = queuedAbortController || new AbortController();
613
+ session.abortController = abortController;
614
+ session.currentPromptId = effectivePromptId; // Track current prompt for emergency stop
615
+ this.promptAbortControllers.set(effectivePromptId, abortController);
616
+ // Emit status update: CLI is starting to process the prompt (IMMEDIATELY)
617
+ // This ensures real-time status updates before any async operations
618
+ onStatusUpdate?.('running');
619
+ // Wait for current query to finish before starting next prompt
620
+ if (session.activeQueryStream !== undefined) {
621
+ try {
622
+ for await (const _ of session.activeQueryStream) { }
623
+ }
624
+ catch (err) {
625
+ console.error(`[AgentSession] Error draining active query stream:`, err);
626
+ }
627
+ session.activeQueryStream = undefined;
628
+ }
629
+ session.activeQueryStream = undefined;
630
+ // Build final prompt with enhancers
631
+ let finalPrompt = effectivePrompt;
632
+ if (enhancers && enhancers.length > 0) {
633
+ const skillContent = getSkillContent(enhancers);
634
+ if (skillContent) {
635
+ finalPrompt = `${skillContent}\n\n${effectivePrompt}`;
636
+ }
637
+ }
638
+ // Inject DB history if context was lost in a previous prompt (resume failed)
639
+ if (session.contextLost) {
640
+ console.log(`[agentSession] Context was lost previously, fetching DB history for session ${sessionId}`);
641
+ try {
642
+ const history = await this.fetchSessionHistory(sessionId);
643
+ if (history.length > 0) {
644
+ const historyPrefix = this.formatHistoryForPrompt(history);
645
+ finalPrompt = historyPrefix + finalPrompt;
646
+ console.log(`[agentSession] Injected ${history.length} history entries into prompt for session ${sessionId}`);
647
+ }
648
+ else {
649
+ console.log(`[agentSession] No DB history available for session ${sessionId}`);
650
+ }
651
+ }
652
+ catch (err) {
653
+ console.error(`[agentSession] Failed to fetch/format history:`, err);
654
+ }
655
+ session.contextLost = false; // Reset after injection attempt
656
+ }
657
+ // Add user message to history
658
+ session.messages.push({
659
+ role: 'user',
660
+ content: finalPrompt,
661
+ timestamp: Date.now()
662
+ });
663
+ // Emit context info
664
+ onOutput({
665
+ type: 'system',
666
+ data: {
667
+ message: `Context: ${session.messages.length} messages, ${session.totalInputTokens + session.totalOutputTokens} total tokens`,
668
+ contextInfo: {
669
+ messageCount: session.messages.length,
670
+ totalInputTokens: session.totalInputTokens,
671
+ totalOutputTokens: session.totalOutputTokens,
672
+ totalTokens: session.totalInputTokens + session.totalOutputTokens,
673
+ totalCostUsd: session.totalCostUsd,
674
+ lastUsage: session.lastUsage
675
+ }
676
+ },
677
+ timestamp: Date.now(),
678
+ metadata: {
679
+ subtype: 'context_info',
680
+ contextSize: session.messages.length,
681
+ totalTokens: session.totalInputTokens + session.totalOutputTokens
682
+ }
683
+ });
684
+ // Use cached Claude executable path (resolved at module load time for performance)
685
+ const pathToClaudeCodeExecutable = CACHED_CLAUDE_PATH;
686
+ // Build query options - include abort signal for cancellation
687
+ const queryOptions = {
688
+ signal: abortController.signal, // Pass abort signal to SDK for interruption
689
+ cwd: projectPath,
690
+ apiKey: CLAUDE_CONFIG.apiKey,
691
+ model: CLAUDE_CONFIG.model,
692
+ tools: { type: 'preset', preset: 'claude_code' },
693
+ disallowedTools: ['AskUserQuestion', 'analyze_image'], // Disable built-in analyze_image (we provide our own via MCP)
694
+ settingSources: ['project'], // Enable CLAUDE.md loading
695
+ permissionMode: 'bypassPermissions',
696
+ allowDangerouslySkipPermissions: true,
697
+ // Create a fresh MCP server for each query call (SDK connects transport internally, cannot reuse)
698
+ ...(() => {
699
+ const mcpServer = this.buildMcpServer(sessionId, attachmentDir, promptId);
700
+ return { mcpServers: { 'claude-voice-modules': mcpServer } };
701
+ })(),
702
+ ...(pathToClaudeCodeExecutable ? { pathToClaudeCodeExecutable } : {}),
703
+ spawnClaudeCodeProcess: (spawnOptions) => {
704
+ const { command, args, cwd: cwd2, env, signal } = spawnOptions;
705
+ // Debug: log what env/args are being passed to Claude process
706
+ console.log(`[agentSession] Spawn ANTHROPIC_BASE_URL:`, env?.ANTHROPIC_BASE_URL || '(not set)');
707
+ console.log(`[agentSession] Spawn ANTHROPIC_API_KEY:`, env?.ANTHROPIC_API_KEY ? '(set)' : '(not set)');
708
+ console.log(`[agentSession] Spawn args:`, args?.join(' '));
709
+ // Only check file existence when command is a path (not a bare name like "claude" from PATH)
710
+ const hasPathSep = command.includes(path.sep) || command.includes('/') || command.includes('\\');
711
+ if (hasPathSep && !existsSync(command)) {
712
+ throw new Error(`Executable not found at ${command}. Set path with: ttc config --claude-path "<path>" (or TTC_CLAUDE_PATH)`);
713
+ }
714
+ try {
715
+ if (cwd2 && !existsSync(cwd2)) {
716
+ mkdirSync(cwd2, { recursive: true });
717
+ }
718
+ }
719
+ catch (err) {
720
+ console.error(`[AgentSession] Failed to create working directory ${cwd2}:`, err);
721
+ }
722
+ const isWin = process.platform === 'win32';
723
+ // Ensure PATH includes common node locations, especially in containers
724
+ const defaultPath = isWin
725
+ ? (process.env.Path || process.env.PATH || '')
726
+ : '/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin';
727
+ const spawnEnv = {
728
+ ...env,
729
+ PATH: env.PATH || process.env.PATH || defaultPath,
730
+ ...(isWin
731
+ ? {
732
+ USERPROFILE: env.USERPROFILE || process.env.USERPROFILE || os.homedir(),
733
+ USERNAME: env.USERNAME || process.env.USERNAME || 'user',
734
+ HOME: env.USERPROFILE || process.env.USERPROFILE || os.homedir(), // Windows: use Windows home, not /home/user
735
+ }
736
+ : { HOME: env.HOME || process.env.HOME || os.homedir(), USER: env.USER || process.env.USER || 'user' }),
737
+ };
738
+ // If command is 'node' and not found, try to resolve it
739
+ if (command === 'node' && !hasPathSep) {
740
+ try {
741
+ const nodePath = execSync('which node', { encoding: 'utf-8', env: spawnEnv }).trim();
742
+ if (nodePath) {
743
+ const child = spawn(nodePath, args, {
744
+ cwd: cwd2 || process.cwd(),
745
+ stdio: ["pipe", "pipe", env.DEBUG_CLAUDE_AGENT_SDK ? "pipe" : "ignore"],
746
+ signal,
747
+ env: spawnEnv,
748
+ windowsHide: true,
749
+ detached: !isWin // Create process group on Unix for tree-killing
750
+ });
751
+ // Track child process for force-kill
752
+ if (!session.childProcesses)
753
+ session.childProcesses = new Set();
754
+ session.childProcesses.add(child);
755
+ // Store process group ID for Unix (negative PID kills entire group)
756
+ if (!isWin && child.pid) {
757
+ session.claudeProcessGroupId = child.pid;
758
+ }
759
+ // Clean up when process exits
760
+ child.on('exit', () => {
761
+ session.childProcesses.delete(child);
762
+ });
763
+ child.on('error', () => {
764
+ session.childProcesses.delete(child);
765
+ });
766
+ return child;
767
+ }
768
+ }
769
+ catch {
770
+ // Fall through to original spawn
771
+ }
772
+ }
773
+ const child = spawn(command, args, {
774
+ cwd: cwd2 || process.cwd(),
775
+ stdio: ["pipe", "pipe", env.DEBUG_CLAUDE_AGENT_SDK ? "pipe" : "ignore"],
776
+ signal,
777
+ env: spawnEnv,
778
+ windowsHide: true,
779
+ detached: !isWin // Create process group on Unix for tree-killing
780
+ });
781
+ // Track child process for force-kill
782
+ if (!session.childProcesses)
783
+ session.childProcesses = new Set();
784
+ session.childProcesses.add(child);
785
+ // Store process group ID for Unix (negative PID kills entire group)
786
+ if (!isWin && child.pid) {
787
+ session.claudeProcessGroupId = child.pid;
788
+ }
789
+ // Clean up when process exits
790
+ child.on('exit', () => {
791
+ session.childProcesses.delete(child);
792
+ });
793
+ child.on('error', () => {
794
+ session.childProcesses.delete(child);
795
+ });
796
+ return child;
797
+ },
798
+ env: envForClaudeCodeChild(),
799
+ hooks: {
800
+ // HookCallbackMatcher format: each entry must be { hooks: [callback] }
801
+ // NOT a raw callback array — wrong format silently breaks MCP server registration.
802
+ PostToolUse: [{
803
+ hooks: [(_toolResult) => {
804
+ // Tool result is handled by the user message handler below
805
+ return { continue: true };
806
+ }]
807
+ }],
808
+ Notification: [{
809
+ hooks: [(notification) => {
810
+ onOutput({
811
+ type: 'progress',
812
+ data: notification,
813
+ timestamp: Date.now(),
814
+ metadata: {
815
+ progress: {
816
+ message: typeof notification === 'string' ? notification : JSON.stringify(notification)
817
+ }
818
+ }
819
+ });
820
+ return { continue: true };
821
+ }]
822
+ }],
823
+ }
824
+ };
825
+ // Log model being used for debugging
826
+ const sessionModel = session.model || CLAUDE_CONFIG.model;
827
+ // Resolve local model adapter overrides (if using OpenAI-compatible endpoint)
828
+ let effectiveModel = sessionModel;
829
+ let effectiveApiKey = queryOptions.apiKey;
830
+ let effectiveEnv = queryOptions.env;
831
+ let effectiveSettings;
832
+ const localOverrides = await getLocalModelEnvOverrides(sessionModel);
833
+ if (localOverrides) {
834
+ effectiveModel = unwrapModelName(sessionModel);
835
+ effectiveApiKey = localOverrides.apiKey;
836
+ effectiveEnv = {
837
+ ...effectiveEnv,
838
+ ANTHROPIC_API_KEY: localOverrides.apiKey,
839
+ ANTHROPIC_BASE_URL: localOverrides.baseUrl,
840
+ };
841
+ // Override settings to prevent ~/.claude/settings.json env from overriding our proxy URL.
842
+ // Claude Code CLI reads settings.json → env section and applies those on top of spawn env,
843
+ // which would replace our ANTHROPIC_BASE_URL with the z.ai URL.
844
+ effectiveSettings = { env: { ANTHROPIC_API_KEY: localOverrides.apiKey, ANTHROPIC_BASE_URL: localOverrides.baseUrl } };
845
+ console.log(`[agentSession] Using local model adapter: ${sessionModel} -> ${localOverrides.baseUrl}`);
846
+ console.log(`[agentSession] effectiveSettings for local model:`, JSON.stringify(effectiveSettings));
847
+ }
848
+ else {
849
+ // Resolve provider for multi-provider switching (Z.ai / MiniMax)
850
+ const resolved = resolveProvider(sessionModel);
851
+ console.log(`[agentSession] Resolved provider: ${resolved.provider} for model: ${sessionModel}`);
852
+ effectiveApiKey = resolved.apiKey;
853
+ effectiveEnv = envForClaudeCodeChild(undefined, resolved);
854
+ // Build settings env to prevent ~/.claude/settings.json from overriding our credentials
855
+ const settingsEnv = {
856
+ ANTHROPIC_API_KEY: resolved.apiKey,
857
+ ANTHROPIC_BASE_URL: resolved.baseUrl,
858
+ };
859
+ // For MiniMax: also override all model aliases in settings
860
+ if (resolved.provider === 'minimax') {
861
+ settingsEnv.ANTHROPIC_MODEL = resolved.model;
862
+ settingsEnv.ANTHROPIC_DEFAULT_SONNET_MODEL = resolved.model;
863
+ settingsEnv.ANTHROPIC_DEFAULT_OPUS_MODEL = resolved.model;
864
+ settingsEnv.ANTHROPIC_DEFAULT_HAIKU_MODEL = resolved.model;
865
+ settingsEnv.CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC = '1';
866
+ }
867
+ // For OpenRouter: also override all model aliases in settings
868
+ if (resolved.provider === 'openrouter') {
869
+ settingsEnv.ANTHROPIC_MODEL = resolved.model;
870
+ settingsEnv.ANTHROPIC_DEFAULT_SONNET_MODEL = resolved.model;
871
+ settingsEnv.ANTHROPIC_DEFAULT_OPUS_MODEL = resolved.model;
872
+ settingsEnv.ANTHROPIC_DEFAULT_HAIKU_MODEL = resolved.model;
873
+ settingsEnv.CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC = '1';
874
+ }
875
+ effectiveSettings = { env: settingsEnv };
876
+ console.log(`[agentSession] Provider: ${resolved.provider}, baseUrl: ${resolved.baseUrl}, model: ${resolved.model}`);
877
+ }
878
+ // Create query stream - resume session if we have a Claude session ID
879
+ // Always explicitly set model even when resuming to ensure we use the session's model
880
+ const queryStream = query({
881
+ prompt: finalPrompt,
882
+ options: {
883
+ ...queryOptions,
884
+ apiKey: effectiveApiKey,
885
+ model: effectiveModel,
886
+ env: effectiveEnv,
887
+ ...(effectiveSettings ? { settings: effectiveSettings } : {}),
888
+ ...(session.claudeSessionId && !localOverrides && (() => {
889
+ // Don't resume if provider changed since last session (context format may differ)
890
+ const persisted = loadSessionState(sessionId);
891
+ const currentProvider = resolveProvider(sessionModel).provider;
892
+ return persisted?.provider === currentProvider;
893
+ })() ? { resume: session.claudeSessionId } : {})
894
+ // Note: don't resume session for local models - context format differs
895
+ // Note: also don't resume if provider differs from persisted session provider (context format may differ)
896
+ }
897
+ });
898
+ session.activeQueryStream = queryStream;
899
+ // Process messages with enhanced abort checking
900
+ // Create a wrapped stream that checks abort status more frequently
901
+ const abortCheckInterval = 200; // Check every 200ms
902
+ let lastAbortCheck = Date.now();
903
+ try {
904
+ for await (const message of queryStream) {
905
+ // Check abort on each message (existing behavior)
906
+ if (session.abortController.signal.aborted || this.emergencyStopInProgress.has(sessionId)) {
907
+ console.log(`[agentSession] Aborting prompt ${effectivePromptId} - abort signal received`);
908
+ break;
909
+ }
910
+ // Periodic check for long-running operations
911
+ const now = Date.now();
912
+ if (now - lastAbortCheck > abortCheckInterval) {
913
+ lastAbortCheck = now;
914
+ if (session.abortController.signal.aborted || this.emergencyStopInProgress.has(sessionId)) {
915
+ console.log(`[agentSession] Aborting prompt ${effectivePromptId} - periodic check detected abort`);
916
+ break;
917
+ }
918
+ }
919
+ // Capture Claude SDK session ID from system init message
920
+ if (message.type === 'system' && message.subtype === 'init') {
921
+ const systemMsg = message;
922
+ if (systemMsg.session_id) {
923
+ // Detect context loss: session_id changed unexpectedly (resume failed)
924
+ if (session.claudeSessionId && session.claudeSessionId !== systemMsg.session_id) {
925
+ session.contextLost = true;
926
+ console.warn(`[AgentSessionManager] Context lost! Session ID changed: ${session.claudeSessionId} → ${systemMsg.session_id}`);
927
+ }
928
+ session.claudeSessionId = systemMsg.session_id;
929
+ saveSessionState(sessionId, { claudeSessionId: systemMsg.session_id, model: session.model, provider: resolveProvider(session.model).provider, updatedAt: Date.now() });
930
+ }
931
+ }
932
+ if (message.type === 'assistant') {
933
+ const msg = message;
934
+ // Capture Claude session ID from assistant message (always update to track session changes)
935
+ if (msg.session_id) {
936
+ if (session.claudeSessionId && session.claudeSessionId !== msg.session_id) {
937
+ session.contextLost = true;
938
+ console.warn(`[AgentSessionManager] Context lost! Session ID changed in assistant msg: ${session.claudeSessionId} → ${msg.session_id}`);
939
+ }
940
+ session.claudeSessionId = msg.session_id;
941
+ saveSessionState(sessionId, { claudeSessionId: msg.session_id, model: session.model, provider: resolveProvider(session.model).provider, updatedAt: Date.now() });
942
+ }
943
+ session.messages.push({
944
+ role: 'assistant',
945
+ content: msg.message,
946
+ timestamp: Date.now()
947
+ });
948
+ onOutput({
949
+ type: 'assistant',
950
+ data: msg.message,
951
+ timestamp: Date.now(),
952
+ metadata: {
953
+ parentToolUseId: msg.parent_tool_use_id,
954
+ uuid: msg.uuid,
955
+ sessionId: msg.session_id,
956
+ error: msg.error,
957
+ contextSize: session.messages.length
958
+ }
959
+ });
960
+ }
961
+ else if (message.type === 'result') {
962
+ const msg = message;
963
+ // Update usage tracking
964
+ if (msg.usage) {
965
+ const usage = msg.usage;
966
+ const inputTokens = usage.input_tokens || usage.inputTokens || 0;
967
+ const outputTokens = usage.output_tokens || usage.outputTokens || 0;
968
+ session.totalInputTokens += inputTokens;
969
+ session.totalOutputTokens += outputTokens;
970
+ session.totalCostUsd += msg.total_cost_usd || 0;
971
+ session.lastUsage = {
972
+ inputTokens,
973
+ outputTokens,
974
+ totalTokens: inputTokens + outputTokens
975
+ };
976
+ }
977
+ onOutput({
978
+ type: 'result',
979
+ data: msg,
980
+ timestamp: Date.now(),
981
+ metadata: {
982
+ subtype: msg.subtype,
983
+ isError: msg.is_error,
984
+ exitCode: msg.is_error ? 1 : 0,
985
+ durationMs: msg.duration_ms,
986
+ durationApiMs: msg.duration_api_ms,
987
+ totalCostUsd: msg.total_cost_usd,
988
+ usage: msg.usage,
989
+ modelUsage: msg.modelUsage,
990
+ structuredOutput: msg.structured_output,
991
+ numTurns: msg.num_turns,
992
+ contextInfo: {
993
+ messageCount: session.messages.length,
994
+ totalInputTokens: session.totalInputTokens,
995
+ totalOutputTokens: session.totalOutputTokens,
996
+ totalTokens: session.totalInputTokens + session.totalOutputTokens,
997
+ totalCostUsd: session.totalCostUsd,
998
+ lastUsage: session.lastUsage
999
+ }
1000
+ }
1001
+ });
1002
+ // Emit status update: CLI finished processing (real-time)
1003
+ const exitCode = msg.is_error ? 1 : 0;
1004
+ // Clean up abort controller
1005
+ this.promptAbortControllers.delete(promptId);
1006
+ // Update status immediately based on exit code
1007
+ onStatusUpdate?.(exitCode === 0 ? 'completed' : 'error');
1008
+ onComplete(exitCode);
1009
+ session.activeQueryStream = undefined;
1010
+ break; // Prompt complete, continue to next in queue
1011
+ }
1012
+ else if (message.type === 'user') {
1013
+ const msg = message;
1014
+ // SDK sends tool results with TWO data sources:
1015
+ // message.content[0] — {type:'tool_result', tool_use_id:'...', content: <raw text>}
1016
+ // msg.tool_use_result — structured object with {stdout,stderr} for Bash,
1017
+ // {type,file} for Read, or [{type:'text',text:'...'}] for MCP tools
1018
+ //
1019
+ // The tool_use_id lives ONLY in message.content[].tool_use_id.
1020
+ // The structured result lives in tool_use_result for built-in tools.
1021
+ // For MCP tools, tool_use_result is a content-block array we need to parse.
1022
+ let toolResult = null;
1023
+ let toolUseId = msg.parent_tool_use_id;
1024
+ // STEP 1: Always extract tool_use_id from message.content (authoritative source)
1025
+ if (Array.isArray(msg.message?.content)) {
1026
+ const contentBlocks = msg.message.content;
1027
+ const toolResultBlock = contentBlocks.find((c) => c.type === 'tool_result');
1028
+ if (toolResultBlock?.tool_use_id) {
1029
+ toolUseId = toolResultBlock.tool_use_id;
1030
+ }
1031
+ }
1032
+ // STEP 2: Use tool_use_result as the primary data source.
1033
+ // For built-in tools (Bash, Read, Edit, Write, Glob, Grep) it's a structured
1034
+ // object like {stdout, stderr} or {type:'text', file:{...}} — use directly.
1035
+ // For MCP tools it's a content-block array [{type:'text', text:'...'}] — parse text.
1036
+ if (msg.tool_use_result) {
1037
+ const raw = msg.tool_use_result;
1038
+ if (Array.isArray(raw)) {
1039
+ // MCP tool result: [{type:'text', text:'...'}] — extract and parse
1040
+ const textParts = raw
1041
+ .filter((c) => c.type === 'text')
1042
+ .map((c) => c.text);
1043
+ const rawContent = textParts.join('\n');
1044
+ try {
1045
+ toolResult = JSON.parse(rawContent);
1046
+ }
1047
+ catch {
1048
+ toolResult = { content: rawContent, type: 'text' };
1049
+ }
1050
+ }
1051
+ else if (typeof raw === 'object' && raw !== null) {
1052
+ // Built-in tool result: {stdout, stderr, ...} or {type, file, ...} — use directly
1053
+ toolResult = raw;
1054
+ }
1055
+ else if (typeof raw === 'string') {
1056
+ try {
1057
+ toolResult = JSON.parse(raw);
1058
+ }
1059
+ catch {
1060
+ toolResult = { content: raw, type: 'text' };
1061
+ }
1062
+ }
1063
+ }
1064
+ // STEP 3: Fallback to parsing message.content if tool_use_result wasn't available
1065
+ // (e.g. subagent calls where tool_use_result may be absent)
1066
+ if (!toolResult && Array.isArray(msg.message?.content)) {
1067
+ const contentBlocks = msg.message.content;
1068
+ const toolResultBlock = contentBlocks.find((c) => c.type === 'tool_result');
1069
+ if (toolResultBlock) {
1070
+ if (typeof toolResultBlock.content === 'string') {
1071
+ try {
1072
+ toolResult = JSON.parse(toolResultBlock.content);
1073
+ }
1074
+ catch {
1075
+ toolResult = { content: toolResultBlock.content, type: 'text' };
1076
+ }
1077
+ }
1078
+ else if (Array.isArray(toolResultBlock.content)) {
1079
+ const textParts = toolResultBlock.content
1080
+ .filter((c) => c.type === 'text')
1081
+ .map((c) => c.text);
1082
+ const rawContent = textParts.join('\n');
1083
+ try {
1084
+ toolResult = JSON.parse(rawContent);
1085
+ }
1086
+ catch {
1087
+ toolResult = { content: rawContent, type: 'text' };
1088
+ }
1089
+ }
1090
+ else {
1091
+ toolResult = toolResultBlock;
1092
+ }
1093
+ }
1094
+ }
1095
+ if (toolResult) {
1096
+ // History lookup is authoritative: the assistant's tool_use block names the tool.
1097
+ // Heuristic detection can't distinguish Bash/Grep/Glob/etc (all have {stdout,stderr}).
1098
+ const historyName = lookupToolNameFromHistory(session.messages, toolUseId);
1099
+ const detectedName = extractToolName(toolResult);
1100
+ const resolvedName = historyName || detectedName;
1101
+ onOutput({
1102
+ type: 'tool_result',
1103
+ data: toolResult,
1104
+ timestamp: Date.now(),
1105
+ metadata: {
1106
+ toolName: resolvedName,
1107
+ toolResult: toolResult,
1108
+ toolUseId: toolUseId || undefined,
1109
+ parentToolUseId: msg.parent_tool_use_id,
1110
+ isSynthetic: msg.isSynthetic
1111
+ }
1112
+ });
1113
+ }
1114
+ else {
1115
+ onOutput({
1116
+ type: 'user',
1117
+ data: msg.message,
1118
+ timestamp: Date.now(),
1119
+ metadata: {
1120
+ parentToolUseId: null,
1121
+ isSynthetic: msg.isSynthetic
1122
+ }
1123
+ });
1124
+ }
1125
+ }
1126
+ else if (message.type === 'system') {
1127
+ const sysMsg = message;
1128
+ onOutput({
1129
+ type: 'system',
1130
+ data: { ...sysMsg, subtype: sysMsg.subtype },
1131
+ timestamp: Date.now(),
1132
+ metadata: {
1133
+ subtype: sysMsg.subtype,
1134
+ messageType: sysMsg.subtype || 'system'
1135
+ }
1136
+ });
1137
+ }
1138
+ else if (message.type === 'tool_progress') {
1139
+ const msg = message;
1140
+ onOutput({
1141
+ type: 'tool_progress',
1142
+ data: msg,
1143
+ timestamp: Date.now(),
1144
+ metadata: {
1145
+ toolName: msg.tool_name,
1146
+ toolUseId: msg.tool_use_id,
1147
+ elapsedTimeSeconds: msg.elapsed_time_seconds,
1148
+ parentToolUseId: msg.parent_tool_use_id
1149
+ }
1150
+ });
1151
+ }
1152
+ else if (message.type === 'auth_status') {
1153
+ onOutput({
1154
+ type: 'auth_status',
1155
+ data: message,
1156
+ timestamp: Date.now(),
1157
+ metadata: {
1158
+ isAuthenticating: message.isAuthenticating,
1159
+ error: message.error
1160
+ }
1161
+ });
1162
+ }
1163
+ else if (message.type === 'stream_event') {
1164
+ onOutput({
1165
+ type: 'stream_event',
1166
+ data: message.event || message,
1167
+ timestamp: Date.now(),
1168
+ metadata: {
1169
+ parentToolUseId: message.parent_tool_use_id
1170
+ }
1171
+ });
1172
+ }
1173
+ else if (message.type === 'tool_use_summary') {
1174
+ const msg = message;
1175
+ onOutput({
1176
+ type: 'tool_use_summary',
1177
+ data: msg.summary || '',
1178
+ timestamp: Date.now(),
1179
+ metadata: {
1180
+ precedingToolUseIds: msg.preceding_tool_use_ids,
1181
+ uuid: msg.uuid,
1182
+ sessionId: msg.session_id
1183
+ }
1184
+ });
1185
+ }
1186
+ else if (message.type === 'rate_limit_event') {
1187
+ const msg = message;
1188
+ onOutput({
1189
+ type: 'rate_limit_event',
1190
+ data: msg,
1191
+ timestamp: Date.now(),
1192
+ metadata: {
1193
+ rateLimitInfo: msg.rate_limit_info,
1194
+ uuid: msg.uuid,
1195
+ sessionId: msg.session_id
1196
+ }
1197
+ });
1198
+ }
1199
+ else if (message.type === 'prompt_suggestion') {
1200
+ const msg = message;
1201
+ onOutput({
1202
+ type: 'prompt_suggestion',
1203
+ data: msg.suggestion || '',
1204
+ timestamp: Date.now(),
1205
+ metadata: {
1206
+ uuid: msg.uuid,
1207
+ sessionId: msg.session_id
1208
+ }
1209
+ });
1210
+ }
1211
+ else if (message.type === 'keep_alive') {
1212
+ // Internal keepalive - silently ignore
1213
+ }
1214
+ else {
1215
+ onOutput({
1216
+ type: 'stdout',
1217
+ data: JSON.stringify(message, null, 2),
1218
+ timestamp: Date.now()
1219
+ });
1220
+ }
1221
+ }
1222
+ }
1223
+ catch (streamError) {
1224
+ // Check if this was an abort-related error
1225
+ if (session.abortController.signal.aborted || this.emergencyStopInProgress.has(sessionId)) {
1226
+ console.log(`[agentSession] Stream aborted for prompt ${effectivePromptId}`);
1227
+ // Handle abort gracefully
1228
+ onStatusUpdate?.('cancelled');
1229
+ onComplete(null);
1230
+ session.activeQueryStream = undefined;
1231
+ session.currentPromptId = undefined;
1232
+ // Use break instead of return to ensure isProcessingQueue gets reset
1233
+ // after the while loop at the end of processPromptQueue
1234
+ break;
1235
+ }
1236
+ // Re-throw non-abort errors
1237
+ throw streamError;
1238
+ }
1239
+ session.activeQueryStream = undefined;
1240
+ session.currentPromptId = undefined; // Clear current prompt when done
1241
+ }
1242
+ catch (error) {
1243
+ const currentSession = this.sessions.get(sessionId);
1244
+ if (currentSession) {
1245
+ currentSession.activeQueryStream = undefined;
1246
+ }
1247
+ // Clean up abort controller (promptId is guaranteed to exist here due to check at line 204)
1248
+ if (promptId) {
1249
+ this.promptAbortControllers.delete(promptId);
1250
+ }
1251
+ // Emit status update: CLI encountered an error (real-time)
1252
+ onStatusUpdate?.('error');
1253
+ onError(error.message || 'Unknown error');
1254
+ onComplete(null);
1255
+ }
1256
+ }
1257
+ session.isProcessingQueue = false;
1258
+ // Continue processing queue if more prompts are waiting
1259
+ if (session.promptQueue.length > 0) {
1260
+ this.processPromptQueue(sessionId);
1261
+ }
1262
+ }
1263
+ async deleteSession(sessionId) {
1264
+ const session = this.sessions.get(sessionId);
1265
+ if (session) {
1266
+ session.abortController.abort();
1267
+ session.activeQueryStream = undefined;
1268
+ this.sessions.delete(sessionId);
1269
+ }
1270
+ // Clean up persisted state
1271
+ deleteSessionState(sessionId);
1272
+ }
1273
+ /**
1274
+ * Cancel a running or queued prompt by promptId
1275
+ */
1276
+ async cancelPrompt(promptId, sessionId, onStatusUpdate) {
1277
+ const session = this.sessions.get(sessionId);
1278
+ if (!session) {
1279
+ return false; // Session not found
1280
+ }
1281
+ // First, check if prompt is in the queue (not yet started)
1282
+ const queuedIndex = session.promptQueue.findIndex(p => p.promptId === promptId);
1283
+ if (queuedIndex !== -1) {
1284
+ // Found in queue - abort it and remove from queue
1285
+ const queuedPrompt = session.promptQueue[queuedIndex];
1286
+ if (queuedPrompt.abortController) {
1287
+ queuedPrompt.abortController.abort();
1288
+ }
1289
+ // Remove from queue
1290
+ session.promptQueue.splice(queuedIndex, 1);
1291
+ // Emit status update: prompt was cancelled (real-time)
1292
+ onStatusUpdate?.('cancelled');
1293
+ console.log(`[agentSession] Cancelled queued prompt: ${promptId}`);
1294
+ return true;
1295
+ }
1296
+ // Not in queue, check if it's currently running
1297
+ const abortController = this.promptAbortControllers.get(promptId);
1298
+ if (!abortController) {
1299
+ return false; // Prompt not found or already completed
1300
+ }
1301
+ // Abort the running prompt
1302
+ abortController.abort();
1303
+ // Clean up
1304
+ this.promptAbortControllers.delete(promptId);
1305
+ // Emit status update: prompt was cancelled (real-time)
1306
+ onStatusUpdate?.('cancelled');
1307
+ console.log(`[agentSession] Cancelled running prompt: ${promptId}`);
1308
+ return true;
1309
+ }
1310
+ /**
1311
+ * Kill the entire process tree for a session (including grandchildren)
1312
+ * Uses process group killing on Unix-like systems
1313
+ */
1314
+ async killProcessTree(sessionId) {
1315
+ const session = this.sessions.get(sessionId);
1316
+ if (!session)
1317
+ return;
1318
+ const isWin = process.platform === 'win32';
1319
+ // 1. Kill all tracked child processes
1320
+ if (session.childProcesses && session.childProcesses.size > 0) {
1321
+ console.log(`[agentSession] Killing ${session.childProcesses.size} tracked child processes`);
1322
+ for (const child of session.childProcesses) {
1323
+ if (!child.killed) {
1324
+ try {
1325
+ if (isWin) {
1326
+ // Windows: use taskkill to force kill
1327
+ if (child.pid) {
1328
+ spawn('taskkill', ['/pid', child.pid.toString(), '/f', '/t'], {
1329
+ stdio: 'ignore',
1330
+ windowsHide: true
1331
+ });
1332
+ }
1333
+ }
1334
+ else {
1335
+ // Unix: try graceful SIGTERM first, then SIGKILL
1336
+ child.kill('SIGTERM');
1337
+ }
1338
+ }
1339
+ catch (e) {
1340
+ // Process may already be dead
1341
+ }
1342
+ }
1343
+ }
1344
+ // Wait a bit for graceful shutdown, then force kill
1345
+ await new Promise(resolve => setTimeout(resolve, 500));
1346
+ for (const child of session.childProcesses) {
1347
+ if (!child.killed) {
1348
+ try {
1349
+ if (!isWin && child.pid) {
1350
+ // Unix: force kill with SIGKILL
1351
+ child.kill('SIGKILL');
1352
+ }
1353
+ }
1354
+ catch (e) {
1355
+ // Already dead
1356
+ }
1357
+ }
1358
+ }
1359
+ session.childProcesses.clear();
1360
+ }
1361
+ // 2. Kill the entire process group on Unix-like systems
1362
+ if (!isWin && session.claudeProcessGroupId) {
1363
+ try {
1364
+ console.log(`[agentSession] Killing process group ${session.claudeProcessGroupId}`);
1365
+ // Kill entire process group using negative PID
1366
+ process.kill(-session.claudeProcessGroupId, 'SIGKILL');
1367
+ }
1368
+ catch (e) {
1369
+ // Process group may already be dead
1370
+ }
1371
+ session.claudeProcessGroupId = undefined;
1372
+ }
1373
+ }
1374
+ /**
1375
+ * Emergency stop - immediately halt all activity in a session
1376
+ * This is a forceful stop that kills all processes and clears state
1377
+ */
1378
+ async emergencyStop(sessionId) {
1379
+ // Prevent concurrent emergency stops
1380
+ if (this.emergencyStopInProgress.has(sessionId)) {
1381
+ return { success: false, message: 'Emergency stop already in progress' };
1382
+ }
1383
+ this.emergencyStopInProgress.add(sessionId);
1384
+ console.log(`[agentSession] EMERGENCY STOP triggered for session ${sessionId}`);
1385
+ try {
1386
+ const session = this.sessions.get(sessionId);
1387
+ if (!session) {
1388
+ return { success: false, message: 'Session not found' };
1389
+ }
1390
+ // 1. Abort session-level controller only (not all sessions' controllers)
1391
+ session.abortController.abort();
1392
+ // Abort only controllers belonging to THIS session
1393
+ // Find and abort controllers for prompts in this session's queue and current prompt
1394
+ if (session.currentPromptId) {
1395
+ const ctrl = this.promptAbortControllers.get(session.currentPromptId);
1396
+ if (ctrl)
1397
+ ctrl.abort();
1398
+ }
1399
+ for (const queued of session.promptQueue) {
1400
+ if (queued.abortController)
1401
+ queued.abortController.abort();
1402
+ if (queued.promptId) {
1403
+ const ctrl = this.promptAbortControllers.get(queued.promptId);
1404
+ if (ctrl)
1405
+ ctrl.abort();
1406
+ }
1407
+ }
1408
+ // 2. Kill the entire process tree
1409
+ await this.killProcessTree(sessionId);
1410
+ // 3. Collect prompt IDs from queue BEFORE clearing it
1411
+ const queueSize = session.promptQueue.length;
1412
+ const queuedPromptIds = session.promptQueue
1413
+ .map(p => p.promptId)
1414
+ .filter((id) => !!id);
1415
+ const currentPromptId = session.currentPromptId;
1416
+ // 4. Clear the prompt queue
1417
+ session.promptQueue = [];
1418
+ // 5. Clear active stream
1419
+ session.activeQueryStream = undefined;
1420
+ // 6. Reset processing state
1421
+ session.isProcessingQueue = false;
1422
+ // 7. Clean up abort controllers map (only for this session's prompts, not ALL sessions)
1423
+ if (currentPromptId) {
1424
+ this.promptAbortControllers.delete(currentPromptId);
1425
+ }
1426
+ for (const pid of queuedPromptIds) {
1427
+ this.promptAbortControllers.delete(pid);
1428
+ }
1429
+ session.currentPromptId = undefined;
1430
+ // 8. Remove from emergency stop tracking
1431
+ this.emergencyStopInProgress.delete(sessionId);
1432
+ const message = currentPromptId
1433
+ ? `Emergency stop: Cancelled prompt '${currentPromptId}' and cleared ${queueSize} queued prompts`
1434
+ : `Emergency stop: Cleared ${queueSize} queued prompts`;
1435
+ console.log(`[agentSession] ${message}`);
1436
+ return { success: true, message };
1437
+ }
1438
+ catch (error) {
1439
+ this.emergencyStopInProgress.delete(sessionId);
1440
+ console.error(`[agentSession] Emergency stop error:`, error);
1441
+ return { success: false, message: error.message || 'Emergency stop failed' };
1442
+ }
1443
+ }
1444
+ /**
1445
+ * Build a fresh MCP server for a query call.
1446
+ * The SDK's query() connects the MCP server's internal transport, so we cannot
1447
+ * reuse a single instance across multiple queries. This must be called fresh each time.
1448
+ */
1449
+ buildMcpServer(sessionId, attachmentDir, promptId) {
1450
+ console.log(`[buildMcpServer] Session ${sessionId}: attachmentDir=${attachmentDir || 'none'}, promptId=${promptId || 'none'}`);
1451
+ return createModuleMcpServer({
1452
+ attachmentDir,
1453
+ sessionId,
1454
+ promptId,
1455
+ });
1456
+ }
1457
+ }
1458
+ export const agentSessionManager = new AgentSessionManager();