pantheon-opencode 1.5.0-beta.2 → 1.5.0-beta.20
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/AGENTS.md +14 -63
- package/CHANGELOG.md +1260 -0
- package/README.md +162 -23
- package/README.pt-BR.md +104 -19
- package/bin/pantheon-init.mjs +178 -21
- package/commands/pantheon.md +1 -35
- package/package.json +23 -15
- package/plugin.json +1 -1
- package/pyproject.toml +62 -0
- package/scripts/code_mode_server.py +431 -55
- 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 +12 -45
- 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 +536 -164
- package/scripts/package-evidence.mjs +242 -0
- package/scripts/probe-pantheon-cost.mjs +10 -1
- package/scripts/release-beta-version.mjs +56 -60
- package/scripts/sync-artifacts.mjs +169 -0
- package/scripts/sync-tui.mjs +129 -64
- package/scripts/test-opencode-v1-v2-sandbox.sh +79 -161
- package/scripts/validation-policy.mjs +7 -7
- package/scripts/version-check.mjs +113 -108
- package/scripts/versioning.mjs +230 -86
- package/src/agents/apollo.md +1 -0
- package/src/agents/gaia.md +1 -0
- package/src/agents/mnemosyne.md +1 -1
- package/src/agents/zeus.md +11 -54
- package/src/instructions/memory-protocol.instructions.md +6 -3
- package/src/instructions/zeus-anti-stall.instructions.md +2 -4
- package/src/instructions/zeus-council-synthesis.instructions.md +3 -28
- package/src/instructions/zeus-timeout-retry.instructions.md +3 -28
- package/src/mcp/code_mode_server.py +431 -55
- package/src/mcp/mcp_persistence_server.py +536 -164
- package/src/mcp/toon_codec.py +2 -4
- package/src/pantheon/background-job-board.ts +47 -5
- package/src/pantheon/{delegation-compaction.ts → compaction-context.ts} +10 -100
- package/src/pantheon/cost-command.ts +309 -44
- package/src/pantheon/delegation-enforce.ts +59 -14
- package/src/pantheon/file-persistence-salvage.ts +64 -0
- package/src/pantheon/file-persistence.ts +194 -45
- package/src/pantheon/goal-loop.ts +1 -1
- package/src/pantheon/goal-store.ts +1 -1
- package/src/pantheon/idle-continuation.ts +4 -6
- package/src/pantheon/model-command.ts +1 -2
- package/src/pantheon/native-task-status.ts +139 -0
- package/src/pantheon/presets.d.mts +0 -20
- package/src/pantheon/presets.mjs +0 -64
- package/src/pantheon/session-guard.ts +2 -2
- package/src/pantheon/session-id.ts +30 -0
- package/src/pantheon/shared-board.ts +37 -0
- package/src/pantheon/task-result-guard.ts +106 -0
- package/src/pantheon/todo-enforcer.ts +3 -3
- package/src/pantheon/tool-ceiling.ts +159 -0
- package/src/pantheon/tool-context.ts +23 -0
- package/src/pantheon/v2-bridge.ts +1 -4
- package/src/plugin-v2.ts +8 -21
- package/src/plugin.ts +83 -251
- 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 +261 -77
- package/src/plugins/tui/dist/tui.d.ts.map +1 -1
- package/src/plugins/tui/dist/tui.js +640 -234
- package/src/plugins/tui/dist/tui.js.map +1 -1
- package/src/plugins/tui/package-lock.json +2 -2
- package/src/plugins/tui/package.json +2 -2
- package/src/plugins/tui/src/index.tsx +872 -226
- package/src/routing.yml +0 -16
- package/src/skills/context-compression/SKILL.md +1 -1
- package/scripts/generate-preset-docs.mjs +0 -300
- package/scripts/generate-prompts.sh +0 -222
- package/scripts/hash_verify.py +0 -192
- package/scripts/install/component.mjs +0 -24
- package/scripts/install/migrate-config.mjs +0 -302
- package/scripts/test-context-compression.sh +0 -169
- package/src/pantheon/auto-wake.ts +0 -89
- package/src/pantheon/chat-reminders.ts +0 -9
- package/src/pantheon/comment-checker.ts +0 -48
- package/src/pantheon/compaction-assert.ts +0 -160
- package/src/pantheon/cost-tracker.ts +0 -108
- package/src/pantheon/delegate-manager.ts +0 -669
- package/src/pantheon/delegation-classifier.ts +0 -307
- package/src/pantheon/delegation-finalize.ts +0 -374
- package/src/pantheon/delegation-notify.ts +0 -212
- package/src/pantheon/delegation-toast.ts +0 -73
- package/src/pantheon/delegation.ts +0 -1120
- package/src/pantheon/dispatch-guard.ts +0 -109
- package/src/pantheon/permission-eval.ts +0 -284
- package/src/pantheon/preemptive-compact.ts +0 -76
- package/src/pantheon/retry-policy.ts +0 -212
- package/src/pantheon/step-cap.ts +0 -110
- package/src/pantheon/token-opt.ts +0 -781
- package/src/pantheon/toon-codec.ts +0 -481
- package/src/pantheon/v2-events.ts +0 -131
- package/src/pantheon/v2-hooks.ts +0 -287
- package/src/pantheon/v2-tool-definitions.ts +0 -331
- package/src/pantheon/zeus-delegate-with-retry.ts +0 -406
- package/src/plugins/tui/dist/tui.tsx +0 -2605
|
@@ -4,11 +4,8 @@
|
|
|
4
4
|
*
|
|
5
5
|
* Reads the opencode session database READ-ONLY (sum tokens by agent and
|
|
6
6
|
* phase over the last N days → markdown table). Zero new dependencies:
|
|
7
|
-
* -
|
|
8
|
-
* -
|
|
9
|
-
* interface when the direct import is unavailable;
|
|
10
|
-
* - missing/unreadable db → FRIENDLY error string, never a crash (tools
|
|
11
|
-
* return errors as TEXT).
|
|
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,15 +16,16 @@
|
|
|
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'
|
|
24
|
+
import type { NativeTaskStatus } from './native-task-status.ts'
|
|
25
|
+
import type { ToolContextLike } from './tool-context.ts'
|
|
27
26
|
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
const execFileAsync = promisify(execFile)
|
|
27
|
+
const __filename = fileURLToPath(import.meta.url)
|
|
28
|
+
const __dirname = dirname(__filename)
|
|
31
29
|
|
|
32
30
|
/** One aggregated token row (per agent and phase). */
|
|
33
31
|
export interface CostRow {
|
|
@@ -41,10 +39,23 @@ export interface CostRow {
|
|
|
41
39
|
export interface CostCommandOptions {
|
|
42
40
|
/** Explicit db path (testability / unusual installs). Default: resolved candidates. */
|
|
43
41
|
dbPath?: string
|
|
42
|
+
/** Injectable loader for tests; production always loads node:sqlite. */
|
|
43
|
+
sqliteLoader?: () => Promise<SqliteModule>
|
|
44
|
+
/** Injectable CLI-fallback process boundary (tests / unusual runtimes). */
|
|
45
|
+
cliFallback?: CliFallbackDeps
|
|
44
46
|
}
|
|
45
47
|
|
|
46
48
|
export type OpenCodeVersion = 'v1' | 'v2'
|
|
47
49
|
|
|
50
|
+
interface SqliteDatabase {
|
|
51
|
+
prepare(sql: string): { get(): unknown; all(...args: unknown[]): unknown[] }
|
|
52
|
+
close(): void
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface SqliteModule {
|
|
56
|
+
DatabaseSync: new (path: string, options: { readOnly: boolean }) => SqliteDatabase
|
|
57
|
+
}
|
|
58
|
+
|
|
48
59
|
/** One cost tool: description + zod args shape + execute (delegation.ts shape). */
|
|
49
60
|
export interface CostTool<Args extends z.ZodRawShape = typeof costArgs> {
|
|
50
61
|
description: string
|
|
@@ -125,8 +136,11 @@ function assertCompatibleSchema(db: {
|
|
|
125
136
|
}
|
|
126
137
|
|
|
127
138
|
/** Aggregate tokens by agent and phase via node:sqlite (read-only). */
|
|
128
|
-
async function queryWithNodeSqlite(
|
|
129
|
-
|
|
139
|
+
async function queryWithNodeSqlite(
|
|
140
|
+
dbPath: string,
|
|
141
|
+
days: number,
|
|
142
|
+
sqlite: SqliteModule,
|
|
143
|
+
): Promise<CostRow[]> {
|
|
130
144
|
const db = new sqlite.DatabaseSync(dbPath, { readOnly: true })
|
|
131
145
|
try {
|
|
132
146
|
assertCompatibleSchema(db)
|
|
@@ -142,25 +156,6 @@ async function queryWithNodeSqlite(dbPath: string, days: number): Promise<CostRo
|
|
|
142
156
|
}
|
|
143
157
|
}
|
|
144
158
|
|
|
145
|
-
/** CLI fallback: delegate the same aggregation to scripts/cost.mjs. */
|
|
146
|
-
async function queryWithScript(
|
|
147
|
-
scriptPath: string,
|
|
148
|
-
dbPath: string,
|
|
149
|
-
days: number,
|
|
150
|
-
): Promise<CostRow[]> {
|
|
151
|
-
const { stdout } = await execFileAsync(
|
|
152
|
-
process.execPath,
|
|
153
|
-
[scriptPath, '--db-path', dbPath, '--days', String(days)],
|
|
154
|
-
{
|
|
155
|
-
timeout: 15_000,
|
|
156
|
-
encoding: 'utf8',
|
|
157
|
-
},
|
|
158
|
-
)
|
|
159
|
-
const parsed = JSON.parse(stdout) as { ok: boolean; rows?: CostRow[]; error?: string }
|
|
160
|
-
if (!parsed.ok) throw new Error(parsed.error ?? 'cost.mjs failed')
|
|
161
|
-
return parsed.rows ?? []
|
|
162
|
-
}
|
|
163
|
-
|
|
164
159
|
/** Parse message.data JSON rows and aggregate tokens by assistant agent/phase. */
|
|
165
160
|
function aggregateMessages(dataValues: readonly string[]): CostRow[] {
|
|
166
161
|
const byAgentAndPhase = new Map<string, CostRow>()
|
|
@@ -231,8 +226,267 @@ function renderMarkdown(rows: readonly CostRow[], days: number): string {
|
|
|
231
226
|
return lines.join('\n')
|
|
232
227
|
}
|
|
233
228
|
|
|
229
|
+
// ─── CLI fallback: resolve a REAL Node, not the plugin runtime ──────────
|
|
230
|
+
//
|
|
231
|
+
// Under Bun (the runtime that motivates this fallback), process.execPath IS
|
|
232
|
+
// the Bun binary, which has no node:sqlite — so spawning it could never work.
|
|
233
|
+
// Resolve a genuine Node instead: PANTHEON_NODE → `node` on PATH → clear error.
|
|
234
|
+
|
|
235
|
+
/** First Node release exposing the built-in `node:sqlite` module. */
|
|
236
|
+
const NODE_SQLITE_MIN_VERSION = '22.5'
|
|
237
|
+
|
|
238
|
+
/** Minimal injectable child-process boundary (tests / unusual runtimes). */
|
|
239
|
+
export type ExecFileLike = (
|
|
240
|
+
file: string,
|
|
241
|
+
args: readonly string[],
|
|
242
|
+
) => Promise<{ stdout: string; stderr?: string }>
|
|
243
|
+
|
|
244
|
+
/** Injectables so the fallback never depends on the real machine in tests. */
|
|
245
|
+
export interface CliFallbackDeps {
|
|
246
|
+
/** Environment for PANTHEON_NODE / PATH lookup. Default: process.env. */
|
|
247
|
+
env?: NodeJS.ProcessEnv
|
|
248
|
+
/** Child-process boundary. Default: node:child_process.execFile. */
|
|
249
|
+
execFile?: ExecFileLike
|
|
250
|
+
/** Executable predicate used by the PANTHEON_NODE / PATH lookups. */
|
|
251
|
+
isExecutable?: (path: string) => boolean
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/** A real Node executable located for the CLI fallback. */
|
|
255
|
+
export interface ResolvedNode {
|
|
256
|
+
path: string
|
|
257
|
+
source: 'env' | 'path'
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
type ExecFailure = Error & { stdout?: string; stderr?: string }
|
|
261
|
+
|
|
262
|
+
function defaultIsExecutable(path: string): boolean {
|
|
263
|
+
try {
|
|
264
|
+
accessSync(path, constants.X_OK)
|
|
265
|
+
return statSync(path).isFile()
|
|
266
|
+
} catch {
|
|
267
|
+
return false
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
function defaultExecFile(
|
|
272
|
+
file: string,
|
|
273
|
+
args: readonly string[],
|
|
274
|
+
): Promise<{ stdout: string; stderr: string }> {
|
|
275
|
+
return new Promise((resolve, reject) => {
|
|
276
|
+
execFile(file, [...args], { encoding: 'utf8' }, (error, stdout, stderr) => {
|
|
277
|
+
if (error) {
|
|
278
|
+
// execFile's callback error does not always carry the captured output;
|
|
279
|
+
// attach it so callers can surface cost.mjs's structured {ok:false}.
|
|
280
|
+
const failure = error as ExecFailure
|
|
281
|
+
if (typeof failure.stdout !== 'string') failure.stdout = stdout
|
|
282
|
+
if (typeof failure.stderr !== 'string') failure.stderr = stderr
|
|
283
|
+
reject(failure)
|
|
284
|
+
return
|
|
285
|
+
}
|
|
286
|
+
resolve({ stdout, stderr })
|
|
287
|
+
})
|
|
288
|
+
})
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/** Scan PATH (no shell) for a `node` executable. */
|
|
292
|
+
export function resolveNodeFromPath(
|
|
293
|
+
env: NodeJS.ProcessEnv,
|
|
294
|
+
isExecutable: (path: string) => boolean = defaultIsExecutable,
|
|
295
|
+
): string | undefined {
|
|
296
|
+
const pathValue = env.PATH ?? env.Path ?? ''
|
|
297
|
+
if (pathValue === '') return undefined
|
|
298
|
+
const isWindows = process.platform === 'win32'
|
|
299
|
+
const extensions = isWindows
|
|
300
|
+
? (env.PATHEXT ?? '.EXE;.CMD;.BAT;.COM').split(';').filter((ext) => ext !== '')
|
|
301
|
+
: ['']
|
|
302
|
+
for (const dir of pathValue.split(delimiter)) {
|
|
303
|
+
if (dir === '') continue
|
|
304
|
+
for (const ext of extensions) {
|
|
305
|
+
const candidate = join(dir, `node${ext}`)
|
|
306
|
+
if (isExecutable(candidate)) return candidate
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
return undefined
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/**
|
|
313
|
+
* Resolve a REAL Node.js binary for the CLI fallback.
|
|
314
|
+
*
|
|
315
|
+
* Order: PANTHEON_NODE (must exist and be executable — an explicit override
|
|
316
|
+
* that is unusable fails fast instead of being silently ignored) → `node`
|
|
317
|
+
* from PATH. Returns undefined only when neither source is available.
|
|
318
|
+
*/
|
|
319
|
+
export function resolveNodeExecutable(
|
|
320
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
321
|
+
isExecutable: (path: string) => boolean = defaultIsExecutable,
|
|
322
|
+
): ResolvedNode | undefined {
|
|
323
|
+
const explicit = env.PANTHEON_NODE
|
|
324
|
+
if (explicit !== undefined && explicit.trim() !== '') {
|
|
325
|
+
if (isExecutable(explicit)) return { path: explicit, source: 'env' }
|
|
326
|
+
throw new Error(
|
|
327
|
+
`PANTHEON_NODE is set to "${explicit}" but that is not an existing executable file`,
|
|
328
|
+
)
|
|
329
|
+
}
|
|
330
|
+
const fromPath = resolveNodeFromPath(env, isExecutable)
|
|
331
|
+
return fromPath === undefined ? undefined : { path: fromPath, source: 'path' }
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
function execErrorMessage(err: unknown): string {
|
|
335
|
+
if (err instanceof Error) {
|
|
336
|
+
const failure = err as ExecFailure
|
|
337
|
+
const stderr = typeof failure.stderr === 'string' ? failure.stderr.trim() : ''
|
|
338
|
+
if (stderr !== '') return stderr
|
|
339
|
+
return failure.message
|
|
340
|
+
}
|
|
341
|
+
return String(err)
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
function tryParseJson(text: string): unknown {
|
|
345
|
+
try {
|
|
346
|
+
return JSON.parse(text)
|
|
347
|
+
} catch {
|
|
348
|
+
return undefined
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
function truncateForError(text: string): string {
|
|
353
|
+
const trimmed = text.trim()
|
|
354
|
+
return trimmed.length > 200 ? `${trimmed.slice(0, 200)}…` : trimmed
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/** Probe the resolved binary for built-in node:sqlite support. */
|
|
358
|
+
async function assertNodeSqliteSupport(
|
|
359
|
+
nodePath: string,
|
|
360
|
+
execFileAsync: ExecFileLike,
|
|
361
|
+
): Promise<void> {
|
|
362
|
+
try {
|
|
363
|
+
await execFileAsync(nodePath, ['-e', "require('node:sqlite')"])
|
|
364
|
+
} catch (err: unknown) {
|
|
365
|
+
throw new Error(
|
|
366
|
+
`resolved Node binary "${nodePath}" does not support node:sqlite (Node.js >= ${NODE_SQLITE_MIN_VERSION} required): ${execErrorMessage(err)}`,
|
|
367
|
+
)
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
/** Pull the structured failure message cost.mjs prints to stdout (exit 1). */
|
|
372
|
+
function extractScriptFailure(err: unknown): string {
|
|
373
|
+
const failure = err as { stdout?: unknown; stderr?: unknown }
|
|
374
|
+
const stdout = typeof failure.stdout === 'string' ? failure.stdout : ''
|
|
375
|
+
const parsed = tryParseJson(stdout)
|
|
376
|
+
if (parsed !== null && typeof parsed === 'object') {
|
|
377
|
+
const record = parsed as { ok?: unknown; error?: unknown }
|
|
378
|
+
if (record.ok === false && typeof record.error === 'string' && record.error !== '') {
|
|
379
|
+
return record.error
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
const stderr = typeof failure.stderr === 'string' ? failure.stderr.trim() : ''
|
|
383
|
+
if (stderr !== '') return stderr
|
|
384
|
+
return execErrorMessage(err)
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
/**
|
|
388
|
+
* Spawn `node scripts/cost.mjs` as a CLI fallback when node:sqlite is
|
|
389
|
+
* unavailable in the plugin process (e.g. Bun runtime). Returns CostRow[] by
|
|
390
|
+
* parsing the JSON the script writes to stdout.
|
|
391
|
+
*
|
|
392
|
+
* Resolves a REAL Node binary (PANTHEON_NODE → PATH): process.execPath under
|
|
393
|
+
* Bun is the Bun binary, which cannot run node:sqlite. The resolved binary is
|
|
394
|
+
* probed for node:sqlite support and the script is always run read-only with
|
|
395
|
+
* an argument array (never a shell).
|
|
396
|
+
*/
|
|
397
|
+
export async function queryWithCliFallback(
|
|
398
|
+
dbPath: string,
|
|
399
|
+
days: number,
|
|
400
|
+
deps?: CliFallbackDeps,
|
|
401
|
+
): Promise<CostRow[]> {
|
|
402
|
+
const env = deps?.env ?? process.env
|
|
403
|
+
const execFileAsync = deps?.execFile ?? defaultExecFile
|
|
404
|
+
const isExecutable = deps?.isExecutable ?? defaultIsExecutable
|
|
405
|
+
|
|
406
|
+
const costScript = join(__dirname, '..', '..', 'scripts', 'cost.mjs')
|
|
407
|
+
if (!existsSync(costScript)) {
|
|
408
|
+
throw new Error(`CLI fallback script not found: ${costScript}`)
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
const node = resolveNodeExecutable(env, isExecutable)
|
|
412
|
+
if (node === undefined) {
|
|
413
|
+
throw new Error(
|
|
414
|
+
`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`,
|
|
415
|
+
)
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
await assertNodeSqliteSupport(node.path, execFileAsync)
|
|
419
|
+
|
|
420
|
+
let stdout: string
|
|
421
|
+
try {
|
|
422
|
+
;({ stdout } = await execFileAsync(node.path, [
|
|
423
|
+
costScript,
|
|
424
|
+
'--db-path',
|
|
425
|
+
dbPath,
|
|
426
|
+
'--days',
|
|
427
|
+
String(days),
|
|
428
|
+
]))
|
|
429
|
+
} catch (err: unknown) {
|
|
430
|
+
// cost.mjs sets exitCode=1 (execFile rejects) and writes {ok:false,error}
|
|
431
|
+
// to stdout before exiting — surface that structured failure.
|
|
432
|
+
throw new Error(`CLI fallback failed: ${extractScriptFailure(err)}`)
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
const parsed = tryParseJson(stdout)
|
|
436
|
+
if (parsed === null || typeof parsed !== 'object') {
|
|
437
|
+
throw new Error(`CLI fallback returned non-JSON output: ${truncateForError(stdout)}`)
|
|
438
|
+
}
|
|
439
|
+
const record = parsed as { ok?: unknown; rows?: unknown; error?: unknown }
|
|
440
|
+
if (record.ok !== true) {
|
|
441
|
+
const detail =
|
|
442
|
+
typeof record.error === 'string' && record.error !== ''
|
|
443
|
+
? record.error
|
|
444
|
+
: 'unexpected response shape from scripts/cost.mjs'
|
|
445
|
+
throw new Error(`CLI fallback failed: ${detail}`)
|
|
446
|
+
}
|
|
447
|
+
if (!Array.isArray(record.rows)) {
|
|
448
|
+
throw new Error('CLI fallback failed: scripts/cost.mjs response is missing the rows array')
|
|
449
|
+
}
|
|
450
|
+
return record.rows as CostRow[]
|
|
451
|
+
}
|
|
452
|
+
|
|
234
453
|
export function createCostCommand(options?: CostCommandOptions): CostCommand {
|
|
235
|
-
const
|
|
454
|
+
const loadSqlite =
|
|
455
|
+
options?.sqliteLoader ?? (async () => (await import('node:sqlite')) as SqliteModule)
|
|
456
|
+
|
|
457
|
+
const failure = (status: NativeTaskStatus, detail: string): string =>
|
|
458
|
+
`status: ${status}\npantheon_cost failed: ${detail}`
|
|
459
|
+
|
|
460
|
+
const diagnostic = (err: unknown): string => {
|
|
461
|
+
if (err instanceof Error) {
|
|
462
|
+
const stderr = (err as Error & { stderr?: unknown }).stderr
|
|
463
|
+
const suffix =
|
|
464
|
+
typeof stderr === 'string' && stderr.trim() !== '' ? `; stderr: ${stderr.trim()}` : ''
|
|
465
|
+
return `${err.message}${suffix}`
|
|
466
|
+
}
|
|
467
|
+
return String(err)
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
const statusForError = (err: unknown): NativeTaskStatus => {
|
|
471
|
+
const detail = diagnostic(err).toLowerCase()
|
|
472
|
+
if (
|
|
473
|
+
detail.includes('not found') ||
|
|
474
|
+
detail.includes('unknown builtin') ||
|
|
475
|
+
detail.includes('no such built-in') ||
|
|
476
|
+
detail.includes('cannot find module')
|
|
477
|
+
) {
|
|
478
|
+
return 'UNSUPPORTED'
|
|
479
|
+
}
|
|
480
|
+
if (
|
|
481
|
+
detail.includes('not a database') ||
|
|
482
|
+
detail.includes('malformed') ||
|
|
483
|
+
detail.includes('corrupt') ||
|
|
484
|
+
detail.includes('incompatible opencode.db schema')
|
|
485
|
+
) {
|
|
486
|
+
return 'CORRUPT_DATA'
|
|
487
|
+
}
|
|
488
|
+
return 'UNAVAILABLE'
|
|
489
|
+
}
|
|
236
490
|
|
|
237
491
|
const execute = async (args: { days?: number }, _ctx: ToolContextLike): Promise<string> => {
|
|
238
492
|
const days = args?.days ?? 7
|
|
@@ -240,20 +494,31 @@ export function createCostCommand(options?: CostCommandOptions): CostCommand {
|
|
|
240
494
|
const dbPath = findExistingDb(options?.dbPath)
|
|
241
495
|
if (dbPath === undefined) {
|
|
242
496
|
const expected = resolveCostDbPath(options)
|
|
243
|
-
return (
|
|
244
|
-
|
|
245
|
-
`No token history is available until opencode has stored session data
|
|
497
|
+
return failure(
|
|
498
|
+
'UNAVAILABLE',
|
|
499
|
+
`opencode.db not found (expected at ${expected}). No token history is available until opencode has stored session data.`,
|
|
246
500
|
)
|
|
247
501
|
}
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
502
|
+
let sqlite: SqliteModule | undefined
|
|
503
|
+
try {
|
|
504
|
+
sqlite = await loadSqlite()
|
|
505
|
+
} catch (loadErr: unknown) {
|
|
506
|
+
// node:sqlite unavailable (e.g. Bun runtime) — fall back to CLI.
|
|
507
|
+
const status = statusForError(loadErr)
|
|
508
|
+
if (status === 'UNSUPPORTED') {
|
|
509
|
+
try {
|
|
510
|
+
const rows = await queryWithCliFallback(dbPath, days, options?.cliFallback)
|
|
511
|
+
return `status: OK\n${renderMarkdown(rows, days)}`
|
|
512
|
+
} catch (cliErr: unknown) {
|
|
513
|
+
return failure(statusForError(cliErr), diagnostic(cliErr))
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
throw loadErr
|
|
517
|
+
}
|
|
518
|
+
const rows = await queryWithNodeSqlite(dbPath, days, sqlite)
|
|
519
|
+
return `status: OK\n${renderMarkdown(rows, days)}`
|
|
254
520
|
} catch (err: unknown) {
|
|
255
|
-
|
|
256
|
-
return `pantheon_cost failed: ${reason}`
|
|
521
|
+
return failure(statusForError(err), diagnostic(err))
|
|
257
522
|
}
|
|
258
523
|
}
|
|
259
524
|
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Read-Only Enforcement (Phase 4) — deny mutating tools inside sessions that
|
|
3
|
-
* were delegated as read-only (
|
|
4
|
-
*
|
|
3
|
+
* were delegated as read-only (an explicit read-only flag, or the agent ∈
|
|
4
|
+
* `readOnlyAgents` from routing.yml).
|
|
5
5
|
*
|
|
6
6
|
* OpenCode hook: `tool.execute.before`. The hook shape is
|
|
7
7
|
* `(input, output) => Promise<void>`; THROWING from the hook denies the tool
|
|
@@ -22,8 +22,10 @@
|
|
|
22
22
|
* fail-closed when the runtime matrix is wired: missing agent identity or
|
|
23
23
|
* target metadata is denied rather than inferred.
|
|
24
24
|
*
|
|
25
|
-
* The registry is populated by
|
|
26
|
-
*
|
|
25
|
+
* The registry is populated by the plugin's `chat.params` hook — the only
|
|
26
|
+
* place the SDK exposes the active agent: when the session's agent is
|
|
27
|
+
* read-only (apollo, gaia — routing.yml `background_delegation.read_only_agents`),
|
|
28
|
+
* the session is registered; `session.deleted` unregisters it.
|
|
27
29
|
*
|
|
28
30
|
* @module delegation-enforce
|
|
29
31
|
*/
|
|
@@ -147,7 +149,6 @@ export function isDelegationAllowed(
|
|
|
147
149
|
function targetFromTool(tool: string, args: unknown): string | undefined {
|
|
148
150
|
if (args === null || typeof args !== 'object') return undefined
|
|
149
151
|
const record = args as Record<string, unknown>
|
|
150
|
-
if (tool === 'pantheon_delegate') return normalizeDelegationAgent(record.agent)
|
|
151
152
|
if (tool === 'task') {
|
|
152
153
|
return normalizeDelegationAgent(record.subagent_type ?? record.subagentType)
|
|
153
154
|
}
|
|
@@ -236,9 +237,10 @@ export function zeusReadGuard(tool: string, args: unknown, agent: string | undef
|
|
|
236
237
|
// ─── Registry ──────────────────────────────────────────────────────────
|
|
237
238
|
|
|
238
239
|
/**
|
|
239
|
-
* Read-only session registry. `
|
|
240
|
-
* sessions
|
|
241
|
-
*
|
|
240
|
+
* Read-only session registry. The plugin's `chat.params` hook registers
|
|
241
|
+
* sessions whose active agent is read-only (see {@link syncReadOnlySession});
|
|
242
|
+
* the `tool.execute.before` guard consults it. Process-wide singleton for
|
|
243
|
+
* plugin wiring + factory for test isolation.
|
|
242
244
|
*/
|
|
243
245
|
export class ReadOnlySessionRegistry {
|
|
244
246
|
private readonly sessions = new Map<string, ReadOnlyEntry>()
|
|
@@ -279,6 +281,50 @@ export class ReadOnlySessionRegistry {
|
|
|
279
281
|
/** Process-wide registry wired into the plugin. */
|
|
280
282
|
export const readOnlyRegistry = new ReadOnlySessionRegistry()
|
|
281
283
|
|
|
284
|
+
// ─── Read-Only Agent Policy ────────────────────────────────────────────
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* Agents that must stay investigation-only. Mirrors
|
|
288
|
+
* `background_delegation.read_only_agents` in routing.yml — the parity is
|
|
289
|
+
* enforced by tests/pantheon/delegation-enforce.test.ts so the two cannot
|
|
290
|
+
* drift silently.
|
|
291
|
+
*/
|
|
292
|
+
export const READ_ONLY_AGENTS: ReadonlySet<string> = new Set(['apollo', 'gaia'])
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* Whether an agent identity belongs to the read-only set. Identity is never
|
|
296
|
+
* inferred from prompts or tool names — only the normalized agent name counts.
|
|
297
|
+
*/
|
|
298
|
+
export function isReadOnlyAgent(
|
|
299
|
+
agent: unknown,
|
|
300
|
+
readOnlyAgents: ReadonlySet<string> = READ_ONLY_AGENTS,
|
|
301
|
+
): boolean {
|
|
302
|
+
const normalized = normalizeDelegationAgent(agent)
|
|
303
|
+
return normalized !== undefined && readOnlyAgents.has(normalized)
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* Sync the read-only registry with a session's active agent. Called from the
|
|
308
|
+
* plugin's `chat.params` hook: a read-only agent registers the session;
|
|
309
|
+
* anything else (unknown/absent/non-read-only) unregisters it, which also
|
|
310
|
+
* covers an in-session agent switch. Returns whether the session is now
|
|
311
|
+
* registered.
|
|
312
|
+
*/
|
|
313
|
+
export function syncReadOnlySession(
|
|
314
|
+
registry: ReadOnlySessionRegistry,
|
|
315
|
+
sessionID: string,
|
|
316
|
+
agent: unknown,
|
|
317
|
+
readOnlyAgents: ReadonlySet<string> = READ_ONLY_AGENTS,
|
|
318
|
+
): boolean {
|
|
319
|
+
const normalized = normalizeDelegationAgent(agent)
|
|
320
|
+
if (normalized === undefined || !readOnlyAgents.has(normalized)) {
|
|
321
|
+
registry.unregister(sessionID)
|
|
322
|
+
return false
|
|
323
|
+
}
|
|
324
|
+
registry.register(sessionID, { agent: normalized })
|
|
325
|
+
return true
|
|
326
|
+
}
|
|
327
|
+
|
|
282
328
|
// ─── Guard ─────────────────────────────────────────────────────────────
|
|
283
329
|
|
|
284
330
|
/**
|
|
@@ -291,19 +337,18 @@ export function createEnforcementGuard(input: {
|
|
|
291
337
|
options?: EnforcementGuardOptions
|
|
292
338
|
}): ToolExecuteBeforeHandler {
|
|
293
339
|
const blockedTools = input.options?.blockedTools ?? DEFAULT_BLOCKED_TOOLS
|
|
294
|
-
const prefix = input.options?.messagePrefix ?? '
|
|
340
|
+
const prefix = input.options?.messagePrefix ?? 'pantheon delegation guard'
|
|
295
341
|
|
|
296
342
|
return async (hook: ToolExecuteBeforeInput, output?: ToolExecuteBeforeOutput): Promise<void> => {
|
|
297
|
-
const isDelegationTool = hook.tool === 'task'
|
|
343
|
+
const isDelegationTool = hook.tool === 'task'
|
|
298
344
|
if (isDelegationTool && input.options?.isChildSession?.(hook.sessionID) === true) {
|
|
299
345
|
throw new Error(
|
|
300
346
|
`${prefix}: session ${hook.sessionID} is a child session — delegation tools are denied ` +
|
|
301
347
|
'to prevent nested delegation',
|
|
302
348
|
)
|
|
303
349
|
}
|
|
304
|
-
//
|
|
305
|
-
//
|
|
306
|
-
// session-scoped agent learned from chat.params for native task().
|
|
350
|
+
// The hook only has the session-scoped agent learned from chat.params,
|
|
351
|
+
// so the runtime matrix check for native task() lives here.
|
|
307
352
|
if (
|
|
308
353
|
hook.tool === 'task' &&
|
|
309
354
|
input.options?.isRootSession !== undefined &&
|
|
@@ -336,7 +381,7 @@ export function createEnforcementGuard(input: {
|
|
|
336
381
|
`This session was delegated for investigation only (read-only agent); it cannot ` +
|
|
337
382
|
`modify files, run commands, or spawn subagents. ` +
|
|
338
383
|
`If a write result is needed, dispatch a write-capable agent ` +
|
|
339
|
-
`(e.g. hermes, aphrodite) with
|
|
384
|
+
`(e.g. hermes, aphrodite) with native task() instead.`,
|
|
340
385
|
)
|
|
341
386
|
}
|
|
342
387
|
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Best-effort salvage of complete top-level JSON objects from a corrupt
|
|
3
|
+
* `state.json`. The file is normally a JSON array; a torn write can leave a
|
|
4
|
+
* truncated tail (or interleaved NUL bytes). We scan for balanced `{…}`
|
|
5
|
+
* objects, parsing each independently, so every intact record survives.
|
|
6
|
+
*
|
|
7
|
+
* Extracted from file-persistence.ts to keep that module within the 300-line
|
|
8
|
+
* ceiling. Exported for tests.
|
|
9
|
+
*/
|
|
10
|
+
import type { BackgroundJobRecord } from './background-job-board.ts'
|
|
11
|
+
|
|
12
|
+
export function salvageRecords(content: string): BackgroundJobRecord[] {
|
|
13
|
+
const records: BackgroundJobRecord[] = []
|
|
14
|
+
let depth = 0
|
|
15
|
+
let start = -1
|
|
16
|
+
let inString = false
|
|
17
|
+
let escaped = false
|
|
18
|
+
|
|
19
|
+
for (let i = 0; i < content.length; i++) {
|
|
20
|
+
const ch = content[i]
|
|
21
|
+
|
|
22
|
+
if (inString) {
|
|
23
|
+
if (escaped) {
|
|
24
|
+
escaped = false
|
|
25
|
+
} else if (ch === '\\') {
|
|
26
|
+
escaped = true
|
|
27
|
+
} else if (ch === '"') {
|
|
28
|
+
inString = false
|
|
29
|
+
}
|
|
30
|
+
continue
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
if (ch === '"') {
|
|
34
|
+
inString = true
|
|
35
|
+
continue
|
|
36
|
+
}
|
|
37
|
+
if (ch === '{') {
|
|
38
|
+
if (depth === 0) start = i
|
|
39
|
+
depth++
|
|
40
|
+
continue
|
|
41
|
+
}
|
|
42
|
+
if (ch === '}') {
|
|
43
|
+
if (depth > 0) depth--
|
|
44
|
+
if (depth === 0 && start >= 0) {
|
|
45
|
+
const candidate = content.slice(start, i + 1)
|
|
46
|
+
try {
|
|
47
|
+
const parsed: unknown = JSON.parse(candidate)
|
|
48
|
+
if (
|
|
49
|
+
parsed !== null &&
|
|
50
|
+
typeof parsed === 'object' &&
|
|
51
|
+
typeof (parsed as BackgroundJobRecord).taskID === 'string'
|
|
52
|
+
) {
|
|
53
|
+
records.push(parsed as BackgroundJobRecord)
|
|
54
|
+
}
|
|
55
|
+
} catch {
|
|
56
|
+
// Incomplete/partial object — skip it, keep scanning.
|
|
57
|
+
}
|
|
58
|
+
start = -1
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return records
|
|
64
|
+
}
|