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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/AGENTS.md +42 -23
  2. package/CHANGELOG.md +41 -0
  3. package/README.md +114 -12
  4. package/README.pt-BR.md +15 -6
  5. package/bin/pantheon-init.mjs +26 -3
  6. package/package.json +5 -6
  7. package/plugin.json +1 -1
  8. package/pyproject.toml +6 -6
  9. package/scripts/{code_mode_server.py → code_mode.py} +2 -2
  10. package/scripts/doctor.mjs +151 -1
  11. package/scripts/install/config-migration.mjs +80 -2
  12. package/scripts/install/health-check.mjs +12 -17
  13. package/scripts/install/opencode-version.mjs +246 -4
  14. package/scripts/install/opencode.mjs +250 -119
  15. package/scripts/install-mcp.mjs +28 -9
  16. package/scripts/{mcp_persistence_server.py → mcp_persistence.py} +1 -1
  17. package/{src/mcp/mcp_resources_server.py → scripts/mcp_resources.py} +1 -1
  18. package/scripts/{memory_mcp_server.py → memory_mcp.py} +1 -1
  19. package/scripts/probe-context-rehydrate.mjs +2 -2
  20. package/scripts/run-node-tests.mjs +189 -0
  21. package/scripts/sync-artifacts.mjs +5 -5
  22. package/scripts/test-opencode-v2-sandbox.sh +21 -21
  23. package/skills-lock.json +2 -2
  24. package/src/agents/aphrodite.md +1 -1
  25. package/src/agents/apollo.md +7 -2
  26. package/src/agents/athena.md +1 -0
  27. package/src/agents/demeter.md +1 -1
  28. package/src/agents/hephaestus.md +1 -1
  29. package/src/agents/hermes.md +1 -1
  30. package/src/agents/iris.md +1 -0
  31. package/src/agents/nyx.md +1 -0
  32. package/src/agents/themis.md +1 -0
  33. package/src/agents/zeus.md +39 -10
  34. package/src/instructions/memory-protocol.instructions.md +21 -10
  35. package/src/instructions/zeus-anti-stall.instructions.md +19 -11
  36. package/src/instructions/zeus-council-synthesis.instructions.md +2 -2
  37. package/src/mcp/{code_mode_server.py → code_mode.py} +2 -2
  38. package/src/mcp/eval_store.py +2 -2
  39. package/src/mcp/{mcp_persistence_server.py → mcp_persistence.py} +1 -1
  40. package/{scripts/mcp_resources_server.py → src/mcp/mcp_resources.py} +1 -1
  41. package/src/mcp/{memory_mcp_server.py → memory_mcp.py} +1 -1
  42. package/src/pantheon/v2-unsupported.d.mts +11 -0
  43. package/src/pantheon/v2-unsupported.mjs +113 -0
  44. package/src/plugin-v2.ts +149 -53
  45. package/src/plugins/tui/dist/tui.js +1 -1
  46. package/src/plugins/tui/package-lock.json +2 -2
  47. package/src/plugins/tui/package.json +1 -1
  48. package/src/skills/auto-continue/SKILL.md +4 -4
  49. package/src/skills/context-compression/SKILL.md +1 -1
  50. package/opencode.json +0 -58
  51. /package/src/mcp/{pantheon_vision_server.py → pantheon_vision.py} +0 -0
@@ -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
 
@@ -229,8 +229,11 @@ function convertProviderV2toV1(_name, config) {
229
229
  /**
230
230
  * Convert V1 MCP config to V2.
231
231
  *
232
- * V1: { bifrost: { type: "remote", url: "...", enabled: true, timeout: 30000 } }
233
- * V2 installer output: { bifrost: { type: "remote", url: "...", enabled: true } }
232
+ * The server names are arbitrary — a user calls theirs whatever they like, and
233
+ * which MCP servers exist is entirely their configuration, never ours.
234
+ *
235
+ * V1: { myserver: { type: "remote", url: "...", enabled: true, timeout: 30000 } }
236
+ * V2 installer output: { myserver: { type: "remote", url: "...", enabled: true } }
234
237
  *
235
238
  * OpenCode 1.18.18 does not have an `mcp.servers` wrapper. It interprets
236
239
  * `servers` as the name of an MCP server and then rejects it with
@@ -285,6 +288,81 @@ function convertMcpV2toV1(mcpConfig) {
285
288
  return convertMcpV1toV2(mcpConfig)
286
289
  }
287
290
 
291
+ // ---------------------------------------------------------------------------
292
+ // MCP entrypoint path migration
293
+ // ---------------------------------------------------------------------------
294
+
295
+ /**
296
+ * Entrypoint filename renames: every historical Python MCP entrypoint shared
297
+ * the `server.py` token, so a broad `pkill -f server.py` (a common idiom in
298
+ * agent and scratch tooling) matched the entire fleet and killed it as
299
+ * collateral. The five entrypoints now carry distinct tokens.
300
+ *
301
+ * The rename alone is a BREAKING change for existing installs: their
302
+ * `opencode.json` carries literal paths like `scripts/memory_mcp_server.py`,
303
+ * and a path that no longer resolves fails every MCP server silently (the
304
+ * same class of failure as the dead plugin surface). This map is the single
305
+ * source of truth for the stale → current basename rewrite that repairs them.
306
+ */
307
+ export const MCP_ENTRYPOINT_RENAMES = Object.freeze({
308
+ 'mcp_resources_server.py': 'mcp_resources.py',
309
+ 'code_mode_server.py': 'code_mode.py',
310
+ 'memory_mcp_server.py': 'memory_mcp.py',
311
+ 'mcp_persistence_server.py': 'mcp_persistence.py',
312
+ 'pantheon_vision_server.py': 'pantheon_vision.py',
313
+ })
314
+
315
+ /**
316
+ * Rewrite a single path/argument string when its basename is a renamed MCP
317
+ * entrypoint. The directory prefix is preserved verbatim, so both relative
318
+ * (`scripts/memory_mcp_server.py`) and absolute/hand-edited paths heal without
319
+ * discarding a user's chosen location. Non-matching values pass through.
320
+ *
321
+ * @param {string} value
322
+ * @returns {string}
323
+ */
324
+ function renameMcpEntrypointPath(value) {
325
+ if (typeof value !== 'string') return value
326
+ const slash = Math.max(value.lastIndexOf('/'), value.lastIndexOf('\\'))
327
+ const base = value.slice(slash + 1)
328
+ const renamed = MCP_ENTRYPOINT_RENAMES[base]
329
+ if (!renamed) return value
330
+ return value.slice(0, value.length - base.length) + renamed
331
+ }
332
+
333
+ /**
334
+ * Heal stale MCP entrypoint paths in a flat named-server map, in place.
335
+ *
336
+ * Scans every server — not only the `pantheon-*` keys — because a user who
337
+ * hand-edited their config may have renamed the key while keeping the
338
+ * Pantheon path. Both `command` (installer writes `[python, script]`) and
339
+ * `args` (the standalone installer writes `[script]`) are covered, since
340
+ * which array carries the script differs between installers.
341
+ *
342
+ * @param {object} mcp - the `mcp` map from a config (mutated in place)
343
+ * @returns {number} count of rewritten strings (0 = already current)
344
+ */
345
+ export function migrateMcpEntrypointPaths(mcp) {
346
+ if (!mcp || typeof mcp !== 'object' || Array.isArray(mcp)) return 0
347
+
348
+ let changed = 0
349
+ for (const server of Object.values(mcp)) {
350
+ if (!server || typeof server !== 'object') continue
351
+ for (const field of ['command', 'args']) {
352
+ const list = server[field]
353
+ if (!Array.isArray(list)) continue
354
+ for (let i = 0; i < list.length; i++) {
355
+ const next = renameMcpEntrypointPath(list[i])
356
+ if (next !== list[i]) {
357
+ list[i] = next
358
+ changed++
359
+ }
360
+ }
361
+ }
362
+ }
363
+ return changed
364
+ }
365
+
288
366
  // ---------------------------------------------------------------------------
289
367
  // Top-level key mappings
290
368
  // ---------------------------------------------------------------------------
@@ -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
  }
@@ -231,13 +226,13 @@ export function healthCheck(target, { dryRun = false, pythonTarget = target } =
231
226
  function resolveVisionServer(target) {
232
227
  const candidates = [
233
228
  {
234
- path: join(target, 'src', 'mcp', 'pantheon_vision_server.py'),
235
- check: 'src/mcp/pantheon_vision_server.py',
229
+ path: join(target, 'src', 'mcp', 'pantheon_vision.py'),
230
+ check: 'src/mcp/pantheon_vision.py',
236
231
  detail: 'canonical source exists',
237
232
  },
238
233
  {
239
- path: join(target, 'scripts', 'pantheon_vision_server.py'),
240
- check: 'scripts/pantheon_vision_server.py',
234
+ path: join(target, 'scripts', 'pantheon_vision.py'),
235
+ check: 'scripts/pantheon_vision.py',
241
236
  detail: 'installed copy exists (deployed from src/mcp)',
242
237
  },
243
238
  ]
@@ -1,5 +1,9 @@
1
1
  /** OpenCode major-version selection shared by the CLI and installer. */
2
2
 
3
+ import { spawnSync } from 'node:child_process'
4
+
5
+ import { warning } from './cli-ui.mjs'
6
+
3
7
  const VALID_VERSIONS = new Set(['v1', 'v2', 'auto'])
4
8
 
5
9
  /** @param {string[]} args @returns {'v1'|'v2'|'auto'|null} */
@@ -28,12 +32,185 @@ function normalize(value) {
28
32
  }
29
33
 
30
34
  /**
31
- * Resolve auto conservatively: only an explicit hint selects V2.
35
+ * Ask the host binary for its version. Throws on any spawn failure so the
36
+ * caller can fail soft in one place.
37
+ *
38
+ * stderr is captured alongside stdout. A host that prints its version banner to
39
+ * stderr and nothing to stdout would otherwise soft-fail to V1 — a wrong
40
+ * answer whose cause has nothing to do with the host's generation.
41
+ * @param {string} binary
42
+ * @returns {{stdout: string, stderr: string}}
43
+ */
44
+ function probeHostVersion(binary) {
45
+ const result = spawnSync(binary, ['--version'], {
46
+ encoding: 'utf8',
47
+ timeout: 5000,
48
+ stdio: ['ignore', 'pipe', 'pipe'],
49
+ })
50
+ if (result.error) throw result.error
51
+ if (result.status !== 0) {
52
+ throw new Error(`"${binary} --version" exited with status ${result.status}`)
53
+ }
54
+ return { stdout: result.stdout ?? '', stderr: result.stderr ?? '' }
55
+ }
56
+
57
+ // A version-SHAPED token: optional "v", a major, a dot, a minor. The lookbehind
58
+ // rejects starting mid-number so "1.18.33" offers ONE candidate ("1.18") rather
59
+ // than also offering "18.33". Case-insensitive because hosts print both
60
+ // "opencode v2.0.22" and "OpenCode V2.0.22".
61
+ const VERSION_TOKEN = /(?<![\d.])v?(\d+)\.(\d+)/gi
62
+
63
+ // A token immediately preceded by the tool name IS that host's own version,
64
+ // whatever else the banner mentions. This is what distinguishes
65
+ // "node v22.1.0 (opencode 1.18.33)" — where the two candidates are a runtime
66
+ // version and the tool version — from a genuine contradiction.
67
+ //
68
+ // KNOWN LIMIT, deliberately not worked around: this anchor is a NAME anchor, so
69
+ // any sentence that puts "opencode" in front of a version-shaped token reads as
70
+ // a host banner. A probe whose output is `warning: deprecated, upgrade to
71
+ // opencode 2.0.0` resolves to v2 in silence. Closing it would need a rule that
72
+ // rejects a mid-sentence "opencode X.Y.Z" — but "node v22.1.0 (opencode 1.18.33)"
73
+ // is exactly such a mid-sentence anchor, and it is the reason the anchor exists:
74
+ // dropping it lets a leading runtime token flip a genuine 1.x host to v2. The
75
+ // defect class is not the nag wording (a phrase blacklist closes these three
76
+ // strings and none of the others), it is name-adjacency. `probeHostVersion`
77
+ // spawns whatever OPENCODE_BIN names and accepts any exit-0 binary, so a wrapper
78
+ // or shim printing a bare path is indistinguishable from the real host by
79
+ // construction. An operator whose `opencode` is a wrapper gets v2 silently; the
80
+ // documented remedy is `--opencode-version`, not a heuristic that would trade
81
+ // this silent misclassification for a different one.
82
+ const ANCHOR_NAME = 'opencode'
83
+
84
+ // How far back the anchor scan looks. `[\s:=]*` is unbounded, so no finite
85
+ // window is exact — the cap is what makes the scan O(1) per token instead of
86
+ // copying the whole prefix, which was quadratic and let a verbose host outrun the
87
+ // 5s spawn timeout (that timeout guards the spawn, not the parse). The budget is
88
+ // sized from the anchor itself (ANCHOR_NAME plus separators), not guessed: the
89
+ // longest separator run in any realistic banner is ~10 bytes. Past the budget an
90
+ // anchor is simply missed, and that degrades to the loud consensus path — it can
91
+ // turn a quiet resolution into a warned one, never into a different resolution.
92
+ const ANCHOR_SEPARATOR_BUDGET = 56
93
+ const ANCHOR_WINDOW_BYTES = ANCHOR_NAME.length + ANCHOR_SEPARATOR_BUDGET
94
+ const TOOL_NAME_BEFORE = new RegExp(`${ANCHOR_NAME}[\\s:=]*$`, 'i')
95
+
96
+ // A major at or above this is a calendar year, not a release number:
97
+ // "built 2026.10.04" is version-SHAPED but describes no release. So is major 0,
98
+ // which is a prerelease placeholder rather than a release — and major 0 read as
99
+ // "older than 2" is the original defect (V1 config on a 2.x host) recurring for
100
+ // a 2.x beta. Which brings the subtlety this gate turns on:
101
+ //
102
+ // * UNANCHORED and implausible -> noise. A date or a build counter says
103
+ // nothing about the host's generation, so dropping it is a resolution, not a
104
+ // failure: "opencode v2.0.22 built 2026.10.04" stays quiet.
105
+ // * ANCHORED and implausible -> the host's OWN version, which we cannot read.
106
+ // Discarding it would promote a surviving `node v22` token to deciding power
107
+ // and answer in silence — "opencode 100.0.0 (node v22.1.0)" would resolve to
108
+ // v2 with no warning. So a refused anchored major is reported, not dropped.
109
+ //
110
+ // Both cases fail LOUD rather than silently, which is the safe direction, and
111
+ // the loud-v1 claim holds for the anchored case without depending on the dropped
112
+ // token being the only candidate.
113
+ const FIRST_PLAUSIBLE_MAJOR = 100
114
+
115
+ /** Is `major` a value we refuse to read as a release number? */
116
+ function isImplausibleMajor(major) {
117
+ return major === 0 || major >= FIRST_PLAUSIBLE_MAJOR
118
+ }
119
+
120
+ /**
121
+ * The slice of `source` the anchor scan inspects — bounded on purpose (see
122
+ * ANCHOR_WINDOW_BYTES) so the scan does not copy the prefix per token.
123
+ * @param {string} source @param {number} index @returns {string}
124
+ */
125
+ function anchorWindow(source, index) {
126
+ return source.slice(Math.max(0, index - ANCHOR_WINDOW_BYTES), index)
127
+ }
128
+
129
+ /**
130
+ * Reduce a `--version` string to the single major it unambiguously reports.
131
+ *
132
+ * Anchoring on the tool name — rather than taking the first or the last
133
+ * version-shaped token — is what makes this robust on both sides. First-match
134
+ * anchoring lets a leading `node v22` outrank the tool's own version and flips
135
+ * a 1.x host to v2; last-match anchoring lets a trailing `built 2026.10.04`
136
+ * outrank it and does the same. A name-anchored token defeats both.
137
+ *
138
+ * @param {string} text raw `--version` output from one stream
139
+ * @returns {{major: number}|{ambiguous: number[]}|{unreadable: number}|null}
140
+ * `{major}` when the host version is identifiable, `{ambiguous}` when
141
+ * version-shaped tokens disagree and nothing says which is the host's,
142
+ * `{unreadable}` when the host's own anchored token reports a major this gate
143
+ * will not interpret, and `null` when nothing is version-shaped.
144
+ */
145
+ function parseHostMajor(text) {
146
+ const source = String(text ?? '')
147
+ const candidates = []
148
+ let unreadable = null
149
+ for (const match of source.matchAll(VERSION_TOKEN)) {
150
+ const major = Number(match[1])
151
+ const anchored = TOOL_NAME_BEFORE.test(anchorWindow(source, match.index))
152
+ if (isImplausibleMajor(major)) {
153
+ if (anchored && unreadable === null) unreadable = major
154
+ continue
155
+ }
156
+ candidates.push({ major, anchored })
157
+ }
158
+ if (unreadable !== null) return { unreadable }
159
+ if (candidates.length === 0) return null
160
+
161
+ const anchored = candidates.filter((candidate) => candidate.anchored)
162
+ if (anchored.length > 0) {
163
+ const majors = new Set(anchored.map((candidate) => candidate.major))
164
+ if (majors.size === 1) return { major: anchored[0].major }
165
+ return { ambiguous: [...majors].sort((a, b) => a - b) }
166
+ }
167
+
168
+ const majors = new Set(candidates.map((candidate) => candidate.major))
169
+ if (majors.size === 1) return { major: candidates[0].major }
170
+ return { ambiguous: [...majors].sort((a, b) => a - b) }
171
+ }
172
+
173
+ /**
174
+ * Resolve the target OpenCode generation.
175
+ *
176
+ * `auto` precedence — the order is load-bearing, do not "simplify" it:
177
+ * 1. explicit `--opencode-version` argument
178
+ * 2. OPENCODE_VERSION env var
179
+ * 3. an `opencode2` binary basename
180
+ * 4. a `--version` probe of the host binary (major >= 2 => v2)
181
+ * 5. soft-fail to v1 with one visible warning
182
+ *
183
+ * Step 3 stays ahead of step 4 because of WHAT THE TWO STEPS ARE. Step 3 is an
184
+ * explicit operator hint: naming a binary `opencode2` and pointing
185
+ * `OPENCODE_BIN` at it is a deliberate act, and it is the only one of the two
186
+ * the operator gets to state. Step 4 is a heuristic read of a free-form string
187
+ * that some program chose to print. A hint must not be overruled by a
188
+ * heuristic — and the argument holds no matter what any given build prints.
189
+ *
190
+ * Historical note, NOT an observation of this host: an OpenCode 2.x prerelease
191
+ * once printed "0.0.0-next-17444", which any "major >= 2 => v2" probe reads as
192
+ * major 0 and would resolve to v1. That string does not come from the host —
193
+ * it survives only in this repo's comments and test fixtures. It is also why a
194
+ * major-0 anchored token now takes the loud path rather than resolving to v1 in
195
+ * silence (see FIRST_PLAUSIBLE_MAJOR). Verified 2026-10-04: `opencode2` here is
196
+ * a 47-byte shim that execs `opencode`, and `opencode2 --version` prints
197
+ * "opencode v2.0.22" on stdout. So for a real `opencode2` today both the
198
+ * basename and the probe return v2 and the ordering is harmless rather than
199
+ * load-bearing; what it still defends is the general case above.
200
+ *
201
+ * Step 5 falls back to v1 rather than v2 on purpose. Writing a plural
202
+ * `plugins` directory entry on an unknown 1.x host would be ignored there,
203
+ * losing the plugin on that host AND dropping the V1-only surface — strictly
204
+ * worse than the V1 default, which at least still works. The fallback is only
205
+ * tolerable because it warns; a silent v1 is what made this a silent failure.
206
+ *
32
207
  * @param {'v1'|'v2'|'auto'|null|undefined} requested
33
- * @param {{env?: Record<string, string|undefined>, binary?: string}} [options]
208
+ * @param {{env?: Record<string, string|undefined>, binary?: string,
209
+ * probe?: () => string|{stdout?: string, stderr?: string},
210
+ * warn?: (message: string) => void}} [options]
34
211
  * @returns {'v1'|'v2'}
35
212
  */
36
- export function resolveOpenCodeVersion(requested = 'v1', options = {}) {
213
+ export function resolveOpenCodeVersion(requested = 'auto', options = {}) {
37
214
  if (!VALID_VERSIONS.has(requested)) {
38
215
  throw new Error(`Invalid OpenCode version "${requested}" — expected v1, v2, or auto`)
39
216
  }
@@ -43,5 +220,70 @@ export function resolveOpenCodeVersion(requested = 'v1', options = {}) {
43
220
  return env.OPENCODE_VERSION
44
221
  }
45
222
  const binary = options.binary ?? env.OPENCODE_BIN ?? ''
46
- return /(?:^|[\\/])opencode2(?:\.exe)?$/i.test(binary) ? 'v2' : 'v1'
223
+ if (/(?:^|[\\/])opencode2(?:\.exe)?$/i.test(binary)) return 'v2'
224
+
225
+ const probe = options.probe ?? (() => probeHostVersion(binary || 'opencode'))
226
+ const warn = options.warn ?? warning
227
+ let raw
228
+ try {
229
+ raw = probe()
230
+ } catch (err) {
231
+ warn(
232
+ `Could not read the host OpenCode version (${err.message}). Falling back to the V1 ` +
233
+ `configuration; if this host is OpenCode 2.x re-run with --opencode-version v2 ` +
234
+ `(or v1 to force V1).`,
235
+ )
236
+ return 'v1'
237
+ }
238
+
239
+ // Accept both the real probe's {stdout, stderr} shape and a bare string, so a
240
+ // caller (or CI) can inject a probe without spawning a host binary.
241
+ const streams =
242
+ raw !== null && typeof raw === 'object'
243
+ ? { stdout: String(raw.stdout ?? ''), stderr: String(raw.stderr ?? '') }
244
+ : { stdout: String(raw ?? ''), stderr: '' }
245
+
246
+ // stdout decides alone when it yields a version; stderr is a FALLBACK for the
247
+ // host that prints nothing there, never a second vote. If both parse, stdout
248
+ // wins, so a stderr banner can never overrule the primary source.
249
+ const parsed = parseHostMajor(streams.stdout) ?? parseHostMajor(streams.stderr)
250
+
251
+ if (parsed === null) {
252
+ // A probe that RAN but whose output we cannot read is just as dangerous as
253
+ // one that failed: silently landing on V1 is the bug this gate exists to
254
+ // fix, so it gets the same visible warning.
255
+ warn(
256
+ `Could not parse the host OpenCode version from ` +
257
+ `${JSON.stringify(`${streams.stdout}${streams.stderr}`.trim())}. ` +
258
+ `Falling back to the V1 configuration; if this host is OpenCode 2.x re-run with ` +
259
+ `--opencode-version v2 (or v1 to force V1).`,
260
+ )
261
+ return 'v1'
262
+ }
263
+ if (parsed.unreadable !== undefined) {
264
+ // The host named itself and reported a major this gate refuses to read (0,
265
+ // or a calendar year). That is not noise like a trailing build date — it is
266
+ // the host's own version, so letting a surviving runtime token decide would
267
+ // be a silent guess. Say what we saw and fall back loudly.
268
+ warn(
269
+ `The host OpenCode version output reports major ${parsed.unreadable}, which is ` +
270
+ `not a release number this gate can interpret (a prerelease placeholder or a ` +
271
+ `calendar year). Falling back to the V1 configuration; pass --opencode-version v2 ` +
272
+ `if this host is OpenCode 2.x (or v1 to force V1).`,
273
+ )
274
+ return 'v1'
275
+ }
276
+ if (parsed.ambiguous) {
277
+ // Version-shaped tokens that disagree, with no tool name to break the tie.
278
+ // The reviewer accepted "wrong but loud" here; guessing silently is the
279
+ // defect this branch exists to prevent.
280
+ warn(
281
+ `The host OpenCode version output has contradictory versions ` +
282
+ `(${parsed.ambiguous.map((major) => `major ${major}`).join(', ')}) and does not say ` +
283
+ `which is the host's. Falling back to the V1 configuration; pass ` +
284
+ `--opencode-version v2 if this host is OpenCode 2.x (or v1 to force V1).`,
285
+ )
286
+ return 'v1'
287
+ }
288
+ return parsed.major >= 2 ? 'v2' : 'v1'
47
289
  }