opencastle 0.16.0 → 0.17.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 (45) hide show
  1. package/dist/cli/convoy/engine.d.ts.map +1 -1
  2. package/dist/cli/convoy/engine.js +32 -9
  3. package/dist/cli/convoy/engine.js.map +1 -1
  4. package/dist/cli/convoy/engine.test.js +104 -1
  5. package/dist/cli/convoy/engine.test.js.map +1 -1
  6. package/dist/cli/convoy/health.test.js +1 -0
  7. package/dist/cli/convoy/health.test.js.map +1 -1
  8. package/dist/cli/convoy/store.d.ts.map +1 -1
  9. package/dist/cli/convoy/store.js +8 -3
  10. package/dist/cli/convoy/store.js.map +1 -1
  11. package/dist/cli/convoy/store.test.js +83 -3
  12. package/dist/cli/convoy/store.test.js.map +1 -1
  13. package/dist/cli/convoy/types.d.ts +1 -0
  14. package/dist/cli/convoy/types.d.ts.map +1 -1
  15. package/dist/cli/run/adapters/index.d.ts.map +1 -1
  16. package/dist/cli/run/adapters/index.js +2 -1
  17. package/dist/cli/run/adapters/index.js.map +1 -1
  18. package/dist/cli/run/adapters/opencode.d.ts +16 -0
  19. package/dist/cli/run/adapters/opencode.d.ts.map +1 -0
  20. package/dist/cli/run/adapters/opencode.js +75 -0
  21. package/dist/cli/run/adapters/opencode.js.map +1 -0
  22. package/dist/cli/run/schema.d.ts.map +1 -1
  23. package/dist/cli/run/schema.js +11 -0
  24. package/dist/cli/run/schema.js.map +1 -1
  25. package/dist/cli/run/schema.test.js +44 -0
  26. package/dist/cli/run/schema.test.js.map +1 -1
  27. package/dist/cli/run.d.ts.map +1 -1
  28. package/dist/cli/run.js +2 -0
  29. package/dist/cli/run.js.map +1 -1
  30. package/dist/cli/types.d.ts +3 -0
  31. package/dist/cli/types.d.ts.map +1 -1
  32. package/package.json +1 -1
  33. package/src/cli/convoy/engine.test.ts +126 -1
  34. package/src/cli/convoy/engine.ts +30 -7
  35. package/src/cli/convoy/health.test.ts +1 -0
  36. package/src/cli/convoy/store.test.ts +89 -3
  37. package/src/cli/convoy/store.ts +8 -3
  38. package/src/cli/convoy/types.ts +1 -0
  39. package/src/cli/run/adapters/index.ts +2 -1
  40. package/src/cli/run/adapters/opencode.ts +88 -0
  41. package/src/cli/run/schema.test.ts +50 -0
  42. package/src/cli/run/schema.ts +13 -0
  43. package/src/cli/run.ts +3 -0
  44. package/src/cli/types.ts +3 -0
  45. package/src/dashboard/node_modules/.vite/deps/_metadata.json +6 -6
@@ -63,6 +63,7 @@ function makeTask(
63
63
  phase: 0,
64
64
  prompt: 'Do something',
65
65
  agent: 'developer',
66
+ adapter: null,
66
67
  model: null,
67
68
  timeout_ms: 60_000,
68
69
  status: 'running' as const,
@@ -43,6 +43,7 @@ function makeTask(overrides: Partial<Parameters<ConvoyStore['insertTask']>[0]> =
43
43
  phase: 0,
44
44
  prompt: 'Do something',
45
45
  agent: 'developer',
46
+ adapter: null as string | null,
46
47
  model: null,
47
48
  timeout_ms: 1_800_000,
48
49
  status: 'pending' as const,
@@ -83,11 +84,11 @@ describe('DB creation', () => {
83
84
  expect(row.journal_mode).toBe('wal')
84
85
  })
85
86
 
86
- it('sets schema version to 1', () => {
87
+ it('sets schema version to 2', () => {
87
88
  const db = new DatabaseSync(dbPath)
88
89
  const row = db.prepare('PRAGMA user_version').get() as { user_version: number }
89
90
  db.close()
90
- expect(row.user_version).toBe(1)
91
+ expect(row.user_version).toBe(2)
91
92
  })
92
93
 
93
94
  it('creates all required tables', () => {
@@ -112,7 +113,86 @@ describe('DB creation', () => {
112
113
  store2.close()
113
114
  // Reassign so afterEach does not double-close
114
115
  store = createConvoyStore(dbPath)
115
- expect(row.user_version).toBe(1)
116
+ expect(row.user_version).toBe(2)
117
+ })
118
+ })
119
+
120
+ // ── schema migration ─────────────────────────────────────────────────────────
121
+
122
+ describe('schema migration', () => {
123
+ it('schema migration v1 to v2 adds adapter column', () => {
124
+ // Create a v1 database manually: task table without adapter column
125
+ const v1DbPath = join(tmpDir, 'v1.db')
126
+ const rawDb = new DatabaseSync(v1DbPath)
127
+ rawDb.exec(`
128
+ CREATE TABLE convoy (
129
+ id TEXT PRIMARY KEY,
130
+ name TEXT NOT NULL,
131
+ spec_hash TEXT NOT NULL,
132
+ status TEXT NOT NULL DEFAULT 'pending',
133
+ branch TEXT,
134
+ created_at TEXT NOT NULL,
135
+ started_at TEXT,
136
+ finished_at TEXT,
137
+ spec_yaml TEXT NOT NULL
138
+ );
139
+ CREATE TABLE task (
140
+ id TEXT PRIMARY KEY,
141
+ convoy_id TEXT NOT NULL REFERENCES convoy(id),
142
+ phase INTEGER NOT NULL,
143
+ prompt TEXT NOT NULL,
144
+ agent TEXT NOT NULL DEFAULT 'developer',
145
+ model TEXT,
146
+ timeout_ms INTEGER NOT NULL DEFAULT 1800000,
147
+ status TEXT NOT NULL DEFAULT 'pending',
148
+ worker_id TEXT,
149
+ worktree TEXT,
150
+ output TEXT,
151
+ exit_code INTEGER,
152
+ started_at TEXT,
153
+ finished_at TEXT,
154
+ retries INTEGER NOT NULL DEFAULT 0,
155
+ max_retries INTEGER NOT NULL DEFAULT 1,
156
+ files TEXT,
157
+ depends_on TEXT
158
+ );
159
+ CREATE TABLE worker (
160
+ id TEXT PRIMARY KEY,
161
+ task_id TEXT REFERENCES task(id),
162
+ adapter TEXT NOT NULL,
163
+ pid INTEGER,
164
+ session_id TEXT,
165
+ status TEXT NOT NULL DEFAULT 'spawned',
166
+ worktree TEXT,
167
+ created_at TEXT NOT NULL,
168
+ finished_at TEXT,
169
+ last_heartbeat TEXT
170
+ );
171
+ CREATE TABLE event (
172
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
173
+ convoy_id TEXT REFERENCES convoy(id),
174
+ task_id TEXT,
175
+ worker_id TEXT,
176
+ type TEXT NOT NULL,
177
+ data TEXT,
178
+ created_at TEXT NOT NULL
179
+ );
180
+ `)
181
+ rawDb.exec('PRAGMA user_version = 1')
182
+ rawDb.close()
183
+
184
+ // Open with createConvoyStore — should apply the v1→v2 migration
185
+ const v1Store = createConvoyStore(v1DbPath)
186
+ v1Store.close()
187
+
188
+ // Verify adapter column was added to task table
189
+ const verifyDb = new DatabaseSync(v1DbPath)
190
+ const cols = verifyDb.prepare('PRAGMA table_info(task)').all() as Array<{ name: string }>
191
+ const version = verifyDb.prepare('PRAGMA user_version').get() as { user_version: number }
192
+ verifyDb.close()
193
+
194
+ expect(cols.map(c => c.name)).toContain('adapter')
195
+ expect(version.user_version).toBe(2)
116
196
  })
117
197
  })
118
198
 
@@ -182,6 +262,12 @@ describe('task CRUD', () => {
182
262
  expect(store.getTask('does-not-exist', 'convoy-1')).toBeUndefined()
183
263
  })
184
264
 
265
+ it('insertTask stores adapter field', () => {
266
+ store.insertTask(makeTask({ adapter: 'opencode' }))
267
+ const retrieved = store.getTask('task-1', 'convoy-1')!
268
+ expect(retrieved.adapter).toBe('opencode')
269
+ })
270
+
185
271
  it('stores JSON fields as strings', () => {
186
272
  const task = makeTask({
187
273
  id: 'task-json',
@@ -9,7 +9,7 @@ import type {
9
9
  EventRecord,
10
10
  } from './types.js'
11
11
 
12
- const SCHEMA_VERSION = 1
12
+ const SCHEMA_VERSION = 2
13
13
 
14
14
  export interface ConvoyStore {
15
15
  insertConvoy(record: Omit<ConvoyRecord, 'started_at' | 'finished_at'>): void
@@ -82,6 +82,7 @@ class ConvoyStoreImpl implements ConvoyStore {
82
82
  phase INTEGER NOT NULL,
83
83
  prompt TEXT NOT NULL,
84
84
  agent TEXT NOT NULL DEFAULT 'developer',
85
+ adapter TEXT,
85
86
  model TEXT,
86
87
  timeout_ms INTEGER NOT NULL DEFAULT 1800000,
87
88
  status TEXT NOT NULL DEFAULT 'pending',
@@ -122,6 +123,10 @@ class ConvoyStoreImpl implements ConvoyStore {
122
123
  `)
123
124
  this.db.exec(`PRAGMA user_version = ${SCHEMA_VERSION}`)
124
125
  }
126
+ if (row.user_version === 1) {
127
+ this.db.exec('ALTER TABLE task ADD COLUMN adapter TEXT')
128
+ this.db.exec('PRAGMA user_version = 2')
129
+ }
125
130
  }
126
131
 
127
132
  insertConvoy(record: Omit<ConvoyRecord, 'started_at' | 'finished_at'>): void {
@@ -174,11 +179,11 @@ class ConvoyStoreImpl implements ConvoyStore {
174
179
  this.db
175
180
  .prepare(
176
181
  `INSERT INTO task
177
- (id, convoy_id, phase, prompt, agent, model, timeout_ms, status,
182
+ (id, convoy_id, phase, prompt, agent, adapter, model, timeout_ms, status,
178
183
  worker_id, worktree, output, exit_code, started_at, finished_at,
179
184
  retries, max_retries, files, depends_on)
180
185
  VALUES
181
- (:id, :convoy_id, :phase, :prompt, :agent, :model, :timeout_ms, :status,
186
+ (:id, :convoy_id, :phase, :prompt, :agent, :adapter, :model, :timeout_ms, :status,
182
187
  NULL, NULL, NULL, NULL, NULL, NULL,
183
188
  :retries, :max_retries, :files, :depends_on)`,
184
189
  )
@@ -29,6 +29,7 @@ export interface TaskRecord {
29
29
  phase: number
30
30
  prompt: string
31
31
  agent: string
32
+ adapter: string | null
32
33
  model: string | null
33
34
  timeout_ms: number
34
35
  status: ConvoyTaskStatus
@@ -7,6 +7,7 @@ const ADAPTERS: Record<string, () => Promise<AgentAdapter>> = {
7
7
  'claude-code': () => import('./claude-code.js') as Promise<AgentAdapter>,
8
8
  copilot: () => import('./copilot.js') as Promise<AgentAdapter>,
9
9
  cursor: () => import('./cursor.js') as Promise<AgentAdapter>,
10
+ opencode: () => import('./opencode.js') as Promise<AgentAdapter>,
10
11
  }
11
12
 
12
13
  /**
@@ -28,7 +29,7 @@ export async function getAdapter(name: string): Promise<AgentAdapter> {
28
29
  * Detection priority order — checked first-to-last.
29
30
  * The first available adapter wins.
30
31
  */
31
- const DETECTION_ORDER = ['copilot', 'claude-code', 'cursor'] as const
32
+ const DETECTION_ORDER = ['copilot', 'claude-code', 'cursor', 'opencode'] as const
32
33
 
33
34
  /**
34
35
  * Auto-detect which adapter CLI is available on the system.
@@ -0,0 +1,88 @@
1
+ import { spawn } from 'node:child_process'
2
+ import type { Task, ExecuteOptions, ExecuteResult } from '../../types.js'
3
+
4
+ /** Adapter name */
5
+ export const name = 'opencode'
6
+
7
+ /**
8
+ * Check if the `opencode` CLI is available on the system PATH.
9
+ */
10
+ export async function isAvailable(): Promise<boolean> {
11
+ return new Promise((resolve) => {
12
+ const proc = spawn('which', ['opencode'], { stdio: 'pipe' })
13
+ proc.on('close', (code) => resolve(code === 0))
14
+ proc.on('error', () => resolve(false))
15
+ })
16
+ }
17
+
18
+ /**
19
+ * Execute a task by invoking the OpenCode CLI in headless mode.
20
+ */
21
+ export async function execute(task: Task, options: ExecuteOptions = {}): Promise<ExecuteResult> {
22
+ let prompt = `You are a ${task.agent}. ${task.prompt}`
23
+
24
+ if (task.files && task.files.length > 0) {
25
+ prompt += `\n\nOnly modify files under: ${task.files.join(', ')}`
26
+ }
27
+
28
+ const args = ['--headless', '-p', prompt]
29
+
30
+ return new Promise((resolve) => {
31
+ const proc = spawn('opencode', args, {
32
+ stdio: ['ignore', 'pipe', 'pipe'],
33
+ env: { ...process.env },
34
+ cwd: options?.cwd ?? process.cwd(),
35
+ })
36
+
37
+ let stdout = ''
38
+ let stderr = ''
39
+
40
+ proc.stdout.on('data', (chunk: Buffer) => {
41
+ stdout += chunk.toString()
42
+ if (options.verbose) {
43
+ process.stdout.write(chunk)
44
+ }
45
+ })
46
+
47
+ proc.stderr.on('data', (chunk: Buffer) => {
48
+ stderr += chunk.toString()
49
+ if (options.verbose) {
50
+ process.stderr.write(chunk)
51
+ }
52
+ })
53
+
54
+ proc.on('close', (code) => {
55
+ const output = [stdout, stderr].filter(Boolean).join('\n')
56
+ resolve({
57
+ success: code === 0,
58
+ output: output.slice(0, 10000), // Cap output size
59
+ exitCode: code ?? -1,
60
+ })
61
+ })
62
+
63
+ proc.on('error', (err) => {
64
+ resolve({
65
+ success: false,
66
+ output: `Failed to spawn opencode: ${err.message}`,
67
+ exitCode: -1,
68
+ })
69
+ })
70
+
71
+ // Store process ref for potential timeout kill
72
+ task._process = proc
73
+ })
74
+ }
75
+
76
+ /**
77
+ * Kill the process associated with a task (used by timeout enforcement).
78
+ */
79
+ export function kill(task: Task): void {
80
+ if (task._process && !task._process.killed) {
81
+ task._process.kill('SIGTERM')
82
+ setTimeout(() => {
83
+ if (task._process && !task._process.killed) {
84
+ task._process.kill('SIGKILL')
85
+ }
86
+ }, 5000)
87
+ }
88
+ }
@@ -529,6 +529,27 @@ describe('validateSpec — branch field', () => {
529
529
  })
530
530
  })
531
531
 
532
+ // ── validateSpec — per-task adapter ──────────────────────────
533
+
534
+ describe('validateSpec — per-task adapter', () => {
535
+ it('task.adapter must be a string', () => {
536
+ const result = validateSpec({
537
+ name: 'test',
538
+ tasks: [{ id: 'a', prompt: 'x', adapter: 123 }],
539
+ })
540
+ expect(result.valid).toBe(false)
541
+ expect(result.errors).toContainEqual(expect.stringContaining('adapter'))
542
+ })
543
+
544
+ it('task.adapter accepts valid string', () => {
545
+ const result = validateSpec({
546
+ name: 'test',
547
+ tasks: [{ id: 'a', prompt: 'x', adapter: 'opencode' }],
548
+ })
549
+ expect(result.valid).toBe(true)
550
+ })
551
+ })
552
+
532
553
  // ── validateSpec — per-task model and max_retries ──────────────
533
554
 
534
555
  describe('validateSpec — per-task model and max_retries', () => {
@@ -721,6 +742,35 @@ describe('applyDefaults — convoy spec (version: 1)', () => {
721
742
  expect(spec.gates).toEqual(['npm test'])
722
743
  expect(spec.branch).toBe('feat/convoy')
723
744
  })
745
+
746
+ it('applies defaults.adapter to tasks without explicit adapter', () => {
747
+ const spec = applyDefaults({
748
+ name: 'test',
749
+ version: 1,
750
+ defaults: { adapter: 'opencode' },
751
+ tasks: [{ id: 'a', prompt: 'x' }],
752
+ })
753
+ expect(spec.tasks![0].adapter).toBe('opencode')
754
+ })
755
+
756
+ it('task-level adapter overrides defaults.adapter', () => {
757
+ const spec = applyDefaults({
758
+ name: 'test',
759
+ version: 1,
760
+ defaults: { adapter: 'opencode' },
761
+ tasks: [{ id: 'a', prompt: 'x', adapter: 'claude-code' }],
762
+ })
763
+ expect(spec.tasks![0].adapter).toBe('claude-code')
764
+ })
765
+
766
+ it('tasks without adapter remain undefined when no defaults', () => {
767
+ const spec = applyDefaults({
768
+ name: 'test',
769
+ version: 1,
770
+ tasks: [{ id: 'a', prompt: 'x' }],
771
+ })
772
+ expect(spec.tasks![0].adapter).toBeUndefined()
773
+ })
724
774
  })
725
775
 
726
776
  // ── applyDefaults — max_retries default always applied ─────────
@@ -55,6 +55,7 @@ interface RawTask {
55
55
  description?: unknown
56
56
  model?: unknown
57
57
  max_retries?: unknown
58
+ adapter?: unknown
58
59
  }
59
60
 
60
61
  /**
@@ -126,6 +127,9 @@ export function validateSpec(spec: unknown): ValidationResult {
126
127
  if (d.agent !== undefined && typeof d.agent !== 'string') {
127
128
  errors.push('`defaults.agent` must be a string')
128
129
  }
130
+ if (d.adapter !== undefined && typeof d.adapter !== 'string') {
131
+ errors.push('`defaults.adapter` must be a string')
132
+ }
129
133
  }
130
134
  }
131
135
 
@@ -219,6 +223,11 @@ export function validateSpec(spec: unknown): ValidationResult {
219
223
  )
220
224
  }
221
225
  }
226
+
227
+ // adapter
228
+ if (task.adapter !== undefined && typeof task.adapter !== 'string') {
229
+ errors.push(`${prefix}: \`adapter\` must be a string`)
230
+ }
222
231
  }
223
232
 
224
233
  // DAG cycle detection
@@ -308,6 +317,10 @@ export function applyDefaults(spec: Record<string, unknown>): TaskSpec {
308
317
  task.max_retries =
309
318
  d.max_retries !== undefined ? Number(d.max_retries) : 1
310
319
  }
320
+ // adapter: task-level overrides defaults, no hardcoded fallback (convoy-level is used at runtime)
321
+ if (task.adapter === undefined && d.adapter !== undefined) {
322
+ task.adapter = d.adapter
323
+ }
311
324
  }
312
325
 
313
326
  return s as unknown as TaskSpec
package/src/cli/run.ts CHANGED
@@ -124,6 +124,9 @@ function printAdapterError(detectionFailed: boolean, adapterName: string): void
124
124
  ' The Cursor agent CLI ships with the Cursor editor.\n' +
125
125
  ' Install Cursor from https://cursor.com and ensure the\n' +
126
126
  ' "agent" command is on your PATH (Cursor > Install CLI).',
127
+ opencode:
128
+ ' Install OpenCode from https://opencode.ai\n' +
129
+ ' Ensure the "opencode" command is on your PATH.',
127
130
  }
128
131
  const cliName = adapterName === 'claude-code' ? 'claude' : adapterName
129
132
  const hint = hints[adapterName] ?? ''
package/src/cli/types.ts CHANGED
@@ -132,6 +132,7 @@ export interface TaskDefaults {
132
132
  model?: string;
133
133
  max_retries?: number;
134
134
  agent?: string;
135
+ adapter?: string;
135
136
  }
136
137
 
137
138
  /** Validated task spec from YAML. */
@@ -166,6 +167,8 @@ export interface Task {
166
167
  model?: string;
167
168
  /** Max retry attempts (default: 1). */
168
169
  max_retries: number;
170
+ /** Per-task adapter override. */
171
+ adapter?: string;
169
172
  }
170
173
 
171
174
  /** Task execution status. */
@@ -1,25 +1,25 @@
1
1
  {
2
- "hash": "f5f05037",
2
+ "hash": "e38e79fd",
3
3
  "configHash": "30f8ea04",
4
- "lockfileHash": "99d70434",
5
- "browserHash": "d43a2a07",
4
+ "lockfileHash": "dc112ec5",
5
+ "browserHash": "ee856457",
6
6
  "optimized": {
7
7
  "astro > cssesc": {
8
8
  "src": "../../../../../node_modules/cssesc/cssesc.js",
9
9
  "file": "astro___cssesc.js",
10
- "fileHash": "29d5277e",
10
+ "fileHash": "a803959e",
11
11
  "needsInterop": true
12
12
  },
13
13
  "astro > aria-query": {
14
14
  "src": "../../../../../node_modules/aria-query/lib/index.js",
15
15
  "file": "astro___aria-query.js",
16
- "fileHash": "4ca620a4",
16
+ "fileHash": "8505e2b8",
17
17
  "needsInterop": true
18
18
  },
19
19
  "astro > axobject-query": {
20
20
  "src": "../../../../../node_modules/axobject-query/lib/index.js",
21
21
  "file": "astro___axobject-query.js",
22
- "fileHash": "4072a265",
22
+ "fileHash": "e7d3a98b",
23
23
  "needsInterop": true
24
24
  }
25
25
  },