@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.
- package/README.md +109 -0
- package/agentLogger.ts +162 -0
- package/agentSession.ts +1176 -0
- package/app-child.ts +2769 -0
- package/appManager.ts +275 -0
- package/appRunner.ts +475 -0
- package/bin/exk +45 -0
- package/container-entrypoint.sh +177 -0
- package/index.ts +2798 -0
- package/install-service.sh +122 -0
- package/moduleMcpServer.ts +131 -0
- package/package.json +67 -0
- package/projectAnalyzer.ts +341 -0
- package/projectManager.ts +111 -0
- package/runnerGenerator.ts +218 -0
- package/shared/types.ts +488 -0
- package/skills/code-review.md +49 -0
- package/skills/front-glass.md +36 -0
- package/skills/frontend-design.md +41 -0
- package/skills/index.ts +151 -0
- package/tsconfig.json +22 -0
- package/updater.ts +512 -0
package/agentSession.ts
ADDED
|
@@ -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()
|