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.
Files changed (51) hide show
  1. package/dist/cli/convoy/engine.d.ts +1 -0
  2. package/dist/cli/convoy/engine.d.ts.map +1 -1
  3. package/dist/cli/convoy/engine.js +72 -22
  4. package/dist/cli/convoy/engine.js.map +1 -1
  5. package/dist/cli/convoy/engine.test.js +205 -0
  6. package/dist/cli/convoy/engine.test.js.map +1 -1
  7. package/dist/cli/dashboard.d.ts.map +1 -1
  8. package/dist/cli/dashboard.js +5 -4
  9. package/dist/cli/dashboard.js.map +1 -1
  10. package/dist/cli/run/adapters/claude.d.ts +6 -0
  11. package/dist/cli/run/adapters/claude.d.ts.map +1 -0
  12. package/dist/cli/run/adapters/claude.js +211 -0
  13. package/dist/cli/run/adapters/claude.js.map +1 -0
  14. package/dist/cli/run/adapters/copilot.d.ts +0 -18
  15. package/dist/cli/run/adapters/copilot.d.ts.map +1 -1
  16. package/dist/cli/run/adapters/copilot.js +123 -38
  17. package/dist/cli/run/adapters/copilot.js.map +1 -1
  18. package/dist/cli/run/adapters/index.js +2 -2
  19. package/dist/cli/run/adapters/index.js.map +1 -1
  20. package/dist/cli/run/schema.d.ts.map +1 -1
  21. package/dist/cli/run/schema.js +8 -0
  22. package/dist/cli/run/schema.js.map +1 -1
  23. package/dist/cli/run/schema.test.js +41 -0
  24. package/dist/cli/run/schema.test.js.map +1 -1
  25. package/dist/cli/run.d.ts.map +1 -1
  26. package/dist/cli/run.js +21 -9
  27. package/dist/cli/run.js.map +1 -1
  28. package/dist/cli/types.d.ts +2 -0
  29. package/dist/cli/types.d.ts.map +1 -1
  30. package/package.json +9 -1
  31. package/src/cli/convoy/engine.test.ts +240 -0
  32. package/src/cli/convoy/engine.ts +80 -23
  33. package/src/cli/dashboard.ts +6 -5
  34. package/src/cli/run/adapters/claude.ts +238 -0
  35. package/src/cli/run/adapters/copilot.ts +125 -47
  36. package/src/cli/run/adapters/index.ts +2 -2
  37. package/src/cli/run/adapters/vendor.d.ts +2 -0
  38. package/src/cli/run/schema.test.ts +51 -0
  39. package/src/cli/run/schema.ts +10 -0
  40. package/src/cli/run.ts +23 -11
  41. package/src/cli/types.ts +2 -0
  42. package/src/dashboard/node_modules/.vite/deps/_metadata.json +6 -6
  43. package/src/orchestrator/agents/team-lead.agent.md +6 -6
  44. package/src/orchestrator/prompts/bug-fix.prompt.md +6 -2
  45. package/src/orchestrator/prompts/generate-convoy.prompt.md +3 -3
  46. package/src/orchestrator/prompts/implement-feature.prompt.md +8 -19
  47. package/dist/cli/run/adapters/claude-code.d.ts +0 -16
  48. package/dist/cli/run/adapters/claude-code.d.ts.map +0 -1
  49. package/dist/cli/run/adapters/claude-code.js +0 -95
  50. package/dist/cli/run/adapters/claude-code.js.map +0 -1
  51. package/src/cli/run/adapters/claude-code.ts +0 -107
@@ -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
- if (verbose) process.stdout.write(`\u2298 ${taskId}\n`)
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
- if (verbose) process.stdout.write(`\u25b6 ${taskRecord.id}\n`)
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
- if (verbose) {
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
- if (verbose) process.stdout.write(`\u23f1 ${taskRecord.id}\n`)
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
- if (verbose) process.stdout.write(`\u2713 ${taskRecord.id} ${elapsed}\n`)
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
- if (verbose) {
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
- if (verbose) process.stdout.write(`\u2717 ${taskRecord.id}\n`)
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 gateResults: Array<{ command: string; exitCode: number; passed: boolean }> = []
378
- if (spec.gates && spec.gates.length > 0) {
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
- typeof (err as { code?: unknown }).code === 'number'
386
- ? (err as { code: number }).code
387
- : 1
388
- gateResults.push({ command, exitCode: code, passed: false })
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 ────────────────────────────────────────────────
@@ -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
+ }