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
package/scripts/cost.mjs
CHANGED
|
@@ -3,19 +3,42 @@
|
|
|
3
3
|
* cost.mjs — read-only token aggregation from opencode.db.
|
|
4
4
|
*
|
|
5
5
|
* CLI fallback for the `pantheon_cost` tool when node:sqlite is unavailable
|
|
6
|
-
* in the plugin process.
|
|
7
|
-
*
|
|
8
|
-
* { ok: true, days, rows: [{agent, phase, tokensInput, tokensOutput, tokensTotal}] }
|
|
6
|
+
* in the plugin process. Prints a single JSON object to stdout:
|
|
7
|
+
* { ok: true, days, rows: [{agent, phase, tokensInput, tokensOutput, tokensTotal}], coverage }
|
|
9
8
|
* { ok: false, error: "<message>" } (exit code 1)
|
|
10
9
|
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
10
|
+
* ## This file is a MIRROR of src/pantheon/cost-command.ts
|
|
11
|
+
*
|
|
12
|
+
* It cannot import that module — it exists precisely to run on a plain Node
|
|
13
|
+
* where the plugin runtime cannot — so the two implementations duplicate the
|
|
14
|
+
* same contract on purpose:
|
|
15
|
+
* - ledger families are detected by TABLE PRESENCE, never by filename and
|
|
16
|
+
* never by a pinned host generation;
|
|
17
|
+
* - PANTHEON_OPENCODE_VERSION only NARROWS an already-detected set;
|
|
18
|
+
* - V1 marks assistant turns with `data.role`, V2 with the `type` COLUMN
|
|
19
|
+
* (V2 payloads carry no `role` key at all);
|
|
20
|
+
* - the coverage counters mean the same thing in both, because the caller
|
|
21
|
+
* feeds them to the same diagnosis.
|
|
22
|
+
*
|
|
23
|
+
* Every one of those was a real divergence once: this script kept the V1
|
|
24
|
+
* predicate and dropped 100% of a V2 ledger, while the caller reported a
|
|
25
|
+
* successful, empty report. A change to one side that is not made on the other
|
|
26
|
+
* is a regression, not a refactor.
|
|
27
|
+
*
|
|
28
|
+
* NEVER writes to the database — opened read-only. Malformed rows are skipped;
|
|
29
|
+
* assistant messages carry the authoritative token usage.
|
|
13
30
|
*
|
|
14
31
|
* Usage: node scripts/cost.mjs --db-path <dbPath> --days <days>
|
|
15
32
|
*/
|
|
16
33
|
import { existsSync } from 'node:fs'
|
|
17
34
|
import { DatabaseSync } from 'node:sqlite'
|
|
18
35
|
|
|
36
|
+
/** Ledger families a host database can carry; a migrated one carries both. */
|
|
37
|
+
const COST_SOURCES = ['message', 'session_message']
|
|
38
|
+
|
|
39
|
+
/** Which family each host generation stores its ledger in. */
|
|
40
|
+
const VERSION_SOURCES = { v1: 'message', v2: 'session_message' }
|
|
41
|
+
|
|
19
42
|
/**
|
|
20
43
|
* Fail via exitCode + natural process exit (NEVER process.exit right after
|
|
21
44
|
* stdout.write — the pipe is async and the write can be truncated).
|
|
@@ -26,22 +49,201 @@ function fail(message) {
|
|
|
26
49
|
process.exitCode = 1
|
|
27
50
|
}
|
|
28
51
|
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
if (
|
|
34
|
-
throw new Error(
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
52
|
+
/** Read and validate the optional host-generation selector. */
|
|
53
|
+
function requestedVersion() {
|
|
54
|
+
const configured = process.env.PANTHEON_OPENCODE_VERSION
|
|
55
|
+
if (configured === undefined || configured === '') return undefined
|
|
56
|
+
if (configured !== 'v1' && configured !== 'v2') {
|
|
57
|
+
throw new Error(`invalid PANTHEON_OPENCODE_VERSION "${configured}"; expected "v1" or "v2"`)
|
|
58
|
+
}
|
|
59
|
+
return configured
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Detect the ledger families by TABLE PRESENCE, then narrow if asked.
|
|
64
|
+
*
|
|
65
|
+
* Presence is the only reliable discriminator: every real installation writes
|
|
66
|
+
* `opencode.db`, and a migrated database carries BOTH families at once.
|
|
67
|
+
*/
|
|
68
|
+
function resolveSources(db) {
|
|
69
|
+
const present = new Set(
|
|
70
|
+
db
|
|
71
|
+
.prepare(
|
|
72
|
+
"SELECT name FROM sqlite_master WHERE type = 'table' AND name IN ('message', 'session_message')",
|
|
73
|
+
)
|
|
74
|
+
.all()
|
|
75
|
+
.map((row) => String(row.name)),
|
|
76
|
+
)
|
|
77
|
+
const detected = COST_SOURCES.filter((source) => present.has(source))
|
|
78
|
+
if (detected.length === 0) {
|
|
79
|
+
throw new Error(
|
|
80
|
+
'incompatible opencode.db schema: neither the "message" nor the "session_message" table exists, so there is no token ledger to read',
|
|
81
|
+
)
|
|
82
|
+
}
|
|
83
|
+
const requested = requestedVersion()
|
|
84
|
+
if (requested === undefined) return detected
|
|
85
|
+
const narrowed = detected.filter((source) => source === VERSION_SOURCES[requested])
|
|
86
|
+
if (narrowed.length === 0) {
|
|
87
|
+
throw new Error(
|
|
88
|
+
`incompatible opencode.db schema: PANTHEON_OPENCODE_VERSION=${requested} selected no table family, but this database contains ${detected.join(' and ')}`,
|
|
89
|
+
)
|
|
90
|
+
}
|
|
91
|
+
return narrowed
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** The columns this reader needs from the family it is about to query. */
|
|
95
|
+
function assertColumns(db, source) {
|
|
96
|
+
const names = new Set(
|
|
97
|
+
db
|
|
98
|
+
.prepare(`PRAGMA table_info(${source})`)
|
|
99
|
+
.all()
|
|
100
|
+
.map((column) => String(column.name)),
|
|
101
|
+
)
|
|
102
|
+
const required =
|
|
103
|
+
source === 'session_message' ? ['data', 'time_created', 'type'] : ['data', 'time_created']
|
|
104
|
+
const missing = required.filter((name) => !names.has(name))
|
|
38
105
|
if (missing.length > 0) {
|
|
39
106
|
throw new Error(
|
|
40
|
-
`incompatible opencode.db schema:
|
|
107
|
+
`incompatible opencode.db schema: ${source} table is missing ${missing.join(', ')}; select a database whose token layout this build understands`,
|
|
41
108
|
)
|
|
42
109
|
}
|
|
43
110
|
}
|
|
44
111
|
|
|
112
|
+
/**
|
|
113
|
+
* Parse one payload. Returns undefined for anything unparseable, which is how
|
|
114
|
+
* both readers treat it: the V1 counter requires json_valid(data) and the V2
|
|
115
|
+
* aggregate does too, so a malformed row contributes to nothing.
|
|
116
|
+
*
|
|
117
|
+
* Deliberately NOT unwrapping a nested `data` string: the in-process reader
|
|
118
|
+
* extracts `$.role`/`$.agent` from the TOP level, so a wrapped payload yields
|
|
119
|
+
* NULL there and is dropped. Unwrapping here would resurrect rows the primary
|
|
120
|
+
* path cannot see, and the two readers would disagree on the same file.
|
|
121
|
+
*/
|
|
122
|
+
function readPayload(data) {
|
|
123
|
+
try {
|
|
124
|
+
const parsed = JSON.parse(String(data))
|
|
125
|
+
return typeof parsed === 'object' && parsed !== null ? parsed : undefined
|
|
126
|
+
} catch {
|
|
127
|
+
return undefined
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** CAST(json_extract(...) AS INTEGER) semantics: absent or non-numeric → 0. */
|
|
132
|
+
function toToken(value) {
|
|
133
|
+
const parsed = Number(value)
|
|
134
|
+
return Number.isFinite(parsed) ? parsed : 0
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Map key for one aggregated row.
|
|
139
|
+
*
|
|
140
|
+
* NUL-separated: agent names cannot contain it, so an agent whose name ends
|
|
141
|
+
* in the phase cannot collide with a phase-only row.
|
|
142
|
+
*/
|
|
143
|
+
function rowKey(agent, phase) {
|
|
144
|
+
return `${agent}\u0000${phase}`
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** Read one family.
|
|
148
|
+
*
|
|
149
|
+
* `assistantRows` follows the per-generation discriminator exactly, including
|
|
150
|
+
* the asymmetry that matters: V2 counts an assistant row by its COLUMN whether
|
|
151
|
+
* or not the payload parses, because that counter SQL has no json_valid gate.
|
|
152
|
+
*/
|
|
153
|
+
function readSource(db, source, since) {
|
|
154
|
+
const windowRow = db
|
|
155
|
+
.prepare(
|
|
156
|
+
`SELECT COUNT(*) AS scanned, MIN(time_created) AS windowStart FROM ${source} WHERE time_created >= ?`,
|
|
157
|
+
)
|
|
158
|
+
.get(since)
|
|
159
|
+
const databaseRow = db.prepare(`SELECT MIN(time_created) AS databaseStart FROM ${source}`).get()
|
|
160
|
+
const select = source === 'session_message' ? 'SELECT type, data' : 'SELECT data'
|
|
161
|
+
const rows = db.prepare(`${select} FROM ${source} WHERE time_created >= ?`).all(since)
|
|
162
|
+
|
|
163
|
+
const groups = new Map()
|
|
164
|
+
let assistantRows = 0
|
|
165
|
+
for (const row of rows) {
|
|
166
|
+
const info = readPayload(row.data)
|
|
167
|
+
if (source === 'session_message') {
|
|
168
|
+
if (row.type !== 'assistant') continue
|
|
169
|
+
assistantRows += 1
|
|
170
|
+
if (info === undefined) continue
|
|
171
|
+
} else {
|
|
172
|
+
if (info === undefined || info.role !== 'assistant') continue
|
|
173
|
+
assistantRows += 1
|
|
174
|
+
}
|
|
175
|
+
const rawAgent = info.agent
|
|
176
|
+
const agent = rawAgent == null ? '' : typeof rawAgent === 'string' ? rawAgent : String(rawAgent)
|
|
177
|
+
if (agent === '') continue
|
|
178
|
+
// V2 has no phase anywhere: its SQL emits the literal, so the mirror does
|
|
179
|
+
// too rather than inventing a column the reader never had.
|
|
180
|
+
const metadata = info.metadata
|
|
181
|
+
const phase =
|
|
182
|
+
source === 'session_message'
|
|
183
|
+
? 'unknown'
|
|
184
|
+
: (typeof info.phase === 'string' && info.phase) ||
|
|
185
|
+
(typeof metadata?.phase === 'string' && metadata.phase) ||
|
|
186
|
+
'unknown'
|
|
187
|
+
const tokensInput = toToken(info.tokens?.input)
|
|
188
|
+
const tokensOutput = toToken(info.tokens?.output)
|
|
189
|
+
const key = rowKey(agent, phase)
|
|
190
|
+
const acc = groups.get(key) ?? {
|
|
191
|
+
agent,
|
|
192
|
+
phase,
|
|
193
|
+
tokensInput: 0,
|
|
194
|
+
tokensOutput: 0,
|
|
195
|
+
tokensTotal: 0,
|
|
196
|
+
}
|
|
197
|
+
acc.tokensInput += tokensInput
|
|
198
|
+
acc.tokensOutput += tokensOutput
|
|
199
|
+
acc.tokensTotal += tokensInput + tokensOutput
|
|
200
|
+
groups.set(key, acc)
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
return {
|
|
204
|
+
groups,
|
|
205
|
+
scanned: Number(windowRow?.scanned ?? 0) || 0,
|
|
206
|
+
windowStart: numberOrUndefined(windowRow?.windowStart),
|
|
207
|
+
databaseStart: numberOrUndefined(databaseRow?.databaseStart),
|
|
208
|
+
assistantRows,
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function numberOrUndefined(value) {
|
|
213
|
+
if (value === null || value === undefined) return undefined
|
|
214
|
+
const parsed = Number(value)
|
|
215
|
+
return Number.isFinite(parsed) ? parsed : undefined
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* The same verdict the in-process reader reaches, with the same phrase: the
|
|
220
|
+
* caller maps "no readable token source" to UNSUPPORTED, so emitting anything
|
|
221
|
+
* softer here would resurrect the silent-empty-report failure this replaced.
|
|
222
|
+
*
|
|
223
|
+
* `scanned === 0` is NOT unreadable — an empty window is a real, reportable
|
|
224
|
+
* answer, and only a ledger that was found and could not be read is a fault.
|
|
225
|
+
*/
|
|
226
|
+
function diagnoseUnreadableLedger(coverage, rows, days) {
|
|
227
|
+
if (coverage.scanned === 0) return undefined
|
|
228
|
+
const tables = coverage.sources.join(' and ')
|
|
229
|
+
if (coverage.assistantRows === 0) {
|
|
230
|
+
return (
|
|
231
|
+
`no readable token source: ${coverage.scanned} row(s) were scanned in the last ${days} day(s) ` +
|
|
232
|
+
`but none matched the assistant discriminator of ${tables}, so no usage can be attributed. ` +
|
|
233
|
+
'This is an unrecognised ledger layout, not an empty one; point PANTHEON_COST_DB at a ' +
|
|
234
|
+
'database whose token layout this build understands.'
|
|
235
|
+
)
|
|
236
|
+
}
|
|
237
|
+
if (!rows.some((row) => row.tokensInput !== 0 || row.tokensOutput !== 0)) {
|
|
238
|
+
return (
|
|
239
|
+
`no readable token source: ${coverage.assistantRows} assistant row(s) were found in ${tables} ` +
|
|
240
|
+
'but none carried both an attributable agent and a token payload, so no usage can be ' +
|
|
241
|
+
'attributed. This is an unrecognised ledger layout, not an empty one.'
|
|
242
|
+
)
|
|
243
|
+
}
|
|
244
|
+
return undefined
|
|
245
|
+
}
|
|
246
|
+
|
|
45
247
|
function main() {
|
|
46
248
|
const argv = process.argv.slice(2)
|
|
47
249
|
const valueFor = (name) => {
|
|
@@ -56,44 +258,45 @@ function main() {
|
|
|
56
258
|
|
|
57
259
|
try {
|
|
58
260
|
const db = new DatabaseSync(dbPath, { readOnly: true })
|
|
59
|
-
|
|
261
|
+
const sources = resolveSources(db)
|
|
262
|
+
for (const source of sources) assertColumns(db, source)
|
|
60
263
|
const since = Date.now() - days * 86_400_000
|
|
61
|
-
const rows = db.prepare('SELECT data FROM message WHERE time_created >= ?').all(since)
|
|
62
|
-
db.close()
|
|
63
264
|
|
|
64
265
|
const byAgent = new Map()
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
(typeof metadata?.phase === 'string' && metadata.phase) ||
|
|
76
|
-
'unknown'
|
|
77
|
-
const tokensInput = Number(info.tokens?.input) || 0
|
|
78
|
-
const tokensOutput = Number(info.tokens?.output) || 0
|
|
79
|
-
const key = `${agent}\u0000${phase}`
|
|
266
|
+
const coverage = { sources: [], scanned: 0, assistantRows: 0 }
|
|
267
|
+
const note = (key, value) => {
|
|
268
|
+
if (value === undefined) return
|
|
269
|
+
const current = coverage[key]
|
|
270
|
+
coverage[key] = current === undefined ? value : Math.min(current, value)
|
|
271
|
+
}
|
|
272
|
+
for (const source of sources) {
|
|
273
|
+
const read = readSource(db, source, since)
|
|
274
|
+
for (const row of read.groups.values()) {
|
|
275
|
+
const key = rowKey(row.agent, row.phase)
|
|
80
276
|
const acc = byAgent.get(key) ?? {
|
|
81
|
-
agent,
|
|
82
|
-
phase,
|
|
277
|
+
agent: row.agent,
|
|
278
|
+
phase: row.phase,
|
|
83
279
|
tokensInput: 0,
|
|
84
280
|
tokensOutput: 0,
|
|
85
281
|
tokensTotal: 0,
|
|
86
282
|
}
|
|
87
|
-
acc.tokensInput += tokensInput
|
|
88
|
-
acc.tokensOutput += tokensOutput
|
|
89
|
-
acc.tokensTotal += tokensInput + tokensOutput
|
|
283
|
+
acc.tokensInput += row.tokensInput
|
|
284
|
+
acc.tokensOutput += row.tokensOutput
|
|
285
|
+
acc.tokensTotal += row.tokensInput + row.tokensOutput
|
|
90
286
|
byAgent.set(key, acc)
|
|
91
|
-
} catch {
|
|
92
|
-
// Skip malformed rows — a partial ledger never breaks the report.
|
|
93
287
|
}
|
|
288
|
+
coverage.sources.push(source)
|
|
289
|
+
coverage.scanned += read.scanned
|
|
290
|
+
coverage.assistantRows += read.assistantRows
|
|
291
|
+
note('windowStart', read.windowStart)
|
|
292
|
+
note('databaseStart', read.databaseStart)
|
|
94
293
|
}
|
|
294
|
+
db.close()
|
|
295
|
+
|
|
95
296
|
const result = [...byAgent.values()].sort((a, b) => b.tokensTotal - a.tokensTotal)
|
|
96
|
-
|
|
297
|
+
const unreadable = diagnoseUnreadableLedger(coverage, result, days)
|
|
298
|
+
if (unreadable !== undefined) return fail(unreadable)
|
|
299
|
+
process.stdout.write(JSON.stringify({ ok: true, days, rows: result, coverage }))
|
|
97
300
|
} catch (err) {
|
|
98
301
|
fail(err instanceof Error ? err.message : String(err))
|
|
99
302
|
}
|
package/scripts/doctor.mjs
CHANGED
|
@@ -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/
|
|
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
|
|
|
@@ -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
|
|
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 =
|
|
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),
|
|
@@ -229,8 +229,11 @@ function convertProviderV2toV1(_name, config) {
|
|
|
229
229
|
/**
|
|
230
230
|
* Convert V1 MCP config to V2.
|
|
231
231
|
*
|
|
232
|
-
*
|
|
233
|
-
*
|
|
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
|
// ---------------------------------------------------------------------------
|