opencastle 0.21.1 → 0.22.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 (53) hide show
  1. package/dist/cli/convoy/engine.d.ts +3 -0
  2. package/dist/cli/convoy/engine.d.ts.map +1 -1
  3. package/dist/cli/convoy/engine.js +22 -1
  4. package/dist/cli/convoy/engine.js.map +1 -1
  5. package/dist/cli/convoy/engine.test.js +148 -0
  6. package/dist/cli/convoy/engine.test.js.map +1 -1
  7. package/dist/cli/convoy/export.d.ts.map +1 -1
  8. package/dist/cli/convoy/export.js +5 -0
  9. package/dist/cli/convoy/export.js.map +1 -1
  10. package/dist/cli/convoy/export.test.js +31 -0
  11. package/dist/cli/convoy/export.test.js.map +1 -1
  12. package/dist/cli/convoy/store.d.ts +5 -3
  13. package/dist/cli/convoy/store.d.ts.map +1 -1
  14. package/dist/cli/convoy/store.js +37 -11
  15. package/dist/cli/convoy/store.js.map +1 -1
  16. package/dist/cli/convoy/store.test.js +204 -4
  17. package/dist/cli/convoy/store.test.js.map +1 -1
  18. package/dist/cli/convoy/types.d.ts +6 -0
  19. package/dist/cli/convoy/types.d.ts.map +1 -1
  20. package/dist/cli/run/adapters/claude-code.d.ts.map +1 -1
  21. package/dist/cli/run/adapters/claude-code.js +13 -0
  22. package/dist/cli/run/adapters/claude-code.js.map +1 -1
  23. package/dist/cli/run/adapters/copilot.d.ts.map +1 -1
  24. package/dist/cli/run/adapters/copilot.js +8 -0
  25. package/dist/cli/run/adapters/copilot.js.map +1 -1
  26. package/dist/cli/run/adapters/cursor.d.ts.map +1 -1
  27. package/dist/cli/run/adapters/cursor.js +13 -0
  28. package/dist/cli/run/adapters/cursor.js.map +1 -1
  29. package/dist/cli/run/adapters/opencode.d.ts.map +1 -1
  30. package/dist/cli/run/adapters/opencode.js +13 -0
  31. package/dist/cli/run/adapters/opencode.js.map +1 -1
  32. package/dist/cli/run.d.ts.map +1 -1
  33. package/dist/cli/run.js +26 -0
  34. package/dist/cli/run.js.map +1 -1
  35. package/dist/cli/types.d.ts +8 -0
  36. package/dist/cli/types.d.ts.map +1 -1
  37. package/package.json +1 -1
  38. package/src/cli/convoy/engine.test.ts +177 -0
  39. package/src/cli/convoy/engine.ts +22 -1
  40. package/src/cli/convoy/export.test.ts +37 -0
  41. package/src/cli/convoy/export.ts +5 -0
  42. package/src/cli/convoy/store.test.ts +220 -4
  43. package/src/cli/convoy/store.ts +46 -20
  44. package/src/cli/convoy/types.ts +6 -0
  45. package/src/cli/run/adapters/claude-code.ts +13 -1
  46. package/src/cli/run/adapters/copilot.ts +8 -0
  47. package/src/cli/run/adapters/cursor.ts +13 -1
  48. package/src/cli/run/adapters/opencode.ts +13 -1
  49. package/src/cli/run.ts +23 -0
  50. package/src/cli/types.ts +9 -0
  51. package/src/dashboard/dist/index.html +11 -1
  52. package/src/dashboard/node_modules/.vite/deps/_metadata.json +6 -6
  53. package/src/dashboard/src/pages/index.astro +11 -1
@@ -84,11 +84,11 @@ describe('DB creation', () => {
84
84
  expect(row.journal_mode).toBe('wal')
85
85
  })
86
86
 
87
- it('sets schema version to 2', () => {
87
+ it('sets schema version to 3', () => {
88
88
  const db = new DatabaseSync(dbPath)
89
89
  const row = db.prepare('PRAGMA user_version').get() as { user_version: number }
90
90
  db.close()
91
- expect(row.user_version).toBe(2)
91
+ expect(row.user_version).toBe(3)
92
92
  })
93
93
 
94
94
  it('creates all required tables', () => {
@@ -113,7 +113,7 @@ describe('DB creation', () => {
113
113
  store2.close()
114
114
  // Reassign so afterEach does not double-close
115
115
  store = createConvoyStore(dbPath)
116
- expect(row.user_version).toBe(2)
116
+ expect(row.user_version).toBe(3)
117
117
  })
118
118
  })
119
119
 
@@ -192,7 +192,180 @@ describe('schema migration', () => {
192
192
  verifyDb.close()
193
193
 
194
194
  expect(cols.map(c => c.name)).toContain('adapter')
195
- expect(version.user_version).toBe(2)
195
+ // v1 chains through v2→v3 in one init, so final version is 3
196
+ expect(version.user_version).toBe(3)
197
+ })
198
+
199
+ it('schema migration v2 to v3 adds cost columns', () => {
200
+ // Create a v2 database manually (has adapter column but no cost columns)
201
+ const v2DbPath = join(tmpDir, 'v2.db')
202
+ const rawDb = new DatabaseSync(v2DbPath)
203
+ rawDb.exec(`
204
+ CREATE TABLE convoy (
205
+ id TEXT PRIMARY KEY,
206
+ name TEXT NOT NULL,
207
+ spec_hash TEXT NOT NULL,
208
+ status TEXT NOT NULL DEFAULT 'pending',
209
+ branch TEXT,
210
+ created_at TEXT NOT NULL,
211
+ started_at TEXT,
212
+ finished_at TEXT,
213
+ spec_yaml TEXT NOT NULL
214
+ );
215
+ CREATE TABLE task (
216
+ id TEXT PRIMARY KEY,
217
+ convoy_id TEXT NOT NULL REFERENCES convoy(id),
218
+ phase INTEGER NOT NULL,
219
+ prompt TEXT NOT NULL,
220
+ agent TEXT NOT NULL DEFAULT 'developer',
221
+ adapter TEXT,
222
+ model TEXT,
223
+ timeout_ms INTEGER NOT NULL DEFAULT 1800000,
224
+ status TEXT NOT NULL DEFAULT 'pending',
225
+ worker_id TEXT,
226
+ worktree TEXT,
227
+ output TEXT,
228
+ exit_code INTEGER,
229
+ started_at TEXT,
230
+ finished_at TEXT,
231
+ retries INTEGER NOT NULL DEFAULT 0,
232
+ max_retries INTEGER NOT NULL DEFAULT 1,
233
+ files TEXT,
234
+ depends_on TEXT
235
+ );
236
+ CREATE TABLE worker (
237
+ id TEXT PRIMARY KEY,
238
+ task_id TEXT REFERENCES task(id),
239
+ adapter TEXT NOT NULL,
240
+ pid INTEGER,
241
+ session_id TEXT,
242
+ status TEXT NOT NULL DEFAULT 'spawned',
243
+ worktree TEXT,
244
+ created_at TEXT NOT NULL,
245
+ finished_at TEXT,
246
+ last_heartbeat TEXT
247
+ );
248
+ CREATE TABLE event (
249
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
250
+ convoy_id TEXT REFERENCES convoy(id),
251
+ task_id TEXT,
252
+ worker_id TEXT,
253
+ type TEXT NOT NULL,
254
+ data TEXT,
255
+ created_at TEXT NOT NULL
256
+ );
257
+ `)
258
+ rawDb.exec('PRAGMA user_version = 2')
259
+ rawDb.close()
260
+
261
+ // Open with createConvoyStore — should apply the v2→v3 migration
262
+ const v2Store = createConvoyStore(v2DbPath)
263
+ v2Store.close()
264
+
265
+ // Verify cost columns were added
266
+ const verifyDb = new DatabaseSync(v2DbPath)
267
+ const taskCols = verifyDb.prepare('PRAGMA table_info(task)').all() as Array<{ name: string }>
268
+ const convoyCols = verifyDb.prepare('PRAGMA table_info(convoy)').all() as Array<{ name: string }>
269
+ const version = verifyDb.prepare('PRAGMA user_version').get() as { user_version: number }
270
+ verifyDb.close()
271
+
272
+ const taskColNames = taskCols.map(c => c.name)
273
+ expect(taskColNames).toContain('prompt_tokens')
274
+ expect(taskColNames).toContain('completion_tokens')
275
+ expect(taskColNames).toContain('total_tokens')
276
+ expect(taskColNames).toContain('cost_usd')
277
+
278
+ const convoyColNames = convoyCols.map(c => c.name)
279
+ expect(convoyColNames).toContain('total_tokens')
280
+ expect(convoyColNames).toContain('total_cost_usd')
281
+
282
+ expect(version.user_version).toBe(3)
283
+ })
284
+
285
+ it('schema migration v1 to v3 chains correctly in a single init', () => {
286
+ // Create a v1 database (task table without adapter or cost columns)
287
+ const v1DbPath = join(tmpDir, 'v1-chain.db')
288
+ const rawDb = new DatabaseSync(v1DbPath)
289
+ rawDb.exec(`
290
+ CREATE TABLE convoy (
291
+ id TEXT PRIMARY KEY,
292
+ name TEXT NOT NULL,
293
+ spec_hash TEXT NOT NULL,
294
+ status TEXT NOT NULL DEFAULT 'pending',
295
+ branch TEXT,
296
+ created_at TEXT NOT NULL,
297
+ started_at TEXT,
298
+ finished_at TEXT,
299
+ spec_yaml TEXT NOT NULL
300
+ );
301
+ CREATE TABLE task (
302
+ id TEXT PRIMARY KEY,
303
+ convoy_id TEXT NOT NULL REFERENCES convoy(id),
304
+ phase INTEGER NOT NULL,
305
+ prompt TEXT NOT NULL,
306
+ agent TEXT NOT NULL DEFAULT 'developer',
307
+ model TEXT,
308
+ timeout_ms INTEGER NOT NULL DEFAULT 1800000,
309
+ status TEXT NOT NULL DEFAULT 'pending',
310
+ worker_id TEXT,
311
+ worktree TEXT,
312
+ output TEXT,
313
+ exit_code INTEGER,
314
+ started_at TEXT,
315
+ finished_at TEXT,
316
+ retries INTEGER NOT NULL DEFAULT 0,
317
+ max_retries INTEGER NOT NULL DEFAULT 1,
318
+ files TEXT,
319
+ depends_on TEXT
320
+ );
321
+ CREATE TABLE worker (
322
+ id TEXT PRIMARY KEY,
323
+ task_id TEXT REFERENCES task(id),
324
+ adapter TEXT NOT NULL,
325
+ pid INTEGER,
326
+ session_id TEXT,
327
+ status TEXT NOT NULL DEFAULT 'spawned',
328
+ worktree TEXT,
329
+ created_at TEXT NOT NULL,
330
+ finished_at TEXT,
331
+ last_heartbeat TEXT
332
+ );
333
+ CREATE TABLE event (
334
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
335
+ convoy_id TEXT REFERENCES convoy(id),
336
+ task_id TEXT,
337
+ worker_id TEXT,
338
+ type TEXT NOT NULL,
339
+ data TEXT,
340
+ created_at TEXT NOT NULL
341
+ );
342
+ `)
343
+ rawDb.exec('PRAGMA user_version = 1')
344
+ rawDb.close()
345
+
346
+ // Open with createConvoyStore — should chain v1→v2→v3 in one init
347
+ const v1Store = createConvoyStore(v1DbPath)
348
+ v1Store.close()
349
+
350
+ // Verify all columns from both migrations are present
351
+ const verifyDb = new DatabaseSync(v1DbPath)
352
+ const taskCols = verifyDb.prepare('PRAGMA table_info(task)').all() as Array<{ name: string }>
353
+ const convoyCols = verifyDb.prepare('PRAGMA table_info(convoy)').all() as Array<{ name: string }>
354
+ const version = verifyDb.prepare('PRAGMA user_version').get() as { user_version: number }
355
+ verifyDb.close()
356
+
357
+ const taskColNames = taskCols.map(c => c.name)
358
+ expect(taskColNames).toContain('adapter')
359
+ expect(taskColNames).toContain('prompt_tokens')
360
+ expect(taskColNames).toContain('completion_tokens')
361
+ expect(taskColNames).toContain('total_tokens')
362
+ expect(taskColNames).toContain('cost_usd')
363
+
364
+ const convoyColNames = convoyCols.map(c => c.name)
365
+ expect(convoyColNames).toContain('total_tokens')
366
+ expect(convoyColNames).toContain('total_cost_usd')
367
+
368
+ expect(version.user_version).toBe(3)
196
369
  })
197
370
  })
198
371
 
@@ -238,6 +411,25 @@ describe('convoy CRUD', () => {
238
411
  expect(retrieved.status).toBe('done')
239
412
  expect(retrieved.finished_at).toBe(ts)
240
413
  })
414
+
415
+ it('cost fields are null by default on a new convoy', () => {
416
+ store.insertConvoy(makeConvoy())
417
+ const retrieved = store.getConvoy('convoy-1')!
418
+ expect(retrieved.total_tokens).toBeNull()
419
+ expect(retrieved.total_cost_usd).toBeNull()
420
+ })
421
+
422
+ it('updateConvoyStatus persists total_tokens and total_cost_usd', () => {
423
+ store.insertConvoy(makeConvoy())
424
+ store.updateConvoyStatus('convoy-1', 'done', {
425
+ finished_at: '2026-01-01T01:00:00.000Z',
426
+ total_tokens: 5000,
427
+ total_cost_usd: '0.015000',
428
+ })
429
+ const retrieved = store.getConvoy('convoy-1')!
430
+ expect(retrieved.total_tokens).toBe(5000)
431
+ expect(retrieved.total_cost_usd).toBe('0.015000')
432
+ })
241
433
  })
242
434
 
243
435
  // ── task CRUD ─────────────────────────────────────────────────────────────────
@@ -311,6 +503,30 @@ describe('task CRUD', () => {
311
503
  expect(task.finished_at).toBe(ts)
312
504
  expect(task.retries).toBe(1)
313
505
  })
506
+
507
+ it('cost fields are null by default on a new task', () => {
508
+ store.insertTask(makeTask())
509
+ const task = store.getTask('task-1', 'convoy-1')!
510
+ expect(task.prompt_tokens).toBeNull()
511
+ expect(task.completion_tokens).toBeNull()
512
+ expect(task.total_tokens).toBeNull()
513
+ expect(task.cost_usd).toBeNull()
514
+ })
515
+
516
+ it('updateTaskStatus persists cost fields', () => {
517
+ store.insertTask(makeTask())
518
+ store.updateTaskStatus('task-1', 'convoy-1', 'done', {
519
+ prompt_tokens: 1200,
520
+ completion_tokens: 800,
521
+ total_tokens: 2000,
522
+ cost_usd: '0.006000',
523
+ })
524
+ const task = store.getTask('task-1', 'convoy-1')!
525
+ expect(task.prompt_tokens).toBe(1200)
526
+ expect(task.completion_tokens).toBe(800)
527
+ expect(task.total_tokens).toBe(2000)
528
+ expect(task.cost_usd).toBe('0.006000')
529
+ })
314
530
  })
315
531
 
316
532
  // ── getReadyTasks ─────────────────────────────────────────────────────────────
@@ -9,21 +9,21 @@ import type {
9
9
  EventRecord,
10
10
  } from './types.js'
11
11
 
12
- const SCHEMA_VERSION = 2
12
+ const SCHEMA_VERSION = 3
13
13
 
14
14
  export interface ConvoyStore {
15
- insertConvoy(record: Omit<ConvoyRecord, 'started_at' | 'finished_at'>): void
15
+ insertConvoy(record: Omit<ConvoyRecord, 'started_at' | 'finished_at' | 'total_tokens' | 'total_cost_usd'>): void
16
16
  getConvoy(id: string): ConvoyRecord | undefined
17
17
  getLatestConvoy(): ConvoyRecord | undefined
18
18
  updateConvoyStatus(
19
19
  id: string,
20
20
  status: ConvoyStatus,
21
- extra?: { started_at?: string; finished_at?: string },
21
+ extra?: { started_at?: string; finished_at?: string; total_tokens?: number | null; total_cost_usd?: string | null },
22
22
  ): void
23
23
  insertTask(
24
24
  record: Omit<
25
25
  TaskRecord,
26
- 'worker_id' | 'worktree' | 'output' | 'exit_code' | 'started_at' | 'finished_at'
26
+ 'worker_id' | 'worktree' | 'output' | 'exit_code' | 'started_at' | 'finished_at' | 'prompt_tokens' | 'completion_tokens' | 'total_tokens' | 'cost_usd'
27
27
  >,
28
28
  ): void
29
29
  getTask(id: string, convoyId: string): TaskRecord | undefined
@@ -33,7 +33,7 @@ export interface ConvoyStore {
33
33
  convoyId: string,
34
34
  status: ConvoyTaskStatus,
35
35
  extra?: Partial<
36
- Pick<TaskRecord, 'worker_id' | 'worktree' | 'output' | 'exit_code' | 'started_at' | 'finished_at' | 'retries'>
36
+ Pick<TaskRecord, 'worker_id' | 'worktree' | 'output' | 'exit_code' | 'started_at' | 'finished_at' | 'retries' | 'prompt_tokens' | 'completion_tokens' | 'total_tokens' | 'cost_usd'>
37
37
  >,
38
38
  ): void
39
39
  getReadyTasks(convoyId: string): TaskRecord[]
@@ -61,8 +61,8 @@ class ConvoyStoreImpl implements ConvoyStore {
61
61
  }
62
62
 
63
63
  private initSchema(): void {
64
- const row = this.db.prepare('PRAGMA user_version').get() as { user_version: number }
65
- if (row.user_version === 0) {
64
+ let version = (this.db.prepare('PRAGMA user_version').get() as { user_version: number }).user_version
65
+ if (version === 0) {
66
66
  this.db.exec(`
67
67
  CREATE TABLE IF NOT EXISTS convoy (
68
68
  id TEXT PRIMARY KEY,
@@ -72,8 +72,10 @@ class ConvoyStoreImpl implements ConvoyStore {
72
72
  branch TEXT,
73
73
  created_at TEXT NOT NULL,
74
74
  started_at TEXT,
75
- finished_at TEXT,
76
- spec_yaml TEXT NOT NULL
75
+ finished_at TEXT,
76
+ spec_yaml TEXT NOT NULL,
77
+ total_tokens INTEGER,
78
+ total_cost_usd TEXT
77
79
  );
78
80
 
79
81
  CREATE TABLE IF NOT EXISTS task (
@@ -92,10 +94,14 @@ class ConvoyStoreImpl implements ConvoyStore {
92
94
  exit_code INTEGER,
93
95
  started_at TEXT,
94
96
  finished_at TEXT,
95
- retries INTEGER NOT NULL DEFAULT 0,
96
- max_retries INTEGER NOT NULL DEFAULT 1,
97
- files TEXT,
98
- depends_on TEXT
97
+ retries INTEGER NOT NULL DEFAULT 0,
98
+ max_retries INTEGER NOT NULL DEFAULT 1,
99
+ files TEXT,
100
+ depends_on TEXT,
101
+ prompt_tokens INTEGER,
102
+ completion_tokens INTEGER,
103
+ total_tokens INTEGER,
104
+ cost_usd TEXT
99
105
  );
100
106
 
101
107
  CREATE TABLE IF NOT EXISTS worker (
@@ -122,14 +128,26 @@ class ConvoyStoreImpl implements ConvoyStore {
122
128
  );
123
129
  `)
124
130
  this.db.exec(`PRAGMA user_version = ${SCHEMA_VERSION}`)
131
+ version = SCHEMA_VERSION
125
132
  }
126
- if (row.user_version === 1) {
133
+ if (version === 1) {
127
134
  this.db.exec('ALTER TABLE task ADD COLUMN adapter TEXT')
128
135
  this.db.exec('PRAGMA user_version = 2')
136
+ version = 2
137
+ }
138
+ if (version === 2) {
139
+ this.db.exec('ALTER TABLE task ADD COLUMN prompt_tokens INTEGER')
140
+ this.db.exec('ALTER TABLE task ADD COLUMN completion_tokens INTEGER')
141
+ this.db.exec('ALTER TABLE task ADD COLUMN total_tokens INTEGER')
142
+ this.db.exec('ALTER TABLE task ADD COLUMN cost_usd TEXT')
143
+ this.db.exec('ALTER TABLE convoy ADD COLUMN total_tokens INTEGER')
144
+ this.db.exec('ALTER TABLE convoy ADD COLUMN total_cost_usd TEXT')
145
+ this.db.exec('PRAGMA user_version = 3')
146
+ version = 3
129
147
  }
130
148
  }
131
149
 
132
- insertConvoy(record: Omit<ConvoyRecord, 'started_at' | 'finished_at'>): void {
150
+ insertConvoy(record: Omit<ConvoyRecord, 'started_at' | 'finished_at' | 'total_tokens' | 'total_cost_usd'>): void {
133
151
  this.db
134
152
  .prepare(
135
153
  `INSERT INTO convoy (id, name, spec_hash, status, branch, created_at, started_at, finished_at, spec_yaml)
@@ -153,10 +171,10 @@ class ConvoyStoreImpl implements ConvoyStore {
153
171
  updateConvoyStatus(
154
172
  id: string,
155
173
  status: ConvoyStatus,
156
- extra?: { started_at?: string; finished_at?: string },
174
+ extra?: { started_at?: string; finished_at?: string; total_tokens?: number | null; total_cost_usd?: string | null },
157
175
  ): void {
158
176
  const sets = ['status = :status']
159
- const params: Record<string, string | null> = { id, status }
177
+ const params: Record<string, string | number | null> = { id, status }
160
178
 
161
179
  if (extra?.started_at !== undefined) {
162
180
  sets.push('started_at = :started_at')
@@ -166,6 +184,14 @@ class ConvoyStoreImpl implements ConvoyStore {
166
184
  sets.push('finished_at = :finished_at')
167
185
  params.finished_at = extra.finished_at
168
186
  }
187
+ if (extra?.total_tokens !== undefined) {
188
+ sets.push('total_tokens = :total_tokens')
189
+ params.total_tokens = extra.total_tokens
190
+ }
191
+ if (extra?.total_cost_usd !== undefined) {
192
+ sets.push('total_cost_usd = :total_cost_usd')
193
+ params.total_cost_usd = extra.total_cost_usd
194
+ }
169
195
 
170
196
  this.db.prepare(`UPDATE convoy SET ${sets.join(', ')} WHERE id = :id`).run(params)
171
197
  }
@@ -173,7 +199,7 @@ class ConvoyStoreImpl implements ConvoyStore {
173
199
  insertTask(
174
200
  record: Omit<
175
201
  TaskRecord,
176
- 'worker_id' | 'worktree' | 'output' | 'exit_code' | 'started_at' | 'finished_at'
202
+ 'worker_id' | 'worktree' | 'output' | 'exit_code' | 'started_at' | 'finished_at' | 'prompt_tokens' | 'completion_tokens' | 'total_tokens' | 'cost_usd'
177
203
  >,
178
204
  ): void {
179
205
  this.db
@@ -207,12 +233,12 @@ class ConvoyStoreImpl implements ConvoyStore {
207
233
  convoyId: string,
208
234
  status: ConvoyTaskStatus,
209
235
  extra?: Partial<
210
- Pick<TaskRecord, 'worker_id' | 'worktree' | 'output' | 'exit_code' | 'started_at' | 'finished_at' | 'retries'>
236
+ Pick<TaskRecord, 'worker_id' | 'worktree' | 'output' | 'exit_code' | 'started_at' | 'finished_at' | 'retries' | 'prompt_tokens' | 'completion_tokens' | 'total_tokens' | 'cost_usd'>
211
237
  >,
212
238
  ): void {
213
239
  const sets = ['status = :status']
214
240
  const params: Record<string, string | number | null> = { id, convoy_id: convoyId, status }
215
- const extraFields = ['worker_id', 'worktree', 'output', 'exit_code', 'started_at', 'finished_at', 'retries'] as const
241
+ const extraFields = ['worker_id', 'worktree', 'output', 'exit_code', 'started_at', 'finished_at', 'retries', 'prompt_tokens', 'completion_tokens', 'total_tokens', 'cost_usd'] as const
216
242
 
217
243
  if (extra) {
218
244
  for (const field of extraFields) {
@@ -21,6 +21,8 @@ export interface ConvoyRecord {
21
21
  started_at: string | null
22
22
  finished_at: string | null
23
23
  spec_yaml: string
24
+ total_tokens: number | null
25
+ total_cost_usd: string | null
24
26
  }
25
27
 
26
28
  export interface TaskRecord {
@@ -43,6 +45,10 @@ export interface TaskRecord {
43
45
  max_retries: number
44
46
  files: string | null
45
47
  depends_on: string | null
48
+ prompt_tokens: number | null
49
+ completion_tokens: number | null
50
+ total_tokens: number | null
51
+ cost_usd: string | null
46
52
  }
47
53
 
48
54
  export interface WorkerRecord {
@@ -1,5 +1,5 @@
1
1
  import { spawn } from 'node:child_process'
2
- import type { Task, ExecuteOptions, ExecuteResult } from '../../types.js'
2
+ import type { Task, ExecuteOptions, ExecuteResult, TokenUsage } from '../../types.js'
3
3
 
4
4
  /** Adapter name */
5
5
  export const name = 'claude-code'
@@ -60,10 +60,22 @@ export async function execute(task: Task, options: ExecuteOptions = {}): Promise
60
60
 
61
61
  proc.on('close', (code) => {
62
62
  const output = [stdout, stderr].filter(Boolean).join('\n')
63
+ let usage: TokenUsage | undefined
64
+ try {
65
+ const parsed = JSON.parse(stdout) as Record<string, unknown>
66
+ const u = parsed?.usage as Record<string, number> | undefined
67
+ if (u) {
68
+ const promptTokens = (u.input_tokens ?? u.prompt_tokens) as number | undefined
69
+ const completionTokens = (u.output_tokens ?? u.completion_tokens) as number | undefined
70
+ const total = ((promptTokens ?? 0) + (completionTokens ?? 0)) || undefined
71
+ usage = { prompt_tokens: promptTokens, completion_tokens: completionTokens, total_tokens: total }
72
+ }
73
+ } catch { /* not JSON or no usage — graceful degradation */ }
63
74
  resolve({
64
75
  success: code === 0,
65
76
  output: output.slice(0, 10000), // Cap output size
66
77
  exitCode: code ?? -1,
78
+ usage,
67
79
  })
68
80
  })
69
81
 
@@ -100,11 +100,19 @@ export async function execute(task: Task, options: ExecuteOptions = {}): Promise
100
100
  const timeoutMs = parseTimeout(task.timeout)
101
101
  const response = await session.sendAndWait({ prompt }, timeoutMs)
102
102
  const output = response?.data?.content ?? ''
103
+ const rawUsage = (response?.data as Record<string, unknown> | undefined)?.usage ?? (response as Record<string, unknown> | undefined)?.usage
104
+ const u = rawUsage as Record<string, number> | undefined
105
+ const usageResult = u ? {
106
+ prompt_tokens: u.prompt_tokens ?? u.promptTokens,
107
+ completion_tokens: u.completion_tokens ?? u.completionTokens,
108
+ total_tokens: u.total_tokens ?? u.totalTokens,
109
+ } : undefined
103
110
 
104
111
  return {
105
112
  success: true,
106
113
  output: output.slice(0, 10_000),
107
114
  exitCode: 0,
115
+ usage: usageResult,
108
116
  }
109
117
  } catch (err: unknown) {
110
118
  return {
@@ -1,5 +1,5 @@
1
1
  import { spawn } from 'node:child_process'
2
- import type { Task, ExecuteOptions, ExecuteResult } from '../../types.js'
2
+ import type { Task, ExecuteOptions, ExecuteResult, TokenUsage } from '../../types.js'
3
3
 
4
4
  /** Adapter name */
5
5
  export const name = 'cursor'
@@ -59,10 +59,22 @@ export async function execute(task: Task, options: ExecuteOptions = {}): Promise
59
59
 
60
60
  proc.on('close', (code) => {
61
61
  const output = [stdout, stderr].filter(Boolean).join('\n')
62
+ let usage: TokenUsage | undefined
63
+ try {
64
+ const parsed = JSON.parse(stdout) as Record<string, unknown>
65
+ const u = parsed?.usage as Record<string, number> | undefined
66
+ if (u) {
67
+ const promptTokens = (u.input_tokens ?? u.prompt_tokens) as number | undefined
68
+ const completionTokens = (u.output_tokens ?? u.completion_tokens) as number | undefined
69
+ const total = ((promptTokens ?? 0) + (completionTokens ?? 0)) || undefined
70
+ usage = { prompt_tokens: promptTokens, completion_tokens: completionTokens, total_tokens: total }
71
+ }
72
+ } catch { /* not JSON or no usage — graceful degradation */ }
62
73
  resolve({
63
74
  success: code === 0,
64
75
  output: output.slice(0, 10000), // Cap output size
65
76
  exitCode: code ?? -1,
77
+ usage,
66
78
  })
67
79
  })
68
80
 
@@ -1,5 +1,5 @@
1
1
  import { spawn } from 'node:child_process'
2
- import type { Task, ExecuteOptions, ExecuteResult } from '../../types.js'
2
+ import type { Task, ExecuteOptions, ExecuteResult, TokenUsage } from '../../types.js'
3
3
 
4
4
  /** Adapter name */
5
5
  export const name = 'opencode'
@@ -53,10 +53,22 @@ export async function execute(task: Task, options: ExecuteOptions = {}): Promise
53
53
 
54
54
  proc.on('close', (code) => {
55
55
  const output = [stdout, stderr].filter(Boolean).join('\n')
56
+ let usage: TokenUsage | undefined
57
+ try {
58
+ const parsed = JSON.parse(stdout) as Record<string, unknown>
59
+ const u = parsed?.usage as Record<string, number> | undefined
60
+ if (u) {
61
+ const promptTokens = (u.input_tokens ?? u.prompt_tokens) as number | undefined
62
+ const completionTokens = (u.output_tokens ?? u.completion_tokens) as number | undefined
63
+ const total = ((promptTokens ?? 0) + (completionTokens ?? 0)) || undefined
64
+ usage = { prompt_tokens: promptTokens, completion_tokens: completionTokens, total_tokens: total }
65
+ }
66
+ } catch { /* not JSON or no usage — graceful degradation */ }
56
67
  resolve({
57
68
  success: code === 0,
58
69
  output: output.slice(0, 10000), // Cap output size
59
70
  exitCode: code ?? -1,
71
+ usage,
60
72
  })
61
73
  })
62
74
 
package/src/cli/run.ts CHANGED
@@ -8,6 +8,12 @@ import { createReporter, printExecutionPlan } from './run/reporter.js'
8
8
  import type { CliContext, RunOptions } from './types.js'
9
9
  import type { ConvoyResult } from './convoy/engine.js'
10
10
 
11
+ function formatTokens(n: number): string {
12
+ if (n >= 1_000_000) return (n / 1_000_000).toFixed(1) + 'M'
13
+ if (n >= 1_000) return (n / 1_000).toFixed(1) + 'K'
14
+ return String(n)
15
+ }
16
+
11
17
  const HELP = `
12
18
  opencastle run [options]
13
19
 
@@ -153,6 +159,9 @@ function printConvoyResult(result: ConvoyResult): void {
153
159
  console.log(` ${g.passed ? '✓' : '✗'} ${g.command}`)
154
160
  }
155
161
  }
162
+ if (result.cost) {
163
+ console.log(` Tokens: ${formatTokens(result.cost.total_tokens)}`)
164
+ }
156
165
  }
157
166
 
158
167
  /**
@@ -199,6 +208,20 @@ export default async function run({ args, pkgRoot }: CliContext): Promise<void>
199
208
  console.log(` ${status}: ${count}`)
200
209
  }
201
210
  console.log(` total: ${tasks.length}`)
211
+ const totalTokens = tasks.reduce((sum, t) => sum + (t.total_tokens ?? 0), 0)
212
+ if (tasks.some(t => t.total_tokens != null)) {
213
+ console.log(`\n Tokens: ${formatTokens(totalTokens)}`)
214
+ const tasksWithTokens = tasks.filter(t => t.total_tokens != null)
215
+ if (tasksWithTokens.length > 0) {
216
+ console.log(`\n Token usage by task:`)
217
+ for (const t of tasksWithTokens) {
218
+ const parts = [formatTokens(t.total_tokens!)]
219
+ if (t.prompt_tokens != null) parts.push(`in: ${formatTokens(t.prompt_tokens)}`)
220
+ if (t.completion_tokens != null) parts.push(`out: ${formatTokens(t.completion_tokens)}`)
221
+ console.log(` ${t.id}: ${parts.join(' | ')}`)
222
+ }
223
+ }
224
+ }
202
225
  } finally {
203
226
  store.close()
204
227
  }
package/src/cli/types.ts CHANGED
@@ -238,6 +238,13 @@ export interface ExecuteOptions {
238
238
  cwd?: string;
239
239
  }
240
240
 
241
+ /** Token usage data from adapter execution. */
242
+ export interface TokenUsage {
243
+ prompt_tokens?: number;
244
+ completion_tokens?: number;
245
+ total_tokens?: number;
246
+ }
247
+
241
248
  /** Result from an agent adapter execution. */
242
249
  export interface ExecuteResult {
243
250
  success: boolean;
@@ -245,6 +252,8 @@ export interface ExecuteResult {
245
252
  exitCode: number;
246
253
  _timedOut?: boolean;
247
254
  taskId?: string;
255
+ /** Token usage data if available from the adapter. */
256
+ usage?: TokenUsage;
248
257
  }
249
258
 
250
259
  /** Reporter interface for the run command. */
@@ -66,6 +66,12 @@ Export
66
66
  return div.innerHTML;
67
67
  }
68
68
 
69
+ function formatTokens(n) {
70
+ if (n >= 1000000) return (n / 1000000).toFixed(1) + 'M';
71
+ if (n >= 1000) return (n / 1000).toFixed(1) + 'K';
72
+ return String(n);
73
+ }
74
+
69
75
  // ── SVG Icon Library (empty states) ────────────────────
70
76
 
71
77
  const EMPTY_ICONS = {
@@ -1133,6 +1139,9 @@ Export
1133
1139
  html += '<div class="convoy-stat"><span class="convoy-stat__label">Tasks</span><span class="convoy-stat__value">' + done + '/' + total + '</span></div>';
1134
1140
  html += '<div class="convoy-stat"><span class="convoy-stat__label">Events</span><span class="convoy-stat__value">' + (convoy.events_count || 0) + '</span></div>';
1135
1141
  html += '<div class="convoy-stat"><span class="convoy-stat__label">Started</span><span class="convoy-stat__value">' + (convoy.started_at ? formatTime(convoy.started_at) : '\u2014') + '</span></div>';
1142
+ if (convoy.total_tokens != null) {
1143
+ html += '<div class="convoy-stat"><span class="convoy-stat__label">Tokens</span><span class="convoy-stat__value">' + formatTokens(convoy.total_tokens) + '</span></div>';
1144
+ }
1136
1145
  html += '</div>';
1137
1146
 
1138
1147
  html += '<div class="convoy-progress">';
@@ -1142,7 +1151,7 @@ Export
1142
1151
 
1143
1152
  if (convoy.tasks && convoy.tasks.length > 0) {
1144
1153
  html += '<table class="sessions-table convoy-tasks">';
1145
- html += '<thead><tr><th>Task</th><th>Phase</th><th>Agent</th><th>Adapter</th><th>Status</th><th>Retries</th></tr></thead>';
1154
+ html += '<thead><tr><th>Task</th><th>Phase</th><th>Agent</th><th>Adapter</th><th>Status</th><th>Retries</th><th>Tokens</th></tr></thead>';
1146
1155
  html += '<tbody>';
1147
1156
  convoy.tasks.forEach(function(t) {
1148
1157
  const tStatus = t.status === 'done' ? 'success'
@@ -1155,6 +1164,7 @@ Export
1155
1164
  html += '<td>' + escapeHtml(t.adapter || '\u2014') + '</td>';
1156
1165
  html += '<td><span class="outcome-badge outcome-badge--' + tStatus + '">' + escapeHtml(t.status) + '</span></td>';
1157
1166
  html += '<td class="td-num">' + (t.retries || 0) + '</td>';
1167
+ html += '<td class="td-num">' + (t.total_tokens != null ? formatTokens(t.total_tokens) : '\u2014') + '</td>';
1158
1168
  html += '</tr>';
1159
1169
  });
1160
1170
  html += '</tbody></table>';