@exreve/exk 1.0.61 → 1.0.63

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,535 @@
1
+ /**
2
+ * Claude Agent SDK Backend
3
+ *
4
+ * Implements AgentBackend using @anthropic-ai/claude-agent-sdk.
5
+ * This is the original backend — all Claude SDK-specific code lives here
6
+ * so the queue manager (agentSession.ts) stays SDK-agnostic.
7
+ */
8
+ import { query } from '@anthropic-ai/claude-agent-sdk';
9
+ import { existsSync, mkdirSync } from 'fs';
10
+ import { execSync, spawn } from 'child_process';
11
+ import path from 'path';
12
+ import os from 'os';
13
+ import { createRequire } from 'module';
14
+ import { createModuleMcpServer } from './moduleMcpServer.js';
15
+ // ============ Claude Executable Resolution ============
16
+ function resolveSdkCliPath() {
17
+ try {
18
+ const req = typeof globalThis.require === 'function'
19
+ ? globalThis.require
20
+ : createRequire(import.meta.url);
21
+ const pkgPath = req.resolve('@anthropic-ai/claude-agent-sdk/package.json');
22
+ const cliPath = path.join(path.dirname(pkgPath), 'cli.js');
23
+ return existsSync(cliPath) ? cliPath : undefined;
24
+ }
25
+ catch {
26
+ return undefined;
27
+ }
28
+ }
29
+ const CACHED_CLAUDE_PATH = (() => {
30
+ const envPath = process.env.TTC_CLAUDE_PATH;
31
+ if (envPath)
32
+ return envPath;
33
+ const sdkPath = resolveSdkCliPath();
34
+ if (sdkPath)
35
+ return sdkPath;
36
+ const localPath = path.join(os.homedir(), '.local', 'bin', 'claude');
37
+ if (existsSync(localPath))
38
+ return localPath;
39
+ return undefined;
40
+ })();
41
+ // ============ Tool Name Detection ============
42
+ // (Moved from agentSession.ts — these are Claude SDK–specific heuristics)
43
+ function extractToolName(toolResult) {
44
+ if (!toolResult || typeof toolResult !== 'object')
45
+ return 'unknown';
46
+ const r = toolResult;
47
+ if (r.file && typeof r.file === 'object'
48
+ && ['text', 'image', 'notebook', 'pdf', 'parts', 'file_unchanged'].includes(r.type)) {
49
+ return 'Read';
50
+ }
51
+ if (r.filePath && r.oldString !== undefined && r.newString !== undefined
52
+ && Array.isArray(r.structuredPatch)) {
53
+ return 'Edit';
54
+ }
55
+ if (r.filePath && (r.type === 'create' || r.type === 'update')
56
+ && r.content !== undefined) {
57
+ return 'Write';
58
+ }
59
+ if (typeof r.numFiles === 'number' && Array.isArray(r.filenames)
60
+ && r.mode !== undefined) {
61
+ return 'Grep';
62
+ }
63
+ if (typeof r.numFiles === 'number' && Array.isArray(r.filenames)
64
+ && typeof r.durationMs === 'number' && r.truncated !== undefined) {
65
+ return 'Glob';
66
+ }
67
+ if (Array.isArray(r.oldTodos) && Array.isArray(r.newTodos)) {
68
+ return 'TodoWrite';
69
+ }
70
+ if (r.stdout !== undefined || r.stderr !== undefined) {
71
+ return 'Bash';
72
+ }
73
+ if (typeof r.query === 'string' && Array.isArray(r.results)) {
74
+ return 'WebSearch';
75
+ }
76
+ if (typeof r.url === 'string' && typeof r.result === 'string'
77
+ && typeof r.code === 'number') {
78
+ return 'WebFetch';
79
+ }
80
+ if (r.content && typeof r.content === 'string' && r.type === 'text') {
81
+ try {
82
+ const parsed = JSON.parse(r.content);
83
+ if (parsed._type === 'send_file')
84
+ return 'send_file';
85
+ }
86
+ catch { /* not JSON */ }
87
+ return 'unknown';
88
+ }
89
+ if (r.agentId && Array.isArray(r.content) && r.status) {
90
+ return 'Task';
91
+ }
92
+ return 'unknown';
93
+ }
94
+ function lookupToolNameFromHistory(messages, toolUseId) {
95
+ if (!toolUseId)
96
+ return null;
97
+ for (let i = messages.length - 1; i >= 0; i--) {
98
+ const msg = messages[i];
99
+ if (msg.role !== 'assistant')
100
+ continue;
101
+ let content = typeof msg.content === 'string' ? null : msg.content;
102
+ if (content && !Array.isArray(content) && Array.isArray(content.content)) {
103
+ content = content.content;
104
+ }
105
+ if (!Array.isArray(content))
106
+ continue;
107
+ const toolUse = content.find((c) => c.type === 'tool_use' && c.id === toolUseId);
108
+ if (toolUse?.name)
109
+ return toolUse.name;
110
+ }
111
+ return null;
112
+ }
113
+ // ============ Claude Backend Class ============
114
+ export class ClaudeBackend {
115
+ id = 'claude';
116
+ /** Tracked child processes for force-kill on emergency stop */
117
+ childProcesses = new Set();
118
+ processGroupId;
119
+ /**
120
+ * Kill all tracked child processes.
121
+ * Called by the queue manager during emergency stop.
122
+ */
123
+ killProcesses() {
124
+ const isWin = process.platform === 'win32';
125
+ for (const child of this.childProcesses) {
126
+ if (!child.killed) {
127
+ try {
128
+ if (isWin && child.pid) {
129
+ spawn('taskkill', ['/pid', child.pid.toString(), '/f', '/t'], {
130
+ stdio: 'ignore',
131
+ windowsHide: true,
132
+ });
133
+ }
134
+ else {
135
+ child.kill('SIGTERM');
136
+ }
137
+ }
138
+ catch { /* already dead */ }
139
+ }
140
+ }
141
+ // Force kill after a brief grace period (sync — caller should handle timing)
142
+ for (const child of this.childProcesses) {
143
+ if (!child.killed) {
144
+ try {
145
+ if (!isWin && child.pid) {
146
+ child.kill('SIGKILL');
147
+ }
148
+ }
149
+ catch { /* already dead */ }
150
+ }
151
+ }
152
+ this.childProcesses.clear();
153
+ if (!isWin && this.processGroupId) {
154
+ try {
155
+ process.kill(-this.processGroupId, 'SIGKILL');
156
+ }
157
+ catch { /* already dead */ }
158
+ this.processGroupId = undefined;
159
+ }
160
+ }
161
+ /**
162
+ * Get the Claude executable path (for diagnostics).
163
+ */
164
+ getClaudePath() {
165
+ return CACHED_CLAUDE_PATH;
166
+ }
167
+ async *executePrompt(prompt, config) {
168
+ const { cwd, apiKey, model, env, settings, signal, attachmentDir, routingSessionId, routingPromptId, resumeSessionId } = config;
169
+ // Build MCP server for this query
170
+ const mcpServer = createModuleMcpServer({
171
+ attachmentDir,
172
+ sessionId: routingSessionId,
173
+ promptId: routingPromptId,
174
+ });
175
+ const pathToClaudeCodeExecutable = CACHED_CLAUDE_PATH;
176
+ // Build query options
177
+ const queryOptions = {
178
+ signal,
179
+ cwd,
180
+ apiKey,
181
+ model,
182
+ tools: { type: 'preset', preset: 'claude_code' },
183
+ disallowedTools: ['AskUserQuestion', 'analyze_image'],
184
+ settingSources: ['project'],
185
+ permissionMode: 'bypassPermissions',
186
+ allowDangerouslySkipPermissions: true,
187
+ mcpServers: { 'claude-voice-modules': mcpServer },
188
+ ...(pathToClaudeCodeExecutable ? { pathToClaudeCodeExecutable } : {}),
189
+ spawnClaudeCodeProcess: (spawnOptions) => {
190
+ return this.spawnProcess(spawnOptions, signal);
191
+ },
192
+ env: env || {},
193
+ hooks: {
194
+ PostToolUse: [{
195
+ hooks: [() => ({ continue: true })]
196
+ }],
197
+ Notification: [{
198
+ hooks: [(_notification) => {
199
+ // Notifications are handled as progress events via the stream
200
+ }]
201
+ }],
202
+ }
203
+ };
204
+ // Create query stream — optionally resume session
205
+ const queryStream = query({
206
+ prompt,
207
+ options: {
208
+ ...queryOptions,
209
+ ...(settings ? { settings } : {}),
210
+ ...(resumeSessionId ? { resume: resumeSessionId } : {}),
211
+ }
212
+ });
213
+ // Message history for tool name lookup (local to this execution)
214
+ const localMessages = [];
215
+ const toolNameMap = new Map();
216
+ for await (const message of queryStream) {
217
+ // Check abort
218
+ if (signal?.aborted)
219
+ break;
220
+ // ── System init ──
221
+ if (message.type === 'system' && message.subtype === 'init') {
222
+ const systemMsg = message;
223
+ if (systemMsg.session_id) {
224
+ yield { type: 'init', sessionId: systemMsg.session_id };
225
+ }
226
+ // Also emit as system event
227
+ yield {
228
+ type: 'system',
229
+ raw: systemMsg,
230
+ subtype: systemMsg.subtype,
231
+ };
232
+ continue;
233
+ }
234
+ // ── Assistant message ──
235
+ if (message.type === 'assistant') {
236
+ const msg = message;
237
+ // Capture session ID
238
+ if (msg.session_id) {
239
+ // Will be used by queue manager for session tracking
240
+ }
241
+ // Store for tool name lookup
242
+ localMessages.push({ role: 'assistant', content: msg.message });
243
+ // Populate toolNameMap
244
+ const toolUses = [];
245
+ if (Array.isArray(msg.message?.content)) {
246
+ for (const block of msg.message.content) {
247
+ if (block.type === 'tool_use' && block.id && block.name) {
248
+ toolNameMap.set(block.id, block.name);
249
+ toolUses.push({ id: block.id, name: block.name });
250
+ }
251
+ }
252
+ }
253
+ // Detect context window limit error
254
+ const isContextWindowError = msg.error === 'max_output_tokens' && ((typeof msg.message === 'string' && msg.message.includes('context window limit')) ||
255
+ (Array.isArray(msg.message?.content) && msg.message.content.some((c) => typeof c?.text === 'string' && c.text.includes('context window limit'))));
256
+ yield {
257
+ type: 'assistant_message',
258
+ raw: msg.message,
259
+ sdkSessionId: msg.session_id,
260
+ error: msg.error,
261
+ toolUses,
262
+ isContextWindowError,
263
+ };
264
+ continue;
265
+ }
266
+ // ── Result ──
267
+ if (message.type === 'result') {
268
+ const msg = message;
269
+ let usage;
270
+ if (msg.usage) {
271
+ const u = msg.usage;
272
+ const inputTokens = u.input_tokens || u.inputTokens || 0;
273
+ const outputTokens = u.output_tokens || u.outputTokens || 0;
274
+ usage = {
275
+ inputTokens,
276
+ outputTokens,
277
+ totalCostUsd: msg.total_cost_usd || 0,
278
+ durationMs: msg.duration_ms,
279
+ numTurns: msg.num_turns,
280
+ };
281
+ }
282
+ yield {
283
+ type: 'result',
284
+ raw: msg,
285
+ isError: !!msg.is_error,
286
+ usage,
287
+ };
288
+ continue;
289
+ }
290
+ // ── User message (tool results) ──
291
+ if (message.type === 'user') {
292
+ const msg = message;
293
+ let toolResult = null;
294
+ let toolUseId = null;
295
+ // Extract tool_use_id from message.content
296
+ if (Array.isArray(msg.message?.content)) {
297
+ const contentBlocks = msg.message.content;
298
+ const toolResultBlock = contentBlocks.find((c) => c.type === 'tool_result');
299
+ if (toolResultBlock?.tool_use_id) {
300
+ toolUseId = toolResultBlock.tool_use_id;
301
+ }
302
+ }
303
+ // Fallback: parent_tool_use_id
304
+ if (!toolUseId && msg.parent_tool_use_id) {
305
+ toolUseId = msg.parent_tool_use_id;
306
+ }
307
+ // Extract tool result data
308
+ if (msg.tool_use_result) {
309
+ const raw = msg.tool_use_result;
310
+ if (Array.isArray(raw)) {
311
+ const textParts = raw
312
+ .filter((c) => c.type === 'text')
313
+ .map((c) => c.text);
314
+ const rawContent = textParts.join('\n');
315
+ try {
316
+ toolResult = JSON.parse(rawContent);
317
+ }
318
+ catch {
319
+ toolResult = { content: rawContent, type: 'text' };
320
+ }
321
+ }
322
+ else if (typeof raw === 'object' && raw !== null) {
323
+ toolResult = raw;
324
+ }
325
+ else if (typeof raw === 'string') {
326
+ try {
327
+ toolResult = JSON.parse(raw);
328
+ }
329
+ catch {
330
+ toolResult = { content: raw, type: 'text' };
331
+ }
332
+ }
333
+ }
334
+ // Fallback to parsing message.content
335
+ if (!toolResult && Array.isArray(msg.message?.content)) {
336
+ const contentBlocks = msg.message.content;
337
+ const toolResultBlock = contentBlocks.find((c) => c.type === 'tool_result');
338
+ if (toolResultBlock) {
339
+ if (typeof toolResultBlock.content === 'string') {
340
+ try {
341
+ toolResult = JSON.parse(toolResultBlock.content);
342
+ }
343
+ catch {
344
+ toolResult = { content: toolResultBlock.content, type: 'text' };
345
+ }
346
+ }
347
+ else if (Array.isArray(toolResultBlock.content)) {
348
+ const textParts = toolResultBlock.content
349
+ .filter((c) => c.type === 'text')
350
+ .map((c) => c.text);
351
+ const rawContent = textParts.join('\n');
352
+ try {
353
+ toolResult = JSON.parse(rawContent);
354
+ }
355
+ catch {
356
+ toolResult = { content: rawContent, type: 'text' };
357
+ }
358
+ }
359
+ else {
360
+ toolResult = toolResultBlock;
361
+ }
362
+ }
363
+ }
364
+ if (toolResult) {
365
+ // Resolve tool name: map lookup → history scan → heuristic
366
+ const mappedName = toolUseId ? toolNameMap.get(toolUseId) ?? null : null;
367
+ const historyName = mappedName || lookupToolNameFromHistory(localMessages, toolUseId);
368
+ const detectedName = historyName || extractToolName(toolResult);
369
+ yield {
370
+ type: 'tool_result',
371
+ result: toolResult,
372
+ toolName: detectedName,
373
+ toolUseId,
374
+ parentToolUseId: msg.parent_tool_use_id,
375
+ isSynthetic: msg.isSynthetic,
376
+ };
377
+ }
378
+ else {
379
+ // No tool result — emit as raw user message (rare)
380
+ yield {
381
+ type: 'tool_result',
382
+ result: msg.message,
383
+ toolName: null,
384
+ toolUseId: null,
385
+ parentToolUseId: msg.parent_tool_use_id,
386
+ isSynthetic: msg.isSynthetic,
387
+ };
388
+ }
389
+ continue;
390
+ }
391
+ // ── System (non-init) ──
392
+ if (message.type === 'system') {
393
+ const sysMsg = message;
394
+ yield {
395
+ type: 'system',
396
+ raw: sysMsg,
397
+ subtype: sysMsg.subtype,
398
+ };
399
+ continue;
400
+ }
401
+ // ── Tool progress ──
402
+ if (message.type === 'tool_progress') {
403
+ const msg = message;
404
+ yield {
405
+ type: 'tool_progress',
406
+ raw: msg,
407
+ toolName: msg.tool_name,
408
+ toolUseId: msg.tool_use_id,
409
+ elapsedTimeSeconds: msg.elapsed_time_seconds,
410
+ parentToolUseId: msg.parent_tool_use_id,
411
+ };
412
+ continue;
413
+ }
414
+ // ── Stream event ──
415
+ if (message.type === 'stream_event') {
416
+ yield {
417
+ type: 'stream_event',
418
+ raw: message.event || message,
419
+ parentToolUseId: message.parent_tool_use_id,
420
+ };
421
+ continue;
422
+ }
423
+ // ── Tool use summary ──
424
+ if (message.type === 'tool_use_summary') {
425
+ // Treat as system event for simplicity
426
+ const msg = message;
427
+ yield {
428
+ type: 'system',
429
+ raw: msg,
430
+ subtype: 'tool_use_summary',
431
+ };
432
+ continue;
433
+ }
434
+ // ── Rate limit ──
435
+ if (message.type === 'rate_limit_event') {
436
+ yield {
437
+ type: 'rate_limit',
438
+ raw: message,
439
+ };
440
+ continue;
441
+ }
442
+ // ── Prompt suggestion ──
443
+ if (message.type === 'prompt_suggestion') {
444
+ const msg = message;
445
+ yield {
446
+ type: 'prompt_suggestion',
447
+ suggestion: msg.suggestion || '',
448
+ raw: msg,
449
+ };
450
+ continue;
451
+ }
452
+ // ── Auth status ──
453
+ if (message.type === 'auth_status') {
454
+ yield {
455
+ type: 'system',
456
+ raw: message,
457
+ subtype: 'auth_status',
458
+ };
459
+ continue;
460
+ }
461
+ // ── Keepalive — ignore ──
462
+ if (message.type === 'keep_alive') {
463
+ continue;
464
+ }
465
+ // ── Unknown — emit as system event ──
466
+ yield {
467
+ type: 'system',
468
+ raw: message,
469
+ subtype: 'unknown',
470
+ };
471
+ }
472
+ }
473
+ /**
474
+ * Spawn a Claude Code child process with proper environment.
475
+ */
476
+ spawnProcess(spawnOptions, signal) {
477
+ const { command, args, cwd, env } = spawnOptions;
478
+ console.log(`[ClaudeBackend] Spawn ANTHROPIC_BASE_URL:`, env?.ANTHROPIC_BASE_URL || '(not set)');
479
+ console.log(`[ClaudeBackend] Spawn ANTHROPIC_API_KEY:`, env?.ANTHROPIC_API_KEY ? '(set)' : '(not set)');
480
+ const hasPathSep = command.includes(path.sep) || command.includes('/') || command.includes('\\');
481
+ if (hasPathSep && !existsSync(command)) {
482
+ throw new Error(`Executable not found at ${command}. Set path with: ttc config --claude-path "<path>" (or TTC_CLAUDE_PATH)`);
483
+ }
484
+ if (cwd && !existsSync(cwd)) {
485
+ try {
486
+ mkdirSync(cwd, { recursive: true });
487
+ }
488
+ catch { /* ignore */ }
489
+ }
490
+ const isWin = process.platform === 'win32';
491
+ const defaultPath = isWin
492
+ ? (process.env.Path || process.env.PATH || '')
493
+ : '/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin';
494
+ const spawnEnv = {
495
+ ...env,
496
+ IS_SANDBOX: '1',
497
+ PATH: env.PATH || process.env.PATH || defaultPath,
498
+ ...(isWin
499
+ ? {
500
+ USERPROFILE: env.USERPROFILE || process.env.USERPROFILE || os.homedir(),
501
+ USERNAME: env.USERNAME || process.env.USERNAME || 'user',
502
+ HOME: env.USERPROFILE || process.env.USERPROFILE || os.homedir(),
503
+ }
504
+ : { HOME: env.HOME || process.env.HOME || os.homedir(), USER: env.USER || process.env.USER || 'user' }),
505
+ };
506
+ // Resolve 'node' if needed
507
+ let effectiveCommand = command;
508
+ if (command === 'node' && !hasPathSep) {
509
+ try {
510
+ const nodePath = execSync('which node', { encoding: 'utf-8', env: spawnEnv }).trim();
511
+ if (nodePath)
512
+ effectiveCommand = nodePath;
513
+ }
514
+ catch { /* fall through */ }
515
+ }
516
+ const child = spawn(effectiveCommand, args, {
517
+ cwd: cwd || process.cwd(),
518
+ stdio: ['pipe', 'pipe', env.DEBUG_CLAUDE_AGENT_SDK ? 'pipe' : 'ignore'],
519
+ signal,
520
+ env: spawnEnv,
521
+ windowsHide: true,
522
+ detached: !isWin,
523
+ });
524
+ // Track for cleanup
525
+ this.childProcesses.add(child);
526
+ if (!isWin && child.pid) {
527
+ this.processGroupId = child.pid;
528
+ }
529
+ child.on('exit', () => { this.childProcesses.delete(child); });
530
+ child.on('error', () => { this.childProcesses.delete(child); });
531
+ return child;
532
+ }
533
+ }
534
+ /** Singleton instance */
535
+ export const claudeBackend = new ClaudeBackend();