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 +50 -0
- package/opencode.json +4 -1
- package/package.json +4 -2
- package/scripts/cost.mjs +69 -0
- package/scripts/install/opencode.mjs +27 -9
- package/src/agents/zeus.md +76 -0
- package/src/pantheon/background-job-board.ts +95 -6
- package/src/pantheon/cost-command.ts +208 -0
- package/src/pantheon/cost-tracker.ts +108 -0
- package/src/pantheon/delegation-compaction.ts +110 -0
- package/src/pantheon/delegation-enforce.ts +144 -0
- package/src/pantheon/delegation-finalize.ts +270 -0
- package/src/pantheon/delegation-notify.ts +238 -0
- package/src/pantheon/delegation.ts +388 -0
- package/src/pantheon/dispatch-guard.ts +102 -0
- package/src/pantheon/file-persistence.ts +10 -6
- package/src/pantheon/goal-loop.ts +297 -0
- package/src/pantheon/goal-store.ts +97 -0
- package/src/pantheon/hashline/core.ts +91 -0
- package/src/pantheon/hashline/read-enhancer.ts +66 -0
- package/src/pantheon/hashline/tool.ts +259 -0
- package/src/pantheon/hashline/xxhash.ts +53 -0
- package/src/pantheon/idle-continuation.ts +54 -0
- package/src/pantheon/todo-enforcer.ts +369 -0
- package/src/plugin.ts +272 -6
- package/src/plugins/pantheon-hooks.ts +17 -8
- package/src/plugins/tui/package.json +1 -1
- package/src/routing.yml +33 -2
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": [
|
|
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.
|
|
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",
|
package/scripts/cost.mjs
ADDED
|
@@ -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
|
|
582
|
-
//
|
|
583
|
-
// Copying
|
|
584
|
-
//
|
|
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 = []
|
package/src/agents/zeus.md
CHANGED
|
@@ -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
|
-
*
|
|
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
|
-
|
|
500
|
-
|
|
501
|
-
|
|
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
|
+
}
|