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.
- package/AGENTS.global.md +182 -0
- package/AGENTS.md +10 -891
- package/CHANGELOG.md +326 -0
- package/README.md +496 -32
- package/README.pt-BR.md +285 -20
- package/bin/pantheon-init.mjs +26 -3
- package/commands/pantheon-audit.md +7 -1
- package/commands/pantheon-consolidate.md +1 -1
- package/commands/pantheon-deepwork.md +14 -3
- package/commands/pantheon-focus.md +17 -0
- package/commands/pantheon-vision.md +61 -12
- package/package.json +21 -14
- package/plugin.json +2 -2
- package/pyproject.toml +8 -6
- package/scripts/build-agents-md.mjs +374 -56
- package/scripts/changelog-merge.mjs +161 -0
- 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 +4 -4
- package/scripts/install/config-migration.mjs +317 -4
- package/scripts/install/health-check.mjs +21 -19
- package/scripts/install/interactive.mjs +1 -1
- package/scripts/install/opencode-version.mjs +246 -4
- package/scripts/install/opencode.mjs +627 -179
- package/scripts/install/plugin.mjs +43 -9
- package/scripts/install/shared.mjs +120 -12
- package/scripts/install/vision-prompt.mjs +161 -0
- 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/run-node-tests.mjs +189 -0
- package/scripts/sync-artifacts.mjs +26 -13
- package/scripts/sync-tui.mjs +24 -3
- package/scripts/test-opencode-v2-sandbox.sh +180 -26
- package/scripts/validate-routing.mjs +3 -3
- package/scripts/validate-sandbox-install.mjs +138 -0
- package/scripts/versioning.mjs +13 -1
- package/scripts/vision-status.mjs +682 -0
- package/skills-lock.json +8 -8
- package/src/agents/aphrodite.md +20 -37
- package/src/agents/apollo.md +7 -3
- package/src/agents/athena.md +63 -35
- package/src/agents/demeter.md +8 -28
- package/src/agents/gaia.md +1 -1
- package/src/agents/hephaestus.md +10 -21
- package/src/agents/hermes.md +15 -38
- package/src/agents/iris.md +1 -1
- package/src/agents/mnemosyne.md +18 -54
- package/src/agents/nyx.md +1 -1
- package/src/agents/prometheus.md +16 -20
- package/src/agents/talos.md +10 -2
- package/src/agents/themis.md +5 -5
- package/src/agents/zeus.md +99 -129
- package/src/instructions/agent-return-format.instructions.md +18 -3
- package/src/instructions/backend-standards.instructions.md +2 -0
- package/src/instructions/frontend-standards.instructions.md +3 -1
- package/src/instructions/memory-protocol.instructions.md +11 -110
- package/src/instructions/zeus-anti-stall.instructions.md +14 -61
- package/src/instructions/zeus-communication-rules.instructions.md +0 -1
- package/src/instructions/zeus-council-synthesis.instructions.md +32 -260
- package/src/instructions/zeus-memory-operations.instructions.md +26 -0
- package/src/instructions/zeus-timeout-retry.instructions.md +40 -87
- 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} +286 -42
- 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/delegation-enforce.ts +10 -1
- package/src/pantheon/presets.d.mts +0 -8
- package/src/pantheon/presets.mjs +5 -32
- package/src/pantheon/v2-bounded-read.ts +640 -0
- package/src/pantheon/v2-delegation-enforce.ts +214 -0
- package/src/pantheon/v2-execute-after.ts +186 -0
- package/src/pantheon/v2-tools.ts +429 -0
- package/src/pantheon/v2-unsupported.d.mts +11 -0
- package/src/pantheon/v2-unsupported.mjs +90 -0
- package/src/pantheon/v2-vision.ts +622 -0
- package/src/pantheon/vision-local.ts +531 -0
- package/src/pantheon/vision.ts +1160 -115
- package/src/plugin-v2.ts +1145 -219
- package/src/plugin.ts +12 -9
- 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-v2.d.ts +154 -0
- package/src/plugins/tui/dist/tui-v2.d.ts.map +1 -0
- package/src/plugins/tui/dist/tui-v2.js +659 -0
- package/src/plugins/tui/dist/tui-v2.js.map +1 -0
- package/src/plugins/tui/dist/tui.d.ts +85 -82
- package/src/plugins/tui/dist/tui.d.ts.map +1 -1
- package/src/plugins/tui/dist/tui.js +72 -59
- package/src/plugins/tui/index-v2.ts +6 -0
- package/src/plugins/tui/package-lock.json +5516 -1353
- package/src/plugins/tui/package.json +22 -12
- package/src/plugins/tui/src/index-v2.tsx +734 -0
- package/src/plugins/tui/src/index.tsx +106 -85
- package/src/plugins/tui/src/opencode-v2.d.ts +13 -0
- package/src/plugins/tui/tsconfig.build.json +5 -0
- package/src/plugins/tui/tsconfig.typecheck.json +5 -0
- package/src/plugins/tui/tsdown.config.ts +22 -2
- package/src/plugins/tui/tui-v2.ts +2 -0
- package/src/plugins/tui/tui.ts +3 -0
- package/src/routing.yml +11 -21
- package/src/skills/README.md +3 -3
- package/src/skills/agent-coordination/SKILL.md +7 -2
- package/src/skills/artifact-management/SKILL.md +12 -5
- package/src/skills/auto-continue/SKILL.md +39 -30
- package/src/skills/code-review-checklist/SKILL.md +5 -5
- package/src/skills/context-compression/SKILL.md +26 -879
- package/src/skills/session-goal/SKILL.md +14 -5
- package/src/skills/spec-driven-development/SKILL.md +3 -1
- package/src/skills/tdd-with-agents/SKILL.md +18 -10
- package/opencode.json +0 -58
|
@@ -8,24 +8,24 @@ import { isAbsolute, join, resolve } from 'node:path'
|
|
|
8
8
|
import { ROOT, writeIfChanged } from './shared.mjs'
|
|
9
9
|
|
|
10
10
|
/**
|
|
11
|
-
* Copy a plugin's runtime payload (the bundled dist/*,
|
|
12
|
-
* package-lock.json) from source to destination.
|
|
13
|
-
*
|
|
14
|
-
*
|
|
11
|
+
* Copy a plugin's runtime payload (the root TUI loader, bundled dist/*,
|
|
12
|
+
* package.json and package-lock.json) from source to destination. The raw TSX
|
|
13
|
+
* implementation is never shipped to the destination; tui.ts only re-exports
|
|
14
|
+
* the host-specific bundle so the local plugin stays hermetic.
|
|
15
15
|
* @param {string} srcDir - Source plugin directory (e.g. ROOT/src/plugins/tui)
|
|
16
16
|
* @param {string} dstDir - Destination plugin directory
|
|
17
17
|
* @param {object} [options]
|
|
18
18
|
* @param {boolean} [options.dryRun=false] - If true, don't write files
|
|
19
|
+
* @param {'v1'|'v2'} [options.version='v1'] - Host plugin generation
|
|
19
20
|
* @returns {{ created: number, skipped: number }}
|
|
20
21
|
*/
|
|
21
|
-
export function copyPluginFiles(srcDir, dstDir, { dryRun = false } = {}) {
|
|
22
|
+
export function copyPluginFiles(srcDir, dstDir, { dryRun = false, version = 'v1' } = {}) {
|
|
22
23
|
const result = { created: 0, skipped: 0 }
|
|
23
24
|
|
|
24
25
|
if (!dryRun) mkdirSync(dstDir, { recursive: true })
|
|
25
26
|
if (!dryRun) mkdirSync(join(dstDir, 'dist'), { recursive: true })
|
|
26
27
|
|
|
27
28
|
// Drop legacy raw copies from previous installs (root index.tsx + dist/tui.tsx).
|
|
28
|
-
// The loader consumes only the bundle, so stale raws must not linger.
|
|
29
29
|
if (!dryRun) {
|
|
30
30
|
rmSync(join(dstDir, 'index.tsx'), { force: true })
|
|
31
31
|
rmSync(join(dstDir, 'dist', 'tui.tsx'), { force: true })
|
|
@@ -45,15 +45,45 @@ export function copyPluginFiles(srcDir, dstDir, { dryRun = false } = {}) {
|
|
|
45
45
|
// Copy package.json
|
|
46
46
|
const pkgSrc = join(srcDir, 'package.json')
|
|
47
47
|
if (existsSync(pkgSrc)) {
|
|
48
|
+
const packageJson = JSON.parse(readFileSync(pkgSrc, 'utf8'))
|
|
49
|
+
if (version === 'v2' && packageJson.exports?.['./tui-v2']) {
|
|
50
|
+
packageJson.exports['./tui'] = packageJson.exports['./tui-v2']
|
|
51
|
+
}
|
|
48
52
|
const pkgStatus = writeIfChanged(
|
|
49
53
|
join(dstDir, 'package.json'),
|
|
50
|
-
|
|
54
|
+
`${JSON.stringify(packageJson, null, 2)}\n`,
|
|
51
55
|
dryRun,
|
|
52
56
|
)
|
|
53
57
|
if (pkgStatus === 'created') result.created++
|
|
54
58
|
else result.skipped++
|
|
55
59
|
}
|
|
56
60
|
|
|
61
|
+
// Each host generation receives its own root loader and compiled bundle.
|
|
62
|
+
const tuiEntrySrc = join(srcDir, version === 'v2' ? 'tui-v2.ts' : 'tui.ts')
|
|
63
|
+
if (existsSync(tuiEntrySrc)) {
|
|
64
|
+
const entryStatus = writeIfChanged(
|
|
65
|
+
join(dstDir, 'tui.ts'),
|
|
66
|
+
readFileSync(tuiEntrySrc, 'utf8'),
|
|
67
|
+
dryRun,
|
|
68
|
+
)
|
|
69
|
+
if (entryStatus === 'created') result.created++
|
|
70
|
+
else result.skipped++
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const v2ServerEntry = join(srcDir, 'index-v2.ts')
|
|
74
|
+
const v2ServerDestination = join(dstDir, 'index.ts')
|
|
75
|
+
if (version === 'v2' && existsSync(v2ServerEntry)) {
|
|
76
|
+
const entryStatus = writeIfChanged(
|
|
77
|
+
v2ServerDestination,
|
|
78
|
+
readFileSync(v2ServerEntry, 'utf8'),
|
|
79
|
+
dryRun,
|
|
80
|
+
)
|
|
81
|
+
if (entryStatus === 'created') result.created++
|
|
82
|
+
else result.skipped++
|
|
83
|
+
} else if (!dryRun) {
|
|
84
|
+
rmSync(v2ServerDestination, { force: true })
|
|
85
|
+
}
|
|
86
|
+
|
|
57
87
|
// Copy the lockfile so dependency installation is reproducible. A missing
|
|
58
88
|
// lockfile is intentionally not synthesized by the installer.
|
|
59
89
|
const lockSrc = join(srcDir, 'package-lock.json')
|
|
@@ -79,14 +109,18 @@ export function copyPluginFiles(srcDir, dstDir, { dryRun = false } = {}) {
|
|
|
79
109
|
* @param {boolean} [options.clean=false] - If true, remove existing before install
|
|
80
110
|
* @returns {{ created: number, skipped: number, errors: number }}
|
|
81
111
|
*/
|
|
82
|
-
export function installPlugin(
|
|
112
|
+
export function installPlugin(
|
|
113
|
+
srcDir,
|
|
114
|
+
dstDir,
|
|
115
|
+
{ dryRun = false, clean = false, version = 'v1' } = {},
|
|
116
|
+
) {
|
|
83
117
|
const result = { created: 0, skipped: 0, errors: 0 }
|
|
84
118
|
|
|
85
119
|
if (clean && existsSync(dstDir) && !dryRun) {
|
|
86
120
|
rmSync(dstDir, { recursive: true, force: true })
|
|
87
121
|
}
|
|
88
122
|
|
|
89
|
-
const copyResult = copyPluginFiles(srcDir, dstDir, { dryRun })
|
|
123
|
+
const copyResult = copyPluginFiles(srcDir, dstDir, { dryRun, version })
|
|
90
124
|
result.created += copyResult.created
|
|
91
125
|
result.skipped += copyResult.skipped
|
|
92
126
|
|
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
copyFileSync,
|
|
8
8
|
cpSync,
|
|
9
9
|
existsSync,
|
|
10
|
+
lstatSync,
|
|
10
11
|
mkdirSync,
|
|
11
12
|
readdirSync,
|
|
12
13
|
readFileSync,
|
|
@@ -24,7 +25,7 @@ if (parseInt(process.versions.node.split('.')[0], 10) < REQUIRED_NODE_MAJOR) {
|
|
|
24
25
|
process.exit(1)
|
|
25
26
|
}
|
|
26
27
|
|
|
27
|
-
import
|
|
28
|
+
import { dump, load } from 'js-yaml'
|
|
28
29
|
|
|
29
30
|
export const __dirname = dirname(fileURLToPath(import.meta.url))
|
|
30
31
|
export const ROOT = join(__dirname, '..', '..')
|
|
@@ -257,17 +258,105 @@ export function copyFiles(srcDir, dstDir, dryRun, renameMap = null, clean = fals
|
|
|
257
258
|
return { created, skipped }
|
|
258
259
|
}
|
|
259
260
|
|
|
260
|
-
|
|
261
|
-
|
|
261
|
+
/**
|
|
262
|
+
* `lstat` that returns `null` instead of throwing for an absent path.
|
|
263
|
+
*
|
|
264
|
+
* Deliberately `lstat`, not `stat`/`existsSync`: both of those follow the link,
|
|
265
|
+
* so a DANGLING symlink reads as absent and the atomic rename below would then
|
|
266
|
+
* silently replace the link itself. `lstat` sees the link and lets the write
|
|
267
|
+
* guard refuse it.
|
|
268
|
+
*/
|
|
269
|
+
function lstatOrNull(filePath) {
|
|
270
|
+
try {
|
|
271
|
+
return lstatSync(filePath)
|
|
272
|
+
} catch {
|
|
273
|
+
return null
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* Read a path's raw text, or `null` when it cannot be read (absent, dangling
|
|
279
|
+
* symlink target, permission error). The caller decides what "unreadable"
|
|
280
|
+
* means; this never throws.
|
|
281
|
+
*/
|
|
282
|
+
function readFileOrNull(filePath) {
|
|
283
|
+
try {
|
|
284
|
+
return readFileSync(filePath, 'utf8')
|
|
285
|
+
} catch {
|
|
286
|
+
return null
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* Atomically create/overwrite a text file, skipping an unchanged write.
|
|
292
|
+
*
|
|
293
|
+
* @param {string} filePath - destination path
|
|
294
|
+
* @param {string} content - full file content
|
|
295
|
+
* @param {boolean} dryRun - when true, compute the outcome without writing
|
|
296
|
+
* @param {object} [options]
|
|
297
|
+
* @param {string|null} [options.expectedContent] - the raw content the caller
|
|
298
|
+
* read this path from, if any. When supplied, the write proceeds only if the
|
|
299
|
+
* on-disk content STILL matches it — compared once up front AND again
|
|
300
|
+
* immediately before the rename; otherwise it returns `'conflict'` and leaves
|
|
301
|
+
* the file alone. This is a best-effort COMPARE-BEFORE-WRITE guard, NOT a
|
|
302
|
+
* filesystem CAS: the final re-read and the rename are still two syscalls, so
|
|
303
|
+
* a writer that lands between them is not prevented. What it does close is
|
|
304
|
+
* the window the installer actually races with — a config the caller parsed
|
|
305
|
+
* going stale during the same run — and it keeps the target and the `.bak`
|
|
306
|
+
* consistent with the bytes that were checked.
|
|
307
|
+
* @param {(existing: string|null) => void} [options.beforeRecheck] - runs after
|
|
308
|
+
* the initial content check and before the last-moment re-read. Injection
|
|
309
|
+
* point so tests can simulate a concurrent writer landing in the window;
|
|
310
|
+
* production callers omit it (undefined is a no-op).
|
|
311
|
+
* @param {(existing: string|null) => void} [options.beforeSwap] - runs after the
|
|
312
|
+
* expected-content checks pass and immediately before the rename, so side
|
|
313
|
+
* effects that must not fire on a conflict (e.g. the `.bak` copy) live here.
|
|
314
|
+
* @returns {'created'|'skipped'|'conflict'}
|
|
315
|
+
*/
|
|
316
|
+
export function writeIfChanged(filePath, content, dryRun, options = {}) {
|
|
317
|
+
const { expectedContent, beforeSwap, beforeRecheck } = options
|
|
318
|
+
|
|
319
|
+
// Refuse to write through/over a symlink. A dangling symlink is invisible to
|
|
320
|
+
// existsSync, and rename-over-link would replace the link rather than its
|
|
321
|
+
// target — both are silent redirections the installer must not perform.
|
|
322
|
+
const stat = lstatOrNull(filePath)
|
|
323
|
+
if (stat?.isSymbolicLink()) {
|
|
324
|
+
throw new Error(`Refusing to write through symlink: ${filePath}`)
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
const existing = stat === null ? null : readFileOrNull(filePath)
|
|
262
328
|
if (existing === content) {
|
|
263
329
|
return 'skipped'
|
|
264
330
|
}
|
|
331
|
+
if (expectedContent !== undefined && existing !== expectedContent) {
|
|
332
|
+
// The file changed since the caller read it (or appeared/disappeared).
|
|
333
|
+
// Do not clobber the newer content; the caller decides how to react.
|
|
334
|
+
return 'conflict'
|
|
335
|
+
}
|
|
265
336
|
if (!dryRun) {
|
|
337
|
+
// Seam for a concurrent writer to land between the read above and the
|
|
338
|
+
// re-read below. No production caller passes this.
|
|
339
|
+
if (typeof beforeRecheck === 'function') beforeRecheck(existing)
|
|
340
|
+
|
|
266
341
|
// Atomic write: write a temp sibling and rename over the target so a
|
|
267
342
|
// crash mid-write can never leave a truncated file behind (rename is
|
|
268
343
|
// atomic within the same directory).
|
|
269
344
|
const tmpPath = `${filePath}.tmp-${process.pid}`
|
|
270
345
|
writeFileSync(tmpPath, content, 'utf8')
|
|
346
|
+
|
|
347
|
+
// Last-moment re-read. The initial compare and this rename are separate
|
|
348
|
+
// syscalls, so a concurrent writer can land in between; re-check the
|
|
349
|
+
// expected bytes immediately before the swap. On a mismatch, drop the temp
|
|
350
|
+
// and leave the target AND any existing `.bak` byte-identical.
|
|
351
|
+
if (expectedContent !== undefined) {
|
|
352
|
+
const current = readFileOrNull(filePath)
|
|
353
|
+
if (current !== expectedContent) {
|
|
354
|
+
rmSync(tmpPath, { force: true })
|
|
355
|
+
return 'conflict'
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
if (typeof beforeSwap === 'function') beforeSwap(existing)
|
|
271
360
|
renameSync(tmpPath, filePath)
|
|
272
361
|
}
|
|
273
362
|
return 'created'
|
|
@@ -277,12 +366,31 @@ export function writeIfChanged(filePath, content, dryRun) {
|
|
|
277
366
|
* beta.5: write the user's opencode.json with a same-directory backup of the
|
|
278
367
|
* previous content. The installer rewrites the whole config; a `.bak` gives
|
|
279
368
|
* the user a one-step recovery if a merge ever goes wrong.
|
|
369
|
+
*
|
|
370
|
+
* The backup is created INSIDE the `beforeSwap` hook, so it fires only after
|
|
371
|
+
* both expected-content checks pass (the initial compare and the last-moment
|
|
372
|
+
* re-read before the rename). A conflict observed at either point leaves the
|
|
373
|
+
* target AND any pre-existing `.bak` untouched, rather than overwriting the
|
|
374
|
+
* user's last known-good backup with a stale copy on a no-op write.
|
|
375
|
+
*
|
|
376
|
+
* @param {string} filePath
|
|
377
|
+
* @param {string} content
|
|
378
|
+
* @param {boolean} dryRun
|
|
379
|
+
* @param {object} [options] - forwarded to {@link writeIfChanged}
|
|
280
380
|
*/
|
|
281
|
-
export function writeConfigWithBackup(filePath, content, dryRun) {
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
381
|
+
export function writeConfigWithBackup(filePath, content, dryRun, options = {}) {
|
|
382
|
+
return writeIfChanged(filePath, content, dryRun, {
|
|
383
|
+
expectedContent: options.expectedContent,
|
|
384
|
+
beforeRecheck: options.beforeRecheck,
|
|
385
|
+
beforeSwap: (existing) => {
|
|
386
|
+
if (existing === null) return
|
|
387
|
+
try {
|
|
388
|
+
copyFileSync(filePath, `${filePath}.bak`)
|
|
389
|
+
} catch {
|
|
390
|
+
// Best-effort: a backup failure must not block the install.
|
|
391
|
+
}
|
|
392
|
+
},
|
|
393
|
+
})
|
|
286
394
|
}
|
|
287
395
|
|
|
288
396
|
/**
|
|
@@ -326,10 +434,10 @@ export function collectSkillNames() {
|
|
|
326
434
|
* Returns { fm: object, body: string } or null if no frontmatter.
|
|
327
435
|
*/
|
|
328
436
|
export function parseFrontmatter(content) {
|
|
329
|
-
const match = content.match(/^---\r?\n([\s\S]*?)
|
|
437
|
+
const match = content.match(/^---\r?\n([\s\S]*?)(?:\r?\n)?---\r?\n?([\s\S]*)$/)
|
|
330
438
|
if (!match) return null
|
|
331
439
|
return {
|
|
332
|
-
fm:
|
|
440
|
+
fm: match[1].trim() ? (load(match[1]) ?? {}) : {},
|
|
333
441
|
body: match[2],
|
|
334
442
|
}
|
|
335
443
|
}
|
|
@@ -339,9 +447,9 @@ export function parseFrontmatter(content) {
|
|
|
339
447
|
* Uses long-line mode and avoids unnecessary quoting.
|
|
340
448
|
*/
|
|
341
449
|
export function serializeFm(fm) {
|
|
342
|
-
return
|
|
450
|
+
return dump(fm, {
|
|
343
451
|
lineWidth: -1,
|
|
344
|
-
|
|
452
|
+
quoteStyle: 'double',
|
|
345
453
|
forceQuotes: false,
|
|
346
454
|
noRefs: true,
|
|
347
455
|
})
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* vision-prompt.mjs — optional vision step of `pantheon-opencode init`.
|
|
3
|
+
*
|
|
4
|
+
* After the model-preset wizard, an interactive TTY session is offered a
|
|
5
|
+
* three-way choice for the vision model:
|
|
6
|
+
* 1. automatic discovery (default, Enter) — writes nothing
|
|
7
|
+
* 2. pin a `provider/model` — merges `visionModel` into the
|
|
8
|
+
* GLOBAL vision config (~/.config/opencode/opencode-vision.json)
|
|
9
|
+
* 3. skip — writes nothing
|
|
10
|
+
*
|
|
11
|
+
* Non-interactive runs (no TTY, `--yes`, `--headless`, automated sandboxes)
|
|
12
|
+
* never reach this module: `installOpenCode` only calls it when
|
|
13
|
+
* `interactive && !dryRun && !autoYes`. Automatic discovery therefore stays
|
|
14
|
+
* the default in every non-interactive flow and no run blocks on stdin.
|
|
15
|
+
*
|
|
16
|
+
* The write mirrors the `/pantheon-vision` command: atomic, `.bak` backup,
|
|
17
|
+
* and a MERGE — existing keys are never overwritten.
|
|
18
|
+
*/
|
|
19
|
+
import { existsSync, mkdirSync, readFileSync } from 'node:fs'
|
|
20
|
+
import { homedir } from 'node:os'
|
|
21
|
+
import { dirname, join } from 'node:path'
|
|
22
|
+
import { writeConfigWithBackup } from './shared.mjs'
|
|
23
|
+
|
|
24
|
+
const CONFIG_FILENAME = 'opencode-vision.json'
|
|
25
|
+
/** `provider/model` — exactly one slash, no surrounding whitespace. */
|
|
26
|
+
const MODEL_PATTERN = /^[^\s/]+\/[^\s/]+$/
|
|
27
|
+
const MAX_PIN_ATTEMPTS = 3
|
|
28
|
+
|
|
29
|
+
/** Global vision config path, honoring `XDG_CONFIG_HOME` (same rule as the plugin). */
|
|
30
|
+
export function globalVisionConfigPath(env = process.env) {
|
|
31
|
+
const home = env.HOME?.trim() || homedir()
|
|
32
|
+
const xdg = env.XDG_CONFIG_HOME?.trim() || join(home, '.config')
|
|
33
|
+
return join(xdg, 'opencode', CONFIG_FILENAME)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** True when `value` looks like a `provider/model` id (one slash, no spaces). */
|
|
37
|
+
export function isValidVisionModel(value) {
|
|
38
|
+
return typeof value === 'string' && MODEL_PATTERN.test(value.trim())
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Merge `visionModel` into a vision config, preserving every other key.
|
|
43
|
+
* Defaults to the GLOBAL config; pass `configPath` to target another file
|
|
44
|
+
* (e.g. the project config `.opencode/opencode-vision.json`). Creates the
|
|
45
|
+
* parent directory when missing; never overwrites existing keys. A corrupted
|
|
46
|
+
* JSON file is left byte-identical (the caller decides what to tell the user)
|
|
47
|
+
* rather than clobbered.
|
|
48
|
+
*
|
|
49
|
+
* A concurrent writer that changes the file between the read and the atomic
|
|
50
|
+
* swap makes `writeConfigWithBackup` return `'conflict'`: the write is aborted,
|
|
51
|
+
* `wrote` is `false`, and the newer content is left untouched.
|
|
52
|
+
*
|
|
53
|
+
* @param {object} [opts]
|
|
54
|
+
* @param {Record<string, string|undefined>} [opts.env]
|
|
55
|
+
* @param {boolean} [opts.dryRun]
|
|
56
|
+
* @param {string} [opts.configPath] - explicit target file (defaults to global)
|
|
57
|
+
* @param {(existing: string|null) => void} [opts.beforeRecheck] - test seam
|
|
58
|
+
* forwarded to `writeConfigWithBackup` to simulate a concurrent writer.
|
|
59
|
+
* @returns {{path: string, wrote: boolean, reason: 'created'|'skipped'|'conflict'|'corrupt'|'dry-run'}}
|
|
60
|
+
*/
|
|
61
|
+
export function mergeVisionModel(
|
|
62
|
+
model,
|
|
63
|
+
{ env = process.env, dryRun = false, beforeRecheck, configPath: target } = {},
|
|
64
|
+
) {
|
|
65
|
+
const configPath = target ?? globalVisionConfigPath(env)
|
|
66
|
+
let parsed = {}
|
|
67
|
+
let existingRaw
|
|
68
|
+
if (existsSync(configPath)) {
|
|
69
|
+
existingRaw = readFileSync(configPath, 'utf8')
|
|
70
|
+
try {
|
|
71
|
+
const data = JSON.parse(existingRaw)
|
|
72
|
+
if (data && typeof data === 'object' && !Array.isArray(data)) parsed = data
|
|
73
|
+
} catch {
|
|
74
|
+
return { path: configPath, wrote: false, reason: 'corrupt' }
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
if (dryRun) return { path: configPath, wrote: false, reason: 'dry-run' }
|
|
78
|
+
const next = { ...parsed, visionModel: model }
|
|
79
|
+
const content = `${JSON.stringify(next, null, 2)}\n`
|
|
80
|
+
mkdirSync(dirname(configPath), { recursive: true })
|
|
81
|
+
const status = writeConfigWithBackup(configPath, content, false, {
|
|
82
|
+
expectedContent: existingRaw,
|
|
83
|
+
beforeRecheck,
|
|
84
|
+
})
|
|
85
|
+
return { path: configPath, wrote: status === 'created', reason: status }
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Ask the interactive vision question. `rl` may be injected (tests / an
|
|
90
|
+
* existing readline interface); otherwise a private one is created and closed.
|
|
91
|
+
* Returns `{action, visionModel?, wrote?, path?}` — never throws on bad input,
|
|
92
|
+
* and never writes for the default/skip paths.
|
|
93
|
+
*
|
|
94
|
+
* @param {{rl?: object, logger?: object, env?: Record<string, string|undefined>, dryRun?: boolean, beforeRecheck?: (existing: string|null) => void}} [opts]
|
|
95
|
+
*/
|
|
96
|
+
export async function runVisionPrompt({
|
|
97
|
+
rl,
|
|
98
|
+
logger = console,
|
|
99
|
+
env = process.env,
|
|
100
|
+
dryRun = false,
|
|
101
|
+
beforeRecheck,
|
|
102
|
+
} = {}) {
|
|
103
|
+
let iface = rl
|
|
104
|
+
let closeAfter = false
|
|
105
|
+
if (!iface) {
|
|
106
|
+
const { createInterface } = await import('node:readline/promises')
|
|
107
|
+
iface = createInterface({ input: process.stdin, output: process.stdout })
|
|
108
|
+
closeAfter = true
|
|
109
|
+
}
|
|
110
|
+
try {
|
|
111
|
+
logger.log?.('')
|
|
112
|
+
logger.log?.('Vision — how should images be described?')
|
|
113
|
+
logger.log?.(
|
|
114
|
+
' 1) Automatic discovery (default) — pick a vision-capable model from the host catalog',
|
|
115
|
+
)
|
|
116
|
+
logger.log?.(' 2) Pin a provider/model (e.g. opencode-go/mimo-v2.5)')
|
|
117
|
+
logger.log?.(' 3) Skip')
|
|
118
|
+
const answer = (await iface.question('Choice [1]: ')).trim().toLowerCase()
|
|
119
|
+
|
|
120
|
+
if (answer === '' || answer === '1' || answer === 'auto' || answer === 'discovery') {
|
|
121
|
+
logger.log?.('Vision: automatic discovery (no config written).')
|
|
122
|
+
return { action: 'auto' }
|
|
123
|
+
}
|
|
124
|
+
if (answer === '3' || answer === 'skip' || answer === 'no') {
|
|
125
|
+
logger.log?.('Vision: skipped (no config written).')
|
|
126
|
+
return { action: 'skip' }
|
|
127
|
+
}
|
|
128
|
+
if (answer === '2' || answer === 'pin') {
|
|
129
|
+
for (let attempt = 0; attempt < MAX_PIN_ATTEMPTS; attempt++) {
|
|
130
|
+
const raw = (await iface.question('provider/model (e.g. opencode-go/mimo-v2.5): ')).trim()
|
|
131
|
+
if (!isValidVisionModel(raw)) {
|
|
132
|
+
logger.warn?.(`Invalid model "${raw}" — expected provider/model (one slash, no spaces).`)
|
|
133
|
+
continue
|
|
134
|
+
}
|
|
135
|
+
const result = mergeVisionModel(raw, { env, dryRun, beforeRecheck })
|
|
136
|
+
if (result.reason === 'corrupt') {
|
|
137
|
+
logger.warn?.(
|
|
138
|
+
`Vision config at ${result.path} is not valid JSON — leaving it untouched; fix it and re-run.`,
|
|
139
|
+
)
|
|
140
|
+
return { action: 'pin', visionModel: raw, wrote: false, path: result.path }
|
|
141
|
+
}
|
|
142
|
+
if (result.reason === 'conflict') {
|
|
143
|
+
logger.warn?.(
|
|
144
|
+
`Vision: not saved — config at ${result.path} was modified concurrently; re-run to retry.`,
|
|
145
|
+
)
|
|
146
|
+
} else {
|
|
147
|
+
logger.log?.(`Vision: pinned ${raw} in ${result.path}.`)
|
|
148
|
+
}
|
|
149
|
+
return { action: 'pin', visionModel: raw, wrote: result.wrote, path: result.path }
|
|
150
|
+
}
|
|
151
|
+
logger.warn?.('Vision: no valid model entered — skipping (no config written).')
|
|
152
|
+
return { action: 'skip' }
|
|
153
|
+
}
|
|
154
|
+
logger.warn?.(
|
|
155
|
+
`Unknown choice "${answer}" — defaulting to automatic discovery (no config written).`,
|
|
156
|
+
)
|
|
157
|
+
return { action: 'auto' }
|
|
158
|
+
} finally {
|
|
159
|
+
if (closeAfter) iface.close()
|
|
160
|
+
}
|
|
161
|
+
}
|
package/scripts/install-mcp.mjs
CHANGED
|
@@ -24,6 +24,7 @@ import { homedir } from 'node:os'
|
|
|
24
24
|
import { basename, dirname, join, resolve } from 'node:path'
|
|
25
25
|
import { createInterface } from 'node:readline'
|
|
26
26
|
import { fileURLToPath } from 'node:url'
|
|
27
|
+
import { migrateMcpEntrypointPaths } from './install/config-migration.mjs'
|
|
27
28
|
|
|
28
29
|
const __dirname = dirname(fileURLToPath(import.meta.url))
|
|
29
30
|
const ROOT = join(__dirname, '..')
|
|
@@ -70,11 +71,11 @@ function resolveServerScript(relPath) {
|
|
|
70
71
|
}
|
|
71
72
|
|
|
72
73
|
const VENV_PYTHON = resolveVenvPython()
|
|
73
|
-
const MCP_RESOURCES_SERVER = resolveServerScript('scripts/
|
|
74
|
-
const CODE_MODE_SERVER = resolveServerScript('scripts/
|
|
75
|
-
const MEMORY_MCP_SERVER = resolveServerScript('scripts/
|
|
76
|
-
const MCP_PERSISTENCE_SERVER = resolveServerScript('scripts/
|
|
77
|
-
const PANTHEON_VISION_SERVER = resolveServerScript('src/mcp/
|
|
74
|
+
const MCP_RESOURCES_SERVER = resolveServerScript('scripts/mcp_resources.py')
|
|
75
|
+
const CODE_MODE_SERVER = resolveServerScript('scripts/code_mode.py')
|
|
76
|
+
const MEMORY_MCP_SERVER = resolveServerScript('scripts/memory_mcp.py')
|
|
77
|
+
const MCP_PERSISTENCE_SERVER = resolveServerScript('scripts/mcp_persistence.py')
|
|
78
|
+
const PANTHEON_VISION_SERVER = resolveServerScript('src/mcp/pantheon_vision.py')
|
|
78
79
|
|
|
79
80
|
// ---------------------------------------------------------------------------
|
|
80
81
|
// MCP Definitions
|
|
@@ -209,7 +210,7 @@ export const MCPS = {
|
|
|
209
210
|
if (existsSync(scriptPath)) {
|
|
210
211
|
return { ok: true, message: 'server script present' }
|
|
211
212
|
}
|
|
212
|
-
return { ok: false, message: '
|
|
213
|
+
return { ok: false, message: 'mcp_resources.py not found at resolved path' }
|
|
213
214
|
},
|
|
214
215
|
},
|
|
215
216
|
'pantheon-code-mode': {
|
|
@@ -230,7 +231,7 @@ export const MCPS = {
|
|
|
230
231
|
if (existsSync(scriptPath)) {
|
|
231
232
|
return { ok: true, message: 'server script present' }
|
|
232
233
|
}
|
|
233
|
-
return { ok: false, message: '
|
|
234
|
+
return { ok: false, message: 'code_mode.py not found at resolved path' }
|
|
234
235
|
},
|
|
235
236
|
},
|
|
236
237
|
'pantheon-memory': {
|
|
@@ -251,7 +252,7 @@ export const MCPS = {
|
|
|
251
252
|
if (existsSync(scriptPath)) {
|
|
252
253
|
return { ok: true, message: 'server script present' }
|
|
253
254
|
}
|
|
254
|
-
return { ok: false, message: '
|
|
255
|
+
return { ok: false, message: 'memory_mcp.py not found at resolved path' }
|
|
255
256
|
},
|
|
256
257
|
},
|
|
257
258
|
'pantheon-persistence': {
|
|
@@ -272,7 +273,7 @@ export const MCPS = {
|
|
|
272
273
|
if (existsSync(scriptPath)) {
|
|
273
274
|
return { ok: true, message: 'server script present' }
|
|
274
275
|
}
|
|
275
|
-
return { ok: false, message: '
|
|
276
|
+
return { ok: false, message: 'mcp_persistence.py not found at resolved path' }
|
|
276
277
|
},
|
|
277
278
|
},
|
|
278
279
|
'pantheon-vision': {
|
|
@@ -471,8 +472,22 @@ function writeMcpConfig(platformName, mcpKey, dryRun, force) {
|
|
|
471
472
|
config[key] = {}
|
|
472
473
|
}
|
|
473
474
|
|
|
475
|
+
// Heal pre-rename MCP entrypoint paths before the skip check, so re-running
|
|
476
|
+
// the installer repairs an existing config whose `*_server.py` paths were
|
|
477
|
+
// orphaned by the entrypoint rename. Without this the rename is a silent
|
|
478
|
+
// break for every already-installed config (see config-migration.mjs).
|
|
479
|
+
const healed = migrateMcpEntrypointPaths(config[key])
|
|
480
|
+
|
|
474
481
|
// Check if the MCP already has an entry
|
|
475
482
|
if (config[key][mcpKey] && !force) {
|
|
483
|
+
if (healed > 0 && !dryRun) {
|
|
484
|
+
writeFileSync(configPath, `${JSON.stringify(config, null, 2)}\n`, 'utf8')
|
|
485
|
+
return {
|
|
486
|
+
status: 'healed',
|
|
487
|
+
path: configPath,
|
|
488
|
+
reason: `Updated ${healed} stale MCP entrypoint path(s) in ${platform.configFile}`,
|
|
489
|
+
}
|
|
490
|
+
}
|
|
476
491
|
return {
|
|
477
492
|
status: 'skipped',
|
|
478
493
|
reason: `Already exists in ${platform.configFile} (use --force to overwrite)`,
|
|
@@ -705,6 +720,10 @@ function logResult(platformLabel, mcpKey, result) {
|
|
|
705
720
|
console.log(` ⏭️ ${label} — ${result.reason}`)
|
|
706
721
|
progress.skipped.push({ platform: platformLabel, mcp: mcpKey, reason: result.reason })
|
|
707
722
|
break
|
|
723
|
+
case 'healed':
|
|
724
|
+
console.log(` 🩹 ${label} — ${result.reason}`)
|
|
725
|
+
progress.installed.push({ platform: platformLabel, mcp: mcpKey })
|
|
726
|
+
break
|
|
708
727
|
case 'dry-run': {
|
|
709
728
|
const desc =
|
|
710
729
|
result.entry?.type === 'remote' || result.entry?.type === 'http'
|
|
@@ -5,7 +5,7 @@ Key-Value store with FTS5 full-text search, TTL-based expiration,
|
|
|
5
5
|
and namespace isolation. Uses SQLite with zero external dependencies.
|
|
6
6
|
|
|
7
7
|
Usage:
|
|
8
|
-
python scripts/
|
|
8
|
+
python scripts/mcp_persistence.py
|
|
9
9
|
"""
|
|
10
10
|
|
|
11
11
|
from __future__ import annotations
|
|
@@ -25,7 +25,7 @@ from pathlib import Path
|
|
|
25
25
|
from typing import Annotated
|
|
26
26
|
|
|
27
27
|
from _pantheon_paths import pantheon_home, pantheon_project
|
|
28
|
-
from
|
|
28
|
+
from fastmcp import FastMCP
|
|
29
29
|
from pydantic import Field
|
|
30
30
|
|
|
31
31
|
# ── Schema ──────────────────────────────────────────────────────────────────────
|
|
@@ -7,7 +7,7 @@ Provides MCP resources for the Pantheon agent framework:
|
|
|
7
7
|
plans/status, memory-bank files
|
|
8
8
|
|
|
9
9
|
Usage:
|
|
10
|
-
python scripts/
|
|
10
|
+
python scripts/mcp_resources.py
|
|
11
11
|
"""
|
|
12
12
|
|
|
13
13
|
from __future__ import annotations
|
|
@@ -19,7 +19,7 @@ from typing import Any
|
|
|
19
19
|
import yaml
|
|
20
20
|
from _pantheon_paths import has_symlink_component, pantheon_home, pantheon_project
|
|
21
21
|
from eval_store import get_latest_eval, list_evals
|
|
22
|
-
from
|
|
22
|
+
from fastmcp import FastMCP
|
|
23
23
|
|
|
24
24
|
# ── Path Resolution ──────────────────────────────────────────────────────────
|
|
25
25
|
_PANTHEON_HOME: Path = pantheon_home()
|
|
@@ -19,7 +19,7 @@ Tools:
|
|
|
19
19
|
memory_stats — DB statistics
|
|
20
20
|
|
|
21
21
|
Usage:
|
|
22
|
-
python scripts/
|
|
22
|
+
python scripts/memory_mcp.py
|
|
23
23
|
"""
|
|
24
24
|
|
|
25
25
|
from __future__ import annotations
|
|
@@ -33,7 +33,7 @@ from pathlib import Path
|
|
|
33
33
|
from typing import Annotated, Any
|
|
34
34
|
|
|
35
35
|
from _pantheon_paths import pantheon_home
|
|
36
|
-
from
|
|
36
|
+
from fastmcp import FastMCP
|
|
37
37
|
from pydantic import BeforeValidator, Field
|
|
38
38
|
|
|
39
39
|
# ── Paths ─────────────────────────────────────────────────────────────────────
|
|
@@ -15,7 +15,7 @@ import { fileURLToPath } from 'node:url'
|
|
|
15
15
|
|
|
16
16
|
const requestedVersion = valueFor('--version')
|
|
17
17
|
const jsonOutput = process.argv.includes('--json')
|
|
18
|
-
const runtime = fileURLToPath(new URL('./
|
|
18
|
+
const runtime = fileURLToPath(new URL('./mcp_persistence.py', import.meta.url))
|
|
19
19
|
|
|
20
20
|
function valueFor(name) {
|
|
21
21
|
const index = process.argv.indexOf(name)
|
|
@@ -63,6 +63,12 @@ spec.loader.exec_module(module)
|
|
|
63
63
|
|
|
64
64
|
async def call(name, arguments):
|
|
65
65
|
result = await module.mcp.call_tool(name, arguments)
|
|
66
|
+
structured = getattr(result, "structured_content", None)
|
|
67
|
+
if structured is not None:
|
|
68
|
+
metadata = getattr(result, "meta", None) or {}
|
|
69
|
+
if metadata.get("fastmcp", {}).get("wrap_result"):
|
|
70
|
+
return structured.get("result") if isinstance(structured, dict) else structured
|
|
71
|
+
return structured
|
|
66
72
|
if isinstance(result, tuple):
|
|
67
73
|
structured = result[1]
|
|
68
74
|
if structured is not None:
|
|
@@ -186,7 +192,7 @@ async function main() {
|
|
|
186
192
|
return
|
|
187
193
|
}
|
|
188
194
|
if (!existsSync(runtime)) {
|
|
189
|
-
unavailable('installed package does not include
|
|
195
|
+
unavailable('installed package does not include mcp_persistence.py')
|
|
190
196
|
return
|
|
191
197
|
}
|
|
192
198
|
|