pantheon-opencode 1.4.3 → 1.5.0-beta.10

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 (118) hide show
  1. package/.pantheon/code-mode/README.md +93 -0
  2. package/.pantheon/code-mode/checkpoint-session.py +7 -0
  3. package/.pantheon/code-mode/checkpoint-session.sh +11 -0
  4. package/.pantheon/code-mode/checkpoint_session.py +481 -0
  5. package/.pantheon/code-mode/compress-inline.py +450 -0
  6. package/.pantheon/code-mode/eval-llm-judge.py +255 -0
  7. package/.pantheon/code-mode/eval-monte-carlo.py +368 -0
  8. package/.pantheon/code-mode/eval-run.py +197 -0
  9. package/.pantheon/code-mode/eval-static.py +321 -0
  10. package/.pantheon/code-mode/example-sync.sh +5 -0
  11. package/.pantheon/code-mode/manifest.json +21 -0
  12. package/.pantheon/code-mode/session-end-save.py +186 -0
  13. package/.pantheon/code-mode/session-end-save.sh +18 -0
  14. package/.pantheon/code-mode/test-allowed.sh +5 -0
  15. package/.pantheon/code-mode/test-auto-continue-checkpoint.sh +179 -0
  16. package/.pantheon/code-mode/test-env.sh +7 -0
  17. package/.pantheon/code-mode/test-prlimit.sh +9 -0
  18. package/.pantheon/code-mode/test-timeout.sh +6 -0
  19. package/CHANGELOG.md +1026 -0
  20. package/README.md +292 -1080
  21. package/README.pt-BR.md +308 -0
  22. package/bin/pantheon-init.mjs +207 -31
  23. package/opencode.json +1 -0
  24. package/package.json +34 -14
  25. package/plugin.json +15 -0
  26. package/pyproject.toml +65 -0
  27. package/scripts/_pantheon_paths.py +48 -7
  28. package/scripts/ci-validate-yaml.py +7 -2
  29. package/scripts/code_mode_server.py +451 -44
  30. package/scripts/cost.mjs +30 -14
  31. package/scripts/doctor.mjs +306 -129
  32. package/scripts/generate-preset-docs.mjs +127 -66
  33. package/scripts/generate-prompts.sh +2 -2
  34. package/scripts/install/cli-ui.mjs +23 -10
  35. package/scripts/install/config-migration.mjs +399 -0
  36. package/scripts/install/health-check.mjs +3 -3
  37. package/scripts/install/model-picker.mjs +72 -23
  38. package/scripts/install/opencode-version.mjs +47 -0
  39. package/scripts/install/opencode.mjs +396 -121
  40. package/scripts/install/plugin.mjs +89 -49
  41. package/scripts/install/shared.mjs +55 -159
  42. package/scripts/install/strings.mjs +129 -0
  43. package/scripts/install-mcp.mjs +45 -232
  44. package/scripts/manifest-inventory.mjs +410 -0
  45. package/scripts/mcp_persistence_server.py +798 -60
  46. package/scripts/mcp_resources_server.py +144 -40
  47. package/scripts/memory_mcp_server.py +2 -2
  48. package/scripts/package-evidence.mjs +242 -0
  49. package/scripts/probe-context-rehydrate.mjs +240 -0
  50. package/scripts/probe-pantheon-cost.mjs +313 -0
  51. package/scripts/recover-zenodo-deposition.mjs +146 -0
  52. package/scripts/release-beta-version.mjs +56 -60
  53. package/scripts/sync-artifacts.mjs +169 -0
  54. package/scripts/sync-tui.mjs +135 -42
  55. package/scripts/test-opencode-v1-v2-sandbox.sh +862 -0
  56. package/scripts/uninstall.mjs +298 -954
  57. package/scripts/validate-zenodo-release.mjs +154 -0
  58. package/scripts/validation-policy.mjs +7 -7
  59. package/scripts/version-check.mjs +113 -108
  60. package/scripts/versioning.mjs +230 -86
  61. package/scripts/zenodo-artifact.mjs +21 -0
  62. package/scripts/zenodo-release-state.mjs +53 -0
  63. package/src/agents/mnemosyne.md +1 -1
  64. package/src/agents/zeus.md +27 -16
  65. package/src/mcp/_pantheon_paths.py +48 -7
  66. package/src/mcp/code_mode_server.py +451 -44
  67. package/src/mcp/eval_store.py +1 -1
  68. package/src/mcp/mcp_persistence_server.py +798 -60
  69. package/src/mcp/mcp_resources_server.py +144 -40
  70. package/src/mcp/pantheon_vision_server.py +1 -1
  71. package/src/mcp/toon_codec.py +491 -0
  72. package/src/pantheon/auto-wake.ts +1 -5
  73. package/src/pantheon/background-job-board.ts +110 -2
  74. package/src/pantheon/compaction-handoff.ts +304 -0
  75. package/src/pantheon/cost-command.ts +356 -69
  76. package/src/pantheon/delegate-manager.ts +766 -0
  77. package/src/pantheon/delegation-classifier.ts +307 -0
  78. package/src/pantheon/delegation-enforce.ts +13 -12
  79. package/src/pantheon/delegation-finalize.ts +406 -0
  80. package/src/pantheon/delegation-notify.ts +212 -0
  81. package/src/pantheon/delegation-toast.ts +73 -0
  82. package/src/pantheon/delegation.ts +1337 -0
  83. package/src/pantheon/file-persistence-salvage.ts +64 -0
  84. package/src/pantheon/file-persistence.ts +194 -45
  85. package/src/pantheon/goal-loop.ts +2 -2
  86. package/src/pantheon/goal-store.ts +1 -1
  87. package/src/pantheon/hashline/tool.ts +2 -2
  88. package/src/pantheon/model-command.ts +2 -1
  89. package/src/pantheon/native-probe.ts +234 -0
  90. package/src/pantheon/native-task-status.ts +139 -0
  91. package/src/pantheon/permission-eval.ts +284 -0
  92. package/src/pantheon/provider-capabilities.ts +221 -0
  93. package/src/pantheon/provider-stream-tracker.ts +182 -0
  94. package/src/pantheon/shared-board.ts +37 -0
  95. package/src/pantheon/step-cap.ts +110 -0
  96. package/src/pantheon/task-result-guard.ts +107 -0
  97. package/src/pantheon/token-opt.ts +689 -0
  98. package/src/pantheon/tool-ceiling.ts +159 -0
  99. package/src/pantheon/toon-codec.ts +481 -0
  100. package/src/pantheon/tui-version-check.ts +1 -3
  101. package/src/pantheon/v2-bridge.ts +129 -0
  102. package/src/pantheon/v2-events.ts +148 -0
  103. package/src/pantheon/v2-hooks.ts +287 -0
  104. package/src/pantheon/v2-tool-definitions.ts +331 -0
  105. package/src/pantheon/zeus-delegate-with-retry.ts +406 -0
  106. package/src/plugin-v2/index.ts +21 -0
  107. package/src/plugin-v2.ts +824 -0
  108. package/src/plugin.ts +261 -51
  109. package/src/plugins/pantheon-hooks.ts +51 -0
  110. package/src/plugins/tui/dist/tui.d.ts +116 -24
  111. package/src/plugins/tui/dist/tui.d.ts.map +1 -1
  112. package/src/plugins/tui/dist/tui.js +416 -149
  113. package/src/plugins/tui/dist/tui.tsx +456 -87
  114. package/src/plugins/tui/package-lock.json +51 -51
  115. package/src/plugins/tui/package.json +7 -7
  116. package/src/plugins/tui/src/index.tsx +462 -95
  117. package/src/skills/README.md +0 -2
  118. package/src/pantheon/tool-context.ts +0 -41
@@ -2,20 +2,20 @@
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
 
15
+ import { createHash } from 'node:crypto'
16
16
  import { chmodSync, existsSync, mkdirSync, readdirSync, readFileSync, rmSync } from 'node:fs'
17
17
  import { homedir } from 'node:os'
18
- import { basename, join, resolve } from 'node:path'
18
+ import { dirname, isAbsolute, join, resolve } from 'node:path'
19
19
  import {
20
20
  bullet,
21
21
  colors,
@@ -29,10 +29,13 @@ import {
29
29
  success,
30
30
  warning,
31
31
  } from './cli-ui.mjs'
32
+ import { migrateV1toV2 } from './config-migration.mjs'
32
33
  import { healthCheck } from './health-check.mjs'
33
34
  import { detectVersion, runMigrations } from './migrate.mjs'
35
+ import { resolveOpenCodeVersion } from './opencode-version.mjs'
34
36
  import { copyPluginFiles, installPlugin, registerPlugin, unregisterPlugin } from './plugin.mjs'
35
37
  import {
38
+ checkRuntimePrerequisites,
36
39
  collectSkillNames,
37
40
  copyFiles,
38
41
  installSkills,
@@ -41,8 +44,10 @@ import {
41
44
  sourceDirValid,
42
45
  summary,
43
46
  syncDir,
47
+ writeConfigWithBackup,
44
48
  writeIfChanged,
45
49
  } from './shared.mjs'
50
+ import { strings } from './strings.mjs'
46
51
 
47
52
  import { setupVenv, venvPythonPath } from './venv.mjs'
48
53
 
@@ -60,6 +65,34 @@ function deepClone(value) {
60
65
  return JSON.parse(JSON.stringify(value))
61
66
  }
62
67
 
68
+ /**
69
+ * (Re)generate the code-mode manifest with SHA-256 digests for every bundled
70
+ * script. This is the explicit installation opt-in: the MCP server only
71
+ * executes scripts listed here whose on-disk hash matches. Missing or empty
72
+ * directories approve nothing (0) — execution is never implicit.
73
+ */
74
+ function writeCodeModeManifest(dir, dryRun) {
75
+ if (!existsSync(dir)) return 0
76
+ let names
77
+ try {
78
+ names = readdirSync(dir)
79
+ .filter((name) => /\.(sh|py)$/i.test(name) && !name.startsWith('.'))
80
+ .sort()
81
+ } catch {
82
+ return 0
83
+ }
84
+ if (names.length === 0) return 0
85
+ const scripts = {}
86
+ for (const name of names) {
87
+ scripts[name] = createHash('sha256')
88
+ .update(readFileSync(join(dir, name)))
89
+ .digest('hex')
90
+ }
91
+ const payload = `${JSON.stringify({ version: 1, scripts }, null, 2)}\n`
92
+ writeIfChanged(join(dir, 'manifest.json'), payload, dryRun)
93
+ return names.length
94
+ }
95
+
63
96
  function readJsonConfig(filePath) {
64
97
  if (!existsSync(filePath)) return {}
65
98
 
@@ -101,24 +134,149 @@ function mergeMissing(target, source) {
101
134
  * Resolve a plugin ref from the packaged opencode.json to a path INSIDE the
102
135
  * installed package.
103
136
  *
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.
137
+ * Only exact Pantheon plugin identities are resolved into the installed
138
+ * package. In particular, a third-party absolute path ending in
139
+ * `src/plugin.ts` or `src/plugins/pantheon-hooks.ts` is not evidence that the
140
+ * plugin belongs to Pantheon and must be preserved verbatim.
110
141
  */
111
142
  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
143
+ if (typeof plugin !== 'string') return plugin
144
+ const normalized = normalizePluginRef(plugin)
145
+ if (PANTHEON_PLUGIN_IDENTITIES.has(normalized)) return join(ROOT, normalized)
146
+ // Legacy V2 file ref (pre-directory-contract): migrate to the installed
147
+ // plugin directory so the beta loader accepts the entry.
148
+ if (normalized === PANTHEON_V2_LEGACY_FILE) return join(ROOT, PANTHEON_V2_PLUGIN)
149
+ const identity = managedPluginIdentity(plugin)
150
+ if (identity !== null && isAbsolute(normalized)) {
151
+ return INSTALLED_PANTHEON_PLUGIN_PATHS.get(identity) ?? plugin
152
+ }
153
+ return plugin
154
+ }
155
+
156
+ function normalizePluginRef(ref) {
157
+ return typeof ref === 'string' ? ref.replaceAll('\\', '/').replace(/\/$/, '') : ref
158
+ }
159
+
160
+ const PANTHEON_V1_PLUGIN = 'src/plugin.ts'
161
+ const PANTHEON_V1_HOOKS = 'src/plugins/pantheon-hooks.ts'
162
+ // Canonical V2 entry is the plugin DIRECTORY (beta loader contract: entries
163
+ // must be dirs with a real index.ts — file paths warn "must be a directory",
164
+ // symlinks are ignored). The shim at src/plugin-v2/index.ts re-exports the
165
+ // real entry src/plugin-v2.ts (single source of truth).
166
+ const PANTHEON_V2_PLUGIN = 'src/plugin-v2'
167
+ // Legacy V2 file ref from before the directory contract (installer + template
168
+ // used to register src/plugin-v2.ts). Still recognized for migration.
169
+ const PANTHEON_V2_LEGACY_FILE = 'src/plugin-v2.ts'
170
+ const PANTHEON_V1_EXPORT = 'pantheon-opencode/plugin'
171
+ const PANTHEON_V2_EXPORT = 'pantheon-opencode/plugin-v2'
172
+ const PANTHEON_PLUGIN_IDENTITIES = new Set([
173
+ PANTHEON_V1_PLUGIN,
174
+ PANTHEON_V1_HOOKS,
175
+ PANTHEON_V2_PLUGIN,
176
+ ])
177
+
178
+ const INSTALLED_PANTHEON_PLUGIN_PATHS = new Map([
179
+ [PANTHEON_V1_PLUGIN, resolve(ROOT, PANTHEON_V1_PLUGIN)],
180
+ [PANTHEON_V1_HOOKS, resolve(ROOT, PANTHEON_V1_HOOKS)],
181
+ [PANTHEON_V2_PLUGIN, resolve(ROOT, PANTHEON_V2_PLUGIN)],
182
+ ])
183
+
184
+ function normalizePluginPath(ref) {
185
+ return ref.replaceAll('\\', '/')
186
+ }
187
+
188
+ function pluginMetadataReferences(ref) {
189
+ if (typeof ref === 'string') return [ref]
190
+ if (!ref || typeof ref !== 'object' || Array.isArray(ref)) return []
191
+ return ['package', 'id', 'name', 'path', 'source']
192
+ .filter((key) => typeof ref[key] === 'string')
193
+ .map((key) => ref[key])
194
+ }
195
+
196
+ function managedPluginIdentity(ref) {
197
+ const normalized = normalizePluginRef(ref)
198
+ if (typeof normalized !== 'string') return null
199
+
200
+ if (PANTHEON_PLUGIN_IDENTITIES.has(normalized)) return normalized
201
+ if (normalized === PANTHEON_V2_LEGACY_FILE) return PANTHEON_V2_PLUGIN
202
+ if (normalized === PANTHEON_V1_EXPORT || normalized.startsWith(`${PANTHEON_V1_EXPORT}@`)) {
203
+ return PANTHEON_V1_PLUGIN
204
+ }
205
+ if (normalized === PANTHEON_V2_EXPORT || normalized.startsWith(`${PANTHEON_V2_EXPORT}@`)) {
206
+ return PANTHEON_V2_PLUGIN
207
+ }
208
+
209
+ if (isAbsolute(normalized)) {
210
+ const absolutePath = normalizePluginPath(resolve(normalized))
211
+ for (const [identity, installedPath] of INSTALLED_PANTHEON_PLUGIN_PATHS) {
212
+ if (absolutePath === normalizePluginPath(installedPath)) return identity
213
+ }
214
+ // Legacy absolute V2 file path inside OUR installed package (pre-
215
+ // directory-contract installs) migrates to the directory identity.
216
+ // ROOT-scoped: third-party paths ending in src/plugin-v2.ts stay theirs.
217
+ if (absolutePath === normalizePluginPath(resolve(ROOT, PANTHEON_V2_LEGACY_FILE))) {
218
+ return PANTHEON_V2_PLUGIN
219
+ }
220
+ // beta.5: stale refs from OTHER installed copies of this package (older
221
+ // nvm prefixes, npx cache hashes). Only paths inside
222
+ // `node_modules/pantheon-opencode/` count as installed copies — a dev
223
+ // checkout elsewhere (e.g. a vendor folder named pantheon-opencode) remains
224
+ // protected by the third-party rule above.
225
+ const installedMarker = '/node_modules/pantheon-opencode/'
226
+ const withSlashes = normalizePluginPath(normalized)
227
+ const markerIndex = withSlashes.lastIndexOf(installedMarker)
228
+ if (markerIndex !== -1) {
229
+ const suffix = withSlashes.slice(markerIndex + installedMarker.length)
230
+ if (suffix === PANTHEON_V1_PLUGIN) return PANTHEON_V1_PLUGIN
231
+ if (suffix === PANTHEON_V1_HOOKS) return PANTHEON_V1_HOOKS
232
+ if (suffix === PANTHEON_V2_PLUGIN) return PANTHEON_V2_PLUGIN
233
+ if (suffix === PANTHEON_V2_LEGACY_FILE) return PANTHEON_V2_PLUGIN
234
+ }
235
+ }
236
+
237
+ return null
238
+ }
239
+
240
+ /**
241
+ * Return the stable identity for a Pantheon plugin, or its normalized path.
242
+ * Basenames are deliberately not used: unrelated user plugins may share a
243
+ * filename such as `plugin.ts`.
244
+ */
245
+ export function pluginReferenceIdentity(ref) {
246
+ const references = pluginMetadataReferences(ref)
247
+ for (const reference of references) {
248
+ const identity = managedPluginIdentity(reference)
249
+ if (identity !== null) return identity
250
+ }
251
+ if (references.length > 0) return normalizePluginPath(normalizePluginRef(references[0]))
252
+ return ref
253
+ }
254
+
255
+ function isPantheonPluginReference(ref) {
256
+ return PANTHEON_PLUGIN_IDENTITIES.has(pluginReferenceIdentity(ref))
257
+ }
258
+
259
+ function removePantheonPluginReferences(refs) {
260
+ return (Array.isArray(refs) ? refs : []).filter((ref) => !isPantheonPluginReference(ref))
261
+ }
262
+
263
+ function hasPluginReference(refs, candidate) {
264
+ const identity = pluginReferenceIdentity(candidate)
265
+ return refs.some((ref) => pluginReferenceIdentity(ref) === identity)
120
266
  }
121
267
 
268
+ export function deduplicatePluginReferences(refs) {
269
+ const seen = new Set()
270
+ return (Array.isArray(refs) ? refs : []).filter((ref) => {
271
+ const identity = pluginReferenceIdentity(ref)
272
+ if (seen.has(identity)) return false
273
+ seen.add(identity)
274
+ return true
275
+ })
276
+ }
277
+
278
+ export { parseOpenCodeVersion, resolveOpenCodeVersion } from './opencode-version.mjs'
279
+
122
280
  /**
123
281
  * Detect whether `target` is the user's global OpenCode installation directory
124
282
  * (~/.opencode or $XDG_CONFIG_HOME/opencode).
@@ -217,16 +375,60 @@ export function syncTuiRegistration(target, { isGlobal = false, dryRun = false }
217
375
 
218
376
  // 2. Clean stale Pantheon TUI refs from every location OpenCode reads.
219
377
  // 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).
378
+ // patterns (dist files, identifiable in-package paths, owned copied
379
+ // directories, relative ids, and npx package identities).
222
380
  for (const tuiConfigPath of tuiConfigLocations(target)) {
223
- unregisterPlugin(tuiConfigPath, 'plugins/pantheon-tui', { dryRun })
381
+ const configDirForPath = dirname(tuiConfigPath)
382
+ const ownedCopy = resolveTuiCopyTarget(configDirForPath)
383
+ unregisterPlugin(tuiConfigPath, 'plugins/pantheon-tui', {
384
+ dryRun,
385
+ knownPantheonPaths: [ownedCopy, tuiCopyDir],
386
+ })
224
387
  }
225
388
 
226
389
  // 3. Register the copied directory in the selected target only.
227
390
  return registerPlugin(targetTuiConfigPath, tuiCopyDir, { dryRun })
228
391
  }
229
392
 
393
+ /**
394
+ * beta.5: set up the Python venv + run the health check. Never throws —
395
+ * failures are reported as warnings and return false so the caller omits
396
+ * MCP entries from the config (entries pointing at a missing venv would
397
+ * make every MCP fail to launch). `deps` is injectable for tests.
398
+ */
399
+ export function setupRuntimePhase(target, { dryRun, clean, isGlobal }, stats, deps = {}) {
400
+ const S = strings()
401
+ const setup = deps.setupVenv ?? setupVenv
402
+ const check = deps.healthCheck ?? healthCheck
403
+ try {
404
+ const venvSpinner = spinner(S.settingUpRuntime)
405
+ setup(target, { dryRun, force: clean })
406
+ venvSpinner(true)
407
+ const runtimeTarget = isGlobal ? target : join(target, '.opencode')
408
+ const health = check(runtimeTarget, { dryRun, pythonTarget: target })
409
+
410
+ section(`\uD83D\uDD0D ${S.healthSection}`)
411
+ const healthStep = step(S.runningHealthChecks)
412
+ for (const p of health.passed) success(`${p.check}: ${p.detail}`)
413
+ for (const w of health.warnings) warning(`${w.check}: ${w.detail}`)
414
+ for (const f of health.failed) error(`${f.check}: ${f.detail}`)
415
+
416
+ if (health.failed.length > 0) {
417
+ healthStep(false)
418
+ warning(`${health.failed.length} check(s) failed — review above`)
419
+ stats.warnings += health.failed.length
420
+ return false
421
+ }
422
+ healthStep(true)
423
+ return true
424
+ } catch (err) {
425
+ error(S.runtimeFailed(err.message))
426
+ warning(S.runtimeSkippedMcp)
427
+ stats.warnings += 1
428
+ return false
429
+ }
430
+ }
431
+
230
432
  export async function installOpenCode(
231
433
  target,
232
434
  dryRun = false,
@@ -241,14 +443,14 @@ export async function installOpenCode(
241
443
 
242
444
  const componentSet = new Set(components)
243
445
  const stats = summary.opencode
244
-
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'
446
+ const S = strings()
447
+
448
+ // V1/V2 dual-version awareness (Phase 3): the installer builds a unified
449
+ // config, then applies V2-native migration (migrateV1toV2) when
450
+ // --opencode-version=v2. V2 normalizes V1 fields in memory (no rewrite) and
451
+ // isolates its state DB via OPENCODE_DB (~/.local/share/opencode/opencode-v2.db)
452
+ // with a distinct service port (49375 vs V1's 49374).
453
+ const version = resolveOpenCodeVersion(opts.version ?? 'v1')
252
454
  if (version === 'v2') {
253
455
  info(
254
456
  'OpenCode V2 target — shared config; state isolated via OPENCODE_DB ' +
@@ -290,6 +492,17 @@ export async function installOpenCode(
290
492
  const config = readJsonConfig(targetConfigPath)
291
493
  const pantheonConfig = readJsonConfig(pantheonConfigPath)
292
494
 
495
+ // beta.5 preflight: fail BEFORE any files are written when the runtime
496
+ // component needs toolchain pieces that are missing. Nothing has been
497
+ // copied yet at this point, so the failure is clean.
498
+ if (componentSet.has('runtime') && !dryRun) {
499
+ const missing = checkRuntimePrerequisites()
500
+ if (missing.length > 0) {
501
+ for (const problem of missing) error(problem)
502
+ throw new Error(S.prereqMissing)
503
+ }
504
+ }
505
+
293
506
  // Determine layout based on install scope.
294
507
  // Global config dir (~/.opencode or ~/.config/opencode) uses a flat layout:
295
508
  // agents/ skills/ commands/ plugins/
@@ -302,7 +515,7 @@ export async function installOpenCode(
302
515
  configure({ dryRun })
303
516
 
304
517
  if (isGlobal) {
305
- info('Global config directory detected — using flat layout (agents/, skills/, commands/)')
518
+ info(S.globalLayout)
306
519
  }
307
520
 
308
521
  // -----------------------------------------------------------------------
@@ -315,7 +528,7 @@ export async function installOpenCode(
315
528
  warning(`Agent source directory not found: ${srcDir}`)
316
529
  stats.errors++
317
530
  } else {
318
- const agentStep = step('Installing agents')
531
+ const agentStep = step(S.installingAgents)
319
532
  const dstDir = isGlobal ? join(target, 'agents') : join(target, '.opencode', 'agents')
320
533
  if (!dryRun) mkdirSync(dstDir, { recursive: true })
321
534
  if (clean && existsSync(dstDir) && !dryRun) {
@@ -445,11 +658,20 @@ export async function installOpenCode(
445
658
  }
446
659
 
447
660
  // -----------------------------------------------------------------------
448
- // 2.9 Create/update tui.json with plugin registration
661
+ // 2.9 Create/update tui.json with plugin registration (--components plugins)
449
662
  // -----------------------------------------------------------------------
450
- const tuiStatus = syncTuiRegistration(target, { isGlobal, dryRun })
451
- if (tuiStatus === 'created') stats.created++
452
- else stats.skipped++
663
+ if (componentSet.has('plugins')) {
664
+ let tuiStatus
665
+ try {
666
+ tuiStatus = syncTuiRegistration(target, { isGlobal, dryRun })
667
+ } catch (cause) {
668
+ error(`TUI plugin setup failed: ${cause.message}`)
669
+ stats.errors++
670
+ throw new Error('TUI plugin setup failed; installation was not completed', { cause })
671
+ }
672
+ if (tuiStatus === 'created') stats.created++
673
+ else stats.skipped++
674
+ }
453
675
 
454
676
  // -----------------------------------------------------------------------
455
677
  // 2.10 Install runtime infrastructure (--components runtime)
@@ -531,6 +753,21 @@ export async function installOpenCode(
531
753
  const cmResult = syncDir(srcCodeModeDir, dstCodeModeDir, dryRun, clean)
532
754
  stats.created += cmResult.created
533
755
  stats.skipped += cmResult.skipped
756
+ } else {
757
+ // The code-mode payload ships inside the published tarball
758
+ // (package.json `files` includes .pantheon/code-mode/**). When it is
759
+ // absent, still create the runtime dir so the code-mode MCP server has
760
+ // a stable home, and warn loudly instead of silently skipping.
761
+ warning(`Code-mode source not found: ${srcCodeModeDir} — creating empty ${dstCodeModeDir}`)
762
+ if (!dryRun) mkdirSync(dstCodeModeDir, { recursive: true })
763
+ stats.created++
764
+ }
765
+
766
+ // B3-08: explicit installation mode — seed the manifest with SHA-256
767
+ // digests so the server never executes a script implicitly.
768
+ const approvedScripts = writeCodeModeManifest(dstCodeModeDir, dryRun)
769
+ if (approvedScripts > 0) {
770
+ info(`Code-mode manifest: ${approvedScripts} script(s) approved`)
534
771
  }
535
772
 
536
773
  // ── tiers.json ──
@@ -544,10 +781,21 @@ export async function installOpenCode(
544
781
  }
545
782
  }
546
783
 
784
+ // -----------------------------------------------------------------------
785
+ // 2.11 Setup virtual environment + health check (--components runtime)
786
+ // beta.5: runs BEFORE the config write and is no longer fatal — on failure
787
+ // the install completes without MCP entries (which would point at a venv
788
+ // that does not exist) instead of aborting a half-done installation.
789
+ // -----------------------------------------------------------------------
790
+ let runtimeHealthy = !componentSet.has('runtime')
791
+ if (componentSet.has('runtime')) {
792
+ runtimeHealthy = setupRuntimePhase(target, { dryRun, clean, isGlobal }, stats)
793
+ }
794
+
547
795
  // -----------------------------------------------------------------------
548
796
  // 3. Create/update opencode.json (always runs)
549
797
  // Reads TARGET's existing config first, then merges Pantheon settings
550
- // on top. Preserves user's MCP, provider, plugin, compaction, theme.
798
+ // on top. Preserves user's MCP, provider, plugins, compaction, theme.
551
799
  // -----------------------------------------------------------------------
552
800
  // --------------------------------------------------------------------
553
801
  // A. Parse canonical agent config from agents/*.agent.md frontmatter
@@ -635,9 +883,8 @@ export async function installOpenCode(
635
883
  existing[field] = JSON.parse(JSON.stringify(agentCfg[field]))
636
884
  }
637
885
  }
638
- // Remove stale fields that are no longer in canonical config
639
- delete existing.model
640
- delete existing.small_model
886
+ // Existing agent fields belong to the user; never remove them during
887
+ // an install. Canonical values are merged only when absent.
641
888
  } else {
642
889
  // ── New agent ──
643
890
  const newAgent = {}
@@ -663,17 +910,7 @@ export async function installOpenCode(
663
910
  }
664
911
  }
665
912
 
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
913
+ // Do not remove stale or user-defined agents. Installation is additive.
677
914
  }
678
915
 
679
916
  // --------------------------------------------------------------------
@@ -738,39 +975,70 @@ export async function installOpenCode(
738
975
  // The source opencode.json keeps its dev path for local development; the
739
976
  // transform happens at install/sync time only.
740
977
 
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)
978
+ if (version === 'v1') {
979
+ // V1 loads the singular `plugin` list. Remove every Pantheon V2 entry from
980
+ // both config shapes before restoring the two V1 plugins. User plugins in
981
+ // either list are retained verbatim.
982
+ if (Array.isArray(config.plugins)) {
983
+ config.plugins = removePantheonPluginReferences(config.plugins)
984
+ }
985
+ if (config.plugin === undefined) config.plugin = []
986
+ if (Array.isArray(config.plugin)) {
987
+ config.plugin = removePantheonPluginReferences(config.plugin)
988
+ if (Array.isArray(pantheonConfig.plugin)) {
989
+ for (const plugin of pantheonConfig.plugin) {
990
+ if (!isPantheonPluginReference(plugin) && !hasPluginReference(config.plugin, plugin)) {
991
+ config.plugin.push(deepClone(plugin))
992
+ }
993
+ }
994
+ }
995
+
996
+ const ensurePantheonPlugin = (ref) => {
997
+ const resolved = resolveInstalledPlugin(ref)
998
+ config.plugin = config.plugin.filter(
999
+ (plugin) => pluginReferenceIdentity(plugin) !== pluginReferenceIdentity(resolved),
1000
+ )
752
1001
  config.plugin.push(resolved)
753
1002
  }
1003
+ // Root-level delegation plugin remains a V1-only registration.
1004
+ ensurePantheonPlugin('src/plugin.ts')
1005
+ ensurePantheonPlugin('src/plugins/pantheon-hooks.ts')
754
1006
  }
1007
+ }
755
1008
 
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)
1009
+ if (version === 'v2') {
1010
+ // V2 loads the plural `plugins` list. Keep third-party V1 entries in the
1011
+ // legacy list for users who still use them, but never leave a Pantheon V1
1012
+ // entry configured alongside the V2 package export.
1013
+ if (Array.isArray(config.plugin)) {
1014
+ config.plugin = removePantheonPluginReferences(config.plugin)
1015
+ }
1016
+ if (!Array.isArray(config.plugins)) config.plugins = []
1017
+ config.plugins = deduplicatePluginReferences(removePantheonPluginReferences(config.plugins))
1018
+ if (Array.isArray(pantheonConfig.plugins)) {
1019
+ for (const plugin of pantheonConfig.plugins) {
1020
+ if (!isPantheonPluginReference(plugin) && !hasPluginReference(config.plugins, plugin)) {
1021
+ config.plugins.push(deepClone(plugin))
1022
+ }
1023
+ }
769
1024
  }
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')
1025
+ // The beta resolves every `plugins` entry through npm install, so an
1026
+ // `a/b` shorthand (e.g. the published `pantheon-opencode/plugin-v2`
1027
+ // export) is treated as a GitHub repo and dies with NpmInstallFailedError
1028
+ // (beta log evidence). The beta loader additionally requires each entry
1029
+ // to be a DIRECTORY with a real index.js/index.ts — a file path warns
1030
+ // "must be a directory" and a symlink is ignored (sandbox probe
1031
+ // evidence). Register the V2 plugin DIRECTORY inside the INSTALLED
1032
+ // package — exactly like V1's ensurePantheonPlugin, but pointing at
1033
+ // src/plugin-v2 whose index.ts shim re-exports the real entry.
1034
+ // Legacy refs (npm shorthand, the old src/plugin-v2.ts file path) are
1035
+ // recognized via managedPluginIdentity and already stripped above, so
1036
+ // they migrate here.
1037
+ const resolvedV2 = resolveInstalledPlugin(PANTHEON_V2_PLUGIN)
1038
+ config.plugins = config.plugins.filter(
1039
+ (plugin) => pluginReferenceIdentity(plugin) !== pluginReferenceIdentity(resolvedV2),
1040
+ )
1041
+ config.plugins.push(resolvedV2)
774
1042
  }
775
1043
 
776
1044
  if (config.provider === undefined && pantheonConfig.provider !== undefined) {
@@ -854,19 +1122,26 @@ export async function installOpenCode(
854
1122
  }
855
1123
 
856
1124
  // --------------------------------------------------------------------
857
- // F. Compatibility cleanup (OpenCode >= v1.15.7)
1125
+ // F. Compatibility cleanup (OpenCode V1 >= 1.15.7)
858
1126
  // --------------------------------------------------------------------
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')) {
1127
+ // Recent V1 releases reject the legacy `todoContinuation` setting. Remove
1128
+ // it from the merged V1 config, while leaving the V2-shaped configuration
1129
+ // untouched.
1130
+ if (version === 'v1' && Object.hasOwn(config, 'todoContinuation')) {
862
1131
  delete config.todoContinuation
863
1132
  }
864
1133
 
865
1134
  // --------------------------------------------------------------------
866
1135
  // F.5 MCP server entries (for runtime-deployed scripts)
1136
+ // beta.5: skipped when the Python runtime setup failed — entries pointing
1137
+ // at a venv that does not exist would make every MCP fail to launch.
867
1138
  // --------------------------------------------------------------------
868
- if (componentSet.has('runtime')) {
1139
+ if (componentSet.has('runtime') && runtimeHealthy) {
869
1140
  config.mcp = config.mcp || {}
1141
+ // MCP stays a flat named-server map for BOTH V1 and V2: OpenCode 1.18.x
1142
+ // has no `mcp.servers` wrapper and rejects one with
1143
+ // "Missing key mcp.servers.enabled". config-migration.mjs normalizes any
1144
+ // legacy wrapper-shaped entries back to the flat map.
870
1145
  // P1-3: derive the MCP python from the SAME venv layout setupVenv
871
1146
  // creates (<target>/.venv via venvPythonPath). For project installs the
872
1147
  // runtime payload lives under <target>/.opencode (runtimeTarget), but the
@@ -940,8 +1215,17 @@ export async function installOpenCode(
940
1215
  }
941
1216
  }
942
1217
 
943
- const configContent = `${JSON.stringify(config, null, 2)}\n`
944
- const status = writeIfChanged(targetConfigPath, configContent, dryRun)
1218
+ // ── V2 native migration ──
1219
+ // When targeting V2, convert the merged V1-shaped config to native V2 format
1220
+ // before writing. V1 path is untouched (zero regression).
1221
+ let configToWrite = config
1222
+ if (version === 'v2') {
1223
+ info(S.v2Migrating)
1224
+ configToWrite = migrateV1toV2(config)
1225
+ }
1226
+
1227
+ const configContent = `${JSON.stringify(configToWrite, null, 2)}\n`
1228
+ const status = writeConfigWithBackup(targetConfigPath, configContent, dryRun)
945
1229
  if (status === 'created') stats.created++
946
1230
  else stats.skipped++
947
1231
 
@@ -962,42 +1246,24 @@ export async function installOpenCode(
962
1246
  }
963
1247
 
964
1248
  // -----------------------------------------------------------------------
965
- // 4. Setup virtual environment + health check (--components runtime)
1249
+ // 4. (runtime setup moved to 2.11 — before the config write — so MCP
1250
+ // entries are only added when the Python runtime is actually healthy)
966
1251
  // -----------------------------------------------------------------------
967
- if (componentSet.has('runtime')) {
968
- try {
969
- const venvSpinner = spinner('Setting up Python virtual environment')
970
- setupVenv(target, { dryRun, force: clean })
971
- venvSpinner(true)
972
- const health = healthCheck(target, { dryRun })
973
-
974
- // Print health summary
975
- section('\uD83D\uDD0D Health Check')
976
- const healthStep = step('Running health checks')
977
- for (const p of health.passed) success(`${p.check}: ${p.detail}`)
978
- for (const w of health.warnings) warning(`${w.check}: ${w.detail}`)
979
- for (const f of health.failed) error(`${f.check}: ${f.detail}`)
980
-
981
- if (health.failed.length > 0) {
982
- healthStep(false)
983
- warning(`${health.failed.length} check(s) failed — review above`)
984
- stats.errors += health.failed.length
985
- } else {
986
- healthStep(true)
987
- }
988
- } catch (err) {
989
- error(`Setup failed: ${err.message}`)
990
- throw err // Fatal — abort installation
991
- }
992
- }
993
1252
 
994
1253
  if (interactive && !dryRun) {
995
1254
  const created = stats.created
996
1255
  const skipped = stats.skipped
997
1256
  const errors = stats.errors
1257
+ const warnings = stats.warnings ?? 0
998
1258
 
999
1259
  process.stdout.write('\n')
1000
- process.stdout.write(` ${colors.bold(colors.green('Installation complete'))}\n`)
1260
+ process.stdout.write(
1261
+ ` ${colors.bold(
1262
+ errors > 0
1263
+ ? colors.yellow('Installation completed with errors')
1264
+ : colors.green('Installation complete'),
1265
+ )}\n`,
1266
+ )
1001
1267
  process.stdout.write(
1002
1268
  ` ${colors.dim('\u2500'.repeat(Math.min(process.stdout.columns || 60, 60)))}\n`,
1003
1269
  )
@@ -1006,11 +1272,14 @@ export async function installOpenCode(
1006
1272
  process.stdout.write(` ${colors.dim('\u2014')} ${skipped} already up-to-date\n`)
1007
1273
  if (errors > 0)
1008
1274
  process.stdout.write(` ${colors.yellow('\u26a0')} ${errors} error(s) encountered\n`)
1275
+ if (warnings > 0)
1276
+ process.stdout.write(
1277
+ ` ${colors.yellow('\u26a0')} ${warnings} warning(s) — run 'pantheon-opencode doctor' for details\n`,
1278
+ )
1009
1279
  process.stdout.write('\n')
1010
1280
  process.stdout.write(` ${colors.bold('Next steps:')}\n`)
1011
1281
  process.stdout.write(` ${colors.dim('\u2022')} Configure agents in opencode.json\n`)
1012
- process.stdout.write(` ${colors.dim('\u2022')} Add MCP servers in mcp.json\n`)
1013
- process.stdout.write(` ${colors.dim('\u2022')} Run 'opencode doctor' to verify\n`)
1282
+ process.stdout.write(` ${colors.dim('\u2022')} Run 'pantheon-opencode doctor' to verify\n`)
1014
1283
  process.stdout.write(
1015
1284
  ` ${colors.dim('\u2022')} Run 'opencode' to start using Pantheon agents\n`,
1016
1285
  )
@@ -1034,4 +1303,10 @@ export async function installOpenCode(
1034
1303
  const { writeActivePreset } = await import('./model-picker.mjs')
1035
1304
  writeActivePreset(target, opts.preset, { source: 'cli' })
1036
1305
  }
1306
+
1307
+ // Fail-closed: accumulated stats.errors (bad sources, missing requirements,
1308
+ // failed health checks) must never masquerade as a successful installation.
1309
+ if (stats.errors > 0) {
1310
+ throw new Error(`installation completed with ${stats.errors} error(s); see output above`)
1311
+ }
1037
1312
  }