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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (120) hide show
  1. package/AGENTS.global.md +182 -0
  2. package/AGENTS.md +10 -891
  3. package/CHANGELOG.md +326 -0
  4. package/README.md +496 -32
  5. package/README.pt-BR.md +285 -20
  6. package/bin/pantheon-init.mjs +26 -3
  7. package/commands/pantheon-audit.md +7 -1
  8. package/commands/pantheon-consolidate.md +1 -1
  9. package/commands/pantheon-deepwork.md +14 -3
  10. package/commands/pantheon-focus.md +17 -0
  11. package/commands/pantheon-vision.md +61 -12
  12. package/package.json +21 -14
  13. package/plugin.json +2 -2
  14. package/pyproject.toml +8 -6
  15. package/scripts/build-agents-md.mjs +374 -56
  16. package/scripts/changelog-merge.mjs +161 -0
  17. package/scripts/{code_mode_server.py → code_mode.py} +3 -3
  18. package/scripts/cost.mjs +244 -41
  19. package/scripts/doctor.mjs +151 -1
  20. package/scripts/install/agents-md.mjs +4 -4
  21. package/scripts/install/config-migration.mjs +317 -4
  22. package/scripts/install/health-check.mjs +21 -19
  23. package/scripts/install/interactive.mjs +1 -1
  24. package/scripts/install/opencode-version.mjs +246 -4
  25. package/scripts/install/opencode.mjs +627 -179
  26. package/scripts/install/plugin.mjs +43 -9
  27. package/scripts/install/shared.mjs +120 -12
  28. package/scripts/install/vision-prompt.mjs +161 -0
  29. package/scripts/install-mcp.mjs +28 -9
  30. package/scripts/{mcp_persistence_server.py → mcp_persistence.py} +2 -2
  31. package/{src/mcp/mcp_resources_server.py → scripts/mcp_resources.py} +2 -2
  32. package/scripts/{memory_mcp_server.py → memory_mcp.py} +2 -2
  33. package/scripts/probe-context-rehydrate.mjs +8 -2
  34. package/scripts/probe-pantheon-cost.mjs +71 -15
  35. package/scripts/run-node-tests.mjs +189 -0
  36. package/scripts/sync-artifacts.mjs +26 -13
  37. package/scripts/sync-tui.mjs +24 -3
  38. package/scripts/test-opencode-v2-sandbox.sh +180 -26
  39. package/scripts/validate-routing.mjs +3 -3
  40. package/scripts/validate-sandbox-install.mjs +138 -0
  41. package/scripts/versioning.mjs +13 -1
  42. package/scripts/vision-status.mjs +682 -0
  43. package/skills-lock.json +8 -8
  44. package/src/agents/aphrodite.md +20 -37
  45. package/src/agents/apollo.md +7 -3
  46. package/src/agents/athena.md +63 -35
  47. package/src/agents/demeter.md +8 -28
  48. package/src/agents/gaia.md +1 -1
  49. package/src/agents/hephaestus.md +10 -21
  50. package/src/agents/hermes.md +15 -38
  51. package/src/agents/iris.md +1 -1
  52. package/src/agents/mnemosyne.md +18 -54
  53. package/src/agents/nyx.md +1 -1
  54. package/src/agents/prometheus.md +16 -20
  55. package/src/agents/talos.md +10 -2
  56. package/src/agents/themis.md +5 -5
  57. package/src/agents/zeus.md +99 -129
  58. package/src/instructions/agent-return-format.instructions.md +18 -3
  59. package/src/instructions/backend-standards.instructions.md +2 -0
  60. package/src/instructions/frontend-standards.instructions.md +3 -1
  61. package/src/instructions/memory-protocol.instructions.md +11 -110
  62. package/src/instructions/zeus-anti-stall.instructions.md +14 -61
  63. package/src/instructions/zeus-communication-rules.instructions.md +0 -1
  64. package/src/instructions/zeus-council-synthesis.instructions.md +32 -260
  65. package/src/instructions/zeus-memory-operations.instructions.md +26 -0
  66. package/src/instructions/zeus-timeout-retry.instructions.md +40 -87
  67. package/src/mcp/{code_mode_server.py → code_mode.py} +3 -3
  68. package/src/mcp/eval_store.py +2 -2
  69. package/src/mcp/{mcp_persistence_server.py → mcp_persistence.py} +2 -2
  70. package/{scripts/mcp_resources_server.py → src/mcp/mcp_resources.py} +2 -2
  71. package/src/mcp/{memory_mcp_server.py → memory_mcp.py} +2 -2
  72. package/src/mcp/{pantheon_vision_server.py → pantheon_vision.py} +286 -42
  73. package/src/mcp/requirements-mcp.txt +6 -4
  74. package/src/mcp/requirements-vision.txt +2 -2
  75. package/src/pantheon/cost-command.ts +399 -101
  76. package/src/pantheon/delegation-enforce.ts +10 -1
  77. package/src/pantheon/presets.d.mts +0 -8
  78. package/src/pantheon/presets.mjs +5 -32
  79. package/src/pantheon/v2-bounded-read.ts +640 -0
  80. package/src/pantheon/v2-delegation-enforce.ts +214 -0
  81. package/src/pantheon/v2-execute-after.ts +186 -0
  82. package/src/pantheon/v2-tools.ts +429 -0
  83. package/src/pantheon/v2-unsupported.d.mts +11 -0
  84. package/src/pantheon/v2-unsupported.mjs +90 -0
  85. package/src/pantheon/v2-vision.ts +622 -0
  86. package/src/pantheon/vision-local.ts +531 -0
  87. package/src/pantheon/vision.ts +1160 -115
  88. package/src/plugin-v2.ts +1145 -219
  89. package/src/plugin.ts +12 -9
  90. package/src/plugins/tui/dist/server.d.ts +1 -2
  91. package/src/plugins/tui/dist/server.d.ts.map +1 -1
  92. package/src/plugins/tui/dist/tui-v2.d.ts +154 -0
  93. package/src/plugins/tui/dist/tui-v2.d.ts.map +1 -0
  94. package/src/plugins/tui/dist/tui-v2.js +659 -0
  95. package/src/plugins/tui/dist/tui-v2.js.map +1 -0
  96. package/src/plugins/tui/dist/tui.d.ts +85 -82
  97. package/src/plugins/tui/dist/tui.d.ts.map +1 -1
  98. package/src/plugins/tui/dist/tui.js +72 -59
  99. package/src/plugins/tui/index-v2.ts +6 -0
  100. package/src/plugins/tui/package-lock.json +5516 -1353
  101. package/src/plugins/tui/package.json +22 -12
  102. package/src/plugins/tui/src/index-v2.tsx +734 -0
  103. package/src/plugins/tui/src/index.tsx +106 -85
  104. package/src/plugins/tui/src/opencode-v2.d.ts +13 -0
  105. package/src/plugins/tui/tsconfig.build.json +5 -0
  106. package/src/plugins/tui/tsconfig.typecheck.json +5 -0
  107. package/src/plugins/tui/tsdown.config.ts +22 -2
  108. package/src/plugins/tui/tui-v2.ts +2 -0
  109. package/src/plugins/tui/tui.ts +3 -0
  110. package/src/routing.yml +11 -21
  111. package/src/skills/README.md +3 -3
  112. package/src/skills/agent-coordination/SKILL.md +7 -2
  113. package/src/skills/artifact-management/SKILL.md +12 -5
  114. package/src/skills/auto-continue/SKILL.md +39 -30
  115. package/src/skills/code-review-checklist/SKILL.md +5 -5
  116. package/src/skills/context-compression/SKILL.md +26 -879
  117. package/src/skills/session-goal/SKILL.md +14 -5
  118. package/src/skills/spec-driven-development/SKILL.md +3 -1
  119. package/src/skills/tdd-with-agents/SKILL.md +18 -10
  120. package/opencode.json +0 -58
@@ -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/*, package.json and
12
- * package-lock.json) from source to destination. Only the bundle is copied —
13
- * the raw TSX source is never shipped to the destination, so the plugin can
14
- * be modularized without the loader needing relative imports.
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
- readFileSync(pkgSrc, 'utf8'),
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(srcDir, dstDir, { dryRun = false, clean = false } = {}) {
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 yaml from 'js-yaml'
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
- export function writeIfChanged(filePath, content, dryRun) {
261
- const existing = existsSync(filePath) ? readFileSync(filePath, 'utf8') : null
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
- if (!dryRun && existsSync(filePath)) {
283
- copyFileSync(filePath, `${filePath}.bak`)
284
- }
285
- return writeIfChanged(filePath, content, dryRun)
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]*?)\r?\n---\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: yaml.load(match[1]) ?? {},
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 yaml.dump(fm, {
450
+ return dump(fm, {
343
451
  lineWidth: -1,
344
- quotingType: '"',
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
+ }
@@ -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/mcp_resources_server.py')
74
- const CODE_MODE_SERVER = resolveServerScript('scripts/code_mode_server.py')
75
- const MEMORY_MCP_SERVER = resolveServerScript('scripts/memory_mcp_server.py')
76
- const MCP_PERSISTENCE_SERVER = resolveServerScript('scripts/mcp_persistence_server.py')
77
- const PANTHEON_VISION_SERVER = resolveServerScript('src/mcp/pantheon_vision_server.py')
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: 'mcp_resources_server.py not found at resolved path' }
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: 'code_mode_server.py not found at resolved path' }
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: 'memory_mcp_server.py not found at resolved path' }
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: 'mcp_persistence_server.py not found at resolved path' }
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/mcp_persistence_server.py
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 mcp.server.fastmcp import FastMCP
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/mcp_resources_server.py
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 mcp.server.fastmcp import FastMCP
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/memory_mcp_server.py
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 mcp.server.fastmcp import FastMCP
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('./mcp_persistence_server.py', import.meta.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 mcp_persistence_server.py')
195
+ unavailable('installed package does not include mcp_persistence.py')
190
196
  return
191
197
  }
192
198