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
@@ -34,7 +34,7 @@ import { fileURLToPath } from 'node:url'
34
34
 
35
35
  const __dirname = dirname(fileURLToPath(import.meta.url))
36
36
  const ROOT = join(__dirname, '..')
37
- const AGENTS_DIR = join(ROOT, "src", "agents")
37
+ const AGENTS_DIR = join(ROOT, 'src', 'agents')
38
38
  const PLATFORM_DIR = join(ROOT, 'platform')
39
39
 
40
40
  // All known platforms (must match platform/ subdirectories with adapter.json)
@@ -246,7 +246,9 @@ function checkAgentFiles(args) {
246
246
 
247
247
  // Determine which platforms to check
248
248
  if (!existsSync(PLATFORM_DIR)) {
249
- info(`Platform component not distributed in this package — platform check skipped (${args.profile} profile)`)
249
+ info(
250
+ `Platform component not distributed in this package — platform check skipped (${args.profile} profile)`,
251
+ )
250
252
  return
251
253
  }
252
254
  const platformDirs = readdirSync(PLATFORM_DIR, { withFileTypes: true })
@@ -255,7 +257,9 @@ function checkAgentFiles(args) {
255
257
  .filter((name) => !args.platform || name === args.platform)
256
258
 
257
259
  if (platformDirs.length === 0) {
258
- info(`No platform directories found in platform/ — platform check skipped (${args.profile} profile)`)
260
+ info(
261
+ `No platform directories found in platform/ — platform check skipped (${args.profile} profile)`,
262
+ )
259
263
  return
260
264
  }
261
265
 
@@ -377,7 +381,8 @@ function checkMcpConfig(args) {
377
381
 
378
382
  const mcp = cfg.data.mcp
379
383
  if (!mcp || typeof mcp !== 'object') {
380
- if (requiredMcpNames.length > 0) error(`${cfg.label}: required MCPs missing — no "mcp" section`)
384
+ if (requiredMcpNames.length > 0)
385
+ error(`${cfg.label}: required MCPs missing — no "mcp" section`)
381
386
  else info(`${cfg.label}: no "mcp" section (${args.profile} profile — optional)`)
382
387
  continue
383
388
  }
@@ -450,7 +455,9 @@ function checkMcpConfig(args) {
450
455
  const exe = cmd[0]
451
456
  if (!exe.includes('/')) {
452
457
  // PATH-resolved executable — cannot verify statically; surface for manual check
453
- info(`${cfg.label}: MCP "${name}" uses PATH executable "${exe}" (verify with \`which ${exe}\`)`)
458
+ info(
459
+ `${cfg.label}: MCP "${name}" uses PATH executable "${exe}" (verify with \`which ${exe}\`)`,
460
+ )
454
461
  continue
455
462
  }
456
463
  if (!existsSync(exe)) {
@@ -467,7 +474,8 @@ function checkMcpConfig(args) {
467
474
  if (requiredMcpNames.length > 0) {
468
475
  const configured = new Set(configs.flatMap((cfg) => Object.keys(cfg.data.mcp ?? {})))
469
476
  for (const required of requiredMcpNames) {
470
- if (configured.has(required)) pass(`Required MCP "${required}" configured in an available config`)
477
+ if (configured.has(required))
478
+ pass(`Required MCP "${required}" configured in an available config`)
471
479
  else error(`Required MCP "${required}" not configured in any available config`)
472
480
  }
473
481
  }
@@ -535,12 +543,17 @@ function mcpInitializeSmoke(command, cwd, timeoutMs) {
535
543
  const msg = JSON.parse(line.trim())
536
544
  if (msg.id === 1 && msg.result) {
537
545
  const info = msg.result.serverInfo
538
- const label = info?.name ? `${info.name}${info.version ? ` ${info.version}` : ''}` : undefined
546
+ const label = info?.name
547
+ ? `${info.name}${info.version ? ` ${info.version}` : ''}`
548
+ : undefined
539
549
  finish({ ok: true, serverName: label })
540
550
  return
541
551
  }
542
552
  if (msg.error) {
543
- finish({ ok: false, reason: `initialize error: ${msg.error.code ?? ''} ${msg.error.message ?? ''}` })
553
+ finish({
554
+ ok: false,
555
+ reason: `initialize error: ${msg.error.code ?? ''} ${msg.error.message ?? ''}`,
556
+ })
544
557
  }
545
558
  } catch {
546
559
  /* not JSON yet — keep buffering */
@@ -592,7 +605,9 @@ async function checkMcpRuntimeSmoke(args) {
592
605
  for (const { name, command, cwd, source } of local) {
593
606
  const result = await mcpInitializeSmoke(command, cwd, 10000)
594
607
  if (result.ok) {
595
- pass(`MCP "${name}" (${source}): initialize handshake OK${result.serverName ? ` — ${result.serverName}` : ''}`)
608
+ pass(
609
+ `MCP "${name}" (${source}): initialize handshake OK${result.serverName ? ` — ${result.serverName}` : ''}`,
610
+ )
596
611
  } else {
597
612
  error(`MCP "${name}" (${source}): ${result.reason}`)
598
613
  }
@@ -645,7 +660,9 @@ function checkPermissionMismatches(args) {
645
660
  // Locate the validation script
646
661
  const validator = join(ROOT, 'scripts', 'validate-agent-frontmatter.py')
647
662
  if (!existsSync(validator)) {
648
- info(`validate-agent-frontmatter.py not found — optional helper skipped (${args.profile} profile)`)
663
+ info(
664
+ `validate-agent-frontmatter.py not found — optional helper skipped (${args.profile} profile)`,
665
+ )
649
666
  return
650
667
  }
651
668
 
@@ -876,12 +893,24 @@ function checkRequirementLine(line, installed) {
876
893
  const cmp = compareVersions(have, want)
877
894
  let ok = false
878
895
  switch (op) {
879
- case '==': ok = cmp === 0; break
880
- case '>=': ok = cmp >= 0; break
881
- case '<=': ok = cmp <= 0; break
882
- case '>': ok = cmp > 0; break
883
- case '<': ok = cmp < 0; break
884
- case '!=': ok = cmp !== 0; break
896
+ case '==':
897
+ ok = cmp === 0
898
+ break
899
+ case '>=':
900
+ ok = cmp >= 0
901
+ break
902
+ case '<=':
903
+ ok = cmp <= 0
904
+ break
905
+ case '>':
906
+ ok = cmp > 0
907
+ break
908
+ case '<':
909
+ ok = cmp < 0
910
+ break
911
+ case '!=':
912
+ ok = cmp !== 0
913
+ break
885
914
  case '~=': {
886
915
  const spec = parseVersion(want)
887
916
  const got = parseVersion(have)
@@ -961,12 +990,12 @@ function checkVenvLayer(args) {
961
990
  */
962
991
  export function summaryMessage(summaryCounts, summaryExitCode) {
963
992
  if (summaryCounts.error > 0 || summaryExitCode !== 0) {
964
- const errorText = summaryCounts.error > 0
965
- ? `${summaryCounts.error} blocking error(s) found`
966
- : 'Blocking check failure'
967
- const warningText = summaryCounts.warn > 0
968
- ? `; ${summaryCounts.warn} warning(s) remain advisory`
969
- : ''
993
+ const errorText =
994
+ summaryCounts.error > 0
995
+ ? `${summaryCounts.error} blocking error(s) found`
996
+ : 'Blocking check failure'
997
+ const warningText =
998
+ summaryCounts.warn > 0 ? `; ${summaryCounts.warn} warning(s) remain advisory` : ''
970
999
  return `❌ ${errorText}${warningText} — exit code ${summaryExitCode}`
971
1000
  }
972
1001
 
@@ -1005,6 +1034,11 @@ async function main() {
1005
1034
  process.exit(0)
1006
1035
  }
1007
1036
 
1037
+ // Report the resolved validation profile up front (auto → global/sandbox)
1038
+ // so callers — including the `doctor` CLI wrapper — can see which policy
1039
+ // gate applied before any check output.
1040
+ console.log(`Validation profile: ${args.profile}`)
1041
+
1008
1042
  if (!existsSync(args.target)) {
1009
1043
  console.error(`❌ Target directory does not exist: ${args.target}`)
1010
1044
  process.exit(2)
@@ -383,12 +383,27 @@ fi
383
383
  # Emit a schema-compatible line: {event, timestamp, agent, status, reason,
384
384
  # session_id, result, delegation_id, task_id, duration_ms} — P0-2: the
385
385
  # delegation_id joins this record to the matching SubagentStart; task_id and
386
- # duration_ms ride in their OWN fields. Built with json.dumps so arbitrary
386
+ # duration_ms ride in their OWN fields. 1.3.4: task_id is OMITTED when empty
387
+ # (never "" — the real child id is always present when a job exists) and
388
+ # duration_ms is emitted as a NUMBER (the plugin computes it; stringifying it
389
+ # broke downstream aggregation). Built with json.dumps so arbitrary
387
390
  # reason/result text cannot break the JSON.
388
391
  LOG_LINE=$(python3 -c '
389
392
  import json
390
393
  import sys
391
- print(json.dumps({
394
+
395
+
396
+ def _num(v):
397
+ """Best-effort numeric parse — the plugin duration_ms is a number; a
398
+ non-numeric value (e.g. unset) becomes null, never a string."""
399
+ try:
400
+ return int(float(v))
401
+ except (TypeError, ValueError):
402
+ return None
403
+
404
+
405
+ task_id = sys.argv[8] or ""
406
+ line = {
392
407
  "event": "SubagentStop",
393
408
  "timestamp": sys.argv[1],
394
409
  "agent": sys.argv[2],
@@ -397,9 +412,11 @@ print(json.dumps({
397
412
  "session_id": sys.argv[5],
398
413
  "result": sys.argv[6],
399
414
  "delegation_id": sys.argv[7],
400
- "task_id": sys.argv[8],
401
- "duration_ms": sys.argv[9] or None,
402
- }, ensure_ascii=False))
415
+ }
416
+ if task_id:
417
+ line["task_id"] = task_id
418
+ line["duration_ms"] = _num(sys.argv[9])
419
+ print(json.dumps(line, ensure_ascii=False))
403
420
  ' "$TIMESTAMP" "$AGENT_NAME" "$STATUS" "$REASON" "$SESSION_ID" "$RESULT_SNIPPET" "$DELEGATION_ID" "$TASK_ID" "$DURATION_MS")
404
421
 
405
422
  LOG_FILE="$LOG_DIR/delegations.log"
@@ -3,15 +3,7 @@
3
3
  * opencode.mjs — OpenCode platform installer
4
4
  */
5
5
 
6
- import {
7
- chmodSync,
8
- existsSync,
9
- mkdirSync,
10
- readdirSync,
11
- readFileSync,
12
- rmSync,
13
- writeFileSync,
14
- } from 'node:fs'
6
+ import { chmodSync, existsSync, mkdirSync, readdirSync, readFileSync, rmSync } from 'node:fs'
15
7
  import { homedir } from 'node:os'
16
8
  import { basename, join, resolve } from 'node:path'
17
9
  import {
@@ -29,7 +21,7 @@ import {
29
21
  } from './cli-ui.mjs'
30
22
  import { healthCheck } from './health-check.mjs'
31
23
  import { detectVersion, runMigrations } from './migrate.mjs'
32
- import { installPlugin, registerPlugin, unregisterPlugin } from './plugin.mjs'
24
+ import { copyPluginFiles, installPlugin, registerPlugin, unregisterPlugin } from './plugin.mjs'
33
25
  import {
34
26
  collectSkillNames,
35
27
  copyFiles,
@@ -84,6 +76,28 @@ function mergeMissing(target, source) {
84
76
  return target
85
77
  }
86
78
 
79
+ /**
80
+ * Resolve a plugin ref from the packaged opencode.json to a path INSIDE the
81
+ * installed package.
82
+ *
83
+ * The packaged config may reference plugins via developer-machine absolute
84
+ * paths (e.g. <dev>/pantheon/.../src/plugin.ts). Copying those verbatim into
85
+ * the user's config would break every install on any other machine. This
86
+ * rewrites the entry to the plugin file inside the INSTALLED package (derived
87
+ * from ROOT — never hardcoded), so both dev and global installs resolve to a
88
+ * path that actually exists.
89
+ */
90
+ export function resolveInstalledPlugin(plugin) {
91
+ // Map any ref whose path tail starts with `src/` to the matching file INSIDE
92
+ // the installed package. This preserves the ref's relative location, so both
93
+ // src/plugins/* (hooks) and the root-level src/plugin.ts (delegation plugin,
94
+ // PR #45) resolve correctly — nothing is forced into src/plugins/.
95
+ const srcIndex = plugin.indexOf('src/')
96
+ if (srcIndex === -1) return plugin
97
+ const packaged = join(ROOT, plugin.slice(srcIndex))
98
+ return existsSync(packaged) ? packaged : plugin
99
+ }
100
+
87
101
  /**
88
102
  * Detect whether `target` is the user's global OpenCode installation directory
89
103
  * (~/.opencode or $XDG_CONFIG_HOME/opencode).
@@ -101,6 +115,97 @@ export function isGlobalConfigDir(target) {
101
115
  return resolve(target) === xdgDir
102
116
  }
103
117
 
118
+ /**
119
+ * Derive where the TUI plugin must be COPIED for a given config directory.
120
+ *
121
+ * The TUI plugin's single source of truth is src/plugins/tui inside the
122
+ * package; the loader must NEVER be pointed at that in-package dir (a package
123
+ * reinstall/upgrade replaces the dir under a live registration and leaves
124
+ * diverging copies loading). Instead the installer always copies it into the
125
+ * config dir's plugins/ dir and registers THAT copy:
126
+ *
127
+ * <configDir>/plugins/pantheon-tui (dist/ + package.json + index.tsx)
128
+ *
129
+ * The loader reads the copied package.json `exports` map:
130
+ * exports["./tui"] → dist/tui.js (COMPILED Solid output)
131
+ * exports["./server"] → dist/server.js (no-op server() stub)
132
+ *
133
+ * @param {string} configDir - directory holding tui.json (target for global
134
+ * installs, <target>/.opencode for project installs)
135
+ * @returns {string} absolute path of the copied plugin directory
136
+ */
137
+ export function resolveTuiCopyTarget(configDir) {
138
+ return join(configDir, 'plugins', 'pantheon-tui')
139
+ }
140
+
141
+ /**
142
+ * Every tui.json location OpenCode reads: the two global config dirs
143
+ * (~/.opencode and $XDG_CONFIG_HOME/opencode) plus the target's own project
144
+ * config. Cleanup runs over ALL of them so a stale Pantheon TUI registration
145
+ * can never survive in a location the loader still reads.
146
+ *
147
+ * @param {string} target - install target directory
148
+ * @returns {string[]} unique tui.json paths (deterministic order)
149
+ */
150
+ export function tuiConfigLocations(target) {
151
+ const homeTui = join(homedir(), '.opencode', 'tui.json')
152
+ const xdgConfig = process.env.XDG_CONFIG_HOME || join(homedir(), '.config')
153
+ const xdgTui = join(xdgConfig, 'opencode', 'tui.json')
154
+ const projectTui = join(target, '.opencode', 'tui.json')
155
+ return [...new Set([homeTui, xdgTui, projectTui])]
156
+ }
157
+
158
+ /**
159
+ * Sync the Pantheon TUI plugin registration across EVERY tui.json location
160
+ * OpenCode reads.
161
+ *
162
+ * Contract (single source of truth):
163
+ * 1. ALWAYS copy src/plugins/tui → <configDir>/plugins/pantheon-tui
164
+ * (dist/* + package.json + index.tsx) so the registered reference is a
165
+ * directory that exists and carries the loader contract (exports →
166
+ * dist/tui.js, dist/server.js). The copy is idempotent: a second run is
167
+ * byte-identical.
168
+ * 2. Clean stale Pantheon TUI refs from ALL tui.json locations OpenCode
169
+ * loads — ~/.opencode, $XDG_CONFIG_HOME/opencode and
170
+ * <target>/.opencode — covering every old reference shape: dist file
171
+ * paths, in-package src/plugins/tui dirs, bare plugins/pantheon-tui
172
+ * copies, and npx paths. A stale ref left in the non-target location
173
+ * would still be loaded and break the TUI.
174
+ * 3. Register EXACTLY the copied directory in the selected target only —
175
+ * exactly ONE Pantheon TUI reference exists system-wide after install.
176
+ *
177
+ * @param {string} target - install target directory
178
+ * @param {object} [opts]
179
+ * @param {boolean} [opts.isGlobal=false] - flat global layout vs .opencode/
180
+ * @param {boolean} [opts.dryRun=false]
181
+ * @returns {'created'|'skipped'} status for the target registration
182
+ */
183
+ export function syncTuiRegistration(target, { isGlobal = false, dryRun = false } = {}) {
184
+ const configDir = isGlobal ? target : join(target, '.opencode')
185
+ const targetTuiConfigPath = join(configDir, 'tui.json')
186
+
187
+ // 1. Always copy the single source of truth into the target config's
188
+ // plugins/pantheon-tui. Registering this copied directory (never the
189
+ // in-package src/plugins/tui) keeps installs hermetic and idempotent:
190
+ // the ref points at a dir that exists and has dist/tui.js.
191
+ const tuiSrcDir = join(ROOT, 'src', 'plugins', 'tui')
192
+ const tuiCopyDir = resolveTuiCopyTarget(configDir)
193
+ if (existsSync(tuiSrcDir)) {
194
+ copyPluginFiles(tuiSrcDir, tuiCopyDir, { dryRun })
195
+ }
196
+
197
+ // 2. Clean stale Pantheon TUI refs from every location OpenCode reads.
198
+ // unregisterPlugin covers the bare relative id plus the extended stale
199
+ // patterns (dist files, in-package src/plugins/tui dir, bare
200
+ // plugins/pantheon-tui copies, npx paths).
201
+ for (const tuiConfigPath of tuiConfigLocations(target)) {
202
+ unregisterPlugin(tuiConfigPath, 'plugins/pantheon-tui', { dryRun })
203
+ }
204
+
205
+ // 3. Register the copied directory in the selected target only.
206
+ return registerPlugin(targetTuiConfigPath, tuiCopyDir, { dryRun })
207
+ }
208
+
104
209
  export async function installOpenCode(
105
210
  target,
106
211
  dryRun = false,
@@ -305,19 +410,7 @@ export async function installOpenCode(
305
410
  // -----------------------------------------------------------------------
306
411
  // 2.9 Create/update tui.json with plugin registration
307
412
  // -----------------------------------------------------------------------
308
- const targetTuiConfigPath = isGlobal
309
- ? join(target, 'tui.json')
310
- : join(target, '.opencode', 'tui.json')
311
- // Hermetic TUI plugin registration: opencode's tui loader treats relative
312
- // entries ("plugins/pantheon-tui") as npm/github package specs and fails
313
- // with NpmInstallFailedError ("Repository not found"). Register the plugin
314
- // by the ABSOLUTE path of the built dist inside the installed package
315
- // (derived from ROOT — never hardcoded), mirroring the loader's
316
- // absolute-path support (same pattern as the hooks plugin fix).
317
- const tuiPluginRef = join(ROOT, 'src', 'plugins', 'tui', 'dist', 'tui.tsx')
318
- // Clean up the old broken relative ref (and stale dist refs) on upgrade.
319
- unregisterPlugin(targetTuiConfigPath, 'plugins/pantheon-tui', { dryRun })
320
- const tuiStatus = registerPlugin(targetTuiConfigPath, tuiPluginRef, { dryRun })
413
+ const tuiStatus = syncTuiRegistration(target, { isGlobal, dryRun })
321
414
  if (tuiStatus === 'created') stats.created++
322
415
  else stats.skipped++
323
416
 
@@ -578,19 +671,15 @@ export async function installOpenCode(
578
671
  // --------------------------------------------------------------------
579
672
  // B.5.1 Hermetic plugin resolution (packaging fix)
580
673
  // --------------------------------------------------------------------
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
674
+ // The packaged opencode.json may reference plugins via developer-machine
675
+ // absolute paths (e.g. <dev>/pantheon/.../src/plugin.ts or
676
+ // <dev>/pantheon/.../src/plugins/pantheon-hooks.ts). Copying those
677
+ // verbatim into the user's config would break every global install on any
678
+ // other machine. resolveInstalledPlugin() rewrites each entry to the plugin
585
679
  // file inside the INSTALLED package (derived from ROOT — never hardcoded),
586
680
  // so both dev and global installs resolve to a path that actually exists.
587
681
  // The source opencode.json keeps its dev path for local development; the
588
682
  // 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
683
 
595
684
  if (!Array.isArray(config.plugin)) {
596
685
  config.plugin = []
@@ -606,6 +695,17 @@ export async function installOpenCode(
606
695
  }
607
696
  }
608
697
 
698
+ // The runtime hooks plugin MUST always be registered. The packaged
699
+ // opencode.json no longer carries dev-machine plugin paths (removed in
700
+ // 3e552cc), so config.plugin would otherwise stay empty and the
701
+ // pantheon-hooks runtime would never load. Register it unconditionally,
702
+ // replacing any stale entry for the same file (same basename-dedup as
703
+ // above) so installs and upgrades are idempotent and hermetic.
704
+ const hooksPlugin = resolveInstalledPlugin('src/plugins/pantheon-hooks.ts')
705
+ const hooksFile = basename(hooksPlugin)
706
+ config.plugin = config.plugin.filter((p) => basename(p) !== hooksFile)
707
+ config.plugin.push(hooksPlugin)
708
+
609
709
  if (!config.provider && pantheonConfig.provider) {
610
710
  config.provider = deepClone(pantheonConfig.provider)
611
711
  } else if (config.provider && pantheonConfig.provider) {
@@ -3,34 +3,34 @@
3
3
  */
4
4
 
5
5
  import { execSync } from 'node:child_process'
6
- import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
6
+ import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync } from 'node:fs'
7
7
  import { join } from 'node:path'
8
8
  import { warning } from './cli-ui.mjs'
9
9
  import { writeIfChanged } from './shared.mjs'
10
10
 
11
11
  /**
12
- * Install a plugin from source to destination.
12
+ * Copy a plugin's runtime payload (src/index.tsx → index.tsx, dist/*,
13
+ * package.json) from source to destination. Pure file copy — no npm install.
13
14
  * @param {string} srcDir - Source plugin directory (e.g. ROOT/src/plugins/tui)
14
15
  * @param {string} dstDir - Destination plugin directory
15
16
  * @param {object} [options]
16
17
  * @param {boolean} [options.dryRun=false] - If true, don't write files
17
- * @param {boolean} [options.clean=false] - If true, remove existing before install
18
- * @returns {{ created: number, skipped: number, errors: number }}
18
+ * @returns {{ created: number, skipped: number }}
19
19
  */
20
- export function installPlugin(srcDir, dstDir, { dryRun = false, clean = false } = {}) {
21
- const result = { created: 0, skipped: 0, errors: 0 }
20
+ export function copyPluginFiles(srcDir, dstDir, { dryRun = false } = {}) {
21
+ const result = { created: 0, skipped: 0 }
22
22
 
23
23
  if (!dryRun) mkdirSync(dstDir, { recursive: true })
24
- if (clean && existsSync(dstDir) && !dryRun) {
25
- rmSync(dstDir, { recursive: true, force: true })
26
- mkdirSync(dstDir, { recursive: true })
27
- }
28
24
  if (!dryRun) mkdirSync(join(dstDir, 'dist'), { recursive: true })
29
25
 
30
26
  // Copy src/index.tsx -> index.tsx
31
27
  const srcIdx = join(srcDir, 'src', 'index.tsx')
32
28
  if (existsSync(srcIdx)) {
33
- const idxStatus = writeIfChanged(join(dstDir, 'index.tsx'), readFileSync(srcIdx, 'utf8'), dryRun)
29
+ const idxStatus = writeIfChanged(
30
+ join(dstDir, 'index.tsx'),
31
+ readFileSync(srcIdx, 'utf8'),
32
+ dryRun,
33
+ )
34
34
  if (idxStatus === 'created') result.created++
35
35
  else result.skipped++
36
36
  }
@@ -40,25 +40,53 @@ export function installPlugin(srcDir, dstDir, { dryRun = false, clean = false }
40
40
  if (existsSync(distSrc)) {
41
41
  for (const f of readdirSync(distSrc)) {
42
42
  const c = readFileSync(join(distSrc, f), 'utf8')
43
- writeIfChanged(join(dstDir, 'dist', f), c, dryRun)
44
- result.created++
43
+ const status = writeIfChanged(join(dstDir, 'dist', f), c, dryRun)
44
+ if (status === 'created') result.created++
45
+ else result.skipped++
45
46
  }
46
47
  }
47
48
 
48
49
  // Copy package.json
49
50
  const pkgSrc = join(srcDir, 'package.json')
50
51
  if (existsSync(pkgSrc)) {
51
- const pkgStatus = writeIfChanged(join(dstDir, 'package.json'), readFileSync(pkgSrc, 'utf8'), dryRun)
52
+ const pkgStatus = writeIfChanged(
53
+ join(dstDir, 'package.json'),
54
+ readFileSync(pkgSrc, 'utf8'),
55
+ dryRun,
56
+ )
52
57
  if (pkgStatus === 'created') result.created++
53
58
  else result.skipped++
54
59
  }
55
60
 
61
+ return result
62
+ }
63
+
64
+ /**
65
+ * Install a plugin from source to destination.
66
+ * @param {string} srcDir - Source plugin directory (e.g. ROOT/src/plugins/tui)
67
+ * @param {string} dstDir - Destination plugin directory
68
+ * @param {object} [options]
69
+ * @param {boolean} [options.dryRun=false] - If true, don't write files
70
+ * @param {boolean} [options.clean=false] - If true, remove existing before install
71
+ * @returns {{ created: number, skipped: number, errors: number }}
72
+ */
73
+ export function installPlugin(srcDir, dstDir, { dryRun = false, clean = false } = {}) {
74
+ const result = { created: 0, skipped: 0, errors: 0 }
75
+
76
+ if (clean && existsSync(dstDir) && !dryRun) {
77
+ rmSync(dstDir, { recursive: true, force: true })
78
+ }
79
+
80
+ const copyResult = copyPluginFiles(srcDir, dstDir, { dryRun })
81
+ result.created += copyResult.created
82
+ result.skipped += copyResult.skipped
83
+
56
84
  // Install plugin dependencies (@opentui/core, @opentui/solid, solid-js)
57
85
  if (!dryRun) {
58
86
  try {
59
87
  execSync('npm install --omit=dev --no-audit', { cwd: dstDir, stdio: 'pipe' })
60
88
  } catch (e) {
61
- warning('Failed to install TUI plugin dependencies: ' + e.message)
89
+ warning(`Failed to install TUI plugin dependencies: ${e.message}`)
62
90
  result.errors++
63
91
  }
64
92
  }
@@ -77,7 +105,7 @@ export function installPluginDependencies(pluginDir, { dryRun = false } = {}) {
77
105
  try {
78
106
  execSync('npm install --omit=dev --no-audit', { cwd: pluginDir, stdio: 'pipe' })
79
107
  } catch (e) {
80
- warning('Failed to install plugin dependencies: ' + e.message)
108
+ warning(`Failed to install plugin dependencies: ${e.message}`)
81
109
  }
82
110
  }
83
111
 
@@ -108,6 +136,55 @@ export function registerPlugin(tuiJsonPath, pluginId, { dryRun = false } = {}) {
108
136
  return writeIfChanged(tuiJsonPath, tuiContent, dryRun)
109
137
  }
110
138
 
139
+ /**
140
+ * Suffix patterns that identify a Pantheon TUI registration as STALE. Every
141
+ * reference shape ever written by previous installers must be covered:
142
+ * - dist file refs (both the old in-package src/plugins/tui target and the
143
+ * copied plugins/pantheon-tui target, tsx and js eras)
144
+ * - the in-package source dir (src/plugins/tui) — a registration pointing
145
+ * into the installed package breaks on reinstall/upgrade (the dir is
146
+ * replaced under a live registration)
147
+ * - bare copied dirs (plugins/pantheon-tui) from any OLD install target —
148
+ * indistinguishable from the current copied dir without knowing the
149
+ * install target, so they are flagged too; syncTuiRegistration keeps the
150
+ * current ref via unregister-then-register
151
+ * - node_modules copies (npx cache installs of the vendored plugin)
152
+ */
153
+ export const TUI_STALE_SUFFIXES = [
154
+ '/src/plugins/tui/dist/tui.tsx',
155
+ '/src/plugins/tui/dist/tui.js',
156
+ '/src/plugins/tui',
157
+ '/plugins/pantheon-tui/dist/tui.tsx',
158
+ '/plugins/pantheon-tui/dist/tui.js',
159
+ '/plugins/pantheon-tui',
160
+ '/node_modules/pantheon-tui',
161
+ ]
162
+
163
+ /**
164
+ * Whether a single tui.json plugin entry is a stale Pantheon TUI reference.
165
+ * @param {unknown} ref - tui.json plugin entry
166
+ * @returns {boolean}
167
+ */
168
+ export function isStaleTuiRef(ref) {
169
+ if (typeof ref !== 'string') return false
170
+ if (ref === 'plugins/pantheon-tui') return true
171
+ const normalized = ref.replaceAll('\\', '/')
172
+ // npx spec strings ("npx pantheon-tui", "npx -y pantheon-tui",
173
+ // "npx:pantheon-tui") and npx cache paths end with /node_modules/pantheon-tui.
174
+ if (/^npx[\s:]/i.test(normalized) && normalized.includes('pantheon-tui')) return true
175
+ return TUI_STALE_SUFFIXES.some((suffix) => normalized.endsWith(suffix))
176
+ }
177
+
178
+ /**
179
+ * Pure filter: return the plugin entries of a tui.json plugin array that are
180
+ * stale Pantheon TUI references.
181
+ * @param {unknown[]} pluginArray - tui.json plugin array
182
+ * @returns {string[]}
183
+ */
184
+ export function staleTuiRefs(pluginArray) {
185
+ return (Array.isArray(pluginArray) ? pluginArray : []).filter(isStaleTuiRef)
186
+ }
187
+
111
188
  /**
112
189
  * Unregister stale plugin references from tui.json.
113
190
  * @param {string} tuiJsonPath - Path to tui.json
@@ -117,25 +194,32 @@ export function registerPlugin(tuiJsonPath, pluginId, { dryRun = false } = {}) {
117
194
  * @returns {'created'|'skipped'}
118
195
  */
119
196
  export function unregisterPlugin(tuiJsonPath, pluginId, { dryRun = false } = {}) {
197
+ if (!existsSync(tuiJsonPath)) {
198
+ // Nothing to clean — and never create an empty tui.json in a location
199
+ // OpenCode reads (the cross-location cleanup also touches global config
200
+ // dirs that may not exist yet; writing there would ENOENT).
201
+ return 'skipped'
202
+ }
120
203
  let tuiConfig = { plugin: [] }
121
- if (existsSync(tuiJsonPath)) {
122
- try {
123
- tuiConfig = JSON.parse(readFileSync(tuiJsonPath, 'utf8'))
124
- } catch {
125
- /* use default */
126
- }
204
+ try {
205
+ tuiConfig = JSON.parse(readFileSync(tuiJsonPath, 'utf8'))
206
+ } catch {
207
+ /* use default */
127
208
  }
128
209
  if (!Array.isArray(tuiConfig.plugin)) {
129
210
  tuiConfig.plugin = []
130
211
  }
131
212
  // Remove stale refs: the bare relative id (openCode < 1.19 wrote
132
213
  // "plugins/pantheon-tui", which its tui loader misreads as an npm/github
133
- // spec → NpmInstallFailedError) and the old dist paths.
134
- const staleRefs = [pluginId, `${pluginId}/dist/tui.tsx`, `${pluginId}/dist/tui.js`]
135
- for (const stale of staleRefs) {
136
- const idx = tuiConfig.plugin.indexOf(stale)
137
- if (idx !== -1) tuiConfig.plugin.splice(idx, 1)
138
- }
214
+ // spec → NpmInstallFailedError), old dist paths, in-package source dirs,
215
+ // bare copy dirs from previous install targets, and npx cache copies.
216
+ // Without removing the absolute paths, each init run accumulates another
217
+ // TUI plugin and OpenCode loads stale copies.
218
+ const exactStale = [pluginId, `${pluginId}/dist/tui.tsx`, `${pluginId}/dist/tui.js`]
219
+ tuiConfig.plugin = tuiConfig.plugin.filter((ref) => {
220
+ if (exactStale.includes(ref)) return false
221
+ return !isStaleTuiRef(ref)
222
+ })
139
223
  const tuiContent = `${JSON.stringify(tuiConfig, null, 2)}\n`
140
224
  return writeIfChanged(tuiJsonPath, tuiContent, dryRun)
141
225
  }
@@ -37,7 +37,7 @@ const ROOT = join(__dirname, '..')
37
37
  // venv python3 and the server script. ROOT alone is ambiguous: when this
38
38
  // installer runs from a global npm install, `join(ROOT, '.venv/bin/python3')`
39
39
  // points at a venv that does not exist there, and a stale config with a
40
- // hardcoded path to a migrated home (e.g. /home/admin) fails with
40
+ // hardcoded path to a migrated home (e.g. <dev>/admin) fails with
41
41
  // `posix_spawn ENOENT`. Resolution order:
42
42
  // 1. Canonical user install: ~/.config/opencode/.venv + ~/.config/opencode/scripts/
43
43
  // 2. Local checkout: <ROOT>/.venv + <ROOT>/<relPath>