pantheon-opencode 1.6.0-beta.6 → 1.6.0-beta.7

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 (58) hide show
  1. package/AGENTS.md +27 -820
  2. package/CHANGELOG.md +116 -0
  3. package/README.md +132 -39
  4. package/README.pt-BR.md +47 -23
  5. package/package.json +6 -1
  6. package/plugin.json +1 -1
  7. package/pyproject.toml +3 -1
  8. package/scripts/build-agents-md.mjs +162 -28
  9. package/scripts/doctor.mjs +104 -22
  10. package/scripts/install/agents-md.mjs +2 -2
  11. package/scripts/install/config-migration.mjs +154 -7
  12. package/scripts/install/opencode.mjs +202 -40
  13. package/scripts/install/shared.mjs +115 -7
  14. package/scripts/install/strings.mjs +8 -0
  15. package/scripts/probe-pantheon-cost.mjs +16 -3
  16. package/scripts/sync-artifacts.mjs +2 -2
  17. package/scripts/test-opencode-v2-sandbox.sh +820 -86
  18. package/skills-lock.json +6 -5
  19. package/src/agents/aphrodite.md +17 -28
  20. package/src/agents/demeter.md +8 -18
  21. package/src/agents/hephaestus.md +10 -11
  22. package/src/agents/hermes.md +15 -30
  23. package/src/agents/prometheus.md +16 -10
  24. package/src/agents/talos.md +4 -1
  25. package/src/agents/themis.md +4 -3
  26. package/src/agents/zeus.md +16 -280
  27. package/src/instructions/agent-return-format.instructions.md +4 -50
  28. package/src/instructions/backend-standards.instructions.md +2 -0
  29. package/src/instructions/frontend-standards.instructions.md +3 -1
  30. package/src/instructions/memory-protocol.instructions.md +12 -121
  31. package/src/instructions/zeus-anti-stall.instructions.md +8 -111
  32. package/src/instructions/zeus-communication-rules.instructions.md +0 -1
  33. package/src/instructions/zeus-memory-operations.instructions.md +26 -0
  34. package/src/instructions/zeus-timeout-retry.instructions.md +9 -130
  35. package/src/pantheon/secret-scanner.ts +489 -0
  36. package/src/pantheon/v2-delegation-enforce.ts +86 -0
  37. package/src/pantheon/v2-execute-after.ts +186 -0
  38. package/src/pantheon/v2-unsupported.mjs +0 -23
  39. package/src/plugin-v2.ts +254 -102
  40. package/src/plugins/pantheon-hooks.ts +82 -35
  41. package/src/plugins/tui/README.md +14 -0
  42. package/src/plugins/tui/dist/tui.d.ts +11 -8
  43. package/src/plugins/tui/dist/tui.d.ts.map +1 -1
  44. package/src/plugins/tui/dist/tui.js +102 -62
  45. package/src/plugins/tui/package-lock.json +30 -33
  46. package/src/plugins/tui/package.json +5 -1
  47. package/src/plugins/tui/src/index.tsx +163 -107
  48. package/src/plugins/tui/tsdown.config.ts +17 -0
  49. package/src/routing.yml +12 -9
  50. package/src/skills/README.md +9 -4
  51. package/src/skills/agent-coordination/SKILL.md +13 -83
  52. package/src/skills/artifact-management/SKILL.md +12 -5
  53. package/src/skills/auto-continue/SKILL.md +37 -27
  54. package/src/skills/code-review-checklist/SKILL.md +5 -5
  55. package/src/skills/council-synthesis/SKILL.md +29 -0
  56. package/src/skills/tdd-with-agents/SKILL.md +18 -10
  57. package/scripts/hooks/scan-secrets.sh +0 -97
  58. package/src/instructions/zeus-council-synthesis.instructions.md +0 -299
@@ -34,10 +34,22 @@ const V1_PERMISSION_TO_ACTION = {
34
34
  bash: 'shell',
35
35
  skill: 'skill',
36
36
  edit: 'edit',
37
+ // V2 folds V1's `write` tool permission into the `edit` action, which also
38
+ // governs write and patch tools. Keep `edit` as the canonical reverse name.
39
+ write: 'edit',
40
+ task: 'subagent',
37
41
  websearch: 'websearch',
38
42
  // passthrough unknowns
39
43
  }
40
44
 
45
+ const V2_ACTION_TO_V1_PERMISSION = {
46
+ shell: 'bash',
47
+ skill: 'skill',
48
+ edit: 'edit',
49
+ subagent: 'task',
50
+ websearch: 'websearch',
51
+ }
52
+
41
53
  /**
42
54
  * Convert V1 permission object to V2 permissions array.
43
55
  *
@@ -49,15 +61,41 @@ const V1_PERMISSION_TO_ACTION = {
49
61
  */
50
62
  function convertPermissionsV1toV2(permObj) {
51
63
  const result = []
64
+ const resultIndices = new Map()
65
+ const effectRank = { allow: 0, ask: 1, deny: 2 }
66
+
67
+ const appendPermission = (action, resource, effect) => {
68
+ const key = JSON.stringify([action, resource])
69
+ const previousIndex = resultIndices.get(key)
70
+ if (previousIndex === undefined) {
71
+ resultIndices.set(key, result.length)
72
+ result.push({ action, resource, effect })
73
+ return
74
+ }
75
+
76
+ const previous = result[previousIndex]
77
+ if (previous.effect === effect) return
78
+
79
+ // V1 keeps `edit` and `write` as separate permission keys; V2's `edit`
80
+ // action covers both. If their rules collide on one resource, retain the
81
+ // stricter effect rather than letting object order turn a deny into allow.
82
+ console.warn(
83
+ `[config-migration] Conflicting V1 permission aliases for ${action}:${resource}; keeping the more restrictive effect`,
84
+ )
85
+ if ((effectRank[effect] ?? 0) > (effectRank[previous.effect] ?? 0)) {
86
+ previous.effect = effect
87
+ }
88
+ }
89
+
52
90
  for (const [key, value] of Object.entries(permObj)) {
53
91
  const action = V1_PERMISSION_TO_ACTION[key] || key
54
92
  if (typeof value === 'string') {
55
93
  // Simple: { edit: "allow" } → [{ action: "edit", resource: "*", effect: "allow" }]
56
- result.push({ action, resource: '*', effect: value })
94
+ appendPermission(action, '*', value)
57
95
  } else if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
58
96
  // Nested: { bash: { "git *": "allow" } } → [{ action: "shell", resource: "git *", effect: "allow" }]
59
97
  for (const [resource, effect] of Object.entries(value)) {
60
- result.push({ action, resource, effect })
98
+ appendPermission(action, resource, effect)
61
99
  }
62
100
  } else {
63
101
  console.warn(`[config-migration] Unexpected permission value for "${key}":`, value)
@@ -71,13 +109,9 @@ function convertPermissionsV1toV2(permObj) {
71
109
  */
72
110
  function convertPermissionsV2toV1(permArray) {
73
111
  const result = {}
74
- // Reverse action mapping: V2 action → V1 key
75
- const ACTION_TO_V1 = Object.fromEntries(
76
- Object.entries(V1_PERMISSION_TO_ACTION).map(([k, v]) => [v, k]),
77
- )
78
112
 
79
113
  for (const { action, resource, effect } of permArray) {
80
- const v1Key = ACTION_TO_V1[action] || action
114
+ const v1Key = V2_ACTION_TO_V1_PERMISSION[action] || action
81
115
  if (resource === '*') {
82
116
  result[v1Key] = effect
83
117
  } else {
@@ -490,6 +524,119 @@ function mergeManagedAgents(base, managed) {
490
524
  return merged
491
525
  }
492
526
 
527
+ // ---------------------------------------------------------------------------
528
+ // context_sandbox: V1 top-level block → V2 plugin options
529
+ // ---------------------------------------------------------------------------
530
+ //
531
+ // `context_sandbox` is a Pantheon-owned, V1 top-level setting (src/plugin.ts
532
+ // reads `config.context_sandbox`). OpenCode V2 no longer carries custom
533
+ // top-level keys: a sandbox probe against V2.0.25 showed `context_sandbox`
534
+ // exits 0 but is SILENTLY DROPPED — identical to an arbitrary unknown key and
535
+ // with no warning — while a value nested under a plugin entry's `options`
536
+ // survives verbatim (plugin options are not schema-validated). The V2 adapter
537
+ // reads the setting as `ctx.options.context_sandbox`.
538
+ //
539
+ // So the migration DUPLICATES the V1 top-level block into the exact Pantheon V2
540
+ // plugin entry's options. The top-level block is deliberately LEFT IN PLACE so
541
+ // a user who downgrades to V1 keeps the setting. Precedence per leaf:
542
+ // explicit V2 plugin option > legacy top-level fill > runtime defaults
543
+ // (defaults are never written by the installer — the adapter resolves them).
544
+ //
545
+ // A null or type-invalid known field is not silently coerced: the merge throws
546
+ // BEFORE the config is written, so a malformed block aborts the install instead
547
+ // of persisting a value that changes runtime behaviour.
548
+
549
+ /** Tool-keyed leaf fields of `context_sandbox.limits`. */
550
+ export const CONTEXT_SANDBOX_LIMIT_FIELDS = Object.freeze({
551
+ read: Object.freeze(['maxLines', 'keepHead', 'keepTail']),
552
+ grep: Object.freeze(['maxResults', 'keepTop']),
553
+ glob: Object.freeze(['maxFiles', 'keepTop']),
554
+ webfetch: Object.freeze(['maxChars', 'keepHead']),
555
+ })
556
+
557
+ /** @returns {boolean} true for a plain name→value map (not null/array) */
558
+ function isPlainObject(value) {
559
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
560
+ }
561
+
562
+ /**
563
+ * Validate one `context_sandbox` block (top-level legacy or V2 plugin option).
564
+ *
565
+ * `undefined` (key absent) is valid. Anything else must be a plain object with
566
+ * an optional boolean `enabled` and an optional `limits` object whose known
567
+ * tool groups are plain objects carrying only non-negative finite numbers.
568
+ * Unknown keys are ignored (the runtime resolver does the same).
569
+ *
570
+ * @param {unknown} block
571
+ * @param {string} label - human-readable origin, used in the thrown message
572
+ * @throws {Error} on a null or type-invalid known field
573
+ */
574
+ function assertSandboxBlock(block, label) {
575
+ if (block === undefined) return
576
+ if (!isPlainObject(block)) {
577
+ throw new Error(`${label} must be an object when present`)
578
+ }
579
+ if ('enabled' in block && typeof block.enabled !== 'boolean') {
580
+ throw new Error(`${label}.enabled must be a boolean`)
581
+ }
582
+ if ('limits' in block) {
583
+ if (!isPlainObject(block.limits)) {
584
+ throw new Error(`${label}.limits must be an object`)
585
+ }
586
+ for (const [tool, fields] of Object.entries(CONTEXT_SANDBOX_LIMIT_FIELDS)) {
587
+ const group = block.limits[tool]
588
+ if (group === undefined) continue
589
+ if (!isPlainObject(group)) {
590
+ throw new Error(`${label}.limits.${tool} must be an object`)
591
+ }
592
+ for (const field of fields) {
593
+ if (!(field in group)) continue
594
+ const value = group[field]
595
+ if (typeof value !== 'number' || !Number.isFinite(value) || value < 0) {
596
+ throw new Error(`${label}.limits.${tool}.${field} must be a non-negative number`)
597
+ }
598
+ }
599
+ }
600
+ }
601
+ }
602
+
603
+ /**
604
+ * Merge a legacy (V1 top-level) context_sandbox block into an optional explicit
605
+ * V2 block. Values are compared per leaf, so `enabled: false` and a numeric `0`
606
+ * are preserved (not mistaken for "unset").
607
+ *
608
+ * @param {object|undefined} v2Explicit - existing `plugins[...].options.context_sandbox`
609
+ * @param {object|undefined} legacy - top-level `context_sandbox`
610
+ * @returns {object} merged block (only keys that were actually set)
611
+ * @throws {Error} when either block is null/type-invalid
612
+ */
613
+ export function mergeContextSandboxBlocks(v2Explicit, legacy) {
614
+ assertSandboxBlock(v2Explicit, 'plugins[...].options.context_sandbox')
615
+ assertSandboxBlock(legacy, 'context_sandbox')
616
+
617
+ const explicit = isPlainObject(v2Explicit) ? v2Explicit : {}
618
+ const base = isPlainObject(legacy) ? legacy : {}
619
+ const merged = {}
620
+
621
+ if ('enabled' in explicit) merged.enabled = explicit.enabled
622
+ else if ('enabled' in base) merged.enabled = base.enabled
623
+
624
+ const mergedLimits = {}
625
+ for (const [tool, fields] of Object.entries(CONTEXT_SANDBOX_LIMIT_FIELDS)) {
626
+ const explicitGroup = isPlainObject(explicit.limits?.[tool]) ? explicit.limits[tool] : null
627
+ const baseGroup = isPlainObject(base.limits?.[tool]) ? base.limits[tool] : null
628
+ const group = {}
629
+ for (const field of fields) {
630
+ if (explicitGroup && field in explicitGroup) group[field] = explicitGroup[field]
631
+ else if (baseGroup && field in baseGroup) group[field] = baseGroup[field]
632
+ }
633
+ if (Object.keys(group).length > 0) mergedLimits[tool] = group
634
+ }
635
+ if (Object.keys(mergedLimits).length > 0) merged.limits = mergedLimits
636
+
637
+ return merged
638
+ }
639
+
493
640
  // ---------------------------------------------------------------------------
494
641
  // Public API
495
642
  // ---------------------------------------------------------------------------
@@ -21,6 +21,7 @@ import { chmodSync, existsSync, mkdirSync, readdirSync, readFileSync, rmSync } f
21
21
  import { homedir } from 'node:os'
22
22
  import { dirname, isAbsolute, join, resolve } from 'node:path'
23
23
  import { listV2UnsupportedFeatures } from '../../src/pantheon/v2-unsupported.mjs'
24
+ import { copyAgentPrompts } from '../build-agents-md.mjs'
24
25
  import {
25
26
  bullet,
26
27
  colors,
@@ -34,7 +35,11 @@ import {
34
35
  success,
35
36
  warning,
36
37
  } from './cli-ui.mjs'
37
- import { migrateMcpEntrypointPaths, migrateV1toV2 } from './config-migration.mjs'
38
+ import {
39
+ mergeContextSandboxBlocks,
40
+ migrateMcpEntrypointPaths,
41
+ migrateV1toV2,
42
+ } from './config-migration.mjs'
38
43
  import { healthCheck } from './health-check.mjs'
39
44
  import { detectVersion, runMigrations } from './migrate.mjs'
40
45
  import { resolveOpenCodeVersion } from './opencode-version.mjs'
@@ -42,7 +47,6 @@ import { copyPluginFiles, installPlugin, registerPlugin, unregisterPlugin } from
42
47
  import {
43
48
  checkRuntimePrerequisites,
44
49
  collectSkillNames,
45
- copyFiles,
46
50
  installSkills,
47
51
  parseFrontmatter,
48
52
  ROOT,
@@ -173,6 +177,27 @@ function readJsonConfig(filePath) {
173
177
  }
174
178
  }
175
179
 
180
+ /**
181
+ * Read a JSON config AND the exact raw text it was parsed from.
182
+ *
183
+ * The raw text is threaded to {@link writeConfigWithBackup} as the
184
+ * expected-content guard, so the compare-before-rename uses the SAME bytes the
185
+ * caller parsed — no second read that could observe a different file.
186
+ *
187
+ * @param {string} filePath
188
+ * @returns {{ config: object, raw: string|null }}
189
+ * @throws {Error} on invalid JSON (before any write happens)
190
+ */
191
+ function readJsonConfigWithRaw(filePath) {
192
+ if (!existsSync(filePath)) return { config: {}, raw: null }
193
+ const raw = readFileSync(filePath, 'utf8')
194
+ try {
195
+ return { config: JSON.parse(raw), raw }
196
+ } catch (cause) {
197
+ throw new Error(`Invalid JSON in ${filePath}`, { cause })
198
+ }
199
+ }
200
+
176
201
  function mergeMissing(target, source) {
177
202
  if (!source || typeof source !== 'object' || Array.isArray(source)) return target
178
203
  if (!target || typeof target !== 'object' || Array.isArray(target)) return deepClone(source)
@@ -201,23 +226,26 @@ function mergeMissing(target, source) {
201
226
  }
202
227
 
203
228
  /**
204
- * Resolve a plugin ref from the repository-root opencode.json to a path INSIDE
205
- * the installed package.
229
+ * Resolve a plugin ref from the repository-root opencode.json to its stable
230
+ * installed form.
206
231
  *
207
232
  * Only exact Pantheon plugin identities are resolved into the installed
208
233
  * package. In particular, a third-party absolute path ending in
209
234
  * `src/plugin.ts` or `src/plugins/pantheon-hooks.ts` is not evidence that the
210
- * plugin belongs to Pantheon and must be preserved verbatim.
235
+ * plugin belongs to Pantheon and must be preserved verbatim. V1 remains a pair
236
+ * of package-local paths; V2 uses an exact npm version so it is independent of
237
+ * the installer's global prefix or transient npx cache.
211
238
  */
212
239
  export function resolveInstalledPlugin(plugin) {
213
240
  if (typeof plugin !== 'string') return plugin
214
241
  const normalized = normalizePluginRef(plugin)
242
+ if (normalized === PANTHEON_V2_PLUGIN || normalized === PANTHEON_V2_LEGACY_FILE) {
243
+ return PANTHEON_V2_PACKAGE_SPEC
244
+ }
215
245
  if (PANTHEON_PLUGIN_IDENTITIES.has(normalized)) return join(ROOT, normalized)
216
- // Legacy V2 file ref (pre-directory-contract): migrate to the installed
217
- // plugin directory so the beta loader accepts the entry.
218
- if (normalized === PANTHEON_V2_LEGACY_FILE) return join(ROOT, PANTHEON_V2_PLUGIN)
219
246
  const identity = managedPluginIdentity(plugin)
220
247
  if (identity !== null && isAbsolute(normalized)) {
248
+ if (identity === PANTHEON_V2_PLUGIN) return PANTHEON_V2_PACKAGE_SPEC
221
249
  return INSTALLED_PANTHEON_PLUGIN_PATHS.get(identity) ?? plugin
222
250
  }
223
251
  return plugin
@@ -239,6 +267,14 @@ const PANTHEON_V2_PLUGIN = 'src/plugin-v2'
239
267
  const PANTHEON_V2_LEGACY_FILE = 'src/plugin-v2.ts'
240
268
  const PANTHEON_V1_EXPORT = 'pantheon-opencode/plugin'
241
269
  const PANTHEON_V2_EXPORT = 'pantheon-opencode/plugin-v2'
270
+ const PACKAGE_MANIFEST = JSON.parse(readFileSync(join(ROOT, 'package.json'), 'utf8'))
271
+ const PANTHEON_V2_PACKAGE_SPEC = `${PACKAGE_MANIFEST.name}@${PACKAGE_MANIFEST.version}`
272
+ // Installation origin probe: npm copies (global prefix, npx cache) always live
273
+ // under a `node_modules` directory; a repo checkout does not. From a checkout —
274
+ // or between a version bump and its publish — the V2 pin above names a version
275
+ // that may not exist on the npm registry yet, and the host cannot resolve it
276
+ // until publish. Advisory only: the install itself is structurally correct.
277
+ const RUNNING_FROM_NPM_INSTALL = import.meta.url.includes('/node_modules/')
242
278
  const PANTHEON_PLUGIN_IDENTITIES = new Set([
243
279
  PANTHEON_V1_PLUGIN,
244
280
  PANTHEON_V1_HOOKS,
@@ -272,6 +308,9 @@ function managedPluginIdentity(ref) {
272
308
  if (normalized === PANTHEON_V1_EXPORT || normalized.startsWith(`${PANTHEON_V1_EXPORT}@`)) {
273
309
  return PANTHEON_V1_PLUGIN
274
310
  }
311
+ if (normalized === PACKAGE_MANIFEST.name || normalized.startsWith(`${PACKAGE_MANIFEST.name}@`)) {
312
+ return PANTHEON_V2_PLUGIN
313
+ }
275
314
  if (normalized === PANTHEON_V2_EXPORT || normalized.startsWith(`${PANTHEON_V2_EXPORT}@`)) {
276
315
  return PANTHEON_V2_PLUGIN
277
316
  }
@@ -345,6 +384,71 @@ export function deduplicatePluginReferences(refs) {
345
384
  })
346
385
  }
347
386
 
387
+ /**
388
+ * Duplicate the V1 top-level `context_sandbox` block into the exact Pantheon V2
389
+ * plugin entry's `options`, converting a plain-string entry into the
390
+ * `{ package, options }` object form the host accepts.
391
+ *
392
+ * Why the copy is required: OpenCode V2.0.25 silently drops a top-level
393
+ * `context_sandbox` key (probe evidence in the module header of
394
+ * config-migration.mjs), so the V1 setting would be lost on a V1→V2 install.
395
+ * The V1 top-level block is LEFT IN PLACE so a downgrade keeps working.
396
+ *
397
+ * Precedence is delegated to {@link mergeContextSandboxBlocks}: an explicit V2
398
+ * option leaf wins, the legacy block fills only unset leaves, and the runtime
399
+ * resolver supplies defaults. Only the Pantheon V2 entry is touched — every
400
+ * other plugin entry, and every other option on this entry, is preserved.
401
+ *
402
+ * The managed V2 option is ALWAYS validated — even when no legacy V1 block is
403
+ * present — because the migration is the only place that validates it. An
404
+ * earlier version returned early on `legacySandbox === undefined`, so an
405
+ * explicit `plugins[].options.context_sandbox` of `null` (or any type-invalid
406
+ * known value) was never checked and persisted straight to disk.
407
+ *
408
+ * @param {unknown[]} plugins - V2 `plugins` array (mutated in place)
409
+ * @param {unknown} legacySandbox - V1 top-level `context_sandbox` (may be undefined)
410
+ * @returns {unknown[]} the same array
411
+ * @throws {Error} when the legacy block or the existing V2 option is null or
412
+ * type-invalid (checked before any config write)
413
+ */
414
+ export function applyContextSandboxToV2Plugins(plugins, legacySandbox) {
415
+ if (!Array.isArray(plugins)) return plugins
416
+
417
+ const index = plugins.findIndex((ref) => pluginReferenceIdentity(ref) === PANTHEON_V2_PLUGIN)
418
+ if (index === -1) return plugins
419
+
420
+ const entry = plugins[index]
421
+ const isObjectEntry = typeof entry === 'object' && entry !== null && !Array.isArray(entry)
422
+ const hasOptions =
423
+ isObjectEntry &&
424
+ typeof entry.options === 'object' &&
425
+ entry.options !== null &&
426
+ !Array.isArray(entry.options)
427
+ const existingOptions = hasOptions ? entry.options : {}
428
+ const hasExistingSandbox = Object.hasOwn(existingOptions, 'context_sandbox')
429
+
430
+ // Nothing managed to migrate OR validate: leave the entry untouched so a
431
+ // re-install stays a true no-op. `legacySandbox === undefined` covers an
432
+ // absent V1 key; `!hasExistingSandbox` covers a V2 entry with no managed
433
+ // option yet. An explicit `null` is a PRESENT key (`hasExistingSandbox`), so
434
+ // it is deliberately NOT skipped and must be validated below.
435
+ if (legacySandbox === undefined && !hasExistingSandbox) return plugins
436
+
437
+ // Run the managed option through the validator unconditionally, merging the
438
+ // legacy block in only when it exists. A null/type-invalid existing option
439
+ // throws here, BEFORE the config is written.
440
+ const mergedSandbox = mergeContextSandboxBlocks(existingOptions.context_sandbox, legacySandbox)
441
+ const packageId = isObjectEntry ? entry.package : entry
442
+
443
+ // Rebuild the entry as an object so the options survive a V2 host (which
444
+ // preserves plugin option objects verbatim).
445
+ plugins[index] = {
446
+ package: packageId,
447
+ options: { ...existingOptions, context_sandbox: mergedSandbox },
448
+ }
449
+ return plugins
450
+ }
451
+
348
452
  export { parseOpenCodeVersion, resolveOpenCodeVersion } from './opencode-version.mjs'
349
453
 
350
454
  /**
@@ -675,7 +779,7 @@ export async function installOpenCode(
675
779
  const pantheonConfigPath =
676
780
  typeof mergeDevConfigOpt === 'string' ? resolve(mergeDevConfigOpt) : null
677
781
  const targetConfigPath = join(target, 'opencode.json')
678
- const config = readJsonConfig(targetConfigPath)
782
+ const { config, raw: configRaw } = readJsonConfigWithRaw(targetConfigPath)
679
783
  const pantheonConfig =
680
784
  pantheonConfigPath !== null && existsSync(pantheonConfigPath)
681
785
  ? readJsonConfig(pantheonConfigPath)
@@ -726,7 +830,7 @@ export async function installOpenCode(
726
830
  rmSync(join(dstDir, f), { recursive: true, force: true })
727
831
  }
728
832
  }
729
- const { created, skipped } = copyFiles(srcDir, dstDir, dryRun)
833
+ const { created, skipped } = copyAgentPrompts(srcDir, dstDir, dryRun)
730
834
  stats.created += created
731
835
  stats.skipped += skipped
732
836
  agentStep(true)
@@ -775,11 +879,12 @@ export async function installOpenCode(
775
879
  }
776
880
 
777
881
  // -----------------------------------------------------------------------
778
- // 2.5 Install instructions: AGENTS.md only (--components instructions)
882
+ // 2.5 Install shared instructions: AGENTS.md (--components instructions)
779
883
  // -----------------------------------------------------------------------
780
884
  // AGENTS.md is the single instruction file both V1 and V2 load; the
781
- // generated file embeds every src/instructions/*.instructions.md body, so
782
- // the individual files are NOT copied anymore (nothing references
885
+ // generated file embeds shared instructions; agent-scoped instructions are
886
+ // already rendered into supported agent prompt markdown during copy. The
887
+ // individual source fragments are NOT copied (nothing references
783
888
  // <config>/instructions/ after the section-D merge fix — copying them would
784
889
  // just leave stale duplicates on disk).
785
890
  if (componentSet.has('instructions')) {
@@ -793,6 +898,20 @@ export async function installOpenCode(
793
898
  if (status === 'created') stats.created++
794
899
  else stats.skipped++
795
900
  }
901
+ // An instruction-only upgrade must also refresh selective guidance on
902
+ // already-installed Pantheon agents, but must not create agents the user
903
+ // did not install. Full agent installs do this in phase 1 above.
904
+ if (!componentSet.has('agents')) {
905
+ const installedAgents = isGlobal
906
+ ? join(target, 'agents')
907
+ : join(target, '.opencode', 'agents')
908
+ const sourceAgents = join(ROOT, 'src', 'agents')
909
+ if (existsSync(installedAgents) && existsSync(sourceAgents)) {
910
+ const refreshed = copyAgentPrompts(sourceAgents, installedAgents, dryRun, true)
911
+ stats.created += refreshed.created
912
+ stats.skipped += refreshed.skipped
913
+ }
914
+ }
796
915
  }
797
916
 
798
917
  // -----------------------------------------------------------------------
@@ -987,7 +1106,7 @@ export async function installOpenCode(
987
1106
  // on top. Preserves user's MCP, provider, plugins, compaction, theme.
988
1107
  // -----------------------------------------------------------------------
989
1108
  // --------------------------------------------------------------------
990
- // A. Parse canonical agent config from agents/*.agent.md frontmatter
1109
+ // A. Parse canonical agent config from src/agents/*.md frontmatter
991
1110
  // and merge into opencode.json config.
992
1111
  // --------------------------------------------------------------------
993
1112
  function getAgentSources(agentPrefix) {
@@ -1147,16 +1266,12 @@ export async function installOpenCode(
1147
1266
  // --------------------------------------------------------------------
1148
1267
  // B.5.1 Hermetic plugin resolution (packaging fix)
1149
1268
  // --------------------------------------------------------------------
1150
- // A developer running from a checkout has a repository-root opencode.json
1151
- // that may reference plugins via developer-machine absolute paths (e.g.
1152
- // <dev>/pantheon/.../src/plugin.ts or
1153
- // <dev>/pantheon/.../src/plugins/pantheon-hooks.ts). Copying those
1154
- // verbatim into the user's config would break every global install on any
1155
- // other machine. resolveInstalledPlugin() rewrites each entry to the plugin
1156
- // file inside the INSTALLED package (derived from ROOT — never hardcoded),
1157
- // so both dev and global installs resolve to a path that actually exists.
1158
- // The source opencode.json keeps its dev path for local development; the
1159
- // transform happens at install/sync time only.
1269
+ // A developer running from a checkout may have a repository-root
1270
+ // opencode.json with machine-specific plugin paths. V1 rewrites its two
1271
+ // managed entries to paths under the installed package. V2 instead writes
1272
+ // the exact package-root npm spec so it survives global-prefix and npx-cache
1273
+ // changes; a source-checkout V2 install warns that the version may not yet
1274
+ // exist on npm. Third-party paths remain untouched in either generation.
1160
1275
 
1161
1276
  if (version === 'v1') {
1162
1277
  // V1 loads the singular `plugin` list. Remove every Pantheon V2 entry from
@@ -1197,6 +1312,18 @@ export async function installOpenCode(
1197
1312
  config.plugin = removePantheonPluginReferences(config.plugin)
1198
1313
  }
1199
1314
  if (!Array.isArray(config.plugins)) config.plugins = []
1315
+ // Preserve any options already set on the Pantheon V2 entry across the
1316
+ // remove/re-add below. The context_sandbox migration reads them, and a
1317
+ // silent drop would lose a hand-set sandbox limit or any other plugin
1318
+ // option the user chose. Only the object form can carry options.
1319
+ const existingV2Options =
1320
+ config.plugins
1321
+ .filter((ref) => pluginReferenceIdentity(ref) === PANTHEON_V2_PLUGIN)
1322
+ .map((ref) =>
1323
+ typeof ref === 'object' && ref !== null && !Array.isArray(ref) ? ref.options : null,
1324
+ )
1325
+ .find((options) => options && typeof options === 'object' && !Array.isArray(options)) ??
1326
+ null
1200
1327
  config.plugins = deduplicatePluginReferences(removePantheonPluginReferences(config.plugins))
1201
1328
  if (Array.isArray(pantheonConfig?.plugins)) {
1202
1329
  for (const plugin of pantheonConfig.plugins) {
@@ -1205,23 +1332,22 @@ export async function installOpenCode(
1205
1332
  }
1206
1333
  }
1207
1334
  }
1208
- // The beta resolves every `plugins` entry through npm install, so an
1209
- // `a/b` shorthand (e.g. the published `pantheon-opencode/plugin-v2`
1210
- // export) is treated as a GitHub repo and dies with NpmInstallFailedError
1211
- // (beta log evidence). The beta loader additionally requires each entry
1212
- // to be a DIRECTORY with a real index.js/index.ts — a file path warns
1213
- // "must be a directory" and a symlink is ignored (sandbox probe
1214
- // evidence). Register the V2 plugin DIRECTORY inside the INSTALLED
1215
- // package — exactly like V1's ensurePantheonPlugin, but pointing at
1216
- // src/plugin-v2 whose index.ts shim re-exports the real entry.
1217
- // Legacy refs (npm shorthand, the old src/plugin-v2.ts file path) are
1218
- // recognized via managedPluginIdentity and already stripped above, so
1219
- // they migrate here.
1335
+ // V2 plugin entries are resolved by OpenCode as npm packages. Pin the
1336
+ // exact current version and use the package-root export, which is the V2
1337
+ // default plugin. Unlike an absolute path into ROOT, this survives moving
1338
+ // between a global install and an npx cache; unlike an unpinned package,
1339
+ // it cannot silently change underneath an existing config. Legacy local
1340
+ // directory/file refs are recognized via managedPluginIdentity and are
1341
+ // replaced here.
1220
1342
  const resolvedV2 = resolveInstalledPlugin(PANTHEON_V2_PLUGIN)
1221
1343
  config.plugins = config.plugins.filter(
1222
1344
  (plugin) => pluginReferenceIdentity(plugin) !== pluginReferenceIdentity(resolvedV2),
1223
1345
  )
1224
- config.plugins.push(resolvedV2)
1346
+ config.plugins.push(
1347
+ existingV2Options === null
1348
+ ? resolvedV2
1349
+ : { package: resolvedV2, options: deepClone(existingV2Options) },
1350
+ )
1225
1351
  }
1226
1352
 
1227
1353
  if (pantheonConfig !== null) {
@@ -1302,8 +1428,8 @@ export async function installOpenCode(
1302
1428
  // --------------------------------------------------------------------
1303
1429
  // AGENTS.md is the single instruction file both V1 and V2 load (V1
1304
1430
  // auto-discovers it, V2 loads it explicitly). The generated AGENTS.md
1305
- // (scripts/build-agents-md.mjs) already embeds every
1306
- // src/instructions/*.instructions.md body, so the `instructions/*.md`
1431
+ // contains shared rules; agent-scoped rules are in the installed agent
1432
+ // prompts. The `instructions/*.md`
1307
1433
  // glob is no longer needed — keeping it would duplicate content under V1
1308
1434
  // and is ignored under V2 anyway. Only ensure AGENTS.md is present.
1309
1435
  const pantheonInstructions = ['AGENTS.md']
@@ -1386,10 +1512,46 @@ export async function installOpenCode(
1386
1512
  if (entry && typeof entry === 'object' && !Array.isArray(entry)) delete entry.steps
1387
1513
  }
1388
1514
  }
1515
+
1516
+ // Carry the V1 top-level `context_sandbox` into the exact Pantheon V2
1517
+ // plugin entry's `options` — V2.0.25 silently drops the top-level key, so
1518
+ // without this the user's enabled/limits choice is lost on a V1→V2 install.
1519
+ // The V1 block itself is preserved by migrateV1toV2 for a downgrade.
1520
+ // A null/type-invalid block throws HERE, before any config write.
1521
+ configToWrite.plugins = applyContextSandboxToV2Plugins(
1522
+ configToWrite.plugins,
1523
+ config.context_sandbox,
1524
+ )
1525
+
1526
+ // Bootstrap chicken-and-egg: the pin names the CURRENT package version,
1527
+ // which exists on the registry only after publish. From an npm install
1528
+ // (global prefix / npx cache) that is the published version by
1529
+ // construction; from a repo checkout the pin may point at an unpublished
1530
+ // version the host cannot resolve. Warn instead of failing — the config is
1531
+ // structurally correct and the pin realigns once the version is out.
1532
+ if (!RUNNING_FROM_NPM_INSTALL) {
1533
+ warning(S.v2UnpublishedPin(PANTHEON_V2_PACKAGE_SPEC))
1534
+ }
1389
1535
  }
1390
1536
 
1391
1537
  const configContent = `${JSON.stringify(configToWrite, null, 2)}\n`
1392
- const status = writeConfigWithBackup(targetConfigPath, configContent, dryRun)
1538
+ const status = writeConfigWithBackup(targetConfigPath, configContent, dryRun, {
1539
+ expectedContent: configRaw,
1540
+ // Test seam: lets a test simulate a concurrent writer landing between the
1541
+ // initial config read and the pre-swap recheck. Absent in production.
1542
+ beforeRecheck: opts.deps?.beforeRecheck,
1543
+ })
1544
+ if (status === 'conflict') {
1545
+ // The user's config changed under the installer between the read and the
1546
+ // swap. Treating this as an ordinary skipped write would report a
1547
+ // successful install over a file we never actually updated — fail loudly
1548
+ // and leave the target and its `.bak` byte-identical instead.
1549
+ throw new Error(
1550
+ `Installation aborted: ${targetConfigPath} changed on disk during install ` +
1551
+ '(it no longer matches the content that was read); refusing to overwrite it. ' +
1552
+ 'Re-run the installer once the file is stable.',
1553
+ )
1554
+ }
1393
1555
  if (status === 'created') stats.created++
1394
1556
  else stats.skipped++
1395
1557