@exreve/exk 1.0.53 → 1.0.55
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.
- package/dist/cli/agentSession.js +1 -0
- package/dist/ttc-cli.tar.gz +0 -0
- package/package.json +1 -1
- package/dist/agentSession.js +0 -1455
package/dist/agentSession.js
DELETED
|
@@ -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();
|