@exreve/exk 1.0.0

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,1176 @@
1
+ import { query } from '@anthropic-ai/claude-agent-sdk'
2
+ import type { SessionOutput } from './shared/types'
3
+ import { execSync, spawn } from 'child_process'
4
+ import { existsSync, realpathSync, mkdirSync, writeFileSync, readFileSync } from 'fs'
5
+ import { symlink as fsSymlink } from 'fs'
6
+ // import { AgentLogger } from './agentLogger' // DISABLED: File logging removed for performance
7
+ // Memory system removed for performance
8
+ import { getSkillContent } from './skills/index'
9
+ import { createModuleMcpServer, type ChoiceRequest, type ChoiceResponse, type ModuleMcpServerConfig } from './moduleMcpServer'
10
+ import path from 'path'
11
+ import os from 'os'
12
+ import { createRequire } from 'module'
13
+ import type { ChildProcess } from 'child_process'
14
+ import { promisify } from 'util'
15
+
16
+ /**
17
+ * Resolve path to the SDK's bundled cli.js.
18
+ * We resolve this ourselves so it works reliably on Windows when running from
19
+ * the PS1 install (ttc.cmd -> node ttc.js); the SDK's internal resolution via
20
+ * import.meta.url can fail or produce wrong paths in that context.
21
+ * CACHED: Path is resolved once at module load time for performance.
22
+ */
23
+ function resolveSdkCliPath(): string | undefined {
24
+ try {
25
+ const req =
26
+ typeof (globalThis as any).require === 'function'
27
+ ? (globalThis as any).require
28
+ : createRequire((import.meta as any).url)
29
+ const pkgPath = req.resolve('@anthropic-ai/claude-agent-sdk/package.json')
30
+ const cliPath = path.join(path.dirname(pkgPath), 'cli.js')
31
+ return existsSync(cliPath) ? cliPath : undefined
32
+ } catch {
33
+ return undefined
34
+ }
35
+ }
36
+
37
+ // Cache the resolved Claude executable path at module load time
38
+ const CACHED_CLAUDE_PATH: string | undefined = (() => {
39
+ const envPath = process.env.TTC_CLAUDE_PATH
40
+ if (envPath) return envPath
41
+
42
+ const sdkPath = resolveSdkCliPath()
43
+ if (sdkPath) return sdkPath
44
+
45
+ const localPath = path.join(os.homedir(), '.local', 'bin', 'claude')
46
+ if (existsSync(localPath)) return localPath
47
+
48
+ return undefined
49
+ })()
50
+
51
+ // Promisify symlink for async use
52
+ const symlinkAsync = promisify(fsSymlink)
53
+
54
+ // Helper function to extract tool name from result structure
55
+ function extractToolName(toolResult: any): string {
56
+ if (toolResult.name) return toolResult.name
57
+ if (toolResult.type === 'text' && toolResult.file) return 'Read'
58
+ if (toolResult.file_path || toolResult.filePath) {
59
+ return (toolResult.content !== undefined || toolResult.type === 'create') ? 'Write' : 'Read'
60
+ }
61
+ if (toolResult.stdout !== undefined || toolResult.stderr !== undefined) return 'Bash'
62
+ return 'unknown'
63
+ }
64
+
65
+ export interface SessionHandler {
66
+ sessionId: string
67
+ projectPath: string
68
+ promptId?: string // Optional promptId for status updates
69
+ model?: string // AI model to use (e.g., 'glm-5', 'glm-4.7')
70
+ userChoiceEnabled?: boolean // Whether user choice tool is enabled
71
+ enabledModules?: string[] // List of enabled module IDs
72
+ moduleSettings?: Record<string, any> // Module settings
73
+ onOutput: (output: SessionOutput) => void
74
+ onError: (error: string) => void
75
+ onComplete: (exitCode: number | null) => void
76
+ onStatusUpdate?: (status: 'running' | 'completed' | 'error' | 'cancelled') => void // Callback for status updates
77
+ onChoiceRequest?: (request: ChoiceRequest) => void // Callback for user choice requests
78
+ }
79
+
80
+ // AI config - loaded from server after registration, stored in ~/.talk-to-code/ai-config.json
81
+ const AI_CONFIG_PATH = path.join(os.homedir(), '.talk-to-code', 'ai-config.json')
82
+
83
+ function loadAiConfig(): { ANTHROPIC_AUTH_TOKEN: string; ANTHROPIC_BASE_URL: string; MODEL: string } {
84
+ try {
85
+ const data = readFileSync(AI_CONFIG_PATH, 'utf-8')
86
+ const config = JSON.parse(data)
87
+ return {
88
+ ANTHROPIC_AUTH_TOKEN: config.authToken || process.env.ANTHROPIC_API_KEY || '',
89
+ ANTHROPIC_BASE_URL: config.baseUrl || process.env.ANTHROPIC_BASE_URL || '',
90
+ MODEL: config.model || process.env.CLAUDE_MODEL || 'glm-5.1',
91
+ }
92
+ } catch {
93
+ return {
94
+ ANTHROPIC_AUTH_TOKEN: process.env.ANTHROPIC_API_KEY || '',
95
+ ANTHROPIC_BASE_URL: process.env.ANTHROPIC_BASE_URL || '',
96
+ MODEL: process.env.CLAUDE_MODEL || 'glm-5.1',
97
+ }
98
+ }
99
+ }
100
+
101
+ // Lazy config getter - reloads from file each time (so daemon picks up changes without restart)
102
+ const CLAUDE_CONFIG = {
103
+ get ANTHROPIC_AUTH_TOKEN() { return loadAiConfig().ANTHROPIC_AUTH_TOKEN },
104
+ get ANTHROPIC_BASE_URL() { return loadAiConfig().ANTHROPIC_BASE_URL },
105
+ get MODEL() { return loadAiConfig().MODEL },
106
+ }
107
+
108
+ interface ConversationMessage {
109
+ role: 'user' | 'assistant' | 'system'
110
+ content: any
111
+ timestamp: number
112
+ }
113
+
114
+ interface QueuedPrompt {
115
+ prompt: string
116
+ enhancers: string[]
117
+ handler: SessionHandler
118
+ timestamp: number
119
+ promptId?: string // Store promptId for cancellation
120
+ abortController?: AbortController // For cancelling queued prompts
121
+ model?: string // AI model to use for this prompt
122
+ }
123
+
124
+ interface SessionContext {
125
+ abortController: AbortController
126
+ activeQueryStream?: AsyncIterable<any>
127
+ messages: ConversationMessage[]
128
+ totalInputTokens: number
129
+ totalOutputTokens: number
130
+ totalCostUsd: number
131
+ lastUsage?: {
132
+ inputTokens: number
133
+ outputTokens: number
134
+ totalTokens: number
135
+ }
136
+ promptQueue: QueuedPrompt[]
137
+ isProcessingQueue: boolean
138
+ claudeSessionId?: string // Claude SDK session ID for resuming context
139
+ // logger?: AgentLogger // DISABLED: File logging removed for performance
140
+ // Memory system removed for performance
141
+ childProcesses: Set<ChildProcess> // Track all spawned child processes for force-kill
142
+ claudeProcessGroupId?: number // Process group ID for tree-killing (Unix-like)
143
+ currentPromptId?: string // Track currently executing promptId for cancellation
144
+ model: string // AI model to use for this session
145
+ pendingChoice?: {
146
+ request: ChoiceRequest
147
+ resolve: (response: ChoiceResponse) => void
148
+ }
149
+ userChoiceEnabled?: boolean // Whether user choice tool is enabled for this session
150
+ enabledModules?: string[] // List of enabled module IDs
151
+ moduleSettings?: Record<string, any> // Module settings
152
+ mcpServer?: ReturnType<typeof createModuleMcpServer> // MCP server for modules
153
+ }
154
+
155
+ export class AgentSessionManager {
156
+ private sessions = new Map<string, SessionContext>()
157
+ private promptAbortControllers = new Map<string, AbortController>() // Map promptId -> AbortController for cancellation
158
+ private emergencyStopInProgress = new Set<string>() // Track sessions being emergency stopped
159
+ private sessionHandlers = new Map<string, SessionHandler>() // Track handlers for each session
160
+
161
+ async createSession(handler: SessionHandler): Promise<void> {
162
+ const { sessionId, projectPath, model } = handler
163
+ const sessionModel = model || CLAUDE_CONFIG.MODEL
164
+
165
+ // Ensure project directory exists - prevents ENOENT errors when SDK spawns process
166
+ if (!existsSync(projectPath)) {
167
+ try {
168
+ mkdirSync(projectPath, { recursive: true })
169
+ console.log(`[AgentSessionManager] Created project directory: ${projectPath}`)
170
+ } catch (error: any) {
171
+ console.error(`[AgentSessionManager] Failed to create project directory ${projectPath}:`, error.message)
172
+ // Fallback for /home/abc - try to create symlink to /tmp/abc
173
+ if (projectPath === '/home/abc') {
174
+ const fallbackPath = '/tmp/abc'
175
+ try {
176
+ mkdirSync(fallbackPath, { recursive: true })
177
+ await symlinkAsync(fallbackPath, projectPath, 'dir')
178
+ console.log(`[AgentSessionManager] Created symlink: ${projectPath} -> ${fallbackPath}`)
179
+ } catch (symlinkError: any) {
180
+ console.log(`[AgentSessionManager] Symlink creation failed: ${symlinkError.message}`)
181
+ }
182
+ }
183
+ }
184
+ }
185
+
186
+ // If session already exists, update the model if provided
187
+ if (this.sessions.has(sessionId)) {
188
+ const existingSession = this.sessions.get(sessionId)!
189
+ // Update model if a new one is provided
190
+ if (model && model !== existingSession.model) {
191
+ existingSession.model = model
192
+ // DISABLED: File logging removed for performance
193
+ // if (existingSession.logger) {
194
+ // await existingSession.logger.info(`Model updated to: ${model}`)
195
+ // }
196
+ }
197
+ // DISABLED: File logging removed for performance
198
+ // if (existingSession.logger) {
199
+ // await existingSession.logger.info('Session already exists, reusing context')
200
+ // }
201
+ // Just ensure abort controller is fresh for new queries
202
+ existingSession.abortController = new AbortController()
203
+ return
204
+ }
205
+
206
+ // DISABLED: File logging removed for performance
207
+ // const logger = new AgentLogger(sessionId)
208
+ // await logger.logSessionCreated(projectPath)
209
+ // await logger.logModelConfig(sessionModel, CLAUDE_CONFIG.ANTHROPIC_BASE_URL)
210
+ const logger = undefined
211
+
212
+ // Store the handler for this session
213
+ this.sessionHandlers.set(sessionId, handler)
214
+
215
+ const abortController = new AbortController()
216
+
217
+ // Create MCP server for modules if enabled modules are provided
218
+ let mcpServer: ReturnType<typeof createModuleMcpServer> | undefined
219
+ const enabledModules = handler.enabledModules || []
220
+ const moduleSettings = handler.moduleSettings || {}
221
+
222
+ if (enabledModules.length > 0) {
223
+ mcpServer = createModuleMcpServer({
224
+ enabledModules,
225
+ moduleSettings,
226
+ onChoiceRequest: handler.onChoiceRequest
227
+ ? async (request) => {
228
+ return new Promise((resolve) => {
229
+ const session = this.sessions.get(sessionId)
230
+ if (session) {
231
+ session.pendingChoice = { request, resolve }
232
+ handler.onChoiceRequest!(request)
233
+ } else {
234
+ resolve({ choiceId: request.choiceId, selectedValue: null })
235
+ }
236
+ })
237
+ }
238
+ : undefined
239
+ })
240
+
241
+ // DISABLED: File logging removed for performance
242
+ // if (logger) {
243
+ // await logger.info(`MCP server created with modules: ${enabledModules.join(', ')}`)
244
+ // }
245
+ }
246
+
247
+ this.sessions.set(sessionId, {
248
+ abortController,
249
+ messages: [],
250
+ totalInputTokens: 0,
251
+ totalOutputTokens: 0,
252
+ totalCostUsd: 0,
253
+ promptQueue: [],
254
+ isProcessingQueue: false,
255
+ claudeSessionId: undefined, // Will be set when Claude SDK returns session ID
256
+ // logger, // DISABLED: File logging removed for performance
257
+ // Memory system removed for performance
258
+ childProcesses: new Set(),
259
+ claudeProcessGroupId: undefined,
260
+ currentPromptId: undefined,
261
+ model: sessionModel,
262
+ userChoiceEnabled: handler.userChoiceEnabled || false,
263
+ enabledModules,
264
+ moduleSettings,
265
+ mcpServer
266
+ })
267
+
268
+ // Auto-regenerate CLAUDE.md for fresh project context
269
+ // DISABLED: File logging removed for performance
270
+ await this.regenerateClaudeMd(projectPath, undefined)
271
+ }
272
+
273
+ /**
274
+ * Regenerate CLAUDE.md for fresh project context
275
+ */
276
+ private async regenerateClaudeMd(projectPath: string, logger?: AgentLogger): Promise<void> {
277
+ try {
278
+ const scriptPath = path.join(projectPath, 'scripts', 'generate-claude-md.js')
279
+ if (existsSync(scriptPath)) {
280
+ const startTime = Date.now()
281
+ execSync(`node "${scriptPath}"`, { cwd: projectPath, stdio: 'ignore' })
282
+ const duration = Date.now() - startTime
283
+ if (logger) {
284
+ await logger.info(`CLAUDE.md regenerated in ${duration}ms`)
285
+ }
286
+ }
287
+ } catch (error) {
288
+ // Don't fail session if CLAUDE.md generation fails
289
+ console.error('[AgentSessionManager] Failed to regenerate CLAUDE.md:', error)
290
+ }
291
+ }
292
+
293
+ // Memory system removed for performance - no loadMemoryContext or storeConversationInMemory
294
+
295
+ async sendPrompt(sessionId: string, prompt: string, enhancers: string[] = [], handler: SessionHandler): Promise<void> {
296
+ // Ensure session exists
297
+ if (!this.sessions.has(sessionId)) {
298
+ await this.createSession(handler)
299
+ }
300
+
301
+ const session = this.sessions.get(sessionId)!
302
+
303
+ // Update session model if provided in handler
304
+ if (handler.model && handler.model !== session.model) {
305
+ session.model = handler.model
306
+ // DISABLED: File logging removed for performance
307
+ // if (session.logger) {
308
+ // await session.logger.info(`Model updated to: ${handler.model}`)
309
+ // }
310
+ }
311
+
312
+ // Add prompt to queue - store promptId for cancellation
313
+ session.promptQueue.push({
314
+ prompt,
315
+ enhancers,
316
+ handler,
317
+ timestamp: Date.now(),
318
+ promptId: handler.promptId,
319
+ abortController: new AbortController(), // Pre-create for queued cancellation
320
+ model: handler.model || session.model // Use handler model or fall back to session model
321
+ })
322
+
323
+ // Start processing queue if not already processing
324
+ if (!session.isProcessingQueue) {
325
+ this.processPromptQueue(sessionId)
326
+ }
327
+ }
328
+
329
+ private async processPromptQueue(sessionId: string): Promise<void> {
330
+ const session = this.sessions.get(sessionId)
331
+ if (!session) return
332
+
333
+ if (session.isProcessingQueue) return
334
+ session.isProcessingQueue = true
335
+
336
+ while (session.promptQueue.length > 0 && !this.emergencyStopInProgress.has(sessionId)) {
337
+ const queuedPrompt = session.promptQueue.shift()!
338
+ const { prompt, enhancers, handler, promptId: queuedPromptId, abortController: queuedAbortController } = queuedPrompt
339
+ const { projectPath, promptId, onOutput, onError, onComplete, onStatusUpdate } = handler
340
+ const promptStartTime = Date.now()
341
+
342
+ try {
343
+ // Verify promptId is present in handler
344
+ if (!promptId) {
345
+ console.error(`[agentSession] Missing promptId in handler for prompt: ${prompt.substring(0, 50)}...`)
346
+ onError?.('Missing promptId in handler')
347
+ continue
348
+ }
349
+
350
+ // Check if this queued prompt was cancelled before processing started
351
+ if (queuedAbortController?.signal.aborted) {
352
+ console.log(`[agentSession] Queued prompt ${promptId} was cancelled before processing`)
353
+ onStatusUpdate?.('cancelled')
354
+ onComplete(null)
355
+ continue
356
+ }
357
+
358
+ // Use the queued promptId and abortController, or fallback to handler values
359
+ const effectivePromptId = queuedPromptId || promptId
360
+ const abortController = queuedAbortController || new AbortController()
361
+ session.abortController = abortController
362
+ session.currentPromptId = effectivePromptId // Track current prompt for emergency stop
363
+ this.promptAbortControllers.set(effectivePromptId, abortController)
364
+
365
+ // Emit status update: CLI is starting to process the prompt (IMMEDIATELY)
366
+ // This ensures real-time status updates before any async operations
367
+ onStatusUpdate?.('running')
368
+
369
+ // DISABLED: File logging removed for performance
370
+ // // Log prompt start
371
+ // if (session.logger) {
372
+ // await session.logger.logPromptStart(prompt, projectPath)
373
+ // }
374
+
375
+ // Wait for current query to finish before starting next prompt
376
+ // REMOVED: 200ms artificial delay - stream draining is sufficient
377
+ if (session.activeQueryStream !== undefined) {
378
+ try {
379
+ for await (const _ of session.activeQueryStream) {}
380
+ } catch {}
381
+ // REMOVED: await new Promise(resolve => setTimeout(resolve, 200))
382
+ session.activeQueryStream = undefined
383
+ }
384
+
385
+ session.activeQueryStream = undefined
386
+
387
+ // Build final prompt with enhancers
388
+ let finalPrompt = prompt
389
+ if (enhancers && enhancers.length > 0) {
390
+ const skillContent = getSkillContent(enhancers)
391
+ if (skillContent) {
392
+ finalPrompt = `${skillContent}\n\n${prompt}`
393
+ // DISABLED: File logging removed for performance
394
+ // // Log that enhancers are being used
395
+ // if (session.logger) {
396
+ // await session.logger.info(`Using enhancers: ${enhancers.join(', ')}`)
397
+ // }
398
+ }
399
+ }
400
+
401
+ // Add user message to history
402
+ session.messages.push({
403
+ role: 'user',
404
+ content: finalPrompt,
405
+ timestamp: Date.now()
406
+ })
407
+
408
+ // Emit context info
409
+ onOutput({
410
+ type: 'system',
411
+ data: {
412
+ message: `Context: ${session.messages.length} messages, ${session.totalInputTokens + session.totalOutputTokens} total tokens`,
413
+ contextInfo: {
414
+ messageCount: session.messages.length,
415
+ totalInputTokens: session.totalInputTokens,
416
+ totalOutputTokens: session.totalOutputTokens,
417
+ totalTokens: session.totalInputTokens + session.totalOutputTokens,
418
+ totalCostUsd: session.totalCostUsd,
419
+ lastUsage: session.lastUsage
420
+ }
421
+ },
422
+ timestamp: Date.now(),
423
+ metadata: {
424
+ subtype: 'context_info',
425
+ contextSize: session.messages.length,
426
+ totalTokens: session.totalInputTokens + session.totalOutputTokens
427
+ }
428
+ })
429
+
430
+ // Setup environment
431
+ process.env.ANTHROPIC_API_KEY = CLAUDE_CONFIG.ANTHROPIC_AUTH_TOKEN
432
+ if (CLAUDE_CONFIG.ANTHROPIC_BASE_URL) {
433
+ process.env.ANTHROPIC_BASE_URL = CLAUDE_CONFIG.ANTHROPIC_BASE_URL
434
+ }
435
+
436
+ // Use cached Claude executable path (resolved at module load time for performance)
437
+ const pathToClaudeCodeExecutable = CACHED_CLAUDE_PATH
438
+ // DISABLED: File logging removed for performance
439
+ // if (session.logger) {
440
+ // if (pathToClaudeCodeExecutable) {
441
+ // session.logger.info(`Using Claude (cli.js): ${pathToClaudeCodeExecutable}`).catch(() => {})
442
+ // } else {
443
+ // session.logger.info('Using SDK built-in Claude (cli.js)').catch(() => {})
444
+ // }
445
+ // }
446
+ // Build query options - include abort signal for cancellation
447
+ const queryOptions: any = {
448
+ signal: abortController.signal, // Pass abort signal to SDK for interruption
449
+ cwd: projectPath,
450
+ apiKey: CLAUDE_CONFIG.ANTHROPIC_AUTH_TOKEN,
451
+ model: CLAUDE_CONFIG.MODEL, // Use Claude Opus 4.5 (configurable via CLAUDE_MODEL env var)
452
+ tools: { type: 'preset', preset: 'claude_code' },
453
+ disallowedTools: ['AskUserQuestion'],
454
+ settingSources: ['project'], // Enable CLAUDE.md loading
455
+ permissionMode: 'bypassPermissions',
456
+ allowDangerouslySkipPermissions: true,
457
+ // Add MCP server for modules if configured
458
+ ...(session.mcpServer ? { mcpServers: [session.mcpServer] } : {}),
459
+ ...(pathToClaudeCodeExecutable ? { pathToClaudeCodeExecutable } : {}),
460
+ spawnClaudeCodeProcess: (spawnOptions: any) => {
461
+ const { command, args, cwd: cwd2, env, signal } = spawnOptions
462
+
463
+ // Only check file existence when command is a path (not a bare name like "claude" from PATH)
464
+ const hasPathSep = command.includes(path.sep) || command.includes('/') || command.includes('\\')
465
+ if (hasPathSep && !existsSync(command)) {
466
+ throw new Error(`Executable not found at ${command}. Set path with: ttc config --claude-path "<path>" (or TTC_CLAUDE_PATH)`)
467
+ }
468
+
469
+ try {
470
+ if (cwd2 && !existsSync(cwd2)) {
471
+ mkdirSync(cwd2, { recursive: true })
472
+ }
473
+ } catch {}
474
+
475
+ const isWin = process.platform === 'win32'
476
+ // Ensure PATH includes common node locations, especially in containers
477
+ const defaultPath = isWin
478
+ ? (process.env.Path || process.env.PATH || '')
479
+ : '/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin'
480
+ const spawnEnv = {
481
+ ...env,
482
+ PATH: env.PATH || process.env.PATH || defaultPath,
483
+ ...(isWin
484
+ ? {
485
+ USERPROFILE: env.USERPROFILE || process.env.USERPROFILE || os.homedir(),
486
+ USERNAME: env.USERNAME || process.env.USERNAME || 'user',
487
+ HOME: env.USERPROFILE || process.env.USERPROFILE || os.homedir(), // Windows: use Windows home, not /home/user
488
+ }
489
+ : { HOME: env.HOME || process.env.HOME || os.homedir(), USER: env.USER || process.env.USER || 'user' }),
490
+ }
491
+
492
+ // If command is 'node' and not found, try to resolve it
493
+ if (command === 'node' && !hasPathSep) {
494
+ try {
495
+ const nodePath = execSync('which node', { encoding: 'utf-8', env: spawnEnv }).trim()
496
+ if (nodePath) {
497
+ const child = spawn(nodePath, args, {
498
+ cwd: cwd2 || process.cwd(),
499
+ stdio: ["pipe", "pipe", env.DEBUG_CLAUDE_AGENT_SDK ? "pipe" : "ignore"],
500
+ signal,
501
+ env: spawnEnv,
502
+ windowsHide: true,
503
+ detached: !isWin // Create process group on Unix for tree-killing
504
+ })
505
+
506
+ // Track child process for force-kill
507
+ if (!session.childProcesses) session.childProcesses = new Set()
508
+ session.childProcesses.add(child)
509
+
510
+ // Store process group ID for Unix (negative PID kills entire group)
511
+ if (!isWin && child.pid) {
512
+ session.claudeProcessGroupId = child.pid
513
+ }
514
+
515
+ // Clean up when process exits
516
+ child.on('exit', () => {
517
+ session.childProcesses.delete(child)
518
+ })
519
+ child.on('error', () => {
520
+ session.childProcesses.delete(child)
521
+ })
522
+
523
+ return child
524
+ }
525
+ } catch {
526
+ // Fall through to original spawn
527
+ }
528
+ }
529
+
530
+ const child = spawn(command, args, {
531
+ cwd: cwd2 || process.cwd(),
532
+ stdio: ["pipe", "pipe", env.DEBUG_CLAUDE_AGENT_SDK ? "pipe" : "ignore"],
533
+ signal,
534
+ env: spawnEnv,
535
+ windowsHide: true,
536
+ detached: !isWin // Create process group on Unix for tree-killing
537
+ })
538
+
539
+ // Track child process for force-kill
540
+ if (!session.childProcesses) session.childProcesses = new Set()
541
+ session.childProcesses.add(child)
542
+
543
+ // Store process group ID for Unix (negative PID kills entire group)
544
+ if (!isWin && child.pid) {
545
+ session.claudeProcessGroupId = child.pid
546
+ }
547
+
548
+ // Clean up when process exits
549
+ child.on('exit', () => {
550
+ session.childProcesses.delete(child)
551
+ })
552
+ child.on('error', () => {
553
+ session.childProcesses.delete(child)
554
+ })
555
+
556
+ return child
557
+ },
558
+ env: {
559
+ ...process.env,
560
+ ANTHROPIC_API_KEY: CLAUDE_CONFIG.ANTHROPIC_AUTH_TOKEN,
561
+ ANTHROPIC_BASE_URL: CLAUDE_CONFIG.ANTHROPIC_BASE_URL,
562
+ },
563
+ hooks: {
564
+ // PostToolUse hook DISABLED for tool_result emission.
565
+ // tool_result events are already emitted via the user message handler (line ~752)
566
+ // which correctly extracts toolUseId from the SDK's user message structure.
567
+ // Sending duplicate events from here caused:
568
+ // 1. Frontend merging conflicts (two results for same tool)
569
+ // 2. Tools stuck showing "Running..." when the second event fails to merge
570
+ // 3. Missing toolUseId in hook events (not always available here)
571
+ PostToolUse: [(_toolResult: any) => {
572
+ // Tool result is handled by the user message handler below
573
+ }],
574
+ Notification: [(notification: any) => {
575
+ onOutput({
576
+ type: 'progress',
577
+ data: notification,
578
+ timestamp: Date.now(),
579
+ metadata: {
580
+ progress: {
581
+ message: typeof notification === 'string' ? notification : JSON.stringify(notification)
582
+ }
583
+ }
584
+ })
585
+ }]
586
+ }
587
+ }
588
+
589
+ // Log model being used for debugging
590
+ const sessionModel = session.model || CLAUDE_CONFIG.MODEL
591
+ // DISABLED: File logging removed for performance
592
+ // if (session.logger) {
593
+ // await session.logger.logModelConfig(sessionModel, CLAUDE_CONFIG.ANTHROPIC_BASE_URL)
594
+ // }
595
+
596
+ // Create query stream - resume session if we have a Claude session ID
597
+ // Always explicitly set model even when resuming to ensure we use the session's model
598
+ const queryStream = query({
599
+ prompt,
600
+ options: {
601
+ ...queryOptions,
602
+ model: sessionModel, // Use session-specific model (supports switching between prompts)
603
+ ...(session.claudeSessionId ? { resume: session.claudeSessionId } : {})
604
+ }
605
+ })
606
+ session.activeQueryStream = queryStream
607
+
608
+ // Process messages with enhanced abort checking
609
+ // Create a wrapped stream that checks abort status more frequently
610
+ const abortCheckInterval = 200 // Check every 200ms
611
+ let lastAbortCheck = Date.now()
612
+
613
+ try {
614
+ for await (const message of queryStream) {
615
+ // Check abort on each message (existing behavior)
616
+ if (session.abortController.signal.aborted || this.emergencyStopInProgress.has(sessionId)) {
617
+ console.log(`[agentSession] Aborting prompt ${effectivePromptId} - abort signal received`)
618
+ break
619
+ }
620
+
621
+ // Periodic check for long-running operations
622
+ const now = Date.now()
623
+ if (now - lastAbortCheck > abortCheckInterval) {
624
+ lastAbortCheck = now
625
+ if (session.abortController.signal.aborted || this.emergencyStopInProgress.has(sessionId)) {
626
+ console.log(`[agentSession] Aborting prompt ${effectivePromptId} - periodic check detected abort`)
627
+ break
628
+ }
629
+ }
630
+
631
+ // Capture Claude SDK session ID from system init message
632
+ if (message.type === 'system' && (message as any).subtype === 'init') {
633
+ const systemMsg = message as any
634
+ if (systemMsg.session_id && !session.claudeSessionId) {
635
+ session.claudeSessionId = systemMsg.session_id
636
+ // DISABLED: File logging removed for performance
637
+ // if (session.logger) {
638
+ // await session.logger.logClaudeSessionId(systemMsg.session_id)
639
+ // }
640
+ }
641
+ }
642
+
643
+ if (message.type === 'assistant') {
644
+ const msg = message as any
645
+ // Capture Claude session ID from assistant message if not already set
646
+ if (msg.session_id && !session.claudeSessionId) {
647
+ session.claudeSessionId = msg.session_id
648
+ // DISABLED: File logging removed for performance
649
+ // if (session.logger) {
650
+ // await session.logger.logClaudeSessionId(msg.session_id)
651
+ // }
652
+ }
653
+ session.messages.push({
654
+ role: 'assistant',
655
+ content: msg.message,
656
+ timestamp: Date.now()
657
+ })
658
+
659
+ // DISABLED: File logging removed for performance
660
+ // // Log agent response
661
+ // const responseText = typeof msg.message === 'string' ? msg.message : JSON.stringify(msg.message)
662
+ // if (session.logger) {
663
+ // await session.logger.logAgentResponse(responseText, {
664
+ // uuid: msg.uuid,
665
+ // sessionId: msg.session_id,
666
+ // error: msg.error,
667
+ // contextSize: session.messages.length
668
+ // })
669
+ // }
670
+
671
+ onOutput({
672
+ type: 'assistant',
673
+ data: msg.message,
674
+ timestamp: Date.now(),
675
+ metadata: {
676
+ parentToolUseId: msg.parent_tool_use_id,
677
+ uuid: msg.uuid,
678
+ sessionId: msg.session_id,
679
+ error: msg.error,
680
+ contextSize: session.messages.length
681
+ }
682
+ })
683
+ } else if (message.type === 'result') {
684
+ const msg = message as any
685
+
686
+ // Update usage tracking
687
+ if (msg.usage) {
688
+ const usage = msg.usage as any
689
+ const inputTokens = usage.input_tokens || usage.inputTokens || 0
690
+ const outputTokens = usage.output_tokens || usage.outputTokens || 0
691
+ session.totalInputTokens += inputTokens
692
+ session.totalOutputTokens += outputTokens
693
+ session.totalCostUsd += msg.total_cost_usd || 0
694
+ session.lastUsage = {
695
+ inputTokens,
696
+ outputTokens,
697
+ totalTokens: inputTokens + outputTokens
698
+ }
699
+
700
+ // DISABLED: File logging removed for performance
701
+ // // Log token usage
702
+ // if (session.logger) {
703
+ // await session.logger.logTokenUsage({
704
+ // inputTokens: session.totalInputTokens,
705
+ // outputTokens: session.totalOutputTokens,
706
+ // totalTokens: session.totalInputTokens + session.totalOutputTokens,
707
+ // costUsd: session.totalCostUsd
708
+ // })
709
+ // }
710
+ }
711
+
712
+ const promptDuration = Date.now() - promptStartTime
713
+
714
+ // DISABLED: File logging removed for performance
715
+ // // Log prompt completion
716
+ // if (session.logger) {
717
+ // await session.logger.logPromptEnd(
718
+ // msg.is_error ? 1 : 0,
719
+ // promptDuration,
720
+ // session.lastUsage ? {
721
+ // inputTokens: session.lastUsage.inputTokens,
722
+ // outputTokens: session.lastUsage.outputTokens,
723
+ // costUsd: msg.total_cost_usd || 0
724
+ // } : undefined
725
+ // )
726
+ // }
727
+
728
+ onOutput({
729
+ type: 'result',
730
+ data: msg,
731
+ timestamp: Date.now(),
732
+ metadata: {
733
+ subtype: msg.subtype,
734
+ isError: msg.is_error,
735
+ exitCode: msg.is_error ? 1 : 0,
736
+ durationMs: msg.duration_ms,
737
+ totalCostUsd: msg.total_cost_usd,
738
+ usage: msg.usage,
739
+ contextInfo: {
740
+ messageCount: session.messages.length,
741
+ totalInputTokens: session.totalInputTokens,
742
+ totalOutputTokens: session.totalOutputTokens,
743
+ totalTokens: session.totalInputTokens + session.totalOutputTokens,
744
+ totalCostUsd: session.totalCostUsd,
745
+ lastUsage: session.lastUsage
746
+ }
747
+ }
748
+ })
749
+ // Emit status update: CLI finished processing (real-time)
750
+ const exitCode = msg.is_error ? 1 : 0
751
+ // Clean up abort controller
752
+ this.promptAbortControllers.delete(promptId)
753
+ // Update status immediately based on exit code
754
+ onStatusUpdate?.(exitCode === 0 ? 'completed' : 'error')
755
+ onComplete(exitCode)
756
+ session.activeQueryStream = undefined
757
+
758
+ // Memory system removed for performance
759
+
760
+ break // Prompt complete, continue to next in queue
761
+ } else if (message.type === 'user') {
762
+ const msg = message as any
763
+ if (msg.tool_use_result) {
764
+ const toolResult = msg.tool_use_result as any
765
+ let toolUseId = msg.parent_tool_use_id
766
+ if (!toolUseId && Array.isArray(msg.message.content)) {
767
+ const toolResultContent = (msg.message.content as any[]).find((c: any) =>
768
+ c.type === 'tool_result' && c.tool_use_id
769
+ )
770
+ toolUseId = toolResultContent?.tool_use_id || null
771
+ }
772
+ onOutput({
773
+ type: 'tool_result',
774
+ data: toolResult,
775
+ timestamp: Date.now(),
776
+ metadata: {
777
+ toolName: extractToolName(toolResult),
778
+ toolResult: toolResult,
779
+ toolUseId: toolUseId || undefined,
780
+ parentToolUseId: msg.parent_tool_use_id,
781
+ isSynthetic: msg.isSynthetic
782
+ }
783
+ })
784
+ } else {
785
+ onOutput({
786
+ type: 'user',
787
+ data: msg.message,
788
+ timestamp: Date.now(),
789
+ metadata: {
790
+ parentToolUseId: null,
791
+ isSynthetic: msg.isSynthetic
792
+ }
793
+ })
794
+ }
795
+ } else if (message.type === 'system') {
796
+ onOutput({
797
+ type: 'system',
798
+ data: message,
799
+ timestamp: Date.now(),
800
+ metadata: {
801
+ subtype: message.subtype,
802
+ messageType: message.subtype || 'system'
803
+ }
804
+ })
805
+ } else if (message.type === 'tool_progress') {
806
+ const msg = message as any
807
+ onOutput({
808
+ type: 'tool_progress',
809
+ data: msg,
810
+ timestamp: Date.now(),
811
+ metadata: {
812
+ toolName: msg.tool_name,
813
+ toolUseId: msg.tool_use_id,
814
+ elapsedTimeSeconds: msg.elapsed_time_seconds,
815
+ parentToolUseId: msg.parent_tool_use_id
816
+ }
817
+ })
818
+ } else if (message.type === 'auth_status') {
819
+ onOutput({
820
+ type: 'auth_status',
821
+ data: message,
822
+ timestamp: Date.now(),
823
+ metadata: {
824
+ isAuthenticating: message.isAuthenticating,
825
+ error: message.error
826
+ }
827
+ })
828
+ } else if (message.type === 'stream_event') {
829
+ onOutput({
830
+ type: 'stream_event',
831
+ data: message.event || message,
832
+ timestamp: Date.now(),
833
+ metadata: {
834
+ parentToolUseId: message.parent_tool_use_id
835
+ }
836
+ })
837
+ } else {
838
+ onOutput({
839
+ type: 'stdout',
840
+ data: JSON.stringify(message, null, 2),
841
+ timestamp: Date.now()
842
+ })
843
+ }
844
+ }
845
+ } catch (streamError: any) {
846
+ // Check if this was an abort-related error
847
+ if (session.abortController.signal.aborted || this.emergencyStopInProgress.has(sessionId)) {
848
+ console.log(`[agentSession] Stream aborted for prompt ${effectivePromptId}`)
849
+ // Handle abort gracefully
850
+ onStatusUpdate?.('cancelled')
851
+ onComplete(null)
852
+ session.activeQueryStream = undefined
853
+ session.currentPromptId = undefined
854
+ return
855
+ }
856
+ // Re-throw non-abort errors
857
+ throw streamError
858
+ }
859
+
860
+ session.activeQueryStream = undefined
861
+ session.currentPromptId = undefined // Clear current prompt when done
862
+ } catch (error: any) {
863
+ const currentSession = this.sessions.get(sessionId)
864
+ if (currentSession) {
865
+ currentSession.activeQueryStream = undefined
866
+ // DISABLED: File logging removed for performance
867
+ // // Log error
868
+ // if (currentSession.logger) {
869
+ // await currentSession.logger.logSessionError(error, {
870
+ // prompt: prompt.substring(0, 200),
871
+ // projectPath
872
+ // })
873
+ // }
874
+ }
875
+ // Clean up abort controller (promptId is guaranteed to exist here due to check at line 204)
876
+ if (promptId) {
877
+ this.promptAbortControllers.delete(promptId)
878
+ }
879
+ // Emit status update: CLI encountered an error (real-time)
880
+ onStatusUpdate?.('error')
881
+ onError(error.message || 'Unknown error')
882
+ onComplete(null)
883
+ }
884
+ }
885
+
886
+ session.isProcessingQueue = false
887
+
888
+ // Continue processing queue if more prompts are waiting
889
+ if (session.promptQueue.length > 0) {
890
+ this.processPromptQueue(sessionId)
891
+ }
892
+ }
893
+
894
+ getContextInfo(sessionId: string): {
895
+ messageCount: number
896
+ totalInputTokens: number
897
+ totalOutputTokens: number
898
+ totalTokens: number
899
+ totalCostUsd: number
900
+ lastUsage?: { inputTokens: number; outputTokens: number; totalTokens: number }
901
+ } | null {
902
+ const session = this.sessions.get(sessionId)
903
+ if (!session) return null
904
+
905
+ return {
906
+ messageCount: session.messages.length,
907
+ totalInputTokens: session.totalInputTokens,
908
+ totalOutputTokens: session.totalOutputTokens,
909
+ totalTokens: session.totalInputTokens + session.totalOutputTokens,
910
+ totalCostUsd: session.totalCostUsd,
911
+ lastUsage: session.lastUsage
912
+ }
913
+ }
914
+
915
+ clearContext(sessionId: string): void {
916
+ const session = this.sessions.get(sessionId)
917
+ if (session) {
918
+ session.messages = []
919
+ session.totalInputTokens = 0
920
+ session.totalOutputTokens = 0
921
+ session.totalCostUsd = 0
922
+ // Clear Claude session ID to start fresh
923
+ session.claudeSessionId = undefined
924
+ session.lastUsage = undefined
925
+ }
926
+ }
927
+
928
+ async deleteSession(sessionId: string): Promise<void> {
929
+ const session = this.sessions.get(sessionId)
930
+ if (session) {
931
+ session.abortController.abort()
932
+ session.activeQueryStream = undefined
933
+ this.sessions.delete(sessionId)
934
+ }
935
+ }
936
+
937
+ /**
938
+ * Cancel a running or queued prompt by promptId
939
+ */
940
+ async cancelPrompt(promptId: string, sessionId: string, onStatusUpdate?: (status: 'cancelled') => void): Promise<boolean> {
941
+ const session = this.sessions.get(sessionId)
942
+ if (!session) {
943
+ return false // Session not found
944
+ }
945
+
946
+ // First, check if prompt is in the queue (not yet started)
947
+ const queuedIndex = session.promptQueue.findIndex(p => p.promptId === promptId)
948
+ if (queuedIndex !== -1) {
949
+ // Found in queue - abort it and remove from queue
950
+ const queuedPrompt = session.promptQueue[queuedIndex]
951
+ if (queuedPrompt.abortController) {
952
+ queuedPrompt.abortController.abort()
953
+ }
954
+ // Remove from queue
955
+ session.promptQueue.splice(queuedIndex, 1)
956
+ // Emit status update: prompt was cancelled (real-time)
957
+ onStatusUpdate?.('cancelled')
958
+ console.log(`[agentSession] Cancelled queued prompt: ${promptId}`)
959
+ return true
960
+ }
961
+
962
+ // Not in queue, check if it's currently running
963
+ const abortController = this.promptAbortControllers.get(promptId)
964
+ if (!abortController) {
965
+ return false // Prompt not found or already completed
966
+ }
967
+
968
+ // Abort the running prompt
969
+ abortController.abort()
970
+
971
+ // Clean up
972
+ this.promptAbortControllers.delete(promptId)
973
+
974
+ // Emit status update: prompt was cancelled (real-time)
975
+ onStatusUpdate?.('cancelled')
976
+
977
+ console.log(`[agentSession] Cancelled running prompt: ${promptId}`)
978
+ return true
979
+ }
980
+
981
+ /**
982
+ * Kill the entire process tree for a session (including grandchildren)
983
+ * Uses process group killing on Unix-like systems
984
+ */
985
+ private async killProcessTree(sessionId: string): Promise<void> {
986
+ const session = this.sessions.get(sessionId)
987
+ if (!session) return
988
+
989
+ const isWin = process.platform === 'win32'
990
+
991
+ // 1. Kill all tracked child processes
992
+ if (session.childProcesses && session.childProcesses.size > 0) {
993
+ console.log(`[agentSession] Killing ${session.childProcesses.size} tracked child processes`)
994
+ for (const child of session.childProcesses) {
995
+ if (!child.killed) {
996
+ try {
997
+ if (isWin) {
998
+ // Windows: use taskkill to force kill
999
+ if (child.pid) {
1000
+ spawn('taskkill', ['/pid', child.pid.toString(), '/f', '/t'], {
1001
+ stdio: 'ignore',
1002
+ windowsHide: true
1003
+ })
1004
+ }
1005
+ } else {
1006
+ // Unix: try graceful SIGTERM first, then SIGKILL
1007
+ child.kill('SIGTERM')
1008
+ }
1009
+ } catch (e) {
1010
+ // Process may already be dead
1011
+ }
1012
+ }
1013
+ }
1014
+
1015
+ // Wait a bit for graceful shutdown, then force kill
1016
+ await new Promise(resolve => setTimeout(resolve, 500))
1017
+
1018
+ for (const child of session.childProcesses) {
1019
+ if (!child.killed) {
1020
+ try {
1021
+ if (!isWin && child.pid) {
1022
+ // Unix: force kill with SIGKILL
1023
+ child.kill('SIGKILL')
1024
+ }
1025
+ } catch (e) {
1026
+ // Already dead
1027
+ }
1028
+ }
1029
+ }
1030
+
1031
+ session.childProcesses.clear()
1032
+ }
1033
+
1034
+ // 2. Kill the entire process group on Unix-like systems
1035
+ if (!isWin && session.claudeProcessGroupId) {
1036
+ try {
1037
+ console.log(`[agentSession] Killing process group ${session.claudeProcessGroupId}`)
1038
+ // Kill entire process group using negative PID
1039
+ process.kill(-session.claudeProcessGroupId, 'SIGKILL')
1040
+ } catch (e) {
1041
+ // Process group may already be dead
1042
+ }
1043
+ session.claudeProcessGroupId = undefined
1044
+ }
1045
+ }
1046
+
1047
+ /**
1048
+ * Emergency stop - immediately halt all activity in a session
1049
+ * This is a forceful stop that kills all processes and clears state
1050
+ */
1051
+ async emergencyStop(sessionId: string): Promise<{ success: boolean; message: string }> {
1052
+ // Prevent concurrent emergency stops
1053
+ if (this.emergencyStopInProgress.has(sessionId)) {
1054
+ return { success: false, message: 'Emergency stop already in progress' }
1055
+ }
1056
+
1057
+ this.emergencyStopInProgress.add(sessionId)
1058
+ console.log(`[agentSession] EMERGENCY STOP triggered for session ${sessionId}`)
1059
+
1060
+ try {
1061
+ const session = this.sessions.get(sessionId)
1062
+ if (!session) {
1063
+ return { success: false, message: 'Session not found' }
1064
+ }
1065
+
1066
+ // 1. Abort all controllers (session level + all prompt-level)
1067
+ session.abortController.abort()
1068
+ for (const controller of this.promptAbortControllers.values()) {
1069
+ controller.abort()
1070
+ }
1071
+
1072
+ // 2. Kill the entire process tree
1073
+ await this.killProcessTree(sessionId)
1074
+
1075
+ // 3. Clear the prompt queue
1076
+ const queueSize = session.promptQueue.length
1077
+ session.promptQueue = []
1078
+
1079
+ // 4. Clear active stream
1080
+ session.activeQueryStream = undefined
1081
+
1082
+ // 5. Reset processing state
1083
+ session.isProcessingQueue = false
1084
+
1085
+ // 6. Clear current prompt tracking
1086
+ const currentPromptId = session.currentPromptId
1087
+
1088
+ // 7. Clean up abort controllers map
1089
+ this.promptAbortControllers.clear()
1090
+
1091
+ // 8. Remove from emergency stop tracking
1092
+ this.emergencyStopInProgress.delete(sessionId)
1093
+
1094
+ // 9. Resolve any pending choice request with null (cancelled)
1095
+ if (session.pendingChoice) {
1096
+ session.pendingChoice.resolve({ choiceId: session.pendingChoice.request.choiceId, selectedValue: null })
1097
+ session.pendingChoice = undefined
1098
+ }
1099
+
1100
+ const message = currentPromptId
1101
+ ? `Emergency stop: Cancelled prompt '${currentPromptId}' and cleared ${queueSize} queued prompts`
1102
+ : `Emergency stop: Cleared ${queueSize} queued prompts`
1103
+
1104
+ console.log(`[agentSession] ${message}`)
1105
+ return { success: true, message }
1106
+ } catch (error: any) {
1107
+ this.emergencyStopInProgress.delete(sessionId)
1108
+ console.error(`[agentSession] Emergency stop error:`, error)
1109
+ return { success: false, message: error.message || 'Emergency stop failed' }
1110
+ }
1111
+ }
1112
+
1113
+ /**
1114
+ * Handle user choice response from frontend
1115
+ */
1116
+ async handleChoiceResponse(sessionId: string, response: ChoiceResponse): Promise<void> {
1117
+ const session = this.sessions.get(sessionId)
1118
+ if (!session) {
1119
+ console.error(`[agentSession] Session ${sessionId} not found for choice response`)
1120
+ return
1121
+ }
1122
+
1123
+ if (!session.pendingChoice) {
1124
+ console.warn(`[agentSession] No pending choice for session ${sessionId}`)
1125
+ return
1126
+ }
1127
+
1128
+ if (session.pendingChoice.request.choiceId !== response.choiceId) {
1129
+ console.warn(`[agentSession] Choice ID mismatch: expected ${session.pendingChoice.request.choiceId}, got ${response.choiceId}`)
1130
+ return
1131
+ }
1132
+
1133
+ // Resolve the pending choice promise
1134
+ session.pendingChoice.resolve(response)
1135
+ session.pendingChoice = undefined
1136
+ }
1137
+
1138
+ /**
1139
+ * Request user choice during agent execution
1140
+ */
1141
+ async requestUserChoice(sessionId: string, request: ChoiceRequest): Promise<ChoiceResponse> {
1142
+ const session = this.sessions.get(sessionId)
1143
+ if (!session) {
1144
+ throw new Error(`Session ${sessionId} not found`)
1145
+ }
1146
+
1147
+ // Check if user choice is enabled for this session
1148
+ if (!session.userChoiceEnabled) {
1149
+ throw new Error('User choice is not enabled for this session')
1150
+ }
1151
+
1152
+ // Create a promise that will be resolved when the user responds
1153
+ return new Promise((resolve) => {
1154
+ session.pendingChoice = { request, resolve }
1155
+
1156
+ // Emit the choice request through the onOutput callback
1157
+ // This will be picked up by the CLI and sent to the frontend
1158
+ const handler = this.sessionHandlers.get(sessionId)
1159
+ if (handler?.onChoiceRequest) {
1160
+ handler.onChoiceRequest(request)
1161
+ }
1162
+
1163
+ // Set timeout if specified
1164
+ if (request.timeout) {
1165
+ setTimeout(() => {
1166
+ if (session.pendingChoice?.request.choiceId === request.choiceId) {
1167
+ session.pendingChoice.resolve({ choiceId: request.choiceId, selectedValue: null })
1168
+ session.pendingChoice = undefined
1169
+ }
1170
+ }, request.timeout)
1171
+ }
1172
+ })
1173
+ }
1174
+ }
1175
+
1176
+ export const agentSessionManager = new AgentSessionManager()