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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (120) hide show
  1. package/AGENTS.global.md +182 -0
  2. package/AGENTS.md +10 -891
  3. package/CHANGELOG.md +326 -0
  4. package/README.md +496 -32
  5. package/README.pt-BR.md +285 -20
  6. package/bin/pantheon-init.mjs +26 -3
  7. package/commands/pantheon-audit.md +7 -1
  8. package/commands/pantheon-consolidate.md +1 -1
  9. package/commands/pantheon-deepwork.md +14 -3
  10. package/commands/pantheon-focus.md +17 -0
  11. package/commands/pantheon-vision.md +61 -12
  12. package/package.json +21 -14
  13. package/plugin.json +2 -2
  14. package/pyproject.toml +8 -6
  15. package/scripts/build-agents-md.mjs +374 -56
  16. package/scripts/changelog-merge.mjs +161 -0
  17. package/scripts/{code_mode_server.py → code_mode.py} +3 -3
  18. package/scripts/cost.mjs +244 -41
  19. package/scripts/doctor.mjs +151 -1
  20. package/scripts/install/agents-md.mjs +4 -4
  21. package/scripts/install/config-migration.mjs +317 -4
  22. package/scripts/install/health-check.mjs +21 -19
  23. package/scripts/install/interactive.mjs +1 -1
  24. package/scripts/install/opencode-version.mjs +246 -4
  25. package/scripts/install/opencode.mjs +627 -179
  26. package/scripts/install/plugin.mjs +43 -9
  27. package/scripts/install/shared.mjs +120 -12
  28. package/scripts/install/vision-prompt.mjs +161 -0
  29. package/scripts/install-mcp.mjs +28 -9
  30. package/scripts/{mcp_persistence_server.py → mcp_persistence.py} +2 -2
  31. package/{src/mcp/mcp_resources_server.py → scripts/mcp_resources.py} +2 -2
  32. package/scripts/{memory_mcp_server.py → memory_mcp.py} +2 -2
  33. package/scripts/probe-context-rehydrate.mjs +8 -2
  34. package/scripts/probe-pantheon-cost.mjs +71 -15
  35. package/scripts/run-node-tests.mjs +189 -0
  36. package/scripts/sync-artifacts.mjs +26 -13
  37. package/scripts/sync-tui.mjs +24 -3
  38. package/scripts/test-opencode-v2-sandbox.sh +180 -26
  39. package/scripts/validate-routing.mjs +3 -3
  40. package/scripts/validate-sandbox-install.mjs +138 -0
  41. package/scripts/versioning.mjs +13 -1
  42. package/scripts/vision-status.mjs +682 -0
  43. package/skills-lock.json +8 -8
  44. package/src/agents/aphrodite.md +20 -37
  45. package/src/agents/apollo.md +7 -3
  46. package/src/agents/athena.md +63 -35
  47. package/src/agents/demeter.md +8 -28
  48. package/src/agents/gaia.md +1 -1
  49. package/src/agents/hephaestus.md +10 -21
  50. package/src/agents/hermes.md +15 -38
  51. package/src/agents/iris.md +1 -1
  52. package/src/agents/mnemosyne.md +18 -54
  53. package/src/agents/nyx.md +1 -1
  54. package/src/agents/prometheus.md +16 -20
  55. package/src/agents/talos.md +10 -2
  56. package/src/agents/themis.md +5 -5
  57. package/src/agents/zeus.md +99 -129
  58. package/src/instructions/agent-return-format.instructions.md +18 -3
  59. package/src/instructions/backend-standards.instructions.md +2 -0
  60. package/src/instructions/frontend-standards.instructions.md +3 -1
  61. package/src/instructions/memory-protocol.instructions.md +11 -110
  62. package/src/instructions/zeus-anti-stall.instructions.md +14 -61
  63. package/src/instructions/zeus-communication-rules.instructions.md +0 -1
  64. package/src/instructions/zeus-council-synthesis.instructions.md +32 -260
  65. package/src/instructions/zeus-memory-operations.instructions.md +26 -0
  66. package/src/instructions/zeus-timeout-retry.instructions.md +40 -87
  67. package/src/mcp/{code_mode_server.py → code_mode.py} +3 -3
  68. package/src/mcp/eval_store.py +2 -2
  69. package/src/mcp/{mcp_persistence_server.py → mcp_persistence.py} +2 -2
  70. package/{scripts/mcp_resources_server.py → src/mcp/mcp_resources.py} +2 -2
  71. package/src/mcp/{memory_mcp_server.py → memory_mcp.py} +2 -2
  72. package/src/mcp/{pantheon_vision_server.py → pantheon_vision.py} +286 -42
  73. package/src/mcp/requirements-mcp.txt +6 -4
  74. package/src/mcp/requirements-vision.txt +2 -2
  75. package/src/pantheon/cost-command.ts +399 -101
  76. package/src/pantheon/delegation-enforce.ts +10 -1
  77. package/src/pantheon/presets.d.mts +0 -8
  78. package/src/pantheon/presets.mjs +5 -32
  79. package/src/pantheon/v2-bounded-read.ts +640 -0
  80. package/src/pantheon/v2-delegation-enforce.ts +214 -0
  81. package/src/pantheon/v2-execute-after.ts +186 -0
  82. package/src/pantheon/v2-tools.ts +429 -0
  83. package/src/pantheon/v2-unsupported.d.mts +11 -0
  84. package/src/pantheon/v2-unsupported.mjs +90 -0
  85. package/src/pantheon/v2-vision.ts +622 -0
  86. package/src/pantheon/vision-local.ts +531 -0
  87. package/src/pantheon/vision.ts +1160 -115
  88. package/src/plugin-v2.ts +1145 -219
  89. package/src/plugin.ts +12 -9
  90. package/src/plugins/tui/dist/server.d.ts +1 -2
  91. package/src/plugins/tui/dist/server.d.ts.map +1 -1
  92. package/src/plugins/tui/dist/tui-v2.d.ts +154 -0
  93. package/src/plugins/tui/dist/tui-v2.d.ts.map +1 -0
  94. package/src/plugins/tui/dist/tui-v2.js +659 -0
  95. package/src/plugins/tui/dist/tui-v2.js.map +1 -0
  96. package/src/plugins/tui/dist/tui.d.ts +85 -82
  97. package/src/plugins/tui/dist/tui.d.ts.map +1 -1
  98. package/src/plugins/tui/dist/tui.js +72 -59
  99. package/src/plugins/tui/index-v2.ts +6 -0
  100. package/src/plugins/tui/package-lock.json +5516 -1353
  101. package/src/plugins/tui/package.json +22 -12
  102. package/src/plugins/tui/src/index-v2.tsx +734 -0
  103. package/src/plugins/tui/src/index.tsx +106 -85
  104. package/src/plugins/tui/src/opencode-v2.d.ts +13 -0
  105. package/src/plugins/tui/tsconfig.build.json +5 -0
  106. package/src/plugins/tui/tsconfig.typecheck.json +5 -0
  107. package/src/plugins/tui/tsdown.config.ts +22 -2
  108. package/src/plugins/tui/tui-v2.ts +2 -0
  109. package/src/plugins/tui/tui.ts +3 -0
  110. package/src/routing.yml +11 -21
  111. package/src/skills/README.md +3 -3
  112. package/src/skills/agent-coordination/SKILL.md +7 -2
  113. package/src/skills/artifact-management/SKILL.md +12 -5
  114. package/src/skills/auto-continue/SKILL.md +39 -30
  115. package/src/skills/code-review-checklist/SKILL.md +5 -5
  116. package/src/skills/context-compression/SKILL.md +26 -879
  117. package/src/skills/session-goal/SKILL.md +14 -5
  118. package/src/skills/spec-driven-development/SKILL.md +3 -1
  119. package/src/skills/tdd-with-agents/SKILL.md +18 -10
  120. package/opencode.json +0 -58
@@ -29,6 +29,8 @@ import { homedir } from 'node:os'
29
29
  import { basename, dirname, isAbsolute, join, resolve } from 'node:path'
30
30
  import { fileURLToPath } from 'node:url'
31
31
 
32
+ import { listV2UnsupportedFeatures } from '../src/pantheon/v2-unsupported.mjs'
33
+
32
34
  // ---------------------------------------------------------------------------
33
35
  // Paths
34
36
  // ---------------------------------------------------------------------------
@@ -1000,7 +1002,7 @@ function checkVenvLayer(args) {
1000
1002
  /**
1001
1003
  * Return whether *dir* contains an executable code-mode target.
1002
1004
  *
1003
- * Mirrors `_has_usable_scripts` in `scripts/code_mode_server.py`: a directory
1005
+ * Mirrors `_has_usable_scripts` in `scripts/code_mode.py`: a directory
1004
1006
  * is only a valid resolver candidate when it is a real (non-symlink) directory
1005
1007
  * containing a regular `.py`/`.sh` direct child. This prevents an empty project
1006
1008
  * overlay from masking a lower-priority installation that actually ships
@@ -1430,6 +1432,153 @@ export function checkPluginVersionDrift(args) {
1430
1432
  return 'sync'
1431
1433
  }
1432
1434
 
1435
+ // ---------------------------------------------------------------------------
1436
+ // Check H4: V2 feature reduction (the surface a migrating user loses)
1437
+ // ---------------------------------------------------------------------------
1438
+
1439
+ /**
1440
+ * Whether an opencode.json registers the V2 generation of the Pantheon plugin.
1441
+ *
1442
+ * The V2 contract is the PLURAL `plugins` key; V1 is the singular `plugin` key.
1443
+ * So the key alone is not the test — a config can legitimately carry a
1444
+ * third-party entry in `plugins`. What identifies the generation is a Pantheon
1445
+ * V2 entry inside it. Matched on the `plugin-v2` marker so all three real
1446
+ * spellings are recognised: the canonical DIRECTORY (`src/plugin-v2`), an
1447
+ * absolute path to it, and the package export (`pantheon-opencode/plugin-v2`).
1448
+ *
1449
+ * @param {unknown} ref
1450
+ * @returns {boolean}
1451
+ */
1452
+ function isV2PluginRef(ref) {
1453
+ const candidate =
1454
+ typeof ref === 'string'
1455
+ ? ref
1456
+ : ref && typeof ref === 'object'
1457
+ ? (ref.path ?? ref.source ?? ref.id ?? ref.name)
1458
+ : undefined
1459
+ return typeof candidate === 'string' && candidate.includes('plugin-v2')
1460
+ }
1461
+
1462
+ /**
1463
+ * Whether any reachable config registers the V2 generation of the Pantheon
1464
+ * plugin. Split out of {@link collectV2UnsupportedFeatures} because "is the V2
1465
+ * generation registered" and "does the seed list anything" are TWO facts: the
1466
+ * first is about the user's config, the second about this repository's seed. A
1467
+ * single `features.length === 0` cannot tell them apart, and conflating them
1468
+ * let an emptied seed print "no V2 generation registered — nothing reduced".
1469
+ *
1470
+ * @param {{path:string, data:object}[]} configs
1471
+ * @returns {boolean}
1472
+ */
1473
+ export function isV2GenerationRegistered(configs) {
1474
+ return configs.some((cfg) => {
1475
+ const list = cfg.data?.plugins
1476
+ return Array.isArray(list) && list.some(isV2PluginRef)
1477
+ })
1478
+ }
1479
+
1480
+ /**
1481
+ * The V2 feature-reduction markers to report for a set of collected configs.
1482
+ *
1483
+ * Returns the shared seed verbatim when ANY reachable config registers the V2
1484
+ * generation, and an empty list otherwise. The strings come from
1485
+ * `src/pantheon/v2-unsupported.mjs` — the same seed `getUnsupportedFeatures()`
1486
+ * is built from — so doctor cannot report a reduction the plugin does not, or
1487
+ * miss one it does. Nothing is reworded here.
1488
+ *
1489
+ * An empty result is ambiguous on its own and deliberately not interpreted
1490
+ * here: ask {@link isV2GenerationRegistered} for the registration fact, or
1491
+ * {@link describeV2Reduction} to get both at once.
1492
+ *
1493
+ * @param {{path:string, data:object}[]} configs
1494
+ * @returns {string[]}
1495
+ */
1496
+ export function collectV2UnsupportedFeatures(configs) {
1497
+ return isV2GenerationRegistered(configs) ? listV2UnsupportedFeatures() : []
1498
+ }
1499
+
1500
+ /**
1501
+ * Decide what H4 prints, from the two distinct facts rather than from one list
1502
+ * length.
1503
+ *
1504
+ * Three cases, and the middle one used to be silently reported as the first:
1505
+ *
1506
+ * - not registered → info. A V1 install is a supported configuration with
1507
+ * nothing to reduce, and saying so plainly is correct.
1508
+ * - registered, seed EMPTY → warn. This is a packaging fault in THIS repo,
1509
+ * not an absence of reduction: the V2 surface really is reduced and the
1510
+ * report is missing. It must never claim "nothing reduced".
1511
+ * - registered, seed populated → warn, listing every marker.
1512
+ *
1513
+ * Pure and exported so the empty-seed branch is testable without stubbing the
1514
+ * frozen seed module. `status` is returned alongside the text so a caller never
1515
+ * has to recover the case by matching on the message.
1516
+ *
1517
+ * @param {{registered:boolean, features:string[]}} input
1518
+ * @returns {{status:'reported'|'empty-seed'|'no-v2-generation', level:'info'|'warn', message:string}}
1519
+ */
1520
+ export function describeV2Reduction({ registered, features }) {
1521
+ if (!registered) {
1522
+ return {
1523
+ status: 'no-v2-generation',
1524
+ level: 'info',
1525
+ message: 'No V2 plugin generation registered — nothing reduced',
1526
+ }
1527
+ }
1528
+ if (features.length === 0) {
1529
+ return {
1530
+ status: 'empty-seed',
1531
+ level: 'warn',
1532
+ message:
1533
+ 'Pantheon IS registered on the V2 plugin generation, but the shared ' +
1534
+ 'unsupported-feature seed is empty, so the reduction cannot be reported. ' +
1535
+ "That is a fault in this installation's reporting, not an absence of " +
1536
+ 'reduction: the V2 surface still drops the 3 goal tools and the ' +
1537
+ 'caller/target delegation matrix.',
1538
+ }
1539
+ }
1540
+ return {
1541
+ status: 'reported',
1542
+ level: 'warn',
1543
+ message:
1544
+ 'Pantheon is registered on the V2 plugin generation, which registers 3 tools ' +
1545
+ "(hashline_edit, pantheon_cost, pantheon_model) instead of the V1 surface's 6 plus " +
1546
+ `the BackgroundJobBoard and the caller/target delegation matrix. Unsupported: ${features.join(', ')}`,
1547
+ }
1548
+ }
1549
+
1550
+ /**
1551
+ * Report the V2 feature reduction through doctor's own output shape
1552
+ * (section + warn/info), so it lands in the health summary like every other
1553
+ * finding and never changes the exit code: a V2 install is a supported
1554
+ * configuration, not a broken one.
1555
+ *
1556
+ * @param {{target:string, env?:object}} args
1557
+ * @returns {'reported'|'empty-seed'|'no-v2-generation'|'no-config'}
1558
+ */
1559
+ export function checkV2UnsupportedFeatures(args) {
1560
+ section('H4. V2 Feature Reduction')
1561
+
1562
+ const configs = collectMcpConfigs(args)
1563
+ if (configs.length === 0) {
1564
+ info('No opencode.json was found — V2 feature reduction check skipped')
1565
+ return 'no-config'
1566
+ }
1567
+
1568
+ // Branch on REGISTRATION, not on the marker count: an empty seed on a
1569
+ // registered V2 generation is a reporting fault and must not be reported as
1570
+ // the reassuring "nothing reduced".
1571
+ const registered = isV2GenerationRegistered(configs)
1572
+ const verdict = describeV2Reduction({
1573
+ registered,
1574
+ features: collectV2UnsupportedFeatures(configs),
1575
+ })
1576
+ if (verdict.level === 'info') info(verdict.message)
1577
+ else warn(verdict.message)
1578
+
1579
+ return verdict.status
1580
+ }
1581
+
1433
1582
  // ---------------------------------------------------------------------------
1434
1583
  // Check G: AGENTS.md freshness
1435
1584
  // ---------------------------------------------------------------------------
@@ -1802,6 +1951,7 @@ async function main() {
1802
1951
  checkAgentsMdFreshness(args)
1803
1952
  checkSubagentDepthPlacement(args)
1804
1953
  checkPermissionTaskPresence(args)
1954
+ checkV2UnsupportedFeatures(args)
1805
1955
 
1806
1956
  printSummary(args.target)
1807
1957
 
@@ -2,7 +2,7 @@
2
2
  /**
3
3
  * agents-md.mjs — Dynamic AGENTS.md generator from canonical agent frontmatter
4
4
  *
5
- * Reads all agents/*.agent.md files, extracts name + description from frontmatter,
5
+ * Reads all canonical src/agents/*.md files, extracts name + description from frontmatter,
6
6
  * and generates AGENTS.md content dynamically. Eliminates hardcoded agent tables
7
7
  * in platform installers (claude.mjs, cursor.mjs, windsurf.mjs, cline.mjs, etc.).
8
8
  *
@@ -21,7 +21,7 @@
21
21
  import { existsSync, readdirSync, readFileSync, writeFileSync } from 'node:fs'
22
22
  import { dirname, join } from 'node:path'
23
23
  import { fileURLToPath } from 'node:url'
24
- import yaml from 'js-yaml'
24
+ import { load } from 'js-yaml'
25
25
 
26
26
  // ---------------------------------------------------------------------------
27
27
  // Paths
@@ -47,7 +47,7 @@ function parseAgentFrontmatter(content) {
47
47
  if (!match) return null
48
48
 
49
49
  try {
50
- const fm = yaml.load(match[1])
50
+ const fm = load(match[1])
51
51
  if (!fm || typeof fm !== 'object' || !fm.name) return null
52
52
  return {
53
53
  name: String(fm.name),
@@ -64,7 +64,7 @@ function parseAgentFrontmatter(content) {
64
64
  // ---------------------------------------------------------------------------
65
65
 
66
66
  /**
67
- * Read all canonical agents/*.agent.md files, parse frontmatter,
67
+ * Read all canonical src/agents/*.md files, parse frontmatter,
68
68
  * and return an array of agent metadata objects sorted alphabetically by name.
69
69
  *
70
70
  * Skips files that don't match the `*.agent.md` pattern, files without
@@ -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)) {
@@ -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
  ]