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.
- package/AGENTS.md +19 -769
- package/CHANGELOG.md +53 -0
- package/README.md +78 -9
- package/README.pt-BR.md +10 -4
- package/package.json +5 -1
- package/plugin.json +2 -2
- package/pyproject.toml +3 -1
- package/scripts/build-agents-md.mjs +159 -26
- package/scripts/install/agents-md.mjs +2 -2
- package/scripts/install/config-migration.mjs +115 -1
- package/scripts/install/interactive.mjs +1 -1
- package/scripts/install/opencode.mjs +245 -12
- package/scripts/install/shared.mjs +115 -7
- package/scripts/sync-artifacts.mjs +2 -2
- package/scripts/test-opencode-v2-sandbox.sh +155 -4
- package/scripts/validate-sandbox-install.mjs +138 -0
- package/skills-lock.json +5 -5
- package/src/agents/aphrodite.md +18 -36
- package/src/agents/demeter.md +8 -27
- package/src/agents/gaia.md +1 -0
- package/src/agents/hephaestus.md +10 -20
- package/src/agents/hermes.md +15 -37
- package/src/agents/mnemosyne.md +14 -42
- package/src/agents/prometheus.md +16 -19
- package/src/agents/talos.md +4 -1
- package/src/agents/themis.md +4 -4
- package/src/agents/zeus.md +24 -127
- package/src/instructions/agent-return-format.instructions.md +2 -3
- 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 +11 -121
- package/src/instructions/zeus-anti-stall.instructions.md +14 -69
- package/src/instructions/zeus-communication-rules.instructions.md +0 -1
- package/src/instructions/zeus-council-synthesis.instructions.md +32 -260
- package/src/instructions/zeus-memory-operations.instructions.md +26 -0
- package/src/instructions/zeus-timeout-retry.instructions.md +40 -87
- package/src/pantheon/v2-delegation-enforce.ts +87 -0
- package/src/pantheon/v2-execute-after.ts +186 -0
- package/src/pantheon/v2-unsupported.mjs +0 -23
- package/src/plugin-v2.ts +271 -36
- 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 +53 -37
- package/src/plugins/tui/package-lock.json +30 -33
- package/src/plugins/tui/package.json +5 -1
- package/src/plugins/tui/src/index.tsx +103 -82
- package/src/plugins/tui/tsdown.config.ts +17 -0
- package/src/routing.yml +10 -5
- package/src/skills/README.md +3 -3
- package/src/skills/artifact-management/SKILL.md +12 -5
- package/src/skills/auto-continue/SKILL.md +37 -28
- package/src/skills/code-review-checklist/SKILL.md +5 -5
- package/src/skills/context-compression/SKILL.md +26 -879
- 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 (${
|
|
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 {
|
|
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 =
|
|
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 } =
|
|
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
|
|
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
|
|
782
|
-
//
|
|
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/*.
|
|
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(
|
|
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
|
-
//
|
|
1306
|
-
//
|
|
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
|
-
|
|
261
|
-
|
|
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
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
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
|
/**
|