pantheon-opencode 1.3.2 → 1.3.3

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/README.md CHANGED
@@ -183,6 +183,56 @@ export OPENCODE_EXPERIMENTAL_BACKGROUND_SUBAGENTS=true
183
183
  opencode
184
184
  ```
185
185
 
186
+ ### Background Delegation
187
+
188
+ Zeus (and other root sessions) can dispatch agents as **background child sessions**
189
+ via three delegation tools, tracked on a persistent job board
190
+ (`.pantheon/board/state.json`, 24h TTL) with reports written to
191
+ `.pantheon/delegations/<parent>/<alias>.md`:
192
+
193
+ | Tool | Signature | Behavior |
194
+ |------|-----------|----------|
195
+ | `pantheon_delegate` | `{prompt, agent, description?, read_only?}` | Creates a child session (parentID = caller), registers it on the job board, arms a 15-minute timeout, and fire-and-forgets `promptAsync` on the child. Returns the readable alias (e.g. `apo-1`). Only root sessions may delegate (sub-sessions are rejected by the depth guard). |
196
+ | `pantheon_delegation_read` | `{id}` | Blocks (`waitForTerminal`, up to 15 min) until the delegation reaches a terminal state, returns the report markdown, and marks the job reconciled. Resolves by alias or task ID. |
197
+ | `pantheon_delegation_list` | `{}` | Lists the current session's delegations with `[unread]` on finished, unreconciled jobs. |
198
+
199
+ **Notification model — no polling, no client push.** When a job reaches a
200
+ terminal state (completion observed via `session.idle`/`session.error` on the
201
+ child), a self-contained `<task-notification>` block is queued in memory for
202
+ the parent session and injected into the parent's **next** `chat.message` hook
203
+ fire (prepended onto the first text part — the graceful-degradation channel the
204
+ TUI toast workaround uses). If the parent never sends another message, the
205
+ notification stays queued. The `<task-notification>` carries the task ID, alias,
206
+ agent, state, description and a `Result:`-prefixed summary so the parent can act
207
+ without an extra tool call.
208
+
209
+ **Timeout:** `background_delegation.timeout_ms` (default `900000` = 15 min). A job
210
+ that has not reached a terminal state is finalized as `error`/`timedOut`.
211
+
212
+ **Read-only enforcement:** delegating with `read_only: true`, or delegating to an
213
+ agent in `background_delegation.read_only_agents` (`apollo`, `gaia`), registers
214
+ the child session as read-only — the `tool.execute.before` guard denies
215
+ `edit`/`write`/`bash`/`task` inside that session.
216
+
217
+ **Compaction carry-forward:** during context compaction, running and unread
218
+ terminal delegations (capped at `background_delegation.max_compaction_items`,
219
+ default 10) are carried into the compacted context so the parent doesn't lose
220
+ track of in-flight work.
221
+
222
+ **Env vars & config:**
223
+
224
+ - `OPENCODE_EXPERIMENTAL_BACKGROUND_SUBAGENTS=true` — required (see launch block above).
225
+ - `PANTHEON_TOASTS` — TUI toast gate for the delegation lifecycle signals.
226
+ Default `{errors, delegations, council}`; `PANTHEON_TOASTS=off` disables all
227
+ TUI toasts (delegation signals then only surface via the chat.message
228
+ `<task-notification>`/`<system-reminder>` channel).
229
+ - `background_delegation` section in `src/routing.yml` — `timeout_ms`,
230
+ `poll_ms`, `max_compaction_items`, `prune_ttl_ms`, `read_only_agents`,
231
+ `session_max`, `retry_count`.
232
+
233
+ See `src/agents/zeus.md` for the agent-facing delegation protocol and
234
+ `src/pantheon/delegation.ts` for the tool implementations.
235
+
186
236
  > **Modes:** Interactive TUI with checkbox selection (default TTY), or `--headless` for scripts/CI.
187
237
  > **Minimal:** `--headless --no-mcp` installs only agents (~2s).
188
238
  > **Full:** `--headless` also creates Python venv + MCP servers (memory, persistence, KV, vision).
package/opencode.json CHANGED
@@ -29,7 +29,10 @@
29
29
  }
30
30
  },
31
31
  "instructions": ["AGENTS.md", "src/instructions/*.instructions.md"],
32
- "plugin": ["/home/ils15/pantheon/src/plugins/pantheon-hooks.ts"],
32
+ "plugin": [
33
+ "/home/ils15/pantheon/src/plugin.ts",
34
+ "/home/ils15/pantheon/src/plugins/pantheon-hooks.ts"
35
+ ],
33
36
  "mcp": {
34
37
  "bifrost": {
35
38
  "type": "remote",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pantheon-opencode",
3
- "version": "1.3.2",
3
+ "version": "1.3.3",
4
4
  "description": "Multi-agent orchestration for OpenCode — 14 specialized agents, TDD, Themis review gates.",
5
5
  "publishConfig": {
6
6
  "access": "public",
@@ -31,6 +31,7 @@
31
31
  "hooks:update": "pre-commit autoupdate",
32
32
  "test:security": "npm run secret-scan && node tests/test_install_mcp_vision.mjs && node tests/test_doctor_layers.mjs",
33
33
  "test:node": "node --test tests/*.mjs",
34
+ "test:ts": "for f in tests/pantheon/*.test.ts; do npx tsx \"$f\" || exit 1; done",
34
35
  "test": "python3 -m pytest tests/ -q 2>&1 || echo \"No tests found\"",
35
36
  "test:ci": "python3 -m pytest tests/ -q --tb=short 2>&1 || true"
36
37
  },
@@ -66,7 +67,8 @@
66
67
  "src/skills/**"
67
68
  ],
68
69
  "dependencies": {
69
- "js-yaml": "^4.3.0"
70
+ "js-yaml": "^4.3.0",
71
+ "zod": "4.1.8"
70
72
  },
71
73
  "devDependencies": {
72
74
  "@commitlint/cli": "^19.8.0",
@@ -0,0 +1,69 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * cost.mjs — read-only cost aggregation from opencode.db (Wave 4, PR #46).
4
+ *
5
+ * Fallback for the `pantheon_cost` tool when node:sqlite is unavailable in
6
+ * the plugin process (node < 22.5). Uses node:sqlite itself and prints a
7
+ * single JSON object to stdout:
8
+ * { ok: true, days, rows: [{agent, costUsd, tokensInput, tokensOutput}] }
9
+ * { ok: false, error: "<message>" } (exit code 1)
10
+ *
11
+ * NEVER writes to the database — opened read-only. Malformed rows are
12
+ * skipped; assistant messages carry the authoritative cost + tokens.
13
+ *
14
+ * Usage: node scripts/cost.mjs <dbPath> <days>
15
+ */
16
+ import { existsSync } from 'node:fs'
17
+ import { DatabaseSync } from 'node:sqlite'
18
+
19
+ /**
20
+ * Fail via exitCode + natural process exit (NEVER process.exit right after
21
+ * stdout.write — the pipe is async and the write can be truncated).
22
+ */
23
+ function fail(message) {
24
+ process.stderr.write(`cost.mjs: ${message}\n`)
25
+ process.stdout.write(JSON.stringify({ ok: false, error: message }))
26
+ process.exitCode = 1
27
+ }
28
+
29
+ function main() {
30
+ const [, , dbPath, daysArg] = process.argv
31
+ const days = Number(daysArg ?? '7')
32
+
33
+ if (!dbPath) return fail('missing <dbPath> argument')
34
+ if (!existsSync(dbPath)) return fail(`database not found: ${dbPath}`)
35
+
36
+ try {
37
+ const db = new DatabaseSync(dbPath, { readOnly: true })
38
+ const since = Date.now() - days * 86_400_000
39
+ const rows = db.prepare('SELECT data FROM message WHERE time_created >= ?').all(since)
40
+ db.close()
41
+
42
+ const byAgent = new Map()
43
+ for (const row of rows) {
44
+ try {
45
+ const outer = JSON.parse(String(row.data))
46
+ const info = typeof outer.data === 'string' ? JSON.parse(outer.data) : outer
47
+ if (info.role !== 'assistant') continue
48
+ const agent = typeof info.agent === 'string' && info.agent !== '' ? info.agent : null
49
+ if (!agent) continue
50
+ const costUsd = Number(info.cost) || 0
51
+ const tokensInput = Number(info.tokens?.input) || 0
52
+ const tokensOutput = Number(info.tokens?.output) || 0
53
+ const acc = byAgent.get(agent) ?? { agent, costUsd: 0, tokensInput: 0, tokensOutput: 0 }
54
+ acc.costUsd += costUsd
55
+ acc.tokensInput += tokensInput
56
+ acc.tokensOutput += tokensOutput
57
+ byAgent.set(agent, acc)
58
+ } catch {
59
+ // Skip malformed rows — a partial ledger never breaks the report.
60
+ }
61
+ }
62
+ const result = [...byAgent.values()].sort((a, b) => b.costUsd - a.costUsd)
63
+ process.stdout.write(JSON.stringify({ ok: true, days, rows: result }))
64
+ } catch (err) {
65
+ fail(err instanceof Error ? err.message : String(err))
66
+ }
67
+ }
68
+
69
+ main()
@@ -84,6 +84,28 @@ function mergeMissing(target, source) {
84
84
  return target
85
85
  }
86
86
 
87
+ /**
88
+ * Resolve a plugin ref from the packaged opencode.json to a path INSIDE the
89
+ * installed package.
90
+ *
91
+ * The packaged config may reference plugins via developer-machine absolute
92
+ * paths (e.g. /home/<dev>/pantheon/src/plugin.ts). Copying those verbatim into
93
+ * the user's config would break every install on any other machine. This
94
+ * rewrites the entry to the plugin file inside the INSTALLED package (derived
95
+ * from ROOT — never hardcoded), so both dev and global installs resolve to a
96
+ * path that actually exists.
97
+ */
98
+ export function resolveInstalledPlugin(plugin) {
99
+ // Map any ref whose path tail starts with `src/` to the matching file INSIDE
100
+ // the installed package. This preserves the ref's relative location, so both
101
+ // src/plugins/* (hooks) and the root-level src/plugin.ts (delegation plugin,
102
+ // PR #45) resolve correctly — nothing is forced into src/plugins/.
103
+ const srcIndex = plugin.indexOf('src/')
104
+ if (srcIndex === -1) return plugin
105
+ const packaged = join(ROOT, plugin.slice(srcIndex))
106
+ return existsSync(packaged) ? packaged : plugin
107
+ }
108
+
87
109
  /**
88
110
  * Detect whether `target` is the user's global OpenCode installation directory
89
111
  * (~/.opencode or $XDG_CONFIG_HOME/opencode).
@@ -578,19 +600,15 @@ export async function installOpenCode(
578
600
  // --------------------------------------------------------------------
579
601
  // B.5.1 Hermetic plugin resolution (packaging fix)
580
602
  // --------------------------------------------------------------------
581
- // The packaged opencode.json may reference the pantheon-hooks plugin via a
582
- // developer-machine absolute path (e.g. /home/<dev>/pantheon/src/plugins/...).
583
- // Copying that verbatim into the user's config would break every global
584
- // install on any other machine. Option A: rewrite the entry to the plugin
603
+ // The packaged opencode.json may reference plugins via developer-machine
604
+ // absolute paths (e.g. /home/<dev>/pantheon/src/plugin.ts or
605
+ // /home/<dev>/pantheon/src/plugins/pantheon-hooks.ts). Copying those
606
+ // verbatim into the user's config would break every global install on any
607
+ // other machine. resolveInstalledPlugin() rewrites each entry to the plugin
585
608
  // file inside the INSTALLED package (derived from ROOT — never hardcoded),
586
609
  // so both dev and global installs resolve to a path that actually exists.
587
610
  // The source opencode.json keeps its dev path for local development; the
588
611
  // transform happens at install/sync time only.
589
- function resolveInstalledPlugin(plugin) {
590
- const file = basename(plugin)
591
- const packaged = join(ROOT, 'src', 'plugins', file)
592
- return existsSync(packaged) ? packaged : plugin
593
- }
594
612
 
595
613
  if (!Array.isArray(config.plugin)) {
596
614
  config.plugin = []
@@ -144,6 +144,30 @@ task_status(task_id="ses_xxx", wait=true)
144
144
  -> bloqueia ate completar: { state: "completed", task_result: "..." }
145
145
  ```
146
146
 
147
+ ### Pantheon Delegation Tools (plugin) — 3-tool API
148
+
149
+ O plugin Pantheon adiciona 3 tools de delegacao gerenciada pelo BackgroundJobBoard:
150
+
151
+ ```
152
+ pantheon_delegate({prompt, agent, description?, read_only?})
153
+ -> cria sessao filha, registra no board, retorna alias: [apo-1]
154
+ pantheon_delegation_read({id: "apo-1"})
155
+ -> BLOQUEIA ate o job terminar, retorna o report markdown, marca reconciled
156
+ pantheon_delegation_list()
157
+ -> lista jobs da sessao, com [unread] para jobs terminados nao lidos
158
+ ```
159
+
160
+ - **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.
161
+ - **Depth-2 hard-enforced:** sessao read-only NAO cria subagentes (`task` bloqueado) — investigacao nunca vira arvore.
162
+ - **Compactacao:** jobs em voo (running + unread terminal) sao injetados no contexto de compactacao via `experimental.session.compacting` — delegacao em andamento nao se perde ao compactar.
163
+
164
+ ### DELEGATION_RULES (plugin path)
165
+
166
+ 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.
168
+ 3. Use `pantheon_delegation_read({id})` APENAS para fan-in explicito / recuperacao de resultado sob demanda (bloqueia ate o fim).
169
+ 4. `pantheon_delegation_list` e para diagnostico, nao para polling.
170
+
147
171
  ### Background (sempre usar)
148
172
  - **Apollo, Hermes, Aphrodite, Demeter, Hephaestus, Prometheus**
149
173
  - Dispare em waves paralelas: ate 5 concorrentes
@@ -256,3 +280,55 @@ Wave: dispare N, colete com tolerancia a falha
256
280
  catch:
257
281
  r = retry(agente, prompt_alternativo)
258
282
  ```
283
+
284
+ ### Plugin Enforcer (auto — session.idle hook)
285
+ O plugin re-injeta "Incomplete tasks remain..." em sessões root/não-board que
286
+ vão a idle com todos incompletos. Guards (todos no `src/pantheon/todo-enforcer.ts`):
287
+ - **User-activity (30s)** — após uma mensagem do usuário (`chat.message` hook),
288
+ a injeção é suprimida por `user_activity_quiet_ms: 30000`.
289
+ - **Board-running** — sessão com job background do nosso board em running → skip.
290
+ - **Native-children (2 min)** — children de `task(background=true)` do opencode
291
+ NÃO estão no nosso board; o enforcer consulta `session.children()` e pula se
292
+ algum child tiver `time.updated` mais novo que `child_active_ms: 120000`
293
+ (background task nativo ainda rodando). API indisponível → fail-open (log + injeta).
294
+ - **Kill-switch**: `PANTHEON_TODO_ENFORCER=off` desativa o enforcer por completo
295
+ (lido na construção do plugin). routing.yml é espelho de documentação — o env
296
+ var é o switch real (precedente COMPACTION_MAX_ITEMS).
297
+
298
+ ## Wave 4 (PR #46): Empty-Result Retry + /cost + Themis Tier
299
+
300
+ ### Empty-Result Retry (dispatch-guard — MANUAL orchestration)
301
+ OpenCode 1.18.x NAO permite interceptar a conclusao de `task()` via hooks,
302
+ entao o `src/pantheon/dispatch-guard.ts` e uma lib pura usada POR VOCE na
303
+ orquestracao (NAO esta wired no plugin):
304
+
305
+ ```
306
+ import { createDispatchGuard } from '.../src/pantheon/dispatch-guard.ts' # via code-mode ou subagente
307
+
308
+ guard = createDispatchGuard({ retryOnEmpty: true, logger: { warn: console.warn } })
309
+
310
+ # classificar resultado de task_status/wait:
311
+ # 'content' → tem texto, pronto
312
+ # 'empty-mode1' → SEM texto E SEM tokens (nada voltou) → RETRY 1x
313
+ # 'empty-mode2' → SEM texto, MAS tokens (raciocinou, perdeu a parte de texto;
314
+ # assinatura da falha themis Wave-2) → RETRY 1x
315
+
316
+ out = await guard.maybeRetry(result, async () => { ...task() de novo... })
317
+ # out.retried=true se redisparou; CAP DURO de 1 retry — nunca 2x.
318
+ # Se o retry voltar vazio, `out.retried=false` — NAO tente de novo: escale.
319
+ ```
320
+ Regra: retry 1x APENAS em `empty-mode1`/`empty-mode2`. Resultado com conteudo
321
+ nunca redispara. Apos 1 retry vazio → escalate (mesma regra do TODO Enforcer).
322
+
323
+ ### /cost — pantheon_cost tool (WIRED no plugin)
324
+ `pantheon_cost({ days?: number })` le o `opencode.db` READ-ONLY (node:sqlite;
325
+ fallback `scripts/cost.mjs`) e devolve tabela markdown de custo + tokens por
326
+ agente nos ultimos N dias (default 7). Uso: quando Nyx pedir visibilidade de
327
+ custo, ou antes de escalar tier — decida se o batch valeu o preco.
328
+
329
+ ### Themis Tier Policy (routing.yml go-deepseek)
330
+ Revisoes de FASE do themis rodam em `opencode/deepseek-v4-flash`
331
+ (review rapido, barato — evita a assinatura empty-mode2 de retorno vazio).
332
+ O tier PRO (`deepseek-v4-pro`) fica RESERVADO para o FINAL GATE (auditoria
333
+ final apos tudo aprovado) — override manual na hora. Athena NAO muda:
334
+ planner continua em pro.
@@ -129,6 +129,13 @@ export interface PersistenceAdapter {
129
129
  deleteJob(taskID: string): Promise<void>
130
130
  }
131
131
 
132
+ /** A pending `waitForTerminal()` registration for a taskID. */
133
+ interface TerminalWaiter {
134
+ resolve: (job: BackgroundJobRecord) => void
135
+ reject: (err: Error) => void
136
+ timer: NodeJS.Timeout
137
+ }
138
+
132
139
  // ─── Agent Prefix Mapping ──────────────────────────────────────────────
133
140
 
134
141
  const AGENT_PREFIXES: Record<string, string> = {
@@ -171,6 +178,7 @@ export class BackgroundJobBoard {
171
178
  */
172
179
  private readonly aliasCounters: Map<string, Map<string, number>> = new Map()
173
180
  private readonly terminalListeners: Set<(taskID: string) => void> = new Set()
181
+ private readonly terminalWaiters: Map<string, TerminalWaiter[]> = new Map()
174
182
  private readonly options: Required<BoardOptions>
175
183
  private persistence: PersistenceAdapter | null = null
176
184
 
@@ -460,6 +468,65 @@ export class BackgroundJobBoard {
460
468
  this.terminalListeners.delete(listener)
461
469
  }
462
470
 
471
+ /**
472
+ * Resolve with the job record once the job reaches a terminal state
473
+ * (`completed`, `error`, `cancelled`, or `reconciled`), or reject with a
474
+ * timeout error if `timeoutMs` elapses first.
475
+ *
476
+ * Resolves immediately when the job is already terminal at call time.
477
+ * Multiple concurrent waiters for the same taskID all resolve.
478
+ */
479
+ waitForTerminal(taskID: string, timeoutMs: number): Promise<BackgroundJobRecord> {
480
+ const existing = this.jobs.get(taskID)
481
+ if (existing && (TERMINAL_STATES.has(existing.state) || existing.state === 'reconciled')) {
482
+ return Promise.resolve(existing)
483
+ }
484
+
485
+ return new Promise<BackgroundJobRecord>((resolve, reject) => {
486
+ const waiter: TerminalWaiter = {
487
+ resolve,
488
+ reject,
489
+ timer: setTimeout(() => {
490
+ this.removeTerminalWaiter(taskID, waiter)
491
+ reject(
492
+ new Error(
493
+ `Timed out after ${timeoutMs}ms waiting for job ${taskID} to reach a terminal state`,
494
+ ),
495
+ )
496
+ }, timeoutMs),
497
+ }
498
+ const waiters = this.terminalWaiters.get(taskID) ?? []
499
+ waiters.push(waiter)
500
+ this.terminalWaiters.set(taskID, waiters)
501
+ })
502
+ }
503
+
504
+ private removeTerminalWaiter(taskID: string, waiter: TerminalWaiter): void {
505
+ const waiters = this.terminalWaiters.get(taskID)
506
+ if (!waiters) return
507
+ const idx = waiters.indexOf(waiter)
508
+ if (idx < 0) return
509
+ waiters.splice(idx, 1)
510
+ if (waiters.length === 0) {
511
+ this.terminalWaiters.delete(taskID)
512
+ }
513
+ }
514
+
515
+ private resolveTerminalWaiters(taskID: string): void {
516
+ const waiters = this.terminalWaiters.get(taskID)
517
+ if (!waiters) return
518
+ this.terminalWaiters.delete(taskID)
519
+ const job = this.jobs.get(taskID)
520
+ for (const waiter of waiters) {
521
+ clearTimeout(waiter.timer)
522
+ if (job) {
523
+ waiter.resolve(job)
524
+ } else {
525
+ waiter.reject(new Error(`Job ${taskID} was removed before reaching a terminal state`))
526
+ }
527
+ }
528
+ }
529
+
463
530
  // ─── Prompt Integration ──────────────────────────────────────────────
464
531
 
465
532
  /**
@@ -491,16 +558,25 @@ export class BackgroundJobBoard {
491
558
  // ─── Cleanup ─────────────────────────────────────────────────────────
492
559
 
493
560
  /**
494
- * Remove jobs whose `updatedAt` is older than `ttlMs` milliseconds
495
- * from the current time.
561
+ * Remove jobs whose `updatedAt` is older than `ttlMs` milliseconds.
562
+ *
563
+ * Only TERMINAL (completed/error/cancelled) and reconciled jobs are pruned —
564
+ * running jobs are never pruned. Each pruned job is also removed from the
565
+ * persistence adapter so it doesn't resurface after a restart.
496
566
  */
497
- pruneExpired(ttlMs: number): void {
567
+ async pruneExpired(ttlMs: number): Promise<void> {
498
568
  const now = Date.now()
499
- for (const [taskID, job] of this.jobs) {
500
- if (now - job.updatedAt > ttlMs) {
501
- this.jobs.delete(taskID)
569
+ const expired: BackgroundJobRecord[] = []
570
+ for (const job of this.jobs.values()) {
571
+ const isDone = TERMINAL_STATES.has(job.state) || job.state === 'reconciled'
572
+ if (isDone && now - job.updatedAt > ttlMs) {
573
+ expired.push(job)
502
574
  }
503
575
  }
576
+ for (const job of expired) {
577
+ this.jobs.delete(job.taskID)
578
+ await this.deletePersisted(job.taskID)
579
+ }
504
580
  }
505
581
 
506
582
  /** Remove all jobs belonging to a parent session. */
@@ -526,6 +602,18 @@ export class BackgroundJobBoard {
526
602
  }
527
603
  }
528
604
 
605
+ private async deletePersisted(taskID: string): Promise<void> {
606
+ if (!this.persistence) return
607
+ try {
608
+ await this.persistence.deleteJob(taskID)
609
+ } catch (err) {
610
+ console.error(
611
+ `[BackgroundJobBoard] Failed to delete persisted job ${taskID}:`,
612
+ err,
613
+ )
614
+ }
615
+ }
616
+
529
617
  private notifyTerminal(taskID: string): void {
530
618
  for (const listener of this.terminalListeners) {
531
619
  try {
@@ -537,6 +625,7 @@ export class BackgroundJobBoard {
537
625
  )
538
626
  }
539
627
  }
628
+ this.resolveTerminalWaiters(taskID)
540
629
  }
541
630
 
542
631
  private async writeSignal(record: BackgroundJobRecord): Promise<void> {
@@ -0,0 +1,208 @@
1
+ /**
2
+ * Cost Command (Wave 4, PR #46) — `pantheon_cost` structural tool: cost +
3
+ * token visibility for delegation traffic, straight from opencode.db.
4
+ *
5
+ * Reads the opencode session database READ-ONLY (sum cost + tokens by
6
+ * agent over the last N days → markdown table). Zero new dependencies:
7
+ * - primary path: node:sqlite (DatabaseSync, readOnly) — node ≥ 22.5;
8
+ * - fallback: spawn scripts/cost.mjs (node:sqlite, prints JSON) when the
9
+ * direct import is unavailable;
10
+ * - missing/unreadable db → FRIENDLY error string, never a crash (tools
11
+ * return errors as TEXT).
12
+ *
13
+ * dbPath resolution: explicit option > env PANTHEON_COST_DB > XDG data
14
+ * home > ~/.local/share/opencode/opencode.db. The tool is wired in
15
+ * plugin.ts alongside the delegation toolset (usable by zeus and any agent
16
+ * with tool access — no routing change needed).
17
+ *
18
+ * @module cost-command
19
+ */
20
+ import { execFile } from 'node:child_process'
21
+ import { existsSync } from 'node:fs'
22
+ import { homedir } from 'node:os'
23
+ import { join } from 'node:path'
24
+ import { promisify } from 'node:util'
25
+ import { z } from 'zod'
26
+
27
+ import type { ToolContextLike } from './delegation.ts'
28
+
29
+ const execFileAsync = promisify(execFile)
30
+
31
+ /** One aggregated cost row (per agent). */
32
+ export interface CostRow {
33
+ agent: string
34
+ costUsd: number
35
+ tokensInput: number
36
+ tokensOutput: number
37
+ }
38
+
39
+ export interface CostCommandOptions {
40
+ /** Explicit db path (testability / unusual installs). Default: resolved candidates. */
41
+ dbPath?: string
42
+ }
43
+
44
+ /** One cost tool: description + zod args shape + execute (delegation.ts shape). */
45
+ export interface CostTool<Args extends z.ZodRawShape = typeof costArgs> {
46
+ description: string
47
+ args: Args
48
+ execute(args: z.infer<z.ZodObject<Args>>, ctx: ToolContextLike): Promise<string>
49
+ }
50
+
51
+ export interface CostCommand {
52
+ pantheon_cost: CostTool
53
+ }
54
+
55
+ const costArgs = {
56
+ days: z
57
+ .number()
58
+ .int()
59
+ .min(1)
60
+ .max(365)
61
+ .optional()
62
+ .describe('Number of days of delegation history to include (default 7).'),
63
+ } satisfies z.ZodRawShape
64
+
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)
70
+ const xdg = process.env.XDG_DATA_HOME
71
+ const dataHome = xdg !== undefined && xdg !== '' ? xdg : join(homedir(), '.local', 'share')
72
+ candidates.push(join(dataHome, 'opencode', 'opencode.db'))
73
+ return candidates
74
+ }
75
+
76
+ /**
77
+ * First existing db path, or undefined. An EXPLICIT override shadows the
78
+ * candidate list entirely (testability: a fake path must not fall through
79
+ * to the real opencode.db).
80
+ */
81
+ 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
85
+ }
86
+ return undefined
87
+ }
88
+
89
+ /** Aggregate cost + tokens by agent via node:sqlite (read-only). */
90
+ async function queryWithNodeSqlite(dbPath: string, days: number): Promise<CostRow[]> {
91
+ const sqlite = await import('node:sqlite')
92
+ const db = new sqlite.DatabaseSync(dbPath, { readOnly: true })
93
+ try {
94
+ const since = Date.now() - days * 86_400_000
95
+ const rows = db.prepare('SELECT data FROM message WHERE time_created >= ?').all(since)
96
+ return aggregateMessages(rows.map((row) => String(row.data)))
97
+ } finally {
98
+ db.close()
99
+ }
100
+ }
101
+
102
+ /** Fallback: delegate the same aggregation to scripts/cost.mjs (node:sqlite). */
103
+ async function queryWithScript(
104
+ scriptPath: string,
105
+ dbPath: string,
106
+ days: number,
107
+ ): Promise<CostRow[]> {
108
+ const { stdout } = await execFileAsync(process.execPath, [scriptPath, dbPath, String(days)], {
109
+ timeout: 15_000,
110
+ encoding: 'utf8',
111
+ })
112
+ const parsed = JSON.parse(stdout) as { ok: boolean; rows?: CostRow[]; error?: string }
113
+ if (!parsed.ok) throw new Error(parsed.error ?? 'cost.mjs failed')
114
+ return parsed.rows ?? []
115
+ }
116
+
117
+ /** Parse message.data JSON rows and aggregate cost + tokens by assistant agent. */
118
+ function aggregateMessages(dataValues: readonly string[]): CostRow[] {
119
+ const byAgent = new Map<string, CostRow>()
120
+ for (const value of dataValues) {
121
+ try {
122
+ const outer = JSON.parse(value) as Record<string, unknown>
123
+ // The message row wraps the payload in a nested JSON string; when it
124
+ // is not a string, the row IS the payload (either way: flat info).
125
+ const info =
126
+ typeof outer.data === 'string' ? (JSON.parse(outer.data) as Record<string, unknown>) : outer
127
+ if (info.role !== 'assistant') continue
128
+ const agent = typeof info.agent === 'string' && info.agent !== '' ? info.agent : null
129
+ if (agent === null) continue
130
+ const costUsd = Number(info.cost) || 0
131
+ const tokens = (info.tokens ?? {}) as Record<string, unknown>
132
+ const tokensInput = Number(tokens.input) || 0
133
+ const tokensOutput = Number(tokens.output) || 0
134
+ const acc = byAgent.get(agent) ?? { agent, costUsd: 0, tokensInput: 0, tokensOutput: 0 }
135
+ acc.costUsd += costUsd
136
+ acc.tokensInput += tokensInput
137
+ acc.tokensOutput += tokensOutput
138
+ byAgent.set(agent, acc)
139
+ } catch {
140
+ // Skip malformed rows — a partial ledger never breaks the report.
141
+ }
142
+ }
143
+ return [...byAgent.values()].sort((a, b) => b.costUsd - a.costUsd)
144
+ }
145
+
146
+ /** Render the aggregate as a markdown table with a total row. */
147
+ function renderMarkdown(rows: readonly CostRow[], days: number): string {
148
+ if (rows.length === 0) {
149
+ return `## Cost by agent (last ${days} days)\n\nNo cost records in the last ${days} days.`
150
+ }
151
+ const total = rows.reduce(
152
+ (acc, r) => {
153
+ acc.costUsd += r.costUsd
154
+ acc.tokensInput += r.tokensInput
155
+ acc.tokensOutput += r.tokensOutput
156
+ return acc
157
+ },
158
+ { costUsd: 0, tokensInput: 0, tokensOutput: 0 },
159
+ )
160
+ const lines = [
161
+ `## Cost by agent (last ${days} days)`,
162
+ '',
163
+ '| Agent | Cost (USD) | Tokens In | Tokens Out |',
164
+ '|-------|-----------:|----------:|-----------:|',
165
+ ...rows.map(
166
+ (r) => `| ${r.agent} | $${r.costUsd.toFixed(6)} | ${r.tokensInput} | ${r.tokensOutput} |`,
167
+ ),
168
+ `| **Total** | **$${total.costUsd.toFixed(6)}** | **${total.tokensInput}** | **${total.tokensOutput}** |`,
169
+ '',
170
+ `Source: opencode.db (read-only)`,
171
+ ]
172
+ return lines.join('\n')
173
+ }
174
+
175
+ export function createCostCommand(options?: CostCommandOptions): CostCommand {
176
+ const scriptPath = new URL('../../scripts/cost.mjs', import.meta.url).pathname
177
+
178
+ const execute = async (args: { days?: number }, _ctx: ToolContextLike): Promise<string> => {
179
+ const days = args.days ?? 7
180
+ try {
181
+ const dbPath = findExistingDb(options?.dbPath)
182
+ if (dbPath === undefined) {
183
+ const expected =
184
+ options?.dbPath ?? process.env.PANTHEON_COST_DB ?? '~/.local/share/opencode/opencode.db'
185
+ return (
186
+ `pantheon_cost failed: opencode.db not found (expected at ${expected}). ` +
187
+ `No cost history is available until opencode has stored session data.`
188
+ )
189
+ }
190
+ const rows = await queryWithNodeSqlite(dbPath, days).catch(async () =>
191
+ queryWithScript(scriptPath, dbPath, days),
192
+ )
193
+ return renderMarkdown(rows, days)
194
+ } catch (err: unknown) {
195
+ const reason = err instanceof Error ? err.message : String(err)
196
+ return `pantheon_cost failed: ${reason}`
197
+ }
198
+ }
199
+
200
+ return {
201
+ pantheon_cost: {
202
+ description:
203
+ 'Report delegation cost + token usage by agent over the last N days, read from opencode.db (read-only, no writes).',
204
+ args: costArgs,
205
+ execute,
206
+ },
207
+ }
208
+ }