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.
- package/.pantheon/code-mode/README.md +93 -0
- package/.pantheon/code-mode/checkpoint-session.py +7 -0
- package/.pantheon/code-mode/checkpoint-session.sh +11 -0
- package/.pantheon/code-mode/checkpoint_session.py +481 -0
- package/.pantheon/code-mode/compress-inline.py +450 -0
- package/.pantheon/code-mode/eval-llm-judge.py +255 -0
- package/.pantheon/code-mode/eval-monte-carlo.py +368 -0
- package/.pantheon/code-mode/eval-run.py +197 -0
- package/.pantheon/code-mode/eval-static.py +321 -0
- package/.pantheon/code-mode/example-sync.sh +5 -0
- package/.pantheon/code-mode/manifest.json +21 -0
- package/.pantheon/code-mode/session-end-save.py +186 -0
- package/.pantheon/code-mode/session-end-save.sh +18 -0
- package/.pantheon/code-mode/test-allowed.sh +5 -0
- package/.pantheon/code-mode/test-auto-continue-checkpoint.sh +179 -0
- package/.pantheon/code-mode/test-env.sh +7 -0
- package/.pantheon/code-mode/test-prlimit.sh +9 -0
- package/.pantheon/code-mode/test-timeout.sh +6 -0
- package/CHANGELOG.md +1026 -0
- package/README.md +292 -1080
- package/README.pt-BR.md +308 -0
- package/bin/pantheon-init.mjs +207 -31
- package/opencode.json +1 -0
- package/package.json +34 -14
- package/plugin.json +15 -0
- package/pyproject.toml +65 -0
- package/scripts/_pantheon_paths.py +48 -7
- package/scripts/ci-validate-yaml.py +7 -2
- package/scripts/code_mode_server.py +451 -44
- package/scripts/cost.mjs +30 -14
- package/scripts/doctor.mjs +306 -129
- package/scripts/generate-preset-docs.mjs +127 -66
- package/scripts/generate-prompts.sh +2 -2
- package/scripts/install/cli-ui.mjs +23 -10
- package/scripts/install/config-migration.mjs +399 -0
- package/scripts/install/health-check.mjs +3 -3
- package/scripts/install/model-picker.mjs +72 -23
- package/scripts/install/opencode-version.mjs +47 -0
- package/scripts/install/opencode.mjs +396 -121
- package/scripts/install/plugin.mjs +89 -49
- package/scripts/install/shared.mjs +55 -159
- package/scripts/install/strings.mjs +129 -0
- package/scripts/install-mcp.mjs +45 -232
- package/scripts/manifest-inventory.mjs +410 -0
- package/scripts/mcp_persistence_server.py +798 -60
- package/scripts/mcp_resources_server.py +144 -40
- package/scripts/memory_mcp_server.py +2 -2
- package/scripts/package-evidence.mjs +242 -0
- package/scripts/probe-context-rehydrate.mjs +240 -0
- package/scripts/probe-pantheon-cost.mjs +313 -0
- package/scripts/recover-zenodo-deposition.mjs +146 -0
- package/scripts/release-beta-version.mjs +56 -60
- package/scripts/sync-artifacts.mjs +169 -0
- package/scripts/sync-tui.mjs +135 -42
- package/scripts/test-opencode-v1-v2-sandbox.sh +862 -0
- package/scripts/uninstall.mjs +298 -954
- package/scripts/validate-zenodo-release.mjs +154 -0
- package/scripts/validation-policy.mjs +7 -7
- package/scripts/version-check.mjs +113 -108
- package/scripts/versioning.mjs +230 -86
- package/scripts/zenodo-artifact.mjs +21 -0
- package/scripts/zenodo-release-state.mjs +53 -0
- package/src/agents/mnemosyne.md +1 -1
- package/src/agents/zeus.md +27 -16
- package/src/mcp/_pantheon_paths.py +48 -7
- package/src/mcp/code_mode_server.py +451 -44
- package/src/mcp/eval_store.py +1 -1
- package/src/mcp/mcp_persistence_server.py +798 -60
- package/src/mcp/mcp_resources_server.py +144 -40
- package/src/mcp/pantheon_vision_server.py +1 -1
- package/src/mcp/toon_codec.py +491 -0
- package/src/pantheon/auto-wake.ts +1 -5
- package/src/pantheon/background-job-board.ts +110 -2
- package/src/pantheon/compaction-handoff.ts +304 -0
- package/src/pantheon/cost-command.ts +356 -69
- package/src/pantheon/delegate-manager.ts +766 -0
- package/src/pantheon/delegation-classifier.ts +307 -0
- package/src/pantheon/delegation-enforce.ts +13 -12
- package/src/pantheon/delegation-finalize.ts +406 -0
- package/src/pantheon/delegation-notify.ts +212 -0
- package/src/pantheon/delegation-toast.ts +73 -0
- package/src/pantheon/delegation.ts +1337 -0
- package/src/pantheon/file-persistence-salvage.ts +64 -0
- package/src/pantheon/file-persistence.ts +194 -45
- package/src/pantheon/goal-loop.ts +2 -2
- package/src/pantheon/goal-store.ts +1 -1
- package/src/pantheon/hashline/tool.ts +2 -2
- package/src/pantheon/model-command.ts +2 -1
- package/src/pantheon/native-probe.ts +234 -0
- package/src/pantheon/native-task-status.ts +139 -0
- package/src/pantheon/permission-eval.ts +284 -0
- package/src/pantheon/provider-capabilities.ts +221 -0
- package/src/pantheon/provider-stream-tracker.ts +182 -0
- package/src/pantheon/shared-board.ts +37 -0
- package/src/pantheon/step-cap.ts +110 -0
- package/src/pantheon/task-result-guard.ts +107 -0
- package/src/pantheon/token-opt.ts +689 -0
- package/src/pantheon/tool-ceiling.ts +159 -0
- package/src/pantheon/toon-codec.ts +481 -0
- package/src/pantheon/tui-version-check.ts +1 -3
- package/src/pantheon/v2-bridge.ts +129 -0
- package/src/pantheon/v2-events.ts +148 -0
- package/src/pantheon/v2-hooks.ts +287 -0
- package/src/pantheon/v2-tool-definitions.ts +331 -0
- package/src/pantheon/zeus-delegate-with-retry.ts +406 -0
- package/src/plugin-v2/index.ts +21 -0
- package/src/plugin-v2.ts +824 -0
- package/src/plugin.ts +261 -51
- package/src/plugins/pantheon-hooks.ts +51 -0
- package/src/plugins/tui/dist/tui.d.ts +116 -24
- package/src/plugins/tui/dist/tui.d.ts.map +1 -1
- package/src/plugins/tui/dist/tui.js +416 -149
- package/src/plugins/tui/dist/tui.tsx +456 -87
- package/src/plugins/tui/package-lock.json +51 -51
- package/src/plugins/tui/package.json +7 -7
- package/src/plugins/tui/src/index.tsx +462 -95
- package/src/skills/README.md +0 -2
- package/src/pantheon/tool-context.ts +0 -41
|
@@ -1,50 +1,63 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
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
|
|
6
|
-
*
|
|
7
|
-
* -
|
|
8
|
-
* -
|
|
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
|
|
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 {
|
|
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 './
|
|
25
|
+
import type { ToolContextLike } from './delegation.ts'
|
|
26
|
+
import type { NativeTaskStatus } from './native-task-status.ts'
|
|
29
27
|
|
|
30
|
-
const
|
|
28
|
+
const __filename = fileURLToPath(import.meta.url)
|
|
29
|
+
const __dirname = dirname(__filename)
|
|
31
30
|
|
|
32
|
-
/** One aggregated
|
|
31
|
+
/** One aggregated token row (per agent and phase). */
|
|
33
32
|
export interface CostRow {
|
|
34
33
|
agent: string
|
|
35
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
127
|
-
async function queryWithNodeSqlite(
|
|
128
|
-
|
|
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
|
|
134
|
-
|
|
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
|
-
/**
|
|
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
|
|
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
|
|
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
|
|
173
|
-
acc.
|
|
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
|
-
|
|
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 [...
|
|
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 `##
|
|
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
|
-
{
|
|
212
|
+
{ tokensInput: 0, tokensOutput: 0, tokensTotal: 0 },
|
|
197
213
|
)
|
|
198
214
|
const lines = [
|
|
199
|
-
`##
|
|
215
|
+
`## Token usage by agent and phase (last ${days} days)`,
|
|
200
216
|
'',
|
|
201
|
-
'| Agent |
|
|
202
|
-
'
|
|
217
|
+
'| Agent | Phase | Tokens In | Tokens Out | Tokens Total |',
|
|
218
|
+
'|-------|-------|----------:|-----------:|-------------:|',
|
|
203
219
|
...rows.map(
|
|
204
|
-
(r) =>
|
|
220
|
+
(r) =>
|
|
221
|
+
`| ${r.agent} | ${r.phase} | ${r.tokensInput} | ${r.tokensOutput} | ${r.tokensTotal} |`,
|
|
205
222
|
),
|
|
206
|
-
`| **Total** |
|
|
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
|
|
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
|
|
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
|
-
|
|
224
|
-
`No
|
|
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
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
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
|
-
|
|
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
|
|
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
|
},
|