pantheon-opencode 1.5.0-beta.1 → 1.5.0-beta.10

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 (82) hide show
  1. package/.pantheon/code-mode/manifest.json +21 -0
  2. package/CHANGELOG.md +1026 -0
  3. package/README.md +171 -16
  4. package/README.pt-BR.md +99 -13
  5. package/bin/pantheon-init.mjs +178 -21
  6. package/package.json +23 -14
  7. package/plugin.json +1 -1
  8. package/pyproject.toml +65 -0
  9. package/scripts/code_mode_server.py +431 -55
  10. package/scripts/cost.mjs +30 -14
  11. package/scripts/doctor.mjs +178 -9
  12. package/scripts/install/cli-ui.mjs +17 -11
  13. package/scripts/install/config-migration.mjs +4 -6
  14. package/scripts/install/opencode.mjs +137 -35
  15. package/scripts/install/plugin.mjs +3 -33
  16. package/scripts/install/shared.mjs +43 -2
  17. package/scripts/install/strings.mjs +129 -0
  18. package/scripts/install-mcp.mjs +1 -0
  19. package/scripts/manifest-inventory.mjs +410 -0
  20. package/scripts/mcp_persistence_server.py +537 -128
  21. package/scripts/package-evidence.mjs +242 -0
  22. package/scripts/probe-context-rehydrate.mjs +240 -0
  23. package/scripts/probe-pantheon-cost.mjs +313 -0
  24. package/scripts/release-beta-version.mjs +56 -60
  25. package/scripts/sync-artifacts.mjs +169 -0
  26. package/scripts/sync-tui.mjs +121 -53
  27. package/scripts/test-opencode-v1-v2-sandbox.sh +367 -96
  28. package/scripts/validation-policy.mjs +7 -7
  29. package/scripts/version-check.mjs +113 -108
  30. package/scripts/versioning.mjs +230 -86
  31. package/src/agents/mnemosyne.md +1 -1
  32. package/src/agents/zeus.md +2 -2
  33. package/src/mcp/code_mode_server.py +431 -55
  34. package/src/mcp/mcp_persistence_server.py +537 -128
  35. package/src/pantheon/auto-wake.ts +1 -5
  36. package/src/pantheon/background-job-board.ts +43 -1
  37. package/src/pantheon/compaction-handoff.ts +304 -0
  38. package/src/pantheon/cost-command.ts +345 -62
  39. package/src/pantheon/delegate-manager.ts +390 -8
  40. package/src/pantheon/delegation-finalize.ts +32 -0
  41. package/src/pantheon/delegation.ts +235 -18
  42. package/src/pantheon/file-persistence-salvage.ts +64 -0
  43. package/src/pantheon/file-persistence.ts +194 -45
  44. package/src/pantheon/native-probe.ts +234 -0
  45. package/src/pantheon/native-task-status.ts +139 -0
  46. package/src/pantheon/provider-capabilities.ts +221 -0
  47. package/src/pantheon/provider-stream-tracker.ts +182 -0
  48. package/src/pantheon/shared-board.ts +37 -0
  49. package/src/pantheon/task-result-guard.ts +107 -0
  50. package/src/pantheon/token-opt.ts +146 -12
  51. package/src/pantheon/tool-ceiling.ts +159 -0
  52. package/src/pantheon/v2-events.ts +18 -1
  53. package/src/plugin-v2.ts +2 -14
  54. package/src/plugin.ts +28 -17
  55. package/src/plugins/pantheon-hooks.ts +51 -0
  56. package/src/plugins/tui/dist/server.d.ts +1 -1
  57. package/src/plugins/tui/dist/server.d.ts.map +1 -1
  58. package/src/plugins/tui/dist/server.js +1 -1
  59. package/src/plugins/tui/dist/server.js.map +1 -1
  60. package/src/plugins/tui/dist/tui.d.ts +116 -27
  61. package/src/plugins/tui/dist/tui.d.ts.map +1 -1
  62. package/src/plugins/tui/dist/tui.js +414 -150
  63. package/src/plugins/tui/dist/tui.js.map +1 -1
  64. package/src/plugins/tui/dist/tui.tsx +447 -84
  65. package/src/plugins/tui/package-lock.json +2 -2
  66. package/src/plugins/tui/package.json +1 -1
  67. package/src/plugins/tui/src/index.tsx +447 -84
  68. package/.pantheon/code-mode/.gitkeep +0 -0
  69. package/.pantheon/code-mode/.pantheon/deepwork/initial-token-40k/checkpoint-1.json +0 -8
  70. package/.pantheon/code-mode/.pantheon/deepwork/initial-token-40k/checkpoint-10.json +0 -8
  71. package/.pantheon/code-mode/.pantheon/deepwork/initial-token-40k/checkpoint-11.json +0 -8
  72. package/.pantheon/code-mode/.pantheon/deepwork/initial-token-40k/checkpoint-2.json +0 -8
  73. package/.pantheon/code-mode/.pantheon/deepwork/initial-token-40k/checkpoint-3.json +0 -8
  74. package/.pantheon/code-mode/.pantheon/deepwork/initial-token-40k/checkpoint-4.json +0 -8
  75. package/.pantheon/code-mode/.pantheon/deepwork/initial-token-40k/checkpoint-5.json +0 -8
  76. package/.pantheon/code-mode/.pantheon/deepwork/initial-token-40k/checkpoint-6.json +0 -8
  77. package/.pantheon/code-mode/.pantheon/deepwork/initial-token-40k/checkpoint-7.json +0 -8
  78. package/.pantheon/code-mode/.pantheon/deepwork/initial-token-40k/checkpoint-8.json +0 -8
  79. package/.pantheon/code-mode/.pantheon/deepwork/initial-token-40k/checkpoint-9.json +0 -8
  80. package/.pantheon/code-mode/.pantheon/deepwork/initial-token-40k/heartbeat.json +0 -8
  81. package/.pantheon/code-mode/.pantheon/deepwork/initial-token-40k/session.json +0 -11
  82. package/src/pantheon/delegate-task-adapter.ts +0 -444
@@ -1,14 +1,11 @@
1
1
  /**
2
- * Cost Command (Wave 4, PR #46) — `pantheon_cost` structural tool: cost +
3
- * token visibility for delegation traffic, straight from opencode.db.
2
+ * Token Command — `pantheon_cost` structural tool: token visibility for
3
+ * delegation traffic, straight from opencode.db.
4
4
  *
5
- * Reads the opencode session database READ-ONLY (sum cost + tokens by
6
- * agent over the last N days → markdown table). Zero new dependencies:
7
- * - primary path: node:sqlite (DatabaseSync, readOnly) — node ≥ 22.5;
8
- * - fallback: spawn scripts/cost.mjs (node:sqlite, prints JSON) when the
9
- * direct import is unavailable;
10
- * - missing/unreadable db → FRIENDLY error string, never a crash (tools
11
- * return errors as TEXT).
5
+ * Reads the opencode session database READ-ONLY (sum tokens by agent and
6
+ * phase over the last N days → markdown table). Zero new dependencies:
7
+ * - only path: node:sqlite (DatabaseSync, readOnly) — node ≥ 22.5;
8
+ * - missing/unreadable db → a diagnostic contract status as TEXT.
12
9
  *
13
10
  * dbPath resolution: explicit option > env PANTHEON_COST_DB > the version-aware
14
11
  * default selected by PANTHEON_OPENCODE_VERSION (v1 or v2). An unset version
@@ -19,31 +16,47 @@
19
16
  * @module cost-command
20
17
  */
21
18
  import { execFile } from 'node:child_process'
22
- import { existsSync } from 'node:fs'
19
+ import { accessSync, constants, existsSync, statSync } from 'node:fs'
23
20
  import { homedir } from 'node:os'
24
- import { join } from 'node:path'
25
- import { promisify } from 'node:util'
21
+ import { delimiter, dirname, join } from 'node:path'
22
+ import { fileURLToPath } from 'node:url'
26
23
  import { z } from 'zod'
27
24
 
28
25
  import type { ToolContextLike } from './delegation.ts'
26
+ import type { NativeTaskStatus } from './native-task-status.ts'
29
27
 
30
- const execFileAsync = promisify(execFile)
28
+ const __filename = fileURLToPath(import.meta.url)
29
+ const __dirname = dirname(__filename)
31
30
 
32
- /** One aggregated cost row (per agent). */
31
+ /** One aggregated token row (per agent and phase). */
33
32
  export interface CostRow {
34
33
  agent: string
35
- costUsd: number
34
+ phase: string
36
35
  tokensInput: number
37
36
  tokensOutput: number
37
+ tokensTotal: number
38
38
  }
39
39
 
40
40
  export interface CostCommandOptions {
41
41
  /** Explicit db path (testability / unusual installs). Default: resolved candidates. */
42
42
  dbPath?: string
43
+ /** Injectable loader for tests; production always loads node:sqlite. */
44
+ sqliteLoader?: () => Promise<SqliteModule>
45
+ /** Injectable CLI-fallback process boundary (tests / unusual runtimes). */
46
+ cliFallback?: CliFallbackDeps
43
47
  }
44
48
 
45
49
  export type OpenCodeVersion = 'v1' | 'v2'
46
50
 
51
+ interface SqliteDatabase {
52
+ prepare(sql: string): { get(): unknown; all(...args: unknown[]): unknown[] }
53
+ close(): void
54
+ }
55
+
56
+ export interface SqliteModule {
57
+ DatabaseSync: new (path: string, options: { readOnly: boolean }) => SqliteDatabase
58
+ }
59
+
47
60
  /** One cost tool: description + zod args shape + execute (delegation.ts shape). */
48
61
  export interface CostTool<Args extends z.ZodRawShape = typeof costArgs> {
49
62
  description: string
@@ -123,9 +136,12 @@ function assertCompatibleSchema(db: {
123
136
  }
124
137
  }
125
138
 
126
- /** Aggregate cost + tokens by agent via node:sqlite (read-only). */
127
- async function queryWithNodeSqlite(dbPath: string, days: number): Promise<CostRow[]> {
128
- const sqlite = await import('node:sqlite')
139
+ /** Aggregate tokens by agent and phase via node:sqlite (read-only). */
140
+ async function queryWithNodeSqlite(
141
+ dbPath: string,
142
+ days: number,
143
+ sqlite: SqliteModule,
144
+ ): Promise<CostRow[]> {
129
145
  const db = new sqlite.DatabaseSync(dbPath, { readOnly: true })
130
146
  try {
131
147
  assertCompatibleSchema(db)
@@ -141,24 +157,9 @@ async function queryWithNodeSqlite(dbPath: string, days: number): Promise<CostRo
141
157
  }
142
158
  }
143
159
 
144
- /** Fallback: delegate the same aggregation to scripts/cost.mjs (node:sqlite). */
145
- async function queryWithScript(
146
- scriptPath: string,
147
- dbPath: string,
148
- days: number,
149
- ): Promise<CostRow[]> {
150
- const { stdout } = await execFileAsync(process.execPath, [scriptPath, dbPath, String(days)], {
151
- timeout: 15_000,
152
- encoding: 'utf8',
153
- })
154
- const parsed = JSON.parse(stdout) as { ok: boolean; rows?: CostRow[]; error?: string }
155
- if (!parsed.ok) throw new Error(parsed.error ?? 'cost.mjs failed')
156
- return parsed.rows ?? []
157
- }
158
-
159
- /** Parse message.data JSON rows and aggregate cost + tokens by assistant agent. */
160
+ /** Parse message.data JSON rows and aggregate tokens by assistant agent/phase. */
160
161
  function aggregateMessages(dataValues: readonly string[]): CostRow[] {
161
- const byAgent = new Map<string, CostRow>()
162
+ const byAgentAndPhase = new Map<string, CostRow>()
162
163
  for (const value of dataValues) {
163
164
  try {
164
165
  const outer = JSON.parse(value) as Record<string, unknown>
@@ -169,81 +170,363 @@ function aggregateMessages(dataValues: readonly string[]): CostRow[] {
169
170
  if (info.role !== 'assistant') continue
170
171
  const agent = typeof info.agent === 'string' && info.agent !== '' ? info.agent : null
171
172
  if (agent === null) continue
172
- const costUsd = Number(info.cost) || 0
173
+ const metadata = info.metadata as Record<string, unknown> | undefined
174
+ const phase =
175
+ (typeof info.phase === 'string' && info.phase) ||
176
+ (typeof metadata?.phase === 'string' && metadata.phase) ||
177
+ 'unknown'
173
178
  const tokens = (info.tokens ?? {}) as Record<string, unknown>
174
179
  const tokensInput = Number(tokens.input) || 0
175
180
  const tokensOutput = Number(tokens.output) || 0
176
- const acc = byAgent.get(agent) ?? { agent, costUsd: 0, tokensInput: 0, tokensOutput: 0 }
177
- acc.costUsd += costUsd
181
+ const key = `${agent}\u0000${phase}`
182
+ const acc = byAgentAndPhase.get(key) ?? {
183
+ agent,
184
+ phase,
185
+ tokensInput: 0,
186
+ tokensOutput: 0,
187
+ tokensTotal: 0,
188
+ }
178
189
  acc.tokensInput += tokensInput
179
190
  acc.tokensOutput += tokensOutput
180
- byAgent.set(agent, acc)
191
+ acc.tokensTotal += tokensInput + tokensOutput
192
+ byAgentAndPhase.set(key, acc)
181
193
  } catch {
182
194
  // Skip malformed rows — a partial ledger never breaks the report.
183
195
  }
184
196
  }
185
- return [...byAgent.values()].sort((a, b) => b.costUsd - a.costUsd)
197
+ return [...byAgentAndPhase.values()].sort((a, b) => b.tokensTotal - a.tokensTotal)
186
198
  }
187
199
 
188
200
  /** Render the aggregate as a markdown table with a total row. */
189
201
  function renderMarkdown(rows: readonly CostRow[], days: number): string {
190
202
  if (rows.length === 0) {
191
- return `## Cost by agent (last ${days} days)\n\nNo cost records in the last ${days} days.`
203
+ return `## Token usage by agent and phase (last ${days} days)\n\nNo token records in the last ${days} days.`
192
204
  }
193
205
  const total = rows.reduce(
194
206
  (acc, r) => {
195
- acc.costUsd += r.costUsd
196
207
  acc.tokensInput += r.tokensInput
197
208
  acc.tokensOutput += r.tokensOutput
209
+ acc.tokensTotal += r.tokensTotal
198
210
  return acc
199
211
  },
200
- { costUsd: 0, tokensInput: 0, tokensOutput: 0 },
212
+ { tokensInput: 0, tokensOutput: 0, tokensTotal: 0 },
201
213
  )
202
214
  const lines = [
203
- `## Cost by agent (last ${days} days)`,
215
+ `## Token usage by agent and phase (last ${days} days)`,
204
216
  '',
205
- '| Agent | Cost (USD) | Tokens In | Tokens Out |',
206
- '|-------|-----------:|----------:|-----------:|',
217
+ '| Agent | Phase | Tokens In | Tokens Out | Tokens Total |',
218
+ '|-------|-------|----------:|-----------:|-------------:|',
207
219
  ...rows.map(
208
- (r) => `| ${r.agent} | $${r.costUsd.toFixed(6)} | ${r.tokensInput} | ${r.tokensOutput} |`,
220
+ (r) =>
221
+ `| ${r.agent} | ${r.phase} | ${r.tokensInput} | ${r.tokensOutput} | ${r.tokensTotal} |`,
209
222
  ),
210
- `| **Total** | **$${total.costUsd.toFixed(6)}** | **${total.tokensInput}** | **${total.tokensOutput}** |`,
223
+ `| **Total** | — | **${total.tokensInput}** | **${total.tokensOutput}** | **${total.tokensTotal}** |`,
211
224
  '',
212
225
  `Source: opencode.db (read-only)`,
213
226
  ]
214
227
  return lines.join('\n')
215
228
  }
216
229
 
230
+ // ─── CLI fallback: resolve a REAL Node, not the plugin runtime ──────────
231
+ //
232
+ // Under Bun (the runtime that motivates this fallback), process.execPath IS
233
+ // the Bun binary, which has no node:sqlite — so spawning it could never work.
234
+ // Resolve a genuine Node instead: PANTHEON_NODE → `node` on PATH → clear error.
235
+
236
+ /** First Node release exposing the built-in `node:sqlite` module. */
237
+ const NODE_SQLITE_MIN_VERSION = '22.5'
238
+
239
+ /** Minimal injectable child-process boundary (tests / unusual runtimes). */
240
+ export type ExecFileLike = (
241
+ file: string,
242
+ args: readonly string[],
243
+ ) => Promise<{ stdout: string; stderr?: string }>
244
+
245
+ /** Injectables so the fallback never depends on the real machine in tests. */
246
+ export interface CliFallbackDeps {
247
+ /** Environment for PANTHEON_NODE / PATH lookup. Default: process.env. */
248
+ env?: NodeJS.ProcessEnv
249
+ /** Child-process boundary. Default: node:child_process.execFile. */
250
+ execFile?: ExecFileLike
251
+ /** Executable predicate used by the PANTHEON_NODE / PATH lookups. */
252
+ isExecutable?: (path: string) => boolean
253
+ }
254
+
255
+ /** A real Node executable located for the CLI fallback. */
256
+ export interface ResolvedNode {
257
+ path: string
258
+ source: 'env' | 'path'
259
+ }
260
+
261
+ type ExecFailure = Error & { stdout?: string; stderr?: string }
262
+
263
+ function defaultIsExecutable(path: string): boolean {
264
+ try {
265
+ accessSync(path, constants.X_OK)
266
+ return statSync(path).isFile()
267
+ } catch {
268
+ return false
269
+ }
270
+ }
271
+
272
+ function defaultExecFile(
273
+ file: string,
274
+ args: readonly string[],
275
+ ): Promise<{ stdout: string; stderr: string }> {
276
+ return new Promise((resolve, reject) => {
277
+ execFile(file, [...args], { encoding: 'utf8' }, (error, stdout, stderr) => {
278
+ if (error) {
279
+ // execFile's callback error does not always carry the captured output;
280
+ // attach it so callers can surface cost.mjs's structured {ok:false}.
281
+ const failure = error as ExecFailure
282
+ if (typeof failure.stdout !== 'string') failure.stdout = stdout
283
+ if (typeof failure.stderr !== 'string') failure.stderr = stderr
284
+ reject(failure)
285
+ return
286
+ }
287
+ resolve({ stdout, stderr })
288
+ })
289
+ })
290
+ }
291
+
292
+ /** Scan PATH (no shell) for a `node` executable. */
293
+ export function resolveNodeFromPath(
294
+ env: NodeJS.ProcessEnv,
295
+ isExecutable: (path: string) => boolean = defaultIsExecutable,
296
+ ): string | undefined {
297
+ const pathValue = env.PATH ?? env.Path ?? ''
298
+ if (pathValue === '') return undefined
299
+ const isWindows = process.platform === 'win32'
300
+ const extensions = isWindows
301
+ ? (env.PATHEXT ?? '.EXE;.CMD;.BAT;.COM').split(';').filter((ext) => ext !== '')
302
+ : ['']
303
+ for (const dir of pathValue.split(delimiter)) {
304
+ if (dir === '') continue
305
+ for (const ext of extensions) {
306
+ const candidate = join(dir, `node${ext}`)
307
+ if (isExecutable(candidate)) return candidate
308
+ }
309
+ }
310
+ return undefined
311
+ }
312
+
313
+ /**
314
+ * Resolve a REAL Node.js binary for the CLI fallback.
315
+ *
316
+ * Order: PANTHEON_NODE (must exist and be executable — an explicit override
317
+ * that is unusable fails fast instead of being silently ignored) → `node`
318
+ * from PATH. Returns undefined only when neither source is available.
319
+ */
320
+ export function resolveNodeExecutable(
321
+ env: NodeJS.ProcessEnv = process.env,
322
+ isExecutable: (path: string) => boolean = defaultIsExecutable,
323
+ ): ResolvedNode | undefined {
324
+ const explicit = env.PANTHEON_NODE
325
+ if (explicit !== undefined && explicit.trim() !== '') {
326
+ if (isExecutable(explicit)) return { path: explicit, source: 'env' }
327
+ throw new Error(
328
+ `PANTHEON_NODE is set to "${explicit}" but that is not an existing executable file`,
329
+ )
330
+ }
331
+ const fromPath = resolveNodeFromPath(env, isExecutable)
332
+ return fromPath === undefined ? undefined : { path: fromPath, source: 'path' }
333
+ }
334
+
335
+ function execErrorMessage(err: unknown): string {
336
+ if (err instanceof Error) {
337
+ const failure = err as ExecFailure
338
+ const stderr = typeof failure.stderr === 'string' ? failure.stderr.trim() : ''
339
+ if (stderr !== '') return stderr
340
+ return failure.message
341
+ }
342
+ return String(err)
343
+ }
344
+
345
+ function tryParseJson(text: string): unknown {
346
+ try {
347
+ return JSON.parse(text)
348
+ } catch {
349
+ return undefined
350
+ }
351
+ }
352
+
353
+ function truncateForError(text: string): string {
354
+ const trimmed = text.trim()
355
+ return trimmed.length > 200 ? `${trimmed.slice(0, 200)}…` : trimmed
356
+ }
357
+
358
+ /** Probe the resolved binary for built-in node:sqlite support. */
359
+ async function assertNodeSqliteSupport(
360
+ nodePath: string,
361
+ execFileAsync: ExecFileLike,
362
+ ): Promise<void> {
363
+ try {
364
+ await execFileAsync(nodePath, ['-e', "require('node:sqlite')"])
365
+ } catch (err: unknown) {
366
+ throw new Error(
367
+ `resolved Node binary "${nodePath}" does not support node:sqlite (Node.js >= ${NODE_SQLITE_MIN_VERSION} required): ${execErrorMessage(err)}`,
368
+ )
369
+ }
370
+ }
371
+
372
+ /** Pull the structured failure message cost.mjs prints to stdout (exit 1). */
373
+ function extractScriptFailure(err: unknown): string {
374
+ const failure = err as { stdout?: unknown; stderr?: unknown }
375
+ const stdout = typeof failure.stdout === 'string' ? failure.stdout : ''
376
+ const parsed = tryParseJson(stdout)
377
+ if (parsed !== null && typeof parsed === 'object') {
378
+ const record = parsed as { ok?: unknown; error?: unknown }
379
+ if (record.ok === false && typeof record.error === 'string' && record.error !== '') {
380
+ return record.error
381
+ }
382
+ }
383
+ const stderr = typeof failure.stderr === 'string' ? failure.stderr.trim() : ''
384
+ if (stderr !== '') return stderr
385
+ return execErrorMessage(err)
386
+ }
387
+
388
+ /**
389
+ * Spawn `node scripts/cost.mjs` as a CLI fallback when node:sqlite is
390
+ * unavailable in the plugin process (e.g. Bun runtime). Returns CostRow[] by
391
+ * parsing the JSON the script writes to stdout.
392
+ *
393
+ * Resolves a REAL Node binary (PANTHEON_NODE → PATH): process.execPath under
394
+ * Bun is the Bun binary, which cannot run node:sqlite. The resolved binary is
395
+ * probed for node:sqlite support and the script is always run read-only with
396
+ * an argument array (never a shell).
397
+ */
398
+ export async function queryWithCliFallback(
399
+ dbPath: string,
400
+ days: number,
401
+ deps?: CliFallbackDeps,
402
+ ): Promise<CostRow[]> {
403
+ const env = deps?.env ?? process.env
404
+ const execFileAsync = deps?.execFile ?? defaultExecFile
405
+ const isExecutable = deps?.isExecutable ?? defaultIsExecutable
406
+
407
+ const costScript = join(__dirname, '..', '..', 'scripts', 'cost.mjs')
408
+ if (!existsSync(costScript)) {
409
+ throw new Error(`CLI fallback script not found: ${costScript}`)
410
+ }
411
+
412
+ const node = resolveNodeExecutable(env, isExecutable)
413
+ if (node === undefined) {
414
+ throw new Error(
415
+ `no Node.js executable found for the node:sqlite CLI fallback: set PANTHEON_NODE to a Node.js >= ${NODE_SQLITE_MIN_VERSION} binary or put "node" on PATH`,
416
+ )
417
+ }
418
+
419
+ await assertNodeSqliteSupport(node.path, execFileAsync)
420
+
421
+ let stdout: string
422
+ try {
423
+ ;({ stdout } = await execFileAsync(node.path, [
424
+ costScript,
425
+ '--db-path',
426
+ dbPath,
427
+ '--days',
428
+ String(days),
429
+ ]))
430
+ } catch (err: unknown) {
431
+ // cost.mjs sets exitCode=1 (execFile rejects) and writes {ok:false,error}
432
+ // to stdout before exiting — surface that structured failure.
433
+ throw new Error(`CLI fallback failed: ${extractScriptFailure(err)}`)
434
+ }
435
+
436
+ const parsed = tryParseJson(stdout)
437
+ if (parsed === null || typeof parsed !== 'object') {
438
+ throw new Error(`CLI fallback returned non-JSON output: ${truncateForError(stdout)}`)
439
+ }
440
+ const record = parsed as { ok?: unknown; rows?: unknown; error?: unknown }
441
+ if (record.ok !== true) {
442
+ const detail =
443
+ typeof record.error === 'string' && record.error !== ''
444
+ ? record.error
445
+ : 'unexpected response shape from scripts/cost.mjs'
446
+ throw new Error(`CLI fallback failed: ${detail}`)
447
+ }
448
+ if (!Array.isArray(record.rows)) {
449
+ throw new Error('CLI fallback failed: scripts/cost.mjs response is missing the rows array')
450
+ }
451
+ return record.rows as CostRow[]
452
+ }
453
+
217
454
  export function createCostCommand(options?: CostCommandOptions): CostCommand {
218
- const scriptPath = new URL('../../scripts/cost.mjs', import.meta.url).pathname
455
+ const loadSqlite =
456
+ options?.sqliteLoader ?? (async () => (await import('node:sqlite')) as SqliteModule)
457
+
458
+ const failure = (status: NativeTaskStatus, detail: string): string =>
459
+ `status: ${status}\npantheon_cost failed: ${detail}`
460
+
461
+ const diagnostic = (err: unknown): string => {
462
+ if (err instanceof Error) {
463
+ const stderr = (err as Error & { stderr?: unknown }).stderr
464
+ const suffix =
465
+ typeof stderr === 'string' && stderr.trim() !== '' ? `; stderr: ${stderr.trim()}` : ''
466
+ return `${err.message}${suffix}`
467
+ }
468
+ return String(err)
469
+ }
470
+
471
+ const statusForError = (err: unknown): NativeTaskStatus => {
472
+ const detail = diagnostic(err).toLowerCase()
473
+ if (
474
+ detail.includes('not found') ||
475
+ detail.includes('unknown builtin') ||
476
+ detail.includes('no such built-in') ||
477
+ detail.includes('cannot find module')
478
+ ) {
479
+ return 'UNSUPPORTED'
480
+ }
481
+ if (
482
+ detail.includes('not a database') ||
483
+ detail.includes('malformed') ||
484
+ detail.includes('corrupt') ||
485
+ detail.includes('incompatible opencode.db schema')
486
+ ) {
487
+ return 'CORRUPT_DATA'
488
+ }
489
+ return 'UNAVAILABLE'
490
+ }
219
491
 
220
492
  const execute = async (args: { days?: number }, _ctx: ToolContextLike): Promise<string> => {
221
- const days = args.days ?? 7
493
+ const days = args?.days ?? 7
222
494
  try {
223
495
  const dbPath = findExistingDb(options?.dbPath)
224
496
  if (dbPath === undefined) {
225
497
  const expected = resolveCostDbPath(options)
226
- return (
227
- `pantheon_cost failed: opencode.db not found (expected at ${expected}). ` +
228
- `No cost history is available until opencode has stored session data.`
498
+ return failure(
499
+ 'UNAVAILABLE',
500
+ `opencode.db not found (expected at ${expected}). No token history is available until opencode has stored session data.`,
229
501
  )
230
502
  }
231
- const rows = await queryWithNodeSqlite(dbPath, days).catch(async (err: unknown) => {
232
- if (err instanceof Error && err.message.startsWith('incompatible opencode.db schema'))
233
- throw err
234
- return queryWithScript(scriptPath, dbPath, days)
235
- })
236
- return renderMarkdown(rows, days)
503
+ let sqlite: SqliteModule | undefined
504
+ try {
505
+ sqlite = await loadSqlite()
506
+ } catch (loadErr: unknown) {
507
+ // node:sqlite unavailable (e.g. Bun runtime) — fall back to CLI.
508
+ const status = statusForError(loadErr)
509
+ if (status === 'UNSUPPORTED') {
510
+ try {
511
+ const rows = await queryWithCliFallback(dbPath, days, options?.cliFallback)
512
+ return `status: OK\n${renderMarkdown(rows, days)}`
513
+ } catch (cliErr: unknown) {
514
+ return failure(statusForError(cliErr), diagnostic(cliErr))
515
+ }
516
+ }
517
+ throw loadErr
518
+ }
519
+ const rows = await queryWithNodeSqlite(dbPath, days, sqlite)
520
+ return `status: OK\n${renderMarkdown(rows, days)}`
237
521
  } catch (err: unknown) {
238
- const reason = err instanceof Error ? err.message : String(err)
239
- return `pantheon_cost failed: ${reason}`
522
+ return failure(statusForError(err), diagnostic(err))
240
523
  }
241
524
  }
242
525
 
243
526
  return {
244
527
  pantheon_cost: {
245
528
  description:
246
- 'Report delegation cost + token usage by agent over the last N days, read from opencode.db (read-only, no writes).',
529
+ 'Report input, output, and total token usage by agent and phase over the last N days, read from opencode.db (read-only, no monetary values).',
247
530
  args: costArgs,
248
531
  execute,
249
532
  },