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

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 (133) hide show
  1. package/AGENTS.global.md +206 -0
  2. package/AGENTS.md +10 -891
  3. package/CHANGELOG.md +350 -0
  4. package/README.md +561 -52
  5. package/README.pt-BR.md +285 -20
  6. package/bin/pantheon-init.mjs +70 -4
  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 +24 -17
  13. package/plugin.json +2 -2
  14. package/pyproject.toml +8 -6
  15. package/scripts/build-agents-md.mjs +428 -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/backup.mjs +475 -0
  22. package/scripts/install/cli-ui.mjs +6 -1
  23. package/scripts/install/config-migration.mjs +317 -4
  24. package/scripts/install/exit-codes.mjs +26 -0
  25. package/scripts/install/health-check.mjs +21 -19
  26. package/scripts/install/interactive.mjs +16 -2
  27. package/scripts/install/migrate.mjs +41 -22
  28. package/scripts/install/opencode-version.mjs +246 -4
  29. package/scripts/install/opencode.mjs +1175 -215
  30. package/scripts/install/plan.mjs +287 -0
  31. package/scripts/install/plugin.mjs +62 -14
  32. package/scripts/install/shared.mjs +190 -48
  33. package/scripts/install/strings.mjs +20 -0
  34. package/scripts/install/venv.mjs +13 -3
  35. package/scripts/install/vision-prompt.mjs +171 -0
  36. package/scripts/install-mcp.mjs +28 -9
  37. package/scripts/{mcp_persistence_server.py → mcp_persistence.py} +52 -17
  38. package/{src/mcp/mcp_resources_server.py → scripts/mcp_resources.py} +2 -2
  39. package/scripts/{memory_mcp_server.py → memory_mcp.py} +2 -2
  40. package/scripts/probe-context-rehydrate.mjs +8 -2
  41. package/scripts/probe-pantheon-cost.mjs +71 -15
  42. package/scripts/prune.mjs +9 -3
  43. package/scripts/restore.mjs +222 -0
  44. package/scripts/run-node-tests.mjs +189 -0
  45. package/scripts/sync-artifacts.mjs +26 -13
  46. package/scripts/sync-tui.mjs +24 -3
  47. package/scripts/test-opencode-v2-sandbox.sh +180 -26
  48. package/scripts/validate-routing.mjs +3 -3
  49. package/scripts/validate-sandbox-install.mjs +138 -0
  50. package/scripts/versioning.mjs +13 -1
  51. package/scripts/vision-status.mjs +682 -0
  52. package/skills-lock.json +8 -8
  53. package/src/agents/aphrodite.md +20 -37
  54. package/src/agents/apollo.md +7 -3
  55. package/src/agents/athena.md +63 -35
  56. package/src/agents/demeter.md +8 -28
  57. package/src/agents/gaia.md +1 -1
  58. package/src/agents/hephaestus.md +10 -21
  59. package/src/agents/hermes.md +15 -38
  60. package/src/agents/iris.md +7 -1
  61. package/src/agents/mnemosyne.md +18 -54
  62. package/src/agents/nyx.md +1 -1
  63. package/src/agents/prometheus.md +16 -20
  64. package/src/agents/talos.md +10 -2
  65. package/src/agents/themis.md +5 -5
  66. package/src/agents/zeus.md +99 -172
  67. package/src/instructions/agent-return-format.instructions.md +34 -17
  68. package/src/instructions/backend-standards.instructions.md +2 -0
  69. package/src/instructions/frontend-standards.instructions.md +3 -1
  70. package/src/instructions/memory-protocol.instructions.md +12 -110
  71. package/src/instructions/test-economy.instructions.md +21 -0
  72. package/src/instructions/zeus-anti-stall.instructions.md +20 -67
  73. package/src/instructions/zeus-communication-rules.instructions.md +0 -1
  74. package/src/instructions/zeus-council-synthesis.instructions.md +31 -260
  75. package/src/instructions/zeus-memory-operations.instructions.md +30 -0
  76. package/src/instructions/zeus-timeout-retry.instructions.md +61 -89
  77. package/src/mcp/{code_mode_server.py → code_mode.py} +3 -3
  78. package/src/mcp/eval_store.py +2 -2
  79. package/src/mcp/{mcp_persistence_server.py → mcp_persistence.py} +52 -17
  80. package/{scripts/mcp_resources_server.py → src/mcp/mcp_resources.py} +2 -2
  81. package/src/mcp/{memory_mcp_server.py → memory_mcp.py} +2 -2
  82. package/src/mcp/{pantheon_vision_server.py → pantheon_vision.py} +286 -42
  83. package/src/mcp/requirements-mcp.txt +6 -4
  84. package/src/mcp/requirements-vision.txt +2 -2
  85. package/src/pantheon/cost-command.ts +403 -102
  86. package/src/pantheon/delegation-enforce.ts +10 -1
  87. package/src/pantheon/model-agents.ts +19 -0
  88. package/src/pantheon/model-command.ts +218 -56
  89. package/src/pantheon/presets.d.mts +0 -8
  90. package/src/pantheon/presets.mjs +5 -32
  91. package/src/pantheon/v2-bounded-read.ts +640 -0
  92. package/src/pantheon/v2-delegation-enforce.ts +214 -0
  93. package/src/pantheon/v2-execute-after.ts +186 -0
  94. package/src/pantheon/v2-model-overrides.ts +275 -0
  95. package/src/pantheon/v2-tools.ts +418 -0
  96. package/src/pantheon/v2-unsupported.d.mts +11 -0
  97. package/src/pantheon/v2-unsupported.mjs +90 -0
  98. package/src/pantheon/v2-vision.ts +622 -0
  99. package/src/pantheon/vision-local.ts +531 -0
  100. package/src/pantheon/vision.ts +1160 -115
  101. package/src/plugin-v2.ts +1158 -219
  102. package/src/plugin.ts +12 -9
  103. package/src/plugins/tui/dist/server.d.ts +1 -2
  104. package/src/plugins/tui/dist/server.d.ts.map +1 -1
  105. package/src/plugins/tui/dist/tui-v2.d.ts +154 -0
  106. package/src/plugins/tui/dist/tui-v2.d.ts.map +1 -0
  107. package/src/plugins/tui/dist/tui-v2.js +659 -0
  108. package/src/plugins/tui/dist/tui-v2.js.map +1 -0
  109. package/src/plugins/tui/dist/tui.d.ts +85 -82
  110. package/src/plugins/tui/dist/tui.d.ts.map +1 -1
  111. package/src/plugins/tui/dist/tui.js +72 -59
  112. package/src/plugins/tui/index-v2.ts +6 -0
  113. package/src/plugins/tui/package-lock.json +5516 -1353
  114. package/src/plugins/tui/package.json +22 -12
  115. package/src/plugins/tui/src/index-v2.tsx +734 -0
  116. package/src/plugins/tui/src/index.tsx +106 -85
  117. package/src/plugins/tui/src/opencode-v2.d.ts +13 -0
  118. package/src/plugins/tui/tsconfig.build.json +5 -0
  119. package/src/plugins/tui/tsconfig.typecheck.json +5 -0
  120. package/src/plugins/tui/tsdown.config.ts +22 -2
  121. package/src/plugins/tui/tui-v2.ts +2 -0
  122. package/src/plugins/tui/tui.ts +3 -0
  123. package/src/routing.yml +11 -21
  124. package/src/skills/README.md +3 -3
  125. package/src/skills/agent-coordination/SKILL.md +7 -2
  126. package/src/skills/artifact-management/SKILL.md +12 -5
  127. package/src/skills/auto-continue/SKILL.md +52 -35
  128. package/src/skills/code-review-checklist/SKILL.md +5 -5
  129. package/src/skills/context-compression/SKILL.md +26 -879
  130. package/src/skills/session-goal/SKILL.md +14 -5
  131. package/src/skills/spec-driven-development/SKILL.md +3 -1
  132. package/src/skills/tdd-with-agents/SKILL.md +18 -10
  133. package/opencode.json +0 -58
@@ -4,9 +4,8 @@
4
4
 
5
5
  import { spawnSync } from 'node:child_process'
6
6
  import {
7
- copyFileSync,
8
- cpSync,
9
7
  existsSync,
8
+ lstatSync,
10
9
  mkdirSync,
11
10
  readdirSync,
12
11
  readFileSync,
@@ -15,7 +14,7 @@ import {
15
14
  statSync,
16
15
  writeFileSync,
17
16
  } from 'node:fs'
18
- import { basename, dirname, join } from 'node:path'
17
+ import { dirname, join } from 'node:path'
19
18
  import { fileURLToPath } from 'node:url'
20
19
 
21
20
  const REQUIRED_NODE_MAJOR = 18
@@ -24,7 +23,9 @@ if (parseInt(process.versions.node.split('.')[0], 10) < REQUIRED_NODE_MAJOR) {
24
23
  process.exit(1)
25
24
  }
26
25
 
27
- import yaml from 'js-yaml'
26
+ import { dump, load } from 'js-yaml'
27
+
28
+ import { ACTIONS, isRecording, recordChange } from './plan.mjs'
28
29
 
29
30
  export const __dirname = dirname(fileURLToPath(import.meta.url))
30
31
  export const ROOT = join(__dirname, '..', '..')
@@ -140,33 +141,6 @@ export function resolveTarget(target) {
140
141
  return join(process.cwd(), target)
141
142
  }
142
143
 
143
- export function createBackup(target) {
144
- const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19)
145
- const backupDir = join(target, '..', `.pantheon-bak-${basename(target)}-${timestamp}`)
146
-
147
- console.log(`\n 💾 Creating backup: ${backupDir}`)
148
- mkdirSync(backupDir, { recursive: true })
149
-
150
- // Copy only relevant Pantheon directories/files
151
- const items = ['agents', 'skills', 'commands', 'instructions', 'opencode.json', 'tui.json']
152
- let count = 0
153
- for (const item of items) {
154
- const src = join(target, item)
155
- try {
156
- cpSync(src, join(backupDir, item), {
157
- recursive: true,
158
- errorOnExist: false,
159
- })
160
- count++
161
- } catch {
162
- // File or directory doesn't exist in target — skip silently
163
- }
164
- }
165
-
166
- console.log(` ✅ Backed up ${count} items to: ${backupDir}`)
167
- return backupDir
168
- }
169
-
170
144
  export function detectAndReport(target) {
171
145
  const found = detectPlatforms(target).length > 0
172
146
 
@@ -257,32 +231,161 @@ export function copyFiles(srcDir, dstDir, dryRun, renameMap = null, clean = fals
257
231
  return { created, skipped }
258
232
  }
259
233
 
260
- export function writeIfChanged(filePath, content, dryRun) {
261
- const existing = existsSync(filePath) ? readFileSync(filePath, 'utf8') : null
234
+ /**
235
+ * `lstat` that returns `null` instead of throwing for an absent path.
236
+ *
237
+ * Deliberately `lstat`, not `stat`/`existsSync`: both of those follow the link,
238
+ * so a DANGLING symlink reads as absent and the atomic rename below would then
239
+ * silently replace the link itself. `lstat` sees the link and lets the write
240
+ * guard refuse it.
241
+ */
242
+ function lstatOrNull(filePath) {
243
+ try {
244
+ return lstatSync(filePath)
245
+ } catch {
246
+ return null
247
+ }
248
+ }
249
+
250
+ /**
251
+ * Read a path's raw text, or `null` when it cannot be read (absent, dangling
252
+ * symlink target, permission error). The caller decides what "unreadable"
253
+ * means; this never throws.
254
+ */
255
+ function readFileOrNull(filePath) {
256
+ try {
257
+ return readFileSync(filePath, 'utf8')
258
+ } catch {
259
+ return null
260
+ }
261
+ }
262
+
263
+ /**
264
+ * Atomically create/overwrite a text file, skipping an unchanged write.
265
+ *
266
+ * @param {string} filePath - destination path
267
+ * @param {string} content - full file content
268
+ * @param {boolean} dryRun - when true, compute the outcome without writing
269
+ * @param {object} [options]
270
+ * @param {string|null} [options.expectedContent] - the raw content the caller
271
+ * read this path from, if any. When supplied, the write proceeds only if the
272
+ * on-disk content STILL matches it — compared once up front AND again
273
+ * immediately before the rename; otherwise it returns `'conflict'` and leaves
274
+ * the file alone. This is a best-effort COMPARE-BEFORE-WRITE guard, NOT a
275
+ * filesystem CAS: the final re-read and the rename are still two syscalls, so
276
+ * a writer that lands between them is not prevented. What it does close is
277
+ * the window the installer actually races with — a config the caller parsed
278
+ * going stale during the same run — and it keeps the target consistent with
279
+ * the bytes that were checked.
280
+ * @param {(existing: string|null) => void} [options.beforeRecheck] - runs after
281
+ * the initial content check and before the last-moment re-read. Injection
282
+ * point so tests can simulate a concurrent writer landing in the window;
283
+ * production callers omit it (undefined is a no-op).
284
+ * @param {(existing: string|null) => void} [options.beforeSwap] - runs after the
285
+ * expected-content checks pass and immediately before the rename, for side
286
+ * effects that must not fire on a conflict.
287
+ * @param {(change: object) => void} [options.recorder] - optional change
288
+ * recorder. When supplied, the outcome is recorded (path + action + byte
289
+ * size only) so `--dry-run` can print the real per-file plan. Defaults to
290
+ * the module-level no-op singleton, so an uninstrumented caller is
291
+ * byte-identical to before.
292
+ * @param {boolean} [options.scored] - set `false` when this write is not part
293
+ * of the install summary (e.g. stale-ref cleanup), so it is shown in the plan
294
+ * but excluded from the consistency guard.
295
+ * @returns {'created'|'skipped'|'conflict'}
296
+ */
297
+ export function writeIfChanged(filePath, content, dryRun, options = {}) {
298
+ const { expectedContent, beforeSwap, beforeRecheck, recorder, scored } = options
299
+
300
+ // Refuse to write through/over a symlink. A dangling symlink is invisible to
301
+ // existsSync, and rename-over-link would replace the link rather than its
302
+ // target — both are silent redirections the installer must not perform.
303
+ const stat = lstatOrNull(filePath)
304
+ if (stat?.isSymbolicLink()) {
305
+ throw new Error(`Refusing to write through symlink: ${filePath}`)
306
+ }
307
+
308
+ const existing = stat === null ? null : readFileOrNull(filePath)
309
+ const record = (outcome) => {
310
+ if (!isRecording(recorder)) return
311
+ const action =
312
+ outcome === 'conflict'
313
+ ? ACTIONS.CONFLICT
314
+ : outcome === 'skipped'
315
+ ? ACTIONS.UNCHANGED
316
+ : existing === null
317
+ ? ACTIONS.CREATE
318
+ : ACTIONS.UPDATE
319
+ recordChange(
320
+ { path: filePath, action, bytes: Buffer.byteLength(content, 'utf8'), scored },
321
+ recorder,
322
+ )
323
+ }
262
324
  if (existing === content) {
325
+ record('skipped')
263
326
  return 'skipped'
264
327
  }
328
+ if (expectedContent !== undefined && existing !== expectedContent) {
329
+ // The file changed since the caller read it (or appeared/disappeared).
330
+ // Do not clobber the newer content; the caller decides how to react.
331
+ record('conflict')
332
+ return 'conflict'
333
+ }
265
334
  if (!dryRun) {
335
+ // Seam for a concurrent writer to land between the read above and the
336
+ // re-read below. No production caller passes this.
337
+ if (typeof beforeRecheck === 'function') beforeRecheck(existing)
338
+
266
339
  // Atomic write: write a temp sibling and rename over the target so a
267
340
  // crash mid-write can never leave a truncated file behind (rename is
268
341
  // atomic within the same directory).
269
342
  const tmpPath = `${filePath}.tmp-${process.pid}`
270
343
  writeFileSync(tmpPath, content, 'utf8')
344
+
345
+ // Last-moment re-read. The initial compare and this rename are separate
346
+ // syscalls, so a concurrent writer can land in between; re-check the
347
+ // expected bytes immediately before the swap. On a mismatch, drop the temp
348
+ // and leave the target byte-identical.
349
+ if (expectedContent !== undefined) {
350
+ const current = readFileOrNull(filePath)
351
+ if (current !== expectedContent) {
352
+ rmSync(tmpPath, { force: true })
353
+ record('conflict')
354
+ return 'conflict'
355
+ }
356
+ }
357
+
358
+ if (typeof beforeSwap === 'function') beforeSwap(existing)
271
359
  renameSync(tmpPath, filePath)
272
360
  }
361
+ record('created')
273
362
  return 'created'
274
363
  }
275
364
 
276
365
  /**
277
- * beta.5: write the user's opencode.json with a same-directory backup of the
278
- * previous content. The installer rewrites the whole config; a `.bak` gives
279
- * the user a one-step recovery if a merge ever goes wrong.
366
+ * Write a config file atomically, honoring the compare-before-write guard.
367
+ * This used to always keep a `<config>.bak` sidecar; the main `opencode.json`
368
+ * no longer does — the target-scoped exact backup set
369
+ * (scripts/install/backup.mjs) preserves it byte-for-byte and
370
+ * `pantheon-opencode restore` reverts it.
371
+ *
372
+ * A caller may still supply `beforeSwap` for a file the exact set does NOT
373
+ * cover: the GLOBAL `opencode-vision.json` lives outside the config dir, so
374
+ * vision-prompt passes a `beforeSwap` that keeps its `<file>.bak` recovery.
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: options.beforeSwap,
386
+ recorder: options.recorder,
387
+ scored: options.scored,
388
+ })
286
389
  }
287
390
 
288
391
  /**
@@ -326,10 +429,10 @@ export function collectSkillNames() {
326
429
  * Returns { fm: object, body: string } or null if no frontmatter.
327
430
  */
328
431
  export function parseFrontmatter(content) {
329
- const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/)
432
+ const match = content.match(/^---\r?\n([\s\S]*?)(?:\r?\n)?---\r?\n?([\s\S]*)$/)
330
433
  if (!match) return null
331
434
  return {
332
- fm: yaml.load(match[1]) ?? {},
435
+ fm: match[1].trim() ? (load(match[1]) ?? {}) : {},
333
436
  body: match[2],
334
437
  }
335
438
  }
@@ -339,15 +442,15 @@ export function parseFrontmatter(content) {
339
442
  * Uses long-line mode and avoids unnecessary quoting.
340
443
  */
341
444
  export function serializeFm(fm) {
342
- return yaml.dump(fm, {
445
+ return dump(fm, {
343
446
  lineWidth: -1,
344
- quotingType: '"',
447
+ quoteStyle: 'double',
345
448
  forceQuotes: false,
346
449
  noRefs: true,
347
450
  })
348
451
  }
349
452
 
350
- export function installSkills(skills, target, dryRun, subDir = '.opencode') {
453
+ export function installSkills(skills, target, dryRun, subDir = '.opencode', recorder = null) {
351
454
  const srcSkillsDir = join(ROOT, 'src', 'skills')
352
455
  const dstSkillsDir = join(target, subDir, 'skills')
353
456
 
@@ -373,9 +476,29 @@ export function installSkills(skills, target, dryRun, subDir = '.opencode') {
373
476
  const existing = existsSync(dstPath) ? readFileSync(dstPath, 'utf8') : null
374
477
  if (existing === content) {
375
478
  skipped++
479
+ if (isRecording(recorder)) {
480
+ recordChange(
481
+ {
482
+ path: dstPath,
483
+ action: ACTIONS.UNCHANGED,
484
+ bytes: Buffer.byteLength(content, 'utf8'),
485
+ },
486
+ recorder,
487
+ )
488
+ }
376
489
  } else {
377
490
  if (!dryRun) writeFileSync(dstPath, content, 'utf8')
378
491
  created++
492
+ if (isRecording(recorder)) {
493
+ recordChange(
494
+ {
495
+ path: dstPath,
496
+ action: existing === null ? ACTIONS.CREATE : ACTIONS.UPDATE,
497
+ bytes: Buffer.byteLength(content, 'utf8'),
498
+ },
499
+ recorder,
500
+ )
501
+ }
379
502
  }
380
503
  }
381
504
  }
@@ -396,6 +519,9 @@ export function installSkills(skills, target, dryRun, subDir = '.opencode') {
396
519
  console.log(` 🗑️ Removed stale skill: ${entry}`)
397
520
  }
398
521
  created++
522
+ // A stale removal increments `created` (a change), so it must also be
523
+ // recorded or the preview would under-report on a non-fresh target.
524
+ recordChange({ path: dstPath, action: ACTIONS.REMOVE, bytes: 0 }, recorder)
399
525
  }
400
526
  }
401
527
  }
@@ -403,7 +529,7 @@ export function installSkills(skills, target, dryRun, subDir = '.opencode') {
403
529
  return { created, skipped }
404
530
  }
405
531
 
406
- export function syncDir(src, dst, dryRun, clean = false, filter = null) {
532
+ export function syncDir(src, dst, dryRun, clean = false, filter = null, recorder = null) {
407
533
  if (!existsSync(src)) return { created: 0, skipped: 0 }
408
534
  let created = 0
409
535
  let skipped = 0
@@ -422,7 +548,7 @@ export function syncDir(src, dst, dryRun, clean = false, filter = null) {
422
548
  const srcPath = join(src, entry)
423
549
  const dstPath = join(dst, entry)
424
550
  if (statSync(srcPath).isDirectory()) {
425
- const sub = syncDir(srcPath, dstPath, dryRun, false, filter)
551
+ const sub = syncDir(srcPath, dstPath, dryRun, false, filter, recorder)
426
552
  created += sub.created
427
553
  skipped += sub.skipped
428
554
  } else {
@@ -430,9 +556,25 @@ export function syncDir(src, dst, dryRun, clean = false, filter = null) {
430
556
  const existing = existsSync(dstPath) ? readFileSync(dstPath, 'utf8') : null
431
557
  if (existing === content) {
432
558
  skipped++
559
+ if (isRecording(recorder)) {
560
+ recordChange(
561
+ { path: dstPath, action: ACTIONS.UNCHANGED, bytes: Buffer.byteLength(content, 'utf8') },
562
+ recorder,
563
+ )
564
+ }
433
565
  } else {
434
566
  if (!dryRun) writeFileSync(dstPath, content, 'utf8')
435
567
  created++
568
+ if (isRecording(recorder)) {
569
+ recordChange(
570
+ {
571
+ path: dstPath,
572
+ action: existing === null ? ACTIONS.CREATE : ACTIONS.UPDATE,
573
+ bytes: Buffer.byteLength(content, 'utf8'),
574
+ },
575
+ recorder,
576
+ )
577
+ }
436
578
  }
437
579
  }
438
580
  }
@@ -47,6 +47,16 @@ const TABLE = {
47
47
  prereqMissing:
48
48
  'runtime prerequisites missing — install the tools above, or re-run with --no-mcp to install without Python MCP servers',
49
49
  v2Migrating: 'Migrating config to V2 native format...',
50
+ nothingApplied: 'Nothing applied.',
51
+ planOnlyNoYes: 'Plan only — re-run with --yes to apply.',
52
+ driftHeader: 'State changed since preview — aborting before any write:',
53
+ driftAbort: 'No files were written. Re-run to re-review.',
54
+ applyFailed: (msg) => `❌ Apply failed: ${msg}`,
55
+ alreadyApplied: 'Paths already applied — no automatic rollback:',
56
+ restoreRecoveryHint: (dir) => `Recover with: pantheon-opencode restore (backup at ${dir})`,
57
+ restoreNoBackup:
58
+ 'No backup was created (the failure occurred before the first write); the target is unchanged.',
59
+ dryRunNothingWritten: 'Preview only — no files were written.',
50
60
  // summary (printSummary)
51
61
  summaryInstalled: (target) => `OpenCode installed in ${target}`,
52
62
  summaryWithErrors: (target) => `Installation finished WITH ERRORS in ${target}`,
@@ -95,6 +105,16 @@ const TABLE = {
95
105
  prereqMissing:
96
106
  'pré-requisitos do runtime ausentes — instale as ferramentas acima, ou rode de novo com --no-mcp para instalar sem os MCPs Python',
97
107
  v2Migrating: 'Migrando config para o formato nativo V2...',
108
+ nothingApplied: 'Nada foi aplicado.',
109
+ planOnlyNoYes: 'Apenas o plano — rode de novo com --yes para aplicar.',
110
+ driftHeader: 'O estado mudou desde o preview — abortando antes de qualquer escrita:',
111
+ driftAbort: 'Nenhum arquivo foi escrito. Rode de novo para revisar.',
112
+ applyFailed: (msg) => `❌ Falha ao aplicar: ${msg}`,
113
+ alreadyApplied: 'Caminhos já aplicados — sem rollback automático:',
114
+ restoreRecoveryHint: (dir) => `Recupere com: pantheon-opencode restore (backup em ${dir})`,
115
+ restoreNoBackup:
116
+ 'Nenhum backup foi criado (a falha ocorreu antes da primeira escrita); o alvo está intacto.',
117
+ dryRunNothingWritten: 'Somente prévia — nenhum arquivo foi escrito.',
98
118
  // summary (printSummary)
99
119
  summaryInstalled: (target) => `OpenCode instalado em ${target}`,
100
120
  summaryWithErrors: (target) => `Instalação concluída COM ERROS em ${target}`,
@@ -14,6 +14,8 @@ import { existsSync, rmSync } from 'node:fs'
14
14
  import { dirname, join } from 'node:path'
15
15
  import { fileURLToPath } from 'node:url'
16
16
 
17
+ import { ACTIONS, recordChange } from './plan.mjs'
18
+
17
19
  const __dirname = dirname(fileURLToPath(import.meta.url))
18
20
  const ROOT = join(__dirname, '..', '..')
19
21
 
@@ -40,10 +42,13 @@ export function venvPythonPath(target) {
40
42
  * Set up a Python virtual environment and install MCP dependencies.
41
43
  *
42
44
  * @param {string} target - Target installation directory (e.g. ~/.config/opencode)
43
- * @param {{ dryRun?: boolean, skipInstall?: boolean, force?: boolean }} [options]
45
+ * @param {{ dryRun?: boolean, skipInstall?: boolean, force?: boolean, recorder?: (change: object) => void }} [options]
44
46
  * @returns {{ venvPath: string, python: string }}
45
47
  */
46
- export function setupVenv(target, { dryRun = false, skipInstall = false, force = false } = {}) {
48
+ export function setupVenv(
49
+ target,
50
+ { dryRun = false, skipInstall = false, force = false, recorder = null } = {},
51
+ ) {
47
52
  const venvPath = join(target, '.venv')
48
53
  const pythonBin = venvPythonPath(target)
49
54
 
@@ -57,7 +62,8 @@ export function setupVenv(target, { dryRun = false, skipInstall = false, force =
57
62
  }
58
63
 
59
64
  // Step 1b: Create venv if not exists
60
- if (!existsSync(pythonBin)) {
65
+ const venvExists = existsSync(pythonBin)
66
+ if (!venvExists) {
61
67
  if (!dryRun) {
62
68
  console.log(' Creating .venv...')
63
69
  const result = spawnSync('python3', ['-m', 'venv', venvPath], {
@@ -69,8 +75,12 @@ export function setupVenv(target, { dryRun = false, skipInstall = false, force =
69
75
  }
70
76
  }
71
77
  console.log(' ✅ .venv created')
78
+ // A single synthetic entry for the venv directory — never enumerate its
79
+ // thousands of files. Unscored: the install summary does not count it.
80
+ recordChange({ path: venvPath, action: ACTIONS.CREATE, bytes: 0, scored: false }, recorder)
72
81
  } else {
73
82
  console.log(' ⏭️ .venv already exists')
83
+ recordChange({ path: venvPath, action: ACTIONS.UNCHANGED, bytes: 0, scored: false }, recorder)
74
84
  }
75
85
 
76
86
  // Step 2: Install MCP dependencies
@@ -0,0 +1,171 @@
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 { copyFileSync, 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
+ // This file is GLOBAL and lives outside the config dir, so the target-scoped
85
+ // exact backup set never captures it. Keep its own `<file>.bak` recovery.
86
+ beforeSwap: (existing) => {
87
+ if (existing === null) return
88
+ try {
89
+ copyFileSync(configPath, `${configPath}.bak`)
90
+ } catch {
91
+ // Best-effort: a backup failure must not block the write.
92
+ }
93
+ },
94
+ })
95
+ return { path: configPath, wrote: status === 'created', reason: status }
96
+ }
97
+
98
+ /**
99
+ * Ask the interactive vision question. `rl` may be injected (tests / an
100
+ * existing readline interface); otherwise a private one is created and closed.
101
+ * Returns `{action, visionModel?, wrote?, path?}` — never throws on bad input,
102
+ * and never writes for the default/skip paths.
103
+ *
104
+ * @param {{rl?: object, logger?: object, env?: Record<string, string|undefined>, dryRun?: boolean, beforeRecheck?: (existing: string|null) => void}} [opts]
105
+ */
106
+ export async function runVisionPrompt({
107
+ rl,
108
+ logger = console,
109
+ env = process.env,
110
+ dryRun = false,
111
+ beforeRecheck,
112
+ } = {}) {
113
+ let iface = rl
114
+ let closeAfter = false
115
+ if (!iface) {
116
+ const { createInterface } = await import('node:readline/promises')
117
+ iface = createInterface({ input: process.stdin, output: process.stdout })
118
+ closeAfter = true
119
+ }
120
+ try {
121
+ logger.log?.('')
122
+ logger.log?.('Vision — how should images be described?')
123
+ logger.log?.(
124
+ ' 1) Automatic discovery (default) — pick a vision-capable model from the host catalog',
125
+ )
126
+ logger.log?.(' 2) Pin a provider/model (e.g. opencode-go/mimo-v2.5)')
127
+ logger.log?.(' 3) Skip')
128
+ const answer = (await iface.question('Choice [1]: ')).trim().toLowerCase()
129
+
130
+ if (answer === '' || answer === '1' || answer === 'auto' || answer === 'discovery') {
131
+ logger.log?.('Vision: automatic discovery (no config written).')
132
+ return { action: 'auto' }
133
+ }
134
+ if (answer === '3' || answer === 'skip' || answer === 'no') {
135
+ logger.log?.('Vision: skipped (no config written).')
136
+ return { action: 'skip' }
137
+ }
138
+ if (answer === '2' || answer === 'pin') {
139
+ for (let attempt = 0; attempt < MAX_PIN_ATTEMPTS; attempt++) {
140
+ const raw = (await iface.question('provider/model (e.g. opencode-go/mimo-v2.5): ')).trim()
141
+ if (!isValidVisionModel(raw)) {
142
+ logger.warn?.(`Invalid model "${raw}" — expected provider/model (one slash, no spaces).`)
143
+ continue
144
+ }
145
+ const result = mergeVisionModel(raw, { env, dryRun, beforeRecheck })
146
+ if (result.reason === 'corrupt') {
147
+ logger.warn?.(
148
+ `Vision config at ${result.path} is not valid JSON — leaving it untouched; fix it and re-run.`,
149
+ )
150
+ return { action: 'pin', visionModel: raw, wrote: false, path: result.path }
151
+ }
152
+ if (result.reason === 'conflict') {
153
+ logger.warn?.(
154
+ `Vision: not saved — config at ${result.path} was modified concurrently; re-run to retry.`,
155
+ )
156
+ } else {
157
+ logger.log?.(`Vision: pinned ${raw} in ${result.path}.`)
158
+ }
159
+ return { action: 'pin', visionModel: raw, wrote: result.wrote, path: result.path }
160
+ }
161
+ logger.warn?.('Vision: no valid model entered — skipping (no config written).')
162
+ return { action: 'skip' }
163
+ }
164
+ logger.warn?.(
165
+ `Unknown choice "${answer}" — defaulting to automatic discovery (no config written).`,
166
+ )
167
+ return { action: 'auto' }
168
+ } finally {
169
+ if (closeAfter) iface.close()
170
+ }
171
+ }