pantheon-opencode 1.5.0-beta.1 → 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 (82) hide show
  1. package/.pantheon/code-mode/manifest.json +21 -0
  2. package/CHANGELOG.md +1026 -0
  3. package/README.md +171 -16
  4. package/README.pt-BR.md +99 -13
  5. package/bin/pantheon-init.mjs +178 -21
  6. package/package.json +23 -14
  7. package/plugin.json +1 -1
  8. package/pyproject.toml +65 -0
  9. package/scripts/code_mode_server.py +431 -55
  10. package/scripts/cost.mjs +30 -14
  11. package/scripts/doctor.mjs +178 -9
  12. package/scripts/install/cli-ui.mjs +17 -11
  13. package/scripts/install/config-migration.mjs +4 -6
  14. package/scripts/install/opencode.mjs +137 -35
  15. package/scripts/install/plugin.mjs +3 -33
  16. package/scripts/install/shared.mjs +43 -2
  17. package/scripts/install/strings.mjs +129 -0
  18. package/scripts/install-mcp.mjs +1 -0
  19. package/scripts/manifest-inventory.mjs +410 -0
  20. package/scripts/mcp_persistence_server.py +537 -128
  21. package/scripts/package-evidence.mjs +242 -0
  22. package/scripts/probe-context-rehydrate.mjs +240 -0
  23. package/scripts/probe-pantheon-cost.mjs +313 -0
  24. package/scripts/release-beta-version.mjs +56 -60
  25. package/scripts/sync-artifacts.mjs +169 -0
  26. package/scripts/sync-tui.mjs +121 -53
  27. package/scripts/test-opencode-v1-v2-sandbox.sh +367 -96
  28. package/scripts/validation-policy.mjs +7 -7
  29. package/scripts/version-check.mjs +113 -108
  30. package/scripts/versioning.mjs +230 -86
  31. package/src/agents/mnemosyne.md +1 -1
  32. package/src/agents/zeus.md +2 -2
  33. package/src/mcp/code_mode_server.py +431 -55
  34. package/src/mcp/mcp_persistence_server.py +537 -128
  35. package/src/pantheon/auto-wake.ts +1 -5
  36. package/src/pantheon/background-job-board.ts +43 -1
  37. package/src/pantheon/compaction-handoff.ts +304 -0
  38. package/src/pantheon/cost-command.ts +345 -62
  39. package/src/pantheon/delegate-manager.ts +390 -8
  40. package/src/pantheon/delegation-finalize.ts +32 -0
  41. package/src/pantheon/delegation.ts +235 -18
  42. package/src/pantheon/file-persistence-salvage.ts +64 -0
  43. package/src/pantheon/file-persistence.ts +194 -45
  44. package/src/pantheon/native-probe.ts +234 -0
  45. package/src/pantheon/native-task-status.ts +139 -0
  46. package/src/pantheon/provider-capabilities.ts +221 -0
  47. package/src/pantheon/provider-stream-tracker.ts +182 -0
  48. package/src/pantheon/shared-board.ts +37 -0
  49. package/src/pantheon/task-result-guard.ts +107 -0
  50. package/src/pantheon/token-opt.ts +146 -12
  51. package/src/pantheon/tool-ceiling.ts +159 -0
  52. package/src/pantheon/v2-events.ts +18 -1
  53. package/src/plugin-v2.ts +2 -14
  54. package/src/plugin.ts +28 -17
  55. package/src/plugins/pantheon-hooks.ts +51 -0
  56. package/src/plugins/tui/dist/server.d.ts +1 -1
  57. package/src/plugins/tui/dist/server.d.ts.map +1 -1
  58. package/src/plugins/tui/dist/server.js +1 -1
  59. package/src/plugins/tui/dist/server.js.map +1 -1
  60. package/src/plugins/tui/dist/tui.d.ts +116 -27
  61. package/src/plugins/tui/dist/tui.d.ts.map +1 -1
  62. package/src/plugins/tui/dist/tui.js +414 -150
  63. package/src/plugins/tui/dist/tui.js.map +1 -1
  64. package/src/plugins/tui/dist/tui.tsx +447 -84
  65. package/src/plugins/tui/package-lock.json +2 -2
  66. package/src/plugins/tui/package.json +1 -1
  67. package/src/plugins/tui/src/index.tsx +447 -84
  68. package/.pantheon/code-mode/.gitkeep +0 -0
  69. package/.pantheon/code-mode/.pantheon/deepwork/initial-token-40k/checkpoint-1.json +0 -8
  70. package/.pantheon/code-mode/.pantheon/deepwork/initial-token-40k/checkpoint-10.json +0 -8
  71. package/.pantheon/code-mode/.pantheon/deepwork/initial-token-40k/checkpoint-11.json +0 -8
  72. package/.pantheon/code-mode/.pantheon/deepwork/initial-token-40k/checkpoint-2.json +0 -8
  73. package/.pantheon/code-mode/.pantheon/deepwork/initial-token-40k/checkpoint-3.json +0 -8
  74. package/.pantheon/code-mode/.pantheon/deepwork/initial-token-40k/checkpoint-4.json +0 -8
  75. package/.pantheon/code-mode/.pantheon/deepwork/initial-token-40k/checkpoint-5.json +0 -8
  76. package/.pantheon/code-mode/.pantheon/deepwork/initial-token-40k/checkpoint-6.json +0 -8
  77. package/.pantheon/code-mode/.pantheon/deepwork/initial-token-40k/checkpoint-7.json +0 -8
  78. package/.pantheon/code-mode/.pantheon/deepwork/initial-token-40k/checkpoint-8.json +0 -8
  79. package/.pantheon/code-mode/.pantheon/deepwork/initial-token-40k/checkpoint-9.json +0 -8
  80. package/.pantheon/code-mode/.pantheon/deepwork/initial-token-40k/heartbeat.json +0 -8
  81. package/.pantheon/code-mode/.pantheon/deepwork/initial-token-40k/session.json +0 -11
  82. package/src/pantheon/delegate-task-adapter.ts +0 -444
@@ -22,7 +22,8 @@
22
22
  */
23
23
 
24
24
  import { spawn as spawnAsync, spawnSync } from 'node:child_process'
25
- import { existsSync, mkdirSync, readdirSync, readFileSync } from 'node:fs'
25
+ import { createHash } from 'node:crypto'
26
+ import { existsSync, mkdirSync, readdirSync, readFileSync, statSync } from 'node:fs'
26
27
  import { homedir } from 'node:os'
27
28
  import { basename, dirname, join, resolve } from 'node:path'
28
29
  import { fileURLToPath } from 'node:url'
@@ -389,7 +390,7 @@ function checkMcpConfig(args) {
389
390
  // ENOENT-on-spawn failure class (dead hardcoded path, ambiguous relative
390
391
  // path) before opencode tries to spawn the server.
391
392
  for (const [name, mcpEntry] of Object.entries(mcp)) {
392
- if (!mcpEntry || mcpEntry.type !== 'local') continue
393
+ if (mcpEntry?.type !== 'local') continue
393
394
  const cmd = mcpEntry.command
394
395
  if (!Array.isArray(cmd) || cmd.length === 0) {
395
396
  warn(`${cfg.label}: MCP "${name}" has no command array`)
@@ -530,7 +531,7 @@ async function checkMcpRuntimeSmoke(args) {
530
531
  const local = []
531
532
  for (const cfg of collectMcpConfigs(args)) {
532
533
  for (const [name, entry] of Object.entries(cfg.data.mcp ?? {})) {
533
- if (!entry || entry.type !== 'local') continue
534
+ if (entry?.type !== 'local') continue
534
535
  if (!Array.isArray(entry.command) || entry.command.length === 0) continue
535
536
  const cwd = resolveMcpCwd(entry, cfg.path)
536
537
  const command = entry.command.map((part, index) =>
@@ -966,12 +967,20 @@ function checkVenvLayer(args) {
966
967
  */
967
968
  export function checkCodeModeDir(args) {
968
969
  section('F.2 Code-Mode Scripts')
969
- const target = resolve(args.target)
970
- const isProject = existsSync(join(target, '.opencode'))
971
- const runtimeRoot = isProject ? target : resolveOpenCodeConfigDir(args.env ?? process.env)
972
- const codeModeDir = isProject
973
- ? join(runtimeRoot, '.opencode', '.pantheon', 'code-mode')
974
- : join(runtimeRoot, '.pantheon', 'code-mode')
970
+ const env = args.env ?? process.env
971
+ const target = resolve(env.PANTHEON_PROJECT ?? args.target)
972
+ const projectCandidates = [
973
+ join(target, '.opencode', '.pantheon', 'code-mode'),
974
+ join(target, '.pantheon', 'code-mode'),
975
+ ]
976
+ // Seeding semantics: an existing project candidate wins; a project
977
+ // install (.opencode present) gets its runtime dir created when missing.
978
+ // Only targets with no project layout fall back to the global config dir.
979
+ const codeModeDir =
980
+ projectCandidates.find((candidate) => existsSync(candidate)) ??
981
+ (existsSync(join(target, '.opencode'))
982
+ ? projectCandidates[0]
983
+ : join(resolveOpenCodeConfigDir(env), '.pantheon', 'code-mode'))
975
984
  if (existsSync(codeModeDir)) {
976
985
  pass(`Code-mode scripts directory exists: ${codeModeDir}`)
977
986
  return codeModeDir
@@ -986,6 +995,83 @@ export function checkCodeModeDir(args) {
986
995
  return codeModeDir
987
996
  }
988
997
 
998
+ /** Resolve code-mode using the same project-first rule as the MCP server. */
999
+ export function resolveCodeModeDir(args) {
1000
+ const env = args.env ?? process.env
1001
+ const target = resolve(env.PANTHEON_PROJECT ?? args.target)
1002
+ const projectCandidates = [
1003
+ join(target, '.opencode', '.pantheon', 'code-mode'),
1004
+ join(target, '.pantheon', 'code-mode'),
1005
+ ]
1006
+ const projectDir = projectCandidates.find((candidate) => existsSync(candidate))
1007
+ if (projectDir) return projectDir
1008
+ return join(resolveOpenCodeConfigDir(env), '.pantheon', 'code-mode')
1009
+ }
1010
+
1011
+ /** Validate a code-mode manifest without modifying it. */
1012
+ export function validateCodeModeManifest(codeModeDir) {
1013
+ const manifestPath = join(codeModeDir, 'manifest.json')
1014
+ if (!existsSync(manifestPath)) {
1015
+ return { ok: false, message: `Code-mode manifest missing: ${manifestPath}` }
1016
+ }
1017
+
1018
+ let data
1019
+ try {
1020
+ data = JSON.parse(readFileSync(manifestPath, 'utf8'))
1021
+ } catch {
1022
+ return { ok: false, message: `Code-mode manifest malformed: ${manifestPath}` }
1023
+ }
1024
+ if (!data || typeof data !== 'object' || Array.isArray(data)) {
1025
+ return { ok: false, message: 'Code-mode manifest malformed: root must be an object' }
1026
+ }
1027
+ if (data.version !== 1) {
1028
+ return { ok: false, message: 'Code-mode manifest malformed: version must be 1' }
1029
+ }
1030
+ if (!data.scripts || typeof data.scripts !== 'object' || Array.isArray(data.scripts)) {
1031
+ return { ok: false, message: 'Code-mode manifest malformed: scripts must be an object' }
1032
+ }
1033
+
1034
+ const listed = Object.keys(data.scripts)
1035
+ const invalid = listed.filter(
1036
+ (name) => typeof data.scripts[name] !== 'string' || !/^[a-f0-9]{64}$/i.test(data.scripts[name]),
1037
+ )
1038
+ if (invalid.length) {
1039
+ return { ok: false, message: `Code-mode manifest invalid digest: ${invalid.join(', ')}` }
1040
+ }
1041
+
1042
+ const actualScripts = readdirSync(codeModeDir).filter(
1043
+ (name) =>
1044
+ (name.endsWith('.py') || name.endsWith('.sh')) && statSync(join(codeModeDir, name)).isFile(),
1045
+ )
1046
+ const missing = listed.filter((name) => !existsSync(join(codeModeDir, name)))
1047
+ const extra = actualScripts.filter((name) => !Object.hasOwn(data.scripts, name))
1048
+ const differing = listed.filter((name) => {
1049
+ const path = join(codeModeDir, name)
1050
+ if (!existsSync(path)) return false
1051
+ const digest = createHash('sha256').update(readFileSync(path)).digest('hex')
1052
+ return digest !== data.scripts[name].toLowerCase()
1053
+ })
1054
+ if (missing.length || extra.length || differing.length) {
1055
+ const names = [...new Set([...missing, ...extra, ...differing])]
1056
+ return {
1057
+ ok: false,
1058
+ message: `Code-mode manifest mismatch: ${differing.length + missing.length + extra.length}/${listed.length} scripts differ — ${names.join(', ')}`,
1059
+ }
1060
+ }
1061
+ return { ok: true, count: listed.length, total: actualScripts.length }
1062
+ }
1063
+
1064
+ /** Run the diagnostic manifest check after directory validation. */
1065
+ export function checkCodeModeManifest(args) {
1066
+ const codeModeDir = resolveCodeModeDir(args)
1067
+ const result = validateCodeModeManifest(codeModeDir)
1068
+ if (!result.ok) {
1069
+ error(result.message)
1070
+ return
1071
+ }
1072
+ pass(`✅ Code-mode manifest valid: ${result.count}/${result.total} scripts verified`)
1073
+ }
1074
+
989
1075
  /** Resolve the venv created by init for either a project or global install. */
990
1076
  export function resolveRuntimePython(args) {
991
1077
  const target = resolve(args.target)
@@ -1018,6 +1104,87 @@ export function resolveMcpCwd(entry, configPath = '') {
1018
1104
  return dirname(configPath)
1019
1105
  }
1020
1106
 
1107
+ // ---------------------------------------------------------------------------
1108
+ // Check H2: installed-version drift (beta.5)
1109
+ // ---------------------------------------------------------------------------
1110
+
1111
+ /**
1112
+ * Prerelease-aware comparison for pantheon versions (X.Y.Z or X.Y.Z-beta.N).
1113
+ * A stable release outranks any beta of the same core version. Returns
1114
+ * <0, 0, >0 (a - b). The shared compareVersions/parseVersion above ignores
1115
+ * the `-beta.N` suffix (it only reads 4 numeric groups), which would make
1116
+ * every beta pair compare equal.
1117
+ */
1118
+ function comparePantheonVersions(a, b) {
1119
+ const parse = (v) => {
1120
+ const m = String(v)
1121
+ .trim()
1122
+ .match(/^(\d+)\.(\d+)\.(\d+)(?:-beta\.(\d+))?$/)
1123
+ if (!m) return null
1124
+ return {
1125
+ core: [+m[1], +m[2], +m[3]],
1126
+ beta: m[4] === undefined ? Number.POSITIVE_INFINITY : +m[4],
1127
+ }
1128
+ }
1129
+ const pa = parse(a)
1130
+ const pb = parse(b)
1131
+ if (!pa || !pb) return 0
1132
+ for (let i = 0; i < 3; i++) {
1133
+ if (pa.core[i] !== pb.core[i]) return pa.core[i] - pb.core[i]
1134
+ }
1135
+ return pa.beta - pb.beta
1136
+ }
1137
+
1138
+ /**
1139
+ * Compare the version recorded in the installation marker
1140
+ * (<config>/.pantheon/install-state.json, written by the postinstall sync)
1141
+ * against this package's version. When the package is newer, the config
1142
+ * merge/venv/MCP wiring may be stale — point the user at `update`.
1143
+ * Advisory only: a missing marker (pre-beta.5 install) is not an error.
1144
+ */
1145
+ export function checkInstallVersionDrift(args) {
1146
+ section('H2 Installed Version')
1147
+ const env = args.env ?? process.env
1148
+ let pkgVersion = null
1149
+ try {
1150
+ pkgVersion = JSON.parse(readFileSync(join(ROOT, 'package.json'), 'utf8')).version
1151
+ } catch {
1152
+ return 'no-marker'
1153
+ }
1154
+ if (!pkgVersion) return 'no-marker'
1155
+
1156
+ const statePath = join(resolveOpenCodeConfigDir(env), '.pantheon', 'install-state.json')
1157
+ let installed = null
1158
+ if (existsSync(statePath)) {
1159
+ try {
1160
+ installed = JSON.parse(readFileSync(statePath, 'utf8')).pantheon_version ?? null
1161
+ } catch {
1162
+ installed = null
1163
+ }
1164
+ }
1165
+ if (!installed) {
1166
+ info('No installation version marker found (pre-beta.5 install) — skipping drift check')
1167
+ return 'no-marker'
1168
+ }
1169
+ const drift = comparePantheonVersions(pkgVersion, installed)
1170
+ if (drift > 0) {
1171
+ warn(
1172
+ `Package v${pkgVersion} is newer than the last-synced installation (v${installed}) — ` +
1173
+ 'run `npx pantheon-opencode update` (or re-run init) to refresh config, venv and MCP entries',
1174
+ )
1175
+ return 'package-newer'
1176
+ }
1177
+ if (drift < 0) {
1178
+ warn(
1179
+ `Installation marker (v${installed}) is newer than this package (v${pkgVersion}) — ` +
1180
+ 'the doctor is running from a different install than the last sync',
1181
+ )
1182
+ return 'install-newer'
1183
+ }
1184
+ pass(`Installation in sync with package v${pkgVersion}`)
1185
+ return 'sync'
1186
+ }
1187
+
1021
1188
  // ---------------------------------------------------------------------------
1022
1189
  // Check G: AGENTS.md freshness
1023
1190
  // ---------------------------------------------------------------------------
@@ -1314,6 +1481,8 @@ async function main() {
1314
1481
  checkMcpConfig(args)
1315
1482
  checkVenvLayer(args)
1316
1483
  checkCodeModeDir(args)
1484
+ checkCodeModeManifest(args)
1485
+ checkInstallVersionDrift(args)
1317
1486
  await checkMcpRuntimeSmoke(args)
1318
1487
  checkPermissionMismatches(args)
1319
1488
  checkSyncStatus(args)
@@ -1,3 +1,5 @@
1
+ import { strings } from './strings.mjs'
2
+
1
3
  export const icons = {
2
4
  success: '\u2705',
3
5
  warning: '\u26a0\ufe0f ',
@@ -8,9 +10,9 @@ export const icons = {
8
10
  arrow: '\u2192',
9
11
  bullet: '\u2022',
10
12
  star: '\u2b50',
11
- wrench: '\U0001f527',
12
- rocket: '\U0001f680',
13
- package: '\U0001f4e6',
13
+ wrench: '\u{1F527}',
14
+ rocket: '\u{1F680}',
15
+ package: '\u{1F4E6}',
14
16
  gear: '\u2699\ufe0f',
15
17
  }
16
18
 
@@ -160,28 +162,32 @@ export function spinner(message) {
160
162
  export function printSummary(target, platforms, stats) {
161
163
  if (_quiet) return
162
164
 
165
+ const S = strings()
163
166
  const line = '\u2500'.repeat(Math.min(process.stdout.columns || 60, 60))
164
167
  const hasErrors = stats && stats.errors > 0
165
168
  console.log(
166
169
  tag(
167
170
  hasErrors
168
- ? `${icons.warning} Instalação concluída com erros em ${target}`
169
- : `${icons.rocket} OpenCode instalado em ${target}`,
171
+ ? `${icons.warning} ${S.summaryWithErrors(target)}`
172
+ : `${icons.rocket} ${S.summaryInstalled(target)}`,
170
173
  ),
171
174
  )
172
175
  console.log(tag(line))
173
176
  console.log('')
174
177
 
175
178
  if (stats) {
176
- console.log(tag(` Componentes:\t${stats.created} instalados, ${stats.skipped} pulados`))
179
+ console.log(tag(S.summaryComponents(stats.created, stats.skipped)))
177
180
  if (stats.errors > 0) {
178
- console.warn(tag(` ${icons.warning}${stats.errors} erro(s) encontrados`))
181
+ console.warn(tag(S.summaryErrors(stats.errors)))
182
+ }
183
+ if (stats.warnings > 0) {
184
+ console.warn(tag(S.summaryWarnings(stats.warnings)))
179
185
  }
180
186
  console.log('')
181
187
  }
182
188
 
183
- console.log(tag(` ${icons.star} Próximos passos:`))
184
- console.log(tag(` ${icons.bullet} Configure seus agentes em opencode.json`))
185
- console.log(tag(` ${icons.bullet} Adicione MCP servers em mcp.json`))
186
- console.log(tag(` ${icons.bullet} Rode 'opencode doctor' para verificar a instalação`))
189
+ console.log(tag(` ${icons.star} ${S.summaryNext.trim()}`))
190
+ console.log(tag(S.summaryNextAgents))
191
+ console.log(tag(S.summaryNextMcp))
192
+ console.log(tag(S.summaryNextDoctor))
187
193
  }
@@ -73,7 +73,7 @@ function convertPermissionsV2toV1(permArray) {
73
73
  const result = {}
74
74
  // Reverse action mapping: V2 action → V1 key
75
75
  const ACTION_TO_V1 = Object.fromEntries(
76
- Object.entries(V1_PERMISSION_TO_ACTION).map(([k, v]) => [v, k])
76
+ Object.entries(V1_PERMISSION_TO_ACTION).map(([k, v]) => [v, k]),
77
77
  )
78
78
 
79
79
  for (const { action, resource, effect } of permArray) {
@@ -104,7 +104,7 @@ const PROVIDER_RENAME = {
104
104
 
105
105
  /** Reverse: V2 → V1 */
106
106
  const PROVIDER_RENAME_REVERSE = Object.fromEntries(
107
- Object.entries(PROVIDER_RENAME).map(([k, v]) => [v, k])
107
+ Object.entries(PROVIDER_RENAME).map(([k, v]) => [v, k]),
108
108
  )
109
109
 
110
110
  /**
@@ -176,9 +176,7 @@ function convertProviderV2toV1(name, config) {
176
176
 
177
177
  // package → npm (strip aisdk: prefix)
178
178
  if (result.package !== undefined) {
179
- result.npm = result.package.startsWith('aisdk:')
180
- ? result.package.slice(6)
181
- : result.package
179
+ result.npm = result.package.startsWith('aisdk:') ? result.package.slice(6) : result.package
182
180
  delete result.package
183
181
  }
184
182
 
@@ -303,7 +301,7 @@ const V1_TO_V2_RENAMES = {
303
301
 
304
302
  /** Build reverse map */
305
303
  const V2_TO_V1_RENAMES = Object.fromEntries(
306
- Object.entries(V1_TO_V2_RENAMES).map(([k, v]) => [v, k])
304
+ Object.entries(V1_TO_V2_RENAMES).map(([k, v]) => [v, k]),
307
305
  )
308
306
 
309
307
  // ---------------------------------------------------------------------------
@@ -12,6 +12,7 @@
12
12
  * isolation is handled at runtime via OPENCODE_DB.
13
13
  */
14
14
 
15
+ import { createHash } from 'node:crypto'
15
16
  import { chmodSync, existsSync, mkdirSync, readdirSync, readFileSync, rmSync } from 'node:fs'
16
17
  import { homedir } from 'node:os'
17
18
  import { dirname, isAbsolute, join, resolve } from 'node:path'
@@ -34,6 +35,7 @@ import { detectVersion, runMigrations } from './migrate.mjs'
34
35
  import { resolveOpenCodeVersion } from './opencode-version.mjs'
35
36
  import { copyPluginFiles, installPlugin, registerPlugin, unregisterPlugin } from './plugin.mjs'
36
37
  import {
38
+ checkRuntimePrerequisites,
37
39
  collectSkillNames,
38
40
  copyFiles,
39
41
  installSkills,
@@ -42,8 +44,10 @@ import {
42
44
  sourceDirValid,
43
45
  summary,
44
46
  syncDir,
47
+ writeConfigWithBackup,
45
48
  writeIfChanged,
46
49
  } from './shared.mjs'
50
+ import { strings } from './strings.mjs'
47
51
 
48
52
  import { setupVenv, venvPythonPath } from './venv.mjs'
49
53
 
@@ -61,6 +65,34 @@ function deepClone(value) {
61
65
  return JSON.parse(JSON.stringify(value))
62
66
  }
63
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
+
64
96
  function readJsonConfig(filePath) {
65
97
  if (!existsSync(filePath)) return {}
66
98
 
@@ -185,6 +217,21 @@ function managedPluginIdentity(ref) {
185
217
  if (absolutePath === normalizePluginPath(resolve(ROOT, PANTHEON_V2_LEGACY_FILE))) {
186
218
  return PANTHEON_V2_PLUGIN
187
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
+ }
188
235
  }
189
236
 
190
237
  return null
@@ -343,6 +390,45 @@ export function syncTuiRegistration(target, { isGlobal = false, dryRun = false }
343
390
  return registerPlugin(targetTuiConfigPath, tuiCopyDir, { dryRun })
344
391
  }
345
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
+
346
432
  export async function installOpenCode(
347
433
  target,
348
434
  dryRun = false,
@@ -357,6 +443,7 @@ export async function installOpenCode(
357
443
 
358
444
  const componentSet = new Set(components)
359
445
  const stats = summary.opencode
446
+ const S = strings()
360
447
 
361
448
  // V1/V2 dual-version awareness (Phase 3): the installer builds a unified
362
449
  // config, then applies V2-native migration (migrateV1toV2) when
@@ -405,6 +492,17 @@ export async function installOpenCode(
405
492
  const config = readJsonConfig(targetConfigPath)
406
493
  const pantheonConfig = readJsonConfig(pantheonConfigPath)
407
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
+
408
506
  // Determine layout based on install scope.
409
507
  // Global config dir (~/.opencode or ~/.config/opencode) uses a flat layout:
410
508
  // agents/ skills/ commands/ plugins/
@@ -417,7 +515,7 @@ export async function installOpenCode(
417
515
  configure({ dryRun })
418
516
 
419
517
  if (isGlobal) {
420
- info('Global config directory detected — using flat layout (agents/, skills/, commands/)')
518
+ info(S.globalLayout)
421
519
  }
422
520
 
423
521
  // -----------------------------------------------------------------------
@@ -430,7 +528,7 @@ export async function installOpenCode(
430
528
  warning(`Agent source directory not found: ${srcDir}`)
431
529
  stats.errors++
432
530
  } else {
433
- const agentStep = step('Installing agents')
531
+ const agentStep = step(S.installingAgents)
434
532
  const dstDir = isGlobal ? join(target, 'agents') : join(target, '.opencode', 'agents')
435
533
  if (!dryRun) mkdirSync(dstDir, { recursive: true })
436
534
  if (clean && existsSync(dstDir) && !dryRun) {
@@ -665,6 +763,13 @@ export async function installOpenCode(
665
763
  stats.created++
666
764
  }
667
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`)
771
+ }
772
+
668
773
  // ── tiers.json ──
669
774
  const srcTiers = join(ROOT, '.pantheon', 'tiers.json')
670
775
  const dstTiers = join(runtimeTarget, '.pantheon', 'tiers.json')
@@ -676,6 +781,17 @@ export async function installOpenCode(
676
781
  }
677
782
  }
678
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
+
679
795
  // -----------------------------------------------------------------------
680
796
  // 3. Create/update opencode.json (always runs)
681
797
  // Reads TARGET's existing config first, then merges Pantheon settings
@@ -1017,8 +1133,10 @@ export async function installOpenCode(
1017
1133
 
1018
1134
  // --------------------------------------------------------------------
1019
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.
1020
1138
  // --------------------------------------------------------------------
1021
- if (componentSet.has('runtime')) {
1139
+ if (componentSet.has('runtime') && runtimeHealthy) {
1022
1140
  config.mcp = config.mcp || {}
1023
1141
  // MCP stays a flat named-server map for BOTH V1 and V2: OpenCode 1.18.x
1024
1142
  // has no `mcp.servers` wrapper and rejects one with
@@ -1102,12 +1220,12 @@ export async function installOpenCode(
1102
1220
  // before writing. V1 path is untouched (zero regression).
1103
1221
  let configToWrite = config
1104
1222
  if (version === 'v2') {
1105
- info('Migrating config to V2 native format...')
1223
+ info(S.v2Migrating)
1106
1224
  configToWrite = migrateV1toV2(config)
1107
1225
  }
1108
1226
 
1109
1227
  const configContent = `${JSON.stringify(configToWrite, null, 2)}\n`
1110
- const status = writeIfChanged(targetConfigPath, configContent, dryRun)
1228
+ const status = writeConfigWithBackup(targetConfigPath, configContent, dryRun)
1111
1229
  if (status === 'created') stats.created++
1112
1230
  else stats.skipped++
1113
1231
 
@@ -1128,40 +1246,15 @@ export async function installOpenCode(
1128
1246
  }
1129
1247
 
1130
1248
  // -----------------------------------------------------------------------
1131
- // 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)
1132
1251
  // -----------------------------------------------------------------------
1133
- if (componentSet.has('runtime')) {
1134
- try {
1135
- const venvSpinner = spinner('Setting up Python virtual environment')
1136
- setupVenv(target, { dryRun, force: clean })
1137
- venvSpinner(true)
1138
- const runtimeTarget = isGlobal ? target : join(target, '.opencode')
1139
- const health = healthCheck(runtimeTarget, { dryRun, pythonTarget: target })
1140
-
1141
- // Print health summary
1142
- section('\uD83D\uDD0D Health Check')
1143
- const healthStep = step('Running health checks')
1144
- for (const p of health.passed) success(`${p.check}: ${p.detail}`)
1145
- for (const w of health.warnings) warning(`${w.check}: ${w.detail}`)
1146
- for (const f of health.failed) error(`${f.check}: ${f.detail}`)
1147
-
1148
- if (health.failed.length > 0) {
1149
- healthStep(false)
1150
- warning(`${health.failed.length} check(s) failed — review above`)
1151
- stats.errors += health.failed.length
1152
- } else {
1153
- healthStep(true)
1154
- }
1155
- } catch (err) {
1156
- error(`Setup failed: ${err.message}`)
1157
- throw err // Fatal — abort installation
1158
- }
1159
- }
1160
1252
 
1161
1253
  if (interactive && !dryRun) {
1162
1254
  const created = stats.created
1163
1255
  const skipped = stats.skipped
1164
1256
  const errors = stats.errors
1257
+ const warnings = stats.warnings ?? 0
1165
1258
 
1166
1259
  process.stdout.write('\n')
1167
1260
  process.stdout.write(
@@ -1179,11 +1272,14 @@ export async function installOpenCode(
1179
1272
  process.stdout.write(` ${colors.dim('\u2014')} ${skipped} already up-to-date\n`)
1180
1273
  if (errors > 0)
1181
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
+ )
1182
1279
  process.stdout.write('\n')
1183
1280
  process.stdout.write(` ${colors.bold('Next steps:')}\n`)
1184
1281
  process.stdout.write(` ${colors.dim('\u2022')} Configure agents in opencode.json\n`)
1185
- process.stdout.write(` ${colors.dim('\u2022')} Add MCP servers in mcp.json\n`)
1186
- 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`)
1187
1283
  process.stdout.write(
1188
1284
  ` ${colors.dim('\u2022')} Run 'opencode' to start using Pantheon agents\n`,
1189
1285
  )
@@ -1207,4 +1303,10 @@ export async function installOpenCode(
1207
1303
  const { writeActivePreset } = await import('./model-picker.mjs')
1208
1304
  writeActivePreset(target, opts.preset, { source: 'cli' })
1209
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
+ }
1210
1312
  }