opencastle 0.23.1 → 0.24.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/dist/cli/convoy/engine.d.ts +1 -0
- package/dist/cli/convoy/engine.d.ts.map +1 -1
- package/dist/cli/convoy/engine.js +72 -22
- package/dist/cli/convoy/engine.js.map +1 -1
- package/dist/cli/convoy/engine.test.js +205 -0
- package/dist/cli/convoy/engine.test.js.map +1 -1
- package/dist/cli/dashboard.d.ts.map +1 -1
- package/dist/cli/dashboard.js +5 -4
- package/dist/cli/dashboard.js.map +1 -1
- package/dist/cli/run/adapters/claude.d.ts +6 -0
- package/dist/cli/run/adapters/claude.d.ts.map +1 -0
- package/dist/cli/run/adapters/claude.js +211 -0
- package/dist/cli/run/adapters/claude.js.map +1 -0
- package/dist/cli/run/adapters/copilot.d.ts +0 -18
- package/dist/cli/run/adapters/copilot.d.ts.map +1 -1
- package/dist/cli/run/adapters/copilot.js +123 -38
- package/dist/cli/run/adapters/copilot.js.map +1 -1
- package/dist/cli/run/adapters/index.js +2 -2
- package/dist/cli/run/adapters/index.js.map +1 -1
- package/dist/cli/run/schema.d.ts.map +1 -1
- package/dist/cli/run/schema.js +8 -0
- package/dist/cli/run/schema.js.map +1 -1
- package/dist/cli/run/schema.test.js +41 -0
- package/dist/cli/run/schema.test.js.map +1 -1
- package/dist/cli/run.d.ts.map +1 -1
- package/dist/cli/run.js +21 -9
- package/dist/cli/run.js.map +1 -1
- package/dist/cli/types.d.ts +2 -0
- package/dist/cli/types.d.ts.map +1 -1
- package/package.json +9 -1
- package/src/cli/convoy/engine.test.ts +240 -0
- package/src/cli/convoy/engine.ts +80 -23
- package/src/cli/dashboard.ts +6 -5
- package/src/cli/run/adapters/claude.ts +238 -0
- package/src/cli/run/adapters/copilot.ts +125 -47
- package/src/cli/run/adapters/index.ts +2 -2
- package/src/cli/run/adapters/vendor.d.ts +2 -0
- package/src/cli/run/schema.test.ts +51 -0
- package/src/cli/run/schema.ts +10 -0
- package/src/cli/run.ts +23 -11
- package/src/cli/types.ts +2 -0
- package/src/dashboard/node_modules/.vite/deps/_metadata.json +6 -6
- package/src/orchestrator/agents/team-lead.agent.md +6 -6
- package/src/orchestrator/prompts/bug-fix.prompt.md +6 -2
- package/src/orchestrator/prompts/generate-convoy.prompt.md +3 -3
- package/src/orchestrator/prompts/implement-feature.prompt.md +8 -19
- package/dist/cli/run/adapters/claude-code.d.ts +0 -16
- package/dist/cli/run/adapters/claude-code.d.ts.map +0 -1
- package/dist/cli/run/adapters/claude-code.js +0 -95
- package/dist/cli/run/adapters/claude-code.js.map +0 -1
- package/src/cli/run/adapters/claude-code.ts +0 -107
package/src/cli/convoy/engine.ts
CHANGED
|
@@ -14,6 +14,7 @@ import type { TaskRecord, ConvoyStatus } from './types.js'
|
|
|
14
14
|
import { buildPhases, formatDuration } from '../run/executor.js'
|
|
15
15
|
import { parseTimeout } from '../run/schema.js'
|
|
16
16
|
import { getAdapter, detectAdapter } from '../run/adapters/index.js'
|
|
17
|
+
import { c } from '../prompt.js'
|
|
17
18
|
|
|
18
19
|
const execFile = promisify(execFileCb)
|
|
19
20
|
|
|
@@ -37,7 +38,7 @@ export interface ConvoyResult {
|
|
|
37
38
|
status: ConvoyStatus
|
|
38
39
|
summary: { total: number; done: number; failed: number; skipped: number; timedOut: number }
|
|
39
40
|
duration: string
|
|
40
|
-
gateResults?: Array<{ command: string; exitCode: number; passed: boolean }>
|
|
41
|
+
gateResults?: Array<{ command: string; exitCode: number; passed: boolean; output?: string }>
|
|
41
42
|
cost?: { total_tokens: number }
|
|
42
43
|
}
|
|
43
44
|
|
|
@@ -95,6 +96,8 @@ async function runConvoy(
|
|
|
95
96
|
verbose: boolean,
|
|
96
97
|
startTime: number,
|
|
97
98
|
): Promise<ConvoyResult> {
|
|
99
|
+
const totalTasks = spec.tasks?.length ?? 0
|
|
100
|
+
let completedCount = 0
|
|
98
101
|
const activeTaskMap = new Map<string, Task>()
|
|
99
102
|
const taskAdapterMap = new Map<string, AgentAdapter>()
|
|
100
103
|
|
|
@@ -123,7 +126,7 @@ async function runConvoy(
|
|
|
123
126
|
const task = allTasks.find(t => t.id === taskId)
|
|
124
127
|
if (!task || task.status !== 'pending') return
|
|
125
128
|
store.updateTaskStatus(taskId, convoyId, 'skipped', { output: reason })
|
|
126
|
-
|
|
129
|
+
process.stdout.write(` ${c.dim('⊘')} ${c.bold(`[${taskId}]`)} skipped\n`)
|
|
127
130
|
events.emit('task_skipped', { reason }, { convoy_id: convoyId, task_id: taskId })
|
|
128
131
|
for (const t of allTasks) {
|
|
129
132
|
const deps = t.depends_on ? (JSON.parse(t.depends_on) as string[]) : []
|
|
@@ -206,7 +209,7 @@ async function runConvoy(
|
|
|
206
209
|
const task = taskRecordToTask(taskRecord)
|
|
207
210
|
activeTaskMap.set(taskRecord.id, task)
|
|
208
211
|
|
|
209
|
-
|
|
212
|
+
process.stdout.write(` ${c.cyan('▶')} ${c.bold(`[${taskRecord.id}]`)} ${taskRecord.agent}${worktreePath ? c.dim(' (worktree)') : ''}\n`)
|
|
210
213
|
events.emit(
|
|
211
214
|
'task_started',
|
|
212
215
|
{ worker_id: workerId },
|
|
@@ -252,11 +255,7 @@ async function runConvoy(
|
|
|
252
255
|
finished_at: null,
|
|
253
256
|
})
|
|
254
257
|
store.updateWorkerStatus(workerId, 'killed', { finished_at: finishedAt })
|
|
255
|
-
|
|
256
|
-
process.stdout.write(
|
|
257
|
-
`\u23f1 ${taskRecord.id} retry ${freshRecord.retries + 1}/${freshRecord.max_retries}\n`,
|
|
258
|
-
)
|
|
259
|
-
}
|
|
258
|
+
process.stdout.write(` ${c.yellow('⟳')} ${c.bold(`[${taskRecord.id}]`)} timed out, retry ${freshRecord.retries + 1}/${freshRecord.max_retries}\n`)
|
|
260
259
|
} else {
|
|
261
260
|
store.withTransaction(() => {
|
|
262
261
|
store.updateTaskStatus(taskRecord.id, convoyId, 'timed-out', {
|
|
@@ -265,7 +264,8 @@ async function runConvoy(
|
|
|
265
264
|
})
|
|
266
265
|
store.updateWorkerStatus(workerId, 'failed', { finished_at: finishedAt })
|
|
267
266
|
})
|
|
268
|
-
|
|
267
|
+
completedCount++
|
|
268
|
+
process.stdout.write(` ${c.red('⏱')} ${c.bold(`[${taskRecord.id}]`)} timed out ${c.dim(`[${completedCount}/${totalTasks}]`)}\n`)
|
|
269
269
|
events.emit(
|
|
270
270
|
'task_failed',
|
|
271
271
|
{ reason: 'timeout', worker_id: workerId },
|
|
@@ -308,7 +308,8 @@ async function runConvoy(
|
|
|
308
308
|
})
|
|
309
309
|
store.updateWorkerStatus(workerId, 'done', { finished_at: finishedAt })
|
|
310
310
|
})
|
|
311
|
-
|
|
311
|
+
completedCount++
|
|
312
|
+
process.stdout.write(` ${c.green('✓')} ${c.bold(`[${taskRecord.id}]`)} ${elapsed} ${c.dim(`[${completedCount}/${totalTasks}]`)}\n`)
|
|
312
313
|
events.emit(
|
|
313
314
|
'task_done',
|
|
314
315
|
{ exit_code: result.exitCode, worker_id: workerId },
|
|
@@ -332,11 +333,7 @@ async function runConvoy(
|
|
|
332
333
|
finished_at: null,
|
|
333
334
|
})
|
|
334
335
|
store.updateWorkerStatus(workerId, 'failed', { finished_at: finishedAt })
|
|
335
|
-
|
|
336
|
-
process.stdout.write(
|
|
337
|
-
`\u2717 ${taskRecord.id} retry ${freshRecord.retries + 1}/${freshRecord.max_retries}\n`,
|
|
338
|
-
)
|
|
339
|
-
}
|
|
336
|
+
process.stdout.write(` ${c.yellow('⟳')} ${c.bold(`[${taskRecord.id}]`)} retry ${freshRecord.retries + 1}/${freshRecord.max_retries}\n`)
|
|
340
337
|
} else {
|
|
341
338
|
store.withTransaction(() => {
|
|
342
339
|
store.updateTaskStatus(taskRecord.id, convoyId, 'failed', {
|
|
@@ -346,7 +343,12 @@ async function runConvoy(
|
|
|
346
343
|
})
|
|
347
344
|
store.updateWorkerStatus(workerId, 'failed', { finished_at: finishedAt })
|
|
348
345
|
})
|
|
349
|
-
|
|
346
|
+
completedCount++
|
|
347
|
+
process.stdout.write(` ${c.red('✗')} ${c.bold(`[${taskRecord.id}]`)} failed ${elapsed} ${c.dim(`[${completedCount}/${totalTasks}]`)}\n`)
|
|
348
|
+
if (verbose) {
|
|
349
|
+
const outputPreview = result.output.split('\n').slice(0, 5).join('\n')
|
|
350
|
+
process.stdout.write(`${outputPreview}\n`)
|
|
351
|
+
}
|
|
350
352
|
events.emit(
|
|
351
353
|
'task_failed',
|
|
352
354
|
{ reason: 'error', exit_code: result.exitCode, worker_id: workerId },
|
|
@@ -359,10 +361,19 @@ async function runConvoy(
|
|
|
359
361
|
|
|
360
362
|
// ── Main execution loop ───────────────────────────────────────────────────
|
|
361
363
|
|
|
364
|
+
let lastPhase = -1
|
|
362
365
|
try {
|
|
363
366
|
let ready = store.getReadyTasks(convoyId)
|
|
364
367
|
const concurrency = spec.concurrency ?? 1
|
|
365
368
|
while (ready.length > 0) {
|
|
369
|
+
for (const t of ready) {
|
|
370
|
+
if (t.phase !== lastPhase) {
|
|
371
|
+
lastPhase = t.phase
|
|
372
|
+
const tasksInPhase = ready.filter(r => r.phase === t.phase)
|
|
373
|
+
const ids = tasksInPhase.map(r => r.id).join(', ')
|
|
374
|
+
process.stdout.write(`\n ${c.bold(`Phase ${t.phase + 1}:`)} ${c.dim(ids)}\n`)
|
|
375
|
+
}
|
|
376
|
+
}
|
|
366
377
|
for (let i = 0; i < ready.length; i += concurrency) {
|
|
367
378
|
await Promise.all(ready.slice(i, i + concurrency).map(t => executeOneTask(t)))
|
|
368
379
|
}
|
|
@@ -374,20 +385,66 @@ async function runConvoy(
|
|
|
374
385
|
|
|
375
386
|
// ── Validation gates ──────────────────────────────────────────────────────
|
|
376
387
|
|
|
377
|
-
const
|
|
378
|
-
|
|
388
|
+
const maxGateRetries = spec.gate_retries ?? 0
|
|
389
|
+
let gateAttempt = 0
|
|
390
|
+
let gateResults: Array<{ command: string; exitCode: number; passed: boolean; output?: string }> = []
|
|
391
|
+
|
|
392
|
+
while (gateAttempt <= maxGateRetries) {
|
|
393
|
+
if (!spec.gates || spec.gates.length === 0) break
|
|
394
|
+
|
|
395
|
+
gateResults = []
|
|
396
|
+
process.stdout.write(`\n ${c.bold(gateAttempt === 0 ? 'Gates:' : `Gates (retry ${gateAttempt}/${maxGateRetries}):`)}\n`)
|
|
397
|
+
|
|
379
398
|
for (const command of spec.gates) {
|
|
380
399
|
try {
|
|
381
400
|
await execFile('sh', ['-c', command], { cwd: basePath })
|
|
382
401
|
gateResults.push({ command, exitCode: 0, passed: true })
|
|
402
|
+
process.stdout.write(` ${c.green('✓')} ${c.dim(command)}\n`)
|
|
383
403
|
} catch (err) {
|
|
384
|
-
const code
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
404
|
+
const execErr = err as Error & { code?: unknown; stderr?: string; stdout?: string }
|
|
405
|
+
const code = typeof execErr.code === 'number' ? execErr.code : 1
|
|
406
|
+
const output = execErr.stderr || execErr.stdout || execErr.message || ''
|
|
407
|
+
gateResults.push({ command, exitCode: code, passed: false, output })
|
|
408
|
+
process.stdout.write(` ${c.red('✗')} ${c.dim(command)}\n`)
|
|
389
409
|
}
|
|
390
410
|
}
|
|
411
|
+
|
|
412
|
+
const failedGates = gateResults.filter(g => !g.passed)
|
|
413
|
+
if (failedGates.length === 0) break // All gates passed
|
|
414
|
+
|
|
415
|
+
// Can we retry?
|
|
416
|
+
if (gateAttempt >= maxGateRetries) break // No more retries
|
|
417
|
+
|
|
418
|
+
// Create and execute a fix task
|
|
419
|
+
gateAttempt++
|
|
420
|
+
const failureSummary = failedGates
|
|
421
|
+
.map(g => `Command: ${g.command}\nExit code: ${g.exitCode}\nOutput:\n${g.output ?? '(no output)'}`)
|
|
422
|
+
.join('\n\n---\n\n')
|
|
423
|
+
|
|
424
|
+
const fixPrompt = `The following validation gates failed after all convoy tasks completed. Fix the issues so these commands pass.\n\n${failureSummary}`
|
|
425
|
+
const fixTaskId = `gate-fix-${gateAttempt}`
|
|
426
|
+
|
|
427
|
+
process.stdout.write(`\n ${c.yellow('⟳')} ${c.bold(`[${fixTaskId}]`)} fixing gate failures (attempt ${gateAttempt}/${maxGateRetries})\n`)
|
|
428
|
+
|
|
429
|
+
const fixTask: Task = {
|
|
430
|
+
id: fixTaskId,
|
|
431
|
+
prompt: fixPrompt,
|
|
432
|
+
agent: spec.defaults?.agent ?? 'developer',
|
|
433
|
+
timeout: spec.defaults?.timeout ?? '30m',
|
|
434
|
+
depends_on: [],
|
|
435
|
+
files: [],
|
|
436
|
+
description: `Auto-fix gate failures (attempt ${gateAttempt})`,
|
|
437
|
+
max_retries: 0,
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
const fixResult = await adapter.execute(fixTask, { verbose, cwd: basePath })
|
|
441
|
+
|
|
442
|
+
if (fixResult.success) {
|
|
443
|
+
process.stdout.write(` ${c.green('✓')} ${c.bold(`[${fixTaskId}]`)} fix applied\n`)
|
|
444
|
+
} else {
|
|
445
|
+
process.stdout.write(` ${c.red('✗')} ${c.bold(`[${fixTaskId}]`)} fix failed\n`)
|
|
446
|
+
break // Don't retry if the fix task itself fails
|
|
447
|
+
}
|
|
391
448
|
}
|
|
392
449
|
|
|
393
450
|
// ── Final status & summary ────────────────────────────────────────────────
|
package/src/cli/dashboard.ts
CHANGED
|
@@ -222,6 +222,11 @@ export async function startDashboardServer(
|
|
|
222
222
|
const actualPort = await tryListen(server, port)
|
|
223
223
|
const resolvedUrl = `http://localhost:${actualPort}`
|
|
224
224
|
|
|
225
|
+
if (options.openBrowser) {
|
|
226
|
+
const fullUrl = options.convoyId ? `${resolvedUrl}/?convoy=${options.convoyId}` : resolvedUrl
|
|
227
|
+
openUrl(fullUrl)
|
|
228
|
+
}
|
|
229
|
+
|
|
225
230
|
return { server, port: actualPort, url: resolvedUrl }
|
|
226
231
|
}
|
|
227
232
|
|
|
@@ -245,7 +250,7 @@ export default async function dashboard({
|
|
|
245
250
|
}
|
|
246
251
|
}
|
|
247
252
|
|
|
248
|
-
const dashResult = await startDashboardServer({ port, seed, pkgRoot, convoyId })
|
|
253
|
+
const dashResult = await startDashboardServer({ port, seed, pkgRoot, convoyId, openBrowser })
|
|
249
254
|
|
|
250
255
|
console.log('')
|
|
251
256
|
console.log(' \u{1F3F0} OpenCastle Dashboard')
|
|
@@ -273,10 +278,6 @@ export default async function dashboard({
|
|
|
273
278
|
console.log(' Press Ctrl+C to stop')
|
|
274
279
|
console.log('')
|
|
275
280
|
|
|
276
|
-
if (openBrowser) {
|
|
277
|
-
openUrl(convoyId ? `${dashResult.url}/?convoy=${convoyId}` : dashResult.url)
|
|
278
|
-
}
|
|
279
|
-
|
|
280
281
|
// Graceful shutdown
|
|
281
282
|
process.on('SIGINT', () => {
|
|
282
283
|
console.log('\n Dashboard stopped.\n')
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process'
|
|
2
|
+
import { parseTimeout } from '../schema.js'
|
|
3
|
+
import type { Task, ExecuteOptions, ExecuteResult, TokenUsage } from '../../types.js'
|
|
4
|
+
|
|
5
|
+
// Adapter name
|
|
6
|
+
export const name = 'claude'
|
|
7
|
+
|
|
8
|
+
// Module-level state for mode selection
|
|
9
|
+
let mode: 'sdk' | 'cli' | null = null
|
|
10
|
+
|
|
11
|
+
// SDK dynamic import check
|
|
12
|
+
async function sdkAvailable(): Promise<boolean> {
|
|
13
|
+
try {
|
|
14
|
+
await import('@anthropic-ai/agent-sdk')
|
|
15
|
+
return true
|
|
16
|
+
} catch {
|
|
17
|
+
return false
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// CLI check
|
|
22
|
+
async function cliAvailable(): Promise<boolean> {
|
|
23
|
+
return new Promise((resolve) => {
|
|
24
|
+
const proc = spawn('which', ['claude'], { stdio: 'pipe' })
|
|
25
|
+
proc.on('close', (code) => resolve(code === 0))
|
|
26
|
+
proc.on('error', () => resolve(false))
|
|
27
|
+
})
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export async function isAvailable(): Promise<boolean> {
|
|
31
|
+
if (await sdkAvailable()) {
|
|
32
|
+
mode = 'sdk'
|
|
33
|
+
return true
|
|
34
|
+
}
|
|
35
|
+
if (await cliAvailable()) {
|
|
36
|
+
mode = 'cli'
|
|
37
|
+
return true
|
|
38
|
+
}
|
|
39
|
+
return false
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// --- SDK implementation (from claude-sdk.ts) ---
|
|
43
|
+
// Local type stubs for @anthropic-ai/agent-sdk
|
|
44
|
+
interface AgentSession {
|
|
45
|
+
on(event: string, handler: (...args: unknown[]) => void): void
|
|
46
|
+
sendAndWait(msg: { prompt: string }, timeoutMs: number): Promise<unknown>
|
|
47
|
+
abort(): Promise<void>
|
|
48
|
+
destroy(): Promise<void>
|
|
49
|
+
}
|
|
50
|
+
interface SessionCreateOptions {
|
|
51
|
+
onPermissionRequest?: unknown
|
|
52
|
+
cwd?: string
|
|
53
|
+
systemMessage?: { content: string }
|
|
54
|
+
infiniteSessions?: { enabled: boolean }
|
|
55
|
+
streaming?: boolean
|
|
56
|
+
}
|
|
57
|
+
interface ClaudeAgentClient {
|
|
58
|
+
start(): Promise<void>
|
|
59
|
+
createSession(options: SessionCreateOptions): Promise<AgentSession>
|
|
60
|
+
}
|
|
61
|
+
let clientPromise: Promise<ClaudeAgentClient> | null = null
|
|
62
|
+
let cachedApproveAll: unknown = null
|
|
63
|
+
const activeSessions = new Map<string, AgentSession>()
|
|
64
|
+
|
|
65
|
+
async function getClient(): Promise<ClaudeAgentClient> {
|
|
66
|
+
if (!clientPromise) {
|
|
67
|
+
clientPromise = (async () => {
|
|
68
|
+
const sdk = await import('@anthropic-ai/agent-sdk') as Record<string, unknown>
|
|
69
|
+
const { AgentClient, approveAll } = sdk as {
|
|
70
|
+
AgentClient: new (opts: { autoStart: boolean; logLevel: string }) => ClaudeAgentClient
|
|
71
|
+
approveAll: unknown
|
|
72
|
+
}
|
|
73
|
+
cachedApproveAll = approveAll
|
|
74
|
+
const client = new AgentClient({
|
|
75
|
+
autoStart: false,
|
|
76
|
+
logLevel: 'error',
|
|
77
|
+
})
|
|
78
|
+
await client.start()
|
|
79
|
+
return client
|
|
80
|
+
})()
|
|
81
|
+
}
|
|
82
|
+
return clientPromise
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
async function executeViaSdk(task: Task, options: ExecuteOptions = {}): Promise<ExecuteResult> {
|
|
86
|
+
let prompt = `You are a ${task.agent}. ${task.prompt}`
|
|
87
|
+
if (task.files && task.files.length > 0) {
|
|
88
|
+
prompt += `\n\nOnly modify files under: ${task.files.join(', ')}`
|
|
89
|
+
}
|
|
90
|
+
const client = await getClient()
|
|
91
|
+
const session = await client.createSession({
|
|
92
|
+
onPermissionRequest: cachedApproveAll!,
|
|
93
|
+
cwd: options.cwd ?? process.cwd(),
|
|
94
|
+
systemMessage: {
|
|
95
|
+
content: [
|
|
96
|
+
`You are a ${task.agent}.`,
|
|
97
|
+
'Work autonomously without asking questions.',
|
|
98
|
+
'Follow all instructions precisely.',
|
|
99
|
+
].join(' '),
|
|
100
|
+
},
|
|
101
|
+
infiniteSessions: { enabled: false },
|
|
102
|
+
...(options.verbose ? { streaming: true } : {}),
|
|
103
|
+
})
|
|
104
|
+
activeSessions.set(task.id, session)
|
|
105
|
+
if (options.verbose) {
|
|
106
|
+
session.on('assistant.message_delta', (...args: unknown[]) => {
|
|
107
|
+
const event = args[0] as { data: { deltaContent: string } }
|
|
108
|
+
process.stdout.write(event.data.deltaContent)
|
|
109
|
+
})
|
|
110
|
+
}
|
|
111
|
+
try {
|
|
112
|
+
const timeoutMs = parseTimeout(task.timeout)
|
|
113
|
+
const response = await session.sendAndWait({ prompt }, timeoutMs)
|
|
114
|
+
const data = (response as any)?.data as Record<string, unknown> | undefined
|
|
115
|
+
const output = (data?.content as string | undefined) ?? ''
|
|
116
|
+
const rawUsage = data?.usage ?? (response as any)?.usage
|
|
117
|
+
const u = rawUsage as Record<string, number> | undefined
|
|
118
|
+
const usageResult = u
|
|
119
|
+
? {
|
|
120
|
+
prompt_tokens: u.prompt_tokens ?? u.promptTokens,
|
|
121
|
+
completion_tokens: u.completion_tokens ?? u.completionTokens,
|
|
122
|
+
total_tokens: u.total_tokens ?? u.totalTokens,
|
|
123
|
+
}
|
|
124
|
+
: undefined
|
|
125
|
+
return {
|
|
126
|
+
success: true,
|
|
127
|
+
output: output.slice(0, 10_000),
|
|
128
|
+
exitCode: 0,
|
|
129
|
+
usage: usageResult,
|
|
130
|
+
}
|
|
131
|
+
} catch (err: unknown) {
|
|
132
|
+
return {
|
|
133
|
+
success: false,
|
|
134
|
+
output: `Claude Agent SDK error: ${(err as Error).message}`,
|
|
135
|
+
exitCode: 1,
|
|
136
|
+
}
|
|
137
|
+
} finally {
|
|
138
|
+
activeSessions.delete(task.id)
|
|
139
|
+
await session.destroy().catch(() => {})
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function killSdk(task: Task): void {
|
|
144
|
+
const session = activeSessions.get(task.id)
|
|
145
|
+
if (session) {
|
|
146
|
+
session.abort().catch(() => {})
|
|
147
|
+
session.destroy().catch(() => {})
|
|
148
|
+
activeSessions.delete(task.id)
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// --- CLI implementation (from claude-code.ts) ---
|
|
153
|
+
async function executeViaCli(task: Task, options: ExecuteOptions = {}): Promise<ExecuteResult> {
|
|
154
|
+
let prompt = `You are a ${task.agent}. ${task.prompt}`
|
|
155
|
+
if (task.files && task.files.length > 0) {
|
|
156
|
+
prompt += `\n\nOnly modify files under: ${task.files.join(', ')}`
|
|
157
|
+
}
|
|
158
|
+
const args = [
|
|
159
|
+
'-p',
|
|
160
|
+
prompt,
|
|
161
|
+
'--output-format',
|
|
162
|
+
'json',
|
|
163
|
+
'--max-turns',
|
|
164
|
+
'50',
|
|
165
|
+
]
|
|
166
|
+
return new Promise((resolve) => {
|
|
167
|
+
const proc = spawn('claude', args, {
|
|
168
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
169
|
+
env: { ...process.env },
|
|
170
|
+
cwd: options?.cwd ?? process.cwd(),
|
|
171
|
+
})
|
|
172
|
+
let stdout = ''
|
|
173
|
+
let stderr = ''
|
|
174
|
+
proc.stdout.on('data', (chunk: Buffer) => {
|
|
175
|
+
stdout += chunk.toString()
|
|
176
|
+
if (options.verbose) {
|
|
177
|
+
process.stdout.write(chunk)
|
|
178
|
+
}
|
|
179
|
+
})
|
|
180
|
+
proc.stderr.on('data', (chunk: Buffer) => {
|
|
181
|
+
stderr += chunk.toString()
|
|
182
|
+
if (options.verbose) {
|
|
183
|
+
process.stderr.write(chunk)
|
|
184
|
+
}
|
|
185
|
+
})
|
|
186
|
+
proc.on('close', (code) => {
|
|
187
|
+
const output = [stdout, stderr].filter(Boolean).join('\n')
|
|
188
|
+
let usage: TokenUsage | undefined
|
|
189
|
+
try {
|
|
190
|
+
const parsedJson = JSON.parse(stdout) as Record<string, unknown>
|
|
191
|
+
const u = parsedJson?.usage as Record<string, number> | undefined
|
|
192
|
+
if (u) {
|
|
193
|
+
const promptTokens = (u.input_tokens ?? u.prompt_tokens) as number | undefined
|
|
194
|
+
const completionTokens = (u.output_tokens ?? u.completion_tokens) as number | undefined
|
|
195
|
+
const total = ((promptTokens ?? 0) + (completionTokens ?? 0)) || undefined
|
|
196
|
+
usage = { prompt_tokens: promptTokens, completion_tokens: completionTokens, total_tokens: total }
|
|
197
|
+
}
|
|
198
|
+
} catch { /* not JSON or no usage — graceful degradation */ }
|
|
199
|
+
resolve({
|
|
200
|
+
success: code === 0,
|
|
201
|
+
output: output.slice(0, 10000),
|
|
202
|
+
exitCode: code ?? -1,
|
|
203
|
+
usage,
|
|
204
|
+
})
|
|
205
|
+
})
|
|
206
|
+
proc.on('error', (err) => {
|
|
207
|
+
resolve({
|
|
208
|
+
success: false,
|
|
209
|
+
output: `Failed to spawn claude: ${err.message}`,
|
|
210
|
+
exitCode: -1,
|
|
211
|
+
})
|
|
212
|
+
})
|
|
213
|
+
task._process = proc
|
|
214
|
+
})
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function killCli(task: Task): void {
|
|
218
|
+
if (task._process && !task._process.killed) {
|
|
219
|
+
task._process.kill('SIGTERM')
|
|
220
|
+
setTimeout(() => {
|
|
221
|
+
if (task._process && !task._process.killed) {
|
|
222
|
+
task._process.kill('SIGKILL')
|
|
223
|
+
}
|
|
224
|
+
}, 5000)
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// --- Unified interface ---
|
|
229
|
+
export async function execute(task: Task, options: ExecuteOptions = {}): Promise<ExecuteResult> {
|
|
230
|
+
if (!mode) await isAvailable()
|
|
231
|
+
if (mode === 'sdk') return executeViaSdk(task, options)
|
|
232
|
+
return executeViaCli(task, options)
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
export function kill(task: Task): void {
|
|
236
|
+
if (mode === 'sdk') killSdk(task)
|
|
237
|
+
else killCli(task)
|
|
238
|
+
}
|