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.
Files changed (107) hide show
  1. package/.pantheon/code-mode/manifest.json +21 -0
  2. package/AGENTS.md +14 -63
  3. package/CHANGELOG.md +1260 -0
  4. package/README.md +162 -23
  5. package/README.pt-BR.md +104 -19
  6. package/bin/pantheon-init.mjs +178 -21
  7. package/commands/pantheon.md +1 -35
  8. package/package.json +23 -15
  9. package/plugin.json +1 -1
  10. package/pyproject.toml +62 -0
  11. package/scripts/code_mode_server.py +431 -55
  12. package/scripts/doctor.mjs +207 -9
  13. package/scripts/install/cli-ui.mjs +17 -11
  14. package/scripts/install/config-migration.mjs +4 -6
  15. package/scripts/install/opencode.mjs +137 -35
  16. package/scripts/install/plugin.mjs +12 -45
  17. package/scripts/install/shared.mjs +43 -2
  18. package/scripts/install/strings.mjs +129 -0
  19. package/scripts/install-mcp.mjs +1 -0
  20. package/scripts/manifest-inventory.mjs +410 -0
  21. package/scripts/mcp_persistence_server.py +536 -164
  22. package/scripts/package-evidence.mjs +242 -0
  23. package/scripts/probe-pantheon-cost.mjs +10 -1
  24. package/scripts/release-beta-version.mjs +56 -60
  25. package/scripts/sync-artifacts.mjs +169 -0
  26. package/scripts/sync-tui.mjs +129 -64
  27. package/scripts/test-opencode-v1-v2-sandbox.sh +79 -161
  28. package/scripts/validation-policy.mjs +7 -7
  29. package/scripts/version-check.mjs +113 -108
  30. package/scripts/versioning.mjs +230 -86
  31. package/src/agents/apollo.md +1 -0
  32. package/src/agents/gaia.md +1 -0
  33. package/src/agents/mnemosyne.md +1 -1
  34. package/src/agents/zeus.md +11 -54
  35. package/src/instructions/memory-protocol.instructions.md +6 -3
  36. package/src/instructions/zeus-anti-stall.instructions.md +2 -4
  37. package/src/instructions/zeus-council-synthesis.instructions.md +3 -28
  38. package/src/instructions/zeus-timeout-retry.instructions.md +3 -28
  39. package/src/mcp/code_mode_server.py +431 -55
  40. package/src/mcp/mcp_persistence_server.py +536 -164
  41. package/src/mcp/toon_codec.py +2 -4
  42. package/src/pantheon/background-job-board.ts +47 -5
  43. package/src/pantheon/{delegation-compaction.ts → compaction-context.ts} +10 -100
  44. package/src/pantheon/cost-command.ts +309 -44
  45. package/src/pantheon/delegation-enforce.ts +59 -14
  46. package/src/pantheon/file-persistence-salvage.ts +64 -0
  47. package/src/pantheon/file-persistence.ts +194 -45
  48. package/src/pantheon/goal-loop.ts +1 -1
  49. package/src/pantheon/goal-store.ts +1 -1
  50. package/src/pantheon/idle-continuation.ts +4 -6
  51. package/src/pantheon/model-command.ts +1 -2
  52. package/src/pantheon/native-task-status.ts +139 -0
  53. package/src/pantheon/presets.d.mts +0 -20
  54. package/src/pantheon/presets.mjs +0 -64
  55. package/src/pantheon/session-guard.ts +2 -2
  56. package/src/pantheon/session-id.ts +30 -0
  57. package/src/pantheon/shared-board.ts +37 -0
  58. package/src/pantheon/task-result-guard.ts +106 -0
  59. package/src/pantheon/todo-enforcer.ts +3 -3
  60. package/src/pantheon/tool-ceiling.ts +159 -0
  61. package/src/pantheon/tool-context.ts +23 -0
  62. package/src/pantheon/v2-bridge.ts +1 -4
  63. package/src/plugin-v2.ts +8 -21
  64. package/src/plugin.ts +83 -251
  65. package/src/plugins/pantheon-hooks.ts +51 -0
  66. package/src/plugins/tui/dist/server.d.ts +1 -1
  67. package/src/plugins/tui/dist/server.d.ts.map +1 -1
  68. package/src/plugins/tui/dist/server.js +1 -1
  69. package/src/plugins/tui/dist/server.js.map +1 -1
  70. package/src/plugins/tui/dist/tui.d.ts +261 -77
  71. package/src/plugins/tui/dist/tui.d.ts.map +1 -1
  72. package/src/plugins/tui/dist/tui.js +640 -234
  73. package/src/plugins/tui/dist/tui.js.map +1 -1
  74. package/src/plugins/tui/package-lock.json +2 -2
  75. package/src/plugins/tui/package.json +2 -2
  76. package/src/plugins/tui/src/index.tsx +872 -226
  77. package/src/routing.yml +0 -16
  78. package/src/skills/context-compression/SKILL.md +1 -1
  79. package/scripts/generate-preset-docs.mjs +0 -300
  80. package/scripts/generate-prompts.sh +0 -222
  81. package/scripts/hash_verify.py +0 -192
  82. package/scripts/install/component.mjs +0 -24
  83. package/scripts/install/migrate-config.mjs +0 -302
  84. package/scripts/test-context-compression.sh +0 -169
  85. package/src/pantheon/auto-wake.ts +0 -89
  86. package/src/pantheon/chat-reminders.ts +0 -9
  87. package/src/pantheon/comment-checker.ts +0 -48
  88. package/src/pantheon/compaction-assert.ts +0 -160
  89. package/src/pantheon/cost-tracker.ts +0 -108
  90. package/src/pantheon/delegate-manager.ts +0 -669
  91. package/src/pantheon/delegation-classifier.ts +0 -307
  92. package/src/pantheon/delegation-finalize.ts +0 -374
  93. package/src/pantheon/delegation-notify.ts +0 -212
  94. package/src/pantheon/delegation-toast.ts +0 -73
  95. package/src/pantheon/delegation.ts +0 -1120
  96. package/src/pantheon/dispatch-guard.ts +0 -109
  97. package/src/pantheon/permission-eval.ts +0 -284
  98. package/src/pantheon/preemptive-compact.ts +0 -76
  99. package/src/pantheon/retry-policy.ts +0 -212
  100. package/src/pantheon/step-cap.ts +0 -110
  101. package/src/pantheon/token-opt.ts +0 -781
  102. package/src/pantheon/toon-codec.ts +0 -481
  103. package/src/pantheon/v2-events.ts +0 -131
  104. package/src/pantheon/v2-hooks.ts +0 -287
  105. package/src/pantheon/v2-tool-definitions.ts +0 -331
  106. package/src/pantheon/zeus-delegate-with-retry.ts +0 -406
  107. 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
- * - primary path: node:sqlite (DatabaseSync, readOnly) — node ≥ 22.5;
8
- * - CLI fallback: spawn scripts/cost.mjs with its explicit flag-based
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 { promisify } from 'node:util'
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
- import type { ToolContextLike } from './delegation.ts'
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(dbPath: string, days: number): Promise<CostRow[]> {
129
- const sqlite = await import('node:sqlite')
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 scriptPath = new URL('../../scripts/cost.mjs', import.meta.url).pathname
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
- `pantheon_cost failed: opencode.db not found (expected at ${expected}). ` +
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
- const rows = await queryWithNodeSqlite(dbPath, days).catch(async (err: unknown) => {
249
- if (err instanceof Error && err.message.startsWith('incompatible opencode.db schema'))
250
- throw err
251
- return queryWithScript(scriptPath, dbPath, days)
252
- })
253
- return renderMarkdown(rows, days)
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
- const reason = err instanceof Error ? err.message : String(err)
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 (advisory `read_only: true` on
4
- * pantheon_delegate, or the agent ∈ `readOnlyAgents` from routing.yml).
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 delegation.ts at delegate time: the CHILD
26
- * session is registered when the delegate call is read-only.
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. `createDelegationTools` registers child
240
- * sessions here; the plugin's `tool.execute.before` guard consults it.
241
- * Process-wide singleton for plugin wiring + factory for test isolation.
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 ?? 'pantheon_delegate guard'
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' || hook.tool === 'pantheon_delegate'
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
- // pantheon_delegate has the authoritative ToolContext.agent at execution
305
- // time, so its matrix check lives in delegation.ts. The hook only has the
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 pantheon_delegate instead.`,
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
+ }