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

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 (54) hide show
  1. package/AGENTS.md +19 -769
  2. package/CHANGELOG.md +53 -0
  3. package/README.md +78 -9
  4. package/README.pt-BR.md +10 -4
  5. package/package.json +5 -1
  6. package/plugin.json +2 -2
  7. package/pyproject.toml +3 -1
  8. package/scripts/build-agents-md.mjs +159 -26
  9. package/scripts/install/agents-md.mjs +2 -2
  10. package/scripts/install/config-migration.mjs +115 -1
  11. package/scripts/install/interactive.mjs +1 -1
  12. package/scripts/install/opencode.mjs +245 -12
  13. package/scripts/install/shared.mjs +115 -7
  14. package/scripts/sync-artifacts.mjs +2 -2
  15. package/scripts/test-opencode-v2-sandbox.sh +155 -4
  16. package/scripts/validate-sandbox-install.mjs +138 -0
  17. package/skills-lock.json +5 -5
  18. package/src/agents/aphrodite.md +18 -36
  19. package/src/agents/demeter.md +8 -27
  20. package/src/agents/gaia.md +1 -0
  21. package/src/agents/hephaestus.md +10 -20
  22. package/src/agents/hermes.md +15 -37
  23. package/src/agents/mnemosyne.md +14 -42
  24. package/src/agents/prometheus.md +16 -19
  25. package/src/agents/talos.md +4 -1
  26. package/src/agents/themis.md +4 -4
  27. package/src/agents/zeus.md +24 -127
  28. package/src/instructions/agent-return-format.instructions.md +2 -3
  29. package/src/instructions/backend-standards.instructions.md +2 -0
  30. package/src/instructions/frontend-standards.instructions.md +3 -1
  31. package/src/instructions/memory-protocol.instructions.md +11 -121
  32. package/src/instructions/zeus-anti-stall.instructions.md +14 -69
  33. package/src/instructions/zeus-communication-rules.instructions.md +0 -1
  34. package/src/instructions/zeus-council-synthesis.instructions.md +32 -260
  35. package/src/instructions/zeus-memory-operations.instructions.md +26 -0
  36. package/src/instructions/zeus-timeout-retry.instructions.md +40 -87
  37. package/src/pantheon/v2-delegation-enforce.ts +87 -0
  38. package/src/pantheon/v2-execute-after.ts +186 -0
  39. package/src/pantheon/v2-unsupported.mjs +0 -23
  40. package/src/plugin-v2.ts +271 -36
  41. package/src/plugins/tui/dist/tui.d.ts +11 -8
  42. package/src/plugins/tui/dist/tui.d.ts.map +1 -1
  43. package/src/plugins/tui/dist/tui.js +53 -37
  44. package/src/plugins/tui/package-lock.json +30 -33
  45. package/src/plugins/tui/package.json +5 -1
  46. package/src/plugins/tui/src/index.tsx +103 -82
  47. package/src/plugins/tui/tsdown.config.ts +17 -0
  48. package/src/routing.yml +10 -5
  49. package/src/skills/README.md +3 -3
  50. package/src/skills/artifact-management/SKILL.md +12 -5
  51. package/src/skills/auto-continue/SKILL.md +37 -28
  52. package/src/skills/code-review-checklist/SKILL.md +5 -5
  53. package/src/skills/context-compression/SKILL.md +26 -879
  54. package/src/skills/tdd-with-agents/SKILL.md +18 -10
@@ -29,9 +29,10 @@ function deepClone(obj) {
29
29
  // V1 → V2 helpers
30
30
  // ---------------------------------------------------------------------------
31
31
 
32
- /** Action name mapping for permissions (V1 key → V2 action) */
32
+ /** Action name mapping for permissions (V1 key → V2 action), including task → subagent. */
33
33
  const V1_PERMISSION_TO_ACTION = {
34
34
  bash: 'shell',
35
+ task: 'subagent',
35
36
  skill: 'skill',
36
37
  edit: 'edit',
37
38
  websearch: 'websearch',
@@ -490,6 +491,119 @@ function mergeManagedAgents(base, managed) {
490
491
  return merged
491
492
  }
492
493
 
494
+ // ---------------------------------------------------------------------------
495
+ // context_sandbox: V1 top-level block → V2 plugin options
496
+ // ---------------------------------------------------------------------------
497
+ //
498
+ // `context_sandbox` is a Pantheon-owned, V1 top-level setting (src/plugin.ts
499
+ // reads `config.context_sandbox`). OpenCode V2 no longer carries custom
500
+ // top-level keys: a sandbox probe against V2.0.25 showed `context_sandbox`
501
+ // exits 0 but is SILENTLY DROPPED — identical to an arbitrary unknown key and
502
+ // with no warning — while a value nested under a plugin entry's `options`
503
+ // survives verbatim (plugin options are not schema-validated). The V2 adapter
504
+ // reads the setting as `ctx.options.context_sandbox`.
505
+ //
506
+ // So the migration DUPLICATES the V1 top-level block into the exact Pantheon V2
507
+ // plugin entry's options. The top-level block is deliberately LEFT IN PLACE so
508
+ // a user who downgrades to V1 keeps the setting. Precedence per leaf:
509
+ // explicit V2 plugin option > legacy top-level fill > runtime defaults
510
+ // (defaults are never written by the installer — the adapter resolves them).
511
+ //
512
+ // A null or type-invalid known field is not silently coerced: the merge throws
513
+ // BEFORE the config is written, so a malformed block aborts the install instead
514
+ // of persisting a value that changes runtime behaviour.
515
+
516
+ /** Tool-keyed leaf fields of `context_sandbox.limits`. */
517
+ export const CONTEXT_SANDBOX_LIMIT_FIELDS = Object.freeze({
518
+ read: Object.freeze(['maxLines', 'keepHead', 'keepTail']),
519
+ grep: Object.freeze(['maxResults', 'keepTop']),
520
+ glob: Object.freeze(['maxFiles', 'keepTop']),
521
+ webfetch: Object.freeze(['maxChars', 'keepHead']),
522
+ })
523
+
524
+ /** @returns {boolean} true for a plain name→value map (not null/array) */
525
+ function isPlainObject(value) {
526
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
527
+ }
528
+
529
+ /**
530
+ * Validate one `context_sandbox` block (top-level legacy or V2 plugin option).
531
+ *
532
+ * `undefined` (key absent) is valid. Anything else must be a plain object with
533
+ * an optional boolean `enabled` and an optional `limits` object whose known
534
+ * tool groups are plain objects carrying only non-negative finite numbers.
535
+ * Unknown keys are ignored (the runtime resolver does the same).
536
+ *
537
+ * @param {unknown} block
538
+ * @param {string} label - human-readable origin, used in the thrown message
539
+ * @throws {Error} on a null or type-invalid known field
540
+ */
541
+ function assertSandboxBlock(block, label) {
542
+ if (block === undefined) return
543
+ if (!isPlainObject(block)) {
544
+ throw new Error(`${label} must be an object when present`)
545
+ }
546
+ if ('enabled' in block && typeof block.enabled !== 'boolean') {
547
+ throw new Error(`${label}.enabled must be a boolean`)
548
+ }
549
+ if ('limits' in block) {
550
+ if (!isPlainObject(block.limits)) {
551
+ throw new Error(`${label}.limits must be an object`)
552
+ }
553
+ for (const [tool, fields] of Object.entries(CONTEXT_SANDBOX_LIMIT_FIELDS)) {
554
+ const group = block.limits[tool]
555
+ if (group === undefined) continue
556
+ if (!isPlainObject(group)) {
557
+ throw new Error(`${label}.limits.${tool} must be an object`)
558
+ }
559
+ for (const field of fields) {
560
+ if (!(field in group)) continue
561
+ const value = group[field]
562
+ if (typeof value !== 'number' || !Number.isFinite(value) || value < 0) {
563
+ throw new Error(`${label}.limits.${tool}.${field} must be a non-negative number`)
564
+ }
565
+ }
566
+ }
567
+ }
568
+ }
569
+
570
+ /**
571
+ * Merge a legacy (V1 top-level) context_sandbox block into an optional explicit
572
+ * V2 block. Values are compared per leaf, so `enabled: false` and a numeric `0`
573
+ * are preserved (not mistaken for "unset").
574
+ *
575
+ * @param {object|undefined} v2Explicit - existing `plugins[...].options.context_sandbox`
576
+ * @param {object|undefined} legacy - top-level `context_sandbox`
577
+ * @returns {object} merged block (only keys that were actually set)
578
+ * @throws {Error} when either block is null/type-invalid
579
+ */
580
+ export function mergeContextSandboxBlocks(v2Explicit, legacy) {
581
+ assertSandboxBlock(v2Explicit, 'plugins[...].options.context_sandbox')
582
+ assertSandboxBlock(legacy, 'context_sandbox')
583
+
584
+ const explicit = isPlainObject(v2Explicit) ? v2Explicit : {}
585
+ const base = isPlainObject(legacy) ? legacy : {}
586
+ const merged = {}
587
+
588
+ if ('enabled' in explicit) merged.enabled = explicit.enabled
589
+ else if ('enabled' in base) merged.enabled = base.enabled
590
+
591
+ const mergedLimits = {}
592
+ for (const [tool, fields] of Object.entries(CONTEXT_SANDBOX_LIMIT_FIELDS)) {
593
+ const explicitGroup = isPlainObject(explicit.limits?.[tool]) ? explicit.limits[tool] : null
594
+ const baseGroup = isPlainObject(base.limits?.[tool]) ? base.limits[tool] : null
595
+ const group = {}
596
+ for (const field of fields) {
597
+ if (explicitGroup && field in explicitGroup) group[field] = explicitGroup[field]
598
+ else if (baseGroup && field in baseGroup) group[field] = baseGroup[field]
599
+ }
600
+ if (Object.keys(group).length > 0) mergedLimits[tool] = group
601
+ }
602
+ if (Object.keys(mergedLimits).length > 0) merged.limits = mergedLimits
603
+
604
+ return merged
605
+ }
606
+
493
607
  // ---------------------------------------------------------------------------
494
608
  // Public API
495
609
  // ---------------------------------------------------------------------------
@@ -329,7 +329,7 @@ export async function runInteractiveInstall({ components = [], defaultComponents
329
329
  {
330
330
  name: 'skills',
331
331
  value: 'skills',
332
- description: `Reusable skill workflows (${21} skills)`,
332
+ description: `Reusable skill workflows (${20} skills)`,
333
333
  },
334
334
  {
335
335
  name: 'instructions',
@@ -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,
@@ -131,6 +135,74 @@ export const MANAGED_FIELDS = Object.freeze([
131
135
  'disable_model_invocation',
132
136
  ])
133
137
 
138
+ const PANTHEON_MCP_SERVERS = Object.freeze([
139
+ 'pantheon-resources',
140
+ 'pantheon-memory',
141
+ 'pantheon-code-mode',
142
+ 'pantheon-persistence',
143
+ 'pantheon-vision',
144
+ ])
145
+
146
+ function normalizeMcpPermissionSegment(value) {
147
+ return value.replace(/[^a-zA-Z0-9_]/g, '_')
148
+ }
149
+
150
+ /**
151
+ * Translate an agent's `mcp_tools` frontmatter into per-tool native
152
+ * permissions. V1 matches the original server-tool names; V2 normalizes
153
+ * punctuation to underscores. Emitting both spellings keeps the same source
154
+ * metadata valid for either host. Unlisted bundled Pantheon servers default to
155
+ * deny; unrelated user MCP servers are not touched.
156
+ *
157
+ * @param {Record<string, 'all' | string[]>} mcpTools
158
+ * @returns {Record<string, 'allow' | 'deny'>}
159
+ */
160
+ export function deriveNativeMcpPermissions(mcpTools) {
161
+ if (mcpTools === null || typeof mcpTools !== 'object' || Array.isArray(mcpTools)) {
162
+ throw new TypeError('mcp_tools must be an object keyed by MCP server name')
163
+ }
164
+
165
+ const servers = new Set([...PANTHEON_MCP_SERVERS, ...Object.keys(mcpTools)])
166
+ const permissions = {}
167
+
168
+ for (const server of servers) {
169
+ if (!/^[a-zA-Z0-9._-]+$/.test(server)) {
170
+ throw new TypeError(`mcp_tools server name is invalid: ${server}`)
171
+ }
172
+ const tools = Object.hasOwn(mcpTools, server) ? mcpTools[server] : []
173
+ if (
174
+ tools !== 'all' &&
175
+ (!Array.isArray(tools) ||
176
+ tools.some((tool) => typeof tool !== 'string' || !/^[a-zA-Z0-9._-]+$/.test(tool)))
177
+ ) {
178
+ throw new TypeError(`mcp_tools.${server} must be "all" or an array of tool names`)
179
+ }
180
+
181
+ const serverAliases = new Set([server, normalizeMcpPermissionSegment(server)])
182
+ for (const alias of serverAliases) permissions[`${alias}_*`] = 'deny'
183
+ }
184
+
185
+ for (const server of servers) {
186
+ const tools = Object.hasOwn(mcpTools, server) ? mcpTools[server] : []
187
+ const serverAliases = new Set([server, normalizeMcpPermissionSegment(server)])
188
+ if (tools === 'all') {
189
+ for (const alias of serverAliases) permissions[`${alias}_*`] = 'allow'
190
+ continue
191
+ }
192
+
193
+ for (const tool of tools) {
194
+ const toolAliases = new Set([tool, normalizeMcpPermissionSegment(tool)])
195
+ for (const serverAlias of serverAliases) {
196
+ for (const toolAlias of toolAliases) {
197
+ permissions[`${serverAlias}_${toolAlias}`] = 'allow'
198
+ }
199
+ }
200
+ }
201
+ }
202
+
203
+ return permissions
204
+ }
205
+
134
206
  function deepClone(value) {
135
207
  return JSON.parse(JSON.stringify(value))
136
208
  }
@@ -173,6 +245,27 @@ function readJsonConfig(filePath) {
173
245
  }
174
246
  }
175
247
 
248
+ /**
249
+ * Read a JSON config AND the exact raw text it was parsed from.
250
+ *
251
+ * The raw text is threaded to {@link writeConfigWithBackup} as the
252
+ * expected-content guard, so the compare-before-rename uses the SAME bytes the
253
+ * caller parsed — no second read that could observe a different file.
254
+ *
255
+ * @param {string} filePath
256
+ * @returns {{ config: object, raw: string|null }}
257
+ * @throws {Error} on invalid JSON (before any write happens)
258
+ */
259
+ function readJsonConfigWithRaw(filePath) {
260
+ if (!existsSync(filePath)) return { config: {}, raw: null }
261
+ const raw = readFileSync(filePath, 'utf8')
262
+ try {
263
+ return { config: JSON.parse(raw), raw }
264
+ } catch (cause) {
265
+ throw new Error(`Invalid JSON in ${filePath}`, { cause })
266
+ }
267
+ }
268
+
176
269
  function mergeMissing(target, source) {
177
270
  if (!source || typeof source !== 'object' || Array.isArray(source)) return target
178
271
  if (!target || typeof target !== 'object' || Array.isArray(target)) return deepClone(source)
@@ -239,6 +332,7 @@ const PANTHEON_V2_PLUGIN = 'src/plugin-v2'
239
332
  const PANTHEON_V2_LEGACY_FILE = 'src/plugin-v2.ts'
240
333
  const PANTHEON_V1_EXPORT = 'pantheon-opencode/plugin'
241
334
  const PANTHEON_V2_EXPORT = 'pantheon-opencode/plugin-v2'
335
+ const PANTHEON_PACKAGE_REF = /^pantheon-opencode(?:@[^/]+)?(?:\/(plugin|plugin-v2))?$/
242
336
  const PANTHEON_PLUGIN_IDENTITIES = new Set([
243
337
  PANTHEON_V1_PLUGIN,
244
338
  PANTHEON_V1_HOOKS,
@@ -267,6 +361,17 @@ function managedPluginIdentity(ref) {
267
361
  const normalized = normalizePluginRef(ref)
268
362
  if (typeof normalized !== 'string') return null
269
363
 
364
+ // Older installs registered the package root itself (often pinned to a
365
+ // beta version) in OpenCode's plural `plugins` list. Treat all exact npm
366
+ // specs for this package as managed so an update removes the old cached
367
+ // copy before registering the current package path. Do not match similarly
368
+ // named third-party packages such as `pantheon-opencode-fork`.
369
+ const packageRef = normalized.match(PANTHEON_PACKAGE_REF)
370
+ if (packageRef !== null) {
371
+ if (packageRef[1] === 'plugin') return PANTHEON_V1_PLUGIN
372
+ return PANTHEON_V2_PLUGIN
373
+ }
374
+
270
375
  if (PANTHEON_PLUGIN_IDENTITIES.has(normalized)) return normalized
271
376
  if (normalized === PANTHEON_V2_LEGACY_FILE) return PANTHEON_V2_PLUGIN
272
377
  if (normalized === PANTHEON_V1_EXPORT || normalized.startsWith(`${PANTHEON_V1_EXPORT}@`)) {
@@ -345,6 +450,71 @@ export function deduplicatePluginReferences(refs) {
345
450
  })
346
451
  }
347
452
 
453
+ /**
454
+ * Duplicate the V1 top-level `context_sandbox` block into the exact Pantheon V2
455
+ * plugin entry's `options`, converting a plain-string entry into the
456
+ * `{ package, options }` object form the host accepts.
457
+ *
458
+ * Why the copy is required: OpenCode V2.0.25 silently drops a top-level
459
+ * `context_sandbox` key (probe evidence in the module header of
460
+ * config-migration.mjs), so the V1 setting would be lost on a V1→V2 install.
461
+ * The V1 top-level block is LEFT IN PLACE so a downgrade keeps working.
462
+ *
463
+ * Precedence is delegated to {@link mergeContextSandboxBlocks}: an explicit V2
464
+ * option leaf wins, the legacy block fills only unset leaves, and the runtime
465
+ * resolver supplies defaults. Only the Pantheon V2 entry is touched — every
466
+ * other plugin entry, and every other option on this entry, is preserved.
467
+ *
468
+ * The managed V2 option is ALWAYS validated — even when no legacy V1 block is
469
+ * present — because the migration is the only place that validates it. An
470
+ * earlier version returned early on `legacySandbox === undefined`, so an
471
+ * explicit `plugins[].options.context_sandbox` of `null` (or any type-invalid
472
+ * known value) was never checked and persisted straight to disk.
473
+ *
474
+ * @param {unknown[]} plugins - V2 `plugins` array (mutated in place)
475
+ * @param {unknown} legacySandbox - V1 top-level `context_sandbox` (may be undefined)
476
+ * @returns {unknown[]} the same array
477
+ * @throws {Error} when the legacy block or the existing V2 option is null or
478
+ * type-invalid (checked before any config write)
479
+ */
480
+ export function applyContextSandboxToV2Plugins(plugins, legacySandbox) {
481
+ if (!Array.isArray(plugins)) return plugins
482
+
483
+ const index = plugins.findIndex((ref) => pluginReferenceIdentity(ref) === PANTHEON_V2_PLUGIN)
484
+ if (index === -1) return plugins
485
+
486
+ const entry = plugins[index]
487
+ const isObjectEntry = typeof entry === 'object' && entry !== null && !Array.isArray(entry)
488
+ const hasOptions =
489
+ isObjectEntry &&
490
+ typeof entry.options === 'object' &&
491
+ entry.options !== null &&
492
+ !Array.isArray(entry.options)
493
+ const existingOptions = hasOptions ? entry.options : {}
494
+ const hasExistingSandbox = Object.hasOwn(existingOptions, 'context_sandbox')
495
+
496
+ // Nothing managed to migrate OR validate: leave the entry untouched so a
497
+ // re-install stays a true no-op. `legacySandbox === undefined` covers an
498
+ // absent V1 key; `!hasExistingSandbox` covers a V2 entry with no managed
499
+ // option yet. An explicit `null` is a PRESENT key (`hasExistingSandbox`), so
500
+ // it is deliberately NOT skipped and must be validated below.
501
+ if (legacySandbox === undefined && !hasExistingSandbox) return plugins
502
+
503
+ // Run the managed option through the validator unconditionally, merging the
504
+ // legacy block in only when it exists. A null/type-invalid existing option
505
+ // throws here, BEFORE the config is written.
506
+ const mergedSandbox = mergeContextSandboxBlocks(existingOptions.context_sandbox, legacySandbox)
507
+ const packageId = isObjectEntry ? entry.package : entry
508
+
509
+ // Rebuild the entry as an object so the options survive a V2 host (which
510
+ // preserves plugin option objects verbatim).
511
+ plugins[index] = {
512
+ package: packageId,
513
+ options: { ...existingOptions, context_sandbox: mergedSandbox },
514
+ }
515
+ return plugins
516
+ }
517
+
348
518
  export { parseOpenCodeVersion, resolveOpenCodeVersion } from './opencode-version.mjs'
349
519
 
350
520
  /**
@@ -675,7 +845,7 @@ export async function installOpenCode(
675
845
  const pantheonConfigPath =
676
846
  typeof mergeDevConfigOpt === 'string' ? resolve(mergeDevConfigOpt) : null
677
847
  const targetConfigPath = join(target, 'opencode.json')
678
- const config = readJsonConfig(targetConfigPath)
848
+ const { config, raw: configRaw } = readJsonConfigWithRaw(targetConfigPath)
679
849
  const pantheonConfig =
680
850
  pantheonConfigPath !== null && existsSync(pantheonConfigPath)
681
851
  ? readJsonConfig(pantheonConfigPath)
@@ -726,7 +896,7 @@ export async function installOpenCode(
726
896
  rmSync(join(dstDir, f), { recursive: true, force: true })
727
897
  }
728
898
  }
729
- const { created, skipped } = copyFiles(srcDir, dstDir, dryRun)
899
+ const { created, skipped } = copyAgentPrompts(srcDir, dstDir, dryRun)
730
900
  stats.created += created
731
901
  stats.skipped += skipped
732
902
  agentStep(true)
@@ -775,11 +945,12 @@ export async function installOpenCode(
775
945
  }
776
946
 
777
947
  // -----------------------------------------------------------------------
778
- // 2.5 Install instructions: AGENTS.md only (--components instructions)
948
+ // 2.5 Install shared instructions: AGENTS.md (--components instructions)
779
949
  // -----------------------------------------------------------------------
780
950
  // 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
951
+ // generated file embeds shared instructions; agent-scoped instructions are
952
+ // already rendered into supported agent prompt markdown during copy. The
953
+ // individual source fragments are NOT copied (nothing references
783
954
  // <config>/instructions/ after the section-D merge fix — copying them would
784
955
  // just leave stale duplicates on disk).
785
956
  if (componentSet.has('instructions')) {
@@ -793,6 +964,20 @@ export async function installOpenCode(
793
964
  if (status === 'created') stats.created++
794
965
  else stats.skipped++
795
966
  }
967
+ // An instruction-only upgrade must also refresh selective guidance on
968
+ // already-installed Pantheon agents, but must not create agents the user
969
+ // did not install. Full agent installs do this in phase 1 above.
970
+ if (!componentSet.has('agents')) {
971
+ const installedAgents = isGlobal
972
+ ? join(target, 'agents')
973
+ : join(target, '.opencode', 'agents')
974
+ const sourceAgents = join(ROOT, 'src', 'agents')
975
+ if (existsSync(installedAgents) && existsSync(sourceAgents)) {
976
+ const refreshed = copyAgentPrompts(sourceAgents, installedAgents, dryRun, true)
977
+ stats.created += refreshed.created
978
+ stats.skipped += refreshed.skipped
979
+ }
980
+ }
796
981
  }
797
982
 
798
983
  // -----------------------------------------------------------------------
@@ -987,7 +1172,7 @@ export async function installOpenCode(
987
1172
  // on top. Preserves user's MCP, provider, plugins, compaction, theme.
988
1173
  // -----------------------------------------------------------------------
989
1174
  // --------------------------------------------------------------------
990
- // A. Parse canonical agent config from agents/*.agent.md frontmatter
1175
+ // A. Parse canonical agent config from src/agents/*.md frontmatter
991
1176
  // and merge into opencode.json config.
992
1177
  // --------------------------------------------------------------------
993
1178
  function getAgentSources(agentPrefix) {
@@ -1035,6 +1220,12 @@ export async function installOpenCode(
1035
1220
  if (fm.permission) {
1036
1221
  agent.permission = JSON.parse(JSON.stringify(fm.permission))
1037
1222
  }
1223
+ if (fm.mcp_tools !== undefined) {
1224
+ agent.permission = {
1225
+ ...(agent.permission ?? {}),
1226
+ ...deriveNativeMcpPermissions(fm.mcp_tools),
1227
+ }
1228
+ }
1038
1229
 
1039
1230
  config[name] = agent
1040
1231
  }
@@ -1197,6 +1388,18 @@ export async function installOpenCode(
1197
1388
  config.plugin = removePantheonPluginReferences(config.plugin)
1198
1389
  }
1199
1390
  if (!Array.isArray(config.plugins)) config.plugins = []
1391
+ // Preserve any options already set on the Pantheon V2 entry across the
1392
+ // remove/re-add below. The context_sandbox migration reads them, and a
1393
+ // silent drop would lose a hand-set sandbox limit or any other plugin
1394
+ // option the user chose. Only the object form can carry options.
1395
+ const existingV2Options =
1396
+ config.plugins
1397
+ .filter((ref) => pluginReferenceIdentity(ref) === PANTHEON_V2_PLUGIN)
1398
+ .map((ref) =>
1399
+ typeof ref === 'object' && ref !== null && !Array.isArray(ref) ? ref.options : null,
1400
+ )
1401
+ .find((options) => options && typeof options === 'object' && !Array.isArray(options)) ??
1402
+ null
1200
1403
  config.plugins = deduplicatePluginReferences(removePantheonPluginReferences(config.plugins))
1201
1404
  if (Array.isArray(pantheonConfig?.plugins)) {
1202
1405
  for (const plugin of pantheonConfig.plugins) {
@@ -1221,7 +1424,11 @@ export async function installOpenCode(
1221
1424
  config.plugins = config.plugins.filter(
1222
1425
  (plugin) => pluginReferenceIdentity(plugin) !== pluginReferenceIdentity(resolvedV2),
1223
1426
  )
1224
- config.plugins.push(resolvedV2)
1427
+ config.plugins.push(
1428
+ existingV2Options === null
1429
+ ? resolvedV2
1430
+ : { package: resolvedV2, options: deepClone(existingV2Options) },
1431
+ )
1225
1432
  }
1226
1433
 
1227
1434
  if (pantheonConfig !== null) {
@@ -1302,8 +1509,8 @@ export async function installOpenCode(
1302
1509
  // --------------------------------------------------------------------
1303
1510
  // AGENTS.md is the single instruction file both V1 and V2 load (V1
1304
1511
  // 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`
1512
+ // contains shared rules; agent-scoped rules are in the installed agent
1513
+ // prompts. The `instructions/*.md`
1307
1514
  // glob is no longer needed — keeping it would duplicate content under V1
1308
1515
  // and is ignored under V2 anyway. Only ensure AGENTS.md is present.
1309
1516
  const pantheonInstructions = ['AGENTS.md']
@@ -1386,10 +1593,36 @@ export async function installOpenCode(
1386
1593
  if (entry && typeof entry === 'object' && !Array.isArray(entry)) delete entry.steps
1387
1594
  }
1388
1595
  }
1596
+
1597
+ // Carry the V1 top-level `context_sandbox` into the exact Pantheon V2
1598
+ // plugin entry's `options` — V2.0.25 silently drops the top-level key, so
1599
+ // without this the user's enabled/limits choice is lost on a V1→V2 install.
1600
+ // The V1 block itself is preserved by migrateV1toV2 for a downgrade.
1601
+ // A null/type-invalid block throws HERE, before any config write.
1602
+ configToWrite.plugins = applyContextSandboxToV2Plugins(
1603
+ configToWrite.plugins,
1604
+ config.context_sandbox,
1605
+ )
1389
1606
  }
1390
1607
 
1391
1608
  const configContent = `${JSON.stringify(configToWrite, null, 2)}\n`
1392
- const status = writeConfigWithBackup(targetConfigPath, configContent, dryRun)
1609
+ const status = writeConfigWithBackup(targetConfigPath, configContent, dryRun, {
1610
+ expectedContent: configRaw,
1611
+ // Test seam: lets a test simulate a concurrent writer landing between the
1612
+ // initial config read and the pre-swap recheck. Absent in production.
1613
+ beforeRecheck: opts.deps?.beforeRecheck,
1614
+ })
1615
+ if (status === 'conflict') {
1616
+ // The user's config changed under the installer between the read and the
1617
+ // swap. Treating this as an ordinary skipped write would report a
1618
+ // successful install over a file we never actually updated — fail loudly
1619
+ // and leave the target and its `.bak` byte-identical instead.
1620
+ throw new Error(
1621
+ `Installation aborted: ${targetConfigPath} changed on disk during install ` +
1622
+ '(it no longer matches the content that was read); refusing to overwrite it. ' +
1623
+ 'Re-run the installer once the file is stable.',
1624
+ )
1625
+ }
1393
1626
  if (status === 'created') stats.created++
1394
1627
  else stats.skipped++
1395
1628
 
@@ -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,
@@ -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
  /**