pantheon-opencode 1.5.0-beta.1 → 1.5.0-beta.11
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/manifest.json +21 -0
- package/CHANGELOG.md +1054 -0
- package/README.md +171 -16
- package/README.pt-BR.md +99 -13
- package/bin/pantheon-init.mjs +178 -21
- package/package.json +23 -15
- package/plugin.json +1 -1
- package/pyproject.toml +65 -0
- package/scripts/code_mode_server.py +431 -55
- package/scripts/cost.mjs +30 -14
- package/scripts/dev-tui.sh +48 -0
- package/scripts/doctor.mjs +207 -9
- package/scripts/install/cli-ui.mjs +17 -11
- package/scripts/install/config-migration.mjs +4 -6
- package/scripts/install/opencode.mjs +137 -35
- package/scripts/install/plugin.mjs +3 -33
- package/scripts/install/shared.mjs +43 -2
- package/scripts/install/strings.mjs +129 -0
- package/scripts/install-mcp.mjs +1 -0
- package/scripts/manifest-inventory.mjs +410 -0
- package/scripts/mcp_persistence_server.py +537 -128
- 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/release-beta-version.mjs +56 -60
- package/scripts/sync-artifacts.mjs +169 -0
- package/scripts/sync-tui.mjs +121 -53
- package/scripts/test-opencode-v1-v2-sandbox.sh +367 -96
- package/scripts/validation-policy.mjs +7 -7
- package/scripts/version-check.mjs +113 -108
- package/scripts/versioning.mjs +230 -86
- package/src/agents/mnemosyne.md +1 -1
- package/src/agents/zeus.md +2 -2
- package/src/mcp/code_mode_server.py +431 -55
- package/src/mcp/mcp_persistence_server.py +537 -128
- package/src/pantheon/auto-wake.ts +1 -5
- package/src/pantheon/background-job-board.ts +43 -1
- package/src/pantheon/compaction-handoff.ts +304 -0
- package/src/pantheon/cost-command.ts +345 -62
- package/src/pantheon/delegate-manager.ts +390 -8
- package/src/pantheon/delegation-finalize.ts +32 -0
- package/src/pantheon/delegation.ts +235 -18
- package/src/pantheon/file-persistence-salvage.ts +64 -0
- package/src/pantheon/file-persistence.ts +194 -45
- package/src/pantheon/native-probe.ts +234 -0
- package/src/pantheon/native-task-status.ts +139 -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/task-result-guard.ts +107 -0
- package/src/pantheon/token-opt.ts +146 -12
- package/src/pantheon/tool-ceiling.ts +159 -0
- package/src/pantheon/v2-events.ts +18 -1
- package/src/plugin-v2.ts +2 -14
- package/src/plugin.ts +28 -17
- package/src/plugins/pantheon-hooks.ts +51 -0
- package/src/plugins/tui/dist/server.d.ts +1 -1
- package/src/plugins/tui/dist/server.d.ts.map +1 -1
- package/src/plugins/tui/dist/server.js +1 -1
- package/src/plugins/tui/dist/server.js.map +1 -1
- package/src/plugins/tui/dist/tui.d.ts +137 -27
- package/src/plugins/tui/dist/tui.d.ts.map +1 -1
- package/src/plugins/tui/dist/tui.js +459 -163
- package/src/plugins/tui/dist/tui.js.map +1 -1
- package/src/plugins/tui/dist/tui.tsx +506 -103
- package/src/plugins/tui/package-lock.json +2 -2
- package/src/plugins/tui/package.json +1 -1
- package/src/plugins/tui/src/index.tsx +506 -103
- package/.pantheon/code-mode/.gitkeep +0 -0
- package/.pantheon/code-mode/.pantheon/deepwork/initial-token-40k/checkpoint-1.json +0 -8
- package/.pantheon/code-mode/.pantheon/deepwork/initial-token-40k/checkpoint-10.json +0 -8
- package/.pantheon/code-mode/.pantheon/deepwork/initial-token-40k/checkpoint-11.json +0 -8
- package/.pantheon/code-mode/.pantheon/deepwork/initial-token-40k/checkpoint-2.json +0 -8
- package/.pantheon/code-mode/.pantheon/deepwork/initial-token-40k/checkpoint-3.json +0 -8
- package/.pantheon/code-mode/.pantheon/deepwork/initial-token-40k/checkpoint-4.json +0 -8
- package/.pantheon/code-mode/.pantheon/deepwork/initial-token-40k/checkpoint-5.json +0 -8
- package/.pantheon/code-mode/.pantheon/deepwork/initial-token-40k/checkpoint-6.json +0 -8
- package/.pantheon/code-mode/.pantheon/deepwork/initial-token-40k/checkpoint-7.json +0 -8
- package/.pantheon/code-mode/.pantheon/deepwork/initial-token-40k/checkpoint-8.json +0 -8
- package/.pantheon/code-mode/.pantheon/deepwork/initial-token-40k/checkpoint-9.json +0 -8
- package/.pantheon/code-mode/.pantheon/deepwork/initial-token-40k/heartbeat.json +0 -8
- package/.pantheon/code-mode/.pantheon/deepwork/initial-token-40k/session.json +0 -11
- package/src/pantheon/delegate-task-adapter.ts +0 -444
|
@@ -1,14 +1,11 @@
|
|
|
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
|
|
@@ -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 {
|
|
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
|
|
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
|
|
|
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
|
|
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)
|
|
@@ -141,24 +157,9 @@ async function queryWithNodeSqlite(dbPath: string, days: number): Promise<CostRo
|
|
|
141
157
|
}
|
|
142
158
|
}
|
|
143
159
|
|
|
144
|
-
/**
|
|
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
|
|
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
|
|
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
|
|
177
|
-
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
|
+
}
|
|
178
189
|
acc.tokensInput += tokensInput
|
|
179
190
|
acc.tokensOutput += tokensOutput
|
|
180
|
-
|
|
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 [...
|
|
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 `##
|
|
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
|
-
{
|
|
212
|
+
{ tokensInput: 0, tokensOutput: 0, tokensTotal: 0 },
|
|
201
213
|
)
|
|
202
214
|
const lines = [
|
|
203
|
-
`##
|
|
215
|
+
`## Token usage by agent and phase (last ${days} days)`,
|
|
204
216
|
'',
|
|
205
|
-
'| Agent |
|
|
206
|
-
'
|
|
217
|
+
'| Agent | Phase | Tokens In | Tokens Out | Tokens Total |',
|
|
218
|
+
'|-------|-------|----------:|-----------:|-------------:|',
|
|
207
219
|
...rows.map(
|
|
208
|
-
(r) =>
|
|
220
|
+
(r) =>
|
|
221
|
+
`| ${r.agent} | ${r.phase} | ${r.tokensInput} | ${r.tokensOutput} | ${r.tokensTotal} |`,
|
|
209
222
|
),
|
|
210
|
-
`| **Total** |
|
|
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
|
|
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
|
|
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
|
-
|
|
228
|
-
`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.`,
|
|
229
501
|
)
|
|
230
502
|
}
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
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
|
-
|
|
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
|
|
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
|
},
|