@exreve/exk 1.0.51 → 1.0.52

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,1456 @@
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
+ return env;
359
+ }
360
+ /** Get env overrides for a local model (adapter proxy URL + dummy key). Returns null if not a local model. */
361
+ async function getLocalModelEnvOverrides(model) {
362
+ if (!isLocalModel(model))
363
+ return null;
364
+ const adapterConfig = getAdapterConfig(model);
365
+ if (!adapterConfig)
366
+ return null;
367
+ const proxyUrl = await startOpenAIAdapter({
368
+ targetBaseUrl: adapterConfig.targetBaseUrl,
369
+ model: unwrapModelName(model),
370
+ apiKey: adapterConfig.apiKey,
371
+ });
372
+ return { baseUrl: proxyUrl, apiKey: 'local-no-key-needed' };
373
+ }
374
+ // Lazy config getter - reloads from file each time (so daemon picks up changes without restart)
375
+ const CLAUDE_CONFIG = {
376
+ get apiKey() { return loadAiConfig().apiKey; },
377
+ get baseUrl() { return loadAiConfig().baseUrl; },
378
+ get model() { return loadAiConfig().model; },
379
+ };
380
+ export class AgentSessionManager {
381
+ sessions = new Map();
382
+ promptAbortControllers = new Map(); // Map promptId -> AbortController for cancellation
383
+ emergencyStopInProgress = new Set(); // Track sessions being emergency stopped
384
+ sessionHandlers = new Map(); // Track handlers for each session
385
+ socketRef = null; // Socket.IO reference for fetching session history from backend
386
+ /** Set the socket reference for backend communication (called from app-child.ts) */
387
+ setSocketRef(socket) {
388
+ this.socketRef = socket;
389
+ }
390
+ /**
391
+ * Fetch conversation history from the backend DB for a session.
392
+ * Returns array of { role, content } pairs (user prompts + assistant responses).
393
+ */
394
+ async fetchSessionHistory(sessionId) {
395
+ return new Promise((resolve) => {
396
+ if (!this.socketRef?.connected) {
397
+ console.log(`[AgentSessionManager] Cannot fetch history: socket not connected`);
398
+ resolve([]);
399
+ return;
400
+ }
401
+ const timeoutId = setTimeout(() => {
402
+ console.warn(`[AgentSessionManager] fetchSessionHistory timed out for ${sessionId}`);
403
+ resolve([]);
404
+ }, 5000);
405
+ this.socketRef.emit('session:history', { sessionId }, (response) => {
406
+ clearTimeout(timeoutId);
407
+ if (response?.history && Array.isArray(response.history)) {
408
+ console.log(`[AgentSessionManager] Fetched ${response.history.length} history entries for session ${sessionId}`);
409
+ resolve(response.history);
410
+ }
411
+ else {
412
+ resolve([]);
413
+ }
414
+ });
415
+ });
416
+ }
417
+ /**
418
+ * Format conversation history into a text block for injection into a prompt.
419
+ * Takes the last N exchanges to avoid token overflow.
420
+ */
421
+ formatHistoryForPrompt(history) {
422
+ if (!history.length)
423
+ return '';
424
+ // Take last N entries to stay within reasonable token limits
425
+ const maxEntries = 40;
426
+ const trimmed = history.slice(-maxEntries);
427
+ const lines = trimmed.map(m => {
428
+ const content = typeof m.content === 'string' ? m.content : JSON.stringify(m.content);
429
+ // Truncate very long individual messages
430
+ const maxLen = 2000;
431
+ const truncated = content.length > maxLen
432
+ ? content.slice(0, maxLen) + '...[truncated]'
433
+ : content;
434
+ return `<${m.role}>\n${truncated}\n</${m.role}>`;
435
+ });
436
+ return [
437
+ '[Previous Conversation Context]',
438
+ 'The following is conversation history from this session that was lost due to a session reset.',
439
+ 'Use it as context for the current request.',
440
+ '',
441
+ '<conversation>',
442
+ ...lines,
443
+ '</conversation>',
444
+ '',
445
+ '[End of Previous Context]',
446
+ '',
447
+ '',
448
+ ].join('\n');
449
+ }
450
+ async createSession(handler) {
451
+ const { sessionId, projectPath, model } = handler;
452
+ const sessionModel = model || CLAUDE_CONFIG.model;
453
+ // Ensure project directory exists - prevents ENOENT errors when SDK spawns process
454
+ if (!existsSync(projectPath)) {
455
+ try {
456
+ mkdirSync(projectPath, { recursive: true });
457
+ console.log(`[AgentSessionManager] Created project directory: ${projectPath}`);
458
+ }
459
+ catch (error) {
460
+ console.error(`[AgentSessionManager] Failed to create project directory ${projectPath}:`, error.message);
461
+ // Fallback for /home/abc - try to create symlink to /tmp/abc
462
+ if (projectPath === '/home/abc') {
463
+ const fallbackPath = '/tmp/abc';
464
+ try {
465
+ mkdirSync(fallbackPath, { recursive: true });
466
+ await symlinkAsync(fallbackPath, projectPath, 'dir');
467
+ console.log(`[AgentSessionManager] Created symlink: ${projectPath} -> ${fallbackPath}`);
468
+ }
469
+ catch (symlinkError) {
470
+ console.log(`[AgentSessionManager] Symlink creation failed: ${symlinkError.message}`);
471
+ }
472
+ }
473
+ }
474
+ }
475
+ // If session already exists, update mutable fields
476
+ if (this.sessions.has(sessionId)) {
477
+ const existingSession = this.sessions.get(sessionId);
478
+ // Update model if a new one is provided
479
+ if (model && model !== existingSession.model) {
480
+ existingSession.model = model;
481
+ }
482
+ // Update enabled modules and settings if provided
483
+ // Ensure abort controller is fresh for new queries
484
+ existingSession.abortController = new AbortController();
485
+ // Update handler reference
486
+ this.sessionHandlers.set(sessionId, handler);
487
+ return;
488
+ }
489
+ // Store the handler for this session
490
+ this.sessionHandlers.set(sessionId, handler);
491
+ const abortController = new AbortController();
492
+ // Restore claudeSessionId from disk (survives CLI restart)
493
+ const persistedState = loadSessionState(sessionId);
494
+ const restoredClaudeSessionId = persistedState?.claudeSessionId;
495
+ if (restoredClaudeSessionId) {
496
+ console.log(`[AgentSessionManager] Restored claudeSessionId for session ${sessionId}: ${restoredClaudeSessionId}`);
497
+ }
498
+ this.sessions.set(sessionId, {
499
+ abortController,
500
+ messages: [],
501
+ totalInputTokens: 0,
502
+ totalOutputTokens: 0,
503
+ totalCostUsd: 0,
504
+ promptQueue: [],
505
+ isProcessingQueue: false,
506
+ claudeSessionId: restoredClaudeSessionId, // Restored from disk or undefined
507
+ childProcesses: new Set(),
508
+ claudeProcessGroupId: undefined,
509
+ currentPromptId: undefined,
510
+ model: sessionModel,
511
+ });
512
+ // Auto-regenerate CLAUDE.md for fresh project context
513
+ await this.regenerateClaudeMd(projectPath);
514
+ }
515
+ /**
516
+ * Regenerate CLAUDE.md for fresh project context
517
+ */
518
+ async regenerateClaudeMd(projectPath) {
519
+ try {
520
+ const scriptPath = path.join(projectPath, 'scripts', 'generate-claude-md.js');
521
+ if (existsSync(scriptPath)) {
522
+ execSync(`node "${scriptPath}"`, { cwd: projectPath, stdio: 'ignore' });
523
+ }
524
+ }
525
+ catch (error) {
526
+ // Don't fail session if CLAUDE.md generation fails
527
+ console.error('[AgentSessionManager] Failed to regenerate CLAUDE.md:', error);
528
+ }
529
+ }
530
+ async sendPrompt(sessionId, prompt, enhancers = [], handler) {
531
+ // Ensure session exists
532
+ if (!this.sessions.has(sessionId)) {
533
+ await this.createSession(handler);
534
+ }
535
+ const session = this.sessions.get(sessionId);
536
+ // Update session model if provided in handler
537
+ if (handler.model && handler.model !== session.model) {
538
+ session.model = handler.model;
539
+ }
540
+ // Add prompt to queue - store promptId for cancellation
541
+ session.promptQueue.push({
542
+ prompt,
543
+ enhancers,
544
+ handler,
545
+ timestamp: Date.now(),
546
+ promptId: handler.promptId,
547
+ abortController: new AbortController(), // Pre-create for queued cancellation
548
+ model: handler.model || session.model, // Use handler model or fall back to session model
549
+ attachments: handler.attachments // Pass attachments through
550
+ });
551
+ // Start processing queue if not already processing
552
+ if (!session.isProcessingQueue) {
553
+ this.processPromptQueue(sessionId);
554
+ }
555
+ else if (session.isProcessingQueue && !session.activeQueryStream && !this.emergencyStopInProgress.has(sessionId)) {
556
+ // Safety: isProcessingQueue is true but there's no active stream and no emergency stop
557
+ // This means the queue got stuck (e.g. from a previous abort return that bypassed cleanup)
558
+ console.warn(`[agentSession] Queue stuck detected for session ${sessionId}, resetting isProcessingQueue`);
559
+ session.isProcessingQueue = false;
560
+ this.processPromptQueue(sessionId);
561
+ }
562
+ }
563
+ async processPromptQueue(sessionId) {
564
+ const session = this.sessions.get(sessionId);
565
+ if (!session)
566
+ return;
567
+ if (session.isProcessingQueue)
568
+ return;
569
+ session.isProcessingQueue = true;
570
+ while (session.promptQueue.length > 0 && !this.emergencyStopInProgress.has(sessionId)) {
571
+ const queuedPrompt = session.promptQueue.shift();
572
+ const { enhancers, handler, promptId: queuedPromptId, abortController: queuedAbortController } = queuedPrompt;
573
+ const { projectPath, promptId, onOutput: _onOutput, onError: _onError, onComplete: _onComplete, onStatusUpdate } = handler;
574
+ const onOutput = _onOutput;
575
+ const onError = _onError;
576
+ const onComplete = _onComplete;
577
+ // Write attachments to temp dir and inject paths into prompt
578
+ let effectivePrompt = queuedPrompt.prompt;
579
+ let attachmentDir;
580
+ if (queuedPrompt.attachments && queuedPrompt.attachments.length > 0) {
581
+ attachmentDir = path.join(os.tmpdir(), 'talk-to-code', 'attachments', sessionId, String(promptId || Date.now()));
582
+ mkdirSync(attachmentDir, { recursive: true });
583
+ const attachmentLines = [];
584
+ for (const att of queuedPrompt.attachments) {
585
+ const safeName = att.filename.replace(/[^a-zA-Z0-9._-]/g, '_');
586
+ const filePath = path.join(attachmentDir, safeName);
587
+ const buf = Buffer.from(att.content, 'base64');
588
+ writeFileSync(filePath, buf);
589
+ attachmentLines.push(`- ${safeName} (${att.mimeType}): path="${filePath}"`);
590
+ }
591
+ 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')}`;
592
+ console.log(`[agentSession] Wrote ${queuedPrompt.attachments.length} attachment(s) to ${attachmentDir}`);
593
+ }
594
+ try {
595
+ // Verify promptId is present in handler
596
+ if (!promptId) {
597
+ console.error(`[agentSession] Missing promptId in handler for prompt: ${queuedPrompt.prompt.substring(0, 50)}...`);
598
+ onError?.('Missing promptId in handler');
599
+ continue;
600
+ }
601
+ // Check if this queued prompt was cancelled before processing started
602
+ if (queuedAbortController?.signal.aborted) {
603
+ console.log(`[agentSession] Queued prompt ${promptId} was cancelled before processing`);
604
+ onStatusUpdate?.('cancelled');
605
+ onComplete(null);
606
+ continue;
607
+ }
608
+ // Use the queued promptId and abortController, or fallback to handler values
609
+ const effectivePromptId = queuedPromptId || promptId;
610
+ const abortController = queuedAbortController || new AbortController();
611
+ session.abortController = abortController;
612
+ session.currentPromptId = effectivePromptId; // Track current prompt for emergency stop
613
+ this.promptAbortControllers.set(effectivePromptId, abortController);
614
+ // Emit status update: CLI is starting to process the prompt (IMMEDIATELY)
615
+ // This ensures real-time status updates before any async operations
616
+ onStatusUpdate?.('running');
617
+ // Wait for current query to finish before starting next prompt
618
+ if (session.activeQueryStream !== undefined) {
619
+ try {
620
+ for await (const _ of session.activeQueryStream) { }
621
+ }
622
+ catch (err) {
623
+ console.error(`[AgentSession] Error draining active query stream:`, err);
624
+ }
625
+ session.activeQueryStream = undefined;
626
+ }
627
+ session.activeQueryStream = undefined;
628
+ // Build final prompt with enhancers
629
+ let finalPrompt = effectivePrompt;
630
+ if (enhancers && enhancers.length > 0) {
631
+ const skillContent = getSkillContent(enhancers);
632
+ if (skillContent) {
633
+ finalPrompt = `${skillContent}\n\n${effectivePrompt}`;
634
+ }
635
+ }
636
+ // Inject DB history if context was lost in a previous prompt (resume failed)
637
+ if (session.contextLost) {
638
+ console.log(`[agentSession] Context was lost previously, fetching DB history for session ${sessionId}`);
639
+ try {
640
+ const history = await this.fetchSessionHistory(sessionId);
641
+ if (history.length > 0) {
642
+ const historyPrefix = this.formatHistoryForPrompt(history);
643
+ finalPrompt = historyPrefix + finalPrompt;
644
+ console.log(`[agentSession] Injected ${history.length} history entries into prompt for session ${sessionId}`);
645
+ }
646
+ else {
647
+ console.log(`[agentSession] No DB history available for session ${sessionId}`);
648
+ }
649
+ }
650
+ catch (err) {
651
+ console.error(`[agentSession] Failed to fetch/format history:`, err);
652
+ }
653
+ session.contextLost = false; // Reset after injection attempt
654
+ }
655
+ // Add user message to history
656
+ session.messages.push({
657
+ role: 'user',
658
+ content: finalPrompt,
659
+ timestamp: Date.now()
660
+ });
661
+ // Emit context info
662
+ onOutput({
663
+ type: 'system',
664
+ data: {
665
+ message: `Context: ${session.messages.length} messages, ${session.totalInputTokens + session.totalOutputTokens} total tokens`,
666
+ contextInfo: {
667
+ messageCount: session.messages.length,
668
+ totalInputTokens: session.totalInputTokens,
669
+ totalOutputTokens: session.totalOutputTokens,
670
+ totalTokens: session.totalInputTokens + session.totalOutputTokens,
671
+ totalCostUsd: session.totalCostUsd,
672
+ lastUsage: session.lastUsage
673
+ }
674
+ },
675
+ timestamp: Date.now(),
676
+ metadata: {
677
+ subtype: 'context_info',
678
+ contextSize: session.messages.length,
679
+ totalTokens: session.totalInputTokens + session.totalOutputTokens
680
+ }
681
+ });
682
+ // Use cached Claude executable path (resolved at module load time for performance)
683
+ const pathToClaudeCodeExecutable = CACHED_CLAUDE_PATH;
684
+ // Build query options - include abort signal for cancellation
685
+ const queryOptions = {
686
+ signal: abortController.signal, // Pass abort signal to SDK for interruption
687
+ cwd: projectPath,
688
+ apiKey: CLAUDE_CONFIG.apiKey,
689
+ model: CLAUDE_CONFIG.model,
690
+ tools: { type: 'preset', preset: 'claude_code' },
691
+ disallowedTools: ['AskUserQuestion', 'analyze_image'], // Disable built-in analyze_image (we provide our own via MCP)
692
+ settingSources: ['project'], // Enable CLAUDE.md loading
693
+ permissionMode: 'bypassPermissions',
694
+ allowDangerouslySkipPermissions: true,
695
+ // Create a fresh MCP server for each query call (SDK connects transport internally, cannot reuse)
696
+ ...(() => {
697
+ const mcpServer = this.buildMcpServer(sessionId, attachmentDir, promptId);
698
+ return { mcpServers: { 'claude-voice-modules': mcpServer } };
699
+ })(),
700
+ ...(pathToClaudeCodeExecutable ? { pathToClaudeCodeExecutable } : {}),
701
+ spawnClaudeCodeProcess: (spawnOptions) => {
702
+ const { command, args, cwd: cwd2, env, signal } = spawnOptions;
703
+ // Debug: log what env/args are being passed to Claude process
704
+ console.log(`[agentSession] Spawn ANTHROPIC_BASE_URL:`, env?.ANTHROPIC_BASE_URL || '(not set)');
705
+ console.log(`[agentSession] Spawn ANTHROPIC_API_KEY:`, env?.ANTHROPIC_API_KEY ? '(set)' : '(not set)');
706
+ console.log(`[agentSession] Spawn args:`, args?.join(' '));
707
+ // Only check file existence when command is a path (not a bare name like "claude" from PATH)
708
+ const hasPathSep = command.includes(path.sep) || command.includes('/') || command.includes('\\');
709
+ if (hasPathSep && !existsSync(command)) {
710
+ throw new Error(`Executable not found at ${command}. Set path with: ttc config --claude-path "<path>" (or TTC_CLAUDE_PATH)`);
711
+ }
712
+ try {
713
+ if (cwd2 && !existsSync(cwd2)) {
714
+ mkdirSync(cwd2, { recursive: true });
715
+ }
716
+ }
717
+ catch (err) {
718
+ console.error(`[AgentSession] Failed to create working directory ${cwd2}:`, err);
719
+ }
720
+ const isWin = process.platform === 'win32';
721
+ // Ensure PATH includes common node locations, especially in containers
722
+ const defaultPath = isWin
723
+ ? (process.env.Path || process.env.PATH || '')
724
+ : '/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin';
725
+ const spawnEnv = {
726
+ ...env,
727
+ PATH: env.PATH || process.env.PATH || defaultPath,
728
+ ...(isWin
729
+ ? {
730
+ USERPROFILE: env.USERPROFILE || process.env.USERPROFILE || os.homedir(),
731
+ USERNAME: env.USERNAME || process.env.USERNAME || 'user',
732
+ HOME: env.USERPROFILE || process.env.USERPROFILE || os.homedir(), // Windows: use Windows home, not /home/user
733
+ }
734
+ : { HOME: env.HOME || process.env.HOME || os.homedir(), USER: env.USER || process.env.USER || 'user' }),
735
+ };
736
+ // If command is 'node' and not found, try to resolve it
737
+ if (command === 'node' && !hasPathSep) {
738
+ try {
739
+ const nodePath = execSync('which node', { encoding: 'utf-8', env: spawnEnv }).trim();
740
+ if (nodePath) {
741
+ const child = spawn(nodePath, args, {
742
+ cwd: cwd2 || process.cwd(),
743
+ stdio: ["pipe", "pipe", env.DEBUG_CLAUDE_AGENT_SDK ? "pipe" : "ignore"],
744
+ signal,
745
+ env: spawnEnv,
746
+ windowsHide: true,
747
+ detached: !isWin // Create process group on Unix for tree-killing
748
+ });
749
+ // Track child process for force-kill
750
+ if (!session.childProcesses)
751
+ session.childProcesses = new Set();
752
+ session.childProcesses.add(child);
753
+ // Store process group ID for Unix (negative PID kills entire group)
754
+ if (!isWin && child.pid) {
755
+ session.claudeProcessGroupId = child.pid;
756
+ }
757
+ // Clean up when process exits
758
+ child.on('exit', () => {
759
+ session.childProcesses.delete(child);
760
+ });
761
+ child.on('error', () => {
762
+ session.childProcesses.delete(child);
763
+ });
764
+ return child;
765
+ }
766
+ }
767
+ catch {
768
+ // Fall through to original spawn
769
+ }
770
+ }
771
+ const child = spawn(command, args, {
772
+ cwd: cwd2 || process.cwd(),
773
+ stdio: ["pipe", "pipe", env.DEBUG_CLAUDE_AGENT_SDK ? "pipe" : "ignore"],
774
+ signal,
775
+ env: spawnEnv,
776
+ windowsHide: true,
777
+ detached: !isWin // Create process group on Unix for tree-killing
778
+ });
779
+ // Track child process for force-kill
780
+ if (!session.childProcesses)
781
+ session.childProcesses = new Set();
782
+ session.childProcesses.add(child);
783
+ // Store process group ID for Unix (negative PID kills entire group)
784
+ if (!isWin && child.pid) {
785
+ session.claudeProcessGroupId = child.pid;
786
+ }
787
+ // Clean up when process exits
788
+ child.on('exit', () => {
789
+ session.childProcesses.delete(child);
790
+ });
791
+ child.on('error', () => {
792
+ session.childProcesses.delete(child);
793
+ });
794
+ return child;
795
+ },
796
+ env: envForClaudeCodeChild(),
797
+ hooks: {
798
+ // HookCallbackMatcher format: each entry must be { hooks: [callback] }
799
+ // NOT a raw callback array — wrong format silently breaks MCP server registration.
800
+ PostToolUse: [{
801
+ hooks: [(_toolResult) => {
802
+ // Tool result is handled by the user message handler below
803
+ return { continue: true };
804
+ }]
805
+ }],
806
+ Notification: [{
807
+ hooks: [(notification) => {
808
+ onOutput({
809
+ type: 'progress',
810
+ data: notification,
811
+ timestamp: Date.now(),
812
+ metadata: {
813
+ progress: {
814
+ message: typeof notification === 'string' ? notification : JSON.stringify(notification)
815
+ }
816
+ }
817
+ });
818
+ return { continue: true };
819
+ }]
820
+ }],
821
+ }
822
+ };
823
+ // Log model being used for debugging
824
+ const sessionModel = session.model || CLAUDE_CONFIG.model;
825
+ // Resolve local model adapter overrides (if using OpenAI-compatible endpoint)
826
+ let effectiveModel = sessionModel;
827
+ let effectiveApiKey = queryOptions.apiKey;
828
+ let effectiveEnv = queryOptions.env;
829
+ let effectiveSettings;
830
+ const localOverrides = await getLocalModelEnvOverrides(sessionModel);
831
+ if (localOverrides) {
832
+ effectiveModel = unwrapModelName(sessionModel);
833
+ effectiveApiKey = localOverrides.apiKey;
834
+ effectiveEnv = {
835
+ ...effectiveEnv,
836
+ ANTHROPIC_API_KEY: localOverrides.apiKey,
837
+ ANTHROPIC_BASE_URL: localOverrides.baseUrl,
838
+ };
839
+ // Override settings to prevent ~/.claude/settings.json env from overriding our proxy URL.
840
+ // Claude Code CLI reads settings.json → env section and applies those on top of spawn env,
841
+ // which would replace our ANTHROPIC_BASE_URL with the z.ai URL.
842
+ effectiveSettings = { env: { ANTHROPIC_API_KEY: localOverrides.apiKey, ANTHROPIC_BASE_URL: localOverrides.baseUrl } };
843
+ console.log(`[agentSession] Using local model adapter: ${sessionModel} -> ${localOverrides.baseUrl}`);
844
+ console.log(`[agentSession] effectiveSettings for local model:`, JSON.stringify(effectiveSettings));
845
+ }
846
+ else {
847
+ // Resolve provider for multi-provider switching (Z.ai / MiniMax)
848
+ const resolved = resolveProvider(sessionModel);
849
+ console.log(`[agentSession] Resolved provider: ${resolved.provider} for model: ${sessionModel}`);
850
+ effectiveApiKey = resolved.apiKey;
851
+ effectiveEnv = envForClaudeCodeChild(undefined, resolved);
852
+ // Build settings env to prevent ~/.claude/settings.json from overriding our credentials
853
+ const settingsEnv = {
854
+ ANTHROPIC_API_KEY: resolved.apiKey,
855
+ ANTHROPIC_BASE_URL: resolved.baseUrl,
856
+ };
857
+ // For MiniMax: also override all model aliases in settings
858
+ if (resolved.provider === 'minimax') {
859
+ settingsEnv.ANTHROPIC_MODEL = resolved.model;
860
+ settingsEnv.ANTHROPIC_DEFAULT_SONNET_MODEL = resolved.model;
861
+ settingsEnv.ANTHROPIC_DEFAULT_OPUS_MODEL = resolved.model;
862
+ settingsEnv.ANTHROPIC_DEFAULT_HAIKU_MODEL = resolved.model;
863
+ settingsEnv.CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC = '1';
864
+ }
865
+ // For OpenRouter: also override all model aliases in settings
866
+ if (resolved.provider === 'openrouter') {
867
+ settingsEnv.ANTHROPIC_MODEL = resolved.model;
868
+ settingsEnv.ANTHROPIC_DEFAULT_SONNET_MODEL = resolved.model;
869
+ settingsEnv.ANTHROPIC_DEFAULT_OPUS_MODEL = resolved.model;
870
+ settingsEnv.ANTHROPIC_DEFAULT_HAIKU_MODEL = resolved.model;
871
+ settingsEnv.CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC = '1';
872
+ }
873
+ effectiveSettings = { env: settingsEnv };
874
+ console.log(`[agentSession] Provider: ${resolved.provider}, baseUrl: ${resolved.baseUrl}, model: ${resolved.model}`);
875
+ }
876
+ // Create query stream - resume session if we have a Claude session ID
877
+ // Always explicitly set model even when resuming to ensure we use the session's model
878
+ const queryStream = query({
879
+ prompt: finalPrompt,
880
+ options: {
881
+ ...queryOptions,
882
+ apiKey: effectiveApiKey,
883
+ model: effectiveModel,
884
+ env: effectiveEnv,
885
+ ...(effectiveSettings ? { settings: effectiveSettings } : {}),
886
+ ...(session.claudeSessionId && !localOverrides && (() => {
887
+ // Don't resume if provider changed since last session (context format may differ)
888
+ const persisted = loadSessionState(sessionId);
889
+ const currentProvider = resolveProvider(sessionModel).provider;
890
+ return persisted?.provider === currentProvider;
891
+ })() ? { resume: session.claudeSessionId } : {})
892
+ // Note: don't resume session for local models - context format differs
893
+ // Note: also don't resume if provider differs from persisted session provider (context format may differ)
894
+ }
895
+ });
896
+ session.activeQueryStream = queryStream;
897
+ // Process messages with enhanced abort checking
898
+ // Create a wrapped stream that checks abort status more frequently
899
+ const abortCheckInterval = 200; // Check every 200ms
900
+ let lastAbortCheck = Date.now();
901
+ try {
902
+ for await (const message of queryStream) {
903
+ // Check abort on each message (existing behavior)
904
+ if (session.abortController.signal.aborted || this.emergencyStopInProgress.has(sessionId)) {
905
+ console.log(`[agentSession] Aborting prompt ${effectivePromptId} - abort signal received`);
906
+ break;
907
+ }
908
+ // Periodic check for long-running operations
909
+ const now = Date.now();
910
+ if (now - lastAbortCheck > abortCheckInterval) {
911
+ lastAbortCheck = now;
912
+ if (session.abortController.signal.aborted || this.emergencyStopInProgress.has(sessionId)) {
913
+ console.log(`[agentSession] Aborting prompt ${effectivePromptId} - periodic check detected abort`);
914
+ break;
915
+ }
916
+ }
917
+ // Capture Claude SDK session ID from system init message
918
+ if (message.type === 'system' && message.subtype === 'init') {
919
+ const systemMsg = message;
920
+ if (systemMsg.session_id) {
921
+ // Detect context loss: session_id changed unexpectedly (resume failed)
922
+ if (session.claudeSessionId && session.claudeSessionId !== systemMsg.session_id) {
923
+ session.contextLost = true;
924
+ console.warn(`[AgentSessionManager] Context lost! Session ID changed: ${session.claudeSessionId} → ${systemMsg.session_id}`);
925
+ }
926
+ session.claudeSessionId = systemMsg.session_id;
927
+ saveSessionState(sessionId, { claudeSessionId: systemMsg.session_id, model: session.model, provider: resolveProvider(session.model).provider, updatedAt: Date.now() });
928
+ }
929
+ }
930
+ if (message.type === 'assistant') {
931
+ const msg = message;
932
+ // Capture Claude session ID from assistant message (always update to track session changes)
933
+ if (msg.session_id) {
934
+ if (session.claudeSessionId && session.claudeSessionId !== msg.session_id) {
935
+ session.contextLost = true;
936
+ console.warn(`[AgentSessionManager] Context lost! Session ID changed in assistant msg: ${session.claudeSessionId} → ${msg.session_id}`);
937
+ }
938
+ session.claudeSessionId = msg.session_id;
939
+ saveSessionState(sessionId, { claudeSessionId: msg.session_id, model: session.model, provider: resolveProvider(session.model).provider, updatedAt: Date.now() });
940
+ }
941
+ session.messages.push({
942
+ role: 'assistant',
943
+ content: msg.message,
944
+ timestamp: Date.now()
945
+ });
946
+ onOutput({
947
+ type: 'assistant',
948
+ data: msg.message,
949
+ timestamp: Date.now(),
950
+ metadata: {
951
+ parentToolUseId: msg.parent_tool_use_id,
952
+ uuid: msg.uuid,
953
+ sessionId: msg.session_id,
954
+ error: msg.error,
955
+ contextSize: session.messages.length
956
+ }
957
+ });
958
+ }
959
+ else if (message.type === 'result') {
960
+ const msg = message;
961
+ // Update usage tracking
962
+ if (msg.usage) {
963
+ const usage = msg.usage;
964
+ const inputTokens = usage.input_tokens || usage.inputTokens || 0;
965
+ const outputTokens = usage.output_tokens || usage.outputTokens || 0;
966
+ session.totalInputTokens += inputTokens;
967
+ session.totalOutputTokens += outputTokens;
968
+ session.totalCostUsd += msg.total_cost_usd || 0;
969
+ session.lastUsage = {
970
+ inputTokens,
971
+ outputTokens,
972
+ totalTokens: inputTokens + outputTokens
973
+ };
974
+ }
975
+ onOutput({
976
+ type: 'result',
977
+ data: msg,
978
+ timestamp: Date.now(),
979
+ metadata: {
980
+ subtype: msg.subtype,
981
+ isError: msg.is_error,
982
+ exitCode: msg.is_error ? 1 : 0,
983
+ durationMs: msg.duration_ms,
984
+ durationApiMs: msg.duration_api_ms,
985
+ totalCostUsd: msg.total_cost_usd,
986
+ usage: msg.usage,
987
+ modelUsage: msg.modelUsage,
988
+ structuredOutput: msg.structured_output,
989
+ numTurns: msg.num_turns,
990
+ contextInfo: {
991
+ messageCount: session.messages.length,
992
+ totalInputTokens: session.totalInputTokens,
993
+ totalOutputTokens: session.totalOutputTokens,
994
+ totalTokens: session.totalInputTokens + session.totalOutputTokens,
995
+ totalCostUsd: session.totalCostUsd,
996
+ lastUsage: session.lastUsage
997
+ }
998
+ }
999
+ });
1000
+ // Emit status update: CLI finished processing (real-time)
1001
+ const exitCode = msg.is_error ? 1 : 0;
1002
+ // Clean up abort controller
1003
+ this.promptAbortControllers.delete(promptId);
1004
+ // Update status immediately based on exit code
1005
+ onStatusUpdate?.(exitCode === 0 ? 'completed' : 'error');
1006
+ onComplete(exitCode);
1007
+ session.activeQueryStream = undefined;
1008
+ break; // Prompt complete, continue to next in queue
1009
+ }
1010
+ else if (message.type === 'user') {
1011
+ const msg = message;
1012
+ // SDK sends tool results with TWO data sources:
1013
+ // message.content[0] — {type:'tool_result', tool_use_id:'...', content: <raw text>}
1014
+ // msg.tool_use_result — structured object with {stdout,stderr} for Bash,
1015
+ // {type,file} for Read, or [{type:'text',text:'...'}] for MCP tools
1016
+ //
1017
+ // The tool_use_id lives ONLY in message.content[].tool_use_id.
1018
+ // The structured result lives in tool_use_result for built-in tools.
1019
+ // For MCP tools, tool_use_result is a content-block array we need to parse.
1020
+ let toolResult = null;
1021
+ let toolUseId = msg.parent_tool_use_id;
1022
+ // STEP 1: Always extract tool_use_id from message.content (authoritative source)
1023
+ if (Array.isArray(msg.message?.content)) {
1024
+ const contentBlocks = msg.message.content;
1025
+ const toolResultBlock = contentBlocks.find((c) => c.type === 'tool_result');
1026
+ if (toolResultBlock?.tool_use_id) {
1027
+ toolUseId = toolResultBlock.tool_use_id;
1028
+ }
1029
+ }
1030
+ // STEP 2: Use tool_use_result as the primary data source.
1031
+ // For built-in tools (Bash, Read, Edit, Write, Glob, Grep) it's a structured
1032
+ // object like {stdout, stderr} or {type:'text', file:{...}} — use directly.
1033
+ // For MCP tools it's a content-block array [{type:'text', text:'...'}] — parse text.
1034
+ if (msg.tool_use_result) {
1035
+ const raw = msg.tool_use_result;
1036
+ if (Array.isArray(raw)) {
1037
+ // MCP tool result: [{type:'text', text:'...'}] — extract and parse
1038
+ const textParts = raw
1039
+ .filter((c) => c.type === 'text')
1040
+ .map((c) => c.text);
1041
+ const rawContent = textParts.join('\n');
1042
+ try {
1043
+ toolResult = JSON.parse(rawContent);
1044
+ }
1045
+ catch {
1046
+ toolResult = { content: rawContent, type: 'text' };
1047
+ }
1048
+ }
1049
+ else if (typeof raw === 'object' && raw !== null) {
1050
+ // Built-in tool result: {stdout, stderr, ...} or {type, file, ...} — use directly
1051
+ toolResult = raw;
1052
+ }
1053
+ else if (typeof raw === 'string') {
1054
+ try {
1055
+ toolResult = JSON.parse(raw);
1056
+ }
1057
+ catch {
1058
+ toolResult = { content: raw, type: 'text' };
1059
+ }
1060
+ }
1061
+ }
1062
+ // STEP 3: Fallback to parsing message.content if tool_use_result wasn't available
1063
+ // (e.g. subagent calls where tool_use_result may be absent)
1064
+ if (!toolResult && Array.isArray(msg.message?.content)) {
1065
+ const contentBlocks = msg.message.content;
1066
+ const toolResultBlock = contentBlocks.find((c) => c.type === 'tool_result');
1067
+ if (toolResultBlock) {
1068
+ if (typeof toolResultBlock.content === 'string') {
1069
+ try {
1070
+ toolResult = JSON.parse(toolResultBlock.content);
1071
+ }
1072
+ catch {
1073
+ toolResult = { content: toolResultBlock.content, type: 'text' };
1074
+ }
1075
+ }
1076
+ else if (Array.isArray(toolResultBlock.content)) {
1077
+ const textParts = toolResultBlock.content
1078
+ .filter((c) => c.type === 'text')
1079
+ .map((c) => c.text);
1080
+ const rawContent = textParts.join('\n');
1081
+ try {
1082
+ toolResult = JSON.parse(rawContent);
1083
+ }
1084
+ catch {
1085
+ toolResult = { content: rawContent, type: 'text' };
1086
+ }
1087
+ }
1088
+ else {
1089
+ toolResult = toolResultBlock;
1090
+ }
1091
+ }
1092
+ }
1093
+ if (toolResult) {
1094
+ // History lookup is authoritative: the assistant's tool_use block names the tool.
1095
+ // Heuristic detection can't distinguish Bash/Grep/Glob/etc (all have {stdout,stderr}).
1096
+ const historyName = lookupToolNameFromHistory(session.messages, toolUseId);
1097
+ const detectedName = extractToolName(toolResult);
1098
+ const resolvedName = historyName || detectedName;
1099
+ onOutput({
1100
+ type: 'tool_result',
1101
+ data: toolResult,
1102
+ timestamp: Date.now(),
1103
+ metadata: {
1104
+ toolName: resolvedName,
1105
+ toolResult: toolResult,
1106
+ toolUseId: toolUseId || undefined,
1107
+ parentToolUseId: msg.parent_tool_use_id,
1108
+ isSynthetic: msg.isSynthetic
1109
+ }
1110
+ });
1111
+ }
1112
+ else {
1113
+ onOutput({
1114
+ type: 'user',
1115
+ data: msg.message,
1116
+ timestamp: Date.now(),
1117
+ metadata: {
1118
+ parentToolUseId: null,
1119
+ isSynthetic: msg.isSynthetic
1120
+ }
1121
+ });
1122
+ }
1123
+ }
1124
+ else if (message.type === 'system') {
1125
+ const sysMsg = message;
1126
+ onOutput({
1127
+ type: 'system',
1128
+ data: { ...sysMsg, subtype: sysMsg.subtype },
1129
+ timestamp: Date.now(),
1130
+ metadata: {
1131
+ subtype: sysMsg.subtype,
1132
+ messageType: sysMsg.subtype || 'system'
1133
+ }
1134
+ });
1135
+ }
1136
+ else if (message.type === 'tool_progress') {
1137
+ const msg = message;
1138
+ onOutput({
1139
+ type: 'tool_progress',
1140
+ data: msg,
1141
+ timestamp: Date.now(),
1142
+ metadata: {
1143
+ toolName: msg.tool_name,
1144
+ toolUseId: msg.tool_use_id,
1145
+ elapsedTimeSeconds: msg.elapsed_time_seconds,
1146
+ parentToolUseId: msg.parent_tool_use_id
1147
+ }
1148
+ });
1149
+ }
1150
+ else if (message.type === 'auth_status') {
1151
+ onOutput({
1152
+ type: 'auth_status',
1153
+ data: message,
1154
+ timestamp: Date.now(),
1155
+ metadata: {
1156
+ isAuthenticating: message.isAuthenticating,
1157
+ error: message.error
1158
+ }
1159
+ });
1160
+ }
1161
+ else if (message.type === 'stream_event') {
1162
+ onOutput({
1163
+ type: 'stream_event',
1164
+ data: message.event || message,
1165
+ timestamp: Date.now(),
1166
+ metadata: {
1167
+ parentToolUseId: message.parent_tool_use_id
1168
+ }
1169
+ });
1170
+ }
1171
+ else if (message.type === 'tool_use_summary') {
1172
+ const msg = message;
1173
+ onOutput({
1174
+ type: 'tool_use_summary',
1175
+ data: msg.summary || '',
1176
+ timestamp: Date.now(),
1177
+ metadata: {
1178
+ precedingToolUseIds: msg.preceding_tool_use_ids,
1179
+ uuid: msg.uuid,
1180
+ sessionId: msg.session_id
1181
+ }
1182
+ });
1183
+ }
1184
+ else if (message.type === 'rate_limit_event') {
1185
+ const msg = message;
1186
+ onOutput({
1187
+ type: 'rate_limit_event',
1188
+ data: msg,
1189
+ timestamp: Date.now(),
1190
+ metadata: {
1191
+ rateLimitInfo: msg.rate_limit_info,
1192
+ uuid: msg.uuid,
1193
+ sessionId: msg.session_id
1194
+ }
1195
+ });
1196
+ }
1197
+ else if (message.type === 'prompt_suggestion') {
1198
+ const msg = message;
1199
+ onOutput({
1200
+ type: 'prompt_suggestion',
1201
+ data: msg.suggestion || '',
1202
+ timestamp: Date.now(),
1203
+ metadata: {
1204
+ uuid: msg.uuid,
1205
+ sessionId: msg.session_id
1206
+ }
1207
+ });
1208
+ }
1209
+ else if (message.type === 'keep_alive') {
1210
+ // Internal keepalive - silently ignore
1211
+ }
1212
+ else {
1213
+ onOutput({
1214
+ type: 'stdout',
1215
+ data: JSON.stringify(message, null, 2),
1216
+ timestamp: Date.now()
1217
+ });
1218
+ }
1219
+ }
1220
+ }
1221
+ catch (streamError) {
1222
+ // Check if this was an abort-related error
1223
+ if (session.abortController.signal.aborted || this.emergencyStopInProgress.has(sessionId)) {
1224
+ console.log(`[agentSession] Stream aborted for prompt ${effectivePromptId}`);
1225
+ // Handle abort gracefully
1226
+ onStatusUpdate?.('cancelled');
1227
+ onComplete(null);
1228
+ session.activeQueryStream = undefined;
1229
+ session.currentPromptId = undefined;
1230
+ // Use break instead of return to ensure isProcessingQueue gets reset
1231
+ // after the while loop at the end of processPromptQueue
1232
+ break;
1233
+ }
1234
+ // Re-throw non-abort errors
1235
+ throw streamError;
1236
+ }
1237
+ session.activeQueryStream = undefined;
1238
+ session.currentPromptId = undefined; // Clear current prompt when done
1239
+ }
1240
+ catch (error) {
1241
+ const currentSession = this.sessions.get(sessionId);
1242
+ if (currentSession) {
1243
+ currentSession.activeQueryStream = undefined;
1244
+ }
1245
+ // Clean up abort controller (promptId is guaranteed to exist here due to check at line 204)
1246
+ if (promptId) {
1247
+ this.promptAbortControllers.delete(promptId);
1248
+ }
1249
+ // Emit status update: CLI encountered an error (real-time)
1250
+ onStatusUpdate?.('error');
1251
+ onError(error.message || 'Unknown error');
1252
+ onComplete(null);
1253
+ }
1254
+ }
1255
+ session.isProcessingQueue = false;
1256
+ // Continue processing queue if more prompts are waiting
1257
+ if (session.promptQueue.length > 0) {
1258
+ this.processPromptQueue(sessionId);
1259
+ }
1260
+ }
1261
+ async deleteSession(sessionId) {
1262
+ const session = this.sessions.get(sessionId);
1263
+ if (session) {
1264
+ session.abortController.abort();
1265
+ session.activeQueryStream = undefined;
1266
+ this.sessions.delete(sessionId);
1267
+ }
1268
+ // Clean up persisted state
1269
+ deleteSessionState(sessionId);
1270
+ }
1271
+ /**
1272
+ * Cancel a running or queued prompt by promptId
1273
+ */
1274
+ async cancelPrompt(promptId, sessionId, onStatusUpdate) {
1275
+ const session = this.sessions.get(sessionId);
1276
+ if (!session) {
1277
+ return false; // Session not found
1278
+ }
1279
+ // First, check if prompt is in the queue (not yet started)
1280
+ const queuedIndex = session.promptQueue.findIndex(p => p.promptId === promptId);
1281
+ if (queuedIndex !== -1) {
1282
+ // Found in queue - abort it and remove from queue
1283
+ const queuedPrompt = session.promptQueue[queuedIndex];
1284
+ if (queuedPrompt.abortController) {
1285
+ queuedPrompt.abortController.abort();
1286
+ }
1287
+ // Remove from queue
1288
+ session.promptQueue.splice(queuedIndex, 1);
1289
+ // Emit status update: prompt was cancelled (real-time)
1290
+ onStatusUpdate?.('cancelled');
1291
+ console.log(`[agentSession] Cancelled queued prompt: ${promptId}`);
1292
+ return true;
1293
+ }
1294
+ // Not in queue, check if it's currently running
1295
+ const abortController = this.promptAbortControllers.get(promptId);
1296
+ if (!abortController) {
1297
+ return false; // Prompt not found or already completed
1298
+ }
1299
+ // Abort the running prompt
1300
+ abortController.abort();
1301
+ // Clean up
1302
+ this.promptAbortControllers.delete(promptId);
1303
+ // Emit status update: prompt was cancelled (real-time)
1304
+ onStatusUpdate?.('cancelled');
1305
+ console.log(`[agentSession] Cancelled running prompt: ${promptId}`);
1306
+ return true;
1307
+ }
1308
+ /**
1309
+ * Kill the entire process tree for a session (including grandchildren)
1310
+ * Uses process group killing on Unix-like systems
1311
+ */
1312
+ async killProcessTree(sessionId) {
1313
+ const session = this.sessions.get(sessionId);
1314
+ if (!session)
1315
+ return;
1316
+ const isWin = process.platform === 'win32';
1317
+ // 1. Kill all tracked child processes
1318
+ if (session.childProcesses && session.childProcesses.size > 0) {
1319
+ console.log(`[agentSession] Killing ${session.childProcesses.size} tracked child processes`);
1320
+ for (const child of session.childProcesses) {
1321
+ if (!child.killed) {
1322
+ try {
1323
+ if (isWin) {
1324
+ // Windows: use taskkill to force kill
1325
+ if (child.pid) {
1326
+ spawn('taskkill', ['/pid', child.pid.toString(), '/f', '/t'], {
1327
+ stdio: 'ignore',
1328
+ windowsHide: true
1329
+ });
1330
+ }
1331
+ }
1332
+ else {
1333
+ // Unix: try graceful SIGTERM first, then SIGKILL
1334
+ child.kill('SIGTERM');
1335
+ }
1336
+ }
1337
+ catch (e) {
1338
+ // Process may already be dead
1339
+ }
1340
+ }
1341
+ }
1342
+ // Wait a bit for graceful shutdown, then force kill
1343
+ await new Promise(resolve => setTimeout(resolve, 500));
1344
+ for (const child of session.childProcesses) {
1345
+ if (!child.killed) {
1346
+ try {
1347
+ if (!isWin && child.pid) {
1348
+ // Unix: force kill with SIGKILL
1349
+ child.kill('SIGKILL');
1350
+ }
1351
+ }
1352
+ catch (e) {
1353
+ // Already dead
1354
+ }
1355
+ }
1356
+ }
1357
+ session.childProcesses.clear();
1358
+ }
1359
+ // 2. Kill the entire process group on Unix-like systems
1360
+ if (!isWin && session.claudeProcessGroupId) {
1361
+ try {
1362
+ console.log(`[agentSession] Killing process group ${session.claudeProcessGroupId}`);
1363
+ // Kill entire process group using negative PID
1364
+ process.kill(-session.claudeProcessGroupId, 'SIGKILL');
1365
+ }
1366
+ catch (e) {
1367
+ // Process group may already be dead
1368
+ }
1369
+ session.claudeProcessGroupId = undefined;
1370
+ }
1371
+ }
1372
+ /**
1373
+ * Emergency stop - immediately halt all activity in a session
1374
+ * This is a forceful stop that kills all processes and clears state
1375
+ */
1376
+ async emergencyStop(sessionId) {
1377
+ // Prevent concurrent emergency stops
1378
+ if (this.emergencyStopInProgress.has(sessionId)) {
1379
+ return { success: false, message: 'Emergency stop already in progress' };
1380
+ }
1381
+ this.emergencyStopInProgress.add(sessionId);
1382
+ console.log(`[agentSession] EMERGENCY STOP triggered for session ${sessionId}`);
1383
+ try {
1384
+ const session = this.sessions.get(sessionId);
1385
+ if (!session) {
1386
+ return { success: false, message: 'Session not found' };
1387
+ }
1388
+ // 1. Abort session-level controller only (not all sessions' controllers)
1389
+ session.abortController.abort();
1390
+ // Abort only controllers belonging to THIS session
1391
+ // Find and abort controllers for prompts in this session's queue and current prompt
1392
+ if (session.currentPromptId) {
1393
+ const ctrl = this.promptAbortControllers.get(session.currentPromptId);
1394
+ if (ctrl)
1395
+ ctrl.abort();
1396
+ }
1397
+ for (const queued of session.promptQueue) {
1398
+ if (queued.abortController)
1399
+ queued.abortController.abort();
1400
+ if (queued.promptId) {
1401
+ const ctrl = this.promptAbortControllers.get(queued.promptId);
1402
+ if (ctrl)
1403
+ ctrl.abort();
1404
+ }
1405
+ }
1406
+ // 2. Kill the entire process tree
1407
+ await this.killProcessTree(sessionId);
1408
+ // 3. Collect prompt IDs from queue BEFORE clearing it
1409
+ const queueSize = session.promptQueue.length;
1410
+ const queuedPromptIds = session.promptQueue
1411
+ .map(p => p.promptId)
1412
+ .filter((id) => !!id);
1413
+ const currentPromptId = session.currentPromptId;
1414
+ // 4. Clear the prompt queue
1415
+ session.promptQueue = [];
1416
+ // 5. Clear active stream
1417
+ session.activeQueryStream = undefined;
1418
+ // 6. Reset processing state
1419
+ session.isProcessingQueue = false;
1420
+ // 7. Clean up abort controllers map (only for this session's prompts, not ALL sessions)
1421
+ if (currentPromptId) {
1422
+ this.promptAbortControllers.delete(currentPromptId);
1423
+ }
1424
+ for (const pid of queuedPromptIds) {
1425
+ this.promptAbortControllers.delete(pid);
1426
+ }
1427
+ session.currentPromptId = undefined;
1428
+ // 8. Remove from emergency stop tracking
1429
+ this.emergencyStopInProgress.delete(sessionId);
1430
+ const message = currentPromptId
1431
+ ? `Emergency stop: Cancelled prompt '${currentPromptId}' and cleared ${queueSize} queued prompts`
1432
+ : `Emergency stop: Cleared ${queueSize} queued prompts`;
1433
+ console.log(`[agentSession] ${message}`);
1434
+ return { success: true, message };
1435
+ }
1436
+ catch (error) {
1437
+ this.emergencyStopInProgress.delete(sessionId);
1438
+ console.error(`[agentSession] Emergency stop error:`, error);
1439
+ return { success: false, message: error.message || 'Emergency stop failed' };
1440
+ }
1441
+ }
1442
+ /**
1443
+ * Build a fresh MCP server for a query call.
1444
+ * The SDK's query() connects the MCP server's internal transport, so we cannot
1445
+ * reuse a single instance across multiple queries. This must be called fresh each time.
1446
+ */
1447
+ buildMcpServer(sessionId, attachmentDir, promptId) {
1448
+ console.log(`[buildMcpServer] Session ${sessionId}: attachmentDir=${attachmentDir || 'none'}, promptId=${promptId || 'none'}`);
1449
+ return createModuleMcpServer({
1450
+ attachmentDir,
1451
+ sessionId,
1452
+ promptId,
1453
+ });
1454
+ }
1455
+ }
1456
+ export const agentSessionManager = new AgentSessionManager();