pantheon-opencode 1.4.3 → 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 (118) hide show
  1. package/.pantheon/code-mode/README.md +93 -0
  2. package/.pantheon/code-mode/checkpoint-session.py +7 -0
  3. package/.pantheon/code-mode/checkpoint-session.sh +11 -0
  4. package/.pantheon/code-mode/checkpoint_session.py +481 -0
  5. package/.pantheon/code-mode/compress-inline.py +450 -0
  6. package/.pantheon/code-mode/eval-llm-judge.py +255 -0
  7. package/.pantheon/code-mode/eval-monte-carlo.py +368 -0
  8. package/.pantheon/code-mode/eval-run.py +197 -0
  9. package/.pantheon/code-mode/eval-static.py +321 -0
  10. package/.pantheon/code-mode/example-sync.sh +5 -0
  11. package/.pantheon/code-mode/manifest.json +21 -0
  12. package/.pantheon/code-mode/session-end-save.py +186 -0
  13. package/.pantheon/code-mode/session-end-save.sh +18 -0
  14. package/.pantheon/code-mode/test-allowed.sh +5 -0
  15. package/.pantheon/code-mode/test-auto-continue-checkpoint.sh +179 -0
  16. package/.pantheon/code-mode/test-env.sh +7 -0
  17. package/.pantheon/code-mode/test-prlimit.sh +9 -0
  18. package/.pantheon/code-mode/test-timeout.sh +6 -0
  19. package/CHANGELOG.md +1026 -0
  20. package/README.md +292 -1080
  21. package/README.pt-BR.md +308 -0
  22. package/bin/pantheon-init.mjs +207 -31
  23. package/opencode.json +1 -0
  24. package/package.json +34 -14
  25. package/plugin.json +15 -0
  26. package/pyproject.toml +65 -0
  27. package/scripts/_pantheon_paths.py +48 -7
  28. package/scripts/ci-validate-yaml.py +7 -2
  29. package/scripts/code_mode_server.py +451 -44
  30. package/scripts/cost.mjs +30 -14
  31. package/scripts/doctor.mjs +306 -129
  32. package/scripts/generate-preset-docs.mjs +127 -66
  33. package/scripts/generate-prompts.sh +2 -2
  34. package/scripts/install/cli-ui.mjs +23 -10
  35. package/scripts/install/config-migration.mjs +399 -0
  36. package/scripts/install/health-check.mjs +3 -3
  37. package/scripts/install/model-picker.mjs +72 -23
  38. package/scripts/install/opencode-version.mjs +47 -0
  39. package/scripts/install/opencode.mjs +396 -121
  40. package/scripts/install/plugin.mjs +89 -49
  41. package/scripts/install/shared.mjs +55 -159
  42. package/scripts/install/strings.mjs +129 -0
  43. package/scripts/install-mcp.mjs +45 -232
  44. package/scripts/manifest-inventory.mjs +410 -0
  45. package/scripts/mcp_persistence_server.py +798 -60
  46. package/scripts/mcp_resources_server.py +144 -40
  47. package/scripts/memory_mcp_server.py +2 -2
  48. package/scripts/package-evidence.mjs +242 -0
  49. package/scripts/probe-context-rehydrate.mjs +240 -0
  50. package/scripts/probe-pantheon-cost.mjs +313 -0
  51. package/scripts/recover-zenodo-deposition.mjs +146 -0
  52. package/scripts/release-beta-version.mjs +56 -60
  53. package/scripts/sync-artifacts.mjs +169 -0
  54. package/scripts/sync-tui.mjs +135 -42
  55. package/scripts/test-opencode-v1-v2-sandbox.sh +862 -0
  56. package/scripts/uninstall.mjs +298 -954
  57. package/scripts/validate-zenodo-release.mjs +154 -0
  58. package/scripts/validation-policy.mjs +7 -7
  59. package/scripts/version-check.mjs +113 -108
  60. package/scripts/versioning.mjs +230 -86
  61. package/scripts/zenodo-artifact.mjs +21 -0
  62. package/scripts/zenodo-release-state.mjs +53 -0
  63. package/src/agents/mnemosyne.md +1 -1
  64. package/src/agents/zeus.md +27 -16
  65. package/src/mcp/_pantheon_paths.py +48 -7
  66. package/src/mcp/code_mode_server.py +451 -44
  67. package/src/mcp/eval_store.py +1 -1
  68. package/src/mcp/mcp_persistence_server.py +798 -60
  69. package/src/mcp/mcp_resources_server.py +144 -40
  70. package/src/mcp/pantheon_vision_server.py +1 -1
  71. package/src/mcp/toon_codec.py +491 -0
  72. package/src/pantheon/auto-wake.ts +1 -5
  73. package/src/pantheon/background-job-board.ts +110 -2
  74. package/src/pantheon/compaction-handoff.ts +304 -0
  75. package/src/pantheon/cost-command.ts +356 -69
  76. package/src/pantheon/delegate-manager.ts +766 -0
  77. package/src/pantheon/delegation-classifier.ts +307 -0
  78. package/src/pantheon/delegation-enforce.ts +13 -12
  79. package/src/pantheon/delegation-finalize.ts +406 -0
  80. package/src/pantheon/delegation-notify.ts +212 -0
  81. package/src/pantheon/delegation-toast.ts +73 -0
  82. package/src/pantheon/delegation.ts +1337 -0
  83. package/src/pantheon/file-persistence-salvage.ts +64 -0
  84. package/src/pantheon/file-persistence.ts +194 -45
  85. package/src/pantheon/goal-loop.ts +2 -2
  86. package/src/pantheon/goal-store.ts +1 -1
  87. package/src/pantheon/hashline/tool.ts +2 -2
  88. package/src/pantheon/model-command.ts +2 -1
  89. package/src/pantheon/native-probe.ts +234 -0
  90. package/src/pantheon/native-task-status.ts +139 -0
  91. package/src/pantheon/permission-eval.ts +284 -0
  92. package/src/pantheon/provider-capabilities.ts +221 -0
  93. package/src/pantheon/provider-stream-tracker.ts +182 -0
  94. package/src/pantheon/shared-board.ts +37 -0
  95. package/src/pantheon/step-cap.ts +110 -0
  96. package/src/pantheon/task-result-guard.ts +107 -0
  97. package/src/pantheon/token-opt.ts +689 -0
  98. package/src/pantheon/tool-ceiling.ts +159 -0
  99. package/src/pantheon/toon-codec.ts +481 -0
  100. package/src/pantheon/tui-version-check.ts +1 -3
  101. package/src/pantheon/v2-bridge.ts +129 -0
  102. package/src/pantheon/v2-events.ts +148 -0
  103. package/src/pantheon/v2-hooks.ts +287 -0
  104. package/src/pantheon/v2-tool-definitions.ts +331 -0
  105. package/src/pantheon/zeus-delegate-with-retry.ts +406 -0
  106. package/src/plugin-v2/index.ts +21 -0
  107. package/src/plugin-v2.ts +824 -0
  108. package/src/plugin.ts +261 -51
  109. package/src/plugins/pantheon-hooks.ts +51 -0
  110. package/src/plugins/tui/dist/tui.d.ts +116 -24
  111. package/src/plugins/tui/dist/tui.d.ts.map +1 -1
  112. package/src/plugins/tui/dist/tui.js +416 -149
  113. package/src/plugins/tui/dist/tui.tsx +456 -87
  114. package/src/plugins/tui/package-lock.json +51 -51
  115. package/src/plugins/tui/package.json +7 -7
  116. package/src/plugins/tui/src/index.tsx +462 -95
  117. package/src/skills/README.md +0 -2
  118. package/src/pantheon/tool-context.ts +0 -41
@@ -1,50 +1,63 @@
1
1
  /**
2
- * Cost Command (Wave 4, PR #46) — `pantheon_cost` structural tool: cost +
3
- * token visibility by agent, 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
15
12
  * deliberately preserves V1 behavior. The tool is wired in
16
- * plugin.ts (usable by zeus and any agent with tool access — no routing change
17
- * needed).
13
+ * plugin.ts alongside the delegation toolset (usable by zeus and any agent
14
+ * with tool access — no routing change needed).
18
15
  *
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
- import type { ToolContextLike } from './tool-context.ts'
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
 
47
- /** One cost tool: description + zod args shape + execute. */
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
+
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
50
63
  args: Args
@@ -62,7 +75,7 @@ const costArgs = {
62
75
  .min(1)
63
76
  .max(365)
64
77
  .optional()
65
- .describe('Number of days of cost history to include (default 7).'),
78
+ .describe('Number of days of delegation history to include (default 7).'),
66
79
  } satisfies z.ZodRawShape
67
80
 
68
81
  /** Return the safe, isolated state DB name for an OpenCode version. */
@@ -123,38 +136,30 @@ 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)
132
148
  const since = Date.now() - days * 86_400_000
133
- const rows = db.prepare('SELECT data FROM message WHERE time_created >= ?').all(since)
134
- return aggregateMessages(rows.map((row) => String(row.data)))
149
+ const rows = db
150
+ .prepare('SELECT data FROM message WHERE time_created >= ?')
151
+ .all(since) as Array<{ data?: unknown }>
152
+ // Rows without a data payload carry no cost info — skip them instead of
153
+ // coercing null/undefined into "null"/"undefined" strings.
154
+ return aggregateMessages(rows.flatMap((row) => (row?.data == null ? [] : [String(row.data)])))
135
155
  } finally {
136
156
  db.close()
137
157
  }
138
158
  }
139
159
 
140
- /** Fallback: delegate the same aggregation to scripts/cost.mjs (node:sqlite). */
141
- async function queryWithScript(
142
- scriptPath: string,
143
- dbPath: string,
144
- days: number,
145
- ): Promise<CostRow[]> {
146
- const { stdout } = await execFileAsync(process.execPath, [scriptPath, dbPath, String(days)], {
147
- timeout: 15_000,
148
- encoding: 'utf8',
149
- })
150
- const parsed = JSON.parse(stdout) as { ok: boolean; rows?: CostRow[]; error?: string }
151
- if (!parsed.ok) throw new Error(parsed.error ?? 'cost.mjs failed')
152
- return parsed.rows ?? []
153
- }
154
-
155
- /** 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. */
156
161
  function aggregateMessages(dataValues: readonly string[]): CostRow[] {
157
- const byAgent = new Map<string, CostRow>()
162
+ const byAgentAndPhase = new Map<string, CostRow>()
158
163
  for (const value of dataValues) {
159
164
  try {
160
165
  const outer = JSON.parse(value) as Record<string, unknown>
@@ -165,81 +170,363 @@ function aggregateMessages(dataValues: readonly string[]): CostRow[] {
165
170
  if (info.role !== 'assistant') continue
166
171
  const agent = typeof info.agent === 'string' && info.agent !== '' ? info.agent : null
167
172
  if (agent === null) continue
168
- 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'
169
178
  const tokens = (info.tokens ?? {}) as Record<string, unknown>
170
179
  const tokensInput = Number(tokens.input) || 0
171
180
  const tokensOutput = Number(tokens.output) || 0
172
- const acc = byAgent.get(agent) ?? { agent, costUsd: 0, tokensInput: 0, tokensOutput: 0 }
173
- 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
+ }
174
189
  acc.tokensInput += tokensInput
175
190
  acc.tokensOutput += tokensOutput
176
- byAgent.set(agent, acc)
191
+ acc.tokensTotal += tokensInput + tokensOutput
192
+ byAgentAndPhase.set(key, acc)
177
193
  } catch {
178
194
  // Skip malformed rows — a partial ledger never breaks the report.
179
195
  }
180
196
  }
181
- return [...byAgent.values()].sort((a, b) => b.costUsd - a.costUsd)
197
+ return [...byAgentAndPhase.values()].sort((a, b) => b.tokensTotal - a.tokensTotal)
182
198
  }
183
199
 
184
200
  /** Render the aggregate as a markdown table with a total row. */
185
201
  function renderMarkdown(rows: readonly CostRow[], days: number): string {
186
202
  if (rows.length === 0) {
187
- 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.`
188
204
  }
189
205
  const total = rows.reduce(
190
206
  (acc, r) => {
191
- acc.costUsd += r.costUsd
192
207
  acc.tokensInput += r.tokensInput
193
208
  acc.tokensOutput += r.tokensOutput
209
+ acc.tokensTotal += r.tokensTotal
194
210
  return acc
195
211
  },
196
- { costUsd: 0, tokensInput: 0, tokensOutput: 0 },
212
+ { tokensInput: 0, tokensOutput: 0, tokensTotal: 0 },
197
213
  )
198
214
  const lines = [
199
- `## Cost by agent (last ${days} days)`,
215
+ `## Token usage by agent and phase (last ${days} days)`,
200
216
  '',
201
- '| Agent | Cost (USD) | Tokens In | Tokens Out |',
202
- '|-------|-----------:|----------:|-----------:|',
217
+ '| Agent | Phase | Tokens In | Tokens Out | Tokens Total |',
218
+ '|-------|-------|----------:|-----------:|-------------:|',
203
219
  ...rows.map(
204
- (r) => `| ${r.agent} | $${r.costUsd.toFixed(6)} | ${r.tokensInput} | ${r.tokensOutput} |`,
220
+ (r) =>
221
+ `| ${r.agent} | ${r.phase} | ${r.tokensInput} | ${r.tokensOutput} | ${r.tokensTotal} |`,
205
222
  ),
206
- `| **Total** | **$${total.costUsd.toFixed(6)}** | **${total.tokensInput}** | **${total.tokensOutput}** |`,
223
+ `| **Total** | — | **${total.tokensInput}** | **${total.tokensOutput}** | **${total.tokensTotal}** |`,
207
224
  '',
208
225
  `Source: opencode.db (read-only)`,
209
226
  ]
210
227
  return lines.join('\n')
211
228
  }
212
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
+
213
454
  export function createCostCommand(options?: CostCommandOptions): CostCommand {
214
- 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
+ }
215
491
 
216
492
  const execute = async (args: { days?: number }, _ctx: ToolContextLike): Promise<string> => {
217
- const days = args.days ?? 7
493
+ const days = args?.days ?? 7
218
494
  try {
219
495
  const dbPath = findExistingDb(options?.dbPath)
220
496
  if (dbPath === undefined) {
221
497
  const expected = resolveCostDbPath(options)
222
- return (
223
- `pantheon_cost failed: opencode.db not found (expected at ${expected}). ` +
224
- `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.`,
225
501
  )
226
502
  }
227
- const rows = await queryWithNodeSqlite(dbPath, days).catch(async (err: unknown) => {
228
- if (err instanceof Error && err.message.startsWith('incompatible opencode.db schema'))
229
- throw err
230
- return queryWithScript(scriptPath, dbPath, days)
231
- })
232
- 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)}`
233
521
  } catch (err: unknown) {
234
- const reason = err instanceof Error ? err.message : String(err)
235
- return `pantheon_cost failed: ${reason}`
522
+ return failure(statusForError(err), diagnostic(err))
236
523
  }
237
524
  }
238
525
 
239
526
  return {
240
527
  pantheon_cost: {
241
528
  description:
242
- 'Report 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).',
243
530
  args: costArgs,
244
531
  execute,
245
532
  },