pantheon-opencode 1.6.0-beta.1 → 1.6.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 (120) hide show
  1. package/AGENTS.global.md +182 -0
  2. package/AGENTS.md +10 -891
  3. package/CHANGELOG.md +326 -0
  4. package/README.md +496 -32
  5. package/README.pt-BR.md +285 -20
  6. package/bin/pantheon-init.mjs +26 -3
  7. package/commands/pantheon-audit.md +7 -1
  8. package/commands/pantheon-consolidate.md +1 -1
  9. package/commands/pantheon-deepwork.md +14 -3
  10. package/commands/pantheon-focus.md +17 -0
  11. package/commands/pantheon-vision.md +61 -12
  12. package/package.json +21 -14
  13. package/plugin.json +2 -2
  14. package/pyproject.toml +8 -6
  15. package/scripts/build-agents-md.mjs +374 -56
  16. package/scripts/changelog-merge.mjs +161 -0
  17. package/scripts/{code_mode_server.py → code_mode.py} +3 -3
  18. package/scripts/cost.mjs +244 -41
  19. package/scripts/doctor.mjs +151 -1
  20. package/scripts/install/agents-md.mjs +4 -4
  21. package/scripts/install/config-migration.mjs +317 -4
  22. package/scripts/install/health-check.mjs +21 -19
  23. package/scripts/install/interactive.mjs +1 -1
  24. package/scripts/install/opencode-version.mjs +246 -4
  25. package/scripts/install/opencode.mjs +627 -179
  26. package/scripts/install/plugin.mjs +43 -9
  27. package/scripts/install/shared.mjs +120 -12
  28. package/scripts/install/vision-prompt.mjs +161 -0
  29. package/scripts/install-mcp.mjs +28 -9
  30. package/scripts/{mcp_persistence_server.py → mcp_persistence.py} +2 -2
  31. package/{src/mcp/mcp_resources_server.py → scripts/mcp_resources.py} +2 -2
  32. package/scripts/{memory_mcp_server.py → memory_mcp.py} +2 -2
  33. package/scripts/probe-context-rehydrate.mjs +8 -2
  34. package/scripts/probe-pantheon-cost.mjs +71 -15
  35. package/scripts/run-node-tests.mjs +189 -0
  36. package/scripts/sync-artifacts.mjs +26 -13
  37. package/scripts/sync-tui.mjs +24 -3
  38. package/scripts/test-opencode-v2-sandbox.sh +180 -26
  39. package/scripts/validate-routing.mjs +3 -3
  40. package/scripts/validate-sandbox-install.mjs +138 -0
  41. package/scripts/versioning.mjs +13 -1
  42. package/scripts/vision-status.mjs +682 -0
  43. package/skills-lock.json +8 -8
  44. package/src/agents/aphrodite.md +20 -37
  45. package/src/agents/apollo.md +7 -3
  46. package/src/agents/athena.md +63 -35
  47. package/src/agents/demeter.md +8 -28
  48. package/src/agents/gaia.md +1 -1
  49. package/src/agents/hephaestus.md +10 -21
  50. package/src/agents/hermes.md +15 -38
  51. package/src/agents/iris.md +1 -1
  52. package/src/agents/mnemosyne.md +18 -54
  53. package/src/agents/nyx.md +1 -1
  54. package/src/agents/prometheus.md +16 -20
  55. package/src/agents/talos.md +10 -2
  56. package/src/agents/themis.md +5 -5
  57. package/src/agents/zeus.md +99 -129
  58. package/src/instructions/agent-return-format.instructions.md +18 -3
  59. package/src/instructions/backend-standards.instructions.md +2 -0
  60. package/src/instructions/frontend-standards.instructions.md +3 -1
  61. package/src/instructions/memory-protocol.instructions.md +11 -110
  62. package/src/instructions/zeus-anti-stall.instructions.md +14 -61
  63. package/src/instructions/zeus-communication-rules.instructions.md +0 -1
  64. package/src/instructions/zeus-council-synthesis.instructions.md +32 -260
  65. package/src/instructions/zeus-memory-operations.instructions.md +26 -0
  66. package/src/instructions/zeus-timeout-retry.instructions.md +40 -87
  67. package/src/mcp/{code_mode_server.py → code_mode.py} +3 -3
  68. package/src/mcp/eval_store.py +2 -2
  69. package/src/mcp/{mcp_persistence_server.py → mcp_persistence.py} +2 -2
  70. package/{scripts/mcp_resources_server.py → src/mcp/mcp_resources.py} +2 -2
  71. package/src/mcp/{memory_mcp_server.py → memory_mcp.py} +2 -2
  72. package/src/mcp/{pantheon_vision_server.py → pantheon_vision.py} +286 -42
  73. package/src/mcp/requirements-mcp.txt +6 -4
  74. package/src/mcp/requirements-vision.txt +2 -2
  75. package/src/pantheon/cost-command.ts +399 -101
  76. package/src/pantheon/delegation-enforce.ts +10 -1
  77. package/src/pantheon/presets.d.mts +0 -8
  78. package/src/pantheon/presets.mjs +5 -32
  79. package/src/pantheon/v2-bounded-read.ts +640 -0
  80. package/src/pantheon/v2-delegation-enforce.ts +214 -0
  81. package/src/pantheon/v2-execute-after.ts +186 -0
  82. package/src/pantheon/v2-tools.ts +429 -0
  83. package/src/pantheon/v2-unsupported.d.mts +11 -0
  84. package/src/pantheon/v2-unsupported.mjs +90 -0
  85. package/src/pantheon/v2-vision.ts +622 -0
  86. package/src/pantheon/vision-local.ts +531 -0
  87. package/src/pantheon/vision.ts +1160 -115
  88. package/src/plugin-v2.ts +1145 -219
  89. package/src/plugin.ts +12 -9
  90. package/src/plugins/tui/dist/server.d.ts +1 -2
  91. package/src/plugins/tui/dist/server.d.ts.map +1 -1
  92. package/src/plugins/tui/dist/tui-v2.d.ts +154 -0
  93. package/src/plugins/tui/dist/tui-v2.d.ts.map +1 -0
  94. package/src/plugins/tui/dist/tui-v2.js +659 -0
  95. package/src/plugins/tui/dist/tui-v2.js.map +1 -0
  96. package/src/plugins/tui/dist/tui.d.ts +85 -82
  97. package/src/plugins/tui/dist/tui.d.ts.map +1 -1
  98. package/src/plugins/tui/dist/tui.js +72 -59
  99. package/src/plugins/tui/index-v2.ts +6 -0
  100. package/src/plugins/tui/package-lock.json +5516 -1353
  101. package/src/plugins/tui/package.json +22 -12
  102. package/src/plugins/tui/src/index-v2.tsx +734 -0
  103. package/src/plugins/tui/src/index.tsx +106 -85
  104. package/src/plugins/tui/src/opencode-v2.d.ts +13 -0
  105. package/src/plugins/tui/tsconfig.build.json +5 -0
  106. package/src/plugins/tui/tsconfig.typecheck.json +5 -0
  107. package/src/plugins/tui/tsdown.config.ts +22 -2
  108. package/src/plugins/tui/tui-v2.ts +2 -0
  109. package/src/plugins/tui/tui.ts +3 -0
  110. package/src/routing.yml +11 -21
  111. package/src/skills/README.md +3 -3
  112. package/src/skills/agent-coordination/SKILL.md +7 -2
  113. package/src/skills/artifact-management/SKILL.md +12 -5
  114. package/src/skills/auto-continue/SKILL.md +39 -30
  115. package/src/skills/code-review-checklist/SKILL.md +5 -5
  116. package/src/skills/context-compression/SKILL.md +26 -879
  117. package/src/skills/session-goal/SKILL.md +14 -5
  118. package/src/skills/spec-driven-development/SKILL.md +3 -1
  119. package/src/skills/tdd-with-agents/SKILL.md +18 -10
  120. package/opencode.json +0 -58
@@ -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,8 @@ 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'
24
+ import { copyAgentPrompts, writeAgentsMdManaged } from '../build-agents-md.mjs'
19
25
  import {
20
26
  bullet,
21
27
  colors,
@@ -29,7 +35,11 @@ import {
29
35
  success,
30
36
  warning,
31
37
  } from './cli-ui.mjs'
32
- import { migrateV1toV2 } from './config-migration.mjs'
38
+ import {
39
+ mergeContextSandboxBlocks,
40
+ migrateMcpEntrypointPaths,
41
+ migrateV1toV2,
42
+ } from './config-migration.mjs'
33
43
  import { healthCheck } from './health-check.mjs'
34
44
  import { detectVersion, runMigrations } from './migrate.mjs'
35
45
  import { resolveOpenCodeVersion } from './opencode-version.mjs'
@@ -37,7 +47,6 @@ import { copyPluginFiles, installPlugin, registerPlugin, unregisterPlugin } from
37
47
  import {
38
48
  checkRuntimePrerequisites,
39
49
  collectSkillNames,
40
- copyFiles,
41
50
  installSkills,
42
51
  parseFrontmatter,
43
52
  ROOT,
@@ -47,6 +56,7 @@ import {
47
56
  writeConfigWithBackup,
48
57
  writeIfChanged,
49
58
  } from './shared.mjs'
59
+ import { createInitialState, hasState, readState, setComponentState, writeState } from './state.mjs'
50
60
  import { strings } from './strings.mjs'
51
61
 
52
62
  import { setupVenv, venvPythonPath } from './venv.mjs'
@@ -61,6 +71,139 @@ const COMPONENT_NAMES = [
61
71
  'runtime',
62
72
  ]
63
73
 
74
+ /**
75
+ * The product defaults an install seeds into the user's `opencode.json`.
76
+ *
77
+ * They live HERE, in code, rather than in a template shipped in the npm
78
+ * tarball: that template also carried a maintainer's private MCP endpoint,
79
+ * provider models and per-developer permission overrides, and shipping it to
80
+ * every installer was the bug (#199). Reading defaults off it meant deleting it
81
+ * silently stopped the seeding, so the defaults are now stated next to the code
82
+ * that consumes them.
83
+ *
84
+ * PERSONAL CONFIGURATION MUST NEVER BE SEEDED HERE: this is the installer's
85
+ * contract with every user, and a value that is only right on one machine
86
+ * belongs in that machine's own `opencode.json`. Deliberately NOT carried over
87
+ * from the template, each because it was personal rather than a product
88
+ * default:
89
+ * - `provider` — pinned the maintainer's provider, model and modalities.
90
+ * - `mcp` — a private remote MCP endpoint.
91
+ * - `agent` — the maintainer's per-agent permission overrides.
92
+ * - `permission.websearch: "deny"` — a personal restriction; a user may rely
93
+ * on web search, so denying it is not ours to decide.
94
+ * - `permission.bash` — the template's dev allowlist (git, npm, ruff, …), a
95
+ * personal judgement about which commands to trust.
96
+ * Anything added here must be justifiable as "correct for a fresh install of
97
+ * the product, for anyone".
98
+ *
99
+ * Other seeding paths are untouched by this constant and still run: the config
100
+ * merge derives plugin refs, provider and compaction from a repository-root
101
+ * opencode.json when a contributor has one, and separately gates its own
102
+ * permission grants — `skill` on the skills component, `bash` unconditionally —
103
+ * on whether the user's own config already set them.
104
+ *
105
+ * Frozen, and copied only into a confirmed gap (never into a key the user
106
+ * already set), so nothing here can be mutated through a user's config.
107
+ */
108
+ const PRODUCT_CONFIG_DEFAULTS = Object.freeze({
109
+ default_agent: 'zeus',
110
+ })
111
+
112
+ /**
113
+ * Per-agent fields owned by the framework: for an agent that already exists in
114
+ * the target config these are overwritten from the canonical source, and for a
115
+ * new agent they are the seed. Everything else on an existing entry is the
116
+ * user's and is left alone.
117
+ *
118
+ * `source` is written right next to these (for both new and existing agents) but
119
+ * is kept out of the loop so it can also be set independently — the migration in
120
+ * config-migration.mjs mirrors this list plus `source`, and tests assert the two
121
+ * stay equal as sets.
122
+ *
123
+ * KNOWN DEBT: `permission` is the V1 spelling. V2 reads `permissions`, so an
124
+ * agent can carry both keys after an install and the V1 one becomes dead weight.
125
+ * Tracked for the next slice; changing it here would change what an install
126
+ * writes.
127
+ *
128
+ * @type {readonly string[]}
129
+ */
130
+ export const MANAGED_FIELDS = Object.freeze([
131
+ 'temperature',
132
+ 'color',
133
+ 'permission',
134
+ 'mode',
135
+ 'hidden',
136
+ 'disable_model_invocation',
137
+ ])
138
+
139
+ const PANTHEON_MCP_SERVERS = Object.freeze([
140
+ 'pantheon-resources',
141
+ 'pantheon-memory',
142
+ 'pantheon-code-mode',
143
+ 'pantheon-persistence',
144
+ 'pantheon-vision',
145
+ ])
146
+
147
+ function normalizeMcpPermissionSegment(value) {
148
+ return value.replace(/[^a-zA-Z0-9_]/g, '_')
149
+ }
150
+
151
+ /**
152
+ * Translate an agent's `mcp_tools` frontmatter into per-tool native
153
+ * permissions. V1 matches the original server-tool names; V2 normalizes
154
+ * punctuation to underscores. Emitting both spellings keeps the same source
155
+ * metadata valid for either host. Unlisted bundled Pantheon servers default to
156
+ * deny; unrelated user MCP servers are not touched.
157
+ *
158
+ * @param {Record<string, 'all' | string[]>} mcpTools
159
+ * @returns {Record<string, 'allow' | 'deny'>}
160
+ */
161
+ export function deriveNativeMcpPermissions(mcpTools) {
162
+ if (mcpTools === null || typeof mcpTools !== 'object' || Array.isArray(mcpTools)) {
163
+ throw new TypeError('mcp_tools must be an object keyed by MCP server name')
164
+ }
165
+
166
+ const servers = new Set([...PANTHEON_MCP_SERVERS, ...Object.keys(mcpTools)])
167
+ const permissions = {}
168
+
169
+ for (const server of servers) {
170
+ if (!/^[a-zA-Z0-9._-]+$/.test(server)) {
171
+ throw new TypeError(`mcp_tools server name is invalid: ${server}`)
172
+ }
173
+ const tools = Object.hasOwn(mcpTools, server) ? mcpTools[server] : []
174
+ if (
175
+ tools !== 'all' &&
176
+ (!Array.isArray(tools) ||
177
+ tools.some((tool) => typeof tool !== 'string' || !/^[a-zA-Z0-9._-]+$/.test(tool)))
178
+ ) {
179
+ throw new TypeError(`mcp_tools.${server} must be "all" or an array of tool names`)
180
+ }
181
+
182
+ const serverAliases = new Set([server, normalizeMcpPermissionSegment(server)])
183
+ for (const alias of serverAliases) permissions[`${alias}_*`] = 'deny'
184
+ }
185
+
186
+ for (const server of servers) {
187
+ const tools = Object.hasOwn(mcpTools, server) ? mcpTools[server] : []
188
+ const serverAliases = new Set([server, normalizeMcpPermissionSegment(server)])
189
+ if (tools === 'all') {
190
+ for (const alias of serverAliases) permissions[`${alias}_*`] = 'allow'
191
+ continue
192
+ }
193
+
194
+ for (const tool of tools) {
195
+ const toolAliases = new Set([tool, normalizeMcpPermissionSegment(tool)])
196
+ for (const serverAlias of serverAliases) {
197
+ for (const toolAlias of toolAliases) {
198
+ permissions[`${serverAlias}_${toolAlias}`] = 'allow'
199
+ }
200
+ }
201
+ }
202
+ }
203
+
204
+ return permissions
205
+ }
206
+
64
207
  function deepClone(value) {
65
208
  return JSON.parse(JSON.stringify(value))
66
209
  }
@@ -103,6 +246,27 @@ function readJsonConfig(filePath) {
103
246
  }
104
247
  }
105
248
 
249
+ /**
250
+ * Read a JSON config AND the exact raw text it was parsed from.
251
+ *
252
+ * The raw text is threaded to {@link writeConfigWithBackup} as the
253
+ * expected-content guard, so the compare-before-rename uses the SAME bytes the
254
+ * caller parsed — no second read that could observe a different file.
255
+ *
256
+ * @param {string} filePath
257
+ * @returns {{ config: object, raw: string|null }}
258
+ * @throws {Error} on invalid JSON (before any write happens)
259
+ */
260
+ function readJsonConfigWithRaw(filePath) {
261
+ if (!existsSync(filePath)) return { config: {}, raw: null }
262
+ const raw = readFileSync(filePath, 'utf8')
263
+ try {
264
+ return { config: JSON.parse(raw), raw }
265
+ } catch (cause) {
266
+ throw new Error(`Invalid JSON in ${filePath}`, { cause })
267
+ }
268
+ }
269
+
106
270
  function mergeMissing(target, source) {
107
271
  if (!source || typeof source !== 'object' || Array.isArray(source)) return target
108
272
  if (!target || typeof target !== 'object' || Array.isArray(target)) return deepClone(source)
@@ -131,8 +295,8 @@ function mergeMissing(target, source) {
131
295
  }
132
296
 
133
297
  /**
134
- * Resolve a plugin ref from the packaged opencode.json to a path INSIDE the
135
- * installed package.
298
+ * Resolve a plugin ref from the repository-root opencode.json to a path INSIDE
299
+ * the installed package.
136
300
  *
137
301
  * Only exact Pantheon plugin identities are resolved into the installed
138
302
  * package. In particular, a third-party absolute path ending in
@@ -169,6 +333,7 @@ const PANTHEON_V2_PLUGIN = 'src/plugin-v2'
169
333
  const PANTHEON_V2_LEGACY_FILE = 'src/plugin-v2.ts'
170
334
  const PANTHEON_V1_EXPORT = 'pantheon-opencode/plugin'
171
335
  const PANTHEON_V2_EXPORT = 'pantheon-opencode/plugin-v2'
336
+ const PANTHEON_PACKAGE_REF = /^pantheon-opencode(?:@[^/]+)?(?:\/(plugin|plugin-v2))?$/
172
337
  const PANTHEON_PLUGIN_IDENTITIES = new Set([
173
338
  PANTHEON_V1_PLUGIN,
174
339
  PANTHEON_V1_HOOKS,
@@ -197,6 +362,17 @@ function managedPluginIdentity(ref) {
197
362
  const normalized = normalizePluginRef(ref)
198
363
  if (typeof normalized !== 'string') return null
199
364
 
365
+ // Older installs registered the package root itself (often pinned to a
366
+ // beta version) in OpenCode's plural `plugins` list. Treat all exact npm
367
+ // specs for this package as managed so an update removes the old cached
368
+ // copy before registering the current package path. Do not match similarly
369
+ // named third-party packages such as `pantheon-opencode-fork`.
370
+ const packageRef = normalized.match(PANTHEON_PACKAGE_REF)
371
+ if (packageRef !== null) {
372
+ if (packageRef[1] === 'plugin') return PANTHEON_V1_PLUGIN
373
+ return PANTHEON_V2_PLUGIN
374
+ }
375
+
200
376
  if (PANTHEON_PLUGIN_IDENTITIES.has(normalized)) return normalized
201
377
  if (normalized === PANTHEON_V2_LEGACY_FILE) return PANTHEON_V2_PLUGIN
202
378
  if (normalized === PANTHEON_V1_EXPORT || normalized.startsWith(`${PANTHEON_V1_EXPORT}@`)) {
@@ -275,6 +451,71 @@ export function deduplicatePluginReferences(refs) {
275
451
  })
276
452
  }
277
453
 
454
+ /**
455
+ * Duplicate the V1 top-level `context_sandbox` block into the exact Pantheon V2
456
+ * plugin entry's `options`, converting a plain-string entry into the
457
+ * `{ package, options }` object form the host accepts.
458
+ *
459
+ * Why the copy is required: OpenCode V2.0.25 silently drops a top-level
460
+ * `context_sandbox` key (probe evidence in the module header of
461
+ * config-migration.mjs), so the V1 setting would be lost on a V1→V2 install.
462
+ * The V1 top-level block is LEFT IN PLACE so a downgrade keeps working.
463
+ *
464
+ * Precedence is delegated to {@link mergeContextSandboxBlocks}: an explicit V2
465
+ * option leaf wins, the legacy block fills only unset leaves, and the runtime
466
+ * resolver supplies defaults. Only the Pantheon V2 entry is touched — every
467
+ * other plugin entry, and every other option on this entry, is preserved.
468
+ *
469
+ * The managed V2 option is ALWAYS validated — even when no legacy V1 block is
470
+ * present — because the migration is the only place that validates it. An
471
+ * earlier version returned early on `legacySandbox === undefined`, so an
472
+ * explicit `plugins[].options.context_sandbox` of `null` (or any type-invalid
473
+ * known value) was never checked and persisted straight to disk.
474
+ *
475
+ * @param {unknown[]} plugins - V2 `plugins` array (mutated in place)
476
+ * @param {unknown} legacySandbox - V1 top-level `context_sandbox` (may be undefined)
477
+ * @returns {unknown[]} the same array
478
+ * @throws {Error} when the legacy block or the existing V2 option is null or
479
+ * type-invalid (checked before any config write)
480
+ */
481
+ export function applyContextSandboxToV2Plugins(plugins, legacySandbox) {
482
+ if (!Array.isArray(plugins)) return plugins
483
+
484
+ const index = plugins.findIndex((ref) => pluginReferenceIdentity(ref) === PANTHEON_V2_PLUGIN)
485
+ if (index === -1) return plugins
486
+
487
+ const entry = plugins[index]
488
+ const isObjectEntry = typeof entry === 'object' && entry !== null && !Array.isArray(entry)
489
+ const hasOptions =
490
+ isObjectEntry &&
491
+ typeof entry.options === 'object' &&
492
+ entry.options !== null &&
493
+ !Array.isArray(entry.options)
494
+ const existingOptions = hasOptions ? entry.options : {}
495
+ const hasExistingSandbox = Object.hasOwn(existingOptions, 'context_sandbox')
496
+
497
+ // Nothing managed to migrate OR validate: leave the entry untouched so a
498
+ // re-install stays a true no-op. `legacySandbox === undefined` covers an
499
+ // absent V1 key; `!hasExistingSandbox` covers a V2 entry with no managed
500
+ // option yet. An explicit `null` is a PRESENT key (`hasExistingSandbox`), so
501
+ // it is deliberately NOT skipped and must be validated below.
502
+ if (legacySandbox === undefined && !hasExistingSandbox) return plugins
503
+
504
+ // Run the managed option through the validator unconditionally, merging the
505
+ // legacy block in only when it exists. A null/type-invalid existing option
506
+ // throws here, BEFORE the config is written.
507
+ const mergedSandbox = mergeContextSandboxBlocks(existingOptions.context_sandbox, legacySandbox)
508
+ const packageId = isObjectEntry ? entry.package : entry
509
+
510
+ // Rebuild the entry as an object so the options survive a V2 host (which
511
+ // preserves plugin option objects verbatim).
512
+ plugins[index] = {
513
+ package: packageId,
514
+ options: { ...existingOptions, context_sandbox: mergedSandbox },
515
+ }
516
+ return plugins
517
+ }
518
+
278
519
  export { parseOpenCodeVersion, resolveOpenCodeVersion } from './opencode-version.mjs'
279
520
 
280
521
  /**
@@ -301,15 +542,14 @@ export function isGlobalConfigDir(target) {
301
542
  * package; the loader must NEVER be pointed at that in-package dir (a package
302
543
  * reinstall/upgrade replaces the dir under a live registration and leaves
303
544
  * diverging copies loading). Instead the installer always copies it into the
304
- * config dir's plugins/ dir and registers THAT copy:
545
+ * config dir's plugins/ dir and uses THAT copy:
305
546
  *
306
- * <configDir>/plugins/pantheon-tui (dist/ + package.json + index.tsx)
547
+ * <configDir>/plugins/pantheon-tui (tui.ts + dist/ + package manifests)
307
548
  *
308
- * The loader reads the copied package.json `exports` map:
309
- * exports["./tui"] → dist/tui.js (COMPILED Solid output)
310
- * exports["./server"] → dist/server.js (no-op server() stub)
549
+ * V1 registers that copy through tui.json. V2 discovers the local plugin's
550
+ * index.ts + tui.ts pair; the V2 shim re-exports dist/tui-v2.js.
311
551
  *
312
- * @param {string} configDir - directory holding tui.json (target for global
552
+ * @param {string} configDir - OpenCode config directory (target for global
313
553
  * installs, <target>/.opencode for project installs)
314
554
  * @returns {string} absolute path of the copied plugin directory
315
555
  */
@@ -335,23 +575,20 @@ export function tuiConfigLocations(target) {
335
575
  }
336
576
 
337
577
  /**
338
- * Sync the Pantheon TUI plugin registration across EVERY tui.json location
339
- * OpenCode reads.
578
+ * Sync the Pantheon TUI plugin payload and V1 registration. V2 discovers the
579
+ * copied local-plugin entrypoints and does not use a project tui.json.
340
580
  *
341
581
  * Contract (single source of truth):
342
- * 1. ALWAYS copy src/plugins/tui → <configDir>/plugins/pantheon-tui
343
- * (dist/* + package.json + index.tsx) so the registered reference is a
344
- * directory that exists and carries the loader contract (exports →
345
- * dist/tui.js, dist/server.js). The copy is idempotent: a second run is
346
- * byte-identical.
582
+ * 1. ALWAYS copy src/plugins/tui → <configDir>/plugins/pantheon-tui with the
583
+ * selected host entrypoints and compiled bundle. The copy is idempotent.
347
584
  * 2. Clean stale Pantheon TUI refs from ALL tui.json locations OpenCode
348
585
  * loads — ~/.opencode, $XDG_CONFIG_HOME/opencode and
349
586
  * <target>/.opencode — covering every old reference shape: dist file
350
587
  * paths, in-package src/plugins/tui dirs, bare plugins/pantheon-tui
351
588
  * copies, and npx paths. A stale ref left in the non-target location
352
589
  * would still be loaded and break the TUI.
353
- * 3. Register EXACTLY the copied directory in the selected target only —
354
- * exactly ONE Pantheon TUI reference exists system-wide after install.
590
+ * 3. V1 registers the copied directory in the selected target. V2 relies on
591
+ * local-plugin discovery and does not write a project tui.json entry.
355
592
  *
356
593
  * @param {string} target - install target directory
357
594
  * @param {object} [opts]
@@ -359,18 +596,19 @@ export function tuiConfigLocations(target) {
359
596
  * @param {boolean} [opts.dryRun=false]
360
597
  * @returns {'created'|'skipped'} status for the target registration
361
598
  */
362
- export function syncTuiRegistration(target, { isGlobal = false, dryRun = false } = {}) {
599
+ export function syncTuiRegistration(
600
+ target,
601
+ { isGlobal = false, dryRun = false, version = 'v1' } = {},
602
+ ) {
363
603
  const configDir = isGlobal ? target : join(target, '.opencode')
364
604
  const targetTuiConfigPath = join(configDir, 'tui.json')
365
605
 
366
- // 1. Always copy the single source of truth into the target config's
367
- // plugins/pantheon-tui. Registering this copied directory (never the
368
- // in-package src/plugins/tui) keeps installs hermetic and idempotent:
369
- // the ref points at a dir that exists and has dist/tui.js.
606
+ // 1. Copy the host-specific plugin into the target config's
607
+ // plugins/pantheon-tui. This keeps the installed entry hermetic.
370
608
  const tuiSrcDir = join(ROOT, 'src', 'plugins', 'tui')
371
609
  const tuiCopyDir = resolveTuiCopyTarget(configDir)
372
610
  if (existsSync(tuiSrcDir)) {
373
- copyPluginFiles(tuiSrcDir, tuiCopyDir, { dryRun })
611
+ copyPluginFiles(tuiSrcDir, tuiCopyDir, { dryRun, version })
374
612
  }
375
613
 
376
614
  // 2. Clean stale Pantheon TUI refs from every location OpenCode reads.
@@ -386,6 +624,11 @@ export function syncTuiRegistration(target, { isGlobal = false, dryRun = false }
386
624
  })
387
625
  }
388
626
 
627
+ // OpenCode V2 discovers local plugins from the config plugins directories;
628
+ // its TUI plugin API is exposed by the sibling tui.ts entrypoint. V1 still
629
+ // requires the explicit tui.json registration.
630
+ if (version === 'v2') return 'skipped'
631
+
389
632
  // 3. Register the copied directory in the selected target only.
390
633
  return registerPlugin(targetTuiConfigPath, tuiCopyDir, { dryRun })
391
634
  }
@@ -429,6 +672,92 @@ export function setupRuntimePhase(target, { dryRun, clean, isGlobal }, stats, de
429
672
  }
430
673
  }
431
674
 
675
+ /**
676
+ * Write the five managed MCP server entries into `config`, healing any stale
677
+ * entrypoint paths first.
678
+ *
679
+ * The heal runs on EVERY install, not only when an entry is missing: the
680
+ * per-server guards below are add-only, so an existing install whose
681
+ * `opencode.json` still carries a pre-rename `*_server.py` path would never be
682
+ * repaired. Healing first means a re-run of the installer fixes an orphaned
683
+ * config in place — the migration that makes the rename non-breaking.
684
+ *
685
+ * @param {object} config - full config object (mutated in place)
686
+ * @param {{ runtimeTarget: string, venvPython: string }} ctx
687
+ * @returns {{ healed: number }} number of stale path strings rewritten
688
+ */
689
+ export function applyMcpServerEntries(config, { runtimeTarget, venvPython }) {
690
+ config.mcp = config.mcp || {}
691
+ // MCP stays a flat named-server map for BOTH V1 and V2: OpenCode 1.18.x has
692
+ // no `mcp.servers` wrapper and rejects one with "Missing key
693
+ // mcp.servers.enabled". config-migration.mjs normalizes any legacy
694
+ // wrapper-shaped entries back to the flat map.
695
+ const healed = migrateMcpEntrypointPaths(config.mcp)
696
+
697
+ // Only add if not already configured by user
698
+ if (!config.mcp['pantheon-resources']) {
699
+ config.mcp['pantheon-resources'] = {
700
+ type: 'local',
701
+ cwd: runtimeTarget,
702
+ command: [venvPython, 'scripts/mcp_resources.py'],
703
+ enabled: true,
704
+ }
705
+ }
706
+ if (!config.mcp['pantheon-code-mode']) {
707
+ config.mcp['pantheon-code-mode'] = {
708
+ type: 'local',
709
+ cwd: runtimeTarget,
710
+ command: [venvPython, 'scripts/code_mode.py'],
711
+ enabled: true,
712
+ }
713
+ }
714
+ if (!config.mcp['pantheon-memory']) {
715
+ config.mcp['pantheon-memory'] = {
716
+ type: 'local',
717
+ cwd: runtimeTarget,
718
+ command: [venvPython, 'scripts/memory_mcp.py'],
719
+ enabled: true,
720
+ }
721
+ }
722
+ if (!config.mcp['pantheon-persistence']) {
723
+ config.mcp['pantheon-persistence'] = {
724
+ type: 'local',
725
+ cwd: runtimeTarget,
726
+ command: [venvPython, 'scripts/mcp_persistence.py'],
727
+ enabled: true,
728
+ }
729
+ }
730
+ if (!config.mcp['pantheon-vision']) {
731
+ config.mcp['pantheon-vision'] = {
732
+ type: 'local',
733
+ cwd: runtimeTarget,
734
+ command: [venvPython, 'scripts/pantheon_vision.py'],
735
+ enabled: true,
736
+ }
737
+ }
738
+
739
+ // Default MCP permissions
740
+ config.permission = config.permission || {}
741
+ config.permission.mcp = config.permission.mcp || {}
742
+ if (!config.permission.mcp['pantheon-resources']) {
743
+ config.permission.mcp['pantheon-resources'] = 'allow'
744
+ }
745
+ if (!config.permission.mcp['pantheon-code-mode']) {
746
+ config.permission.mcp['pantheon-code-mode'] = 'ask'
747
+ }
748
+ if (!config.permission.mcp['pantheon-memory']) {
749
+ config.permission.mcp['pantheon-memory'] = 'allow'
750
+ }
751
+ if (!config.permission.mcp['pantheon-persistence']) {
752
+ config.permission.mcp['pantheon-persistence'] = 'allow'
753
+ }
754
+ if (!config.permission.mcp['pantheon-vision']) {
755
+ config.permission.mcp['pantheon-vision'] = 'ask'
756
+ }
757
+
758
+ return { healed }
759
+ }
760
+
432
761
  export async function installOpenCode(
433
762
  target,
434
763
  dryRun = false,
@@ -450,12 +779,31 @@ export async function installOpenCode(
450
779
  // --opencode-version=v2. V2 normalizes V1 fields in memory (no rewrite) and
451
780
  // isolates its state DB via OPENCODE_DB (~/.local/share/opencode/opencode-v2.db)
452
781
  // with a distinct service port (49375 vs V1's 49374).
453
- const version = resolveOpenCodeVersion(opts.version ?? 'v1')
782
+ // Default to 'auto' so a flagless run resolves against the actual host. A
783
+ // hardcoded 'v1' here (or in bin/pantheon-init.mjs) is what shipped the bug:
784
+ // the V1 branch writes .ts file paths that a 2.x host drops from the config.
785
+ // `opts` is forwarded as the resolver's options bag so a caller (and CI) can
786
+ // pin env/probe instead of spawning a real host binary.
787
+ const version = resolveOpenCodeVersion(opts.version ?? 'auto', opts)
454
788
  if (version === 'v2') {
455
789
  info(
456
790
  'OpenCode V2 target — shared config; state isolated via OPENCODE_DB ' +
457
791
  '(~/.local/share/opencode/opencode-v2.db), service port 49375',
458
792
  )
793
+ // Say what the V2 contract does NOT give you, at the moment the user picks
794
+ // it. The V2 surface is 3 tools (hashline_edit, pantheon_cost,
795
+ // pantheon_model); V1's is 6 plus the BackgroundJobBoard, the three goal
796
+ // tools and the caller/target delegation matrix. This list was previously
797
+ // reachable only through `getUnsupportedFeatures()`, which no production
798
+ // code called — a user migrating hit the reduction with no warning at all.
799
+ //
800
+ // The markers are read from the same seed `getUnsupportedFeatures()` is
801
+ // built from (src/pantheon/v2-unsupported.mjs), so this notice cannot drift
802
+ // from what the plugin reports. Advisory: a V2 install is a supported
803
+ // configuration, not a failure.
804
+ info(
805
+ `V2 plugin generation — reduced surface. Unsupported features: ${listV2UnsupportedFeatures().join(', ')}`,
806
+ )
459
807
  }
460
808
 
461
809
  // Interactive mode handling
@@ -487,10 +835,24 @@ export async function installOpenCode(
487
835
  }
488
836
  }
489
837
 
490
- const pantheonConfigPath = join(ROOT, 'opencode.json')
838
+ // Product defaults live in code (PRODUCT_CONFIG_DEFAULTS). A repository-root
839
+ // opencode.json is a contributor's PERSONAL dev harness, not product data, so
840
+ // it must never be read just because it happens to exist: doing so silently
841
+ // injects the developer's provider/plugins/compaction into every config an
842
+ // install generates. The merge is therefore opt-in only, via
843
+ // --merge-dev-config <path>. Absent the flag, no root-level config is read.
844
+ const mergeDevConfigOpt = opts.mergeDevConfig ?? null
845
+ if (mergeDevConfigOpt !== null && typeof mergeDevConfigOpt !== 'string') {
846
+ throw new Error('mergeDevConfig requires a file path string')
847
+ }
848
+ const pantheonConfigPath =
849
+ typeof mergeDevConfigOpt === 'string' ? resolve(mergeDevConfigOpt) : null
491
850
  const targetConfigPath = join(target, 'opencode.json')
492
- const config = readJsonConfig(targetConfigPath)
493
- const pantheonConfig = readJsonConfig(pantheonConfigPath)
851
+ const { config, raw: configRaw } = readJsonConfigWithRaw(targetConfigPath)
852
+ const pantheonConfig =
853
+ pantheonConfigPath !== null && existsSync(pantheonConfigPath)
854
+ ? readJsonConfig(pantheonConfigPath)
855
+ : null
494
856
 
495
857
  // beta.5 preflight: fail BEFORE any files are written when the runtime
496
858
  // component needs toolchain pieces that are missing. Nothing has been
@@ -503,14 +865,13 @@ export async function installOpenCode(
503
865
  }
504
866
  }
505
867
 
506
- // Determine layout based on install scope.
507
- // Global config dir (~/.opencode or ~/.config/opencode) uses a flat layout:
508
- // agents/ skills/ commands/ plugins/
509
- // Project installs use the .opencode/ sub-directory layout:
510
- // .opencode/agents/ .opencode/skills/ .opencode/commands/
868
+ // V1 project installs use the legacy .opencode/agents layout. OpenCode V2's
869
+ // active config directory is the project root and its agent loader scans
870
+ // <config-dir>/{agent,agents}/**, so V2 project agents must be at ./agents.
871
+ // Global installs are flat for both versions.
511
872
  const isGlobal = isGlobalConfigDir(target)
512
873
  const _subDir = isGlobal ? '' : '.opencode'
513
- const agentPrefix = isGlobal ? 'agents' : '.opencode/agents'
874
+ const agentPrefix = isGlobal || version === 'v2' ? 'agents' : '.opencode/agents'
514
875
 
515
876
  configure({ dryRun })
516
877
 
@@ -529,7 +890,7 @@ export async function installOpenCode(
529
890
  stats.errors++
530
891
  } else {
531
892
  const agentStep = step(S.installingAgents)
532
- const dstDir = isGlobal ? join(target, 'agents') : join(target, '.opencode', 'agents')
893
+ const dstDir = join(target, agentPrefix)
533
894
  if (!dryRun) mkdirSync(dstDir, { recursive: true })
534
895
  if (clean && existsSync(dstDir) && !dryRun) {
535
896
  const existing = readdirSync(dstDir)
@@ -537,7 +898,7 @@ export async function installOpenCode(
537
898
  rmSync(join(dstDir, f), { recursive: true, force: true })
538
899
  }
539
900
  }
540
- const { created, skipped } = copyFiles(srcDir, dstDir, dryRun)
901
+ const { created, skipped } = copyAgentPrompts(srcDir, dstDir, dryRun)
541
902
  stats.created += created
542
903
  stats.skipped += skipped
543
904
  agentStep(true)
@@ -586,23 +947,45 @@ export async function installOpenCode(
586
947
  }
587
948
 
588
949
  // -----------------------------------------------------------------------
589
- // 2.5 Install instructions: AGENTS.md only (--components instructions)
950
+ // 2.5 Install shared instructions: AGENTS.md (--components instructions)
590
951
  // -----------------------------------------------------------------------
591
- // AGENTS.md is the single instruction file both V1 and V2 load; the
592
- // generated file embeds every src/instructions/*.instructions.md body, so
593
- // the individual files are NOT copied anymore (nothing references
594
- // <config>/instructions/ after the section-D merge fix — copying them would
595
- // just leave stale duplicates on disk).
952
+ // Both AGENTS.md artifacts are managed regions (scripts/build-agents-md.mjs).
953
+ // A GLOBAL config dir receives the repo-agnostic global artifact (agent
954
+ // table + shared rules); a PROJECT layout keeps the repository artifact.
955
+ // writeAgentsMdManaged is conservative: content outside the managed region
956
+ // is never deleted, and a legacy fully-generated file upgrades in place.
596
957
  if (componentSet.has('instructions')) {
597
958
  section('\uD83D\uDCCB Instructions')
598
- // AGENTS.md
599
- const srcAgentsMd = join(ROOT, 'AGENTS.md')
959
+ const srcAgentsMd = isGlobal ? join(ROOT, 'AGENTS.global.md') : join(ROOT, 'AGENTS.md')
600
960
  const dstAgentsMd = join(target, 'AGENTS.md')
601
961
  if (existsSync(srcAgentsMd)) {
602
962
  const content = readFileSync(srcAgentsMd, 'utf8')
603
- const status = writeIfChanged(dstAgentsMd, content, dryRun)
604
- if (status === 'created') stats.created++
605
- else stats.skipped++
963
+ const status = writeAgentsMdManaged(dstAgentsMd, content, dryRun)
964
+ if (status === 'skipped') stats.skipped++
965
+ else {
966
+ stats.created++
967
+ if (status === 'merged') {
968
+ warning(
969
+ `AGENTS.md had non-generated content — it was preserved around the ` +
970
+ `Pantheon managed region in ${dstAgentsMd}. Review and merge manually.`,
971
+ )
972
+ }
973
+ }
974
+ } else {
975
+ warning(`AGENTS.md artifact not found in the package: ${srcAgentsMd}`)
976
+ stats.errors++
977
+ }
978
+ // An instruction-only upgrade must also refresh selective guidance on
979
+ // already-installed Pantheon agents, but must not create agents the user
980
+ // did not install. Full agent installs do this in phase 1 above.
981
+ if (!componentSet.has('agents')) {
982
+ const installedAgents = join(target, agentPrefix)
983
+ const sourceAgents = join(ROOT, 'src', 'agents')
984
+ if (existsSync(installedAgents) && existsSync(sourceAgents)) {
985
+ const refreshed = copyAgentPrompts(sourceAgents, installedAgents, dryRun, true)
986
+ stats.created += refreshed.created
987
+ stats.skipped += refreshed.skipped
988
+ }
606
989
  }
607
990
  }
608
991
 
@@ -626,7 +1009,11 @@ export async function installOpenCode(
626
1009
  if (componentSet.has('commands')) {
627
1010
  section('\u26A1 Commands')
628
1011
  const srcCmds = join(ROOT, 'commands')
629
- const dstCmds = isGlobal ? join(target, 'commands') : join(target, '.opencode', 'commands')
1012
+ // V2 project sandboxes use OPENCODE_CONFIG_DIR=<project root>, so OpenCode
1013
+ // discovers custom commands from <target>/commands. V1 project installs
1014
+ // keep the standard .opencode/commands layout; global installs are flat.
1015
+ const commandDir = isGlobal || version === 'v2' ? 'commands' : '.opencode/commands'
1016
+ const dstCmds = join(target, commandDir)
630
1017
  if (existsSync(srcCmds)) {
631
1018
  const cmdResult = syncDir(srcCmds, dstCmds, dryRun, clean, (f) => f.endsWith('.md'))
632
1019
  stats.created += cmdResult.created
@@ -651,7 +1038,7 @@ export async function installOpenCode(
651
1038
  const dstPluginDir = isGlobal
652
1039
  ? join(target, 'plugins', 'pantheon-tui')
653
1040
  : join(target, '.opencode', 'plugins', 'pantheon-tui')
654
- const pluginStats = installPlugin(srcPluginDir, dstPluginDir, { dryRun, clean })
1041
+ const pluginStats = installPlugin(srcPluginDir, dstPluginDir, { dryRun, clean, version })
655
1042
  stats.created += pluginStats.created
656
1043
  stats.skipped += pluginStats.skipped
657
1044
  stats.errors += pluginStats.errors
@@ -663,7 +1050,7 @@ export async function installOpenCode(
663
1050
  if (componentSet.has('plugins')) {
664
1051
  let tuiStatus
665
1052
  try {
666
- tuiStatus = syncTuiRegistration(target, { isGlobal, dryRun })
1053
+ tuiStatus = syncTuiRegistration(target, { isGlobal, dryRun, version })
667
1054
  } catch (cause) {
668
1055
  error(`TUI plugin setup failed: ${cause.message}`)
669
1056
  stats.errors++
@@ -683,30 +1070,31 @@ export async function installOpenCode(
683
1070
 
684
1071
  // ── MCP server scripts ──
685
1072
  const mcpScripts = [
686
- 'mcp_resources_server.py',
687
- 'code_mode_server.py',
688
- 'memory_mcp_server.py',
1073
+ 'mcp_resources.py',
1074
+ 'code_mode.py',
1075
+ 'memory_mcp.py',
689
1076
  'scrub-secrets.py',
690
1077
  '_pantheon_paths.py',
691
1078
  'mcp_codemap_module.py',
692
- 'mcp_persistence_server.py',
693
- 'pantheon_vision_server.py',
1079
+ 'mcp_persistence.py',
1080
+ 'pantheon_vision.py',
694
1081
  'eval_store.py',
1082
+ 'themis_heuristic_scan.py',
695
1083
  ]
696
1084
  const srcScriptsDir = join(ROOT, 'scripts')
697
1085
  // Canonical MCP server sources live in src/mcp/. Map them explicitly so the
698
1086
  // sync always copies from the canonical source, never from the stale root
699
1087
  // scripts/ copies (which previously propagated bugs like the missing
700
- // `import uuid` in mcp_persistence_server.py). scrub-secrets.py is not in
1088
+ // `import uuid` in mcp_persistence.py). scrub-secrets.py is not in
701
1089
  // src/mcp/ and intentionally falls back to join(ROOT, 'scripts').
702
1090
  const canonicalMcpScripts = {
703
- 'mcp_resources_server.py': join(ROOT, 'src', 'mcp', 'mcp_resources_server.py'),
704
- 'code_mode_server.py': join(ROOT, 'src', 'mcp', 'code_mode_server.py'),
705
- 'memory_mcp_server.py': join(ROOT, 'src', 'mcp', 'memory_mcp_server.py'),
1091
+ 'mcp_resources.py': join(ROOT, 'src', 'mcp', 'mcp_resources.py'),
1092
+ 'code_mode.py': join(ROOT, 'src', 'mcp', 'code_mode.py'),
1093
+ 'memory_mcp.py': join(ROOT, 'src', 'mcp', 'memory_mcp.py'),
706
1094
  '_pantheon_paths.py': join(ROOT, 'src', 'mcp', '_pantheon_paths.py'),
707
1095
  'mcp_codemap_module.py': join(ROOT, 'src', 'mcp', 'mcp_codemap_module.py'),
708
- 'mcp_persistence_server.py': join(ROOT, 'src', 'mcp', 'mcp_persistence_server.py'),
709
- 'pantheon_vision_server.py': join(ROOT, 'src', 'mcp', 'pantheon_vision_server.py'),
1096
+ 'mcp_persistence.py': join(ROOT, 'src', 'mcp', 'mcp_persistence.py'),
1097
+ 'pantheon_vision.py': join(ROOT, 'src', 'mcp', 'pantheon_vision.py'),
710
1098
  'eval_store.py': join(ROOT, 'src', 'mcp', 'eval_store.py'),
711
1099
  }
712
1100
  const dstScriptsDir = join(runtimeTarget, 'scripts')
@@ -798,7 +1186,7 @@ export async function installOpenCode(
798
1186
  // on top. Preserves user's MCP, provider, plugins, compaction, theme.
799
1187
  // -----------------------------------------------------------------------
800
1188
  // --------------------------------------------------------------------
801
- // A. Parse canonical agent config from agents/*.agent.md frontmatter
1189
+ // A. Parse canonical agent config from src/agents/*.md frontmatter
802
1190
  // and merge into opencode.json config.
803
1191
  // --------------------------------------------------------------------
804
1192
  function getAgentSources(agentPrefix) {
@@ -836,7 +1224,6 @@ export async function installOpenCode(
836
1224
  if (fm.mode) agent.mode = fm.mode
837
1225
  if (fm.hidden) agent.hidden = fm.hidden
838
1226
  if (fm.temperature !== undefined) agent.temperature = fm.temperature
839
- if (fm.steps !== undefined) agent.steps = fm.steps
840
1227
  // Support both hyphen (YAML) and underscore (JSON) key variants
841
1228
  if (fm['disable-model-invocation'] !== undefined)
842
1229
  agent.disable_model_invocation = fm['disable-model-invocation']
@@ -847,6 +1234,12 @@ export async function installOpenCode(
847
1234
  if (fm.permission) {
848
1235
  agent.permission = JSON.parse(JSON.stringify(fm.permission))
849
1236
  }
1237
+ if (fm.mcp_tools !== undefined) {
1238
+ agent.permission = {
1239
+ ...(agent.permission ?? {}),
1240
+ ...deriveNativeMcpPermissions(fm.mcp_tools),
1241
+ }
1242
+ }
850
1243
 
851
1244
  config[name] = agent
852
1245
  }
@@ -855,15 +1248,6 @@ export async function installOpenCode(
855
1248
 
856
1249
  const canonicalAgentConfig = readAgentConfigFromCanonical()
857
1250
  const agentSources = getAgentSources(agentPrefix)
858
- const MANAGED_FIELDS = [
859
- 'steps',
860
- 'temperature',
861
- 'color',
862
- 'permission',
863
- 'mode',
864
- 'hidden',
865
- 'disable_model_invocation',
866
- ]
867
1251
 
868
1252
  if (config.agent === undefined) config.agent = {}
869
1253
 
@@ -922,8 +1306,11 @@ export async function installOpenCode(
922
1306
  // --------------------------------------------------------------------
923
1307
  // B.5 Ensure critical top-level OpenCode config sections
924
1308
  // --------------------------------------------------------------------
925
- if (config.default_agent === undefined && pantheonConfig.default_agent !== undefined) {
926
- config.default_agent = pantheonConfig.default_agent
1309
+ // Seeded from code, not from a packaged template: see
1310
+ // PRODUCT_CONFIG_DEFAULTS. Only fills a gap — a user who already chose a
1311
+ // default agent keeps it.
1312
+ if (config.default_agent === undefined) {
1313
+ config.default_agent = PRODUCT_CONFIG_DEFAULTS.default_agent
927
1314
  }
928
1315
  // Apply only explicit top-level model overrides. Existing values remain
929
1316
  // untouched and an install without either flag leaves both fields absent.
@@ -965,8 +1352,9 @@ export async function installOpenCode(
965
1352
  // --------------------------------------------------------------------
966
1353
  // B.5.1 Hermetic plugin resolution (packaging fix)
967
1354
  // --------------------------------------------------------------------
968
- // The packaged opencode.json may reference plugins via developer-machine
969
- // absolute paths (e.g. <dev>/pantheon/.../src/plugin.ts or
1355
+ // A developer running from a checkout has a repository-root opencode.json
1356
+ // that may reference plugins via developer-machine absolute paths (e.g.
1357
+ // <dev>/pantheon/.../src/plugin.ts or
970
1358
  // <dev>/pantheon/.../src/plugins/pantheon-hooks.ts). Copying those
971
1359
  // verbatim into the user's config would break every global install on any
972
1360
  // other machine. resolveInstalledPlugin() rewrites each entry to the plugin
@@ -985,7 +1373,7 @@ export async function installOpenCode(
985
1373
  if (config.plugin === undefined) config.plugin = []
986
1374
  if (Array.isArray(config.plugin)) {
987
1375
  config.plugin = removePantheonPluginReferences(config.plugin)
988
- if (Array.isArray(pantheonConfig.plugin)) {
1376
+ if (Array.isArray(pantheonConfig?.plugin)) {
989
1377
  for (const plugin of pantheonConfig.plugin) {
990
1378
  if (!isPantheonPluginReference(plugin) && !hasPluginReference(config.plugin, plugin)) {
991
1379
  config.plugin.push(deepClone(plugin))
@@ -1014,8 +1402,20 @@ export async function installOpenCode(
1014
1402
  config.plugin = removePantheonPluginReferences(config.plugin)
1015
1403
  }
1016
1404
  if (!Array.isArray(config.plugins)) config.plugins = []
1405
+ // Preserve any options already set on the Pantheon V2 entry across the
1406
+ // remove/re-add below. The context_sandbox migration reads them, and a
1407
+ // silent drop would lose a hand-set sandbox limit or any other plugin
1408
+ // option the user chose. Only the object form can carry options.
1409
+ const existingV2Options =
1410
+ config.plugins
1411
+ .filter((ref) => pluginReferenceIdentity(ref) === PANTHEON_V2_PLUGIN)
1412
+ .map((ref) =>
1413
+ typeof ref === 'object' && ref !== null && !Array.isArray(ref) ? ref.options : null,
1414
+ )
1415
+ .find((options) => options && typeof options === 'object' && !Array.isArray(options)) ??
1416
+ null
1017
1417
  config.plugins = deduplicatePluginReferences(removePantheonPluginReferences(config.plugins))
1018
- if (Array.isArray(pantheonConfig.plugins)) {
1418
+ if (Array.isArray(pantheonConfig?.plugins)) {
1019
1419
  for (const plugin of pantheonConfig.plugins) {
1020
1420
  if (!isPantheonPluginReference(plugin) && !hasPluginReference(config.plugins, plugin)) {
1021
1421
  config.plugins.push(deepClone(plugin))
@@ -1038,36 +1438,75 @@ export async function installOpenCode(
1038
1438
  config.plugins = config.plugins.filter(
1039
1439
  (plugin) => pluginReferenceIdentity(plugin) !== pluginReferenceIdentity(resolvedV2),
1040
1440
  )
1041
- config.plugins.push(resolvedV2)
1441
+ config.plugins.push(
1442
+ existingV2Options === null
1443
+ ? resolvedV2
1444
+ : { package: resolvedV2, options: deepClone(existingV2Options) },
1445
+ )
1042
1446
  }
1043
1447
 
1044
- if (config.provider === undefined && pantheonConfig.provider !== undefined) {
1045
- config.provider = deepClone(pantheonConfig.provider)
1046
- } else if (config.provider !== undefined && pantheonConfig.provider !== undefined) {
1047
- mergeMissing(config.provider, pantheonConfig.provider)
1048
- }
1448
+ if (pantheonConfig !== null) {
1449
+ if (config.provider === undefined && pantheonConfig.provider !== undefined) {
1450
+ config.provider = deepClone(pantheonConfig.provider)
1451
+ } else if (config.provider !== undefined && pantheonConfig.provider !== undefined) {
1452
+ mergeMissing(config.provider, pantheonConfig.provider)
1453
+ }
1049
1454
 
1050
- if (config.compaction === undefined && pantheonConfig.compaction !== undefined) {
1051
- config.compaction = deepClone(pantheonConfig.compaction)
1052
- } else if (config.compaction !== undefined && pantheonConfig.compaction !== undefined) {
1053
- mergeMissing(config.compaction, pantheonConfig.compaction)
1455
+ if (config.compaction === undefined && pantheonConfig.compaction !== undefined) {
1456
+ config.compaction = deepClone(pantheonConfig.compaction)
1457
+ } else if (config.compaction !== undefined && pantheonConfig.compaction !== undefined) {
1458
+ mergeMissing(config.compaction, pantheonConfig.compaction)
1459
+ }
1054
1460
  }
1055
1461
 
1056
1462
  // --------------------------------------------------------------------
1057
1463
  // C. Merge permissions
1058
1464
  // --------------------------------------------------------------------
1059
1465
  if (config.permission === undefined) config.permission = {}
1060
- if (pantheonConfig.permission !== undefined) {
1061
- mergeMissing(config.permission, pantheonConfig.permission)
1062
- }
1063
1466
  if (
1064
1467
  typeof config.permission === 'object' &&
1065
1468
  config.permission !== null &&
1066
1469
  !Array.isArray(config.permission)
1067
1470
  ) {
1068
- if (componentSet.has('skills')) {
1471
+ // Fill-if-absent, never overwrite. Pantheon's skills cannot be invoked
1472
+ // without this grant, so an install that just installed them seeds it —
1473
+ // but a user who deliberately set `permission.skill` (to `deny`, or to a
1474
+ // partial map of their own) keeps their choice. This was an unconditional
1475
+ // assignment nine lines below a comment that said "never overwrite", which
1476
+ // silently flipped an explicit deny to allow.
1477
+ //
1478
+ // Gated on the skills component AND absent-only, which is the whole point:
1479
+ // `skill` is a capability grant for something this install just wrote to
1480
+ // disk. A runtime-only or agents-only install has nothing to grant, so it
1481
+ // must not seed the key. It used to anyway, via the unconditional
1482
+ // PRODUCT_CONFIG_DEFAULTS.permission merge that sat above this block —
1483
+ // which also made this gate dead code.
1484
+ if (componentSet.has('skills') && config.permission.skill === undefined) {
1069
1485
  config.permission.skill = { '*': 'allow' }
1070
1486
  }
1487
+ // These five are the commands Pantheon's OWN agents must be able to run
1488
+ // unattended to do their job: version control, package execution, and the
1489
+ // test/lint pair every implementation agent reaches for. That is the whole
1490
+ // bar for a grant made on every install.
1491
+ //
1492
+ // Anything beyond it is the installing user's judgement about which
1493
+ // commands to trust on their machine, to be made in THEIR config — not ours
1494
+ // to grant silently on their behalf. The personal dev allowlist this grew
1495
+ // out of was the same category of decision, one layer further out of view.
1496
+ //
1497
+ // Specifically NOT granted, and why:
1498
+ // - `curl` — arbitrary network egress; the widest grant on the list and
1499
+ // not required to install or run anything here.
1500
+ // - `docker` — host/daemon access; a container runtime is not an
1501
+ // installation requirement.
1502
+ // - `gh` — third-party account access on someone's behalf; a GitHub CLI
1503
+ // login is never something an installer may assume.
1504
+ // - `pip`, `black`, `make` — ordinary toolchain commands with no Pantheon
1505
+ // agent dependency; users who want them can add them.
1506
+ //
1507
+ // NOTE: this grant is NOT gated on the skills component — unlike
1508
+ // permission.skill above, agents are installed by the agents component and
1509
+ // the test/lint commands are needed whichever components were selected.
1071
1510
  if (config.permission.bash === undefined) {
1072
1511
  config.permission.bash = {
1073
1512
  'git *': 'allow',
@@ -1075,12 +1514,6 @@ export async function installOpenCode(
1075
1514
  'npx *': 'allow',
1076
1515
  'pytest *': 'allow',
1077
1516
  'ruff *': 'allow',
1078
- 'black *': 'allow',
1079
- 'pip *': 'allow',
1080
- 'docker *': 'allow',
1081
- 'curl *': 'allow',
1082
- 'gh *': 'allow',
1083
- 'make *': 'allow',
1084
1517
  }
1085
1518
  }
1086
1519
  }
@@ -1090,8 +1523,8 @@ export async function installOpenCode(
1090
1523
  // --------------------------------------------------------------------
1091
1524
  // AGENTS.md is the single instruction file both V1 and V2 load (V1
1092
1525
  // auto-discovers it, V2 loads it explicitly). The generated AGENTS.md
1093
- // (scripts/build-agents-md.mjs) already embeds every
1094
- // src/instructions/*.instructions.md body, so the `instructions/*.md`
1526
+ // contains shared rules; agent-scoped rules are in the installed agent
1527
+ // prompts. The `instructions/*.md`
1095
1528
  // glob is no longer needed — keeping it would duplicate content under V1
1096
1529
  // and is ignored under V2 anyway. Only ensure AGENTS.md is present.
1097
1530
  const pantheonInstructions = ['AGENTS.md']
@@ -1137,82 +1570,18 @@ export async function installOpenCode(
1137
1570
  // at a venv that does not exist would make every MCP fail to launch.
1138
1571
  // --------------------------------------------------------------------
1139
1572
  if (componentSet.has('runtime') && runtimeHealthy) {
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.
1145
- // P1-3: derive the MCP python from the SAME venv layout setupVenv
1146
- // creates (<target>/.venv via venvPythonPath). For project installs the
1147
- // runtime payload lives under <target>/.opencode (runtimeTarget), but the
1148
- // venv is created at <target>/.venv — pointing MCP commands at
1573
+ // P1-3: derive the MCP python from the SAME venv layout setupVenv creates
1574
+ // (<target>/.venv via venvPythonPath). For project installs the runtime
1575
+ // payload lives under <target>/.opencode (runtimeTarget), but the venv is
1576
+ // created at <target>/.venv — pointing MCP commands at
1149
1577
  // runtimeTarget/.venv would reference an executable that never exists.
1150
- const runtimeTarget = isGlobal ? target : join(target, '.opencode')
1151
- const venvPython = venvPythonPath(target)
1152
1578
  // Point the config at the venv even on a first install; setupVenv runs
1153
1579
  // later in this function and creates this path before OpenCode starts.
1154
- const memoryPython = venvPython
1155
-
1156
- // Only add if not already configured by user
1157
- if (!config.mcp['pantheon-resources']) {
1158
- config.mcp['pantheon-resources'] = {
1159
- type: 'local',
1160
- cwd: runtimeTarget,
1161
- command: [memoryPython, 'scripts/mcp_resources_server.py'],
1162
- enabled: true,
1163
- }
1164
- }
1165
- if (!config.mcp['pantheon-code-mode']) {
1166
- config.mcp['pantheon-code-mode'] = {
1167
- type: 'local',
1168
- cwd: runtimeTarget,
1169
- command: [memoryPython, 'scripts/code_mode_server.py'],
1170
- enabled: true,
1171
- }
1172
- }
1173
- if (!config.mcp['pantheon-memory']) {
1174
- config.mcp['pantheon-memory'] = {
1175
- type: 'local',
1176
- cwd: runtimeTarget,
1177
- command: [memoryPython, 'scripts/memory_mcp_server.py'],
1178
- enabled: true,
1179
- }
1180
- }
1181
- if (!config.mcp['pantheon-persistence']) {
1182
- config.mcp['pantheon-persistence'] = {
1183
- type: 'local',
1184
- cwd: runtimeTarget,
1185
- command: [memoryPython, 'scripts/mcp_persistence_server.py'],
1186
- enabled: true,
1187
- }
1188
- }
1189
- if (!config.mcp['pantheon-vision']) {
1190
- config.mcp['pantheon-vision'] = {
1191
- type: 'local',
1192
- cwd: runtimeTarget,
1193
- command: [memoryPython, 'scripts/pantheon_vision_server.py'],
1194
- enabled: true,
1195
- }
1196
- }
1197
-
1198
- // Default MCP permissions
1199
- config.permission = config.permission || {}
1200
- config.permission.mcp = config.permission.mcp || {}
1201
- if (!config.permission.mcp['pantheon-resources']) {
1202
- config.permission.mcp['pantheon-resources'] = 'allow'
1203
- }
1204
- if (!config.permission.mcp['pantheon-code-mode']) {
1205
- config.permission.mcp['pantheon-code-mode'] = 'ask'
1206
- }
1207
- if (!config.permission.mcp['pantheon-memory']) {
1208
- config.permission.mcp['pantheon-memory'] = 'allow'
1209
- }
1210
- if (!config.permission.mcp['pantheon-persistence']) {
1211
- config.permission.mcp['pantheon-persistence'] = 'allow'
1212
- }
1213
- if (!config.permission.mcp['pantheon-vision']) {
1214
- config.permission.mcp['pantheon-vision'] = 'ask'
1215
- }
1580
+ const runtimeTarget = isGlobal ? target : join(target, '.opencode')
1581
+ applyMcpServerEntries(config, {
1582
+ runtimeTarget,
1583
+ venvPython: venvPythonPath(target),
1584
+ })
1216
1585
  }
1217
1586
 
1218
1587
  // ── V2 native migration ──
@@ -1222,10 +1591,52 @@ export async function installOpenCode(
1222
1591
  if (version === 'v2') {
1223
1592
  info(S.v2Migrating)
1224
1593
  configToWrite = migrateV1toV2(config)
1594
+
1595
+ // Pantheon no longer sets a step ceiling. Any `steps` sitting on a managed
1596
+ // agent was written by an earlier install and never chosen by the user —
1597
+ // the merge above is additive by design (no delete path), so drop it here
1598
+ // and let the host default apply. This runs after the migration because
1599
+ // migrateV1toV2 copies the V1 `agent` block across verbatim (including any
1600
+ // stale `steps`), which is exactly how the value reappears in V2.
1601
+ // User-defined agents are out of scope and keep their value; the V1
1602
+ // singular `agent` block is deliberately untouched (V1 is retiring).
1603
+ const v2Agents = configToWrite.agents
1604
+ if (v2Agents && typeof v2Agents === 'object' && !Array.isArray(v2Agents)) {
1605
+ for (const name of Object.keys(canonicalAgentConfig)) {
1606
+ const entry = v2Agents[name]
1607
+ if (entry && typeof entry === 'object' && !Array.isArray(entry)) delete entry.steps
1608
+ }
1609
+ }
1610
+
1611
+ // Carry the V1 top-level `context_sandbox` into the exact Pantheon V2
1612
+ // plugin entry's `options` — V2.0.25 silently drops the top-level key, so
1613
+ // without this the user's enabled/limits choice is lost on a V1→V2 install.
1614
+ // The V1 block itself is preserved by migrateV1toV2 for a downgrade.
1615
+ // A null/type-invalid block throws HERE, before any config write.
1616
+ configToWrite.plugins = applyContextSandboxToV2Plugins(
1617
+ configToWrite.plugins,
1618
+ config.context_sandbox,
1619
+ )
1225
1620
  }
1226
1621
 
1227
1622
  const configContent = `${JSON.stringify(configToWrite, null, 2)}\n`
1228
- const status = writeConfigWithBackup(targetConfigPath, configContent, dryRun)
1623
+ const status = writeConfigWithBackup(targetConfigPath, configContent, dryRun, {
1624
+ expectedContent: configRaw,
1625
+ // Test seam: lets a test simulate a concurrent writer landing between the
1626
+ // initial config read and the pre-swap recheck. Absent in production.
1627
+ beforeRecheck: opts.deps?.beforeRecheck,
1628
+ })
1629
+ if (status === 'conflict') {
1630
+ // The user's config changed under the installer between the read and the
1631
+ // swap. Treating this as an ordinary skipped write would report a
1632
+ // successful install over a file we never actually updated — fail loudly
1633
+ // and leave the target and its `.bak` byte-identical instead.
1634
+ throw new Error(
1635
+ `Installation aborted: ${targetConfigPath} changed on disk during install ` +
1636
+ '(it no longer matches the content that was read); refusing to overwrite it. ' +
1637
+ 'Re-run the installer once the file is stable.',
1638
+ )
1639
+ }
1229
1640
  if (status === 'created') stats.created++
1230
1641
  else stats.skipped++
1231
1642
 
@@ -1304,9 +1715,46 @@ export async function installOpenCode(
1304
1715
  writeActivePreset(target, opts.preset, { source: 'cli' })
1305
1716
  }
1306
1717
 
1718
+ // -----------------------------------------------------------------------
1719
+ // 5.5 Optional vision prompt (interactive TTY only). The default — automatic
1720
+ // discovery — writes nothing, and non-interactive flows (no TTY, --yes,
1721
+ // --headless, automated sandboxes) never reach this block, so no run
1722
+ // blocks on stdin and the default behavior is unchanged. A failure here
1723
+ // must never fail the install: vision stays optional.
1724
+ // -----------------------------------------------------------------------
1725
+ if (interactive && !dryRun && !autoYes) {
1726
+ const { runVisionPrompt } = await import('./vision-prompt.mjs')
1727
+ try {
1728
+ await runVisionPrompt({ logger: console })
1729
+ } catch (err) {
1730
+ warning(`Vision setup skipped: ${err instanceof Error ? err.message : String(err)}`)
1731
+ }
1732
+ }
1733
+
1307
1734
  // Fail-closed: accumulated stats.errors (bad sources, missing requirements,
1308
1735
  // failed health checks) must never masquerade as a successful installation.
1309
1736
  if (stats.errors > 0) {
1310
1737
  throw new Error(`installation completed with ${stats.errors} error(s); see output above`)
1311
1738
  }
1739
+
1740
+ // Record the component state only after the install has completed. The
1741
+ // postinstall marker can predate `init` and start with every component
1742
+ // pending; leaving it untouched made a healthy beta install look incomplete
1743
+ // to `doctor` forever. Unselected components retain their previous state.
1744
+ if (!dryRun) {
1745
+ const packageVersion = JSON.parse(readFileSync(join(ROOT, 'package.json'), 'utf8')).version
1746
+ let state = readState(target)
1747
+ if (!state && hasState(target)) {
1748
+ throw new Error('Installation state exists but cannot be read; refusing to replace it')
1749
+ }
1750
+ state ??= createInitialState(packageVersion)
1751
+ state.pantheon_version = packageVersion
1752
+ for (const component of ['agents', 'skills', 'plugins', 'runtime']) {
1753
+ if (!componentSet.has(component)) continue
1754
+ const status = component === 'runtime' && !runtimeHealthy ? 'failed' : 'installed'
1755
+ const componentVersion = state.components?.[component]?.version ?? 1
1756
+ setComponentState(state, component, componentVersion, status)
1757
+ }
1758
+ writeState(target, state)
1759
+ }
1312
1760
  }