pantheon-opencode 1.6.0-beta.4 → 1.6.0-beta.6

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 (51) hide show
  1. package/AGENTS.md +42 -23
  2. package/CHANGELOG.md +41 -0
  3. package/README.md +114 -12
  4. package/README.pt-BR.md +15 -6
  5. package/bin/pantheon-init.mjs +26 -3
  6. package/package.json +5 -6
  7. package/plugin.json +1 -1
  8. package/pyproject.toml +6 -6
  9. package/scripts/{code_mode_server.py → code_mode.py} +2 -2
  10. package/scripts/doctor.mjs +151 -1
  11. package/scripts/install/config-migration.mjs +80 -2
  12. package/scripts/install/health-check.mjs +12 -17
  13. package/scripts/install/opencode-version.mjs +246 -4
  14. package/scripts/install/opencode.mjs +250 -119
  15. package/scripts/install-mcp.mjs +28 -9
  16. package/scripts/{mcp_persistence_server.py → mcp_persistence.py} +1 -1
  17. package/{src/mcp/mcp_resources_server.py → scripts/mcp_resources.py} +1 -1
  18. package/scripts/{memory_mcp_server.py → memory_mcp.py} +1 -1
  19. package/scripts/probe-context-rehydrate.mjs +2 -2
  20. package/scripts/run-node-tests.mjs +189 -0
  21. package/scripts/sync-artifacts.mjs +5 -5
  22. package/scripts/test-opencode-v2-sandbox.sh +21 -21
  23. package/skills-lock.json +2 -2
  24. package/src/agents/aphrodite.md +1 -1
  25. package/src/agents/apollo.md +7 -2
  26. package/src/agents/athena.md +1 -0
  27. package/src/agents/demeter.md +1 -1
  28. package/src/agents/hephaestus.md +1 -1
  29. package/src/agents/hermes.md +1 -1
  30. package/src/agents/iris.md +1 -0
  31. package/src/agents/nyx.md +1 -0
  32. package/src/agents/themis.md +1 -0
  33. package/src/agents/zeus.md +39 -10
  34. package/src/instructions/memory-protocol.instructions.md +21 -10
  35. package/src/instructions/zeus-anti-stall.instructions.md +19 -11
  36. package/src/instructions/zeus-council-synthesis.instructions.md +2 -2
  37. package/src/mcp/{code_mode_server.py → code_mode.py} +2 -2
  38. package/src/mcp/eval_store.py +2 -2
  39. package/src/mcp/{mcp_persistence_server.py → mcp_persistence.py} +1 -1
  40. package/{scripts/mcp_resources_server.py → src/mcp/mcp_resources.py} +1 -1
  41. package/src/mcp/{memory_mcp_server.py → memory_mcp.py} +1 -1
  42. package/src/pantheon/v2-unsupported.d.mts +11 -0
  43. package/src/pantheon/v2-unsupported.mjs +113 -0
  44. package/src/plugin-v2.ts +149 -53
  45. package/src/plugins/tui/dist/tui.js +1 -1
  46. package/src/plugins/tui/package-lock.json +2 -2
  47. package/src/plugins/tui/package.json +1 -1
  48. package/src/skills/auto-continue/SKILL.md +4 -4
  49. package/src/skills/context-compression/SKILL.md +1 -1
  50. package/opencode.json +0 -58
  51. /package/src/mcp/{pantheon_vision_server.py → pantheon_vision.py} +0 -0
@@ -4,10 +4,14 @@
4
4
  *
5
5
  * Dual-version install (Phase 3): V1 and V2 receive the schema each runtime
6
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
7
+ * V2 (`opencode2`, OpenCode 2.x — `opencode2 --version` prints
8
+ * "opencode v2.0.22") converts that list to `plugins` before
9
+ * writing its config. An early 2.x prerelease printed "0.0.0-next-17444"
10
+ * instead; that is history, not what hosts report today, and it must not be
11
+ * cited as an observation (see the precedence rationale in
12
+ * scripts/install/opencode-version.mjs). Known V2 beta gaps handled here:
13
+ * top-level `subagent_depth` is migrated to `experimental.subagent_depth`, and
14
+ * the instruction content is consolidated into AGENTS.md, which both versions
11
15
  * load. Pass --version v2 to pantheon-init for an informational label; state
12
16
  * isolation is handled at runtime via OPENCODE_DB.
13
17
  */
@@ -16,6 +20,7 @@ import { createHash } from 'node:crypto'
16
20
  import { chmodSync, existsSync, mkdirSync, readdirSync, readFileSync, rmSync } from 'node:fs'
17
21
  import { homedir } from 'node:os'
18
22
  import { dirname, isAbsolute, join, resolve } from 'node:path'
23
+ import { listV2UnsupportedFeatures } from '../../src/pantheon/v2-unsupported.mjs'
19
24
  import {
20
25
  bullet,
21
26
  colors,
@@ -29,7 +34,7 @@ import {
29
34
  success,
30
35
  warning,
31
36
  } from './cli-ui.mjs'
32
- import { migrateV1toV2 } from './config-migration.mjs'
37
+ import { migrateMcpEntrypointPaths, migrateV1toV2 } from './config-migration.mjs'
33
38
  import { healthCheck } from './health-check.mjs'
34
39
  import { detectVersion, runMigrations } from './migrate.mjs'
35
40
  import { resolveOpenCodeVersion } from './opencode-version.mjs'
@@ -61,6 +66,44 @@ const COMPONENT_NAMES = [
61
66
  'runtime',
62
67
  ]
63
68
 
69
+ /**
70
+ * The product defaults an install seeds into the user's `opencode.json`.
71
+ *
72
+ * They live HERE, in code, rather than in a template shipped in the npm
73
+ * tarball: that template also carried a maintainer's private MCP endpoint,
74
+ * provider models and per-developer permission overrides, and shipping it to
75
+ * every installer was the bug (#199). Reading defaults off it meant deleting it
76
+ * silently stopped the seeding, so the defaults are now stated next to the code
77
+ * that consumes them.
78
+ *
79
+ * PERSONAL CONFIGURATION MUST NEVER BE SEEDED HERE: this is the installer's
80
+ * contract with every user, and a value that is only right on one machine
81
+ * belongs in that machine's own `opencode.json`. Deliberately NOT carried over
82
+ * from the template, each because it was personal rather than a product
83
+ * default:
84
+ * - `provider` — pinned the maintainer's provider, model and modalities.
85
+ * - `mcp` — a private remote MCP endpoint.
86
+ * - `agent` — the maintainer's per-agent permission overrides.
87
+ * - `permission.websearch: "deny"` — a personal restriction; a user may rely
88
+ * on web search, so denying it is not ours to decide.
89
+ * - `permission.bash` — the template's dev allowlist (git, npm, ruff, …), a
90
+ * personal judgement about which commands to trust.
91
+ * Anything added here must be justifiable as "correct for a fresh install of
92
+ * the product, for anyone".
93
+ *
94
+ * Other seeding paths are untouched by this constant and still run: the config
95
+ * merge derives plugin refs, provider and compaction from a repository-root
96
+ * opencode.json when a contributor has one, and separately gates its own
97
+ * permission grants — `skill` on the skills component, `bash` unconditionally —
98
+ * on whether the user's own config already set them.
99
+ *
100
+ * Frozen, and copied only into a confirmed gap (never into a key the user
101
+ * already set), so nothing here can be mutated through a user's config.
102
+ */
103
+ const PRODUCT_CONFIG_DEFAULTS = Object.freeze({
104
+ default_agent: 'zeus',
105
+ })
106
+
64
107
  /**
65
108
  * Per-agent fields owned by the framework: for an agent that already exists in
66
109
  * the target config these are overwritten from the canonical source, and for a
@@ -158,8 +201,8 @@ function mergeMissing(target, source) {
158
201
  }
159
202
 
160
203
  /**
161
- * Resolve a plugin ref from the packaged opencode.json to a path INSIDE the
162
- * installed package.
204
+ * Resolve a plugin ref from the repository-root opencode.json to a path INSIDE
205
+ * the installed package.
163
206
  *
164
207
  * Only exact Pantheon plugin identities are resolved into the installed
165
208
  * package. In particular, a third-party absolute path ending in
@@ -456,6 +499,92 @@ export function setupRuntimePhase(target, { dryRun, clean, isGlobal }, stats, de
456
499
  }
457
500
  }
458
501
 
502
+ /**
503
+ * Write the five managed MCP server entries into `config`, healing any stale
504
+ * entrypoint paths first.
505
+ *
506
+ * The heal runs on EVERY install, not only when an entry is missing: the
507
+ * per-server guards below are add-only, so an existing install whose
508
+ * `opencode.json` still carries a pre-rename `*_server.py` path would never be
509
+ * repaired. Healing first means a re-run of the installer fixes an orphaned
510
+ * config in place — the migration that makes the rename non-breaking.
511
+ *
512
+ * @param {object} config - full config object (mutated in place)
513
+ * @param {{ runtimeTarget: string, venvPython: string }} ctx
514
+ * @returns {{ healed: number }} number of stale path strings rewritten
515
+ */
516
+ export function applyMcpServerEntries(config, { runtimeTarget, venvPython }) {
517
+ config.mcp = config.mcp || {}
518
+ // MCP stays a flat named-server map for BOTH V1 and V2: OpenCode 1.18.x has
519
+ // no `mcp.servers` wrapper and rejects one with "Missing key
520
+ // mcp.servers.enabled". config-migration.mjs normalizes any legacy
521
+ // wrapper-shaped entries back to the flat map.
522
+ const healed = migrateMcpEntrypointPaths(config.mcp)
523
+
524
+ // Only add if not already configured by user
525
+ if (!config.mcp['pantheon-resources']) {
526
+ config.mcp['pantheon-resources'] = {
527
+ type: 'local',
528
+ cwd: runtimeTarget,
529
+ command: [venvPython, 'scripts/mcp_resources.py'],
530
+ enabled: true,
531
+ }
532
+ }
533
+ if (!config.mcp['pantheon-code-mode']) {
534
+ config.mcp['pantheon-code-mode'] = {
535
+ type: 'local',
536
+ cwd: runtimeTarget,
537
+ command: [venvPython, 'scripts/code_mode.py'],
538
+ enabled: true,
539
+ }
540
+ }
541
+ if (!config.mcp['pantheon-memory']) {
542
+ config.mcp['pantheon-memory'] = {
543
+ type: 'local',
544
+ cwd: runtimeTarget,
545
+ command: [venvPython, 'scripts/memory_mcp.py'],
546
+ enabled: true,
547
+ }
548
+ }
549
+ if (!config.mcp['pantheon-persistence']) {
550
+ config.mcp['pantheon-persistence'] = {
551
+ type: 'local',
552
+ cwd: runtimeTarget,
553
+ command: [venvPython, 'scripts/mcp_persistence.py'],
554
+ enabled: true,
555
+ }
556
+ }
557
+ if (!config.mcp['pantheon-vision']) {
558
+ config.mcp['pantheon-vision'] = {
559
+ type: 'local',
560
+ cwd: runtimeTarget,
561
+ command: [venvPython, 'scripts/pantheon_vision.py'],
562
+ enabled: true,
563
+ }
564
+ }
565
+
566
+ // Default MCP permissions
567
+ config.permission = config.permission || {}
568
+ config.permission.mcp = config.permission.mcp || {}
569
+ if (!config.permission.mcp['pantheon-resources']) {
570
+ config.permission.mcp['pantheon-resources'] = 'allow'
571
+ }
572
+ if (!config.permission.mcp['pantheon-code-mode']) {
573
+ config.permission.mcp['pantheon-code-mode'] = 'ask'
574
+ }
575
+ if (!config.permission.mcp['pantheon-memory']) {
576
+ config.permission.mcp['pantheon-memory'] = 'allow'
577
+ }
578
+ if (!config.permission.mcp['pantheon-persistence']) {
579
+ config.permission.mcp['pantheon-persistence'] = 'allow'
580
+ }
581
+ if (!config.permission.mcp['pantheon-vision']) {
582
+ config.permission.mcp['pantheon-vision'] = 'ask'
583
+ }
584
+
585
+ return { healed }
586
+ }
587
+
459
588
  export async function installOpenCode(
460
589
  target,
461
590
  dryRun = false,
@@ -477,12 +606,31 @@ export async function installOpenCode(
477
606
  // --opencode-version=v2. V2 normalizes V1 fields in memory (no rewrite) and
478
607
  // isolates its state DB via OPENCODE_DB (~/.local/share/opencode/opencode-v2.db)
479
608
  // with a distinct service port (49375 vs V1's 49374).
480
- const version = resolveOpenCodeVersion(opts.version ?? 'v1')
609
+ // Default to 'auto' so a flagless run resolves against the actual host. A
610
+ // hardcoded 'v1' here (or in bin/pantheon-init.mjs) is what shipped the bug:
611
+ // the V1 branch writes .ts file paths that a 2.x host drops from the config.
612
+ // `opts` is forwarded as the resolver's options bag so a caller (and CI) can
613
+ // pin env/probe instead of spawning a real host binary.
614
+ const version = resolveOpenCodeVersion(opts.version ?? 'auto', opts)
481
615
  if (version === 'v2') {
482
616
  info(
483
617
  'OpenCode V2 target — shared config; state isolated via OPENCODE_DB ' +
484
618
  '(~/.local/share/opencode/opencode-v2.db), service port 49375',
485
619
  )
620
+ // Say what the V2 contract does NOT give you, at the moment the user picks
621
+ // it. The V2 surface is 3 tools (hashline_edit, pantheon_cost,
622
+ // pantheon_model); V1's is 6 plus the BackgroundJobBoard, the three goal
623
+ // tools and the caller/target delegation matrix. This list was previously
624
+ // reachable only through `getUnsupportedFeatures()`, which no production
625
+ // code called — a user migrating hit the reduction with no warning at all.
626
+ //
627
+ // The markers are read from the same seed `getUnsupportedFeatures()` is
628
+ // built from (src/pantheon/v2-unsupported.mjs), so this notice cannot drift
629
+ // from what the plugin reports. Advisory: a V2 install is a supported
630
+ // configuration, not a failure.
631
+ info(
632
+ `V2 plugin generation — reduced surface. Unsupported features: ${listV2UnsupportedFeatures().join(', ')}`,
633
+ )
486
634
  }
487
635
 
488
636
  // Interactive mode handling
@@ -514,10 +662,24 @@ export async function installOpenCode(
514
662
  }
515
663
  }
516
664
 
517
- const pantheonConfigPath = join(ROOT, 'opencode.json')
665
+ // Product defaults live in code (PRODUCT_CONFIG_DEFAULTS). A repository-root
666
+ // opencode.json is a contributor's PERSONAL dev harness, not product data, so
667
+ // it must never be read just because it happens to exist: doing so silently
668
+ // injects the developer's provider/plugins/compaction into every config an
669
+ // install generates. The merge is therefore opt-in only, via
670
+ // --merge-dev-config <path>. Absent the flag, no root-level config is read.
671
+ const mergeDevConfigOpt = opts.mergeDevConfig ?? null
672
+ if (mergeDevConfigOpt !== null && typeof mergeDevConfigOpt !== 'string') {
673
+ throw new Error('mergeDevConfig requires a file path string')
674
+ }
675
+ const pantheonConfigPath =
676
+ typeof mergeDevConfigOpt === 'string' ? resolve(mergeDevConfigOpt) : null
518
677
  const targetConfigPath = join(target, 'opencode.json')
519
678
  const config = readJsonConfig(targetConfigPath)
520
- const pantheonConfig = readJsonConfig(pantheonConfigPath)
679
+ const pantheonConfig =
680
+ pantheonConfigPath !== null && existsSync(pantheonConfigPath)
681
+ ? readJsonConfig(pantheonConfigPath)
682
+ : null
521
683
 
522
684
  // beta.5 preflight: fail BEFORE any files are written when the runtime
523
685
  // component needs toolchain pieces that are missing. Nothing has been
@@ -710,30 +872,30 @@ export async function installOpenCode(
710
872
 
711
873
  // ── MCP server scripts ──
712
874
  const mcpScripts = [
713
- 'mcp_resources_server.py',
714
- 'code_mode_server.py',
715
- 'memory_mcp_server.py',
875
+ 'mcp_resources.py',
876
+ 'code_mode.py',
877
+ 'memory_mcp.py',
716
878
  'scrub-secrets.py',
717
879
  '_pantheon_paths.py',
718
880
  'mcp_codemap_module.py',
719
- 'mcp_persistence_server.py',
720
- 'pantheon_vision_server.py',
881
+ 'mcp_persistence.py',
882
+ 'pantheon_vision.py',
721
883
  'eval_store.py',
722
884
  ]
723
885
  const srcScriptsDir = join(ROOT, 'scripts')
724
886
  // Canonical MCP server sources live in src/mcp/. Map them explicitly so the
725
887
  // sync always copies from the canonical source, never from the stale root
726
888
  // scripts/ copies (which previously propagated bugs like the missing
727
- // `import uuid` in mcp_persistence_server.py). scrub-secrets.py is not in
889
+ // `import uuid` in mcp_persistence.py). scrub-secrets.py is not in
728
890
  // src/mcp/ and intentionally falls back to join(ROOT, 'scripts').
729
891
  const canonicalMcpScripts = {
730
- 'mcp_resources_server.py': join(ROOT, 'src', 'mcp', 'mcp_resources_server.py'),
731
- 'code_mode_server.py': join(ROOT, 'src', 'mcp', 'code_mode_server.py'),
732
- 'memory_mcp_server.py': join(ROOT, 'src', 'mcp', 'memory_mcp_server.py'),
892
+ 'mcp_resources.py': join(ROOT, 'src', 'mcp', 'mcp_resources.py'),
893
+ 'code_mode.py': join(ROOT, 'src', 'mcp', 'code_mode.py'),
894
+ 'memory_mcp.py': join(ROOT, 'src', 'mcp', 'memory_mcp.py'),
733
895
  '_pantheon_paths.py': join(ROOT, 'src', 'mcp', '_pantheon_paths.py'),
734
896
  'mcp_codemap_module.py': join(ROOT, 'src', 'mcp', 'mcp_codemap_module.py'),
735
- 'mcp_persistence_server.py': join(ROOT, 'src', 'mcp', 'mcp_persistence_server.py'),
736
- 'pantheon_vision_server.py': join(ROOT, 'src', 'mcp', 'pantheon_vision_server.py'),
897
+ 'mcp_persistence.py': join(ROOT, 'src', 'mcp', 'mcp_persistence.py'),
898
+ 'pantheon_vision.py': join(ROOT, 'src', 'mcp', 'pantheon_vision.py'),
737
899
  'eval_store.py': join(ROOT, 'src', 'mcp', 'eval_store.py'),
738
900
  }
739
901
  const dstScriptsDir = join(runtimeTarget, 'scripts')
@@ -939,8 +1101,11 @@ export async function installOpenCode(
939
1101
  // --------------------------------------------------------------------
940
1102
  // B.5 Ensure critical top-level OpenCode config sections
941
1103
  // --------------------------------------------------------------------
942
- if (config.default_agent === undefined && pantheonConfig.default_agent !== undefined) {
943
- config.default_agent = pantheonConfig.default_agent
1104
+ // Seeded from code, not from a packaged template: see
1105
+ // PRODUCT_CONFIG_DEFAULTS. Only fills a gap — a user who already chose a
1106
+ // default agent keeps it.
1107
+ if (config.default_agent === undefined) {
1108
+ config.default_agent = PRODUCT_CONFIG_DEFAULTS.default_agent
944
1109
  }
945
1110
  // Apply only explicit top-level model overrides. Existing values remain
946
1111
  // untouched and an install without either flag leaves both fields absent.
@@ -982,8 +1147,9 @@ export async function installOpenCode(
982
1147
  // --------------------------------------------------------------------
983
1148
  // B.5.1 Hermetic plugin resolution (packaging fix)
984
1149
  // --------------------------------------------------------------------
985
- // The packaged opencode.json may reference plugins via developer-machine
986
- // absolute paths (e.g. <dev>/pantheon/.../src/plugin.ts or
1150
+ // A developer running from a checkout has a repository-root opencode.json
1151
+ // that may reference plugins via developer-machine absolute paths (e.g.
1152
+ // <dev>/pantheon/.../src/plugin.ts or
987
1153
  // <dev>/pantheon/.../src/plugins/pantheon-hooks.ts). Copying those
988
1154
  // verbatim into the user's config would break every global install on any
989
1155
  // other machine. resolveInstalledPlugin() rewrites each entry to the plugin
@@ -1002,7 +1168,7 @@ export async function installOpenCode(
1002
1168
  if (config.plugin === undefined) config.plugin = []
1003
1169
  if (Array.isArray(config.plugin)) {
1004
1170
  config.plugin = removePantheonPluginReferences(config.plugin)
1005
- if (Array.isArray(pantheonConfig.plugin)) {
1171
+ if (Array.isArray(pantheonConfig?.plugin)) {
1006
1172
  for (const plugin of pantheonConfig.plugin) {
1007
1173
  if (!isPantheonPluginReference(plugin) && !hasPluginReference(config.plugin, plugin)) {
1008
1174
  config.plugin.push(deepClone(plugin))
@@ -1032,7 +1198,7 @@ export async function installOpenCode(
1032
1198
  }
1033
1199
  if (!Array.isArray(config.plugins)) config.plugins = []
1034
1200
  config.plugins = deduplicatePluginReferences(removePantheonPluginReferences(config.plugins))
1035
- if (Array.isArray(pantheonConfig.plugins)) {
1201
+ if (Array.isArray(pantheonConfig?.plugins)) {
1036
1202
  for (const plugin of pantheonConfig.plugins) {
1037
1203
  if (!isPantheonPluginReference(plugin) && !hasPluginReference(config.plugins, plugin)) {
1038
1204
  config.plugins.push(deepClone(plugin))
@@ -1058,33 +1224,68 @@ export async function installOpenCode(
1058
1224
  config.plugins.push(resolvedV2)
1059
1225
  }
1060
1226
 
1061
- if (config.provider === undefined && pantheonConfig.provider !== undefined) {
1062
- config.provider = deepClone(pantheonConfig.provider)
1063
- } else if (config.provider !== undefined && pantheonConfig.provider !== undefined) {
1064
- mergeMissing(config.provider, pantheonConfig.provider)
1065
- }
1227
+ if (pantheonConfig !== null) {
1228
+ if (config.provider === undefined && pantheonConfig.provider !== undefined) {
1229
+ config.provider = deepClone(pantheonConfig.provider)
1230
+ } else if (config.provider !== undefined && pantheonConfig.provider !== undefined) {
1231
+ mergeMissing(config.provider, pantheonConfig.provider)
1232
+ }
1066
1233
 
1067
- if (config.compaction === undefined && pantheonConfig.compaction !== undefined) {
1068
- config.compaction = deepClone(pantheonConfig.compaction)
1069
- } else if (config.compaction !== undefined && pantheonConfig.compaction !== undefined) {
1070
- mergeMissing(config.compaction, pantheonConfig.compaction)
1234
+ if (config.compaction === undefined && pantheonConfig.compaction !== undefined) {
1235
+ config.compaction = deepClone(pantheonConfig.compaction)
1236
+ } else if (config.compaction !== undefined && pantheonConfig.compaction !== undefined) {
1237
+ mergeMissing(config.compaction, pantheonConfig.compaction)
1238
+ }
1071
1239
  }
1072
1240
 
1073
1241
  // --------------------------------------------------------------------
1074
1242
  // C. Merge permissions
1075
1243
  // --------------------------------------------------------------------
1076
1244
  if (config.permission === undefined) config.permission = {}
1077
- if (pantheonConfig.permission !== undefined) {
1078
- mergeMissing(config.permission, pantheonConfig.permission)
1079
- }
1080
1245
  if (
1081
1246
  typeof config.permission === 'object' &&
1082
1247
  config.permission !== null &&
1083
1248
  !Array.isArray(config.permission)
1084
1249
  ) {
1085
- if (componentSet.has('skills')) {
1250
+ // Fill-if-absent, never overwrite. Pantheon's skills cannot be invoked
1251
+ // without this grant, so an install that just installed them seeds it —
1252
+ // but a user who deliberately set `permission.skill` (to `deny`, or to a
1253
+ // partial map of their own) keeps their choice. This was an unconditional
1254
+ // assignment nine lines below a comment that said "never overwrite", which
1255
+ // silently flipped an explicit deny to allow.
1256
+ //
1257
+ // Gated on the skills component AND absent-only, which is the whole point:
1258
+ // `skill` is a capability grant for something this install just wrote to
1259
+ // disk. A runtime-only or agents-only install has nothing to grant, so it
1260
+ // must not seed the key. It used to anyway, via the unconditional
1261
+ // PRODUCT_CONFIG_DEFAULTS.permission merge that sat above this block —
1262
+ // which also made this gate dead code.
1263
+ if (componentSet.has('skills') && config.permission.skill === undefined) {
1086
1264
  config.permission.skill = { '*': 'allow' }
1087
1265
  }
1266
+ // These five are the commands Pantheon's OWN agents must be able to run
1267
+ // unattended to do their job: version control, package execution, and the
1268
+ // test/lint pair every implementation agent reaches for. That is the whole
1269
+ // bar for a grant made on every install.
1270
+ //
1271
+ // Anything beyond it is the installing user's judgement about which
1272
+ // commands to trust on their machine, to be made in THEIR config — not ours
1273
+ // to grant silently on their behalf. The personal dev allowlist this grew
1274
+ // out of was the same category of decision, one layer further out of view.
1275
+ //
1276
+ // Specifically NOT granted, and why:
1277
+ // - `curl` — arbitrary network egress; the widest grant on the list and
1278
+ // not required to install or run anything here.
1279
+ // - `docker` — host/daemon access; a container runtime is not an
1280
+ // installation requirement.
1281
+ // - `gh` — third-party account access on someone's behalf; a GitHub CLI
1282
+ // login is never something an installer may assume.
1283
+ // - `pip`, `black`, `make` — ordinary toolchain commands with no Pantheon
1284
+ // agent dependency; users who want them can add them.
1285
+ //
1286
+ // NOTE: this grant is NOT gated on the skills component — unlike
1287
+ // permission.skill above, agents are installed by the agents component and
1288
+ // the test/lint commands are needed whichever components were selected.
1088
1289
  if (config.permission.bash === undefined) {
1089
1290
  config.permission.bash = {
1090
1291
  'git *': 'allow',
@@ -1092,12 +1293,6 @@ export async function installOpenCode(
1092
1293
  'npx *': 'allow',
1093
1294
  'pytest *': 'allow',
1094
1295
  'ruff *': 'allow',
1095
- 'black *': 'allow',
1096
- 'pip *': 'allow',
1097
- 'docker *': 'allow',
1098
- 'curl *': 'allow',
1099
- 'gh *': 'allow',
1100
- 'make *': 'allow',
1101
1296
  }
1102
1297
  }
1103
1298
  }
@@ -1154,82 +1349,18 @@ export async function installOpenCode(
1154
1349
  // at a venv that does not exist would make every MCP fail to launch.
1155
1350
  // --------------------------------------------------------------------
1156
1351
  if (componentSet.has('runtime') && runtimeHealthy) {
1157
- config.mcp = config.mcp || {}
1158
- // MCP stays a flat named-server map for BOTH V1 and V2: OpenCode 1.18.x
1159
- // has no `mcp.servers` wrapper and rejects one with
1160
- // "Missing key mcp.servers.enabled". config-migration.mjs normalizes any
1161
- // legacy wrapper-shaped entries back to the flat map.
1162
- // P1-3: derive the MCP python from the SAME venv layout setupVenv
1163
- // creates (<target>/.venv via venvPythonPath). For project installs the
1164
- // runtime payload lives under <target>/.opencode (runtimeTarget), but the
1165
- // venv is created at <target>/.venv — pointing MCP commands at
1352
+ // P1-3: derive the MCP python from the SAME venv layout setupVenv creates
1353
+ // (<target>/.venv via venvPythonPath). For project installs the runtime
1354
+ // payload lives under <target>/.opencode (runtimeTarget), but the venv is
1355
+ // created at <target>/.venv — pointing MCP commands at
1166
1356
  // runtimeTarget/.venv would reference an executable that never exists.
1167
- const runtimeTarget = isGlobal ? target : join(target, '.opencode')
1168
- const venvPython = venvPythonPath(target)
1169
1357
  // Point the config at the venv even on a first install; setupVenv runs
1170
1358
  // later in this function and creates this path before OpenCode starts.
1171
- const memoryPython = venvPython
1172
-
1173
- // Only add if not already configured by user
1174
- if (!config.mcp['pantheon-resources']) {
1175
- config.mcp['pantheon-resources'] = {
1176
- type: 'local',
1177
- cwd: runtimeTarget,
1178
- command: [memoryPython, 'scripts/mcp_resources_server.py'],
1179
- enabled: true,
1180
- }
1181
- }
1182
- if (!config.mcp['pantheon-code-mode']) {
1183
- config.mcp['pantheon-code-mode'] = {
1184
- type: 'local',
1185
- cwd: runtimeTarget,
1186
- command: [memoryPython, 'scripts/code_mode_server.py'],
1187
- enabled: true,
1188
- }
1189
- }
1190
- if (!config.mcp['pantheon-memory']) {
1191
- config.mcp['pantheon-memory'] = {
1192
- type: 'local',
1193
- cwd: runtimeTarget,
1194
- command: [memoryPython, 'scripts/memory_mcp_server.py'],
1195
- enabled: true,
1196
- }
1197
- }
1198
- if (!config.mcp['pantheon-persistence']) {
1199
- config.mcp['pantheon-persistence'] = {
1200
- type: 'local',
1201
- cwd: runtimeTarget,
1202
- command: [memoryPython, 'scripts/mcp_persistence_server.py'],
1203
- enabled: true,
1204
- }
1205
- }
1206
- if (!config.mcp['pantheon-vision']) {
1207
- config.mcp['pantheon-vision'] = {
1208
- type: 'local',
1209
- cwd: runtimeTarget,
1210
- command: [memoryPython, 'scripts/pantheon_vision_server.py'],
1211
- enabled: true,
1212
- }
1213
- }
1214
-
1215
- // Default MCP permissions
1216
- config.permission = config.permission || {}
1217
- config.permission.mcp = config.permission.mcp || {}
1218
- if (!config.permission.mcp['pantheon-resources']) {
1219
- config.permission.mcp['pantheon-resources'] = 'allow'
1220
- }
1221
- if (!config.permission.mcp['pantheon-code-mode']) {
1222
- config.permission.mcp['pantheon-code-mode'] = 'ask'
1223
- }
1224
- if (!config.permission.mcp['pantheon-memory']) {
1225
- config.permission.mcp['pantheon-memory'] = 'allow'
1226
- }
1227
- if (!config.permission.mcp['pantheon-persistence']) {
1228
- config.permission.mcp['pantheon-persistence'] = 'allow'
1229
- }
1230
- if (!config.permission.mcp['pantheon-vision']) {
1231
- config.permission.mcp['pantheon-vision'] = 'ask'
1232
- }
1359
+ const runtimeTarget = isGlobal ? target : join(target, '.opencode')
1360
+ applyMcpServerEntries(config, {
1361
+ runtimeTarget,
1362
+ venvPython: venvPythonPath(target),
1363
+ })
1233
1364
  }
1234
1365
 
1235
1366
  // ── V2 native migration ──
@@ -24,6 +24,7 @@ import { homedir } from 'node:os'
24
24
  import { basename, dirname, join, resolve } from 'node:path'
25
25
  import { createInterface } from 'node:readline'
26
26
  import { fileURLToPath } from 'node:url'
27
+ import { migrateMcpEntrypointPaths } from './install/config-migration.mjs'
27
28
 
28
29
  const __dirname = dirname(fileURLToPath(import.meta.url))
29
30
  const ROOT = join(__dirname, '..')
@@ -70,11 +71,11 @@ function resolveServerScript(relPath) {
70
71
  }
71
72
 
72
73
  const VENV_PYTHON = resolveVenvPython()
73
- const MCP_RESOURCES_SERVER = resolveServerScript('scripts/mcp_resources_server.py')
74
- const CODE_MODE_SERVER = resolveServerScript('scripts/code_mode_server.py')
75
- const MEMORY_MCP_SERVER = resolveServerScript('scripts/memory_mcp_server.py')
76
- const MCP_PERSISTENCE_SERVER = resolveServerScript('scripts/mcp_persistence_server.py')
77
- const PANTHEON_VISION_SERVER = resolveServerScript('src/mcp/pantheon_vision_server.py')
74
+ const MCP_RESOURCES_SERVER = resolveServerScript('scripts/mcp_resources.py')
75
+ const CODE_MODE_SERVER = resolveServerScript('scripts/code_mode.py')
76
+ const MEMORY_MCP_SERVER = resolveServerScript('scripts/memory_mcp.py')
77
+ const MCP_PERSISTENCE_SERVER = resolveServerScript('scripts/mcp_persistence.py')
78
+ const PANTHEON_VISION_SERVER = resolveServerScript('src/mcp/pantheon_vision.py')
78
79
 
79
80
  // ---------------------------------------------------------------------------
80
81
  // MCP Definitions
@@ -209,7 +210,7 @@ export const MCPS = {
209
210
  if (existsSync(scriptPath)) {
210
211
  return { ok: true, message: 'server script present' }
211
212
  }
212
- return { ok: false, message: 'mcp_resources_server.py not found at resolved path' }
213
+ return { ok: false, message: 'mcp_resources.py not found at resolved path' }
213
214
  },
214
215
  },
215
216
  'pantheon-code-mode': {
@@ -230,7 +231,7 @@ export const MCPS = {
230
231
  if (existsSync(scriptPath)) {
231
232
  return { ok: true, message: 'server script present' }
232
233
  }
233
- return { ok: false, message: 'code_mode_server.py not found at resolved path' }
234
+ return { ok: false, message: 'code_mode.py not found at resolved path' }
234
235
  },
235
236
  },
236
237
  'pantheon-memory': {
@@ -251,7 +252,7 @@ export const MCPS = {
251
252
  if (existsSync(scriptPath)) {
252
253
  return { ok: true, message: 'server script present' }
253
254
  }
254
- return { ok: false, message: 'memory_mcp_server.py not found at resolved path' }
255
+ return { ok: false, message: 'memory_mcp.py not found at resolved path' }
255
256
  },
256
257
  },
257
258
  'pantheon-persistence': {
@@ -272,7 +273,7 @@ export const MCPS = {
272
273
  if (existsSync(scriptPath)) {
273
274
  return { ok: true, message: 'server script present' }
274
275
  }
275
- return { ok: false, message: 'mcp_persistence_server.py not found at resolved path' }
276
+ return { ok: false, message: 'mcp_persistence.py not found at resolved path' }
276
277
  },
277
278
  },
278
279
  'pantheon-vision': {
@@ -471,8 +472,22 @@ function writeMcpConfig(platformName, mcpKey, dryRun, force) {
471
472
  config[key] = {}
472
473
  }
473
474
 
475
+ // Heal pre-rename MCP entrypoint paths before the skip check, so re-running
476
+ // the installer repairs an existing config whose `*_server.py` paths were
477
+ // orphaned by the entrypoint rename. Without this the rename is a silent
478
+ // break for every already-installed config (see config-migration.mjs).
479
+ const healed = migrateMcpEntrypointPaths(config[key])
480
+
474
481
  // Check if the MCP already has an entry
475
482
  if (config[key][mcpKey] && !force) {
483
+ if (healed > 0 && !dryRun) {
484
+ writeFileSync(configPath, `${JSON.stringify(config, null, 2)}\n`, 'utf8')
485
+ return {
486
+ status: 'healed',
487
+ path: configPath,
488
+ reason: `Updated ${healed} stale MCP entrypoint path(s) in ${platform.configFile}`,
489
+ }
490
+ }
476
491
  return {
477
492
  status: 'skipped',
478
493
  reason: `Already exists in ${platform.configFile} (use --force to overwrite)`,
@@ -705,6 +720,10 @@ function logResult(platformLabel, mcpKey, result) {
705
720
  console.log(` ⏭️ ${label} — ${result.reason}`)
706
721
  progress.skipped.push({ platform: platformLabel, mcp: mcpKey, reason: result.reason })
707
722
  break
723
+ case 'healed':
724
+ console.log(` 🩹 ${label} — ${result.reason}`)
725
+ progress.installed.push({ platform: platformLabel, mcp: mcpKey })
726
+ break
708
727
  case 'dry-run': {
709
728
  const desc =
710
729
  result.entry?.type === 'remote' || result.entry?.type === 'http'
@@ -5,7 +5,7 @@ Key-Value store with FTS5 full-text search, TTL-based expiration,
5
5
  and namespace isolation. Uses SQLite with zero external dependencies.
6
6
 
7
7
  Usage:
8
- python scripts/mcp_persistence_server.py
8
+ python scripts/mcp_persistence.py
9
9
  """
10
10
 
11
11
  from __future__ import annotations
@@ -7,7 +7,7 @@ Provides MCP resources for the Pantheon agent framework:
7
7
  plans/status, memory-bank files
8
8
 
9
9
  Usage:
10
- python scripts/mcp_resources_server.py
10
+ python scripts/mcp_resources.py
11
11
  """
12
12
 
13
13
  from __future__ import annotations
@@ -19,7 +19,7 @@ Tools:
19
19
  memory_stats — DB statistics
20
20
 
21
21
  Usage:
22
- python scripts/memory_mcp_server.py
22
+ python scripts/memory_mcp.py
23
23
  """
24
24
 
25
25
  from __future__ import annotations
@@ -15,7 +15,7 @@ import { fileURLToPath } from 'node:url'
15
15
 
16
16
  const requestedVersion = valueFor('--version')
17
17
  const jsonOutput = process.argv.includes('--json')
18
- const runtime = fileURLToPath(new URL('./mcp_persistence_server.py', import.meta.url))
18
+ const runtime = fileURLToPath(new URL('./mcp_persistence.py', import.meta.url))
19
19
 
20
20
  function valueFor(name) {
21
21
  const index = process.argv.indexOf(name)
@@ -192,7 +192,7 @@ async function main() {
192
192
  return
193
193
  }
194
194
  if (!existsSync(runtime)) {
195
- unavailable('installed package does not include mcp_persistence_server.py')
195
+ unavailable('installed package does not include mcp_persistence.py')
196
196
  return
197
197
  }
198
198