pantheon-opencode 1.6.0-beta.5 → 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.
- package/AGENTS.md +27 -820
- package/CHANGELOG.md +130 -0
- package/README.md +205 -30
- package/README.pt-BR.md +47 -23
- package/package.json +10 -5
- package/plugin.json +1 -1
- package/pyproject.toml +3 -1
- package/scripts/build-agents-md.mjs +162 -28
- package/scripts/doctor.mjs +104 -22
- package/scripts/install/agents-md.mjs +2 -2
- package/scripts/install/config-migration.mjs +154 -7
- package/scripts/install/opencode.mjs +202 -40
- package/scripts/install/shared.mjs +115 -7
- package/scripts/install/strings.mjs +8 -0
- package/scripts/probe-pantheon-cost.mjs +16 -3
- package/scripts/run-node-tests.mjs +189 -0
- package/scripts/sync-artifacts.mjs +2 -2
- package/scripts/test-opencode-v2-sandbox.sh +820 -86
- package/skills-lock.json +6 -5
- package/src/agents/aphrodite.md +17 -28
- package/src/agents/athena.md +1 -0
- package/src/agents/demeter.md +8 -18
- package/src/agents/hephaestus.md +10 -11
- package/src/agents/hermes.md +15 -30
- package/src/agents/iris.md +1 -0
- package/src/agents/nyx.md +1 -0
- package/src/agents/prometheus.md +16 -10
- package/src/agents/talos.md +4 -1
- package/src/agents/themis.md +5 -3
- package/src/agents/zeus.md +17 -280
- package/src/instructions/agent-return-format.instructions.md +4 -50
- package/src/instructions/backend-standards.instructions.md +2 -0
- package/src/instructions/frontend-standards.instructions.md +3 -1
- package/src/instructions/memory-protocol.instructions.md +12 -121
- package/src/instructions/zeus-anti-stall.instructions.md +8 -111
- package/src/instructions/zeus-communication-rules.instructions.md +0 -1
- package/src/instructions/zeus-memory-operations.instructions.md +26 -0
- package/src/instructions/zeus-timeout-retry.instructions.md +9 -130
- package/src/pantheon/secret-scanner.ts +489 -0
- package/src/pantheon/v2-delegation-enforce.ts +86 -0
- package/src/pantheon/v2-execute-after.ts +186 -0
- package/src/pantheon/v2-unsupported.mjs +0 -23
- package/src/plugin-v2.ts +254 -102
- package/src/plugins/pantheon-hooks.ts +82 -35
- package/src/plugins/tui/README.md +14 -0
- package/src/plugins/tui/dist/tui.d.ts +11 -8
- package/src/plugins/tui/dist/tui.d.ts.map +1 -1
- package/src/plugins/tui/dist/tui.js +102 -62
- package/src/plugins/tui/package-lock.json +30 -33
- package/src/plugins/tui/package.json +5 -1
- package/src/plugins/tui/src/index.tsx +163 -107
- package/src/plugins/tui/tsdown.config.ts +17 -0
- package/src/routing.yml +12 -9
- package/src/skills/README.md +9 -4
- package/src/skills/agent-coordination/SKILL.md +13 -83
- package/src/skills/artifact-management/SKILL.md +12 -5
- package/src/skills/auto-continue/SKILL.md +38 -28
- package/src/skills/code-review-checklist/SKILL.md +5 -5
- package/src/skills/council-synthesis/SKILL.md +29 -0
- package/src/skills/tdd-with-agents/SKILL.md +18 -10
- package/scripts/hooks/scan-secrets.sh +0 -97
- 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
|
-
|
|
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
|
-
|
|
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 =
|
|
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 {
|
|
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
|
|
205
|
-
*
|
|
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 =
|
|
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 } =
|
|
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
|
|
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
|
|
782
|
-
//
|
|
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/*.
|
|
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
|
|
1151
|
-
//
|
|
1152
|
-
//
|
|
1153
|
-
//
|
|
1154
|
-
//
|
|
1155
|
-
//
|
|
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
|
-
//
|
|
1209
|
-
//
|
|
1210
|
-
//
|
|
1211
|
-
//
|
|
1212
|
-
//
|
|
1213
|
-
//
|
|
1214
|
-
//
|
|
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(
|
|
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
|
-
//
|
|
1306
|
-
//
|
|
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
|
|