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
@@ -29,9 +29,10 @@ function deepClone(obj) {
29
29
  // V1 → V2 helpers
30
30
  // ---------------------------------------------------------------------------
31
31
 
32
- /** Action name mapping for permissions (V1 key → V2 action) */
32
+ /** Action name mapping for permissions (V1 key → V2 action), including task → subagent. */
33
33
  const V1_PERMISSION_TO_ACTION = {
34
34
  bash: 'shell',
35
+ task: 'subagent',
35
36
  skill: 'skill',
36
37
  edit: 'edit',
37
38
  websearch: 'websearch',
@@ -229,8 +230,11 @@ function convertProviderV2toV1(_name, config) {
229
230
  /**
230
231
  * Convert V1 MCP config to V2.
231
232
  *
232
- * V1: { bifrost: { type: "remote", url: "...", enabled: true, timeout: 30000 } }
233
- * V2 installer output: { bifrost: { type: "remote", url: "...", enabled: true } }
233
+ * The server names are arbitrary — a user calls theirs whatever they like, and
234
+ * which MCP servers exist is entirely their configuration, never ours.
235
+ *
236
+ * V1: { myserver: { type: "remote", url: "...", enabled: true, timeout: 30000 } }
237
+ * V2 installer output: { myserver: { type: "remote", url: "...", enabled: true } }
234
238
  *
235
239
  * OpenCode 1.18.18 does not have an `mcp.servers` wrapper. It interprets
236
240
  * `servers` as the name of an MCP server and then rejects it with
@@ -285,6 +289,81 @@ function convertMcpV2toV1(mcpConfig) {
285
289
  return convertMcpV1toV2(mcpConfig)
286
290
  }
287
291
 
292
+ // ---------------------------------------------------------------------------
293
+ // MCP entrypoint path migration
294
+ // ---------------------------------------------------------------------------
295
+
296
+ /**
297
+ * Entrypoint filename renames: every historical Python MCP entrypoint shared
298
+ * the `server.py` token, so a broad `pkill -f server.py` (a common idiom in
299
+ * agent and scratch tooling) matched the entire fleet and killed it as
300
+ * collateral. The five entrypoints now carry distinct tokens.
301
+ *
302
+ * The rename alone is a BREAKING change for existing installs: their
303
+ * `opencode.json` carries literal paths like `scripts/memory_mcp_server.py`,
304
+ * and a path that no longer resolves fails every MCP server silently (the
305
+ * same class of failure as the dead plugin surface). This map is the single
306
+ * source of truth for the stale → current basename rewrite that repairs them.
307
+ */
308
+ export const MCP_ENTRYPOINT_RENAMES = Object.freeze({
309
+ 'mcp_resources_server.py': 'mcp_resources.py',
310
+ 'code_mode_server.py': 'code_mode.py',
311
+ 'memory_mcp_server.py': 'memory_mcp.py',
312
+ 'mcp_persistence_server.py': 'mcp_persistence.py',
313
+ 'pantheon_vision_server.py': 'pantheon_vision.py',
314
+ })
315
+
316
+ /**
317
+ * Rewrite a single path/argument string when its basename is a renamed MCP
318
+ * entrypoint. The directory prefix is preserved verbatim, so both relative
319
+ * (`scripts/memory_mcp_server.py`) and absolute/hand-edited paths heal without
320
+ * discarding a user's chosen location. Non-matching values pass through.
321
+ *
322
+ * @param {string} value
323
+ * @returns {string}
324
+ */
325
+ function renameMcpEntrypointPath(value) {
326
+ if (typeof value !== 'string') return value
327
+ const slash = Math.max(value.lastIndexOf('/'), value.lastIndexOf('\\'))
328
+ const base = value.slice(slash + 1)
329
+ const renamed = MCP_ENTRYPOINT_RENAMES[base]
330
+ if (!renamed) return value
331
+ return value.slice(0, value.length - base.length) + renamed
332
+ }
333
+
334
+ /**
335
+ * Heal stale MCP entrypoint paths in a flat named-server map, in place.
336
+ *
337
+ * Scans every server — not only the `pantheon-*` keys — because a user who
338
+ * hand-edited their config may have renamed the key while keeping the
339
+ * Pantheon path. Both `command` (installer writes `[python, script]`) and
340
+ * `args` (the standalone installer writes `[script]`) are covered, since
341
+ * which array carries the script differs between installers.
342
+ *
343
+ * @param {object} mcp - the `mcp` map from a config (mutated in place)
344
+ * @returns {number} count of rewritten strings (0 = already current)
345
+ */
346
+ export function migrateMcpEntrypointPaths(mcp) {
347
+ if (!mcp || typeof mcp !== 'object' || Array.isArray(mcp)) return 0
348
+
349
+ let changed = 0
350
+ for (const server of Object.values(mcp)) {
351
+ if (!server || typeof server !== 'object') continue
352
+ for (const field of ['command', 'args']) {
353
+ const list = server[field]
354
+ if (!Array.isArray(list)) continue
355
+ for (let i = 0; i < list.length; i++) {
356
+ const next = renameMcpEntrypointPath(list[i])
357
+ if (next !== list[i]) {
358
+ list[i] = next
359
+ changed++
360
+ }
361
+ }
362
+ }
363
+ }
364
+ return changed
365
+ }
366
+
288
367
  // ---------------------------------------------------------------------------
289
368
  // Top-level key mappings
290
369
  // ---------------------------------------------------------------------------
@@ -304,6 +383,227 @@ const V2_TO_V1_RENAMES = Object.fromEntries(
304
383
  Object.entries(V1_TO_V2_RENAMES).map(([k, v]) => [v, k]),
305
384
  )
306
385
 
386
+ /**
387
+ * Agent fields owned by the framework. Mirrors the exported MANAGED_FIELDS in
388
+ * install/opencode.mjs plus `source`, which the installer writes next to them.
389
+ * The migration runs before that merge, so it has to apply the same precedence
390
+ * itself when both `agent` and `agents` coexist: these come from the V1 block,
391
+ * every other field stays the user's.
392
+ *
393
+ * `permissions` is the V2 name — the converter renames the V1 `permission`
394
+ * block before the merge runs.
395
+ *
396
+ * `description` is deliberately absent, and this list is deliberately narrower
397
+ * than what the installer writes. The installer writes a description only when
398
+ * it creates an agent (install/opencode.mjs, the new-agent branch); on an agent
399
+ * that already exists it leaves the value alone, under its own comment "Existing
400
+ * agent fields belong to the user". So a description is framework-owned for a
401
+ * NEW agent and user-owned for an EXISTING one. Listing it here would make the
402
+ * migration stricter than the installer and would overwrite a description the
403
+ * installer deliberately preserves. mergeManagedAgents carries the value across
404
+ * instead, so it survives the merge without being treated as framework-owned.
405
+ *
406
+ * KNOWN DEBT: the installer still writes the V1 spelling `permission`, while V2
407
+ * reads `permissions`. An agent that goes through this merge can therefore end
408
+ * up carrying both keys, with `permission` left as dead weight that nothing
409
+ * reads. Tracked for the next slice; not fixed here, because picking a winner
410
+ * changes what an install writes.
411
+ */
412
+ export const MANAGED_AGENT_FIELDS = Object.freeze([
413
+ 'source',
414
+ 'temperature',
415
+ 'color',
416
+ 'permissions',
417
+ 'mode',
418
+ 'hidden',
419
+ 'disable_model_invocation',
420
+ ])
421
+
422
+ /**
423
+ * Compare this module's managed-agent list against the installer's.
424
+ *
425
+ * The two merge paths have to agree: the migration reimplements the installer's
426
+ * merge because it runs before it. A field present on only one side is silent
427
+ * divergence — either the migration overwrites a field the installer leaves
428
+ * alone, or it stops mirroring one the installer now writes. Both directions
429
+ * matter, so the result is reported as a pair of differences rather than a
430
+ * boolean.
431
+ *
432
+ * Names are compared after the converter's renames, because that is the
433
+ * spelling the merge actually sees.
434
+ *
435
+ * @param {string[]} installerFields - MANAGED_FIELDS as exported by install/opencode.mjs
436
+ * @returns {{ onlyInMigration: string[], onlyInInstaller: string[] }}
437
+ */
438
+ export function managedAgentFieldDrift(installerFields) {
439
+ const toV2Name = (field) => V1_TO_V2_RENAMES[field] ?? field
440
+ const migration = new Set(MANAGED_AGENT_FIELDS.map(toV2Name))
441
+ const installer = new Set(installerFields.map(toV2Name))
442
+ return {
443
+ onlyInMigration: [...migration].filter((f) => !installer.has(f)).sort(),
444
+ onlyInInstaller: [...installer].filter((f) => !migration.has(f)).sort(),
445
+ }
446
+ }
447
+
448
+ /** @returns {boolean} true for a plain name→config map (not null/array) */
449
+ function isAgentMap(value) {
450
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
451
+ }
452
+
453
+ /**
454
+ * Merge the converted V1 `agent` block into an existing V2 `agents` base.
455
+ *
456
+ * Agents present in both are merged field by field: the base is kept, keys the
457
+ * base does not have are filled in from the V1 block, and MANAGED_AGENT_FIELDS
458
+ * are overwritten by the V1 values. Agents present in only one block are taken
459
+ * as they are — a union, not a replacement.
460
+ *
461
+ * Filling the gaps matters, and it is not description-specific: any key present
462
+ * only in the V1 block used to vanish here — `model`, `provider` and `mcp` too.
463
+ * `description` is the visible case, because the installer never writes one for
464
+ * an existing agent, so the V1 block is the only place that value can come from
465
+ * and the agent lost it on every install, permanently. Filling gaps can never
466
+ * destroy a base value; on conflict the base still wins, as before.
467
+ *
468
+ * @param {object} base - existing V2 `agents` map
469
+ * @param {object} managed - converted V1 `agent` map
470
+ * @returns {object} merged map
471
+ */
472
+ function mergeManagedAgents(base, managed) {
473
+ const merged = { ...base }
474
+
475
+ for (const [name, agentConfig] of Object.entries(managed)) {
476
+ const existing = merged[name]
477
+ if (!isAgentMap(existing) || !isAgentMap(agentConfig)) {
478
+ merged[name] = agentConfig
479
+ continue
480
+ }
481
+ const entry = { ...existing }
482
+ for (const [field, value] of Object.entries(agentConfig)) {
483
+ if (!(field in entry)) entry[field] = value
484
+ }
485
+ for (const field of MANAGED_AGENT_FIELDS) {
486
+ if (field in agentConfig) entry[field] = agentConfig[field]
487
+ }
488
+ merged[name] = entry
489
+ }
490
+
491
+ return merged
492
+ }
493
+
494
+ // ---------------------------------------------------------------------------
495
+ // context_sandbox: V1 top-level block → V2 plugin options
496
+ // ---------------------------------------------------------------------------
497
+ //
498
+ // `context_sandbox` is a Pantheon-owned, V1 top-level setting (src/plugin.ts
499
+ // reads `config.context_sandbox`). OpenCode V2 no longer carries custom
500
+ // top-level keys: a sandbox probe against V2.0.25 showed `context_sandbox`
501
+ // exits 0 but is SILENTLY DROPPED — identical to an arbitrary unknown key and
502
+ // with no warning — while a value nested under a plugin entry's `options`
503
+ // survives verbatim (plugin options are not schema-validated). The V2 adapter
504
+ // reads the setting as `ctx.options.context_sandbox`.
505
+ //
506
+ // So the migration DUPLICATES the V1 top-level block into the exact Pantheon V2
507
+ // plugin entry's options. The top-level block is deliberately LEFT IN PLACE so
508
+ // a user who downgrades to V1 keeps the setting. Precedence per leaf:
509
+ // explicit V2 plugin option > legacy top-level fill > runtime defaults
510
+ // (defaults are never written by the installer — the adapter resolves them).
511
+ //
512
+ // A null or type-invalid known field is not silently coerced: the merge throws
513
+ // BEFORE the config is written, so a malformed block aborts the install instead
514
+ // of persisting a value that changes runtime behaviour.
515
+
516
+ /** Tool-keyed leaf fields of `context_sandbox.limits`. */
517
+ export const CONTEXT_SANDBOX_LIMIT_FIELDS = Object.freeze({
518
+ read: Object.freeze(['maxLines', 'keepHead', 'keepTail']),
519
+ grep: Object.freeze(['maxResults', 'keepTop']),
520
+ glob: Object.freeze(['maxFiles', 'keepTop']),
521
+ webfetch: Object.freeze(['maxChars', 'keepHead']),
522
+ })
523
+
524
+ /** @returns {boolean} true for a plain name→value map (not null/array) */
525
+ function isPlainObject(value) {
526
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
527
+ }
528
+
529
+ /**
530
+ * Validate one `context_sandbox` block (top-level legacy or V2 plugin option).
531
+ *
532
+ * `undefined` (key absent) is valid. Anything else must be a plain object with
533
+ * an optional boolean `enabled` and an optional `limits` object whose known
534
+ * tool groups are plain objects carrying only non-negative finite numbers.
535
+ * Unknown keys are ignored (the runtime resolver does the same).
536
+ *
537
+ * @param {unknown} block
538
+ * @param {string} label - human-readable origin, used in the thrown message
539
+ * @throws {Error} on a null or type-invalid known field
540
+ */
541
+ function assertSandboxBlock(block, label) {
542
+ if (block === undefined) return
543
+ if (!isPlainObject(block)) {
544
+ throw new Error(`${label} must be an object when present`)
545
+ }
546
+ if ('enabled' in block && typeof block.enabled !== 'boolean') {
547
+ throw new Error(`${label}.enabled must be a boolean`)
548
+ }
549
+ if ('limits' in block) {
550
+ if (!isPlainObject(block.limits)) {
551
+ throw new Error(`${label}.limits must be an object`)
552
+ }
553
+ for (const [tool, fields] of Object.entries(CONTEXT_SANDBOX_LIMIT_FIELDS)) {
554
+ const group = block.limits[tool]
555
+ if (group === undefined) continue
556
+ if (!isPlainObject(group)) {
557
+ throw new Error(`${label}.limits.${tool} must be an object`)
558
+ }
559
+ for (const field of fields) {
560
+ if (!(field in group)) continue
561
+ const value = group[field]
562
+ if (typeof value !== 'number' || !Number.isFinite(value) || value < 0) {
563
+ throw new Error(`${label}.limits.${tool}.${field} must be a non-negative number`)
564
+ }
565
+ }
566
+ }
567
+ }
568
+ }
569
+
570
+ /**
571
+ * Merge a legacy (V1 top-level) context_sandbox block into an optional explicit
572
+ * V2 block. Values are compared per leaf, so `enabled: false` and a numeric `0`
573
+ * are preserved (not mistaken for "unset").
574
+ *
575
+ * @param {object|undefined} v2Explicit - existing `plugins[...].options.context_sandbox`
576
+ * @param {object|undefined} legacy - top-level `context_sandbox`
577
+ * @returns {object} merged block (only keys that were actually set)
578
+ * @throws {Error} when either block is null/type-invalid
579
+ */
580
+ export function mergeContextSandboxBlocks(v2Explicit, legacy) {
581
+ assertSandboxBlock(v2Explicit, 'plugins[...].options.context_sandbox')
582
+ assertSandboxBlock(legacy, 'context_sandbox')
583
+
584
+ const explicit = isPlainObject(v2Explicit) ? v2Explicit : {}
585
+ const base = isPlainObject(legacy) ? legacy : {}
586
+ const merged = {}
587
+
588
+ if ('enabled' in explicit) merged.enabled = explicit.enabled
589
+ else if ('enabled' in base) merged.enabled = base.enabled
590
+
591
+ const mergedLimits = {}
592
+ for (const [tool, fields] of Object.entries(CONTEXT_SANDBOX_LIMIT_FIELDS)) {
593
+ const explicitGroup = isPlainObject(explicit.limits?.[tool]) ? explicit.limits[tool] : null
594
+ const baseGroup = isPlainObject(base.limits?.[tool]) ? base.limits[tool] : null
595
+ const group = {}
596
+ for (const field of fields) {
597
+ if (explicitGroup && field in explicitGroup) group[field] = explicitGroup[field]
598
+ else if (baseGroup && field in baseGroup) group[field] = baseGroup[field]
599
+ }
600
+ if (Object.keys(group).length > 0) mergedLimits[tool] = group
601
+ }
602
+ if (Object.keys(mergedLimits).length > 0) merged.limits = mergedLimits
603
+
604
+ return merged
605
+ }
606
+
307
607
  // ---------------------------------------------------------------------------
308
608
  // Public API
309
609
  // ---------------------------------------------------------------------------
@@ -333,7 +633,20 @@ export function migrateV1toV2(config) {
333
633
  }
334
634
  agents[agentName] = ac
335
635
  }
336
- result.agents = agents
636
+ // An `agents` (V2) block may already exist — the installed opencode.json
637
+ // carries both, in either key order. Renaming onto it with a plain
638
+ // replace dropped the whole managed merge, so merge instead.
639
+ const base = result.agents
640
+ result.agents = isAgentMap(base) ? mergeManagedAgents(base, agents) : agents
641
+ } else if (key === 'agents') {
642
+ // V2 block: it is the base. The singular `agent` key may already have
643
+ // been converted above, in which case merge the two.
644
+ const managed = result.agents
645
+ if (isAgentMap(value)) {
646
+ result.agents = isAgentMap(managed) ? mergeManagedAgents(value, managed) : value
647
+ } else if (!isAgentMap(managed)) {
648
+ result.agents = value
649
+ }
337
650
  } else if (key === 'provider' && typeof value === 'object' && value !== null) {
338
651
  const providers = {}
339
652
  for (const [provName, provConfig] of Object.entries(value)) {
@@ -0,0 +1,26 @@
1
+ /**
2
+ * exit-codes.mjs — the process exit code the `init` CLI returns for an
3
+ * `installOpenCode` result. Pure, so the mapping is unit-testable without
4
+ * spawning the CLI.
5
+ *
6
+ * `0` success, or plan-only / declined (nothing applied)
7
+ * `1` failure (the CLI handles thrown errors separately)
8
+ * `2` apply requested without authorization (non-interactive, no --yes)
9
+ * `3` drift detected — a planned target changed between plan and apply
10
+ * `130` canceled
11
+ */
12
+
13
+ export function resolveInstallExitCode(result, isDryRun) {
14
+ if (isDryRun) return 0
15
+ if (result?.applied !== false) return 0
16
+ switch (result.reason) {
17
+ case 'not-authorized':
18
+ return 2
19
+ case 'drift':
20
+ return 3
21
+ case 'canceled':
22
+ return 130
23
+ default:
24
+ return 0
25
+ }
26
+ }
@@ -27,7 +27,7 @@ export function healthCheck(target, { dryRun = false, pythonTarget = target } =
27
27
  const visionServer = resolveVisionServer(target)
28
28
 
29
29
  // Check 1: Critical runtime scripts exist
30
- const scripts = ['mcp_persistence_server.py', '_pantheon_paths.py']
30
+ const scripts = ['mcp_persistence.py', '_pantheon_paths.py']
31
31
  for (const script of scripts) {
32
32
  const path = join(target, 'scripts', script)
33
33
  if (existsSync(path)) {
@@ -44,8 +44,8 @@ export function healthCheck(target, { dryRun = false, pythonTarget = target } =
44
44
  check: visionServer.check,
45
45
  detail: visionServer.detail,
46
46
  })
47
- const installedVisionServer = join(target, 'scripts', 'pantheon_vision_server.py')
48
- const canonicalVisionServer = join(target, 'src', 'mcp', 'pantheon_vision_server.py')
47
+ const installedVisionServer = join(target, 'scripts', 'pantheon_vision.py')
48
+ const canonicalVisionServer = join(target, 'src', 'mcp', 'pantheon_vision.py')
49
49
  if (existsSync(installedVisionServer) && existsSync(canonicalVisionServer)) {
50
50
  try {
51
51
  if (
@@ -54,7 +54,7 @@ export function healthCheck(target, { dryRun = false, pythonTarget = target } =
54
54
  ) {
55
55
  results.failed.push({
56
56
  check: 'pantheon-vision source drift',
57
- detail: 'installed script differs from src/mcp/pantheon_vision_server.py',
57
+ detail: 'installed script differs from src/mcp/pantheon_vision.py',
58
58
  })
59
59
  }
60
60
  } catch {
@@ -66,7 +66,7 @@ export function healthCheck(target, { dryRun = false, pythonTarget = target } =
66
66
  }
67
67
  } else {
68
68
  results.failed.push({
69
- check: 'src/mcp/pantheon_vision_server.py',
69
+ check: 'src/mcp/pantheon_vision.py',
70
70
  detail: 'NOT FOUND — canonical vision server source is unavailable',
71
71
  })
72
72
  }
@@ -104,12 +104,7 @@ export function healthCheck(target, { dryRun = false, pythonTarget = target } =
104
104
  }
105
105
 
106
106
  // Check 2: Syntax check on each MCP server script
107
- const mcpScripts = [
108
- 'mcp_persistence_server.py',
109
- 'mcp_resources_server.py',
110
- 'code_mode_server.py',
111
- 'memory_mcp_server.py',
112
- ]
107
+ const mcpScripts = ['mcp_persistence.py', 'mcp_resources.py', 'code_mode.py', 'memory_mcp.py']
113
108
  for (const script of mcpScripts) {
114
109
  const path = join(target, 'scripts', script)
115
110
  if (existsSync(path) && python) {
@@ -135,12 +130,12 @@ export function healthCheck(target, { dryRun = false, pythonTarget = target } =
135
130
  })
136
131
  if (result.status === 0) {
137
132
  results.passed.push({
138
- check: 'syntax:pantheon_vision_server.py',
133
+ check: 'syntax:pantheon_vision.py',
139
134
  detail: 'valid',
140
135
  })
141
136
  } else {
142
137
  results.failed.push({
143
- check: 'syntax:pantheon_vision_server.py',
138
+ check: 'syntax:pantheon_vision.py',
144
139
  detail: result.stderr.toString().trim().split('\n').slice(-1)[0],
145
140
  })
146
141
  }
@@ -173,13 +168,20 @@ export function healthCheck(target, { dryRun = false, pythonTarget = target } =
173
168
  if (python) {
174
169
  const mcpResult = spawnSync(
175
170
  python,
176
- ['-c', 'from mcp.server.fastmcp import FastMCP; print(FastMCP.__module__)'],
171
+ [
172
+ '-c',
173
+ 'import importlib.metadata as metadata; from fastmcp import FastMCP; ' +
174
+ 'print(f\'mcp={metadata.version("mcp")} fastmcp={metadata.version("fastmcp")} {FastMCP.__module__}\')',
175
+ ],
177
176
  { stdio: 'pipe' },
178
177
  )
179
178
  if (mcpResult.status === 0) {
180
179
  results.passed.push({ check: 'mcp-sdk', detail: mcpResult.stdout.toString().trim() })
181
180
  } else {
182
- results.failed.push({ check: 'mcp-sdk', detail: 'NOT INSTALLED — all MCP servers will fail' })
181
+ results.failed.push({
182
+ check: 'mcp-sdk',
183
+ detail: 'MCP SDK or standalone FastMCP is unavailable — all MCP servers will fail',
184
+ })
183
185
  }
184
186
  }
185
187
  // Check 4: the memory server's only search backend is the stdlib's own
@@ -224,13 +226,13 @@ export function healthCheck(target, { dryRun = false, pythonTarget = target } =
224
226
  function resolveVisionServer(target) {
225
227
  const candidates = [
226
228
  {
227
- path: join(target, 'src', 'mcp', 'pantheon_vision_server.py'),
228
- check: 'src/mcp/pantheon_vision_server.py',
229
+ path: join(target, 'src', 'mcp', 'pantheon_vision.py'),
230
+ check: 'src/mcp/pantheon_vision.py',
229
231
  detail: 'canonical source exists',
230
232
  },
231
233
  {
232
- path: join(target, 'scripts', 'pantheon_vision_server.py'),
233
- check: 'scripts/pantheon_vision_server.py',
234
+ path: join(target, 'scripts', 'pantheon_vision.py'),
235
+ check: 'scripts/pantheon_vision.py',
234
236
  detail: 'installed copy exists (deployed from src/mcp)',
235
237
  },
236
238
  ]
@@ -184,7 +184,7 @@ export async function promptConfirm(message, { default: def = true } = {}) {
184
184
  reject(new Error('Canceled'))
185
185
  }
186
186
 
187
- function handler(_str, key) {
187
+ function handler(str, key) {
188
188
  if (!key) return
189
189
  if ((key.ctrl && key.name === 'c') || key.name === 'escape') {
190
190
  cancel()
@@ -208,6 +208,20 @@ export async function promptConfirm(message, { default: def = true } = {}) {
208
208
  })
209
209
  }
210
210
 
211
+ /**
212
+ * Apply-gate prompt. Unlike the pre-install confirm this defaults to NO, so a
213
+ * bare Enter never authorizes a write. In a non-TTY it returns the default
214
+ * (`false`) without blocking — callers that must not prompt at all should branch
215
+ * on TTY before calling.
216
+ *
217
+ * @param {string} planSummary e.g. "Apply these 12 changes?"
218
+ * @param {{ default?: boolean }} [opts]
219
+ * @returns {Promise<boolean>}
220
+ */
221
+ export async function promptReview(planSummary, { default: def = false } = {}) {
222
+ return promptConfirm(planSummary, { default: def })
223
+ }
224
+
211
225
  export async function promptList(options, { title = '' } = {}) {
212
226
  if (options.length === 0) throw new Error('No options provided')
213
227
  if (options.length === 1) return options[0].value
@@ -329,7 +343,7 @@ export async function runInteractiveInstall({ components = [], defaultComponents
329
343
  {
330
344
  name: 'skills',
331
345
  value: 'skills',
332
- description: `Reusable skill workflows (${21} skills)`,
346
+ description: `Reusable skill workflows (${20} skills)`,
333
347
  },
334
348
  {
335
349
  name: 'instructions',
@@ -11,6 +11,7 @@
11
11
  import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
12
12
  import { dirname, join } from 'node:path'
13
13
  import { fileURLToPath } from 'node:url'
14
+ import { ACTIONS, recordChange, snapshot } from './plan.mjs'
14
15
  import { createInitialState, readState, writeState } from './state.mjs'
15
16
 
16
17
  const __dirname = dirname(fileURLToPath(import.meta.url))
@@ -44,10 +45,10 @@ export function detectVersion(target) {
44
45
  *
45
46
  * @param {string} target - Installation directory
46
47
  * @param {string|null} currentVersion - Detected version (null = fresh install)
47
- * @param {{ dryRun?: boolean }} [options]
48
+ * @param {{ dryRun?: boolean, recorder?: (change: object) => void }} [options]
48
49
  * @returns {{ applied: number, messages: string[] }}
49
50
  */
50
- export function runMigrations(target, currentVersion, { dryRun = false } = {}) {
51
+ export function runMigrations(target, currentVersion, { dryRun = false, recorder = null } = {}) {
51
52
  const applied = []
52
53
  const semver = parseSemver(currentVersion)
53
54
 
@@ -104,28 +105,46 @@ export function runMigrations(target, currentVersion, { dryRun = false } = {}) {
104
105
  }
105
106
 
106
107
  // ── Write updated state ───────────────────────────────────────────
107
- if (applied.length > 0 && !dryRun) {
108
- const newVersion = formatSemver(semver)
109
- let state = readState(target)
110
- if (!state) state = createInitialState(newVersion)
111
- state.pantheon_version = newVersion
112
- for (const m of applied) {
113
- state.applied_migrations.push({
114
- from: m.from,
115
- to: m.to,
116
- applied_at: new Date().toISOString(),
117
- status: 'completed',
118
- })
119
- }
120
- if (!state.components) {
121
- state.components = {
122
- agents: { version: 1, status: 'installed' },
123
- skills: { version: 1, status: 'installed' },
124
- plugins: { version: 1, status: 'installed' },
125
- runtime: { version: 1, status: 'installed' },
108
+ if (applied.length > 0) {
109
+ const statePath = join(target, '.pantheon', 'install-state.json')
110
+ if (dryRun) {
111
+ // State-only bookkeeping write: recorded so the preview is complete, but
112
+ // unscored because the install summary does not count it. Classified as
113
+ // an update — a migration that applies implies a state file existed.
114
+ recordChange({ path: statePath, action: ACTIONS.UPDATE, bytes: 0, scored: false }, recorder)
115
+ } else {
116
+ const newVersion = formatSemver(semver)
117
+ let state = readState(target)
118
+ const stateExisted = state !== null
119
+ if (!state) state = createInitialState(newVersion)
120
+ state.pantheon_version = newVersion
121
+ for (const m of applied) {
122
+ state.applied_migrations.push({
123
+ from: m.from,
124
+ to: m.to,
125
+ applied_at: new Date().toISOString(),
126
+ status: 'completed',
127
+ })
126
128
  }
129
+ if (!state.components) {
130
+ state.components = {
131
+ agents: { version: 1, status: 'installed' },
132
+ skills: { version: 1, status: 'installed' },
133
+ plugins: { version: 1, status: 'installed' },
134
+ runtime: { version: 1, status: 'installed' },
135
+ }
136
+ }
137
+ writeState(target, state)
138
+ recordChange(
139
+ {
140
+ path: statePath,
141
+ action: stateExisted ? ACTIONS.UPDATE : ACTIONS.CREATE,
142
+ bytes: snapshot(statePath).size,
143
+ scored: false,
144
+ },
145
+ recorder,
146
+ )
127
147
  }
128
- writeState(target, state)
129
148
  }
130
149
 
131
150
  return {