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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/AGENTS.md +919 -0
  2. package/README.md +157 -15
  3. package/bin/pantheon-init.mjs +177 -120
  4. package/opencode.json +3 -6
  5. package/package.json +8 -4
  6. package/scripts/build-agents-md.mjs +195 -0
  7. package/scripts/cost.mjs +17 -0
  8. package/scripts/doctor.mjs +56 -22
  9. package/scripts/hooks/on-subagent-delegation-stop.sh +22 -5
  10. package/scripts/install/opencode.mjs +189 -38
  11. package/scripts/install/plugin.mjs +112 -28
  12. package/scripts/install-mcp.mjs +1 -1
  13. package/scripts/redaction-gate.mjs +69 -0
  14. package/scripts/release-beta-version.mjs +80 -0
  15. package/scripts/release-notes.mjs +317 -0
  16. package/scripts/test-context-compression.sh +5 -2
  17. package/scripts/validate-package.mjs +56 -0
  18. package/scripts/versioning.mjs +117 -18
  19. package/src/agents/zeus.md +6 -2
  20. package/src/pantheon/background-job-board.ts +24 -38
  21. package/src/pantheon/chat-reminders.ts +79 -0
  22. package/src/pantheon/compaction-assert.ts +160 -0
  23. package/src/pantheon/cost-command.ts +57 -18
  24. package/src/pantheon/delegation-compaction.ts +143 -16
  25. package/src/pantheon/delegation-finalize.ts +8 -1
  26. package/src/pantheon/delegation-notify.ts +19 -157
  27. package/src/pantheon/delegation.ts +330 -28
  28. package/src/pantheon/dispatch-guard.ts +9 -4
  29. package/src/pantheon/goal-loop.ts +7 -4
  30. package/src/pantheon/hashline/tool.ts +2 -1
  31. package/src/pantheon/logger.ts +117 -0
  32. package/src/pantheon/plugin-once.ts +76 -0
  33. package/src/pantheon/preemptive-compact.ts +76 -0
  34. package/src/pantheon/presets.d.mts +31 -0
  35. package/src/pantheon/presets.mjs +105 -3
  36. package/src/pantheon/todo-enforcer.ts +79 -27
  37. package/src/pantheon/todo-preserve.ts +295 -0
  38. package/src/plugin.ts +177 -50
  39. package/src/plugins/pantheon-hooks.ts +35 -22
  40. package/src/plugins/tui/dist/server.d.ts +17 -0
  41. package/src/plugins/tui/dist/server.d.ts.map +1 -0
  42. package/src/plugins/tui/dist/server.js +20 -0
  43. package/src/plugins/tui/dist/server.js.map +1 -0
  44. package/src/plugins/tui/dist/tui.d.ts +303 -1
  45. package/src/plugins/tui/dist/tui.d.ts.map +1 -1
  46. package/src/plugins/tui/dist/tui.js +977 -350
  47. package/src/plugins/tui/dist/tui.tsx +1427 -150
  48. package/src/plugins/tui/package.json +4 -3
  49. package/src/plugins/tui/src/index.tsx +1427 -150
  50. package/src/plugins/tui/src/server.ts +15 -0
  51. package/src/plugins/tui/tsdown.config.ts +1 -1
  52. package/src/routing.yml +3 -1
@@ -1,17 +1,19 @@
1
1
  #!/usr/bin/env node
2
2
  /**
3
3
  * opencode.mjs — OpenCode platform installer
4
+ *
5
+ * Dual-version install (Phase 3): the generated config is V1-shaped and valid
6
+ * under BOTH OpenCode V1 (`opencode` 1.18.x) and V2 (`opencode2`
7
+ * v0.0.0-next-17444). V2 reads the same config locations and normalizes V1
8
+ * fields in memory — do NOT convert to native V2 format. Known V2 beta gaps
9
+ * handled here: top-level `subagent_depth` is silently ignored (migrated to
10
+ * `experimental.subagent_depth`), and the `instructions` config key is
11
+ * accepted-but-not-loaded (content consolidated into AGENTS.md, which both
12
+ * versions load). Pass --version v2 to pantheon-init for an informational
13
+ * label; state isolation is handled at runtime via OPENCODE_DB.
4
14
  */
5
15
 
6
- import {
7
- chmodSync,
8
- existsSync,
9
- mkdirSync,
10
- readdirSync,
11
- readFileSync,
12
- rmSync,
13
- writeFileSync,
14
- } from 'node:fs'
16
+ import { chmodSync, existsSync, mkdirSync, readdirSync, readFileSync, rmSync } from 'node:fs'
15
17
  import { homedir } from 'node:os'
16
18
  import { basename, join, resolve } from 'node:path'
17
19
  import {
@@ -29,7 +31,7 @@ import {
29
31
  } from './cli-ui.mjs'
30
32
  import { healthCheck } from './health-check.mjs'
31
33
  import { detectVersion, runMigrations } from './migrate.mjs'
32
- import { installPlugin, registerPlugin, unregisterPlugin } from './plugin.mjs'
34
+ import { copyPluginFiles, installPlugin, registerPlugin, unregisterPlugin } from './plugin.mjs'
33
35
  import {
34
36
  collectSkillNames,
35
37
  copyFiles,
@@ -89,7 +91,7 @@ function mergeMissing(target, source) {
89
91
  * installed package.
90
92
  *
91
93
  * The packaged config may reference plugins via developer-machine absolute
92
- * paths (e.g. /home/<dev>/pantheon/src/plugin.ts). Copying those verbatim into
94
+ * paths (e.g. <dev>/pantheon/.../src/plugin.ts). Copying those verbatim into
93
95
  * the user's config would break every install on any other machine. This
94
96
  * rewrites the entry to the plugin file inside the INSTALLED package (derived
95
97
  * from ROOT — never hardcoded), so both dev and global installs resolve to a
@@ -123,6 +125,97 @@ export function isGlobalConfigDir(target) {
123
125
  return resolve(target) === xdgDir
124
126
  }
125
127
 
128
+ /**
129
+ * Derive where the TUI plugin must be COPIED for a given config directory.
130
+ *
131
+ * The TUI plugin's single source of truth is src/plugins/tui inside the
132
+ * package; the loader must NEVER be pointed at that in-package dir (a package
133
+ * reinstall/upgrade replaces the dir under a live registration and leaves
134
+ * diverging copies loading). Instead the installer always copies it into the
135
+ * config dir's plugins/ dir and registers THAT copy:
136
+ *
137
+ * <configDir>/plugins/pantheon-tui (dist/ + package.json + index.tsx)
138
+ *
139
+ * The loader reads the copied package.json `exports` map:
140
+ * exports["./tui"] → dist/tui.js (COMPILED Solid output)
141
+ * exports["./server"] → dist/server.js (no-op server() stub)
142
+ *
143
+ * @param {string} configDir - directory holding tui.json (target for global
144
+ * installs, <target>/.opencode for project installs)
145
+ * @returns {string} absolute path of the copied plugin directory
146
+ */
147
+ export function resolveTuiCopyTarget(configDir) {
148
+ return join(configDir, 'plugins', 'pantheon-tui')
149
+ }
150
+
151
+ /**
152
+ * Every tui.json location OpenCode reads: the two global config dirs
153
+ * (~/.opencode and $XDG_CONFIG_HOME/opencode) plus the target's own project
154
+ * config. Cleanup runs over ALL of them so a stale Pantheon TUI registration
155
+ * can never survive in a location the loader still reads.
156
+ *
157
+ * @param {string} target - install target directory
158
+ * @returns {string[]} unique tui.json paths (deterministic order)
159
+ */
160
+ export function tuiConfigLocations(target) {
161
+ const homeTui = join(homedir(), '.opencode', 'tui.json')
162
+ const xdgConfig = process.env.XDG_CONFIG_HOME || join(homedir(), '.config')
163
+ const xdgTui = join(xdgConfig, 'opencode', 'tui.json')
164
+ const projectTui = join(target, '.opencode', 'tui.json')
165
+ return [...new Set([homeTui, xdgTui, projectTui])]
166
+ }
167
+
168
+ /**
169
+ * Sync the Pantheon TUI plugin registration across EVERY tui.json location
170
+ * OpenCode reads.
171
+ *
172
+ * Contract (single source of truth):
173
+ * 1. ALWAYS copy src/plugins/tui → <configDir>/plugins/pantheon-tui
174
+ * (dist/* + package.json + index.tsx) so the registered reference is a
175
+ * directory that exists and carries the loader contract (exports →
176
+ * dist/tui.js, dist/server.js). The copy is idempotent: a second run is
177
+ * byte-identical.
178
+ * 2. Clean stale Pantheon TUI refs from ALL tui.json locations OpenCode
179
+ * loads — ~/.opencode, $XDG_CONFIG_HOME/opencode and
180
+ * <target>/.opencode — covering every old reference shape: dist file
181
+ * paths, in-package src/plugins/tui dirs, bare plugins/pantheon-tui
182
+ * copies, and npx paths. A stale ref left in the non-target location
183
+ * would still be loaded and break the TUI.
184
+ * 3. Register EXACTLY the copied directory in the selected target only —
185
+ * exactly ONE Pantheon TUI reference exists system-wide after install.
186
+ *
187
+ * @param {string} target - install target directory
188
+ * @param {object} [opts]
189
+ * @param {boolean} [opts.isGlobal=false] - flat global layout vs .opencode/
190
+ * @param {boolean} [opts.dryRun=false]
191
+ * @returns {'created'|'skipped'} status for the target registration
192
+ */
193
+ export function syncTuiRegistration(target, { isGlobal = false, dryRun = false } = {}) {
194
+ const configDir = isGlobal ? target : join(target, '.opencode')
195
+ const targetTuiConfigPath = join(configDir, 'tui.json')
196
+
197
+ // 1. Always copy the single source of truth into the target config's
198
+ // plugins/pantheon-tui. Registering this copied directory (never the
199
+ // in-package src/plugins/tui) keeps installs hermetic and idempotent:
200
+ // the ref points at a dir that exists and has dist/tui.js.
201
+ const tuiSrcDir = join(ROOT, 'src', 'plugins', 'tui')
202
+ const tuiCopyDir = resolveTuiCopyTarget(configDir)
203
+ if (existsSync(tuiSrcDir)) {
204
+ copyPluginFiles(tuiSrcDir, tuiCopyDir, { dryRun })
205
+ }
206
+
207
+ // 2. Clean stale Pantheon TUI refs from every location OpenCode reads.
208
+ // unregisterPlugin covers the bare relative id plus the extended stale
209
+ // patterns (dist files, in-package src/plugins/tui dir, bare
210
+ // plugins/pantheon-tui copies, npx paths).
211
+ for (const tuiConfigPath of tuiConfigLocations(target)) {
212
+ unregisterPlugin(tuiConfigPath, 'plugins/pantheon-tui', { dryRun })
213
+ }
214
+
215
+ // 3. Register the copied directory in the selected target only.
216
+ return registerPlugin(targetTuiConfigPath, tuiCopyDir, { dryRun })
217
+ }
218
+
126
219
  export async function installOpenCode(
127
220
  target,
128
221
  dryRun = false,
@@ -138,6 +231,20 @@ export async function installOpenCode(
138
231
  const componentSet = new Set(components)
139
232
  const stats = summary.opencode
140
233
 
234
+ // V1/V2 dual-version awareness (Phase 3): the SAME config works under both
235
+ // OpenCode V1 (`opencode`) and V2 (`opencode2`, beta v0.0.0-next-17444).
236
+ // V2 normalizes V1 fields in memory (no rewrite) and isolates its state DB
237
+ // via OPENCODE_DB (~/.local/share/opencode/opencode-v2.db) with a distinct
238
+ // service port (49375 vs V1's 49374). The installer writes one shared
239
+ // config; the --version flag only labels the install for the operator.
240
+ const version = opts.version === 'v2' ? 'v2' : 'v1'
241
+ if (version === 'v2') {
242
+ info(
243
+ 'OpenCode V2 target — shared config; state isolated via OPENCODE_DB ' +
244
+ '(~/.local/share/opencode/opencode-v2.db), service port 49375',
245
+ )
246
+ }
247
+
141
248
  // Interactive mode handling
142
249
  const stdinTTY = process.stdin.isTTY && process.stdout.isTTY
143
250
  const forceInteractive = opts.interactive === true
@@ -250,8 +357,13 @@ export async function installOpenCode(
250
357
  }
251
358
 
252
359
  // -----------------------------------------------------------------------
253
- // 2.5 Install instructions: AGENTS.md + instructions/ (--components instructions)
360
+ // 2.5 Install instructions: AGENTS.md only (--components instructions)
254
361
  // -----------------------------------------------------------------------
362
+ // AGENTS.md is the single instruction file both V1 and V2 load; the
363
+ // generated file embeds every src/instructions/*.instructions.md body, so
364
+ // the individual files are NOT copied anymore (nothing references
365
+ // <config>/instructions/ after the section-D merge fix — copying them would
366
+ // just leave stale duplicates on disk).
255
367
  if (componentSet.has('instructions')) {
256
368
  section('\uD83D\uDCCB Instructions')
257
369
  // AGENTS.md
@@ -263,14 +375,6 @@ export async function installOpenCode(
263
375
  if (status === 'created') stats.created++
264
376
  else stats.skipped++
265
377
  }
266
- // instructions/ directory
267
- const srcInstr = join(ROOT, 'src', 'instructions')
268
- const dstInstr = join(target, 'instructions')
269
- if (existsSync(srcInstr)) {
270
- const instrResult = syncDir(srcInstr, dstInstr, dryRun, clean)
271
- stats.created += instrResult.created
272
- stats.skipped += instrResult.skipped
273
- }
274
378
  }
275
379
 
276
380
  // -----------------------------------------------------------------------
@@ -327,19 +431,7 @@ export async function installOpenCode(
327
431
  // -----------------------------------------------------------------------
328
432
  // 2.9 Create/update tui.json with plugin registration
329
433
  // -----------------------------------------------------------------------
330
- const targetTuiConfigPath = isGlobal
331
- ? join(target, 'tui.json')
332
- : join(target, '.opencode', 'tui.json')
333
- // Hermetic TUI plugin registration: opencode's tui loader treats relative
334
- // entries ("plugins/pantheon-tui") as npm/github package specs and fails
335
- // with NpmInstallFailedError ("Repository not found"). Register the plugin
336
- // by the ABSOLUTE path of the built dist inside the installed package
337
- // (derived from ROOT — never hardcoded), mirroring the loader's
338
- // absolute-path support (same pattern as the hooks plugin fix).
339
- const tuiPluginRef = join(ROOT, 'src', 'plugins', 'tui', 'dist', 'tui.tsx')
340
- // Clean up the old broken relative ref (and stale dist refs) on upgrade.
341
- unregisterPlugin(targetTuiConfigPath, 'plugins/pantheon-tui', { dryRun })
342
- const tuiStatus = registerPlugin(targetTuiConfigPath, tuiPluginRef, { dryRun })
434
+ const tuiStatus = syncTuiRegistration(target, { isGlobal, dryRun })
343
435
  if (tuiStatus === 'created') stats.created++
344
436
  else stats.skipped++
345
437
 
@@ -590,19 +682,44 @@ export async function installOpenCode(
590
682
  if (!config.default_agent && pantheonConfig.default_agent) {
591
683
  config.default_agent = pantheonConfig.default_agent
592
684
  }
685
+ // Merge top-level model/small_model from the repo config when the target
686
+ // config has no top-level `model` (fresh sandbox installs have none). The
687
+ // repo's opencode.json carries the default pair
688
+ // (opencode-go/deepseek-v4-flash); a user-set model is never overwritten.
689
+ if (!config.model && pantheonConfig.model) {
690
+ config.model = pantheonConfig.model
691
+ if (!config.small_model && pantheonConfig.small_model) {
692
+ config.small_model = pantheonConfig.small_model
693
+ }
694
+ }
593
695
  // Merge subagent_depth HERE (the config merge always runs, including on a
594
696
  // fresh install) instead of a file-existence-gated block, so run #1 already
595
697
  // produces the final config — run #2 must be a byte-identical no-op (#19).
596
- if (config.subagent_depth === undefined) {
597
- config.subagent_depth = 2
698
+ //
699
+ // V1/V2 compat (Phase 3): top-level `subagent_depth` is in V2's
700
+ // unsupportedTopLevel list and is SILENTLY IGNORED. The accepted form is
701
+ // `experimental.subagent_depth`. Migrate any existing top-level value into
702
+ // experimental (preserving the user's choice) and default fresh installs to
703
+ // experimental.subagent_depth = 2. V1 reads experimental.subagent_depth
704
+ // too, so the V1-shape config stays valid under both versions.
705
+ if (config.experimental === undefined || typeof config.experimental !== 'object') {
706
+ config.experimental = {}
707
+ }
708
+ if (config.subagent_depth !== undefined) {
709
+ if (config.experimental.subagent_depth === undefined) {
710
+ config.experimental.subagent_depth = config.subagent_depth
711
+ }
712
+ delete config.subagent_depth
713
+ } else if (config.experimental.subagent_depth === undefined) {
714
+ config.experimental.subagent_depth = 2
598
715
  }
599
716
 
600
717
  // --------------------------------------------------------------------
601
718
  // B.5.1 Hermetic plugin resolution (packaging fix)
602
719
  // --------------------------------------------------------------------
603
720
  // 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
721
+ // absolute paths (e.g. <dev>/pantheon/.../src/plugin.ts or
722
+ // <dev>/pantheon/.../src/plugins/pantheon-hooks.ts). Copying those
606
723
  // verbatim into the user's config would break every global install on any
607
724
  // other machine. resolveInstalledPlugin() rewrites each entry to the plugin
608
725
  // file inside the INSTALLED package (derived from ROOT — never hardcoded),
@@ -624,6 +741,25 @@ export async function installOpenCode(
624
741
  }
625
742
  }
626
743
 
744
+ // The two Pantheon plugins MUST always be registered, on fresh installs AND
745
+ // upgrades. The packaged opencode.json carries no `plugin` key (dev-machine
746
+ // paths removed in 3e552cc), so config.plugin would otherwise only contain
747
+ // whatever the user had — a fresh install would register NOTHING and lose
748
+ // delegation tools, GoalLoop, cost command, hashline and vision. Register
749
+ // both unconditionally, replacing any stale entry for the same file
750
+ // (basename-dedup) so installs and upgrades are idempotent and hermetic
751
+ // while preserving any user plugins.
752
+ const ensurePantheonPlugin = (ref) => {
753
+ const resolved = resolveInstalledPlugin(ref)
754
+ const file = basename(resolved)
755
+ config.plugin = config.plugin.filter((p) => basename(p) !== file)
756
+ config.plugin.push(resolved)
757
+ }
758
+ // Root-level delegation plugin (PR #45): tools, GoalLoop, cost, hashline.
759
+ ensurePantheonPlugin('src/plugin.ts')
760
+ // Runtime hooks plugin: chat hooks, hook-runner, TUI wiring.
761
+ ensurePantheonPlugin('src/plugins/pantheon-hooks.ts')
762
+
627
763
  if (!config.provider && pantheonConfig.provider) {
628
764
  config.provider = deepClone(pantheonConfig.provider)
629
765
  } else if (config.provider && pantheonConfig.provider) {
@@ -665,10 +801,25 @@ export async function installOpenCode(
665
801
  // --------------------------------------------------------------------
666
802
  // D. Merge instructions paths
667
803
  // --------------------------------------------------------------------
668
- const pantheonInstructions = ['AGENTS.md', 'instructions/*.instructions.md']
804
+ // AGENTS.md is the single instruction file both V1 and V2 load (V1
805
+ // auto-discovers it, V2 loads it explicitly). The generated AGENTS.md
806
+ // (scripts/build-agents-md.mjs) already embeds every
807
+ // src/instructions/*.instructions.md body, so the `instructions/*.md`
808
+ // glob is no longer needed — keeping it would duplicate content under V1
809
+ // and is ignored under V2 anyway. Only ensure AGENTS.md is present.
810
+ const pantheonInstructions = ['AGENTS.md']
669
811
  if (!config.instructions) {
670
812
  config.instructions = [...pantheonInstructions]
671
813
  } else {
814
+ // Strip stale per-file instruction globs from PREVIOUS installs
815
+ // (e.g. `instructions/*.instructions.md` / `src/instructions/*.md`).
816
+ // The merge is additive-only otherwise, so without this filter existing
817
+ // configs would keep loading instruction content twice under V1
818
+ // (generated AGENTS.md + individual files). User entries that are not
819
+ // *.instructions.md globs are preserved.
820
+ config.instructions = config.instructions.filter(
821
+ (instr) => typeof instr !== 'string' || !instr.endsWith('.instructions.md'),
822
+ )
672
823
  for (const instr of pantheonInstructions) {
673
824
  if (!config.instructions.includes(instr)) {
674
825
  config.instructions.push(instr)
@@ -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>
@@ -0,0 +1,69 @@
1
+ #!/usr/bin/env node
2
+
3
+ /** Fail closed when validation artifacts contain callback URLs or credentials. */
4
+
5
+ import { execFileSync } from 'node:child_process'
6
+ import { existsSync, lstatSync, readdirSync, readFileSync } from 'node:fs'
7
+ import { basename, join } from 'node:path'
8
+
9
+ const callbackUrl = /https?:\/\/[^\s"'<>]+(?:callback|redirect|return|webhook)[^\s"'<>]*/iu
10
+ const bearer = /\bBearer\s+[A-Za-z0-9._~+/=-]{12,}\b/u
11
+ const jwt = /\beyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\b/u
12
+ const apiSecret =
13
+ /\b(?:api[_-]?key|token|secret|password)\s*[=:]\s*["'](?!\$\{|process\.env|os\.getenv|env:|<redacted>|\[redacted\]|fixture)[^"']{12,}["']/iu
14
+
15
+ function isText(buffer) {
16
+ return !buffer.subarray(0, Math.min(buffer.length, 4096)).includes(0)
17
+ }
18
+
19
+ function findings(text) {
20
+ const result = []
21
+ if (callbackUrl.test(text)) result.push('callback URL')
22
+ if (bearer.test(text) || jwt.test(text) || apiSecret.test(text)) result.push('credential/token')
23
+ return result
24
+ }
25
+
26
+ function scanFile(path) {
27
+ if (!existsSync(path) || !lstatSync(path).isFile()) return []
28
+ const content = readFileSync(path)
29
+ return isText(content) ? findings(content.toString('utf8')) : []
30
+ }
31
+
32
+ function scanTarball(path) {
33
+ const entries = execFileSync('tar', ['-tzf', path], { encoding: 'utf8' }).split('\n').filter(Boolean)
34
+ const result = []
35
+ for (const entry of entries) {
36
+ const content = execFileSync('tar', ['-xOzf', path, entry])
37
+ if (isText(content)) result.push(...findings(content.toString('utf8')))
38
+ }
39
+ return [...new Set(result)]
40
+ }
41
+
42
+ function scanPath(path) {
43
+ if (path.endsWith('.tgz') || path.endsWith('.tar.gz')) return scanTarball(path)
44
+ if (!existsSync(path)) throw new Error(`path does not exist: ${path}`)
45
+ if (lstatSync(path).isDirectory()) {
46
+ return readdirSync(path, { withFileTypes: true }).flatMap((entry) =>
47
+ scanPath(join(path, entry.name)),
48
+ )
49
+ }
50
+ return scanFile(path)
51
+ }
52
+
53
+ const paths = process.argv.slice(2)
54
+ if (paths.length === 0) {
55
+ console.error(`Usage: ${basename(process.argv[1])} <file|directory|tarball> [...]`)
56
+ process.exit(2)
57
+ }
58
+
59
+ try {
60
+ const hits = [...new Set(paths.flatMap(scanPath))]
61
+ if (hits.length > 0) {
62
+ for (const hit of hits) console.error(`[REDACTION GATE] ${hit} detected`)
63
+ process.exit(1)
64
+ }
65
+ console.log('Redaction gate passed: no callback URLs or credentials detected.')
66
+ } catch (error) {
67
+ console.error(`[REDACTION GATE] failed to inspect input: ${error.message}`)
68
+ process.exit(2)
69
+ }