pantheon-opencode 1.4.2 → 1.5.0-beta.1

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 (98) hide show
  1. package/.pantheon/code-mode/.gitkeep +0 -0
  2. package/.pantheon/code-mode/.pantheon/deepwork/initial-token-40k/checkpoint-1.json +8 -0
  3. package/.pantheon/code-mode/.pantheon/deepwork/initial-token-40k/checkpoint-10.json +8 -0
  4. package/.pantheon/code-mode/.pantheon/deepwork/initial-token-40k/checkpoint-11.json +8 -0
  5. package/.pantheon/code-mode/.pantheon/deepwork/initial-token-40k/checkpoint-2.json +8 -0
  6. package/.pantheon/code-mode/.pantheon/deepwork/initial-token-40k/checkpoint-3.json +8 -0
  7. package/.pantheon/code-mode/.pantheon/deepwork/initial-token-40k/checkpoint-4.json +8 -0
  8. package/.pantheon/code-mode/.pantheon/deepwork/initial-token-40k/checkpoint-5.json +8 -0
  9. package/.pantheon/code-mode/.pantheon/deepwork/initial-token-40k/checkpoint-6.json +8 -0
  10. package/.pantheon/code-mode/.pantheon/deepwork/initial-token-40k/checkpoint-7.json +8 -0
  11. package/.pantheon/code-mode/.pantheon/deepwork/initial-token-40k/checkpoint-8.json +8 -0
  12. package/.pantheon/code-mode/.pantheon/deepwork/initial-token-40k/checkpoint-9.json +8 -0
  13. package/.pantheon/code-mode/.pantheon/deepwork/initial-token-40k/heartbeat.json +8 -0
  14. package/.pantheon/code-mode/.pantheon/deepwork/initial-token-40k/session.json +11 -0
  15. package/.pantheon/code-mode/README.md +93 -0
  16. package/.pantheon/code-mode/checkpoint-session.py +7 -0
  17. package/.pantheon/code-mode/checkpoint-session.sh +11 -0
  18. package/.pantheon/code-mode/checkpoint_session.py +481 -0
  19. package/.pantheon/code-mode/compress-inline.py +450 -0
  20. package/.pantheon/code-mode/eval-llm-judge.py +255 -0
  21. package/.pantheon/code-mode/eval-monte-carlo.py +368 -0
  22. package/.pantheon/code-mode/eval-run.py +197 -0
  23. package/.pantheon/code-mode/eval-static.py +321 -0
  24. package/.pantheon/code-mode/example-sync.sh +5 -0
  25. package/.pantheon/code-mode/session-end-save.py +186 -0
  26. package/.pantheon/code-mode/session-end-save.sh +18 -0
  27. package/.pantheon/code-mode/test-allowed.sh +5 -0
  28. package/.pantheon/code-mode/test-auto-continue-checkpoint.sh +179 -0
  29. package/.pantheon/code-mode/test-env.sh +7 -0
  30. package/.pantheon/code-mode/test-prlimit.sh +9 -0
  31. package/.pantheon/code-mode/test-timeout.sh +6 -0
  32. package/README.md +158 -1189
  33. package/README.pt-BR.md +222 -0
  34. package/bin/pantheon-init.mjs +30 -11
  35. package/opencode.json +1 -0
  36. package/package.json +14 -3
  37. package/plugin.json +15 -0
  38. package/scripts/_pantheon_paths.py +48 -7
  39. package/scripts/ci-validate-yaml.py +7 -2
  40. package/scripts/code_mode_server.py +33 -2
  41. package/scripts/doctor.mjs +135 -127
  42. package/scripts/generate-preset-docs.mjs +127 -66
  43. package/scripts/generate-prompts.sh +2 -2
  44. package/scripts/install/cli-ui.mjs +8 -1
  45. package/scripts/install/config-migration.mjs +401 -0
  46. package/scripts/install/health-check.mjs +3 -3
  47. package/scripts/install/model-picker.mjs +72 -23
  48. package/scripts/install/opencode-version.mjs +47 -0
  49. package/scripts/install/opencode.mjs +261 -88
  50. package/scripts/install/plugin.mjs +112 -42
  51. package/scripts/install/shared.mjs +13 -158
  52. package/scripts/install-mcp.mjs +44 -232
  53. package/scripts/mcp_persistence_server.py +344 -15
  54. package/scripts/mcp_resources_server.py +144 -40
  55. package/scripts/memory_mcp_server.py +2 -2
  56. package/scripts/recover-zenodo-deposition.mjs +146 -0
  57. package/scripts/sync-tui.mjs +32 -7
  58. package/scripts/test-opencode-v1-v2-sandbox.sh +591 -0
  59. package/scripts/uninstall.mjs +298 -954
  60. package/scripts/validate-zenodo-release.mjs +154 -0
  61. package/scripts/zenodo-artifact.mjs +21 -0
  62. package/scripts/zenodo-release-state.mjs +53 -0
  63. package/src/mcp/_pantheon_paths.py +48 -7
  64. package/src/mcp/code_mode_server.py +33 -2
  65. package/src/mcp/eval_store.py +1 -1
  66. package/src/mcp/mcp_persistence_server.py +344 -15
  67. package/src/mcp/mcp_resources_server.py +144 -40
  68. package/src/mcp/pantheon_vision_server.py +1 -1
  69. package/src/mcp/toon_codec.py +491 -0
  70. package/src/pantheon/background-job-board.ts +67 -1
  71. package/src/pantheon/cost-command.ts +6 -2
  72. package/src/pantheon/delegate-manager.ts +384 -0
  73. package/src/pantheon/delegate-task-adapter.ts +444 -0
  74. package/src/pantheon/delegation-notify.ts +35 -20
  75. package/src/pantheon/model-command.ts +86 -32
  76. package/src/pantheon/token-opt.ts +555 -0
  77. package/src/pantheon/toon-codec.ts +481 -0
  78. package/src/pantheon/tui-version-check.ts +1 -3
  79. package/src/pantheon/v2-bridge.ts +129 -0
  80. package/src/pantheon/v2-events.ts +131 -0
  81. package/src/pantheon/v2-hooks.ts +287 -0
  82. package/src/pantheon/v2-tool-definitions.ts +331 -0
  83. package/src/plugin-v2/index.ts +21 -0
  84. package/src/plugin-v2.ts +836 -0
  85. package/src/plugin.ts +57 -10
  86. package/src/plugins/tui/dist/server.d.ts +1 -1
  87. package/src/plugins/tui/dist/server.d.ts.map +1 -1
  88. package/src/plugins/tui/dist/server.js +1 -1
  89. package/src/plugins/tui/dist/server.js.map +1 -1
  90. package/src/plugins/tui/dist/tui.d.ts +10 -7
  91. package/src/plugins/tui/dist/tui.d.ts.map +1 -1
  92. package/src/plugins/tui/dist/tui.js +11 -8
  93. package/src/plugins/tui/dist/tui.js.map +1 -1
  94. package/src/plugins/tui/dist/tui.tsx +22 -16
  95. package/src/plugins/tui/package-lock.json +51 -51
  96. package/src/plugins/tui/package.json +7 -7
  97. package/src/plugins/tui/src/index.tsx +22 -16
  98. package/src/skills/README.md +0 -2
@@ -2,20 +2,19 @@
2
2
  /**
3
3
  * opencode.mjs — OpenCode platform installer
4
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.
5
+ * Dual-version install (Phase 3): V1 and V2 receive the schema each runtime
6
+ * actually consumes. V1 (`opencode` 1.18.x) uses the singular `plugin` key;
7
+ * V2 (`opencode2` v0.0.0-next-17444) converts that list to `plugins` before
8
+ * writing its config. Known V2 beta gaps handled here: top-level
9
+ * `subagent_depth` is migrated to `experimental.subagent_depth`, and the
10
+ * instruction content is consolidated into AGENTS.md, which both versions
11
+ * load. Pass --version v2 to pantheon-init for an informational label; state
12
+ * isolation is handled at runtime via OPENCODE_DB.
14
13
  */
15
14
 
16
15
  import { chmodSync, existsSync, mkdirSync, readdirSync, readFileSync, rmSync } from 'node:fs'
17
16
  import { homedir } from 'node:os'
18
- import { basename, join, resolve } from 'node:path'
17
+ import { dirname, isAbsolute, join, resolve } from 'node:path'
19
18
  import {
20
19
  bullet,
21
20
  colors,
@@ -29,8 +28,10 @@ import {
29
28
  success,
30
29
  warning,
31
30
  } from './cli-ui.mjs'
31
+ import { migrateV1toV2 } from './config-migration.mjs'
32
32
  import { healthCheck } from './health-check.mjs'
33
33
  import { detectVersion, runMigrations } from './migrate.mjs'
34
+ import { resolveOpenCodeVersion } from './opencode-version.mjs'
34
35
  import { copyPluginFiles, installPlugin, registerPlugin, unregisterPlugin } from './plugin.mjs'
35
36
  import {
36
37
  collectSkillNames,
@@ -101,24 +102,134 @@ function mergeMissing(target, source) {
101
102
  * Resolve a plugin ref from the packaged opencode.json to a path INSIDE the
102
103
  * installed package.
103
104
  *
104
- * The packaged config may reference plugins via developer-machine absolute
105
- * paths (e.g. <dev>/pantheon/.../src/plugin.ts). Copying those verbatim into
106
- * the user's config would break every install on any other machine. This
107
- * rewrites the entry to the plugin file inside the INSTALLED package (derived
108
- * from ROOT — never hardcoded), so both dev and global installs resolve to a
109
- * path that actually exists.
105
+ * Only exact Pantheon plugin identities are resolved into the installed
106
+ * package. In particular, a third-party absolute path ending in
107
+ * `src/plugin.ts` or `src/plugins/pantheon-hooks.ts` is not evidence that the
108
+ * plugin belongs to Pantheon and must be preserved verbatim.
110
109
  */
111
110
  export function resolveInstalledPlugin(plugin) {
112
- // Map any ref whose path tail starts with `src/` to the matching file INSIDE
113
- // the installed package. This preserves the ref's relative location, so both
114
- // src/plugins/* (hooks) and the root-level src/plugin.ts (delegation plugin,
115
- // PR #45) resolve correctly — nothing is forced into src/plugins/.
116
- const srcIndex = plugin.indexOf('src/')
117
- if (srcIndex === -1) return plugin
118
- const packaged = join(ROOT, plugin.slice(srcIndex))
119
- return existsSync(packaged) ? packaged : plugin
111
+ if (typeof plugin !== 'string') return plugin
112
+ const normalized = normalizePluginRef(plugin)
113
+ if (PANTHEON_PLUGIN_IDENTITIES.has(normalized)) return join(ROOT, normalized)
114
+ // Legacy V2 file ref (pre-directory-contract): migrate to the installed
115
+ // plugin directory so the beta loader accepts the entry.
116
+ if (normalized === PANTHEON_V2_LEGACY_FILE) return join(ROOT, PANTHEON_V2_PLUGIN)
117
+ const identity = managedPluginIdentity(plugin)
118
+ if (identity !== null && isAbsolute(normalized)) {
119
+ return INSTALLED_PANTHEON_PLUGIN_PATHS.get(identity) ?? plugin
120
+ }
121
+ return plugin
122
+ }
123
+
124
+ function normalizePluginRef(ref) {
125
+ return typeof ref === 'string' ? ref.replaceAll('\\', '/').replace(/\/$/, '') : ref
126
+ }
127
+
128
+ const PANTHEON_V1_PLUGIN = 'src/plugin.ts'
129
+ const PANTHEON_V1_HOOKS = 'src/plugins/pantheon-hooks.ts'
130
+ // Canonical V2 entry is the plugin DIRECTORY (beta loader contract: entries
131
+ // must be dirs with a real index.ts — file paths warn "must be a directory",
132
+ // symlinks are ignored). The shim at src/plugin-v2/index.ts re-exports the
133
+ // real entry src/plugin-v2.ts (single source of truth).
134
+ const PANTHEON_V2_PLUGIN = 'src/plugin-v2'
135
+ // Legacy V2 file ref from before the directory contract (installer + template
136
+ // used to register src/plugin-v2.ts). Still recognized for migration.
137
+ const PANTHEON_V2_LEGACY_FILE = 'src/plugin-v2.ts'
138
+ const PANTHEON_V1_EXPORT = 'pantheon-opencode/plugin'
139
+ const PANTHEON_V2_EXPORT = 'pantheon-opencode/plugin-v2'
140
+ const PANTHEON_PLUGIN_IDENTITIES = new Set([
141
+ PANTHEON_V1_PLUGIN,
142
+ PANTHEON_V1_HOOKS,
143
+ PANTHEON_V2_PLUGIN,
144
+ ])
145
+
146
+ const INSTALLED_PANTHEON_PLUGIN_PATHS = new Map([
147
+ [PANTHEON_V1_PLUGIN, resolve(ROOT, PANTHEON_V1_PLUGIN)],
148
+ [PANTHEON_V1_HOOKS, resolve(ROOT, PANTHEON_V1_HOOKS)],
149
+ [PANTHEON_V2_PLUGIN, resolve(ROOT, PANTHEON_V2_PLUGIN)],
150
+ ])
151
+
152
+ function normalizePluginPath(ref) {
153
+ return ref.replaceAll('\\', '/')
154
+ }
155
+
156
+ function pluginMetadataReferences(ref) {
157
+ if (typeof ref === 'string') return [ref]
158
+ if (!ref || typeof ref !== 'object' || Array.isArray(ref)) return []
159
+ return ['package', 'id', 'name', 'path', 'source']
160
+ .filter((key) => typeof ref[key] === 'string')
161
+ .map((key) => ref[key])
162
+ }
163
+
164
+ function managedPluginIdentity(ref) {
165
+ const normalized = normalizePluginRef(ref)
166
+ if (typeof normalized !== 'string') return null
167
+
168
+ if (PANTHEON_PLUGIN_IDENTITIES.has(normalized)) return normalized
169
+ if (normalized === PANTHEON_V2_LEGACY_FILE) return PANTHEON_V2_PLUGIN
170
+ if (normalized === PANTHEON_V1_EXPORT || normalized.startsWith(`${PANTHEON_V1_EXPORT}@`)) {
171
+ return PANTHEON_V1_PLUGIN
172
+ }
173
+ if (normalized === PANTHEON_V2_EXPORT || normalized.startsWith(`${PANTHEON_V2_EXPORT}@`)) {
174
+ return PANTHEON_V2_PLUGIN
175
+ }
176
+
177
+ if (isAbsolute(normalized)) {
178
+ const absolutePath = normalizePluginPath(resolve(normalized))
179
+ for (const [identity, installedPath] of INSTALLED_PANTHEON_PLUGIN_PATHS) {
180
+ if (absolutePath === normalizePluginPath(installedPath)) return identity
181
+ }
182
+ // Legacy absolute V2 file path inside OUR installed package (pre-
183
+ // directory-contract installs) migrates to the directory identity.
184
+ // ROOT-scoped: third-party paths ending in src/plugin-v2.ts stay theirs.
185
+ if (absolutePath === normalizePluginPath(resolve(ROOT, PANTHEON_V2_LEGACY_FILE))) {
186
+ return PANTHEON_V2_PLUGIN
187
+ }
188
+ }
189
+
190
+ return null
191
+ }
192
+
193
+ /**
194
+ * Return the stable identity for a Pantheon plugin, or its normalized path.
195
+ * Basenames are deliberately not used: unrelated user plugins may share a
196
+ * filename such as `plugin.ts`.
197
+ */
198
+ export function pluginReferenceIdentity(ref) {
199
+ const references = pluginMetadataReferences(ref)
200
+ for (const reference of references) {
201
+ const identity = managedPluginIdentity(reference)
202
+ if (identity !== null) return identity
203
+ }
204
+ if (references.length > 0) return normalizePluginPath(normalizePluginRef(references[0]))
205
+ return ref
206
+ }
207
+
208
+ function isPantheonPluginReference(ref) {
209
+ return PANTHEON_PLUGIN_IDENTITIES.has(pluginReferenceIdentity(ref))
210
+ }
211
+
212
+ function removePantheonPluginReferences(refs) {
213
+ return (Array.isArray(refs) ? refs : []).filter((ref) => !isPantheonPluginReference(ref))
214
+ }
215
+
216
+ function hasPluginReference(refs, candidate) {
217
+ const identity = pluginReferenceIdentity(candidate)
218
+ return refs.some((ref) => pluginReferenceIdentity(ref) === identity)
219
+ }
220
+
221
+ export function deduplicatePluginReferences(refs) {
222
+ const seen = new Set()
223
+ return (Array.isArray(refs) ? refs : []).filter((ref) => {
224
+ const identity = pluginReferenceIdentity(ref)
225
+ if (seen.has(identity)) return false
226
+ seen.add(identity)
227
+ return true
228
+ })
120
229
  }
121
230
 
231
+ export { parseOpenCodeVersion, resolveOpenCodeVersion } from './opencode-version.mjs'
232
+
122
233
  /**
123
234
  * Detect whether `target` is the user's global OpenCode installation directory
124
235
  * (~/.opencode or $XDG_CONFIG_HOME/opencode).
@@ -217,10 +328,15 @@ export function syncTuiRegistration(target, { isGlobal = false, dryRun = false }
217
328
 
218
329
  // 2. Clean stale Pantheon TUI refs from every location OpenCode reads.
219
330
  // unregisterPlugin covers the bare relative id plus the extended stale
220
- // patterns (dist files, in-package src/plugins/tui dir, bare
221
- // plugins/pantheon-tui copies, npx paths).
331
+ // patterns (dist files, identifiable in-package paths, owned copied
332
+ // directories, relative ids, and npx package identities).
222
333
  for (const tuiConfigPath of tuiConfigLocations(target)) {
223
- unregisterPlugin(tuiConfigPath, 'plugins/pantheon-tui', { dryRun })
334
+ const configDirForPath = dirname(tuiConfigPath)
335
+ const ownedCopy = resolveTuiCopyTarget(configDirForPath)
336
+ unregisterPlugin(tuiConfigPath, 'plugins/pantheon-tui', {
337
+ dryRun,
338
+ knownPantheonPaths: [ownedCopy, tuiCopyDir],
339
+ })
224
340
  }
225
341
 
226
342
  // 3. Register the copied directory in the selected target only.
@@ -242,13 +358,12 @@ export async function installOpenCode(
242
358
  const componentSet = new Set(components)
243
359
  const stats = summary.opencode
244
360
 
245
- // V1/V2 dual-version awareness (Phase 3): the SAME config works under both
246
- // OpenCode V1 (`opencode`) and V2 (`opencode2`, beta v0.0.0-next-17444).
247
- // V2 normalizes V1 fields in memory (no rewrite) and isolates its state DB
248
- // via OPENCODE_DB (~/.local/share/opencode/opencode-v2.db) with a distinct
249
- // service port (49375 vs V1's 49374). The installer writes one shared
250
- // config; the --version flag only labels the install for the operator.
251
- const version = opts.version === 'v2' ? 'v2' : 'v1'
361
+ // V1/V2 dual-version awareness (Phase 3): the installer builds a unified
362
+ // config, then applies V2-native migration (migrateV1toV2) when
363
+ // --opencode-version=v2. V2 normalizes V1 fields in memory (no rewrite) and
364
+ // isolates its state DB via OPENCODE_DB (~/.local/share/opencode/opencode-v2.db)
365
+ // with a distinct service port (49375 vs V1's 49374).
366
+ const version = resolveOpenCodeVersion(opts.version ?? 'v1')
252
367
  if (version === 'v2') {
253
368
  info(
254
369
  'OpenCode V2 target — shared config; state isolated via OPENCODE_DB ' +
@@ -445,11 +560,20 @@ export async function installOpenCode(
445
560
  }
446
561
 
447
562
  // -----------------------------------------------------------------------
448
- // 2.9 Create/update tui.json with plugin registration
563
+ // 2.9 Create/update tui.json with plugin registration (--components plugins)
449
564
  // -----------------------------------------------------------------------
450
- const tuiStatus = syncTuiRegistration(target, { isGlobal, dryRun })
451
- if (tuiStatus === 'created') stats.created++
452
- else stats.skipped++
565
+ if (componentSet.has('plugins')) {
566
+ let tuiStatus
567
+ try {
568
+ tuiStatus = syncTuiRegistration(target, { isGlobal, dryRun })
569
+ } catch (cause) {
570
+ error(`TUI plugin setup failed: ${cause.message}`)
571
+ stats.errors++
572
+ throw new Error('TUI plugin setup failed; installation was not completed', { cause })
573
+ }
574
+ if (tuiStatus === 'created') stats.created++
575
+ else stats.skipped++
576
+ }
453
577
 
454
578
  // -----------------------------------------------------------------------
455
579
  // 2.10 Install runtime infrastructure (--components runtime)
@@ -531,6 +655,14 @@ export async function installOpenCode(
531
655
  const cmResult = syncDir(srcCodeModeDir, dstCodeModeDir, dryRun, clean)
532
656
  stats.created += cmResult.created
533
657
  stats.skipped += cmResult.skipped
658
+ } else {
659
+ // The code-mode payload ships inside the published tarball
660
+ // (package.json `files` includes .pantheon/code-mode/**). When it is
661
+ // absent, still create the runtime dir so the code-mode MCP server has
662
+ // a stable home, and warn loudly instead of silently skipping.
663
+ warning(`Code-mode source not found: ${srcCodeModeDir} — creating empty ${dstCodeModeDir}`)
664
+ if (!dryRun) mkdirSync(dstCodeModeDir, { recursive: true })
665
+ stats.created++
534
666
  }
535
667
 
536
668
  // ── tiers.json ──
@@ -547,7 +679,7 @@ export async function installOpenCode(
547
679
  // -----------------------------------------------------------------------
548
680
  // 3. Create/update opencode.json (always runs)
549
681
  // Reads TARGET's existing config first, then merges Pantheon settings
550
- // on top. Preserves user's MCP, provider, plugin, compaction, theme.
682
+ // on top. Preserves user's MCP, provider, plugins, compaction, theme.
551
683
  // -----------------------------------------------------------------------
552
684
  // --------------------------------------------------------------------
553
685
  // A. Parse canonical agent config from agents/*.agent.md frontmatter
@@ -635,9 +767,8 @@ export async function installOpenCode(
635
767
  existing[field] = JSON.parse(JSON.stringify(agentCfg[field]))
636
768
  }
637
769
  }
638
- // Remove stale fields that are no longer in canonical config
639
- delete existing.model
640
- delete existing.small_model
770
+ // Existing agent fields belong to the user; never remove them during
771
+ // an install. Canonical values are merged only when absent.
641
772
  } else {
642
773
  // ── New agent ──
643
774
  const newAgent = {}
@@ -663,17 +794,7 @@ export async function installOpenCode(
663
794
  }
664
795
  }
665
796
 
666
- // Remove stale agents (exist in target config but not in canonical source)
667
- // Only removes agents whose source is managed (starts with our prefix)
668
- // so user-defined agents with different source paths are preserved.
669
- const canonicalNames = new Set(Object.keys(canonicalAgentConfig))
670
- for (const [agentName, agentCfg] of Object.entries(config.agent)) {
671
- const source = agentCfg?.source || ''
672
- if (source.startsWith(agentPrefix) && !canonicalNames.has(agentName)) {
673
- delete config.agent[agentName]
674
- }
675
- }
676
- if (Object.keys(config.agent).length === 0) delete config.agent
797
+ // Do not remove stale or user-defined agents. Installation is additive.
677
798
  }
678
799
 
679
800
  // --------------------------------------------------------------------
@@ -738,39 +859,70 @@ export async function installOpenCode(
738
859
  // The source opencode.json keeps its dev path for local development; the
739
860
  // transform happens at install/sync time only.
740
861
 
741
- if (config.plugin === undefined) {
742
- config.plugin = []
743
- }
744
- if (Array.isArray(config.plugin)) {
745
- if (Array.isArray(pantheonConfig.plugin)) {
746
- for (const plugin of pantheonConfig.plugin) {
747
- const resolved = resolveInstalledPlugin(plugin)
748
- const file = basename(resolved)
749
- // Replace any pre-existing entry for the same plugin file (e.g. a stale
750
- // dev-machine path from an earlier install) so upgrades stay hermetic.
751
- config.plugin = config.plugin.filter((p) => basename(p) !== file)
862
+ if (version === 'v1') {
863
+ // V1 loads the singular `plugin` list. Remove every Pantheon V2 entry from
864
+ // both config shapes before restoring the two V1 plugins. User plugins in
865
+ // either list are retained verbatim.
866
+ if (Array.isArray(config.plugins)) {
867
+ config.plugins = removePantheonPluginReferences(config.plugins)
868
+ }
869
+ if (config.plugin === undefined) config.plugin = []
870
+ if (Array.isArray(config.plugin)) {
871
+ config.plugin = removePantheonPluginReferences(config.plugin)
872
+ if (Array.isArray(pantheonConfig.plugin)) {
873
+ for (const plugin of pantheonConfig.plugin) {
874
+ if (!isPantheonPluginReference(plugin) && !hasPluginReference(config.plugin, plugin)) {
875
+ config.plugin.push(deepClone(plugin))
876
+ }
877
+ }
878
+ }
879
+
880
+ const ensurePantheonPlugin = (ref) => {
881
+ const resolved = resolveInstalledPlugin(ref)
882
+ config.plugin = config.plugin.filter(
883
+ (plugin) => pluginReferenceIdentity(plugin) !== pluginReferenceIdentity(resolved),
884
+ )
752
885
  config.plugin.push(resolved)
753
886
  }
887
+ // Root-level delegation plugin remains a V1-only registration.
888
+ ensurePantheonPlugin('src/plugin.ts')
889
+ ensurePantheonPlugin('src/plugins/pantheon-hooks.ts')
754
890
  }
891
+ }
755
892
 
756
- // The two Pantheon plugins MUST always be registered, on fresh installs AND
757
- // upgrades. The packaged opencode.json carries no `plugin` key (dev-machine
758
- // paths removed in 3e552cc), so config.plugin would otherwise only contain
759
- // whatever the user had — a fresh install would register NOTHING and lose
760
- // delegation tools, GoalLoop, cost command, hashline and vision. Register
761
- // both unconditionally, replacing any stale entry for the same file
762
- // (basename-dedup) so installs and upgrades are idempotent and hermetic
763
- // while preserving any user plugins.
764
- const ensurePantheonPlugin = (ref) => {
765
- const resolved = resolveInstalledPlugin(ref)
766
- const file = basename(resolved)
767
- config.plugin = config.plugin.filter((p) => basename(p) !== file)
768
- config.plugin.push(resolved)
893
+ if (version === 'v2') {
894
+ // V2 loads the plural `plugins` list. Keep third-party V1 entries in the
895
+ // legacy list for users who still use them, but never leave a Pantheon V1
896
+ // entry configured alongside the V2 package export.
897
+ if (Array.isArray(config.plugin)) {
898
+ config.plugin = removePantheonPluginReferences(config.plugin)
899
+ }
900
+ if (!Array.isArray(config.plugins)) config.plugins = []
901
+ config.plugins = deduplicatePluginReferences(removePantheonPluginReferences(config.plugins))
902
+ if (Array.isArray(pantheonConfig.plugins)) {
903
+ for (const plugin of pantheonConfig.plugins) {
904
+ if (!isPantheonPluginReference(plugin) && !hasPluginReference(config.plugins, plugin)) {
905
+ config.plugins.push(deepClone(plugin))
906
+ }
907
+ }
769
908
  }
770
- // Root-level delegation plugin (PR #45): tools, GoalLoop, cost, hashline.
771
- ensurePantheonPlugin('src/plugin.ts')
772
- // Runtime hooks plugin: chat hooks, hook-runner, TUI wiring.
773
- ensurePantheonPlugin('src/plugins/pantheon-hooks.ts')
909
+ // The beta resolves every `plugins` entry through npm install, so an
910
+ // `a/b` shorthand (e.g. the published `pantheon-opencode/plugin-v2`
911
+ // export) is treated as a GitHub repo and dies with NpmInstallFailedError
912
+ // (beta log evidence). The beta loader additionally requires each entry
913
+ // to be a DIRECTORY with a real index.js/index.ts — a file path warns
914
+ // "must be a directory" and a symlink is ignored (sandbox probe
915
+ // evidence). Register the V2 plugin DIRECTORY inside the INSTALLED
916
+ // package — exactly like V1's ensurePantheonPlugin, but pointing at
917
+ // src/plugin-v2 whose index.ts shim re-exports the real entry.
918
+ // Legacy refs (npm shorthand, the old src/plugin-v2.ts file path) are
919
+ // recognized via managedPluginIdentity and already stripped above, so
920
+ // they migrate here.
921
+ const resolvedV2 = resolveInstalledPlugin(PANTHEON_V2_PLUGIN)
922
+ config.plugins = config.plugins.filter(
923
+ (plugin) => pluginReferenceIdentity(plugin) !== pluginReferenceIdentity(resolvedV2),
924
+ )
925
+ config.plugins.push(resolvedV2)
774
926
  }
775
927
 
776
928
  if (config.provider === undefined && pantheonConfig.provider !== undefined) {
@@ -854,11 +1006,12 @@ export async function installOpenCode(
854
1006
  }
855
1007
 
856
1008
  // --------------------------------------------------------------------
857
- // F. Compatibility cleanup (OpenCode >= v1.15.7)
1009
+ // F. Compatibility cleanup (OpenCode V1 >= 1.15.7)
858
1010
  // --------------------------------------------------------------------
859
- // `todoContinuation` is rejected by newer OpenCode versions.
860
- // Remove it from merged user config to avoid startup/config failures.
861
- if (Object.hasOwn(config, 'todoContinuation')) {
1011
+ // Recent V1 releases reject the legacy `todoContinuation` setting. Remove
1012
+ // it from the merged V1 config, while leaving the V2-shaped configuration
1013
+ // untouched.
1014
+ if (version === 'v1' && Object.hasOwn(config, 'todoContinuation')) {
862
1015
  delete config.todoContinuation
863
1016
  }
864
1017
 
@@ -867,6 +1020,10 @@ export async function installOpenCode(
867
1020
  // --------------------------------------------------------------------
868
1021
  if (componentSet.has('runtime')) {
869
1022
  config.mcp = config.mcp || {}
1023
+ // MCP stays a flat named-server map for BOTH V1 and V2: OpenCode 1.18.x
1024
+ // has no `mcp.servers` wrapper and rejects one with
1025
+ // "Missing key mcp.servers.enabled". config-migration.mjs normalizes any
1026
+ // legacy wrapper-shaped entries back to the flat map.
870
1027
  // P1-3: derive the MCP python from the SAME venv layout setupVenv
871
1028
  // creates (<target>/.venv via venvPythonPath). For project installs the
872
1029
  // runtime payload lives under <target>/.opencode (runtimeTarget), but the
@@ -940,7 +1097,16 @@ export async function installOpenCode(
940
1097
  }
941
1098
  }
942
1099
 
943
- const configContent = `${JSON.stringify(config, null, 2)}\n`
1100
+ // ── V2 native migration ──
1101
+ // When targeting V2, convert the merged V1-shaped config to native V2 format
1102
+ // before writing. V1 path is untouched (zero regression).
1103
+ let configToWrite = config
1104
+ if (version === 'v2') {
1105
+ info('Migrating config to V2 native format...')
1106
+ configToWrite = migrateV1toV2(config)
1107
+ }
1108
+
1109
+ const configContent = `${JSON.stringify(configToWrite, null, 2)}\n`
944
1110
  const status = writeIfChanged(targetConfigPath, configContent, dryRun)
945
1111
  if (status === 'created') stats.created++
946
1112
  else stats.skipped++
@@ -969,7 +1135,8 @@ export async function installOpenCode(
969
1135
  const venvSpinner = spinner('Setting up Python virtual environment')
970
1136
  setupVenv(target, { dryRun, force: clean })
971
1137
  venvSpinner(true)
972
- const health = healthCheck(target, { dryRun })
1138
+ const runtimeTarget = isGlobal ? target : join(target, '.opencode')
1139
+ const health = healthCheck(runtimeTarget, { dryRun, pythonTarget: target })
973
1140
 
974
1141
  // Print health summary
975
1142
  section('\uD83D\uDD0D Health Check')
@@ -997,7 +1164,13 @@ export async function installOpenCode(
997
1164
  const errors = stats.errors
998
1165
 
999
1166
  process.stdout.write('\n')
1000
- process.stdout.write(` ${colors.bold(colors.green('Installation complete'))}\n`)
1167
+ process.stdout.write(
1168
+ ` ${colors.bold(
1169
+ errors > 0
1170
+ ? colors.yellow('Installation completed with errors')
1171
+ : colors.green('Installation complete'),
1172
+ )}\n`,
1173
+ )
1001
1174
  process.stdout.write(
1002
1175
  ` ${colors.dim('\u2500'.repeat(Math.min(process.stdout.columns || 60, 60)))}\n`,
1003
1176
  )