@exreve/exk 1.0.54 → 1.0.56

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