pantheon-opencode 1.6.0-beta.3 → 1.6.0-beta.5
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.
- package/AGENTS.md +24 -5
- package/CHANGELOG.md +49 -0
- package/README.md +51 -19
- package/README.pt-BR.md +32 -13
- package/bin/pantheon-init.mjs +26 -3
- package/package.json +12 -11
- package/plugin.json +1 -1
- package/pyproject.toml +6 -6
- package/scripts/{code_mode_server.py → code_mode.py} +3 -3
- package/scripts/cost.mjs +244 -41
- package/scripts/doctor.mjs +151 -1
- package/scripts/install/agents-md.mjs +2 -2
- package/scripts/install/config-migration.mjs +80 -2
- package/scripts/install/health-check.mjs +21 -19
- package/scripts/install/opencode-version.mjs +246 -4
- package/scripts/install/opencode.mjs +250 -119
- package/scripts/install/shared.mjs +5 -5
- package/scripts/install-mcp.mjs +28 -9
- package/scripts/{mcp_persistence_server.py → mcp_persistence.py} +2 -2
- package/{src/mcp/mcp_resources_server.py → scripts/mcp_resources.py} +2 -2
- package/scripts/{memory_mcp_server.py → memory_mcp.py} +2 -2
- package/scripts/probe-context-rehydrate.mjs +8 -2
- package/scripts/probe-pantheon-cost.mjs +71 -15
- package/scripts/sync-artifacts.mjs +5 -5
- package/scripts/test-opencode-v2-sandbox.sh +21 -21
- package/scripts/validate-routing.mjs +3 -3
- package/skills-lock.json +1 -1
- package/src/agents/apollo.md +7 -2
- package/src/agents/zeus.md +29 -1
- package/src/instructions/memory-protocol.instructions.md +15 -4
- package/src/instructions/zeus-anti-stall.instructions.md +9 -1
- package/src/mcp/{code_mode_server.py → code_mode.py} +3 -3
- package/src/mcp/eval_store.py +2 -2
- package/src/mcp/{mcp_persistence_server.py → mcp_persistence.py} +2 -2
- package/{scripts/mcp_resources_server.py → src/mcp/mcp_resources.py} +2 -2
- package/src/mcp/{memory_mcp_server.py → memory_mcp.py} +2 -2
- package/src/mcp/{pantheon_vision_server.py → pantheon_vision.py} +1 -1
- package/src/mcp/requirements-mcp.txt +6 -4
- package/src/mcp/requirements-vision.txt +2 -2
- package/src/pantheon/cost-command.ts +399 -101
- package/src/pantheon/presets.mjs +3 -3
- package/src/pantheon/v2-unsupported.d.mts +11 -0
- package/src/pantheon/v2-unsupported.mjs +113 -0
- package/src/plugin-v2.ts +269 -100
- package/src/plugins/tui/dist/server.d.ts +1 -2
- package/src/plugins/tui/dist/server.d.ts.map +1 -1
- package/src/plugins/tui/dist/tui.d.ts +74 -74
- package/src/plugins/tui/dist/tui.d.ts.map +1 -1
- package/src/plugins/tui/dist/tui.js +17 -20
- package/src/plugins/tui/package-lock.json +364 -317
- package/src/plugins/tui/package.json +8 -5
- package/src/plugins/tui/tsconfig.build.json +5 -0
- package/src/plugins/tui/tsdown.config.ts +3 -0
- package/src/skills/context-compression/SKILL.md +1 -1
- package/opencode.json +0 -58
|
@@ -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 = ['
|
|
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', '
|
|
48
|
-
const canonicalVisionServer = join(target, 'src', 'mcp', '
|
|
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/
|
|
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/
|
|
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:
|
|
133
|
+
check: 'syntax:pantheon_vision.py',
|
|
139
134
|
detail: 'valid',
|
|
140
135
|
})
|
|
141
136
|
} else {
|
|
142
137
|
results.failed.push({
|
|
143
|
-
check: 'syntax:
|
|
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
|
-
[
|
|
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({
|
|
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', '
|
|
228
|
-
check: 'src/mcp/
|
|
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', '
|
|
233
|
-
check: 'scripts/
|
|
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
|
]
|
|
@@ -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
|
-
*
|
|
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
|
|
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 = '
|
|
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
|
-
|
|
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
|
}
|