pantheon-opencode 1.3.3 → 1.3.5-beta.52.c75b04d

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 (52) hide show
  1. package/AGENTS.md +919 -0
  2. package/README.md +157 -15
  3. package/bin/pantheon-init.mjs +177 -120
  4. package/opencode.json +3 -6
  5. package/package.json +8 -4
  6. package/scripts/build-agents-md.mjs +195 -0
  7. package/scripts/cost.mjs +17 -0
  8. package/scripts/doctor.mjs +56 -22
  9. package/scripts/hooks/on-subagent-delegation-stop.sh +22 -5
  10. package/scripts/install/opencode.mjs +189 -38
  11. package/scripts/install/plugin.mjs +112 -28
  12. package/scripts/install-mcp.mjs +1 -1
  13. package/scripts/redaction-gate.mjs +69 -0
  14. package/scripts/release-beta-version.mjs +80 -0
  15. package/scripts/release-notes.mjs +317 -0
  16. package/scripts/test-context-compression.sh +5 -2
  17. package/scripts/validate-package.mjs +56 -0
  18. package/scripts/versioning.mjs +117 -18
  19. package/src/agents/zeus.md +6 -2
  20. package/src/pantheon/background-job-board.ts +24 -38
  21. package/src/pantheon/chat-reminders.ts +79 -0
  22. package/src/pantheon/compaction-assert.ts +160 -0
  23. package/src/pantheon/cost-command.ts +57 -18
  24. package/src/pantheon/delegation-compaction.ts +143 -16
  25. package/src/pantheon/delegation-finalize.ts +8 -1
  26. package/src/pantheon/delegation-notify.ts +19 -157
  27. package/src/pantheon/delegation.ts +330 -28
  28. package/src/pantheon/dispatch-guard.ts +9 -4
  29. package/src/pantheon/goal-loop.ts +7 -4
  30. package/src/pantheon/hashline/tool.ts +2 -1
  31. package/src/pantheon/logger.ts +117 -0
  32. package/src/pantheon/plugin-once.ts +76 -0
  33. package/src/pantheon/preemptive-compact.ts +76 -0
  34. package/src/pantheon/presets.d.mts +31 -0
  35. package/src/pantheon/presets.mjs +105 -3
  36. package/src/pantheon/todo-enforcer.ts +79 -27
  37. package/src/pantheon/todo-preserve.ts +295 -0
  38. package/src/plugin.ts +177 -50
  39. package/src/plugins/pantheon-hooks.ts +35 -22
  40. package/src/plugins/tui/dist/server.d.ts +17 -0
  41. package/src/plugins/tui/dist/server.d.ts.map +1 -0
  42. package/src/plugins/tui/dist/server.js +20 -0
  43. package/src/plugins/tui/dist/server.js.map +1 -0
  44. package/src/plugins/tui/dist/tui.d.ts +303 -1
  45. package/src/plugins/tui/dist/tui.d.ts.map +1 -1
  46. package/src/plugins/tui/dist/tui.js +977 -350
  47. package/src/plugins/tui/dist/tui.tsx +1427 -150
  48. package/src/plugins/tui/package.json +4 -3
  49. package/src/plugins/tui/src/index.tsx +1427 -150
  50. package/src/plugins/tui/src/server.ts +15 -0
  51. package/src/plugins/tui/tsdown.config.ts +1 -1
  52. package/src/routing.yml +3 -1
@@ -153,8 +153,12 @@ pantheon_delegate({prompt, agent, description?, read_only?})
153
153
  -> cria sessao filha, registra no board, retorna alias: [apo-1]
154
154
  pantheon_delegation_read({id: "apo-1"})
155
155
  -> BLOQUEIA ate o job terminar, retorna o report markdown, marca reconciled
156
+ -> durante a espera, mostra o que o agente esta fazendo: seccao final "## Agent Activity"
157
+ (amostra as messages do child a cada ~2s; tool calls + args truncados / texto)
156
158
  pantheon_delegation_list()
157
159
  -> lista jobs da sessao, com [unread] para jobs terminados nao lidos
160
+ -> jobs RUNNING mostram "last activity:" (ultima acao visivel do child)
161
+ -> fail-open: sem suporte a messages, formato atual inalterado
158
162
  ```
159
163
 
160
164
  - **Read-only enforcement:** agentes read-only (apollo, gaia — `read_only_agents` no routing.yml) tem `edit`, `write`, `bash`, `task` NEGADOS dentro da sessao delegada (o guard de `tool.execute.before` THROW com mensagem acionavel). Use `read_only: true` no delegate para qualquer agente de investigacao.
@@ -164,7 +168,7 @@ pantheon_delegation_list()
164
168
  ### DELEGATION_RULES (plugin path)
165
169
 
166
170
  1. **NUNCA** polle `pantheon_delegation_list` para checar se um job terminou.
167
- 2. Voce SERA notificado via `task-notification` injetado no chat quando um job atinge estado terminal.
171
+ 2. Voce NAO recebe notificacao injetada no chat — politica do usuario: ZERO `task-notification` no transcript. Visibilidade de conclusao: marcador `[unread]` em `pantheon_delegation_list`, `pantheon_delegation_read({id})`, toasts TUI (pantheon-hooks) e carry-forward de compactacao.
168
172
  3. Use `pantheon_delegation_read({id})` APENAS para fan-in explicito / recuperacao de resultado sob demanda (bloqueia ate o fim).
169
173
  4. `pantheon_delegation_list` e para diagnostico, nao para polling.
170
174
 
@@ -282,7 +286,7 @@ Wave: dispare N, colete com tolerancia a falha
282
286
  ```
283
287
 
284
288
  ### Plugin Enforcer (auto — session.idle hook)
285
- O plugin re-injeta "Incomplete tasks remain..." em sessões root/não-board que
289
+ O plugin re-injeta "Continue: pending todos remain — review and proceed." em sessões root/não-board que
286
290
  vão a idle com todos incompletos. Guards (todos no `src/pantheon/todo-enforcer.ts`):
287
291
  - **User-activity (30s)** — após uma mensagem do usuário (`chat.message` hook),
288
292
  a injeção é suprimida por `user_activity_quiet_ms: 30000`.
@@ -11,17 +11,19 @@
11
11
  */
12
12
 
13
13
  import { mkdir, rename, writeFile } from 'node:fs/promises'
14
- import { join, dirname } from 'node:path'
14
+ import { dirname, join } from 'node:path'
15
+
16
+ import { createPantheonLogger } from './logger.ts'
17
+
18
+ // Silence-by-default TUI policy (pantheon-hooks L42-58): console output in a
19
+ // plugin renders into the opencode TUI — the "lixo". Board internals log to
20
+ // .pantheon/logs/hooks.log; console echo is opt-in via PANTHEON_HOOKS_LOG=1.
21
+ const log = createPantheonLogger({ module: 'BackgroundJobBoard' })
15
22
 
16
23
  // ─── Types ─────────────────────────────────────────────────────────────
17
24
 
18
25
  /** Valid states for a background job. */
19
- export type BackgroundJobState =
20
- | 'running'
21
- | 'completed'
22
- | 'error'
23
- | 'cancelled'
24
- | 'reconciled'
26
+ export type BackgroundJobState = 'running' | 'completed' | 'error' | 'cancelled' | 'reconciled'
25
27
 
26
28
  /** Terminal states that can transition to reconciled. */
27
29
  const TERMINAL_STATES: ReadonlySet<BackgroundJobState> = new Set([
@@ -35,10 +37,7 @@ const TERMINAL_STATES: ReadonlySet<BackgroundJobState> = new Set([
35
37
  * Each entry defines which target states are reachable from the current state.
36
38
  * Only running → terminal, or same-terminal → same-terminal (idempotent re-notify).
37
39
  */
38
- const UPDATE_TRANSITIONS: Record<
39
- BackgroundJobState,
40
- ReadonlySet<BackgroundJobState>
41
- > = {
40
+ const UPDATE_TRANSITIONS: Record<BackgroundJobState, ReadonlySet<BackgroundJobState>> = {
42
41
  running: new Set(['completed', 'error', 'cancelled']),
43
42
  completed: new Set(['completed']),
44
43
  error: new Set(['error']),
@@ -371,6 +370,13 @@ export class BackgroundJobBoard {
371
370
  * Valid: only `completed`, `error`, or `cancelled` → `reconciled`.
372
371
  * Idempotent: already-reconciled jobs are returned as-is.
373
372
  * Rejected: `running` → `reconciled`.
373
+ *
374
+ * Reconcile is an ACKNOWLEDGMENT, not a completion event: it does NOT fire
375
+ * the terminal listeners (onTerminal) and does NOT resolve waiters — the
376
+ * terminal transition already did both. Firing them here produced a second
377
+ * `<task-notification>` per job (completed + reconciled) in the parent
378
+ * transcript; waiters are covered by `waitForTerminal` resolving immediately
379
+ * for already-terminal/reconciled jobs.
374
380
  */
375
381
  async markReconciled(taskID: string): Promise<BackgroundJobRecord | undefined> {
376
382
  const job = this.jobs.get(taskID)
@@ -386,7 +392,6 @@ export class BackgroundJobBoard {
386
392
  )
387
393
  }
388
394
 
389
- const wasTerminalUnreconciled = job.terminalUnreconciled
390
395
  job.state = 'reconciled'
391
396
  job.updatedAt = Date.now()
392
397
  job.terminalUnreconciled = false
@@ -394,10 +399,6 @@ export class BackgroundJobBoard {
394
399
  await this.persistRecord(job)
395
400
  await this.writeSignal(job)
396
401
 
397
- if (wasTerminalUnreconciled) {
398
- this.notifyTerminal(job.taskID)
399
- }
400
-
401
402
  return job
402
403
  }
403
404
 
@@ -415,17 +416,14 @@ export class BackgroundJobBoard {
415
416
  list(parentSessionID?: string): BackgroundJobRecord[] {
416
417
  const all = Array.from(this.jobs.values())
417
418
  if (!parentSessionID) return [...all]
418
- return all.filter(j => j.parentSessionID === parentSessionID)
419
+ return all.filter((j) => j.parentSessionID === parentSessionID)
419
420
  }
420
421
 
421
422
  /**
422
423
  * Resolve a job by either taskID or alias within a parent session.
423
424
  * Checks exact taskID first, then falls back to alias matching.
424
425
  */
425
- resolve(
426
- parentSessionID: string,
427
- taskIDOrAlias: string,
428
- ): BackgroundJobRecord | undefined {
426
+ resolve(parentSessionID: string, taskIDOrAlias: string): BackgroundJobRecord | undefined {
429
427
  // Exact taskID match (session-scoped)
430
428
  const byID = this.jobs.get(taskIDOrAlias)
431
429
  if (byID?.parentSessionID === parentSessionID) return byID
@@ -538,7 +536,7 @@ export class BackgroundJobBoard {
538
536
  const jobs = this.list(parentSessionID)
539
537
  if (jobs.length === 0) return undefined
540
538
 
541
- const lines = jobs.map(j => {
539
+ const lines = jobs.map((j) => {
542
540
  const status =
543
541
  j.state === 'completed'
544
542
  ? 'OK'
@@ -595,10 +593,7 @@ export class BackgroundJobBoard {
595
593
  try {
596
594
  await this.persistence.saveJob(record)
597
595
  } catch (err) {
598
- console.error(
599
- `[BackgroundJobBoard] Failed to persist job ${record.taskID}:`,
600
- err,
601
- )
596
+ log.error(`[BackgroundJobBoard] Failed to persist job ${record.taskID}:`, err)
602
597
  }
603
598
  }
604
599
 
@@ -607,10 +602,7 @@ export class BackgroundJobBoard {
607
602
  try {
608
603
  await this.persistence.deleteJob(taskID)
609
604
  } catch (err) {
610
- console.error(
611
- `[BackgroundJobBoard] Failed to delete persisted job ${taskID}:`,
612
- err,
613
- )
605
+ log.error(`[BackgroundJobBoard] Failed to delete persisted job ${taskID}:`, err)
614
606
  }
615
607
  }
616
608
 
@@ -619,10 +611,7 @@ export class BackgroundJobBoard {
619
611
  try {
620
612
  listener(taskID)
621
613
  } catch (err) {
622
- console.error(
623
- `[BackgroundJobBoard] Terminal listener error for ${taskID}:`,
624
- err,
625
- )
614
+ log.error(`[BackgroundJobBoard] Terminal listener error for ${taskID}:`, err)
626
615
  }
627
616
  }
628
617
  this.resolveTerminalWaiters(taskID)
@@ -648,10 +637,7 @@ export class BackgroundJobBoard {
648
637
  await writeFile(tmpPath, content, 'utf-8')
649
638
  await rename(tmpPath, signalPath)
650
639
  } catch (err) {
651
- console.error(
652
- `[BackgroundJobBoard] Failed to write signal for ${record.alias}:`,
653
- err,
654
- )
640
+ log.error(`[BackgroundJobBoard] Failed to write signal for ${record.alias}:`, err)
655
641
  }
656
642
  }
657
643
  }
@@ -0,0 +1,79 @@
1
+ /**
2
+ * Chat Reminders (release-134 Phase 4) — the SHARED <system-reminder> buffer.
3
+ *
4
+ * WHY a shared module (not owned by pantheon-hooks.ts):
5
+ * 1. opencode's legacy plugin loader (verified in the 1.18.16 source,
6
+ * packages/opencode/src/plugin/index.ts getLegacyPlugins) does
7
+ * `Object.values(mod)` and treats EVERY export as a plugin factory —
8
+ * a non-function export throws `TypeError("Plugin export is not a
9
+ * function")` and a function export is invoked with a PluginInput.
10
+ * pantheon-hooks.ts therefore cannot EXPORT the enqueue API.
11
+ * 2. src/plugin.ts (a separate plugin file) must ENQUEUE into the SAME
12
+ * buffer the pantheon-hooks `chat.message` hook drains — a local copy
13
+ * would enqueue into a buffer that is never delivered.
14
+ * 3. opencode loads plugins via plain `await import(entry)` (PluginLoader),
15
+ * so both plugin files importing this module resolve to ONE module
16
+ * instance (Bun/Node module cache) → one shared buffer.
17
+ *
18
+ * release-134 Phase 4 uses this buffer for the post-compaction state
19
+ * re-assertion: `reassertAfterCompaction` (compaction-assert.ts) enqueues a
20
+ * fresh-state block here, and the existing chat.message delivery path
21
+ * (pantheon-hooks.ts) injects it as a <system-reminder> into the session's
22
+ * next message. Zero new delivery code — the P0 messageID guard already
23
+ * protects subagent promptAsync fires.
24
+ *
25
+ * Bounded: at most CHAT_REMINDER_MAX entries, each expiring
26
+ * CHAT_REMINDER_TTL_MS after enqueue. Full-buffer enqueues are SKIPPED,
27
+ * never unbounded. No background timers — expiry is only checked on enqueue
28
+ * and on delivery (drainFreshChatReminders).
29
+ *
30
+ * @module chat-reminders
31
+ */
32
+
33
+ /**
34
+ * Hard cap on queued reminder entries (anti-spam philosophy, matching the
35
+ * "throttled signals are skipped, never backlogged" rule).
36
+ */
37
+ export const CHAT_REMINDER_MAX = 10
38
+
39
+ /** How long a queued reminder stays deliverable (pruned after ~60s). */
40
+ export const CHAT_REMINDER_TTL_MS = 60_000
41
+
42
+ /** One queued reminder: the text plus its enqueue timestamp. */
43
+ export interface ChatReminderEntry {
44
+ text: string
45
+ at: number
46
+ }
47
+
48
+ /** The shared buffer — module-level state, consumed by the chat.message hook. */
49
+ export const pendingChatReminders: ChatReminderEntry[] = []
50
+
51
+ /**
52
+ * Queue one chat-reminder entry, pruning expired entries first. Never throws.
53
+ * A full buffer skips the push (bounded, never backlogged).
54
+ */
55
+ export function enqueueChatReminder(text: string): void {
56
+ const now = Date.now()
57
+ for (let i = pendingChatReminders.length - 1; i >= 0; i--) {
58
+ const r = pendingChatReminders[i]
59
+ if (r !== undefined && now - r.at > CHAT_REMINDER_TTL_MS) pendingChatReminders.splice(i, 1)
60
+ }
61
+ if (pendingChatReminders.length >= CHAT_REMINDER_MAX) return
62
+ pendingChatReminders.push({ text, at: now })
63
+ }
64
+
65
+ /**
66
+ * Deliver (drain) every FRESH reminder as one joined block: expired entries
67
+ * are dropped, the buffer is cleared in all cases (no leak). Returns
68
+ * undefined when nothing fresh remains — the caller skips injection.
69
+ *
70
+ * This is the delivery helper the pantheon-hooks `chat.message` hook calls
71
+ * AFTER its P0 messageID guard (empty messageID → early return, buffer kept).
72
+ */
73
+ export function drainFreshChatReminders(): string | undefined {
74
+ const now = Date.now()
75
+ const fresh = pendingChatReminders.filter((r) => now - r.at <= CHAT_REMINDER_TTL_MS)
76
+ pendingChatReminders.length = 0
77
+ if (fresh.length === 0) return undefined
78
+ return fresh.map((r) => r.text).join('\n')
79
+ }
@@ -0,0 +1,160 @@
1
+ /**
2
+ * Compaction Re-Assert (release-134 Phase 4) — re-inject fresh state after
3
+ * compaction.
4
+ *
5
+ * WHY: after a session is compacted, the system state may have changed since
6
+ * the summary was built (new background delegations launched, results
7
+ * delivered, goals updated). The compacted session's context is frozen at
8
+ * summary time — the model would operate on stale state.
9
+ *
10
+ * HOW: the `session.compacted` event hook calls `reassertAfterCompaction`,
11
+ * which builds a COMPACT fresh-state block (running + unread delegations from
12
+ * the board, active goals from the GoalStore — same gating as Phase 2) and
13
+ * enqueues it into the SHARED chat-reminder buffer (chat-reminders.ts). The
14
+ * existing pantheon-hooks `chat.message` hook delivers it as a
15
+ * <system-reminder> into the session's next message — zero new delivery
16
+ * code, and the P0 messageID guard already protects subagent fires.
17
+ *
18
+ * Deliberately NOT `session.promptAsync({noReply:true})`: the sprint 4+7
19
+ * spike refuted noReply as a push mechanism on this opencode build (nothing
20
+ * is delivered to the parent).
21
+ *
22
+ * FAIL-OPEN: every source (board list, goals list, enqueue) is wrapped —
23
+ * a failure logs a warn via the silence-by-default logger and the hook never
24
+ * throws. A session with NO fresh state (nothing running/unread/goals) is a
25
+ * SILENT skip (no warn, no reminder). The block is capped at
26
+ * REASSERT_MAX_LINES item lines (1 header) so it stays compact.
27
+ *
28
+ * Pure TypeScript — zero runtime deps beyond the board/goal types and the
29
+ * shared reminder buffer.
30
+ *
31
+ * @module compaction-assert
32
+ */
33
+ import type { BackgroundJobBoard } from './background-job-board.ts'
34
+ import { enqueueChatReminder } from './chat-reminders.ts'
35
+ import type { CompactionGoalSource } from './delegation-compaction.ts'
36
+ import { createPantheonLogger } from './logger.ts'
37
+
38
+ // Silence-by-default TUI policy (pantheon-hooks L42-58): console echo is
39
+ // opt-in via PANTHEON_HOOKS_LOG=1. `deps.logger` injection stays for tests.
40
+ const log = createPantheonLogger({ module: 'pantheon-compaction' })
41
+
42
+ // ─── Constants ─────────────────────────────────────────────────────────
43
+
44
+ /** Max fresh-state item lines (running + unread + goals) in the block. */
45
+ export const REASSERT_MAX_LINES = 10
46
+
47
+ /** Header line — tells the model the summary may be stale. */
48
+ export const REASSERT_HEADER = 'State re-assertion after compaction — the summary may be stale:'
49
+
50
+ // ─── Types ─────────────────────────────────────────────────────────────
51
+
52
+ /** Dependencies threaded to reassertAfterCompaction (wired from plugin.ts). */
53
+ export interface CompactionAssertDeps {
54
+ /** The compacted session whose next message receives the reminder. */
55
+ sessionID: string
56
+ /** Board query surface — `list` is all the re-assertion needs. */
57
+ board: Pick<BackgroundJobBoard, 'list'>
58
+ /** Goal source for the goals block (optional — same gating as Phase 2). */
59
+ goals?: CompactionGoalSource
60
+ /** Injectable enqueue (testable). Defaults to the shared chat buffer. */
61
+ enqueue?: (text: string) => void
62
+ /** Injectable warn logger (testable). Defaults to silence-by-default. */
63
+ logger?: { warn: (message: string) => void }
64
+ }
65
+
66
+ // ─── Formatting ────────────────────────────────────────────────────────
67
+
68
+ /** One-line terminal status label, mirroring formatForPrompt. */
69
+ function terminalLabel(state: string): string {
70
+ switch (state) {
71
+ case 'completed':
72
+ return 'OK'
73
+ case 'error':
74
+ return 'ERR'
75
+ case 'cancelled':
76
+ return 'CAN'
77
+ default:
78
+ return state.toUpperCase()
79
+ }
80
+ }
81
+
82
+ /** Collapse whitespace and truncate a description/objective. */
83
+ function truncate(text: string, max = 80): string {
84
+ const flat = text.trim().replace(/\s+/g, ' ')
85
+ return flat.length <= max ? flat : `${flat.slice(0, max)}…`
86
+ }
87
+
88
+ // ─── Build ─────────────────────────────────────────────────────────────
89
+
90
+ /**
91
+ * Build the compact fresh-state lines: running delegations first (active work
92
+ * is the freshest signal), then unread terminal delegations, then active
93
+ * goals (only while the line budget allows). Returns undefined when there is
94
+ * nothing to assert (silent skip) or a source failed (warn + skip).
95
+ */
96
+ async function buildStateLines(
97
+ deps: CompactionAssertDeps,
98
+ warn: (message: string) => void,
99
+ ): Promise<string[] | undefined> {
100
+ const lines: string[] = []
101
+ try {
102
+ const jobs = deps.board.list(deps.sessionID)
103
+ const running = jobs
104
+ .filter((j) => j.state === 'running')
105
+ .sort((a, b) => a.launchedAt - b.launchedAt)
106
+ const unread = jobs
107
+ .filter((j) => j.terminalUnreconciled)
108
+ .sort((a, b) => b.updatedAt - a.updatedAt)
109
+
110
+ for (const job of running) {
111
+ if (lines.length >= REASSERT_MAX_LINES) break
112
+ lines.push(` running [${job.alias}] ${job.agent} — ${truncate(job.description)}`)
113
+ }
114
+ for (const job of unread) {
115
+ if (lines.length >= REASSERT_MAX_LINES) break
116
+ lines.push(
117
+ ` unread [${job.alias}] ${job.agent} — ${truncate(job.description)} — ${terminalLabel(job.state)}`,
118
+ )
119
+ }
120
+
121
+ const source = deps.goals
122
+ if (lines.length < REASSERT_MAX_LINES && source !== undefined && source.enabled) {
123
+ const goals = await source.list(deps.sessionID)
124
+ for (const goal of goals.filter((g) => g.status !== 'done')) {
125
+ if (lines.length >= REASSERT_MAX_LINES) break
126
+ lines.push(` goal [${goal.id}] ${truncate(goal.objective)} — ${goal.status}`)
127
+ }
128
+ }
129
+ } catch (err: unknown) {
130
+ const reason = err instanceof Error ? err.message : String(err)
131
+ warn(`compaction re-assert failed for session ${deps.sessionID}: ${reason}`)
132
+ return undefined
133
+ }
134
+ return lines.length === 0 ? undefined : lines
135
+ }
136
+
137
+ /**
138
+ * Re-assert fresh state for a compacted session: build the compact block and
139
+ * enqueue it as a chat reminder (delivered on the session's next message).
140
+ *
141
+ * Returns true when a reminder was enqueued; false when there was nothing to
142
+ * assert (silent skip) or a source failed (warned). NEVER throws — the event
143
+ * hook must never break the session.
144
+ */
145
+ export async function reassertAfterCompaction(deps: CompactionAssertDeps): Promise<boolean> {
146
+ // Silence-by-default logger: file-only warn, console echo opt-in via
147
+ // PANTHEON_HOOKS_LOG=1 (TUI pollution policy, pantheon-hooks L42-58).
148
+ const warn = deps.logger?.warn ?? ((message: string) => log.warn(message))
149
+ try {
150
+ const lines = await buildStateLines(deps, warn)
151
+ if (lines === undefined) return false
152
+ const enqueue = deps.enqueue ?? enqueueChatReminder
153
+ enqueue(`${REASSERT_HEADER}\n${lines.join('\n')}`)
154
+ return true
155
+ } catch (err: unknown) {
156
+ const reason = err instanceof Error ? err.message : String(err)
157
+ warn(`compaction re-assert failed for session ${deps.sessionID}: ${reason}`)
158
+ return false
159
+ }
160
+ }
@@ -10,8 +10,9 @@
10
10
  * - missing/unreadable db → FRIENDLY error string, never a crash (tools
11
11
  * return errors as TEXT).
12
12
  *
13
- * dbPath resolution: explicit option > env PANTHEON_COST_DB > XDG data
14
- * home > ~/.local/share/opencode/opencode.db. The tool is wired in
13
+ * dbPath resolution: explicit option > env PANTHEON_COST_DB > the version-aware
14
+ * default selected by PANTHEON_OPENCODE_VERSION (v1 or v2). An unset version
15
+ * deliberately preserves V1 behavior. The tool is wired in
15
16
  * plugin.ts alongside the delegation toolset (usable by zeus and any agent
16
17
  * with tool access — no routing change needed).
17
18
  *
@@ -41,6 +42,8 @@ export interface CostCommandOptions {
41
42
  dbPath?: string
42
43
  }
43
44
 
45
+ export type OpenCodeVersion = 'v1' | 'v2'
46
+
44
47
  /** One cost tool: description + zod args shape + execute (delegation.ts shape). */
45
48
  export interface CostTool<Args extends z.ZodRawShape = typeof costArgs> {
46
49
  description: string
@@ -62,15 +65,32 @@ const costArgs = {
62
65
  .describe('Number of days of delegation history to include (default 7).'),
63
66
  } satisfies z.ZodRawShape
64
67
 
65
- /** Resolve candidate db paths in priority order (env override first). */
66
- function dbCandidates(): string[] {
67
- const candidates: string[] = []
68
- const env = process.env.PANTHEON_COST_DB
69
- if (env !== undefined && env !== '') candidates.push(env)
68
+ /** Return the safe, isolated state DB name for an OpenCode version. */
69
+ function defaultDbPath(version: OpenCodeVersion | undefined): string {
70
70
  const xdg = process.env.XDG_DATA_HOME
71
71
  const dataHome = xdg !== undefined && xdg !== '' ? xdg : join(homedir(), '.local', 'share')
72
- candidates.push(join(dataHome, 'opencode', 'opencode.db'))
73
- return candidates
72
+ const filename = version === 'v2' ? 'opencode-v2.db' : 'opencode.db'
73
+ return join(dataHome, 'opencode', filename)
74
+ }
75
+
76
+ /**
77
+ * Resolve the database without probing another version's database.
78
+ *
79
+ * Explicit paths always win. A version selector changes exactly one default
80
+ * filename; it never falls back to V1, which prevents a V2 report from
81
+ * silently reading V1 history (or vice versa).
82
+ */
83
+ export function resolveCostDbPath(options?: CostCommandOptions): string {
84
+ const explicit = options?.dbPath || process.env.PANTHEON_COST_DB
85
+ if (explicit) return explicit
86
+
87
+ const configuredVersion = process.env.PANTHEON_OPENCODE_VERSION
88
+ if (configuredVersion !== undefined && configuredVersion !== 'v1' && configuredVersion !== 'v2') {
89
+ throw new Error(
90
+ `invalid PANTHEON_OPENCODE_VERSION "${configuredVersion}"; expected "v1" or "v2"`,
91
+ )
92
+ }
93
+ return defaultDbPath(configuredVersion as OpenCodeVersion | undefined)
74
94
  }
75
95
 
76
96
  /**
@@ -79,11 +99,28 @@ function dbCandidates(): string[] {
79
99
  * to the real opencode.db).
80
100
  */
81
101
  function findExistingDb(override?: string): string | undefined {
82
- const candidates = override !== undefined && override !== '' ? [override] : dbCandidates()
83
- for (const candidate of candidates) {
84
- if (existsSync(candidate)) return candidate
102
+ const candidate =
103
+ override === undefined ? resolveCostDbPath() : resolveCostDbPath({ dbPath: override })
104
+ return existsSync(candidate) ? candidate : undefined
105
+ }
106
+
107
+ function assertCompatibleSchema(db: {
108
+ prepare(sql: string): { get(): unknown; all(...args: unknown[]): unknown[] }
109
+ }): void {
110
+ const row = db
111
+ .prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'message'")
112
+ .get()
113
+ if (row === undefined) {
114
+ throw new Error('incompatible opencode.db schema: required "message" table is missing')
115
+ }
116
+ const columns = db.prepare('PRAGMA table_info(message)').all() as Array<{ name?: unknown }>
117
+ const names = new Set(columns.map((column) => column.name))
118
+ const missing = ['data', 'time_created'].filter((name) => !names.has(name))
119
+ if (missing.length > 0) {
120
+ throw new Error(
121
+ `incompatible opencode.db schema: message table is missing ${missing.join(', ')}; select the matching V1/V2 database`,
122
+ )
85
123
  }
86
- return undefined
87
124
  }
88
125
 
89
126
  /** Aggregate cost + tokens by agent via node:sqlite (read-only). */
@@ -91,6 +128,7 @@ async function queryWithNodeSqlite(dbPath: string, days: number): Promise<CostRo
91
128
  const sqlite = await import('node:sqlite')
92
129
  const db = new sqlite.DatabaseSync(dbPath, { readOnly: true })
93
130
  try {
131
+ assertCompatibleSchema(db)
94
132
  const since = Date.now() - days * 86_400_000
95
133
  const rows = db.prepare('SELECT data FROM message WHERE time_created >= ?').all(since)
96
134
  return aggregateMessages(rows.map((row) => String(row.data)))
@@ -180,16 +218,17 @@ export function createCostCommand(options?: CostCommandOptions): CostCommand {
180
218
  try {
181
219
  const dbPath = findExistingDb(options?.dbPath)
182
220
  if (dbPath === undefined) {
183
- const expected =
184
- options?.dbPath ?? process.env.PANTHEON_COST_DB ?? '~/.local/share/opencode/opencode.db'
221
+ const expected = resolveCostDbPath(options)
185
222
  return (
186
223
  `pantheon_cost failed: opencode.db not found (expected at ${expected}). ` +
187
224
  `No cost history is available until opencode has stored session data.`
188
225
  )
189
226
  }
190
- const rows = await queryWithNodeSqlite(dbPath, days).catch(async () =>
191
- queryWithScript(scriptPath, dbPath, days),
192
- )
227
+ const rows = await queryWithNodeSqlite(dbPath, days).catch(async (err: unknown) => {
228
+ if (err instanceof Error && err.message.startsWith('incompatible opencode.db schema'))
229
+ throw err
230
+ return queryWithScript(scriptPath, dbPath, days)
231
+ })
193
232
  return renderMarkdown(rows, days)
194
233
  } catch (err: unknown) {
195
234
  const reason = err instanceof Error ? err.message : String(err)