pantheon-opencode 1.3.2 → 1.3.4

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 (57) hide show
  1. package/README.md +154 -1
  2. package/bin/pantheon-init.mjs +160 -120
  3. package/opencode.json +0 -1
  4. package/package.json +10 -5
  5. package/scripts/cost.mjs +69 -0
  6. package/scripts/doctor.mjs +56 -22
  7. package/scripts/hooks/on-subagent-delegation-stop.sh +22 -5
  8. package/scripts/install/opencode.mjs +132 -32
  9. package/scripts/install/plugin.mjs +112 -28
  10. package/scripts/install-mcp.mjs +1 -1
  11. package/scripts/redaction-gate.mjs +69 -0
  12. package/scripts/release-notes.mjs +317 -0
  13. package/scripts/test-context-compression.sh +5 -2
  14. package/scripts/validate-package.mjs +56 -0
  15. package/scripts/versioning.mjs +117 -18
  16. package/src/agents/zeus.md +80 -0
  17. package/src/pantheon/background-job-board.ts +115 -40
  18. package/src/pantheon/chat-reminders.ts +79 -0
  19. package/src/pantheon/compaction-assert.ts +160 -0
  20. package/src/pantheon/cost-command.ts +208 -0
  21. package/src/pantheon/cost-tracker.ts +108 -0
  22. package/src/pantheon/delegation-compaction.ts +237 -0
  23. package/src/pantheon/delegation-enforce.ts +144 -0
  24. package/src/pantheon/delegation-finalize.ts +277 -0
  25. package/src/pantheon/delegation-notify.ts +100 -0
  26. package/src/pantheon/delegation.ts +690 -0
  27. package/src/pantheon/dispatch-guard.ts +107 -0
  28. package/src/pantheon/file-persistence.ts +10 -6
  29. package/src/pantheon/goal-loop.ts +300 -0
  30. package/src/pantheon/goal-store.ts +97 -0
  31. package/src/pantheon/hashline/core.ts +91 -0
  32. package/src/pantheon/hashline/read-enhancer.ts +66 -0
  33. package/src/pantheon/hashline/tool.ts +260 -0
  34. package/src/pantheon/hashline/xxhash.ts +53 -0
  35. package/src/pantheon/idle-continuation.ts +54 -0
  36. package/src/pantheon/logger.ts +117 -0
  37. package/src/pantheon/plugin-once.ts +76 -0
  38. package/src/pantheon/preemptive-compact.ts +76 -0
  39. package/src/pantheon/presets.d.mts +31 -0
  40. package/src/pantheon/presets.mjs +105 -3
  41. package/src/pantheon/todo-enforcer.ts +421 -0
  42. package/src/pantheon/todo-preserve.ts +295 -0
  43. package/src/plugin.ts +406 -13
  44. package/src/plugins/pantheon-hooks.ts +52 -30
  45. package/src/plugins/tui/dist/server.d.ts +17 -0
  46. package/src/plugins/tui/dist/server.d.ts.map +1 -0
  47. package/src/plugins/tui/dist/server.js +20 -0
  48. package/src/plugins/tui/dist/server.js.map +1 -0
  49. package/src/plugins/tui/dist/tui.d.ts +303 -1
  50. package/src/plugins/tui/dist/tui.d.ts.map +1 -1
  51. package/src/plugins/tui/dist/tui.js +977 -350
  52. package/src/plugins/tui/dist/tui.tsx +1427 -150
  53. package/src/plugins/tui/package.json +4 -3
  54. package/src/plugins/tui/src/index.tsx +1427 -150
  55. package/src/plugins/tui/src/server.ts +15 -0
  56. package/src/plugins/tui/tsdown.config.ts +1 -1
  57. package/src/routing.yml +35 -2
package/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
  <h1 align="center">Pantheon</h1>
3
3
 
4
4
  <p align="center">
5
- <a href="CHANGELOG.md"><img src="https://img.shields.io/badge/version-v1.0.0-blue" alt="Version"></a>
5
+ <a href="CHANGELOG.md"><img src="https://img.shields.io/badge/version-v1.3.4-blue" alt="Version"></a>
6
6
  <a href="LICENSE"><img src="https://img.shields.io/badge/license-MIT-blue" alt="License"></a>
7
7
  <a href="src/agents/README.md"><img src="https://img.shields.io/badge/agents-14-purple" alt="Agents"></a>
8
8
  <a href="src/skills/README.md"><img src="https://img.shields.io/badge/skills-21-orange" alt="Skills"></a>
@@ -183,6 +183,159 @@ 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
+ **Agent activity visibility.** While `pantheon_delegation_read` blocks on a
200
+ running job, it samples the child session's messages every ~2s and appends the
201
+ collected lines to the report as a trailing `## Agent Activity` section (latest
202
+ tool calls with truncated args, or user/assistant text, capped at ~200 chars per
203
+ line) — so you can see what the agent is doing during the wait. Likewise,
204
+ `pantheon_delegation_list` shows a `last activity:` line for running jobs.
205
+ Fail-open: if the child session messages are unavailable (or empty), the read
206
+ returns the report exactly as before — the activity sampling never breaks the
207
+ delegation read.
208
+
209
+ **Completion visibility — no chat injection.** User policy: ZERO delegation
210
+ notifications in the chat transcript — no `<task-notification>` block is ever
211
+ injected into a `chat.message` output (the old queue + flush channel was
212
+ removed). When a job reaches a terminal state (completion observed via
213
+ `session.idle`/`session.error` on the child, or the timeout finalize path), the
214
+ plugin writes a file-only audit log line (echo opt-in via `PANTHEON_HOOKS_LOG`).
215
+ Completion visibility lives in the legitimate channels: the board `[unread]`
216
+ marker (`pantheon_delegation_list`), `pantheon_delegation_read`, TUI toasts
217
+ (pantheon-hooks, `PANTHEON_TOASTS` gate) and compaction carry-forward. Reconcile
218
+ is an acknowledgment, not a completion — it never re-fires the terminal
219
+ transition.
220
+
221
+ **Timeout:** `background_delegation.timeout_ms` (default `900000` = 15 min). A job
222
+ that has not reached a terminal state is finalized as `error`/`timedOut`.
223
+
224
+ **Read-only enforcement:** delegating with `read_only: true`, or delegating to an
225
+ agent in `background_delegation.read_only_agents` (`apollo`, `gaia`), registers
226
+ the child session as read-only — the `tool.execute.before` guard denies
227
+ `edit`/`write`/`bash`/`task` inside that session.
228
+
229
+ **Compaction carry-forward:** during context compaction, running and unread
230
+ terminal delegations (capped at `background_delegation.max_compaction_items`,
231
+ default 10) are carried into the compacted context so the parent doesn't lose
232
+ track of in-flight work.
233
+
234
+ **Compaction summary v2 (1.3.4):** the `experimental.session.compacting` hook
235
+ now emits sections in a stable order — `<pantheon-context directive>`
236
+ ("preserve these sections verbatim in the summarized context", emitted only
237
+ when at least one other section follows), `<mission_context>` (active,
238
+ non-done goals), `<todo_context>` (pending todos), then the delegation blocks
239
+ (running, then unread terminal ≤ `max_compaction_items`) byte-for-byte
240
+ unchanged. Each source is fail-open: a disabled, unscoped (no sessionID),
241
+ throwing, or empty source omits its section (warned to `hooks.log`), and a
242
+ totally-empty state returns nothing — the hook injects nothing, preserving
243
+ the pre-1.3.4 behavior.
244
+
245
+ **Post-compaction todo preservation (1.3.4):** the session's todo list is
246
+ captured via the `session.todo` GET at compacting time and restored by
247
+ intercepting the first `todowrite` after `session.compacted` — opencode
248
+ 1.18.x exposes no todo write API, so that first `todowrite` has its args
249
+ rewritten with the exact captured list (no model cooperation, zero transcript
250
+ noise). Subsequent `todowrite` calls inside the 5s restore window are denied
251
+ with a clear "retry in a moment" error; the snapshot TTL is 60s. Fail-open: a
252
+ failed capture, expired snapshot, or malformed hook output degrades to a
253
+ logged warn and pass-through.
254
+
255
+ **Post-compaction state re-assertion (1.3.4):** on `session.compacted`, fresh
256
+ state — running/unread delegations from the board + active goals, capped at 10
257
+ lines — is re-injected as a `<system-reminder>` into the session's next
258
+ message via the shared chat-reminder buffer (`chat-reminders.ts`, the same P0
259
+ messageID guard that protects subagent fires). A session with nothing to
260
+ assert is a silent skip.
261
+
262
+ **Preemptive compaction check (1.3.4):** a pure threshold core
263
+ (`preemptive-compact.ts`) warns the model before the context fills: at 78%
264
+ usage, re-warning only when usage rose ≥5pp since the last warning. It is
265
+ dormant / ready-to-wire — opencode 1.18.x exposes no runtime context-usage
266
+ percentage, so nothing observes it yet; when a source appears, the caller
267
+ wires it in (the enqueue callback is injected).
268
+
269
+ **Model API-key validation (1.3.4):** `pantheon_delegate` gates the resolved
270
+ child model's provider before dispatching (single source of truth:
271
+ `routing.yml` preset definitions' `apiKeyEnv` — the same check `applyPreset`
272
+ enforces at startup). If an **auto-resolved** model (`options.agentModels` or
273
+ the active preset) points to a provider that requires an API key
274
+ (`apiKeyEnv`) and the env var is unset, the delegate falls back to
275
+ `opencode/deepseek-v4-flash-free` (validated the same way). If the fallback is
276
+ also unusable, the tool returns a clear error **text** (never throws) naming
277
+ the missing env var — and registers **no job** on the board. An **explicit**
278
+ `model` passed by the caller is always respected (warned, not overridden).
279
+ Setting `PANTHEON_MODEL_PRESET` to a preset whose providers have keys, or
280
+ filling the required `PANTHEON_*_API_KEY` env var for the preset's provider,
281
+ resolves the fallback path. If nothing resolves at all (no model, no preset),
282
+ the child keeps using opencode's default model (warned).
283
+
284
+ **agentModels wiring (1.3.4):** the delegate now also resolves the child model
285
+ via `routing.yml` — `loadRoutingAgentModels` extracts the per-agent models of
286
+ the FIRST-listed preset (the static default, `go-deepseek` today) and passes
287
+ them as `options.agentModels`, branch (b) of the resolve precedence: explicit
288
+ `model` > `options.agentModels` > active preset > opencode default. Delegation
289
+ no longer depends exclusively on the active preset — a delegated child gets a
290
+ sane per-agent model even when no preset is active. Fail-open: a missing or
291
+ unparseable routing.yml yields `{}` (previous behavior, warned).
292
+
293
+ **Delegation log hygiene (1.3.4):** `delegations.log` now records the real
294
+ `task_id` (omitted when empty, never `""`) and a **numeric** `duration_ms`
295
+ (null when unset) so downstream aggregation works. The idle-flush log entry is
296
+ deduplicated: the flush logs a summary (count + aggregated line count) and the
297
+ reminder content is logged exactly once, at `chat.message` delivery — no more
298
+ duplicate lines for the same notification.
299
+
300
+ **Env vars & config:**
301
+
302
+ - `OPENCODE_EXPERIMENTAL_BACKGROUND_SUBAGENTS=true` — required (see launch block above).
303
+ - `PANTHEON_TOASTS` — TUI toast gate for the delegation lifecycle signals.
304
+ Default `{errors, delegations, council}`; `PANTHEON_TOASTS=off` disables all
305
+ TUI toasts (delegation signals then only surface via the board `[unread]`
306
+ marker, `pantheon_delegation_read` and the on-disk audit log — never via
307
+ chat.message injection from the plugin).
308
+ - `background_delegation` section in `src/routing.yml` — `timeout_ms`,
309
+ `poll_ms`, `max_compaction_items`, `prune_ttl_ms`, `read_only_agents`,
310
+ `session_max`, `retry_count`.
311
+
312
+ See `src/agents/zeus.md` for the agent-facing delegation protocol and
313
+ `src/pantheon/delegation.ts` for the tool implementations.
314
+
315
+ ### Real-time Delegations panel (1.3.4)
316
+
317
+ The TUI sidebar ships a live **Delegations** panel (open by default, above the
318
+ collapsed Sessions list). Its primary source is `api.client.session.children`,
319
+ so it shows **both** kinds of background work in one place:
320
+
321
+ - **`pantheon_delegate` children** — rendered with their board alias tag
322
+ (`[apo-1]`) and enriched from the `.pantheon/delegations/` report.
323
+ - **Native `task()` children** — rendered with a distinct `[task]` tag (info
324
+ color) when no board report exists for the session.
325
+
326
+ Rows animate through the delegation lifecycle — a 140ms spinner plus
327
+ `DELEGATING` / `WORKING` / `READING RESULT` / `DONE` / `DONE (TIMED OUT)` /
328
+ `ERROR` / `CANCELLED` — and **clicking a row navigates to the child session**
329
+ (route API, guarded; mouse enabled in `tui.json`). The panel also reads the
330
+ md reports from **every** session (`readAllDelegationEntries`), so the full
331
+ delegation history renders even when no session is focused. Refresh is driven
332
+ by session events + `message.part.updated/removed` (live version bumps) with a
333
+ 1s safety poll; every re-fetch appends a diagnostic
334
+ `panel: children=N md=N events=N` line to `.pantheon/logs/hooks.log`
335
+ (silence-by-default policy — see `PANTHEON_HOOKS_LOG`). Everything is
336
+ fail-open: a missing children API or directory renders `Delegations (0)` /
337
+ the md history instead of erroring.
338
+
186
339
  > **Modes:** Interactive TUI with checkbox selection (default TTY), or `--headless` for scripts/CI.
187
340
  > **Minimal:** `--headless --no-mcp` installs only agents (~2s).
188
341
  > **Full:** `--headless` also creates Python venv + MCP servers (memory, persistence, KV, vision).
@@ -7,204 +7,244 @@
7
7
  * Thin CLI wrapper that parses arguments and delegates to
8
8
  * installOpenCode() from scripts/install/opencode.mjs.
9
9
  */
10
- import fs from 'fs';
11
- import os from 'os';
12
- import path from 'path';
13
- import { fileURLToPath } from 'url';
10
+ import fs from 'fs'
11
+ import os from 'os'
12
+ import path from 'path'
13
+ import { fileURLToPath } from 'url'
14
14
 
15
- const __dirname = path.dirname(fileURLToPath(import.meta.url));
16
- const ROOT = path.resolve(__dirname, '..');
15
+ const __dirname = path.dirname(fileURLToPath(import.meta.url))
16
+ const ROOT = path.resolve(__dirname, '..')
17
17
 
18
- const REQUIRED_NODE_MAJOR = 18;
18
+ const REQUIRED_NODE_MAJOR = 18
19
19
  if (parseInt(process.versions.node.split('.')[0], 10) < REQUIRED_NODE_MAJOR) {
20
- console.error(`❌ Node.js >= ${REQUIRED_NODE_MAJOR} required (current: ${process.versions.node})`);
21
- process.exit(1);
20
+ console.error(`❌ Node.js >= ${REQUIRED_NODE_MAJOR} required (current: ${process.versions.node})`)
21
+ process.exit(1)
22
+ }
23
+
24
+ function readVersion() {
25
+ try {
26
+ const pkgPath = path.join(ROOT, 'package.json')
27
+ return JSON.parse(fs.readFileSync(pkgPath, 'utf8')).version || '?'
28
+ } catch {
29
+ return '?'
30
+ }
22
31
  }
23
32
 
24
33
  function printUsage() {
25
- console.log('Pantheon OpenCode — Multi-agent orchestration platform');
26
- console.log('');
27
- console.log('Usage:');
28
- console.log(' npx pantheon-opencode init # Install globally');
29
- console.log(' npx pantheon-opencode init --project # Install in project');
30
- console.log(' npx pantheon-opencode init --dry-run # Preview only');
31
- console.log(' npx pantheon-opencode init --no-mcp # Skip MCP + venv');
32
- console.log(' npx pantheon-opencode init --force # Overwrite + recreate venv');
33
- console.log(' npx pantheon-opencode init --doctor # Run health check after install');
34
- console.log(' npx pantheon-opencode init --interactive # Force interactive TUI mode');
35
- console.log(' npx pantheon-opencode init --headless # Force non-interactive mode');
36
- console.log(' npx pantheon-opencode init -y # Skip confirmations, use defaults');
37
- console.log(' npx pantheon-opencode init --preset <name> # Install and activate model preset');
38
- console.log(' npx pantheon-opencode set-tier <name> # Set active model preset (global)');
39
- console.log(' npx pantheon-opencode set-tier <name> --project # Set active model preset (project)');
40
- console.log(' npx pantheon-opencode prune # List legacy artifacts (dry-run)');
41
- console.log(' npx pantheon-opencode prune --apply # Remove stale backups');
42
- console.log(' npx pantheon-opencode --help # Show this help');
34
+ console.log('Pantheon OpenCode — Multi-agent orchestration platform')
35
+ console.log('')
36
+ console.log('Usage:')
37
+ console.log(' npx pantheon-opencode init # Install globally')
38
+ console.log(' npx pantheon-opencode init --project # Install in project')
39
+ console.log(' npx pantheon-opencode init --dry-run # Preview only')
40
+ console.log(' npx pantheon-opencode init --no-mcp # Skip MCP + venv')
41
+ console.log(' npx pantheon-opencode init --force # Overwrite + recreate venv')
42
+ console.log(' npx pantheon-opencode init --doctor # Run health check after install')
43
+ console.log(' npx pantheon-opencode init --interactive # Force interactive TUI mode')
44
+ console.log(' npx pantheon-opencode init --headless # Force non-interactive mode')
45
+ console.log(' npx pantheon-opencode init -y # Skip confirmations, use defaults')
46
+ console.log(' npx pantheon-opencode init --preset <name> # Install and activate model preset')
47
+ console.log(' npx pantheon-opencode set-tier <name> # Set active model preset (global)')
48
+ console.log(
49
+ ' npx pantheon-opencode set-tier <name> --project # Set active model preset (project)',
50
+ )
51
+ console.log(' npx pantheon-opencode prune # List legacy artifacts (dry-run)')
52
+ console.log(' npx pantheon-opencode prune --apply # Remove stale backups')
53
+ console.log(' npx pantheon-opencode doctor [--profile] # Run health check')
54
+ console.log(' npx pantheon-opencode --version, -v # Print version and exit')
55
+ console.log(' npx pantheon-opencode --help # Show this help')
43
56
  }
44
57
 
45
58
  async function main() {
46
- const args = process.argv.slice(2);
47
- const command = args[0];
59
+ const args = process.argv.slice(2)
60
+ const command = args[0]
61
+
62
+ // --version / -v: print the package version and exit. Must run before any
63
+ // install/help dispatch so it never touches the filesystem beyond reading
64
+ // package.json.
65
+ if (command === '--version' || command === '-v') {
66
+ console.log(readVersion())
67
+ process.exit(0)
68
+ }
48
69
 
49
70
  // prune handles its own --help (delegated to scripts/prune.mjs), so its
50
71
  // dispatch must run BEFORE the generic --help check.
51
72
  if (command === 'prune') {
52
- const { spawnSync } = await import('child_process');
53
- const pruneScript = path.join(ROOT, 'scripts', 'prune.mjs');
73
+ const { spawnSync } = await import('child_process')
74
+ const pruneScript = path.join(ROOT, 'scripts', 'prune.mjs')
54
75
  const result = spawnSync(process.execPath, [pruneScript, ...args.slice(1)], {
55
76
  stdio: 'inherit',
56
77
  cwd: ROOT,
57
- });
58
- process.exit(result.status ?? 1);
78
+ })
79
+ process.exit(result.status ?? 1)
80
+ }
81
+
82
+ // doctor delegates to scripts/doctor.mjs (same spawnSync pattern as prune).
83
+ // Forwarding the FULL args (profile, target, --fix, ...) keeps the packaged
84
+ // CLI a thin wrapper; the doctor banner reports the resolved profile.
85
+ if (command === 'doctor') {
86
+ const { spawnSync } = await import('child_process')
87
+ const doctorScript = path.join(ROOT, 'scripts', 'doctor.mjs')
88
+ const result = spawnSync(process.execPath, [doctorScript, ...args.slice(1)], {
89
+ stdio: 'inherit',
90
+ cwd: ROOT,
91
+ })
92
+ process.exit(result.status ?? 1)
59
93
  }
60
94
 
61
- if (args.includes('--help')) { printUsage(); process.exit(0); }
95
+ if (args.includes('--help')) {
96
+ printUsage()
97
+ process.exit(0)
98
+ }
62
99
 
63
100
  if (command === 'set-tier') {
64
- const isProject = args.includes('--project');
65
- const isDryRun = args.includes('--dry-run');
66
- const name = args[1];
101
+ const isProject = args.includes('--project')
102
+ const isDryRun = args.includes('--dry-run')
103
+ const name = args[1]
67
104
 
68
- const { loadPresetDefs } = await import('../src/pantheon/presets.mjs');
69
- const { writeActivePreset } = await import('../scripts/install/model-picker.mjs');
70
- const presets = loadPresetDefs();
71
- const presetList = `none, ${Object.keys(presets).join(', ')}`;
105
+ const { loadPresetDefs } = await import('../src/pantheon/presets.mjs')
106
+ const { writeActivePreset } = await import('../scripts/install/model-picker.mjs')
107
+ const presets = loadPresetDefs()
108
+ const presetList = `none, ${Object.keys(presets).join(', ')}`
72
109
 
73
110
  if (!name) {
74
- console.error('❌ set-tier requires a preset name');
75
- console.error(` Available: ${presetList}`);
76
- process.exit(1);
111
+ console.error('❌ set-tier requires a preset name')
112
+ console.error(` Available: ${presetList}`)
113
+ process.exit(1)
77
114
  }
78
115
  if (name !== 'none' && !presets[name]) {
79
- console.error(`❌ Unknown preset "${name}"`);
80
- console.error(` Available: ${presetList}`);
81
- process.exit(1);
116
+ console.error(`❌ Unknown preset "${name}"`)
117
+ console.error(` Available: ${presetList}`)
118
+ process.exit(1)
82
119
  }
83
120
 
84
121
  // Fail-fast: every provider's API key env var must be set before writing
85
122
  if (name !== 'none') {
86
123
  for (const [pid, provider] of Object.entries(presets[name].providers || {})) {
87
124
  if (!process.env[provider.apiKeyEnv]) {
88
- console.error(`❌ Missing required API key configuration for provider "${pid}"`);
89
- console.error(' Set the provider API key environment variable and retry.');
90
- process.exit(1);
125
+ console.error(`❌ Missing required API key configuration for provider "${pid}"`)
126
+ console.error(' Set the provider API key environment variable and retry.')
127
+ process.exit(1)
91
128
  }
92
129
  }
93
130
  }
94
131
 
95
132
  const presetDir = isProject
96
133
  ? process.cwd()
97
- : path.join(process.env.XDG_CONFIG_HOME || path.join(os.homedir(), '.config'), 'opencode');
98
- const filePath = path.join(presetDir, '.pantheon', 'active-preset.json');
99
- const bakPath = path.join(presetDir, '.pantheon', 'active-preset.json.bak');
134
+ : path.join(process.env.XDG_CONFIG_HOME || path.join(os.homedir(), '.config'), 'opencode')
135
+ const filePath = path.join(presetDir, '.pantheon', 'active-preset.json')
136
+ const bakPath = path.join(presetDir, '.pantheon', 'active-preset.json.bak')
100
137
 
101
- let prevName = '(none)';
138
+ let prevName = '(none)'
102
139
  if (fs.existsSync(filePath)) {
103
140
  try {
104
- prevName = JSON.parse(fs.readFileSync(filePath, 'utf8')).preset || '(unknown)';
105
- } catch { prevName = '(unknown)'; }
141
+ prevName = JSON.parse(fs.readFileSync(filePath, 'utf8')).preset || '(unknown)'
142
+ } catch {
143
+ prevName = '(unknown)'
144
+ }
106
145
  }
107
146
 
108
147
  if (name === 'none') {
109
148
  if (isDryRun) {
110
- console.log('[dry-run] Would clear model preset');
149
+ console.log('[dry-run] Would clear model preset')
111
150
  } else {
112
151
  if (fs.existsSync(filePath)) {
113
- fs.mkdirSync(path.dirname(bakPath), { recursive: true });
114
- fs.copyFileSync(filePath, bakPath);
115
- fs.rmSync(filePath, { force: true });
152
+ fs.mkdirSync(path.dirname(bakPath), { recursive: true })
153
+ fs.copyFileSync(filePath, bakPath)
154
+ fs.rmSync(filePath, { force: true })
116
155
  }
117
- console.log('Model preset cleared. Using opencode defaults.');
118
- console.log(` Backup: ${bakPath}`);
156
+ console.log('Model preset cleared. Using opencode defaults.')
157
+ console.log(` Backup: ${bakPath}`)
119
158
  }
120
- return;
159
+ return
121
160
  }
122
161
 
123
- const result = writeActivePreset(presetDir, name, { dryRun: isDryRun, source: 'cli' });
162
+ const result = writeActivePreset(presetDir, name, { dryRun: isDryRun, source: 'cli' })
124
163
 
125
- console.log(`Model preset set: ${prevName} → ${name}`);
164
+ console.log(`Model preset set: ${prevName} → ${name}`)
126
165
  for (const [agent, spec] of Object.entries(presets[name].agents || {})) {
127
- console.log(` ${agent}: ${spec.model} [${spec.reasoning_effort}]`);
166
+ console.log(` ${agent}: ${spec.model} [${spec.reasoning_effort}]`)
128
167
  }
129
- const providerNames = Object.keys(presets[name].providers || {});
130
- console.log(` Providers: ${providerNames.join(', ') || 'none'}`);
131
- if (result.backupPath) console.log(` Backup: ${result.backupPath}`);
132
- return;
168
+ const providerNames = Object.keys(presets[name].providers || {})
169
+ console.log(` Providers: ${providerNames.join(', ') || 'none'}`)
170
+ if (result.backupPath) console.log(` Backup: ${result.backupPath}`)
171
+ return
133
172
  }
134
173
 
135
174
  if (command === 'init' || !command) {
136
- const isProject = args.includes('--project');
137
- const isDryRun = args.includes('--dry-run');
138
- const skipMCP = args.includes('--no-mcp');
139
- const forceReinstall = args.includes('--force');
140
- const runDoctor = args.includes('--doctor');
141
- const forceInteractive = args.includes('--interactive');
142
- const forceHeadless = args.includes('--headless');
143
- const autoYes = args.includes('--yes') || args.includes('-y');
144
- const presetIndex = args.indexOf('--preset');
145
- const presetOpt = presetIndex >= 0 ? (args[presetIndex + 1] ?? null) : null;
146
-
147
- const components = ['agents', 'skills', 'instructions', 'commands', 'plugins'];
148
- if (!skipMCP) components.push('runtime');
175
+ const isProject = args.includes('--project')
176
+ const isDryRun = args.includes('--dry-run')
177
+ const skipMCP = args.includes('--no-mcp')
178
+ const forceReinstall = args.includes('--force')
179
+ const runDoctor = args.includes('--doctor')
180
+ const forceInteractive = args.includes('--interactive')
181
+ const forceHeadless = args.includes('--headless')
182
+ const autoYes = args.includes('--yes') || args.includes('-y')
183
+ const presetIndex = args.indexOf('--preset')
184
+ const presetOpt = presetIndex >= 0 ? (args[presetIndex + 1] ?? null) : null
185
+
186
+ const components = ['agents', 'skills', 'instructions', 'commands', 'plugins']
187
+ if (!skipMCP) components.push('runtime')
149
188
 
150
189
  // Version info
151
- let version = '?';
152
- try {
153
- const pkgPath = path.join(ROOT, 'package.json');
154
- version = JSON.parse(fs.readFileSync(pkgPath, 'utf8')).version || '?';
155
- } catch { /* use default */ }
190
+ const version = readVersion()
156
191
 
157
- console.log('');
192
+ console.log('')
158
193
  if (!forceInteractive || isDryRun) {
159
- console.log(`Pantheon OpenCode v${version} — ${isDryRun ? 'DRY RUN' : 'Installing...'}`);
160
- console.log('');
194
+ console.log(`Pantheon OpenCode v${version} — ${isDryRun ? 'DRY RUN' : 'Installing...'}`)
195
+ console.log('')
161
196
  }
162
197
 
163
198
  try {
164
- const { installOpenCode } = await import('../scripts/install/opencode.mjs');
165
- const target = isProject ? process.cwd() : undefined;
199
+ const { installOpenCode } = await import('../scripts/install/opencode.mjs')
200
+ const target = isProject ? process.cwd() : undefined
166
201
  await installOpenCode(target, isDryRun, forceReinstall, components, {
167
202
  interactive: forceInteractive,
168
203
  headless: forceHeadless,
169
204
  yes: autoYes,
170
205
  preset: presetOpt,
171
- });
206
+ })
172
207
  } catch (err) {
173
- console.error(`❌ Installation failed: ${err.message}\n Run with --no-mcp to skip Python dependencies:\n npx pantheon-opencode init --no-mcp\n Or retry with --force to recreate the venv:\n npx pantheon-opencode init --force`);
174
- process.exit(1);
208
+ console.error(
209
+ `❌ Installation failed: ${err.message}\n Run with --no-mcp to skip Python dependencies:\n npx pantheon-opencode init --no-mcp\n Or retry with --force to recreate the venv:\n npx pantheon-opencode init --force`,
210
+ )
211
+ process.exit(1)
175
212
  }
176
213
 
177
214
  if (runDoctor && !isDryRun) {
178
- console.log('');
179
- console.log(' Running health check...');
215
+ console.log('')
216
+ console.log(' Running health check...')
180
217
  try {
181
- const { spawnSync } = await import('child_process');
182
- const doctorScript = path.join(ROOT, 'scripts', 'doctor.mjs');
183
- spawnSync(process.execPath, [doctorScript], { stdio: 'inherit', cwd: ROOT });
218
+ const { spawnSync } = await import('child_process')
219
+ const doctorScript = path.join(ROOT, 'scripts', 'doctor.mjs')
220
+ spawnSync(process.execPath, [doctorScript], { stdio: 'inherit', cwd: ROOT })
184
221
  } catch {
185
- console.log(' ⚠️ Could not run doctor.mjs');
222
+ console.log(' ⚠️ Could not run doctor.mjs')
186
223
  }
187
224
  }
188
225
 
189
- console.log('');
190
- console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
191
- console.log(` ✅ Pantheon OpenCode v${version} installed!`);
192
- console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
193
- console.log('');
194
- console.log(' Next steps:');
195
- console.log(' 1. Verify installation:');
196
- console.log(' npx pantheon-opencode doctor');
197
- console.log(' 2. Launch your AI coding tool (OpenCode, Claude Code, Cursor, etc.)');
198
- console.log(' 3. Invoke agents with @agent-name in chat');
199
- console.log(' 4. For project-local install:');
200
- console.log(' npx pantheon-opencode init --project');
201
- console.log('');
202
-
203
- return;
226
+ console.log('')
227
+ console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━')
228
+ console.log(` ✅ Pantheon OpenCode v${version} installed!`)
229
+ console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━')
230
+ console.log('')
231
+ console.log(' Next steps:')
232
+ console.log(' 1. Verify installation:')
233
+ console.log(' npx pantheon-opencode doctor')
234
+ console.log(' 2. Launch your AI coding tool (OpenCode, Claude Code, Cursor, etc.)')
235
+ console.log(' 3. Invoke agents with @agent-name in chat')
236
+ console.log(' 4. For project-local install:')
237
+ console.log(' npx pantheon-opencode init --project')
238
+ console.log('')
239
+
240
+ return
204
241
  }
205
242
 
206
- printUsage();
207
- process.exit(1);
243
+ printUsage()
244
+ process.exit(1)
208
245
  }
209
246
 
210
- main().catch(err => { console.error('Error:', err.stack); process.exit(1); });
247
+ main().catch((err) => {
248
+ console.error('Error:', err.stack)
249
+ process.exit(1)
250
+ })
package/opencode.json CHANGED
@@ -29,7 +29,6 @@
29
29
  }
30
30
  },
31
31
  "instructions": ["AGENTS.md", "src/instructions/*.instructions.md"],
32
- "plugin": ["/home/ils15/pantheon/src/plugins/pantheon-hooks.ts"],
33
32
  "mcp": {
34
33
  "bifrost": {
35
34
  "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.4",
4
4
  "description": "Multi-agent orchestration for OpenCode — 14 specialized agents, TDD, Themis review gates.",
5
5
  "publishConfig": {
6
6
  "access": "public",
@@ -21,7 +21,7 @@
21
21
  "version:status": "node scripts/versioning.mjs status",
22
22
  "version:apply": "node scripts/versioning.mjs apply",
23
23
  "version:check": "node scripts/version-check.mjs",
24
- "release:dry-run": "node scripts/versioning.mjs status && npm pack --dry-run",
24
+ "release:dry-run": "node scripts/versioning.mjs status && node scripts/release-notes.mjs && npm pack --dry-run",
25
25
  "publish:next": "npm publish --tag next --access public",
26
26
  "lint": "npx biome ci .",
27
27
  "typecheck": "npx tsc --noEmit",
@@ -30,7 +30,8 @@
30
30
  "hooks:install": "pre-commit install",
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
- "test:node": "node --test tests/*.mjs",
33
+ "test:node": "node --test tests/*.mjs tests/pantheon/*.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
  },
@@ -63,10 +64,14 @@
63
64
  "scripts/hooks/**",
64
65
  "src/mcp/requirements*",
65
66
  "src/plugins/tui/**",
66
- "src/skills/**"
67
+ "!src/plugins/tui/node_modules/**",
68
+ "src/skills/**",
69
+ "!**/__pycache__/**",
70
+ "!**/*.pyc"
67
71
  ],
68
72
  "dependencies": {
69
- "js-yaml": "^4.3.0"
73
+ "js-yaml": "^4.3.0",
74
+ "zod": "4.1.8"
70
75
  },
71
76
  "devDependencies": {
72
77
  "@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()