pantheon-opencode 1.6.0-beta.1 → 1.6.0-beta.11

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 (133) hide show
  1. package/AGENTS.global.md +206 -0
  2. package/AGENTS.md +10 -891
  3. package/CHANGELOG.md +350 -0
  4. package/README.md +561 -52
  5. package/README.pt-BR.md +285 -20
  6. package/bin/pantheon-init.mjs +70 -4
  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 +24 -17
  13. package/plugin.json +2 -2
  14. package/pyproject.toml +8 -6
  15. package/scripts/build-agents-md.mjs +428 -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/backup.mjs +475 -0
  22. package/scripts/install/cli-ui.mjs +6 -1
  23. package/scripts/install/config-migration.mjs +317 -4
  24. package/scripts/install/exit-codes.mjs +26 -0
  25. package/scripts/install/health-check.mjs +21 -19
  26. package/scripts/install/interactive.mjs +16 -2
  27. package/scripts/install/migrate.mjs +41 -22
  28. package/scripts/install/opencode-version.mjs +246 -4
  29. package/scripts/install/opencode.mjs +1175 -215
  30. package/scripts/install/plan.mjs +287 -0
  31. package/scripts/install/plugin.mjs +62 -14
  32. package/scripts/install/shared.mjs +190 -48
  33. package/scripts/install/strings.mjs +20 -0
  34. package/scripts/install/venv.mjs +13 -3
  35. package/scripts/install/vision-prompt.mjs +171 -0
  36. package/scripts/install-mcp.mjs +28 -9
  37. package/scripts/{mcp_persistence_server.py → mcp_persistence.py} +52 -17
  38. package/{src/mcp/mcp_resources_server.py → scripts/mcp_resources.py} +2 -2
  39. package/scripts/{memory_mcp_server.py → memory_mcp.py} +2 -2
  40. package/scripts/probe-context-rehydrate.mjs +8 -2
  41. package/scripts/probe-pantheon-cost.mjs +71 -15
  42. package/scripts/prune.mjs +9 -3
  43. package/scripts/restore.mjs +222 -0
  44. package/scripts/run-node-tests.mjs +189 -0
  45. package/scripts/sync-artifacts.mjs +26 -13
  46. package/scripts/sync-tui.mjs +24 -3
  47. package/scripts/test-opencode-v2-sandbox.sh +180 -26
  48. package/scripts/validate-routing.mjs +3 -3
  49. package/scripts/validate-sandbox-install.mjs +138 -0
  50. package/scripts/versioning.mjs +13 -1
  51. package/scripts/vision-status.mjs +682 -0
  52. package/skills-lock.json +8 -8
  53. package/src/agents/aphrodite.md +20 -37
  54. package/src/agents/apollo.md +7 -3
  55. package/src/agents/athena.md +63 -35
  56. package/src/agents/demeter.md +8 -28
  57. package/src/agents/gaia.md +1 -1
  58. package/src/agents/hephaestus.md +10 -21
  59. package/src/agents/hermes.md +15 -38
  60. package/src/agents/iris.md +7 -1
  61. package/src/agents/mnemosyne.md +18 -54
  62. package/src/agents/nyx.md +1 -1
  63. package/src/agents/prometheus.md +16 -20
  64. package/src/agents/talos.md +10 -2
  65. package/src/agents/themis.md +5 -5
  66. package/src/agents/zeus.md +99 -172
  67. package/src/instructions/agent-return-format.instructions.md +34 -17
  68. package/src/instructions/backend-standards.instructions.md +2 -0
  69. package/src/instructions/frontend-standards.instructions.md +3 -1
  70. package/src/instructions/memory-protocol.instructions.md +12 -110
  71. package/src/instructions/test-economy.instructions.md +21 -0
  72. package/src/instructions/zeus-anti-stall.instructions.md +20 -67
  73. package/src/instructions/zeus-communication-rules.instructions.md +0 -1
  74. package/src/instructions/zeus-council-synthesis.instructions.md +31 -260
  75. package/src/instructions/zeus-memory-operations.instructions.md +30 -0
  76. package/src/instructions/zeus-timeout-retry.instructions.md +61 -89
  77. package/src/mcp/{code_mode_server.py → code_mode.py} +3 -3
  78. package/src/mcp/eval_store.py +2 -2
  79. package/src/mcp/{mcp_persistence_server.py → mcp_persistence.py} +52 -17
  80. package/{scripts/mcp_resources_server.py → src/mcp/mcp_resources.py} +2 -2
  81. package/src/mcp/{memory_mcp_server.py → memory_mcp.py} +2 -2
  82. package/src/mcp/{pantheon_vision_server.py → pantheon_vision.py} +286 -42
  83. package/src/mcp/requirements-mcp.txt +6 -4
  84. package/src/mcp/requirements-vision.txt +2 -2
  85. package/src/pantheon/cost-command.ts +403 -102
  86. package/src/pantheon/delegation-enforce.ts +10 -1
  87. package/src/pantheon/model-agents.ts +19 -0
  88. package/src/pantheon/model-command.ts +218 -56
  89. package/src/pantheon/presets.d.mts +0 -8
  90. package/src/pantheon/presets.mjs +5 -32
  91. package/src/pantheon/v2-bounded-read.ts +640 -0
  92. package/src/pantheon/v2-delegation-enforce.ts +214 -0
  93. package/src/pantheon/v2-execute-after.ts +186 -0
  94. package/src/pantheon/v2-model-overrides.ts +275 -0
  95. package/src/pantheon/v2-tools.ts +418 -0
  96. package/src/pantheon/v2-unsupported.d.mts +11 -0
  97. package/src/pantheon/v2-unsupported.mjs +90 -0
  98. package/src/pantheon/v2-vision.ts +622 -0
  99. package/src/pantheon/vision-local.ts +531 -0
  100. package/src/pantheon/vision.ts +1160 -115
  101. package/src/plugin-v2.ts +1158 -219
  102. package/src/plugin.ts +12 -9
  103. package/src/plugins/tui/dist/server.d.ts +1 -2
  104. package/src/plugins/tui/dist/server.d.ts.map +1 -1
  105. package/src/plugins/tui/dist/tui-v2.d.ts +154 -0
  106. package/src/plugins/tui/dist/tui-v2.d.ts.map +1 -0
  107. package/src/plugins/tui/dist/tui-v2.js +659 -0
  108. package/src/plugins/tui/dist/tui-v2.js.map +1 -0
  109. package/src/plugins/tui/dist/tui.d.ts +85 -82
  110. package/src/plugins/tui/dist/tui.d.ts.map +1 -1
  111. package/src/plugins/tui/dist/tui.js +72 -59
  112. package/src/plugins/tui/index-v2.ts +6 -0
  113. package/src/plugins/tui/package-lock.json +5516 -1353
  114. package/src/plugins/tui/package.json +22 -12
  115. package/src/plugins/tui/src/index-v2.tsx +734 -0
  116. package/src/plugins/tui/src/index.tsx +106 -85
  117. package/src/plugins/tui/src/opencode-v2.d.ts +13 -0
  118. package/src/plugins/tui/tsconfig.build.json +5 -0
  119. package/src/plugins/tui/tsconfig.typecheck.json +5 -0
  120. package/src/plugins/tui/tsdown.config.ts +22 -2
  121. package/src/plugins/tui/tui-v2.ts +2 -0
  122. package/src/plugins/tui/tui.ts +3 -0
  123. package/src/routing.yml +11 -21
  124. package/src/skills/README.md +3 -3
  125. package/src/skills/agent-coordination/SKILL.md +7 -2
  126. package/src/skills/artifact-management/SKILL.md +12 -5
  127. package/src/skills/auto-continue/SKILL.md +52 -35
  128. package/src/skills/code-review-checklist/SKILL.md +5 -5
  129. package/src/skills/context-compression/SKILL.md +26 -879
  130. package/src/skills/session-goal/SKILL.md +14 -5
  131. package/src/skills/spec-driven-development/SKILL.md +3 -1
  132. package/src/skills/tdd-with-agents/SKILL.md +18 -10
  133. 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
  */
@@ -15,7 +19,10 @@
15
19
  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
- import { dirname, isAbsolute, join, resolve } from 'node:path'
22
+ import { dirname, isAbsolute, join, relative, resolve } from 'node:path'
23
+ import { listV2UnsupportedFeatures } from '../../src/pantheon/v2-unsupported.mjs'
24
+ import { copyAgentPrompts, writeAgentsMdManaged } from '../build-agents-md.mjs'
25
+ import { backupSetPath, backupWarning, createBackupSet, nextBackupName } from './backup.mjs'
19
26
  import {
20
27
  bullet,
21
28
  colors,
@@ -27,17 +34,32 @@ import {
27
34
  spinner,
28
35
  step,
29
36
  success,
37
+ tag,
30
38
  warning,
31
39
  } from './cli-ui.mjs'
32
- import { migrateV1toV2 } from './config-migration.mjs'
40
+ import {
41
+ mergeContextSandboxBlocks,
42
+ migrateMcpEntrypointPaths,
43
+ migrateV1toV2,
44
+ } from './config-migration.mjs'
33
45
  import { healthCheck } from './health-check.mjs'
34
46
  import { detectVersion, runMigrations } from './migrate.mjs'
35
47
  import { resolveOpenCodeVersion } from './opencode-version.mjs'
48
+ import {
49
+ ACTIONS,
50
+ createRecorder,
51
+ displayPath,
52
+ formatChangePlan,
53
+ isRecording,
54
+ planStatsMismatch,
55
+ recordChange,
56
+ snapshot,
57
+ summarizeChanges,
58
+ } from './plan.mjs'
36
59
  import { copyPluginFiles, installPlugin, registerPlugin, unregisterPlugin } from './plugin.mjs'
37
60
  import {
38
61
  checkRuntimePrerequisites,
39
62
  collectSkillNames,
40
- copyFiles,
41
63
  installSkills,
42
64
  parseFrontmatter,
43
65
  ROOT,
@@ -47,6 +69,7 @@ import {
47
69
  writeConfigWithBackup,
48
70
  writeIfChanged,
49
71
  } from './shared.mjs'
72
+ import { createInitialState, hasState, readState, setComponentState, writeState } from './state.mjs'
50
73
  import { strings } from './strings.mjs'
51
74
 
52
75
  import { setupVenv, venvPythonPath } from './venv.mjs'
@@ -61,36 +84,343 @@ const COMPONENT_NAMES = [
61
84
  'runtime',
62
85
  ]
63
86
 
87
+ /**
88
+ * MCP server scripts the runtime component installs. Hoisted so the backup-set
89
+ * enumeration and the runtime writer share one source of truth.
90
+ */
91
+ const MCP_SCRIPTS = [
92
+ 'mcp_resources.py',
93
+ 'code_mode.py',
94
+ 'memory_mcp.py',
95
+ 'scrub-secrets.py',
96
+ '_pantheon_paths.py',
97
+ 'mcp_codemap_module.py',
98
+ 'mcp_persistence.py',
99
+ 'pantheon_vision.py',
100
+ 'eval_store.py',
101
+ 'themis_heuristic_scan.py',
102
+ ]
103
+
104
+ /** Recursively list regular files under `root`, relative to `root`. */
105
+ function walkFiles(root, base = root, out = []) {
106
+ for (const entry of readdirSync(root, { withFileTypes: true })) {
107
+ const full = join(root, entry.name)
108
+ if (entry.isDirectory()) walkFiles(full, base, out)
109
+ else if (entry.isFile()) out.push(relative(base, full))
110
+ }
111
+ return out
112
+ }
113
+
114
+ /**
115
+ * Enumerate what the backup set must preserve, derived from the selected
116
+ * components. It returns:
117
+ * - `entries`: paths (relative to `target`) the run can CREATE/UPDATE/REMOVE.
118
+ * For every managed destination tree we take the UNION of the files that
119
+ * already exist there (so a `--clean`, a stale removal, or an
120
+ * instructions-only refresh is captured) and the source-derived destination
121
+ * paths (so a file that will be created is recorded as absent). The union is
122
+ * a superset of the plan's changes — unchanged files are backed up too.
123
+ * - `notRevertible`: paths the run may delete or write but whose byte-for-byte
124
+ * copy is not feasible or is out of the config dir (bulk `.venv` / plugin
125
+ * `node_modules`, the global `tui.json` copies and the global vision config).
126
+ * These are recorded explicitly in the manifest rather than silently ignored.
127
+ * It never reads file contents.
128
+ *
129
+ * @param {string} target
130
+ * @param {Set<string>} componentSet
131
+ * @param {string} version
132
+ * @param {boolean} isGlobal
133
+ * @returns {{ entries: string[], notRevertible: string[] }}
134
+ */
135
+ function collectInstallBackupEntries(target, componentSet, version, isGlobal) {
136
+ const entries = []
137
+ const notRevertible = []
138
+ const rel = (abs) => relative(target, abs)
139
+ const addFile = (abs) => entries.push(rel(abs))
140
+ // Existing files under an in-target tree (covers removals / clean / refresh).
141
+ // `skipDirNames` prunes bulk subdirectories (e.g. plugin `node_modules`) so the
142
+ // walk stays cheap while everything else — including legacy files a clean pass
143
+ // deletes — is captured.
144
+ const addExisting = (dir, skipDirNames = null) => {
145
+ if (!existsSync(dir)) return
146
+ const walk = (base) => {
147
+ for (const entry of readdirSync(base, { withFileTypes: true })) {
148
+ if (skipDirNames && entry.isDirectory() && skipDirNames.has(entry.name)) continue
149
+ const full = join(base, entry.name)
150
+ if (entry.isDirectory()) walk(full)
151
+ else if (entry.isFile()) addFile(full)
152
+ }
153
+ }
154
+ walk(dir)
155
+ }
156
+ // Destination paths derived from a source tree (covers files to be created).
157
+ const addSource = (srcDir, dstDir, filter = null) => {
158
+ if (!existsSync(srcDir)) return
159
+ for (const r of walkFiles(srcDir)) {
160
+ if (filter && !filter(r)) continue
161
+ addFile(join(dstDir, r))
162
+ }
163
+ }
164
+
165
+ const baseDir = isGlobal ? target : join(target, '.opencode')
166
+ const agentPrefix = isGlobal || version === 'v2' ? 'agents' : '.opencode/agents'
167
+ const agentDir = join(target, agentPrefix)
168
+
169
+ // Always touched.
170
+ addFile(join(target, 'opencode.json'))
171
+ addFile(join(target, '.pantheon', 'install-state.json'))
172
+ addFile(join(baseDir, 'opencode-vision.json'))
173
+ // The active-preset writer (writeActivePreset(target, …)) lives at
174
+ // <target>/.pantheon, NOT under <baseDir>, for both project and global scope.
175
+ addFile(join(target, '.pantheon', 'active-preset.json'))
176
+ addFile(join(target, '.pantheon', 'active-preset.json.bak'))
177
+
178
+ // The agent prompt dir is refreshed (existing-only) by an instructions-only
179
+ // run too, so enumerate it whenever EITHER component can write it.
180
+ if (componentSet.has('agents') || componentSet.has('instructions')) {
181
+ addExisting(agentDir)
182
+ }
183
+ if (componentSet.has('agents')) {
184
+ addFile(join(target, 'routing.yml'))
185
+ addSource(
186
+ join(ROOT, 'src', 'agents'),
187
+ agentDir,
188
+ (r) => r.endsWith('.md') && r.toLowerCase() !== 'readme.md',
189
+ )
190
+ }
191
+ if (componentSet.has('skills')) {
192
+ addExisting(join(baseDir, 'skills'))
193
+ addSource(join(ROOT, 'src', 'skills'), join(baseDir, 'skills'))
194
+ }
195
+ if (componentSet.has('instructions')) addFile(join(target, 'AGENTS.md'))
196
+ if (componentSet.has('prompts')) {
197
+ addExisting(join(target, 'prompts'))
198
+ addSource(join(ROOT, 'prompts'), join(target, 'prompts'))
199
+ }
200
+ if (componentSet.has('commands')) {
201
+ const commandDir = isGlobal || version === 'v2' ? 'commands' : '.opencode/commands'
202
+ addExisting(join(target, commandDir))
203
+ addSource(join(ROOT, 'commands'), join(target, commandDir), (r) => r.endsWith('.md'))
204
+ }
205
+ if (componentSet.has('plugins')) {
206
+ const dstPluginDir = isGlobal
207
+ ? join(target, 'plugins', 'pantheon-tui')
208
+ : join(target, '.opencode', 'plugins', 'pantheon-tui')
209
+ // Existing files under the installed plugin dir (legacy `index.tsx` /
210
+ // `dist/tui.tsx` and any extras a `--clean` run deletes) are captured, like
211
+ // every sibling tree — EXCEPT the bulk `node_modules`, which is regenerated
212
+ // by `npm ci` and listed as not revertible.
213
+ addExisting(dstPluginDir, new Set(['node_modules']))
214
+ addSource(join(ROOT, 'src', 'plugins', 'tui', 'dist'), join(dstPluginDir, 'dist'))
215
+ addFile(join(dstPluginDir, 'package.json'))
216
+ addFile(join(dstPluginDir, 'package-lock.json'))
217
+ addFile(join(dstPluginDir, 'tui.ts'))
218
+ addFile(join(dstPluginDir, 'index.ts'))
219
+ notRevertible.push(rel(join(dstPluginDir, 'node_modules')))
220
+ if (version === 'v1') {
221
+ const projectTui = join(baseDir, 'tui.json')
222
+ addFile(projectTui)
223
+ // Cross-location cleanup touches copies OUTSIDE the config dir.
224
+ for (const loc of tuiConfigLocations(target)) {
225
+ if (resolve(loc) !== resolve(projectTui)) notRevertible.push(loc)
226
+ }
227
+ }
228
+ }
229
+ if (componentSet.has('runtime')) {
230
+ const runtimeTarget = isGlobal ? target : join(target, '.opencode')
231
+ addExisting(join(runtimeTarget, 'scripts'))
232
+ for (const script of MCP_SCRIPTS) addFile(join(runtimeTarget, 'scripts', script))
233
+ addFile(join(runtimeTarget, 'requirements-vision.txt'))
234
+ addSource(join(ROOT, '.pantheon', 'code-mode'), join(runtimeTarget, '.pantheon', 'code-mode'))
235
+ addExisting(join(runtimeTarget, '.pantheon', 'code-mode'))
236
+ addFile(join(runtimeTarget, '.pantheon', 'tiers.json'))
237
+ // `--force` recreates the venv from scratch: bulk, not copied.
238
+ notRevertible.push(rel(join(target, '.venv')))
239
+ }
240
+
241
+ // The vision config is written GLOBALLY (outside the config dir).
242
+ const xdg = process.env.XDG_CONFIG_HOME || join(homedir(), '.config')
243
+ const globalVision = join(xdg, 'opencode', 'opencode-vision.json')
244
+ if (resolve(globalVision) !== resolve(join(baseDir, 'opencode-vision.json'))) {
245
+ notRevertible.push(globalVision)
246
+ }
247
+
248
+ return { entries, notRevertible }
249
+ }
250
+
251
+ /**
252
+ * The product defaults an install seeds into the user's `opencode.json`.
253
+ *
254
+ * They live HERE, in code, rather than in a template shipped in the npm
255
+ * tarball: that template also carried a maintainer's private MCP endpoint,
256
+ * provider models and per-developer permission overrides, and shipping it to
257
+ * every installer was the bug (#199). Reading defaults off it meant deleting it
258
+ * silently stopped the seeding, so the defaults are now stated next to the code
259
+ * that consumes them.
260
+ *
261
+ * PERSONAL CONFIGURATION MUST NEVER BE SEEDED HERE: this is the installer's
262
+ * contract with every user, and a value that is only right on one machine
263
+ * belongs in that machine's own `opencode.json`. Deliberately NOT carried over
264
+ * from the template, each because it was personal rather than a product
265
+ * default:
266
+ * - `provider` — pinned the maintainer's provider, model and modalities.
267
+ * - `mcp` — a private remote MCP endpoint.
268
+ * - `agent` — the maintainer's per-agent permission overrides.
269
+ * - `permission.websearch: "deny"` — a personal restriction; a user may rely
270
+ * on web search, so denying it is not ours to decide.
271
+ * - `permission.bash` — the template's dev allowlist (git, npm, ruff, …), a
272
+ * personal judgement about which commands to trust.
273
+ * Anything added here must be justifiable as "correct for a fresh install of
274
+ * the product, for anyone".
275
+ *
276
+ * Other seeding paths are untouched by this constant and still run: the config
277
+ * merge derives plugin refs, provider and compaction from a repository-root
278
+ * opencode.json when a contributor has one, and separately gates its own
279
+ * permission grants — `skill` on the skills component, `bash` unconditionally —
280
+ * on whether the user's own config already set them.
281
+ *
282
+ * Frozen, and copied only into a confirmed gap (never into a key the user
283
+ * already set), so nothing here can be mutated through a user's config.
284
+ */
285
+ const PRODUCT_CONFIG_DEFAULTS = Object.freeze({
286
+ default_agent: 'zeus',
287
+ })
288
+
289
+ /**
290
+ * Per-agent fields owned by the framework: for an agent that already exists in
291
+ * the target config these are overwritten from the canonical source, and for a
292
+ * new agent they are the seed. Everything else on an existing entry is the
293
+ * user's and is left alone.
294
+ *
295
+ * `source` is written right next to these (for both new and existing agents) but
296
+ * is kept out of the loop so it can also be set independently — the migration in
297
+ * config-migration.mjs mirrors this list plus `source`, and tests assert the two
298
+ * stay equal as sets.
299
+ *
300
+ * KNOWN DEBT: `permission` is the V1 spelling. V2 reads `permissions`, so an
301
+ * agent can carry both keys after an install and the V1 one becomes dead weight.
302
+ * Tracked for the next slice; changing it here would change what an install
303
+ * writes.
304
+ *
305
+ * @type {readonly string[]}
306
+ */
307
+ export const MANAGED_FIELDS = Object.freeze([
308
+ 'temperature',
309
+ 'color',
310
+ 'permission',
311
+ 'mode',
312
+ 'hidden',
313
+ 'disable_model_invocation',
314
+ ])
315
+
316
+ const PANTHEON_MCP_SERVERS = Object.freeze([
317
+ 'pantheon-resources',
318
+ 'pantheon-memory',
319
+ 'pantheon-code-mode',
320
+ 'pantheon-persistence',
321
+ 'pantheon-vision',
322
+ ])
323
+
324
+ function normalizeMcpPermissionSegment(value) {
325
+ return value.replace(/[^a-zA-Z0-9_]/g, '_')
326
+ }
327
+
328
+ /**
329
+ * Translate an agent's `mcp_tools` frontmatter into per-tool native
330
+ * permissions. V1 matches the original server-tool names; V2 normalizes
331
+ * punctuation to underscores. Emitting both spellings keeps the same source
332
+ * metadata valid for either host. Unlisted bundled Pantheon servers default to
333
+ * deny; unrelated user MCP servers are not touched.
334
+ *
335
+ * @param {Record<string, 'all' | string[]>} mcpTools
336
+ * @returns {Record<string, 'allow' | 'deny'>}
337
+ */
338
+ export function deriveNativeMcpPermissions(mcpTools) {
339
+ if (mcpTools === null || typeof mcpTools !== 'object' || Array.isArray(mcpTools)) {
340
+ throw new TypeError('mcp_tools must be an object keyed by MCP server name')
341
+ }
342
+
343
+ const servers = new Set([...PANTHEON_MCP_SERVERS, ...Object.keys(mcpTools)])
344
+ const permissions = {}
345
+
346
+ for (const server of servers) {
347
+ if (!/^[a-zA-Z0-9._-]+$/.test(server)) {
348
+ throw new TypeError(`mcp_tools server name is invalid: ${server}`)
349
+ }
350
+ const tools = Object.hasOwn(mcpTools, server) ? mcpTools[server] : []
351
+ if (
352
+ tools !== 'all' &&
353
+ (!Array.isArray(tools) ||
354
+ tools.some((tool) => typeof tool !== 'string' || !/^[a-zA-Z0-9._-]+$/.test(tool)))
355
+ ) {
356
+ throw new TypeError(`mcp_tools.${server} must be "all" or an array of tool names`)
357
+ }
358
+
359
+ const serverAliases = new Set([server, normalizeMcpPermissionSegment(server)])
360
+ for (const alias of serverAliases) permissions[`${alias}_*`] = 'deny'
361
+ }
362
+
363
+ for (const server of servers) {
364
+ const tools = Object.hasOwn(mcpTools, server) ? mcpTools[server] : []
365
+ const serverAliases = new Set([server, normalizeMcpPermissionSegment(server)])
366
+ if (tools === 'all') {
367
+ for (const alias of serverAliases) permissions[`${alias}_*`] = 'allow'
368
+ continue
369
+ }
370
+
371
+ for (const tool of tools) {
372
+ const toolAliases = new Set([tool, normalizeMcpPermissionSegment(tool)])
373
+ for (const serverAlias of serverAliases) {
374
+ for (const toolAlias of toolAliases) {
375
+ permissions[`${serverAlias}_${toolAlias}`] = 'allow'
376
+ }
377
+ }
378
+ }
379
+ }
380
+
381
+ return permissions
382
+ }
383
+
64
384
  function deepClone(value) {
65
385
  return JSON.parse(JSON.stringify(value))
66
386
  }
67
387
 
68
388
  /**
69
389
  * (Re)generate the code-mode manifest with SHA-256 digests for every bundled
70
- * script. This is the explicit installation opt-in: the MCP server only
71
- * executes scripts listed here whose on-disk hash matches. Missing or empty
72
- * directories approve nothing (0) — execution is never implicit.
390
+ * script, and write it to the destination. This is the explicit installation
391
+ * opt-in: the MCP server only executes scripts listed here whose on-disk hash
392
+ * matches. Missing or empty source approves nothing — execution is never
393
+ * implicit.
394
+ *
395
+ * This function is the SINGLE owner of `manifest.json`: `syncDir` deliberately
396
+ * excludes it (see the runtime block), so exactly one writer and one stats
397
+ * count exist for that path. The payload is derived from the SOURCE scripts
398
+ * (what will be installed), then compared against the CURRENT installed
399
+ * manifest, so the preview action matches what apply writes — including on a
400
+ * script upgrade, where the installed manifest is stale.
401
+ *
402
+ * @returns {{ approved: number, status: 'created'|'skipped'|'conflict'|null }}
73
403
  */
74
- function writeCodeModeManifest(dir, dryRun) {
75
- if (!existsSync(dir)) return 0
404
+ function writeCodeModeManifest(srcDir, dstDir, dryRun, recorder = null) {
405
+ if (!existsSync(srcDir)) return { approved: 0, status: null }
76
406
  let names
77
407
  try {
78
- names = readdirSync(dir)
408
+ names = readdirSync(srcDir)
79
409
  .filter((name) => /\.(sh|py)$/i.test(name) && !name.startsWith('.'))
80
410
  .sort()
81
411
  } catch {
82
- return 0
412
+ return { approved: 0, status: null }
83
413
  }
84
- if (names.length === 0) return 0
414
+ if (names.length === 0) return { approved: 0, status: null }
85
415
  const scripts = {}
86
416
  for (const name of names) {
87
417
  scripts[name] = createHash('sha256')
88
- .update(readFileSync(join(dir, name)))
418
+ .update(readFileSync(join(srcDir, name)))
89
419
  .digest('hex')
90
420
  }
91
421
  const payload = `${JSON.stringify({ version: 1, scripts }, null, 2)}\n`
92
- writeIfChanged(join(dir, 'manifest.json'), payload, dryRun)
93
- return names.length
422
+ const status = writeIfChanged(join(dstDir, 'manifest.json'), payload, dryRun, { recorder })
423
+ return { approved: names.length, status }
94
424
  }
95
425
 
96
426
  function readJsonConfig(filePath) {
@@ -103,6 +433,27 @@ function readJsonConfig(filePath) {
103
433
  }
104
434
  }
105
435
 
436
+ /**
437
+ * Read a JSON config AND the exact raw text it was parsed from.
438
+ *
439
+ * The raw text is threaded to {@link writeConfigWithBackup} as the
440
+ * expected-content guard, so the compare-before-rename uses the SAME bytes the
441
+ * caller parsed — no second read that could observe a different file.
442
+ *
443
+ * @param {string} filePath
444
+ * @returns {{ config: object, raw: string|null }}
445
+ * @throws {Error} on invalid JSON (before any write happens)
446
+ */
447
+ function readJsonConfigWithRaw(filePath) {
448
+ if (!existsSync(filePath)) return { config: {}, raw: null }
449
+ const raw = readFileSync(filePath, 'utf8')
450
+ try {
451
+ return { config: JSON.parse(raw), raw }
452
+ } catch (cause) {
453
+ throw new Error(`Invalid JSON in ${filePath}`, { cause })
454
+ }
455
+ }
456
+
106
457
  function mergeMissing(target, source) {
107
458
  if (!source || typeof source !== 'object' || Array.isArray(source)) return target
108
459
  if (!target || typeof target !== 'object' || Array.isArray(target)) return deepClone(source)
@@ -131,8 +482,8 @@ function mergeMissing(target, source) {
131
482
  }
132
483
 
133
484
  /**
134
- * Resolve a plugin ref from the packaged opencode.json to a path INSIDE the
135
- * installed package.
485
+ * Resolve a plugin ref from the repository-root opencode.json to a path INSIDE
486
+ * the installed package.
136
487
  *
137
488
  * Only exact Pantheon plugin identities are resolved into the installed
138
489
  * package. In particular, a third-party absolute path ending in
@@ -169,6 +520,7 @@ const PANTHEON_V2_PLUGIN = 'src/plugin-v2'
169
520
  const PANTHEON_V2_LEGACY_FILE = 'src/plugin-v2.ts'
170
521
  const PANTHEON_V1_EXPORT = 'pantheon-opencode/plugin'
171
522
  const PANTHEON_V2_EXPORT = 'pantheon-opencode/plugin-v2'
523
+ const PANTHEON_PACKAGE_REF = /^pantheon-opencode(?:@[^/]+)?(?:\/(plugin|plugin-v2))?$/
172
524
  const PANTHEON_PLUGIN_IDENTITIES = new Set([
173
525
  PANTHEON_V1_PLUGIN,
174
526
  PANTHEON_V1_HOOKS,
@@ -197,6 +549,17 @@ function managedPluginIdentity(ref) {
197
549
  const normalized = normalizePluginRef(ref)
198
550
  if (typeof normalized !== 'string') return null
199
551
 
552
+ // Older installs registered the package root itself (often pinned to a
553
+ // beta version) in OpenCode's plural `plugins` list. Treat all exact npm
554
+ // specs for this package as managed so an update removes the old cached
555
+ // copy before registering the current package path. Do not match similarly
556
+ // named third-party packages such as `pantheon-opencode-fork`.
557
+ const packageRef = normalized.match(PANTHEON_PACKAGE_REF)
558
+ if (packageRef !== null) {
559
+ if (packageRef[1] === 'plugin') return PANTHEON_V1_PLUGIN
560
+ return PANTHEON_V2_PLUGIN
561
+ }
562
+
200
563
  if (PANTHEON_PLUGIN_IDENTITIES.has(normalized)) return normalized
201
564
  if (normalized === PANTHEON_V2_LEGACY_FILE) return PANTHEON_V2_PLUGIN
202
565
  if (normalized === PANTHEON_V1_EXPORT || normalized.startsWith(`${PANTHEON_V1_EXPORT}@`)) {
@@ -275,6 +638,71 @@ export function deduplicatePluginReferences(refs) {
275
638
  })
276
639
  }
277
640
 
641
+ /**
642
+ * Duplicate the V1 top-level `context_sandbox` block into the exact Pantheon V2
643
+ * plugin entry's `options`, converting a plain-string entry into the
644
+ * `{ package, options }` object form the host accepts.
645
+ *
646
+ * Why the copy is required: OpenCode V2.0.25 silently drops a top-level
647
+ * `context_sandbox` key (probe evidence in the module header of
648
+ * config-migration.mjs), so the V1 setting would be lost on a V1→V2 install.
649
+ * The V1 top-level block is LEFT IN PLACE so a downgrade keeps working.
650
+ *
651
+ * Precedence is delegated to {@link mergeContextSandboxBlocks}: an explicit V2
652
+ * option leaf wins, the legacy block fills only unset leaves, and the runtime
653
+ * resolver supplies defaults. Only the Pantheon V2 entry is touched — every
654
+ * other plugin entry, and every other option on this entry, is preserved.
655
+ *
656
+ * The managed V2 option is ALWAYS validated — even when no legacy V1 block is
657
+ * present — because the migration is the only place that validates it. An
658
+ * earlier version returned early on `legacySandbox === undefined`, so an
659
+ * explicit `plugins[].options.context_sandbox` of `null` (or any type-invalid
660
+ * known value) was never checked and persisted straight to disk.
661
+ *
662
+ * @param {unknown[]} plugins - V2 `plugins` array (mutated in place)
663
+ * @param {unknown} legacySandbox - V1 top-level `context_sandbox` (may be undefined)
664
+ * @returns {unknown[]} the same array
665
+ * @throws {Error} when the legacy block or the existing V2 option is null or
666
+ * type-invalid (checked before any config write)
667
+ */
668
+ export function applyContextSandboxToV2Plugins(plugins, legacySandbox) {
669
+ if (!Array.isArray(plugins)) return plugins
670
+
671
+ const index = plugins.findIndex((ref) => pluginReferenceIdentity(ref) === PANTHEON_V2_PLUGIN)
672
+ if (index === -1) return plugins
673
+
674
+ const entry = plugins[index]
675
+ const isObjectEntry = typeof entry === 'object' && entry !== null && !Array.isArray(entry)
676
+ const hasOptions =
677
+ isObjectEntry &&
678
+ typeof entry.options === 'object' &&
679
+ entry.options !== null &&
680
+ !Array.isArray(entry.options)
681
+ const existingOptions = hasOptions ? entry.options : {}
682
+ const hasExistingSandbox = Object.hasOwn(existingOptions, 'context_sandbox')
683
+
684
+ // Nothing managed to migrate OR validate: leave the entry untouched so a
685
+ // re-install stays a true no-op. `legacySandbox === undefined` covers an
686
+ // absent V1 key; `!hasExistingSandbox` covers a V2 entry with no managed
687
+ // option yet. An explicit `null` is a PRESENT key (`hasExistingSandbox`), so
688
+ // it is deliberately NOT skipped and must be validated below.
689
+ if (legacySandbox === undefined && !hasExistingSandbox) return plugins
690
+
691
+ // Run the managed option through the validator unconditionally, merging the
692
+ // legacy block in only when it exists. A null/type-invalid existing option
693
+ // throws here, BEFORE the config is written.
694
+ const mergedSandbox = mergeContextSandboxBlocks(existingOptions.context_sandbox, legacySandbox)
695
+ const packageId = isObjectEntry ? entry.package : entry
696
+
697
+ // Rebuild the entry as an object so the options survive a V2 host (which
698
+ // preserves plugin option objects verbatim).
699
+ plugins[index] = {
700
+ package: packageId,
701
+ options: { ...existingOptions, context_sandbox: mergedSandbox },
702
+ }
703
+ return plugins
704
+ }
705
+
278
706
  export { parseOpenCodeVersion, resolveOpenCodeVersion } from './opencode-version.mjs'
279
707
 
280
708
  /**
@@ -301,15 +729,14 @@ export function isGlobalConfigDir(target) {
301
729
  * package; the loader must NEVER be pointed at that in-package dir (a package
302
730
  * reinstall/upgrade replaces the dir under a live registration and leaves
303
731
  * diverging copies loading). Instead the installer always copies it into the
304
- * config dir's plugins/ dir and registers THAT copy:
732
+ * config dir's plugins/ dir and uses THAT copy:
305
733
  *
306
- * <configDir>/plugins/pantheon-tui (dist/ + package.json + index.tsx)
734
+ * <configDir>/plugins/pantheon-tui (tui.ts + dist/ + package manifests)
307
735
  *
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)
736
+ * V1 registers that copy through tui.json. V2 discovers the local plugin's
737
+ * index.ts + tui.ts pair; the V2 shim re-exports dist/tui-v2.js.
311
738
  *
312
- * @param {string} configDir - directory holding tui.json (target for global
739
+ * @param {string} configDir - OpenCode config directory (target for global
313
740
  * installs, <target>/.opencode for project installs)
314
741
  * @returns {string} absolute path of the copied plugin directory
315
742
  */
@@ -335,42 +762,41 @@ export function tuiConfigLocations(target) {
335
762
  }
336
763
 
337
764
  /**
338
- * Sync the Pantheon TUI plugin registration across EVERY tui.json location
339
- * OpenCode reads.
765
+ * Sync the Pantheon TUI plugin payload and V1 registration. V2 discovers the
766
+ * copied local-plugin entrypoints and does not use a project tui.json.
340
767
  *
341
768
  * 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.
769
+ * 1. ALWAYS copy src/plugins/tui → <configDir>/plugins/pantheon-tui with the
770
+ * selected host entrypoints and compiled bundle. The copy is idempotent.
347
771
  * 2. Clean stale Pantheon TUI refs from ALL tui.json locations OpenCode
348
772
  * loads — ~/.opencode, $XDG_CONFIG_HOME/opencode and
349
773
  * <target>/.opencode — covering every old reference shape: dist file
350
774
  * paths, in-package src/plugins/tui dirs, bare plugins/pantheon-tui
351
775
  * copies, and npx paths. A stale ref left in the non-target location
352
776
  * 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.
777
+ * 3. V1 registers the copied directory in the selected target. V2 relies on
778
+ * local-plugin discovery and does not write a project tui.json entry.
355
779
  *
356
780
  * @param {string} target - install target directory
357
781
  * @param {object} [opts]
358
782
  * @param {boolean} [opts.isGlobal=false] - flat global layout vs .opencode/
359
783
  * @param {boolean} [opts.dryRun=false]
784
+ * @param {(change: object) => void} [opts.recorder] - optional change recorder for preview
360
785
  * @returns {'created'|'skipped'} status for the target registration
361
786
  */
362
- export function syncTuiRegistration(target, { isGlobal = false, dryRun = false } = {}) {
787
+ export function syncTuiRegistration(
788
+ target,
789
+ { isGlobal = false, dryRun = false, version = 'v1', recorder = null } = {},
790
+ ) {
363
791
  const configDir = isGlobal ? target : join(target, '.opencode')
364
792
  const targetTuiConfigPath = join(configDir, 'tui.json')
365
793
 
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.
794
+ // 1. Copy the host-specific plugin into the target config's
795
+ // plugins/pantheon-tui. This keeps the installed entry hermetic.
370
796
  const tuiSrcDir = join(ROOT, 'src', 'plugins', 'tui')
371
797
  const tuiCopyDir = resolveTuiCopyTarget(configDir)
372
798
  if (existsSync(tuiSrcDir)) {
373
- copyPluginFiles(tuiSrcDir, tuiCopyDir, { dryRun })
799
+ copyPluginFiles(tuiSrcDir, tuiCopyDir, { dryRun, version, recorder })
374
800
  }
375
801
 
376
802
  // 2. Clean stale Pantheon TUI refs from every location OpenCode reads.
@@ -383,11 +809,17 @@ export function syncTuiRegistration(target, { isGlobal = false, dryRun = false }
383
809
  unregisterPlugin(tuiConfigPath, 'plugins/pantheon-tui', {
384
810
  dryRun,
385
811
  knownPantheonPaths: [ownedCopy, tuiCopyDir],
812
+ recorder,
386
813
  })
387
814
  }
388
815
 
816
+ // OpenCode V2 discovers local plugins from the config plugins directories;
817
+ // its TUI plugin API is exposed by the sibling tui.ts entrypoint. V1 still
818
+ // requires the explicit tui.json registration.
819
+ if (version === 'v2') return 'skipped'
820
+
389
821
  // 3. Register the copied directory in the selected target only.
390
- return registerPlugin(targetTuiConfigPath, tuiCopyDir, { dryRun })
822
+ return registerPlugin(targetTuiConfigPath, tuiCopyDir, { dryRun, recorder })
391
823
  }
392
824
 
393
825
  /**
@@ -396,13 +828,18 @@ export function syncTuiRegistration(target, { isGlobal = false, dryRun = false }
396
828
  * MCP entries from the config (entries pointing at a missing venv would
397
829
  * make every MCP fail to launch). `deps` is injectable for tests.
398
830
  */
399
- export function setupRuntimePhase(target, { dryRun, clean, isGlobal }, stats, deps = {}) {
831
+ export function setupRuntimePhase(
832
+ target,
833
+ { dryRun, clean, isGlobal, recorder = null },
834
+ stats,
835
+ deps = {},
836
+ ) {
400
837
  const S = strings()
401
838
  const setup = deps.setupVenv ?? setupVenv
402
839
  const check = deps.healthCheck ?? healthCheck
403
840
  try {
404
841
  const venvSpinner = spinner(S.settingUpRuntime)
405
- setup(target, { dryRun, force: clean })
842
+ setup(target, { dryRun, force: clean, recorder })
406
843
  venvSpinner(true)
407
844
  const runtimeTarget = isGlobal ? target : join(target, '.opencode')
408
845
  const health = check(runtimeTarget, { dryRun, pythonTarget: target })
@@ -429,12 +866,206 @@ export function setupRuntimePhase(target, { dryRun, clean, isGlobal }, stats, de
429
866
  }
430
867
  }
431
868
 
869
+ /**
870
+ * Write the five managed MCP server entries into `config`, healing any stale
871
+ * entrypoint paths first.
872
+ *
873
+ * The heal runs on EVERY install, not only when an entry is missing: the
874
+ * per-server guards below are add-only, so an existing install whose
875
+ * `opencode.json` still carries a pre-rename `*_server.py` path would never be
876
+ * repaired. Healing first means a re-run of the installer fixes an orphaned
877
+ * config in place — the migration that makes the rename non-breaking.
878
+ *
879
+ * @param {object} config - full config object (mutated in place)
880
+ * @param {{ runtimeTarget: string, venvPython: string }} ctx
881
+ * @returns {{ healed: number }} number of stale path strings rewritten
882
+ */
883
+ export function applyMcpServerEntries(config, { runtimeTarget, venvPython }) {
884
+ config.mcp = config.mcp || {}
885
+ // MCP stays a flat named-server map for BOTH V1 and V2: OpenCode 1.18.x has
886
+ // no `mcp.servers` wrapper and rejects one with "Missing key
887
+ // mcp.servers.enabled". config-migration.mjs normalizes any legacy
888
+ // wrapper-shaped entries back to the flat map.
889
+ const healed = migrateMcpEntrypointPaths(config.mcp)
890
+
891
+ // Only add if not already configured by user
892
+ if (!config.mcp['pantheon-resources']) {
893
+ config.mcp['pantheon-resources'] = {
894
+ type: 'local',
895
+ cwd: runtimeTarget,
896
+ command: [venvPython, 'scripts/mcp_resources.py'],
897
+ enabled: true,
898
+ }
899
+ }
900
+ if (!config.mcp['pantheon-code-mode']) {
901
+ config.mcp['pantheon-code-mode'] = {
902
+ type: 'local',
903
+ cwd: runtimeTarget,
904
+ command: [venvPython, 'scripts/code_mode.py'],
905
+ enabled: true,
906
+ }
907
+ }
908
+ if (!config.mcp['pantheon-memory']) {
909
+ config.mcp['pantheon-memory'] = {
910
+ type: 'local',
911
+ cwd: runtimeTarget,
912
+ command: [venvPython, 'scripts/memory_mcp.py'],
913
+ enabled: true,
914
+ }
915
+ }
916
+ if (!config.mcp['pantheon-persistence']) {
917
+ config.mcp['pantheon-persistence'] = {
918
+ type: 'local',
919
+ cwd: runtimeTarget,
920
+ command: [venvPython, 'scripts/mcp_persistence.py'],
921
+ enabled: true,
922
+ }
923
+ }
924
+ if (!config.mcp['pantheon-vision']) {
925
+ config.mcp['pantheon-vision'] = {
926
+ type: 'local',
927
+ cwd: runtimeTarget,
928
+ command: [venvPython, 'scripts/pantheon_vision.py'],
929
+ enabled: true,
930
+ }
931
+ }
932
+
933
+ // Default MCP permissions
934
+ config.permission = config.permission || {}
935
+ config.permission.mcp = config.permission.mcp || {}
936
+ if (!config.permission.mcp['pantheon-resources']) {
937
+ config.permission.mcp['pantheon-resources'] = 'allow'
938
+ }
939
+ if (!config.permission.mcp['pantheon-code-mode']) {
940
+ config.permission.mcp['pantheon-code-mode'] = 'ask'
941
+ }
942
+ if (!config.permission.mcp['pantheon-memory']) {
943
+ config.permission.mcp['pantheon-memory'] = 'allow'
944
+ }
945
+ if (!config.permission.mcp['pantheon-persistence']) {
946
+ config.permission.mcp['pantheon-persistence'] = 'allow'
947
+ }
948
+ if (!config.permission.mcp['pantheon-vision']) {
949
+ config.permission.mcp['pantheon-vision'] = 'ask'
950
+ }
951
+
952
+ return { healed }
953
+ }
954
+
955
+ /**
956
+ * Phase 4 drift detection: the planned targets whose on-disk state changed
957
+ * between the plan pass (snapshots) and the apply pass. A path counts as drifted
958
+ * when its existence, content hash, size, or mode differs — which includes a
959
+ * planned-NEW path that now exists. Returns the absolute paths (empty = no
960
+ * drift); callers print only these paths, never contents or hashes.
961
+ */
962
+ function detectSnapshotDrift(snapshots) {
963
+ if (!Array.isArray(snapshots)) return []
964
+ const changed = []
965
+ for (const before of snapshots) {
966
+ const now = snapshot(before.path)
967
+ if (
968
+ now.exists !== before.exists ||
969
+ now.sha256 !== before.sha256 ||
970
+ now.size !== before.size ||
971
+ now.mode !== before.mode
972
+ ) {
973
+ changed.push(before.path)
974
+ }
975
+ }
976
+ return changed
977
+ }
978
+
979
+ /**
980
+ * Remove lingering atomic-write temp siblings (`<file>.tmp-<pid>`) left behind
981
+ * when a writer aborted mid-write. Best-effort; skips the bulk dirs so a failure
982
+ * path stays cheap.
983
+ */
984
+ /**
985
+ * Remove lingering atomic-write temp siblings (`<file>.tmp-<pid>`) left when a
986
+ * writer aborted mid-write. Only files matching the installer's own temp
987
+ * pattern are removed, and the bulk/foreign dirs are skipped so a failure path
988
+ * stays cheap and never touches unrelated files.
989
+ */
990
+ function cleanupTempSiblings(root) {
991
+ if (!root) return
992
+ const suffix = `.tmp-${process.pid}`
993
+ const skip = new Set(['.git', '.pantheon', 'node_modules', '.venv'])
994
+ const walk = (dir) => {
995
+ let entries
996
+ try {
997
+ entries = readdirSync(dir, { withFileTypes: true })
998
+ } catch {
999
+ return
1000
+ }
1001
+ for (const entry of entries) {
1002
+ if (entry.isDirectory()) {
1003
+ if (skip.has(entry.name)) continue
1004
+ walk(join(dir, entry.name))
1005
+ } else if (entry.isFile() && entry.name.endsWith(suffix)) {
1006
+ try {
1007
+ rmSync(join(dir, entry.name), { force: true })
1008
+ } catch {
1009
+ // best-effort cleanup
1010
+ }
1011
+ }
1012
+ }
1013
+ }
1014
+ walk(root)
1015
+ }
1016
+
1017
+ /**
1018
+ * Phase 5 failure semantics. `installPass` is the whole installer; this thin
1019
+ * wrapper reports a mid-apply failure HONESTLY: it aborts the remaining writes
1020
+ * (the throw), cleans up any lingering atomic-write temp siblings, reports the
1021
+ * PATHS already applied (never contents), prints the recovery path, and
1022
+ * re-throws so the CLI exits non-zero. There is NO automatic rollback — the
1023
+ * exact backup set + `pantheon-opencode restore` is the recovery.
1024
+ *
1025
+ * Progress is tracked in `_lastRun` (set only by a non-dry pass, so the nested
1026
+ * dry plan pass never clobbers it). The RESOLVED target is read back from
1027
+ * `_lastRun.target` so the report and the sweep work when the caller passed
1028
+ * `undefined` (the default global install resolves to `~/.config/opencode`).
1029
+ */
1030
+ let _lastRun = null
1031
+
432
1032
  export async function installOpenCode(
433
1033
  target,
434
1034
  dryRun = false,
435
1035
  clean = false,
436
1036
  components = ['agents', 'skills', 'instructions', 'commands', 'plugins', 'runtime'],
437
1037
  opts = {},
1038
+ ) {
1039
+ // Reset per-call so a stale run can never leak into a fresh failure report.
1040
+ if (!dryRun) _lastRun = null
1041
+ try {
1042
+ return await installPass(target, dryRun, clean, components, opts)
1043
+ } catch (err) {
1044
+ const run = dryRun ? null : _lastRun
1045
+ if (run) {
1046
+ const S = strings()
1047
+ const paths = summarizeChanges(run.entries ?? [])
1048
+ .files.filter((f) => f.action !== ACTIONS.UNCHANGED && f.action !== ACTIONS.CONFLICT)
1049
+ .map((f) => displayPath(run.target, f.path))
1050
+ error(S.applyFailed(err instanceof Error ? err.message : String(err)))
1051
+ if (paths.length > 0) {
1052
+ warning(S.alreadyApplied)
1053
+ for (const p of paths) warning(` ${p}`)
1054
+ }
1055
+ warning(run.backupDir ? S.restoreRecoveryHint(run.backupDir) : S.restoreNoBackup)
1056
+ // Sweep only on the failure path (never a full walk on a clean apply).
1057
+ cleanupTempSiblings(run.target)
1058
+ }
1059
+ throw err
1060
+ }
1061
+ }
1062
+
1063
+ async function installPass(
1064
+ target,
1065
+ dryRun = false,
1066
+ clean = false,
1067
+ components = ['agents', 'skills', 'instructions', 'commands', 'plugins', 'runtime'],
1068
+ opts = {},
438
1069
  ) {
439
1070
  // Default target if not provided (global ~/.config/opencode)
440
1071
  if (!target) {
@@ -443,19 +1074,69 @@ export async function installOpenCode(
443
1074
 
444
1075
  const componentSet = new Set(components)
445
1076
  const stats = summary.opencode
1077
+ // Per-call baseline. `stats` is the module-level cumulative summary, so the
1078
+ // consistency guard must compare against THIS call's delta, not the total.
1079
+ const statsBefore = { created: stats.created, skipped: stats.skipped }
1080
+ // Number of planned changes, filled by a dry plan pass (used by the gate).
1081
+ let plannedChanges = 0
1082
+ // Phase 4: planned-target snapshots from the plan pass, used for drift checks.
1083
+ let plannedSnapshots = null
446
1084
  const S = strings()
447
1085
 
1086
+ // Beta 11 (Setup phase 1): a dry pass records every writer's intended change
1087
+ // so we can print the REAL per-file plan without writing anything. A recorder
1088
+ // ALWAYS runs now: a dry pass builds the preview/plan from it, and an apply
1089
+ // pass uses it to report which PATHS were already written if a writer throws
1090
+ // (Phase 5 failure semantics). The default is the module-level no-op recorder,
1091
+ // so an uninstrumented caller stays byte-identical.
1092
+ const ownRecorder = opts.recorder ? null : createRecorder()
1093
+ const preview = dryRun && ownRecorder ? ownRecorder : null
1094
+ let recorder = opts.recorder ?? ownRecorder.recorder
1095
+ // Test-only failure seam: throw after N recorded writes so the failure path is
1096
+ // deterministic. Never reachable from production callers (opts.deps is not a
1097
+ // CLI flag) and never applied to the internal dry plan pass.
1098
+ if (!dryRun && typeof opts.deps?.failAfter === 'number') {
1099
+ const base = recorder
1100
+ const limit = opts.deps.failAfter
1101
+ let applied = 0
1102
+ recorder = (change) => {
1103
+ base(change)
1104
+ if (++applied >= limit) throw new Error('injected failure (test-only failAfter seam)')
1105
+ }
1106
+ }
1107
+ // Track this apply so the wrapper can report the paths written on a failure.
1108
+ if (!dryRun) _lastRun = { target, entries: ownRecorder?.entries ?? [], backupDir: null }
1109
+
448
1110
  // V1/V2 dual-version awareness (Phase 3): the installer builds a unified
449
1111
  // config, then applies V2-native migration (migrateV1toV2) when
450
1112
  // --opencode-version=v2. V2 normalizes V1 fields in memory (no rewrite) and
451
1113
  // isolates its state DB via OPENCODE_DB (~/.local/share/opencode/opencode-v2.db)
452
1114
  // with a distinct service port (49375 vs V1's 49374).
453
- const version = resolveOpenCodeVersion(opts.version ?? 'v1')
1115
+ // Default to 'auto' so a flagless run resolves against the actual host. A
1116
+ // hardcoded 'v1' here (or in bin/pantheon-init.mjs) is what shipped the bug:
1117
+ // the V1 branch writes .ts file paths that a 2.x host drops from the config.
1118
+ // `opts` is forwarded as the resolver's options bag so a caller (and CI) can
1119
+ // pin env/probe instead of spawning a real host binary.
1120
+ const version = resolveOpenCodeVersion(opts.version ?? 'auto', opts)
454
1121
  if (version === 'v2') {
455
1122
  info(
456
1123
  'OpenCode V2 target — shared config; state isolated via OPENCODE_DB ' +
457
1124
  '(~/.local/share/opencode/opencode-v2.db), service port 49375',
458
1125
  )
1126
+ // Say what the V2 contract does NOT give you, at the moment the user picks
1127
+ // it. The V2 surface is 3 tools (hashline_edit, pantheon_cost,
1128
+ // pantheon_model); V1's is 6 plus the BackgroundJobBoard, the three goal
1129
+ // tools and the caller/target delegation matrix. This list was previously
1130
+ // reachable only through `getUnsupportedFeatures()`, which no production
1131
+ // code called — a user migrating hit the reduction with no warning at all.
1132
+ //
1133
+ // The markers are read from the same seed `getUnsupportedFeatures()` is
1134
+ // built from (src/pantheon/v2-unsupported.mjs), so this notice cannot drift
1135
+ // from what the plugin reports. Advisory: a V2 install is a supported
1136
+ // configuration, not a failure.
1137
+ info(
1138
+ `V2 plugin generation — reduced surface. Unsupported features: ${listV2UnsupportedFeatures().join(', ')}`,
1139
+ )
459
1140
  }
460
1141
 
461
1142
  // Interactive mode handling
@@ -474,7 +1155,7 @@ export async function installOpenCode(
474
1155
 
475
1156
  if (!result.confirmed && !autoYes) {
476
1157
  info('Installation canceled.')
477
- return stats
1158
+ return { ...stats, applied: false, reason: 'canceled' }
478
1159
  }
479
1160
 
480
1161
  // Map target selection to actual path
@@ -487,10 +1168,24 @@ export async function installOpenCode(
487
1168
  }
488
1169
  }
489
1170
 
490
- const pantheonConfigPath = join(ROOT, 'opencode.json')
1171
+ // Product defaults live in code (PRODUCT_CONFIG_DEFAULTS). A repository-root
1172
+ // opencode.json is a contributor's PERSONAL dev harness, not product data, so
1173
+ // it must never be read just because it happens to exist: doing so silently
1174
+ // injects the developer's provider/plugins/compaction into every config an
1175
+ // install generates. The merge is therefore opt-in only, via
1176
+ // --merge-dev-config <path>. Absent the flag, no root-level config is read.
1177
+ const mergeDevConfigOpt = opts.mergeDevConfig ?? null
1178
+ if (mergeDevConfigOpt !== null && typeof mergeDevConfigOpt !== 'string') {
1179
+ throw new Error('mergeDevConfig requires a file path string')
1180
+ }
1181
+ const pantheonConfigPath =
1182
+ typeof mergeDevConfigOpt === 'string' ? resolve(mergeDevConfigOpt) : null
491
1183
  const targetConfigPath = join(target, 'opencode.json')
492
- const config = readJsonConfig(targetConfigPath)
493
- const pantheonConfig = readJsonConfig(pantheonConfigPath)
1184
+ const { config, raw: configRaw } = readJsonConfigWithRaw(targetConfigPath)
1185
+ const pantheonConfig =
1186
+ pantheonConfigPath !== null && existsSync(pantheonConfigPath)
1187
+ ? readJsonConfig(pantheonConfigPath)
1188
+ : null
494
1189
 
495
1190
  // beta.5 preflight: fail BEFORE any files are written when the runtime
496
1191
  // component needs toolchain pieces that are missing. Nothing has been
@@ -503,16 +1198,116 @@ export async function installOpenCode(
503
1198
  }
504
1199
  }
505
1200
 
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/
1201
+ // V1 project installs use the legacy .opencode/agents layout. OpenCode V2's
1202
+ // active config directory is the project root and its agent loader scans
1203
+ // <config-dir>/{agent,agents}/**, so V2 project agents must be at ./agents.
1204
+ // Global installs are flat for both versions.
511
1205
  const isGlobal = isGlobalConfigDir(target)
512
1206
  const _subDir = isGlobal ? '' : '.opencode'
513
- const agentPrefix = isGlobal ? 'agents' : '.opencode/agents'
1207
+ const agentPrefix = isGlobal || version === 'v2' ? 'agents' : '.opencode/agents'
1208
+
1209
+ configure({ dryRun, quiet: opts.quiet === true })
1210
+
1211
+ // -----------------------------------------------------------------------
1212
+ // Plan pass + apply gate + drift check (Beta 11 Setup phases 3-4).
1213
+ // * the plan pass (a dry run — writes nothing) prints the plan and
1214
+ // snapshots every planned target;
1215
+ // * applying requires explicit authorization (Phase 3): a declined or
1216
+ // unauthorized run writes NOTHING;
1217
+ // * drift (Phase 4) aborts BEFORE any write if a planned target changed
1218
+ // between the plan pass and now.
1219
+ // A result with `applied: false` means the caller must not print success.
1220
+ // -----------------------------------------------------------------------
1221
+ const applyRequested = !dryRun
1222
+ if (applyRequested) {
1223
+ const savedStats = { ...stats }
1224
+ let planned
1225
+ try {
1226
+ // Use the LIVE selection: the wizard above may have changed componentSet.
1227
+ // An AUTHORIZED apply (`--yes`) is not a preview: compute the plan +
1228
+ // snapshots but stay silent. The gate path (no `--yes`) still prints the
1229
+ // plan for the review / decline preview.
1230
+ planned = await installOpenCode(target, true, clean, [...componentSet], {
1231
+ ...opts,
1232
+ planOnly: true,
1233
+ quiet: autoYes,
1234
+ quietPlan: autoYes,
1235
+ })
1236
+ } finally {
1237
+ Object.assign(stats, savedStats)
1238
+ // The plan pass left the UI in dry-run mode; restore this run's mode.
1239
+ configure({ dryRun, quiet: opts.quiet === true })
1240
+ }
1241
+ plannedSnapshots = planned?.snapshots ?? null
1242
+
1243
+ if (!autoYes) {
1244
+ const injected = opts.deps?.promptReview
1245
+ const canPrompt = typeof injected === 'function' || interactive
1246
+ if (canPrompt) {
1247
+ const { promptReview } = await import('./interactive.mjs')
1248
+ const ask = typeof injected === 'function' ? injected : promptReview
1249
+ const accepted = await ask(`Apply these ${planned?.planned ?? 0} changes?`, {
1250
+ default: false,
1251
+ })
1252
+ if (!accepted) {
1253
+ info(S.nothingApplied)
1254
+ return { ...stats, applied: false, reason: 'declined' }
1255
+ }
1256
+ } else {
1257
+ info(S.planOnlyNoYes)
1258
+ return { ...stats, applied: false, reason: 'not-authorized' }
1259
+ }
1260
+ }
514
1261
 
515
- configure({ dryRun })
1262
+ // Test seam: mutate a target between the plan/authorization and the drift
1263
+ // check. Absent in production callers.
1264
+ if (typeof opts.deps?.betweenPlanAndApply === 'function') opts.deps.betweenPlanAndApply()
1265
+
1266
+ const drifted = detectSnapshotDrift(plannedSnapshots)
1267
+ if (drifted.length > 0) {
1268
+ warning(S.driftHeader)
1269
+ for (const p of drifted) warning(` ${displayPath(target, p)}`)
1270
+ warning(S.driftAbort)
1271
+ return {
1272
+ ...stats,
1273
+ applied: false,
1274
+ reason: 'drift',
1275
+ drift: drifted.map((p) => displayPath(target, p)),
1276
+ }
1277
+ }
1278
+ }
1279
+
1280
+ // -----------------------------------------------------------------------
1281
+ // 0. Exact backup (Beta 11 Setup phase 2): preserve the CURRENT state of
1282
+ // every regular file this run can modify/remove INSIDE the config dir,
1283
+ // byte-for-byte, BEFORE the first modifying write. Bulk directories and
1284
+ // out-of-target global files are recorded in the manifest as
1285
+ // `notRevertible`. A dry run records the planned backup-manifest path only.
1286
+ // -----------------------------------------------------------------------
1287
+ const backupManifestPath = join(
1288
+ backupSetPath(target, nextBackupName(target, new Date())),
1289
+ 'manifest.json',
1290
+ )
1291
+ if (dryRun) {
1292
+ if (isRecording(recorder)) {
1293
+ recordChange(
1294
+ { path: backupManifestPath, action: ACTIONS.CREATE, bytes: 0, scored: false },
1295
+ recorder,
1296
+ )
1297
+ }
1298
+ } else {
1299
+ const backupPlan = collectInstallBackupEntries(target, componentSet, version, isGlobal)
1300
+ const pkgVersion = JSON.parse(readFileSync(join(ROOT, 'package.json'), 'utf8')).version
1301
+ const { dir: backupDir } = createBackupSet({
1302
+ configDir: target,
1303
+ entries: backupPlan.entries,
1304
+ notRevertible: backupPlan.notRevertible,
1305
+ packageVersion: pkgVersion,
1306
+ })
1307
+ if (_lastRun && _lastRun.target === target) _lastRun.backupDir = backupDir
1308
+ // Secret-safe: the path only. The exact wording is fixed (once per apply).
1309
+ console.warn(backupWarning(backupDir))
1310
+ }
516
1311
 
517
1312
  if (isGlobal) {
518
1313
  info(S.globalLayout)
@@ -529,7 +1324,7 @@ export async function installOpenCode(
529
1324
  stats.errors++
530
1325
  } else {
531
1326
  const agentStep = step(S.installingAgents)
532
- const dstDir = isGlobal ? join(target, 'agents') : join(target, '.opencode', 'agents')
1327
+ const dstDir = join(target, agentPrefix)
533
1328
  if (!dryRun) mkdirSync(dstDir, { recursive: true })
534
1329
  if (clean && existsSync(dstDir) && !dryRun) {
535
1330
  const existing = readdirSync(dstDir)
@@ -537,7 +1332,7 @@ export async function installOpenCode(
537
1332
  rmSync(join(dstDir, f), { recursive: true, force: true })
538
1333
  }
539
1334
  }
540
- const { created, skipped } = copyFiles(srcDir, dstDir, dryRun)
1335
+ const { created, skipped } = copyAgentPrompts(srcDir, dstDir, dryRun, false, recorder)
541
1336
  stats.created += created
542
1337
  stats.skipped += skipped
543
1338
  agentStep(true)
@@ -551,7 +1346,9 @@ export async function installOpenCode(
551
1346
  const srcRouting = join(ROOT, 'src', 'routing.yml')
552
1347
  const dstRouting = join(target, 'routing.yml')
553
1348
  if (existsSync(srcRouting)) {
554
- const routingStatus = writeIfChanged(dstRouting, readFileSync(srcRouting, 'utf8'), dryRun)
1349
+ const routingStatus = writeIfChanged(dstRouting, readFileSync(srcRouting, 'utf8'), dryRun, {
1350
+ recorder,
1351
+ })
555
1352
  if (routingStatus === 'created') stats.created++
556
1353
  else stats.skipped++
557
1354
  }
@@ -578,6 +1375,7 @@ export async function installOpenCode(
578
1375
  target,
579
1376
  dryRun,
580
1377
  installSubDir,
1378
+ recorder,
581
1379
  )
582
1380
  stats.created += sCreated
583
1381
  stats.skipped += sSkipped
@@ -586,23 +1384,45 @@ export async function installOpenCode(
586
1384
  }
587
1385
 
588
1386
  // -----------------------------------------------------------------------
589
- // 2.5 Install instructions: AGENTS.md only (--components instructions)
1387
+ // 2.5 Install shared instructions: AGENTS.md (--components instructions)
590
1388
  // -----------------------------------------------------------------------
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).
1389
+ // Both AGENTS.md artifacts are managed regions (scripts/build-agents-md.mjs).
1390
+ // A GLOBAL config dir receives the repo-agnostic global artifact (agent
1391
+ // table + shared rules); a PROJECT layout keeps the repository artifact.
1392
+ // writeAgentsMdManaged is conservative: content outside the managed region
1393
+ // is never deleted, and a legacy fully-generated file upgrades in place.
596
1394
  if (componentSet.has('instructions')) {
597
1395
  section('\uD83D\uDCCB Instructions')
598
- // AGENTS.md
599
- const srcAgentsMd = join(ROOT, 'AGENTS.md')
1396
+ const srcAgentsMd = isGlobal ? join(ROOT, 'AGENTS.global.md') : join(ROOT, 'AGENTS.md')
600
1397
  const dstAgentsMd = join(target, 'AGENTS.md')
601
1398
  if (existsSync(srcAgentsMd)) {
602
1399
  const content = readFileSync(srcAgentsMd, 'utf8')
603
- const status = writeIfChanged(dstAgentsMd, content, dryRun)
604
- if (status === 'created') stats.created++
605
- else stats.skipped++
1400
+ const status = writeAgentsMdManaged(dstAgentsMd, content, dryRun, recorder)
1401
+ if (status === 'skipped') stats.skipped++
1402
+ else {
1403
+ stats.created++
1404
+ if (status === 'merged') {
1405
+ warning(
1406
+ `AGENTS.md had non-generated content — it was preserved around the ` +
1407
+ `Pantheon managed region in ${dstAgentsMd}. Review and merge manually.`,
1408
+ )
1409
+ }
1410
+ }
1411
+ } else {
1412
+ warning(`AGENTS.md artifact not found in the package: ${srcAgentsMd}`)
1413
+ stats.errors++
1414
+ }
1415
+ // An instruction-only upgrade must also refresh selective guidance on
1416
+ // already-installed Pantheon agents, but must not create agents the user
1417
+ // did not install. Full agent installs do this in phase 1 above.
1418
+ if (!componentSet.has('agents')) {
1419
+ const installedAgents = join(target, agentPrefix)
1420
+ const sourceAgents = join(ROOT, 'src', 'agents')
1421
+ if (existsSync(installedAgents) && existsSync(sourceAgents)) {
1422
+ const refreshed = copyAgentPrompts(sourceAgents, installedAgents, dryRun, true, recorder)
1423
+ stats.created += refreshed.created
1424
+ stats.skipped += refreshed.skipped
1425
+ }
606
1426
  }
607
1427
  }
608
1428
 
@@ -614,7 +1434,7 @@ export async function installOpenCode(
614
1434
  const srcPrompts = join(ROOT, 'prompts')
615
1435
  const dstPrompts = join(target, 'prompts')
616
1436
  if (existsSync(srcPrompts)) {
617
- const promptsResult = syncDir(srcPrompts, dstPrompts, dryRun, clean)
1437
+ const promptsResult = syncDir(srcPrompts, dstPrompts, dryRun, clean, null, recorder)
618
1438
  stats.created += promptsResult.created
619
1439
  stats.skipped += promptsResult.skipped
620
1440
  }
@@ -626,9 +1446,13 @@ export async function installOpenCode(
626
1446
  if (componentSet.has('commands')) {
627
1447
  section('\u26A1 Commands')
628
1448
  const srcCmds = join(ROOT, 'commands')
629
- const dstCmds = isGlobal ? join(target, 'commands') : join(target, '.opencode', 'commands')
1449
+ // V2 project sandboxes use OPENCODE_CONFIG_DIR=<project root>, so OpenCode
1450
+ // discovers custom commands from <target>/commands. V1 project installs
1451
+ // keep the standard .opencode/commands layout; global installs are flat.
1452
+ const commandDir = isGlobal || version === 'v2' ? 'commands' : '.opencode/commands'
1453
+ const dstCmds = join(target, commandDir)
630
1454
  if (existsSync(srcCmds)) {
631
- const cmdResult = syncDir(srcCmds, dstCmds, dryRun, clean, (f) => f.endsWith('.md'))
1455
+ const cmdResult = syncDir(srcCmds, dstCmds, dryRun, clean, (f) => f.endsWith('.md'), recorder)
632
1456
  stats.created += cmdResult.created
633
1457
  stats.skipped += cmdResult.skipped
634
1458
  }
@@ -651,7 +1475,12 @@ export async function installOpenCode(
651
1475
  const dstPluginDir = isGlobal
652
1476
  ? join(target, 'plugins', 'pantheon-tui')
653
1477
  : join(target, '.opencode', 'plugins', 'pantheon-tui')
654
- const pluginStats = installPlugin(srcPluginDir, dstPluginDir, { dryRun, clean })
1478
+ const pluginStats = installPlugin(srcPluginDir, dstPluginDir, {
1479
+ dryRun,
1480
+ clean,
1481
+ version,
1482
+ recorder,
1483
+ })
655
1484
  stats.created += pluginStats.created
656
1485
  stats.skipped += pluginStats.skipped
657
1486
  stats.errors += pluginStats.errors
@@ -663,7 +1492,7 @@ export async function installOpenCode(
663
1492
  if (componentSet.has('plugins')) {
664
1493
  let tuiStatus
665
1494
  try {
666
- tuiStatus = syncTuiRegistration(target, { isGlobal, dryRun })
1495
+ tuiStatus = syncTuiRegistration(target, { isGlobal, dryRun, version, recorder })
667
1496
  } catch (cause) {
668
1497
  error(`TUI plugin setup failed: ${cause.message}`)
669
1498
  stats.errors++
@@ -682,31 +1511,21 @@ export async function installOpenCode(
682
1511
  const runtimeTarget = isGlobal ? target : join(target, '.opencode')
683
1512
 
684
1513
  // ── MCP server scripts ──
685
- const mcpScripts = [
686
- 'mcp_resources_server.py',
687
- 'code_mode_server.py',
688
- 'memory_mcp_server.py',
689
- 'scrub-secrets.py',
690
- '_pantheon_paths.py',
691
- 'mcp_codemap_module.py',
692
- 'mcp_persistence_server.py',
693
- 'pantheon_vision_server.py',
694
- 'eval_store.py',
695
- ]
1514
+ const mcpScripts = MCP_SCRIPTS
696
1515
  const srcScriptsDir = join(ROOT, 'scripts')
697
1516
  // Canonical MCP server sources live in src/mcp/. Map them explicitly so the
698
1517
  // sync always copies from the canonical source, never from the stale root
699
1518
  // scripts/ copies (which previously propagated bugs like the missing
700
- // `import uuid` in mcp_persistence_server.py). scrub-secrets.py is not in
1519
+ // `import uuid` in mcp_persistence.py). scrub-secrets.py is not in
701
1520
  // src/mcp/ and intentionally falls back to join(ROOT, 'scripts').
702
1521
  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'),
1522
+ 'mcp_resources.py': join(ROOT, 'src', 'mcp', 'mcp_resources.py'),
1523
+ 'code_mode.py': join(ROOT, 'src', 'mcp', 'code_mode.py'),
1524
+ 'memory_mcp.py': join(ROOT, 'src', 'mcp', 'memory_mcp.py'),
706
1525
  '_pantheon_paths.py': join(ROOT, 'src', 'mcp', '_pantheon_paths.py'),
707
1526
  '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'),
1527
+ 'mcp_persistence.py': join(ROOT, 'src', 'mcp', 'mcp_persistence.py'),
1528
+ 'pantheon_vision.py': join(ROOT, 'src', 'mcp', 'pantheon_vision.py'),
710
1529
  'eval_store.py': join(ROOT, 'src', 'mcp', 'eval_store.py'),
711
1530
  }
712
1531
  const dstScriptsDir = join(runtimeTarget, 'scripts')
@@ -720,7 +1539,7 @@ export async function installOpenCode(
720
1539
  }
721
1540
  const content = readFileSync(src, 'utf8')
722
1541
  const dst = join(dstScriptsDir, script)
723
- const status = writeIfChanged(dst, content, dryRun)
1542
+ const status = writeIfChanged(dst, content, dryRun, { recorder })
724
1543
  if (status === 'created') {
725
1544
  stats.created++
726
1545
  // Make executable
@@ -738,7 +1557,9 @@ export async function installOpenCode(
738
1557
  const visionRequirements = join(ROOT, 'src', 'mcp', 'requirements-vision.txt')
739
1558
  if (existsSync(visionRequirements)) {
740
1559
  const destination = join(runtimeTarget, 'requirements-vision.txt')
741
- const status = writeIfChanged(destination, readFileSync(visionRequirements, 'utf8'), dryRun)
1560
+ const status = writeIfChanged(destination, readFileSync(visionRequirements, 'utf8'), dryRun, {
1561
+ recorder,
1562
+ })
742
1563
  if (status === 'created') stats.created++
743
1564
  else stats.skipped++
744
1565
  } else {
@@ -750,7 +1571,17 @@ export async function installOpenCode(
750
1571
  const srcCodeModeDir = join(ROOT, '.pantheon', 'code-mode')
751
1572
  const dstCodeModeDir = join(runtimeTarget, '.pantheon', 'code-mode')
752
1573
  if (existsSync(srcCodeModeDir)) {
753
- const cmResult = syncDir(srcCodeModeDir, dstCodeModeDir, dryRun, clean)
1574
+ // manifest.json is owned by writeCodeModeManifest (below), not by the dir
1575
+ // sync. Excluding it here keeps exactly ONE writer/count for that path,
1576
+ // so the preview action and the install summary can never diverge.
1577
+ const cmResult = syncDir(
1578
+ srcCodeModeDir,
1579
+ dstCodeModeDir,
1580
+ dryRun,
1581
+ clean,
1582
+ (f) => f !== 'manifest.json',
1583
+ recorder,
1584
+ )
754
1585
  stats.created += cmResult.created
755
1586
  stats.skipped += cmResult.skipped
756
1587
  } else {
@@ -761,13 +1592,18 @@ export async function installOpenCode(
761
1592
  warning(`Code-mode source not found: ${srcCodeModeDir} — creating empty ${dstCodeModeDir}`)
762
1593
  if (!dryRun) mkdirSync(dstCodeModeDir, { recursive: true })
763
1594
  stats.created++
1595
+ // The empty runtime dir is a real create counted by `stats.created`, so
1596
+ // record it too (or the preview under-reports on this fallback path).
1597
+ recordChange({ path: dstCodeModeDir, action: ACTIONS.CREATE, bytes: 0 }, recorder)
764
1598
  }
765
1599
 
766
1600
  // B3-08: explicit installation mode — seed the manifest with SHA-256
767
1601
  // digests so the server never executes a script implicitly.
768
- const approvedScripts = writeCodeModeManifest(dstCodeModeDir, dryRun)
769
- if (approvedScripts > 0) {
770
- info(`Code-mode manifest: ${approvedScripts} script(s) approved`)
1602
+ const manifest = writeCodeModeManifest(srcCodeModeDir, dstCodeModeDir, dryRun, recorder)
1603
+ if (manifest.status === 'created') stats.created++
1604
+ else if (manifest.status === 'skipped') stats.skipped++
1605
+ if (manifest.approved > 0) {
1606
+ info(`Code-mode manifest: ${manifest.approved} script(s) approved`)
771
1607
  }
772
1608
 
773
1609
  // ── tiers.json ──
@@ -775,7 +1611,7 @@ export async function installOpenCode(
775
1611
  const dstTiers = join(runtimeTarget, '.pantheon', 'tiers.json')
776
1612
  if (existsSync(srcTiers)) {
777
1613
  const content = readFileSync(srcTiers, 'utf8')
778
- const status = writeIfChanged(dstTiers, content, dryRun)
1614
+ const status = writeIfChanged(dstTiers, content, dryRun, { recorder })
779
1615
  if (status === 'created') stats.created++
780
1616
  else stats.skipped++
781
1617
  }
@@ -789,7 +1625,7 @@ export async function installOpenCode(
789
1625
  // -----------------------------------------------------------------------
790
1626
  let runtimeHealthy = !componentSet.has('runtime')
791
1627
  if (componentSet.has('runtime')) {
792
- runtimeHealthy = setupRuntimePhase(target, { dryRun, clean, isGlobal }, stats)
1628
+ runtimeHealthy = setupRuntimePhase(target, { dryRun, clean, isGlobal, recorder }, stats)
793
1629
  }
794
1630
 
795
1631
  // -----------------------------------------------------------------------
@@ -798,7 +1634,7 @@ export async function installOpenCode(
798
1634
  // on top. Preserves user's MCP, provider, plugins, compaction, theme.
799
1635
  // -----------------------------------------------------------------------
800
1636
  // --------------------------------------------------------------------
801
- // A. Parse canonical agent config from agents/*.agent.md frontmatter
1637
+ // A. Parse canonical agent config from src/agents/*.md frontmatter
802
1638
  // and merge into opencode.json config.
803
1639
  // --------------------------------------------------------------------
804
1640
  function getAgentSources(agentPrefix) {
@@ -836,7 +1672,6 @@ export async function installOpenCode(
836
1672
  if (fm.mode) agent.mode = fm.mode
837
1673
  if (fm.hidden) agent.hidden = fm.hidden
838
1674
  if (fm.temperature !== undefined) agent.temperature = fm.temperature
839
- if (fm.steps !== undefined) agent.steps = fm.steps
840
1675
  // Support both hyphen (YAML) and underscore (JSON) key variants
841
1676
  if (fm['disable-model-invocation'] !== undefined)
842
1677
  agent.disable_model_invocation = fm['disable-model-invocation']
@@ -847,6 +1682,12 @@ export async function installOpenCode(
847
1682
  if (fm.permission) {
848
1683
  agent.permission = JSON.parse(JSON.stringify(fm.permission))
849
1684
  }
1685
+ if (fm.mcp_tools !== undefined) {
1686
+ agent.permission = {
1687
+ ...(agent.permission ?? {}),
1688
+ ...deriveNativeMcpPermissions(fm.mcp_tools),
1689
+ }
1690
+ }
850
1691
 
851
1692
  config[name] = agent
852
1693
  }
@@ -855,15 +1696,6 @@ export async function installOpenCode(
855
1696
 
856
1697
  const canonicalAgentConfig = readAgentConfigFromCanonical()
857
1698
  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
1699
 
868
1700
  if (config.agent === undefined) config.agent = {}
869
1701
 
@@ -922,8 +1754,11 @@ export async function installOpenCode(
922
1754
  // --------------------------------------------------------------------
923
1755
  // B.5 Ensure critical top-level OpenCode config sections
924
1756
  // --------------------------------------------------------------------
925
- if (config.default_agent === undefined && pantheonConfig.default_agent !== undefined) {
926
- config.default_agent = pantheonConfig.default_agent
1757
+ // Seeded from code, not from a packaged template: see
1758
+ // PRODUCT_CONFIG_DEFAULTS. Only fills a gap — a user who already chose a
1759
+ // default agent keeps it.
1760
+ if (config.default_agent === undefined) {
1761
+ config.default_agent = PRODUCT_CONFIG_DEFAULTS.default_agent
927
1762
  }
928
1763
  // Apply only explicit top-level model overrides. Existing values remain
929
1764
  // untouched and an install without either flag leaves both fields absent.
@@ -965,8 +1800,9 @@ export async function installOpenCode(
965
1800
  // --------------------------------------------------------------------
966
1801
  // B.5.1 Hermetic plugin resolution (packaging fix)
967
1802
  // --------------------------------------------------------------------
968
- // The packaged opencode.json may reference plugins via developer-machine
969
- // absolute paths (e.g. <dev>/pantheon/.../src/plugin.ts or
1803
+ // A developer running from a checkout has a repository-root opencode.json
1804
+ // that may reference plugins via developer-machine absolute paths (e.g.
1805
+ // <dev>/pantheon/.../src/plugin.ts or
970
1806
  // <dev>/pantheon/.../src/plugins/pantheon-hooks.ts). Copying those
971
1807
  // verbatim into the user's config would break every global install on any
972
1808
  // other machine. resolveInstalledPlugin() rewrites each entry to the plugin
@@ -985,7 +1821,7 @@ export async function installOpenCode(
985
1821
  if (config.plugin === undefined) config.plugin = []
986
1822
  if (Array.isArray(config.plugin)) {
987
1823
  config.plugin = removePantheonPluginReferences(config.plugin)
988
- if (Array.isArray(pantheonConfig.plugin)) {
1824
+ if (Array.isArray(pantheonConfig?.plugin)) {
989
1825
  for (const plugin of pantheonConfig.plugin) {
990
1826
  if (!isPantheonPluginReference(plugin) && !hasPluginReference(config.plugin, plugin)) {
991
1827
  config.plugin.push(deepClone(plugin))
@@ -1014,8 +1850,20 @@ export async function installOpenCode(
1014
1850
  config.plugin = removePantheonPluginReferences(config.plugin)
1015
1851
  }
1016
1852
  if (!Array.isArray(config.plugins)) config.plugins = []
1853
+ // Preserve any options already set on the Pantheon V2 entry across the
1854
+ // remove/re-add below. The context_sandbox migration reads them, and a
1855
+ // silent drop would lose a hand-set sandbox limit or any other plugin
1856
+ // option the user chose. Only the object form can carry options.
1857
+ const existingV2Options =
1858
+ config.plugins
1859
+ .filter((ref) => pluginReferenceIdentity(ref) === PANTHEON_V2_PLUGIN)
1860
+ .map((ref) =>
1861
+ typeof ref === 'object' && ref !== null && !Array.isArray(ref) ? ref.options : null,
1862
+ )
1863
+ .find((options) => options && typeof options === 'object' && !Array.isArray(options)) ??
1864
+ null
1017
1865
  config.plugins = deduplicatePluginReferences(removePantheonPluginReferences(config.plugins))
1018
- if (Array.isArray(pantheonConfig.plugins)) {
1866
+ if (Array.isArray(pantheonConfig?.plugins)) {
1019
1867
  for (const plugin of pantheonConfig.plugins) {
1020
1868
  if (!isPantheonPluginReference(plugin) && !hasPluginReference(config.plugins, plugin)) {
1021
1869
  config.plugins.push(deepClone(plugin))
@@ -1038,36 +1886,75 @@ export async function installOpenCode(
1038
1886
  config.plugins = config.plugins.filter(
1039
1887
  (plugin) => pluginReferenceIdentity(plugin) !== pluginReferenceIdentity(resolvedV2),
1040
1888
  )
1041
- config.plugins.push(resolvedV2)
1889
+ config.plugins.push(
1890
+ existingV2Options === null
1891
+ ? resolvedV2
1892
+ : { package: resolvedV2, options: deepClone(existingV2Options) },
1893
+ )
1042
1894
  }
1043
1895
 
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
- }
1896
+ if (pantheonConfig !== null) {
1897
+ if (config.provider === undefined && pantheonConfig.provider !== undefined) {
1898
+ config.provider = deepClone(pantheonConfig.provider)
1899
+ } else if (config.provider !== undefined && pantheonConfig.provider !== undefined) {
1900
+ mergeMissing(config.provider, pantheonConfig.provider)
1901
+ }
1049
1902
 
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)
1903
+ if (config.compaction === undefined && pantheonConfig.compaction !== undefined) {
1904
+ config.compaction = deepClone(pantheonConfig.compaction)
1905
+ } else if (config.compaction !== undefined && pantheonConfig.compaction !== undefined) {
1906
+ mergeMissing(config.compaction, pantheonConfig.compaction)
1907
+ }
1054
1908
  }
1055
1909
 
1056
1910
  // --------------------------------------------------------------------
1057
1911
  // C. Merge permissions
1058
1912
  // --------------------------------------------------------------------
1059
1913
  if (config.permission === undefined) config.permission = {}
1060
- if (pantheonConfig.permission !== undefined) {
1061
- mergeMissing(config.permission, pantheonConfig.permission)
1062
- }
1063
1914
  if (
1064
1915
  typeof config.permission === 'object' &&
1065
1916
  config.permission !== null &&
1066
1917
  !Array.isArray(config.permission)
1067
1918
  ) {
1068
- if (componentSet.has('skills')) {
1919
+ // Fill-if-absent, never overwrite. Pantheon's skills cannot be invoked
1920
+ // without this grant, so an install that just installed them seeds it —
1921
+ // but a user who deliberately set `permission.skill` (to `deny`, or to a
1922
+ // partial map of their own) keeps their choice. This was an unconditional
1923
+ // assignment nine lines below a comment that said "never overwrite", which
1924
+ // silently flipped an explicit deny to allow.
1925
+ //
1926
+ // Gated on the skills component AND absent-only, which is the whole point:
1927
+ // `skill` is a capability grant for something this install just wrote to
1928
+ // disk. A runtime-only or agents-only install has nothing to grant, so it
1929
+ // must not seed the key. It used to anyway, via the unconditional
1930
+ // PRODUCT_CONFIG_DEFAULTS.permission merge that sat above this block —
1931
+ // which also made this gate dead code.
1932
+ if (componentSet.has('skills') && config.permission.skill === undefined) {
1069
1933
  config.permission.skill = { '*': 'allow' }
1070
1934
  }
1935
+ // These five are the commands Pantheon's OWN agents must be able to run
1936
+ // unattended to do their job: version control, package execution, and the
1937
+ // test/lint pair every implementation agent reaches for. That is the whole
1938
+ // bar for a grant made on every install.
1939
+ //
1940
+ // Anything beyond it is the installing user's judgement about which
1941
+ // commands to trust on their machine, to be made in THEIR config — not ours
1942
+ // to grant silently on their behalf. The personal dev allowlist this grew
1943
+ // out of was the same category of decision, one layer further out of view.
1944
+ //
1945
+ // Specifically NOT granted, and why:
1946
+ // - `curl` — arbitrary network egress; the widest grant on the list and
1947
+ // not required to install or run anything here.
1948
+ // - `docker` — host/daemon access; a container runtime is not an
1949
+ // installation requirement.
1950
+ // - `gh` — third-party account access on someone's behalf; a GitHub CLI
1951
+ // login is never something an installer may assume.
1952
+ // - `pip`, `black`, `make` — ordinary toolchain commands with no Pantheon
1953
+ // agent dependency; users who want them can add them.
1954
+ //
1955
+ // NOTE: this grant is NOT gated on the skills component — unlike
1956
+ // permission.skill above, agents are installed by the agents component and
1957
+ // the test/lint commands are needed whichever components were selected.
1071
1958
  if (config.permission.bash === undefined) {
1072
1959
  config.permission.bash = {
1073
1960
  'git *': 'allow',
@@ -1075,12 +1962,6 @@ export async function installOpenCode(
1075
1962
  'npx *': 'allow',
1076
1963
  'pytest *': 'allow',
1077
1964
  'ruff *': 'allow',
1078
- 'black *': 'allow',
1079
- 'pip *': 'allow',
1080
- 'docker *': 'allow',
1081
- 'curl *': 'allow',
1082
- 'gh *': 'allow',
1083
- 'make *': 'allow',
1084
1965
  }
1085
1966
  }
1086
1967
  }
@@ -1090,8 +1971,8 @@ export async function installOpenCode(
1090
1971
  // --------------------------------------------------------------------
1091
1972
  // AGENTS.md is the single instruction file both V1 and V2 load (V1
1092
1973
  // 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`
1974
+ // contains shared rules; agent-scoped rules are in the installed agent
1975
+ // prompts. The `instructions/*.md`
1095
1976
  // glob is no longer needed — keeping it would duplicate content under V1
1096
1977
  // and is ignored under V2 anyway. Only ensure AGENTS.md is present.
1097
1978
  const pantheonInstructions = ['AGENTS.md']
@@ -1137,82 +2018,18 @@ export async function installOpenCode(
1137
2018
  // at a venv that does not exist would make every MCP fail to launch.
1138
2019
  // --------------------------------------------------------------------
1139
2020
  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
2021
+ // P1-3: derive the MCP python from the SAME venv layout setupVenv creates
2022
+ // (<target>/.venv via venvPythonPath). For project installs the runtime
2023
+ // payload lives under <target>/.opencode (runtimeTarget), but the venv is
2024
+ // created at <target>/.venv — pointing MCP commands at
1149
2025
  // runtimeTarget/.venv would reference an executable that never exists.
1150
- const runtimeTarget = isGlobal ? target : join(target, '.opencode')
1151
- const venvPython = venvPythonPath(target)
1152
2026
  // Point the config at the venv even on a first install; setupVenv runs
1153
2027
  // 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
- }
2028
+ const runtimeTarget = isGlobal ? target : join(target, '.opencode')
2029
+ applyMcpServerEntries(config, {
2030
+ runtimeTarget,
2031
+ venvPython: venvPythonPath(target),
2032
+ })
1216
2033
  }
1217
2034
 
1218
2035
  // ── V2 native migration ──
@@ -1222,10 +2039,53 @@ export async function installOpenCode(
1222
2039
  if (version === 'v2') {
1223
2040
  info(S.v2Migrating)
1224
2041
  configToWrite = migrateV1toV2(config)
2042
+
2043
+ // Pantheon no longer sets a step ceiling. Any `steps` sitting on a managed
2044
+ // agent was written by an earlier install and never chosen by the user —
2045
+ // the merge above is additive by design (no delete path), so drop it here
2046
+ // and let the host default apply. This runs after the migration because
2047
+ // migrateV1toV2 copies the V1 `agent` block across verbatim (including any
2048
+ // stale `steps`), which is exactly how the value reappears in V2.
2049
+ // User-defined agents are out of scope and keep their value; the V1
2050
+ // singular `agent` block is deliberately untouched (V1 is retiring).
2051
+ const v2Agents = configToWrite.agents
2052
+ if (v2Agents && typeof v2Agents === 'object' && !Array.isArray(v2Agents)) {
2053
+ for (const name of Object.keys(canonicalAgentConfig)) {
2054
+ const entry = v2Agents[name]
2055
+ if (entry && typeof entry === 'object' && !Array.isArray(entry)) delete entry.steps
2056
+ }
2057
+ }
2058
+
2059
+ // Carry the V1 top-level `context_sandbox` into the exact Pantheon V2
2060
+ // plugin entry's `options` — V2.0.25 silently drops the top-level key, so
2061
+ // without this the user's enabled/limits choice is lost on a V1→V2 install.
2062
+ // The V1 block itself is preserved by migrateV1toV2 for a downgrade.
2063
+ // A null/type-invalid block throws HERE, before any config write.
2064
+ configToWrite.plugins = applyContextSandboxToV2Plugins(
2065
+ configToWrite.plugins,
2066
+ config.context_sandbox,
2067
+ )
1225
2068
  }
1226
2069
 
1227
2070
  const configContent = `${JSON.stringify(configToWrite, null, 2)}\n`
1228
- const status = writeConfigWithBackup(targetConfigPath, configContent, dryRun)
2071
+ const status = writeConfigWithBackup(targetConfigPath, configContent, dryRun, {
2072
+ expectedContent: configRaw,
2073
+ // Test seam: lets a test simulate a concurrent writer landing between the
2074
+ // initial config read and the pre-swap recheck. Absent in production.
2075
+ beforeRecheck: opts.deps?.beforeRecheck,
2076
+ recorder,
2077
+ })
2078
+ if (status === 'conflict') {
2079
+ // The user's config changed under the installer between the read and the
2080
+ // swap. Treating this as an ordinary skipped write would report a
2081
+ // successful install over a file we never actually updated — fail loudly
2082
+ // and leave the target byte-identical instead.
2083
+ throw new Error(
2084
+ `Installation aborted: ${targetConfigPath} changed on disk during install ` +
2085
+ '(it no longer matches the content that was read); refusing to overwrite it. ' +
2086
+ 'Re-run the installer once the file is stable.',
2087
+ )
2088
+ }
1229
2089
  if (status === 'created') stats.created++
1230
2090
  else stats.skipped++
1231
2091
 
@@ -1235,7 +2095,7 @@ export async function installOpenCode(
1235
2095
  if (componentSet.has('runtime')) {
1236
2096
  const currentVersion = detectVersion(target)
1237
2097
  if (currentVersion) {
1238
- const migration = runMigrations(target, currentVersion, { dryRun })
2098
+ const migration = runMigrations(target, currentVersion, { dryRun, recorder })
1239
2099
  if (migration.applied > 0) {
1240
2100
  success(`Applied ${migration.applied} migration(s)`)
1241
2101
  for (const msg of migration.messages) {
@@ -1245,6 +2105,56 @@ export async function installOpenCode(
1245
2105
  }
1246
2106
  }
1247
2107
 
2108
+ // -----------------------------------------------------------------------
2109
+ // 3.75 Preview summary (Beta 11 Setup phase 1): print the REAL per-file
2110
+ // change set this same pass would apply. Paths + action + byte size
2111
+ // ONLY — never file contents or hashes. Nothing was written.
2112
+ // -----------------------------------------------------------------------
2113
+ if (preview) {
2114
+ // Apply always writes the install-state file (terminal step); record the
2115
+ // planned state-only write here so the printed plan is complete. Unscored:
2116
+ // it is bookkeeping, not part of the install summary.
2117
+ recordChange(
2118
+ {
2119
+ path: join(target, '.pantheon', 'install-state.json'),
2120
+ action: hasState(target) ? ACTIONS.UPDATE : ACTIONS.CREATE,
2121
+ bytes: 0,
2122
+ scored: false,
2123
+ },
2124
+ recorder,
2125
+ )
2126
+ const plan = summarizeChanges(preview.entries)
2127
+ plannedChanges = plan.totals.create + plan.totals.update + plan.totals.remove
2128
+ // Phase 4: snapshot each planned target now, so the apply pass can detect
2129
+ // any change between plan and apply (drift) before the first write.
2130
+ plannedSnapshots = plan.files.map((f) => ({ path: f.path, ...snapshot(f.path) }))
2131
+ const statsDelta = {
2132
+ created: stats.created - statsBefore.created,
2133
+ skipped: stats.skipped - statsBefore.skipped,
2134
+ }
2135
+ const mismatch = planStatsMismatch(plan, statsDelta)
2136
+ if (mismatch) {
2137
+ // Development safety net: a writer that incremented THIS call's install
2138
+ // stats but never recorded a change would otherwise make the preview
2139
+ // silently incomplete. Surface it loudly instead of hiding it.
2140
+ warning(
2141
+ `Change-plan consistency mismatch: plan reports ${mismatch.changed} changed / ` +
2142
+ `${mismatch.unchanged} unchanged vs stats ${mismatch.recordedChanged}/${mismatch.recordedUnchanged}`,
2143
+ )
2144
+ }
2145
+ // Tag the plan like every other dry-run line so the output reads uniformly.
2146
+ // An internal snapshot pass on the apply path (`quietPlan`, e.g. an
2147
+ // authorized `--yes` run) computes the plan and snapshots but must NOT echo
2148
+ // it — a real apply is not a dry run.
2149
+ if (!opts.quietPlan) {
2150
+ console.log('')
2151
+ for (const line of formatChangePlan(target, plan).split('\n')) {
2152
+ console.log(tag(line))
2153
+ }
2154
+ console.log('')
2155
+ }
2156
+ }
2157
+
1248
2158
  // -----------------------------------------------------------------------
1249
2159
  // 4. (runtime setup moved to 2.11 — before the config write — so MCP
1250
2160
  // entries are only added when the Python runtime is actually healthy)
@@ -1284,7 +2194,7 @@ export async function installOpenCode(
1284
2194
  ` ${colors.dim('\u2022')} Run 'opencode' to start using Pantheon agents\n`,
1285
2195
  )
1286
2196
  process.stdout.write('\n')
1287
- } else {
2197
+ } else if (!opts.planOnly) {
1288
2198
  printSummary(target, ['opencode'], stats)
1289
2199
  }
1290
2200
 
@@ -1304,9 +2214,59 @@ export async function installOpenCode(
1304
2214
  writeActivePreset(target, opts.preset, { source: 'cli' })
1305
2215
  }
1306
2216
 
2217
+ // -----------------------------------------------------------------------
2218
+ // 5.5 Optional vision prompt (interactive TTY only). The default — automatic
2219
+ // discovery — writes nothing, and non-interactive flows (no TTY, --yes,
2220
+ // --headless, automated sandboxes) never reach this block, so no run
2221
+ // blocks on stdin and the default behavior is unchanged. A failure here
2222
+ // must never fail the install: vision stays optional.
2223
+ // -----------------------------------------------------------------------
2224
+ if (interactive && !dryRun && !autoYes) {
2225
+ const { runVisionPrompt } = await import('./vision-prompt.mjs')
2226
+ try {
2227
+ await runVisionPrompt({ logger: console })
2228
+ } catch (err) {
2229
+ warning(`Vision setup skipped: ${err instanceof Error ? err.message : String(err)}`)
2230
+ }
2231
+ }
2232
+
1307
2233
  // Fail-closed: accumulated stats.errors (bad sources, missing requirements,
1308
2234
  // failed health checks) must never masquerade as a successful installation.
1309
2235
  if (stats.errors > 0) {
1310
2236
  throw new Error(`installation completed with ${stats.errors} error(s); see output above`)
1311
2237
  }
2238
+
2239
+ // Record the component state only after the install has completed. The
2240
+ // postinstall marker can predate `init` and start with every component
2241
+ // pending; leaving it untouched made a healthy beta install look incomplete
2242
+ // to `doctor` forever. Unselected components retain their previous state.
2243
+ if (!dryRun) {
2244
+ const packageVersion = JSON.parse(readFileSync(join(ROOT, 'package.json'), 'utf8')).version
2245
+ let state = readState(target)
2246
+ if (!state && hasState(target)) {
2247
+ throw new Error('Installation state exists but cannot be read; refusing to replace it')
2248
+ }
2249
+ state ??= createInitialState(packageVersion)
2250
+ state.pantheon_version = packageVersion
2251
+ for (const component of ['agents', 'skills', 'plugins', 'runtime']) {
2252
+ if (!componentSet.has(component)) continue
2253
+ const status = component === 'runtime' && !runtimeHealthy ? 'failed' : 'installed'
2254
+ const componentVersion = state.components?.[component]?.version ?? 1
2255
+ setComponentState(state, component, componentVersion, status)
2256
+ }
2257
+ writeState(target, state)
2258
+ }
2259
+
2260
+ // Beta 11 Setup phase 3: report whether this call APPLIED, so the caller can
2261
+ // avoid a success banner for a plan-only / unauthorized run. The dry pass also
2262
+ // returns the planned-target snapshots for the apply pass's drift check.
2263
+ return dryRun
2264
+ ? {
2265
+ ...stats,
2266
+ applied: false,
2267
+ reason: 'plan',
2268
+ planned: plannedChanges,
2269
+ snapshots: plannedSnapshots,
2270
+ }
2271
+ : { ...stats, applied: true, reason: 'applied' }
1312
2272
  }