pantheon-opencode 1.6.0-beta.1 → 1.6.0-beta.10
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.global.md +182 -0
- package/AGENTS.md +10 -891
- package/CHANGELOG.md +326 -0
- package/README.md +496 -32
- package/README.pt-BR.md +285 -20
- package/bin/pantheon-init.mjs +26 -3
- package/commands/pantheon-audit.md +7 -1
- package/commands/pantheon-consolidate.md +1 -1
- package/commands/pantheon-deepwork.md +14 -3
- package/commands/pantheon-focus.md +17 -0
- package/commands/pantheon-vision.md +61 -12
- package/package.json +21 -14
- package/plugin.json +2 -2
- package/pyproject.toml +8 -6
- package/scripts/build-agents-md.mjs +374 -56
- package/scripts/changelog-merge.mjs +161 -0
- package/scripts/{code_mode_server.py → code_mode.py} +3 -3
- package/scripts/cost.mjs +244 -41
- package/scripts/doctor.mjs +151 -1
- package/scripts/install/agents-md.mjs +4 -4
- package/scripts/install/config-migration.mjs +317 -4
- package/scripts/install/health-check.mjs +21 -19
- package/scripts/install/interactive.mjs +1 -1
- package/scripts/install/opencode-version.mjs +246 -4
- package/scripts/install/opencode.mjs +627 -179
- package/scripts/install/plugin.mjs +43 -9
- package/scripts/install/shared.mjs +120 -12
- package/scripts/install/vision-prompt.mjs +161 -0
- package/scripts/install-mcp.mjs +28 -9
- package/scripts/{mcp_persistence_server.py → mcp_persistence.py} +2 -2
- package/{src/mcp/mcp_resources_server.py → scripts/mcp_resources.py} +2 -2
- package/scripts/{memory_mcp_server.py → memory_mcp.py} +2 -2
- package/scripts/probe-context-rehydrate.mjs +8 -2
- package/scripts/probe-pantheon-cost.mjs +71 -15
- package/scripts/run-node-tests.mjs +189 -0
- package/scripts/sync-artifacts.mjs +26 -13
- package/scripts/sync-tui.mjs +24 -3
- package/scripts/test-opencode-v2-sandbox.sh +180 -26
- package/scripts/validate-routing.mjs +3 -3
- package/scripts/validate-sandbox-install.mjs +138 -0
- package/scripts/versioning.mjs +13 -1
- package/scripts/vision-status.mjs +682 -0
- package/skills-lock.json +8 -8
- package/src/agents/aphrodite.md +20 -37
- package/src/agents/apollo.md +7 -3
- package/src/agents/athena.md +63 -35
- package/src/agents/demeter.md +8 -28
- package/src/agents/gaia.md +1 -1
- package/src/agents/hephaestus.md +10 -21
- package/src/agents/hermes.md +15 -38
- package/src/agents/iris.md +1 -1
- package/src/agents/mnemosyne.md +18 -54
- package/src/agents/nyx.md +1 -1
- package/src/agents/prometheus.md +16 -20
- package/src/agents/talos.md +10 -2
- package/src/agents/themis.md +5 -5
- package/src/agents/zeus.md +99 -129
- package/src/instructions/agent-return-format.instructions.md +18 -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 -110
- package/src/instructions/zeus-anti-stall.instructions.md +14 -61
- 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/mcp/{code_mode_server.py → code_mode.py} +3 -3
- package/src/mcp/eval_store.py +2 -2
- package/src/mcp/{mcp_persistence_server.py → mcp_persistence.py} +2 -2
- package/{scripts/mcp_resources_server.py → src/mcp/mcp_resources.py} +2 -2
- package/src/mcp/{memory_mcp_server.py → memory_mcp.py} +2 -2
- package/src/mcp/{pantheon_vision_server.py → pantheon_vision.py} +286 -42
- package/src/mcp/requirements-mcp.txt +6 -4
- package/src/mcp/requirements-vision.txt +2 -2
- package/src/pantheon/cost-command.ts +399 -101
- package/src/pantheon/delegation-enforce.ts +10 -1
- package/src/pantheon/presets.d.mts +0 -8
- package/src/pantheon/presets.mjs +5 -32
- package/src/pantheon/v2-bounded-read.ts +640 -0
- package/src/pantheon/v2-delegation-enforce.ts +214 -0
- package/src/pantheon/v2-execute-after.ts +186 -0
- package/src/pantheon/v2-tools.ts +429 -0
- package/src/pantheon/v2-unsupported.d.mts +11 -0
- package/src/pantheon/v2-unsupported.mjs +90 -0
- package/src/pantheon/v2-vision.ts +622 -0
- package/src/pantheon/vision-local.ts +531 -0
- package/src/pantheon/vision.ts +1160 -115
- package/src/plugin-v2.ts +1145 -219
- package/src/plugin.ts +12 -9
- package/src/plugins/tui/dist/server.d.ts +1 -2
- package/src/plugins/tui/dist/server.d.ts.map +1 -1
- package/src/plugins/tui/dist/tui-v2.d.ts +154 -0
- package/src/plugins/tui/dist/tui-v2.d.ts.map +1 -0
- package/src/plugins/tui/dist/tui-v2.js +659 -0
- package/src/plugins/tui/dist/tui-v2.js.map +1 -0
- package/src/plugins/tui/dist/tui.d.ts +85 -82
- package/src/plugins/tui/dist/tui.d.ts.map +1 -1
- package/src/plugins/tui/dist/tui.js +72 -59
- package/src/plugins/tui/index-v2.ts +6 -0
- package/src/plugins/tui/package-lock.json +5516 -1353
- package/src/plugins/tui/package.json +22 -12
- package/src/plugins/tui/src/index-v2.tsx +734 -0
- package/src/plugins/tui/src/index.tsx +106 -85
- package/src/plugins/tui/src/opencode-v2.d.ts +13 -0
- package/src/plugins/tui/tsconfig.build.json +5 -0
- package/src/plugins/tui/tsconfig.typecheck.json +5 -0
- package/src/plugins/tui/tsdown.config.ts +22 -2
- package/src/plugins/tui/tui-v2.ts +2 -0
- package/src/plugins/tui/tui.ts +3 -0
- package/src/routing.yml +11 -21
- package/src/skills/README.md +3 -3
- package/src/skills/agent-coordination/SKILL.md +7 -2
- package/src/skills/artifact-management/SKILL.md +12 -5
- package/src/skills/auto-continue/SKILL.md +39 -30
- package/src/skills/code-review-checklist/SKILL.md +5 -5
- package/src/skills/context-compression/SKILL.md +26 -879
- package/src/skills/session-goal/SKILL.md +14 -5
- package/src/skills/spec-driven-development/SKILL.md +3 -1
- package/src/skills/tdd-with-agents/SKILL.md +18 -10
- package/opencode.json +0 -58
|
@@ -4,10 +4,14 @@
|
|
|
4
4
|
*
|
|
5
5
|
* Dual-version install (Phase 3): V1 and V2 receive the schema each runtime
|
|
6
6
|
* actually consumes. V1 (`opencode` 1.18.x) uses the singular `plugin` key;
|
|
7
|
-
* V2 (`opencode2
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
7
|
+
* V2 (`opencode2`, OpenCode 2.x — `opencode2 --version` prints
|
|
8
|
+
* "opencode v2.0.22") converts that list to `plugins` before
|
|
9
|
+
* writing its config. An early 2.x prerelease printed "0.0.0-next-17444"
|
|
10
|
+
* instead; that is history, not what hosts report today, and it must not be
|
|
11
|
+
* cited as an observation (see the precedence rationale in
|
|
12
|
+
* scripts/install/opencode-version.mjs). Known V2 beta gaps handled here:
|
|
13
|
+
* top-level `subagent_depth` is migrated to `experimental.subagent_depth`, and
|
|
14
|
+
* the instruction content is consolidated into AGENTS.md, which both versions
|
|
11
15
|
* load. Pass --version v2 to pantheon-init for an informational label; state
|
|
12
16
|
* isolation is handled at runtime via OPENCODE_DB.
|
|
13
17
|
*/
|
|
@@ -16,6 +20,8 @@ import { createHash } from 'node:crypto'
|
|
|
16
20
|
import { chmodSync, existsSync, mkdirSync, readdirSync, readFileSync, rmSync } from 'node:fs'
|
|
17
21
|
import { homedir } from 'node:os'
|
|
18
22
|
import { dirname, isAbsolute, join, resolve } from 'node:path'
|
|
23
|
+
import { listV2UnsupportedFeatures } from '../../src/pantheon/v2-unsupported.mjs'
|
|
24
|
+
import { copyAgentPrompts, writeAgentsMdManaged } from '../build-agents-md.mjs'
|
|
19
25
|
import {
|
|
20
26
|
bullet,
|
|
21
27
|
colors,
|
|
@@ -29,7 +35,11 @@ import {
|
|
|
29
35
|
success,
|
|
30
36
|
warning,
|
|
31
37
|
} from './cli-ui.mjs'
|
|
32
|
-
import {
|
|
38
|
+
import {
|
|
39
|
+
mergeContextSandboxBlocks,
|
|
40
|
+
migrateMcpEntrypointPaths,
|
|
41
|
+
migrateV1toV2,
|
|
42
|
+
} from './config-migration.mjs'
|
|
33
43
|
import { healthCheck } from './health-check.mjs'
|
|
34
44
|
import { detectVersion, runMigrations } from './migrate.mjs'
|
|
35
45
|
import { resolveOpenCodeVersion } from './opencode-version.mjs'
|
|
@@ -37,7 +47,6 @@ import { copyPluginFiles, installPlugin, registerPlugin, unregisterPlugin } from
|
|
|
37
47
|
import {
|
|
38
48
|
checkRuntimePrerequisites,
|
|
39
49
|
collectSkillNames,
|
|
40
|
-
copyFiles,
|
|
41
50
|
installSkills,
|
|
42
51
|
parseFrontmatter,
|
|
43
52
|
ROOT,
|
|
@@ -47,6 +56,7 @@ import {
|
|
|
47
56
|
writeConfigWithBackup,
|
|
48
57
|
writeIfChanged,
|
|
49
58
|
} from './shared.mjs'
|
|
59
|
+
import { createInitialState, hasState, readState, setComponentState, writeState } from './state.mjs'
|
|
50
60
|
import { strings } from './strings.mjs'
|
|
51
61
|
|
|
52
62
|
import { setupVenv, venvPythonPath } from './venv.mjs'
|
|
@@ -61,6 +71,139 @@ const COMPONENT_NAMES = [
|
|
|
61
71
|
'runtime',
|
|
62
72
|
]
|
|
63
73
|
|
|
74
|
+
/**
|
|
75
|
+
* The product defaults an install seeds into the user's `opencode.json`.
|
|
76
|
+
*
|
|
77
|
+
* They live HERE, in code, rather than in a template shipped in the npm
|
|
78
|
+
* tarball: that template also carried a maintainer's private MCP endpoint,
|
|
79
|
+
* provider models and per-developer permission overrides, and shipping it to
|
|
80
|
+
* every installer was the bug (#199). Reading defaults off it meant deleting it
|
|
81
|
+
* silently stopped the seeding, so the defaults are now stated next to the code
|
|
82
|
+
* that consumes them.
|
|
83
|
+
*
|
|
84
|
+
* PERSONAL CONFIGURATION MUST NEVER BE SEEDED HERE: this is the installer's
|
|
85
|
+
* contract with every user, and a value that is only right on one machine
|
|
86
|
+
* belongs in that machine's own `opencode.json`. Deliberately NOT carried over
|
|
87
|
+
* from the template, each because it was personal rather than a product
|
|
88
|
+
* default:
|
|
89
|
+
* - `provider` — pinned the maintainer's provider, model and modalities.
|
|
90
|
+
* - `mcp` — a private remote MCP endpoint.
|
|
91
|
+
* - `agent` — the maintainer's per-agent permission overrides.
|
|
92
|
+
* - `permission.websearch: "deny"` — a personal restriction; a user may rely
|
|
93
|
+
* on web search, so denying it is not ours to decide.
|
|
94
|
+
* - `permission.bash` — the template's dev allowlist (git, npm, ruff, …), a
|
|
95
|
+
* personal judgement about which commands to trust.
|
|
96
|
+
* Anything added here must be justifiable as "correct for a fresh install of
|
|
97
|
+
* the product, for anyone".
|
|
98
|
+
*
|
|
99
|
+
* Other seeding paths are untouched by this constant and still run: the config
|
|
100
|
+
* merge derives plugin refs, provider and compaction from a repository-root
|
|
101
|
+
* opencode.json when a contributor has one, and separately gates its own
|
|
102
|
+
* permission grants — `skill` on the skills component, `bash` unconditionally —
|
|
103
|
+
* on whether the user's own config already set them.
|
|
104
|
+
*
|
|
105
|
+
* Frozen, and copied only into a confirmed gap (never into a key the user
|
|
106
|
+
* already set), so nothing here can be mutated through a user's config.
|
|
107
|
+
*/
|
|
108
|
+
const PRODUCT_CONFIG_DEFAULTS = Object.freeze({
|
|
109
|
+
default_agent: 'zeus',
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Per-agent fields owned by the framework: for an agent that already exists in
|
|
114
|
+
* the target config these are overwritten from the canonical source, and for a
|
|
115
|
+
* new agent they are the seed. Everything else on an existing entry is the
|
|
116
|
+
* user's and is left alone.
|
|
117
|
+
*
|
|
118
|
+
* `source` is written right next to these (for both new and existing agents) but
|
|
119
|
+
* is kept out of the loop so it can also be set independently — the migration in
|
|
120
|
+
* config-migration.mjs mirrors this list plus `source`, and tests assert the two
|
|
121
|
+
* stay equal as sets.
|
|
122
|
+
*
|
|
123
|
+
* KNOWN DEBT: `permission` is the V1 spelling. V2 reads `permissions`, so an
|
|
124
|
+
* agent can carry both keys after an install and the V1 one becomes dead weight.
|
|
125
|
+
* Tracked for the next slice; changing it here would change what an install
|
|
126
|
+
* writes.
|
|
127
|
+
*
|
|
128
|
+
* @type {readonly string[]}
|
|
129
|
+
*/
|
|
130
|
+
export const MANAGED_FIELDS = Object.freeze([
|
|
131
|
+
'temperature',
|
|
132
|
+
'color',
|
|
133
|
+
'permission',
|
|
134
|
+
'mode',
|
|
135
|
+
'hidden',
|
|
136
|
+
'disable_model_invocation',
|
|
137
|
+
])
|
|
138
|
+
|
|
139
|
+
const PANTHEON_MCP_SERVERS = Object.freeze([
|
|
140
|
+
'pantheon-resources',
|
|
141
|
+
'pantheon-memory',
|
|
142
|
+
'pantheon-code-mode',
|
|
143
|
+
'pantheon-persistence',
|
|
144
|
+
'pantheon-vision',
|
|
145
|
+
])
|
|
146
|
+
|
|
147
|
+
function normalizeMcpPermissionSegment(value) {
|
|
148
|
+
return value.replace(/[^a-zA-Z0-9_]/g, '_')
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Translate an agent's `mcp_tools` frontmatter into per-tool native
|
|
153
|
+
* permissions. V1 matches the original server-tool names; V2 normalizes
|
|
154
|
+
* punctuation to underscores. Emitting both spellings keeps the same source
|
|
155
|
+
* metadata valid for either host. Unlisted bundled Pantheon servers default to
|
|
156
|
+
* deny; unrelated user MCP servers are not touched.
|
|
157
|
+
*
|
|
158
|
+
* @param {Record<string, 'all' | string[]>} mcpTools
|
|
159
|
+
* @returns {Record<string, 'allow' | 'deny'>}
|
|
160
|
+
*/
|
|
161
|
+
export function deriveNativeMcpPermissions(mcpTools) {
|
|
162
|
+
if (mcpTools === null || typeof mcpTools !== 'object' || Array.isArray(mcpTools)) {
|
|
163
|
+
throw new TypeError('mcp_tools must be an object keyed by MCP server name')
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const servers = new Set([...PANTHEON_MCP_SERVERS, ...Object.keys(mcpTools)])
|
|
167
|
+
const permissions = {}
|
|
168
|
+
|
|
169
|
+
for (const server of servers) {
|
|
170
|
+
if (!/^[a-zA-Z0-9._-]+$/.test(server)) {
|
|
171
|
+
throw new TypeError(`mcp_tools server name is invalid: ${server}`)
|
|
172
|
+
}
|
|
173
|
+
const tools = Object.hasOwn(mcpTools, server) ? mcpTools[server] : []
|
|
174
|
+
if (
|
|
175
|
+
tools !== 'all' &&
|
|
176
|
+
(!Array.isArray(tools) ||
|
|
177
|
+
tools.some((tool) => typeof tool !== 'string' || !/^[a-zA-Z0-9._-]+$/.test(tool)))
|
|
178
|
+
) {
|
|
179
|
+
throw new TypeError(`mcp_tools.${server} must be "all" or an array of tool names`)
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const serverAliases = new Set([server, normalizeMcpPermissionSegment(server)])
|
|
183
|
+
for (const alias of serverAliases) permissions[`${alias}_*`] = 'deny'
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
for (const server of servers) {
|
|
187
|
+
const tools = Object.hasOwn(mcpTools, server) ? mcpTools[server] : []
|
|
188
|
+
const serverAliases = new Set([server, normalizeMcpPermissionSegment(server)])
|
|
189
|
+
if (tools === 'all') {
|
|
190
|
+
for (const alias of serverAliases) permissions[`${alias}_*`] = 'allow'
|
|
191
|
+
continue
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
for (const tool of tools) {
|
|
195
|
+
const toolAliases = new Set([tool, normalizeMcpPermissionSegment(tool)])
|
|
196
|
+
for (const serverAlias of serverAliases) {
|
|
197
|
+
for (const toolAlias of toolAliases) {
|
|
198
|
+
permissions[`${serverAlias}_${toolAlias}`] = 'allow'
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
return permissions
|
|
205
|
+
}
|
|
206
|
+
|
|
64
207
|
function deepClone(value) {
|
|
65
208
|
return JSON.parse(JSON.stringify(value))
|
|
66
209
|
}
|
|
@@ -103,6 +246,27 @@ function readJsonConfig(filePath) {
|
|
|
103
246
|
}
|
|
104
247
|
}
|
|
105
248
|
|
|
249
|
+
/**
|
|
250
|
+
* Read a JSON config AND the exact raw text it was parsed from.
|
|
251
|
+
*
|
|
252
|
+
* The raw text is threaded to {@link writeConfigWithBackup} as the
|
|
253
|
+
* expected-content guard, so the compare-before-rename uses the SAME bytes the
|
|
254
|
+
* caller parsed — no second read that could observe a different file.
|
|
255
|
+
*
|
|
256
|
+
* @param {string} filePath
|
|
257
|
+
* @returns {{ config: object, raw: string|null }}
|
|
258
|
+
* @throws {Error} on invalid JSON (before any write happens)
|
|
259
|
+
*/
|
|
260
|
+
function readJsonConfigWithRaw(filePath) {
|
|
261
|
+
if (!existsSync(filePath)) return { config: {}, raw: null }
|
|
262
|
+
const raw = readFileSync(filePath, 'utf8')
|
|
263
|
+
try {
|
|
264
|
+
return { config: JSON.parse(raw), raw }
|
|
265
|
+
} catch (cause) {
|
|
266
|
+
throw new Error(`Invalid JSON in ${filePath}`, { cause })
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
106
270
|
function mergeMissing(target, source) {
|
|
107
271
|
if (!source || typeof source !== 'object' || Array.isArray(source)) return target
|
|
108
272
|
if (!target || typeof target !== 'object' || Array.isArray(target)) return deepClone(source)
|
|
@@ -131,8 +295,8 @@ function mergeMissing(target, source) {
|
|
|
131
295
|
}
|
|
132
296
|
|
|
133
297
|
/**
|
|
134
|
-
* Resolve a plugin ref from the
|
|
135
|
-
* installed package.
|
|
298
|
+
* Resolve a plugin ref from the repository-root opencode.json to a path INSIDE
|
|
299
|
+
* the installed package.
|
|
136
300
|
*
|
|
137
301
|
* Only exact Pantheon plugin identities are resolved into the installed
|
|
138
302
|
* package. In particular, a third-party absolute path ending in
|
|
@@ -169,6 +333,7 @@ const PANTHEON_V2_PLUGIN = 'src/plugin-v2'
|
|
|
169
333
|
const PANTHEON_V2_LEGACY_FILE = 'src/plugin-v2.ts'
|
|
170
334
|
const PANTHEON_V1_EXPORT = 'pantheon-opencode/plugin'
|
|
171
335
|
const PANTHEON_V2_EXPORT = 'pantheon-opencode/plugin-v2'
|
|
336
|
+
const PANTHEON_PACKAGE_REF = /^pantheon-opencode(?:@[^/]+)?(?:\/(plugin|plugin-v2))?$/
|
|
172
337
|
const PANTHEON_PLUGIN_IDENTITIES = new Set([
|
|
173
338
|
PANTHEON_V1_PLUGIN,
|
|
174
339
|
PANTHEON_V1_HOOKS,
|
|
@@ -197,6 +362,17 @@ function managedPluginIdentity(ref) {
|
|
|
197
362
|
const normalized = normalizePluginRef(ref)
|
|
198
363
|
if (typeof normalized !== 'string') return null
|
|
199
364
|
|
|
365
|
+
// Older installs registered the package root itself (often pinned to a
|
|
366
|
+
// beta version) in OpenCode's plural `plugins` list. Treat all exact npm
|
|
367
|
+
// specs for this package as managed so an update removes the old cached
|
|
368
|
+
// copy before registering the current package path. Do not match similarly
|
|
369
|
+
// named third-party packages such as `pantheon-opencode-fork`.
|
|
370
|
+
const packageRef = normalized.match(PANTHEON_PACKAGE_REF)
|
|
371
|
+
if (packageRef !== null) {
|
|
372
|
+
if (packageRef[1] === 'plugin') return PANTHEON_V1_PLUGIN
|
|
373
|
+
return PANTHEON_V2_PLUGIN
|
|
374
|
+
}
|
|
375
|
+
|
|
200
376
|
if (PANTHEON_PLUGIN_IDENTITIES.has(normalized)) return normalized
|
|
201
377
|
if (normalized === PANTHEON_V2_LEGACY_FILE) return PANTHEON_V2_PLUGIN
|
|
202
378
|
if (normalized === PANTHEON_V1_EXPORT || normalized.startsWith(`${PANTHEON_V1_EXPORT}@`)) {
|
|
@@ -275,6 +451,71 @@ export function deduplicatePluginReferences(refs) {
|
|
|
275
451
|
})
|
|
276
452
|
}
|
|
277
453
|
|
|
454
|
+
/**
|
|
455
|
+
* Duplicate the V1 top-level `context_sandbox` block into the exact Pantheon V2
|
|
456
|
+
* plugin entry's `options`, converting a plain-string entry into the
|
|
457
|
+
* `{ package, options }` object form the host accepts.
|
|
458
|
+
*
|
|
459
|
+
* Why the copy is required: OpenCode V2.0.25 silently drops a top-level
|
|
460
|
+
* `context_sandbox` key (probe evidence in the module header of
|
|
461
|
+
* config-migration.mjs), so the V1 setting would be lost on a V1→V2 install.
|
|
462
|
+
* The V1 top-level block is LEFT IN PLACE so a downgrade keeps working.
|
|
463
|
+
*
|
|
464
|
+
* Precedence is delegated to {@link mergeContextSandboxBlocks}: an explicit V2
|
|
465
|
+
* option leaf wins, the legacy block fills only unset leaves, and the runtime
|
|
466
|
+
* resolver supplies defaults. Only the Pantheon V2 entry is touched — every
|
|
467
|
+
* other plugin entry, and every other option on this entry, is preserved.
|
|
468
|
+
*
|
|
469
|
+
* The managed V2 option is ALWAYS validated — even when no legacy V1 block is
|
|
470
|
+
* present — because the migration is the only place that validates it. An
|
|
471
|
+
* earlier version returned early on `legacySandbox === undefined`, so an
|
|
472
|
+
* explicit `plugins[].options.context_sandbox` of `null` (or any type-invalid
|
|
473
|
+
* known value) was never checked and persisted straight to disk.
|
|
474
|
+
*
|
|
475
|
+
* @param {unknown[]} plugins - V2 `plugins` array (mutated in place)
|
|
476
|
+
* @param {unknown} legacySandbox - V1 top-level `context_sandbox` (may be undefined)
|
|
477
|
+
* @returns {unknown[]} the same array
|
|
478
|
+
* @throws {Error} when the legacy block or the existing V2 option is null or
|
|
479
|
+
* type-invalid (checked before any config write)
|
|
480
|
+
*/
|
|
481
|
+
export function applyContextSandboxToV2Plugins(plugins, legacySandbox) {
|
|
482
|
+
if (!Array.isArray(plugins)) return plugins
|
|
483
|
+
|
|
484
|
+
const index = plugins.findIndex((ref) => pluginReferenceIdentity(ref) === PANTHEON_V2_PLUGIN)
|
|
485
|
+
if (index === -1) return plugins
|
|
486
|
+
|
|
487
|
+
const entry = plugins[index]
|
|
488
|
+
const isObjectEntry = typeof entry === 'object' && entry !== null && !Array.isArray(entry)
|
|
489
|
+
const hasOptions =
|
|
490
|
+
isObjectEntry &&
|
|
491
|
+
typeof entry.options === 'object' &&
|
|
492
|
+
entry.options !== null &&
|
|
493
|
+
!Array.isArray(entry.options)
|
|
494
|
+
const existingOptions = hasOptions ? entry.options : {}
|
|
495
|
+
const hasExistingSandbox = Object.hasOwn(existingOptions, 'context_sandbox')
|
|
496
|
+
|
|
497
|
+
// Nothing managed to migrate OR validate: leave the entry untouched so a
|
|
498
|
+
// re-install stays a true no-op. `legacySandbox === undefined` covers an
|
|
499
|
+
// absent V1 key; `!hasExistingSandbox` covers a V2 entry with no managed
|
|
500
|
+
// option yet. An explicit `null` is a PRESENT key (`hasExistingSandbox`), so
|
|
501
|
+
// it is deliberately NOT skipped and must be validated below.
|
|
502
|
+
if (legacySandbox === undefined && !hasExistingSandbox) return plugins
|
|
503
|
+
|
|
504
|
+
// Run the managed option through the validator unconditionally, merging the
|
|
505
|
+
// legacy block in only when it exists. A null/type-invalid existing option
|
|
506
|
+
// throws here, BEFORE the config is written.
|
|
507
|
+
const mergedSandbox = mergeContextSandboxBlocks(existingOptions.context_sandbox, legacySandbox)
|
|
508
|
+
const packageId = isObjectEntry ? entry.package : entry
|
|
509
|
+
|
|
510
|
+
// Rebuild the entry as an object so the options survive a V2 host (which
|
|
511
|
+
// preserves plugin option objects verbatim).
|
|
512
|
+
plugins[index] = {
|
|
513
|
+
package: packageId,
|
|
514
|
+
options: { ...existingOptions, context_sandbox: mergedSandbox },
|
|
515
|
+
}
|
|
516
|
+
return plugins
|
|
517
|
+
}
|
|
518
|
+
|
|
278
519
|
export { parseOpenCodeVersion, resolveOpenCodeVersion } from './opencode-version.mjs'
|
|
279
520
|
|
|
280
521
|
/**
|
|
@@ -301,15 +542,14 @@ export function isGlobalConfigDir(target) {
|
|
|
301
542
|
* package; the loader must NEVER be pointed at that in-package dir (a package
|
|
302
543
|
* reinstall/upgrade replaces the dir under a live registration and leaves
|
|
303
544
|
* diverging copies loading). Instead the installer always copies it into the
|
|
304
|
-
* config dir's plugins/ dir and
|
|
545
|
+
* config dir's plugins/ dir and uses THAT copy:
|
|
305
546
|
*
|
|
306
|
-
* <configDir>/plugins/pantheon-tui (dist/ + package
|
|
547
|
+
* <configDir>/plugins/pantheon-tui (tui.ts + dist/ + package manifests)
|
|
307
548
|
*
|
|
308
|
-
*
|
|
309
|
-
*
|
|
310
|
-
* exports["./server"] → dist/server.js (no-op server() stub)
|
|
549
|
+
* V1 registers that copy through tui.json. V2 discovers the local plugin's
|
|
550
|
+
* index.ts + tui.ts pair; the V2 shim re-exports dist/tui-v2.js.
|
|
311
551
|
*
|
|
312
|
-
* @param {string} configDir -
|
|
552
|
+
* @param {string} configDir - OpenCode config directory (target for global
|
|
313
553
|
* installs, <target>/.opencode for project installs)
|
|
314
554
|
* @returns {string} absolute path of the copied plugin directory
|
|
315
555
|
*/
|
|
@@ -335,23 +575,20 @@ export function tuiConfigLocations(target) {
|
|
|
335
575
|
}
|
|
336
576
|
|
|
337
577
|
/**
|
|
338
|
-
* Sync the Pantheon TUI plugin
|
|
339
|
-
*
|
|
578
|
+
* Sync the Pantheon TUI plugin payload and V1 registration. V2 discovers the
|
|
579
|
+
* copied local-plugin entrypoints and does not use a project tui.json.
|
|
340
580
|
*
|
|
341
581
|
* Contract (single source of truth):
|
|
342
|
-
* 1. ALWAYS copy src/plugins/tui → <configDir>/plugins/pantheon-tui
|
|
343
|
-
*
|
|
344
|
-
* directory that exists and carries the loader contract (exports →
|
|
345
|
-
* dist/tui.js, dist/server.js). The copy is idempotent: a second run is
|
|
346
|
-
* byte-identical.
|
|
582
|
+
* 1. ALWAYS copy src/plugins/tui → <configDir>/plugins/pantheon-tui with the
|
|
583
|
+
* selected host entrypoints and compiled bundle. The copy is idempotent.
|
|
347
584
|
* 2. Clean stale Pantheon TUI refs from ALL tui.json locations OpenCode
|
|
348
585
|
* loads — ~/.opencode, $XDG_CONFIG_HOME/opencode and
|
|
349
586
|
* <target>/.opencode — covering every old reference shape: dist file
|
|
350
587
|
* paths, in-package src/plugins/tui dirs, bare plugins/pantheon-tui
|
|
351
588
|
* copies, and npx paths. A stale ref left in the non-target location
|
|
352
589
|
* would still be loaded and break the TUI.
|
|
353
|
-
* 3.
|
|
354
|
-
*
|
|
590
|
+
* 3. V1 registers the copied directory in the selected target. V2 relies on
|
|
591
|
+
* local-plugin discovery and does not write a project tui.json entry.
|
|
355
592
|
*
|
|
356
593
|
* @param {string} target - install target directory
|
|
357
594
|
* @param {object} [opts]
|
|
@@ -359,18 +596,19 @@ export function tuiConfigLocations(target) {
|
|
|
359
596
|
* @param {boolean} [opts.dryRun=false]
|
|
360
597
|
* @returns {'created'|'skipped'} status for the target registration
|
|
361
598
|
*/
|
|
362
|
-
export function syncTuiRegistration(
|
|
599
|
+
export function syncTuiRegistration(
|
|
600
|
+
target,
|
|
601
|
+
{ isGlobal = false, dryRun = false, version = 'v1' } = {},
|
|
602
|
+
) {
|
|
363
603
|
const configDir = isGlobal ? target : join(target, '.opencode')
|
|
364
604
|
const targetTuiConfigPath = join(configDir, 'tui.json')
|
|
365
605
|
|
|
366
|
-
// 1.
|
|
367
|
-
// plugins/pantheon-tui.
|
|
368
|
-
// in-package src/plugins/tui) keeps installs hermetic and idempotent:
|
|
369
|
-
// the ref points at a dir that exists and has dist/tui.js.
|
|
606
|
+
// 1. Copy the host-specific plugin into the target config's
|
|
607
|
+
// plugins/pantheon-tui. This keeps the installed entry hermetic.
|
|
370
608
|
const tuiSrcDir = join(ROOT, 'src', 'plugins', 'tui')
|
|
371
609
|
const tuiCopyDir = resolveTuiCopyTarget(configDir)
|
|
372
610
|
if (existsSync(tuiSrcDir)) {
|
|
373
|
-
copyPluginFiles(tuiSrcDir, tuiCopyDir, { dryRun })
|
|
611
|
+
copyPluginFiles(tuiSrcDir, tuiCopyDir, { dryRun, version })
|
|
374
612
|
}
|
|
375
613
|
|
|
376
614
|
// 2. Clean stale Pantheon TUI refs from every location OpenCode reads.
|
|
@@ -386,6 +624,11 @@ export function syncTuiRegistration(target, { isGlobal = false, dryRun = false }
|
|
|
386
624
|
})
|
|
387
625
|
}
|
|
388
626
|
|
|
627
|
+
// OpenCode V2 discovers local plugins from the config plugins directories;
|
|
628
|
+
// its TUI plugin API is exposed by the sibling tui.ts entrypoint. V1 still
|
|
629
|
+
// requires the explicit tui.json registration.
|
|
630
|
+
if (version === 'v2') return 'skipped'
|
|
631
|
+
|
|
389
632
|
// 3. Register the copied directory in the selected target only.
|
|
390
633
|
return registerPlugin(targetTuiConfigPath, tuiCopyDir, { dryRun })
|
|
391
634
|
}
|
|
@@ -429,6 +672,92 @@ export function setupRuntimePhase(target, { dryRun, clean, isGlobal }, stats, de
|
|
|
429
672
|
}
|
|
430
673
|
}
|
|
431
674
|
|
|
675
|
+
/**
|
|
676
|
+
* Write the five managed MCP server entries into `config`, healing any stale
|
|
677
|
+
* entrypoint paths first.
|
|
678
|
+
*
|
|
679
|
+
* The heal runs on EVERY install, not only when an entry is missing: the
|
|
680
|
+
* per-server guards below are add-only, so an existing install whose
|
|
681
|
+
* `opencode.json` still carries a pre-rename `*_server.py` path would never be
|
|
682
|
+
* repaired. Healing first means a re-run of the installer fixes an orphaned
|
|
683
|
+
* config in place — the migration that makes the rename non-breaking.
|
|
684
|
+
*
|
|
685
|
+
* @param {object} config - full config object (mutated in place)
|
|
686
|
+
* @param {{ runtimeTarget: string, venvPython: string }} ctx
|
|
687
|
+
* @returns {{ healed: number }} number of stale path strings rewritten
|
|
688
|
+
*/
|
|
689
|
+
export function applyMcpServerEntries(config, { runtimeTarget, venvPython }) {
|
|
690
|
+
config.mcp = config.mcp || {}
|
|
691
|
+
// MCP stays a flat named-server map for BOTH V1 and V2: OpenCode 1.18.x has
|
|
692
|
+
// no `mcp.servers` wrapper and rejects one with "Missing key
|
|
693
|
+
// mcp.servers.enabled". config-migration.mjs normalizes any legacy
|
|
694
|
+
// wrapper-shaped entries back to the flat map.
|
|
695
|
+
const healed = migrateMcpEntrypointPaths(config.mcp)
|
|
696
|
+
|
|
697
|
+
// Only add if not already configured by user
|
|
698
|
+
if (!config.mcp['pantheon-resources']) {
|
|
699
|
+
config.mcp['pantheon-resources'] = {
|
|
700
|
+
type: 'local',
|
|
701
|
+
cwd: runtimeTarget,
|
|
702
|
+
command: [venvPython, 'scripts/mcp_resources.py'],
|
|
703
|
+
enabled: true,
|
|
704
|
+
}
|
|
705
|
+
}
|
|
706
|
+
if (!config.mcp['pantheon-code-mode']) {
|
|
707
|
+
config.mcp['pantheon-code-mode'] = {
|
|
708
|
+
type: 'local',
|
|
709
|
+
cwd: runtimeTarget,
|
|
710
|
+
command: [venvPython, 'scripts/code_mode.py'],
|
|
711
|
+
enabled: true,
|
|
712
|
+
}
|
|
713
|
+
}
|
|
714
|
+
if (!config.mcp['pantheon-memory']) {
|
|
715
|
+
config.mcp['pantheon-memory'] = {
|
|
716
|
+
type: 'local',
|
|
717
|
+
cwd: runtimeTarget,
|
|
718
|
+
command: [venvPython, 'scripts/memory_mcp.py'],
|
|
719
|
+
enabled: true,
|
|
720
|
+
}
|
|
721
|
+
}
|
|
722
|
+
if (!config.mcp['pantheon-persistence']) {
|
|
723
|
+
config.mcp['pantheon-persistence'] = {
|
|
724
|
+
type: 'local',
|
|
725
|
+
cwd: runtimeTarget,
|
|
726
|
+
command: [venvPython, 'scripts/mcp_persistence.py'],
|
|
727
|
+
enabled: true,
|
|
728
|
+
}
|
|
729
|
+
}
|
|
730
|
+
if (!config.mcp['pantheon-vision']) {
|
|
731
|
+
config.mcp['pantheon-vision'] = {
|
|
732
|
+
type: 'local',
|
|
733
|
+
cwd: runtimeTarget,
|
|
734
|
+
command: [venvPython, 'scripts/pantheon_vision.py'],
|
|
735
|
+
enabled: true,
|
|
736
|
+
}
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
// Default MCP permissions
|
|
740
|
+
config.permission = config.permission || {}
|
|
741
|
+
config.permission.mcp = config.permission.mcp || {}
|
|
742
|
+
if (!config.permission.mcp['pantheon-resources']) {
|
|
743
|
+
config.permission.mcp['pantheon-resources'] = 'allow'
|
|
744
|
+
}
|
|
745
|
+
if (!config.permission.mcp['pantheon-code-mode']) {
|
|
746
|
+
config.permission.mcp['pantheon-code-mode'] = 'ask'
|
|
747
|
+
}
|
|
748
|
+
if (!config.permission.mcp['pantheon-memory']) {
|
|
749
|
+
config.permission.mcp['pantheon-memory'] = 'allow'
|
|
750
|
+
}
|
|
751
|
+
if (!config.permission.mcp['pantheon-persistence']) {
|
|
752
|
+
config.permission.mcp['pantheon-persistence'] = 'allow'
|
|
753
|
+
}
|
|
754
|
+
if (!config.permission.mcp['pantheon-vision']) {
|
|
755
|
+
config.permission.mcp['pantheon-vision'] = 'ask'
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
return { healed }
|
|
759
|
+
}
|
|
760
|
+
|
|
432
761
|
export async function installOpenCode(
|
|
433
762
|
target,
|
|
434
763
|
dryRun = false,
|
|
@@ -450,12 +779,31 @@ export async function installOpenCode(
|
|
|
450
779
|
// --opencode-version=v2. V2 normalizes V1 fields in memory (no rewrite) and
|
|
451
780
|
// isolates its state DB via OPENCODE_DB (~/.local/share/opencode/opencode-v2.db)
|
|
452
781
|
// with a distinct service port (49375 vs V1's 49374).
|
|
453
|
-
|
|
782
|
+
// Default to 'auto' so a flagless run resolves against the actual host. A
|
|
783
|
+
// hardcoded 'v1' here (or in bin/pantheon-init.mjs) is what shipped the bug:
|
|
784
|
+
// the V1 branch writes .ts file paths that a 2.x host drops from the config.
|
|
785
|
+
// `opts` is forwarded as the resolver's options bag so a caller (and CI) can
|
|
786
|
+
// pin env/probe instead of spawning a real host binary.
|
|
787
|
+
const version = resolveOpenCodeVersion(opts.version ?? 'auto', opts)
|
|
454
788
|
if (version === 'v2') {
|
|
455
789
|
info(
|
|
456
790
|
'OpenCode V2 target — shared config; state isolated via OPENCODE_DB ' +
|
|
457
791
|
'(~/.local/share/opencode/opencode-v2.db), service port 49375',
|
|
458
792
|
)
|
|
793
|
+
// Say what the V2 contract does NOT give you, at the moment the user picks
|
|
794
|
+
// it. The V2 surface is 3 tools (hashline_edit, pantheon_cost,
|
|
795
|
+
// pantheon_model); V1's is 6 plus the BackgroundJobBoard, the three goal
|
|
796
|
+
// tools and the caller/target delegation matrix. This list was previously
|
|
797
|
+
// reachable only through `getUnsupportedFeatures()`, which no production
|
|
798
|
+
// code called — a user migrating hit the reduction with no warning at all.
|
|
799
|
+
//
|
|
800
|
+
// The markers are read from the same seed `getUnsupportedFeatures()` is
|
|
801
|
+
// built from (src/pantheon/v2-unsupported.mjs), so this notice cannot drift
|
|
802
|
+
// from what the plugin reports. Advisory: a V2 install is a supported
|
|
803
|
+
// configuration, not a failure.
|
|
804
|
+
info(
|
|
805
|
+
`V2 plugin generation — reduced surface. Unsupported features: ${listV2UnsupportedFeatures().join(', ')}`,
|
|
806
|
+
)
|
|
459
807
|
}
|
|
460
808
|
|
|
461
809
|
// Interactive mode handling
|
|
@@ -487,10 +835,24 @@ export async function installOpenCode(
|
|
|
487
835
|
}
|
|
488
836
|
}
|
|
489
837
|
|
|
490
|
-
|
|
838
|
+
// Product defaults live in code (PRODUCT_CONFIG_DEFAULTS). A repository-root
|
|
839
|
+
// opencode.json is a contributor's PERSONAL dev harness, not product data, so
|
|
840
|
+
// it must never be read just because it happens to exist: doing so silently
|
|
841
|
+
// injects the developer's provider/plugins/compaction into every config an
|
|
842
|
+
// install generates. The merge is therefore opt-in only, via
|
|
843
|
+
// --merge-dev-config <path>. Absent the flag, no root-level config is read.
|
|
844
|
+
const mergeDevConfigOpt = opts.mergeDevConfig ?? null
|
|
845
|
+
if (mergeDevConfigOpt !== null && typeof mergeDevConfigOpt !== 'string') {
|
|
846
|
+
throw new Error('mergeDevConfig requires a file path string')
|
|
847
|
+
}
|
|
848
|
+
const pantheonConfigPath =
|
|
849
|
+
typeof mergeDevConfigOpt === 'string' ? resolve(mergeDevConfigOpt) : null
|
|
491
850
|
const targetConfigPath = join(target, 'opencode.json')
|
|
492
|
-
const config =
|
|
493
|
-
const pantheonConfig =
|
|
851
|
+
const { config, raw: configRaw } = readJsonConfigWithRaw(targetConfigPath)
|
|
852
|
+
const pantheonConfig =
|
|
853
|
+
pantheonConfigPath !== null && existsSync(pantheonConfigPath)
|
|
854
|
+
? readJsonConfig(pantheonConfigPath)
|
|
855
|
+
: null
|
|
494
856
|
|
|
495
857
|
// beta.5 preflight: fail BEFORE any files are written when the runtime
|
|
496
858
|
// component needs toolchain pieces that are missing. Nothing has been
|
|
@@ -503,14 +865,13 @@ export async function installOpenCode(
|
|
|
503
865
|
}
|
|
504
866
|
}
|
|
505
867
|
|
|
506
|
-
//
|
|
507
|
-
//
|
|
508
|
-
//
|
|
509
|
-
//
|
|
510
|
-
// .opencode/agents/ .opencode/skills/ .opencode/commands/
|
|
868
|
+
// V1 project installs use the legacy .opencode/agents layout. OpenCode V2's
|
|
869
|
+
// active config directory is the project root and its agent loader scans
|
|
870
|
+
// <config-dir>/{agent,agents}/**, so V2 project agents must be at ./agents.
|
|
871
|
+
// Global installs are flat for both versions.
|
|
511
872
|
const isGlobal = isGlobalConfigDir(target)
|
|
512
873
|
const _subDir = isGlobal ? '' : '.opencode'
|
|
513
|
-
const agentPrefix = isGlobal ? 'agents' : '.opencode/agents'
|
|
874
|
+
const agentPrefix = isGlobal || version === 'v2' ? 'agents' : '.opencode/agents'
|
|
514
875
|
|
|
515
876
|
configure({ dryRun })
|
|
516
877
|
|
|
@@ -529,7 +890,7 @@ export async function installOpenCode(
|
|
|
529
890
|
stats.errors++
|
|
530
891
|
} else {
|
|
531
892
|
const agentStep = step(S.installingAgents)
|
|
532
|
-
const dstDir =
|
|
893
|
+
const dstDir = join(target, agentPrefix)
|
|
533
894
|
if (!dryRun) mkdirSync(dstDir, { recursive: true })
|
|
534
895
|
if (clean && existsSync(dstDir) && !dryRun) {
|
|
535
896
|
const existing = readdirSync(dstDir)
|
|
@@ -537,7 +898,7 @@ export async function installOpenCode(
|
|
|
537
898
|
rmSync(join(dstDir, f), { recursive: true, force: true })
|
|
538
899
|
}
|
|
539
900
|
}
|
|
540
|
-
const { created, skipped } =
|
|
901
|
+
const { created, skipped } = copyAgentPrompts(srcDir, dstDir, dryRun)
|
|
541
902
|
stats.created += created
|
|
542
903
|
stats.skipped += skipped
|
|
543
904
|
agentStep(true)
|
|
@@ -586,23 +947,45 @@ export async function installOpenCode(
|
|
|
586
947
|
}
|
|
587
948
|
|
|
588
949
|
// -----------------------------------------------------------------------
|
|
589
|
-
// 2.5 Install instructions: AGENTS.md
|
|
950
|
+
// 2.5 Install shared instructions: AGENTS.md (--components instructions)
|
|
590
951
|
// -----------------------------------------------------------------------
|
|
591
|
-
// AGENTS.md
|
|
592
|
-
//
|
|
593
|
-
//
|
|
594
|
-
//
|
|
595
|
-
//
|
|
952
|
+
// Both AGENTS.md artifacts are managed regions (scripts/build-agents-md.mjs).
|
|
953
|
+
// A GLOBAL config dir receives the repo-agnostic global artifact (agent
|
|
954
|
+
// table + shared rules); a PROJECT layout keeps the repository artifact.
|
|
955
|
+
// writeAgentsMdManaged is conservative: content outside the managed region
|
|
956
|
+
// is never deleted, and a legacy fully-generated file upgrades in place.
|
|
596
957
|
if (componentSet.has('instructions')) {
|
|
597
958
|
section('\uD83D\uDCCB Instructions')
|
|
598
|
-
|
|
599
|
-
const srcAgentsMd = join(ROOT, 'AGENTS.md')
|
|
959
|
+
const srcAgentsMd = isGlobal ? join(ROOT, 'AGENTS.global.md') : join(ROOT, 'AGENTS.md')
|
|
600
960
|
const dstAgentsMd = join(target, 'AGENTS.md')
|
|
601
961
|
if (existsSync(srcAgentsMd)) {
|
|
602
962
|
const content = readFileSync(srcAgentsMd, 'utf8')
|
|
603
|
-
const status =
|
|
604
|
-
if (status === '
|
|
605
|
-
else
|
|
963
|
+
const status = writeAgentsMdManaged(dstAgentsMd, content, dryRun)
|
|
964
|
+
if (status === 'skipped') stats.skipped++
|
|
965
|
+
else {
|
|
966
|
+
stats.created++
|
|
967
|
+
if (status === 'merged') {
|
|
968
|
+
warning(
|
|
969
|
+
`AGENTS.md had non-generated content — it was preserved around the ` +
|
|
970
|
+
`Pantheon managed region in ${dstAgentsMd}. Review and merge manually.`,
|
|
971
|
+
)
|
|
972
|
+
}
|
|
973
|
+
}
|
|
974
|
+
} else {
|
|
975
|
+
warning(`AGENTS.md artifact not found in the package: ${srcAgentsMd}`)
|
|
976
|
+
stats.errors++
|
|
977
|
+
}
|
|
978
|
+
// An instruction-only upgrade must also refresh selective guidance on
|
|
979
|
+
// already-installed Pantheon agents, but must not create agents the user
|
|
980
|
+
// did not install. Full agent installs do this in phase 1 above.
|
|
981
|
+
if (!componentSet.has('agents')) {
|
|
982
|
+
const installedAgents = join(target, agentPrefix)
|
|
983
|
+
const sourceAgents = join(ROOT, 'src', 'agents')
|
|
984
|
+
if (existsSync(installedAgents) && existsSync(sourceAgents)) {
|
|
985
|
+
const refreshed = copyAgentPrompts(sourceAgents, installedAgents, dryRun, true)
|
|
986
|
+
stats.created += refreshed.created
|
|
987
|
+
stats.skipped += refreshed.skipped
|
|
988
|
+
}
|
|
606
989
|
}
|
|
607
990
|
}
|
|
608
991
|
|
|
@@ -626,7 +1009,11 @@ export async function installOpenCode(
|
|
|
626
1009
|
if (componentSet.has('commands')) {
|
|
627
1010
|
section('\u26A1 Commands')
|
|
628
1011
|
const srcCmds = join(ROOT, 'commands')
|
|
629
|
-
|
|
1012
|
+
// V2 project sandboxes use OPENCODE_CONFIG_DIR=<project root>, so OpenCode
|
|
1013
|
+
// discovers custom commands from <target>/commands. V1 project installs
|
|
1014
|
+
// keep the standard .opencode/commands layout; global installs are flat.
|
|
1015
|
+
const commandDir = isGlobal || version === 'v2' ? 'commands' : '.opencode/commands'
|
|
1016
|
+
const dstCmds = join(target, commandDir)
|
|
630
1017
|
if (existsSync(srcCmds)) {
|
|
631
1018
|
const cmdResult = syncDir(srcCmds, dstCmds, dryRun, clean, (f) => f.endsWith('.md'))
|
|
632
1019
|
stats.created += cmdResult.created
|
|
@@ -651,7 +1038,7 @@ export async function installOpenCode(
|
|
|
651
1038
|
const dstPluginDir = isGlobal
|
|
652
1039
|
? join(target, 'plugins', 'pantheon-tui')
|
|
653
1040
|
: join(target, '.opencode', 'plugins', 'pantheon-tui')
|
|
654
|
-
const pluginStats = installPlugin(srcPluginDir, dstPluginDir, { dryRun, clean })
|
|
1041
|
+
const pluginStats = installPlugin(srcPluginDir, dstPluginDir, { dryRun, clean, version })
|
|
655
1042
|
stats.created += pluginStats.created
|
|
656
1043
|
stats.skipped += pluginStats.skipped
|
|
657
1044
|
stats.errors += pluginStats.errors
|
|
@@ -663,7 +1050,7 @@ export async function installOpenCode(
|
|
|
663
1050
|
if (componentSet.has('plugins')) {
|
|
664
1051
|
let tuiStatus
|
|
665
1052
|
try {
|
|
666
|
-
tuiStatus = syncTuiRegistration(target, { isGlobal, dryRun })
|
|
1053
|
+
tuiStatus = syncTuiRegistration(target, { isGlobal, dryRun, version })
|
|
667
1054
|
} catch (cause) {
|
|
668
1055
|
error(`TUI plugin setup failed: ${cause.message}`)
|
|
669
1056
|
stats.errors++
|
|
@@ -683,30 +1070,31 @@ export async function installOpenCode(
|
|
|
683
1070
|
|
|
684
1071
|
// ── MCP server scripts ──
|
|
685
1072
|
const mcpScripts = [
|
|
686
|
-
'
|
|
687
|
-
'
|
|
688
|
-
'
|
|
1073
|
+
'mcp_resources.py',
|
|
1074
|
+
'code_mode.py',
|
|
1075
|
+
'memory_mcp.py',
|
|
689
1076
|
'scrub-secrets.py',
|
|
690
1077
|
'_pantheon_paths.py',
|
|
691
1078
|
'mcp_codemap_module.py',
|
|
692
|
-
'
|
|
693
|
-
'
|
|
1079
|
+
'mcp_persistence.py',
|
|
1080
|
+
'pantheon_vision.py',
|
|
694
1081
|
'eval_store.py',
|
|
1082
|
+
'themis_heuristic_scan.py',
|
|
695
1083
|
]
|
|
696
1084
|
const srcScriptsDir = join(ROOT, 'scripts')
|
|
697
1085
|
// Canonical MCP server sources live in src/mcp/. Map them explicitly so the
|
|
698
1086
|
// sync always copies from the canonical source, never from the stale root
|
|
699
1087
|
// scripts/ copies (which previously propagated bugs like the missing
|
|
700
|
-
// `import uuid` in
|
|
1088
|
+
// `import uuid` in mcp_persistence.py). scrub-secrets.py is not in
|
|
701
1089
|
// src/mcp/ and intentionally falls back to join(ROOT, 'scripts').
|
|
702
1090
|
const canonicalMcpScripts = {
|
|
703
|
-
'
|
|
704
|
-
'
|
|
705
|
-
'
|
|
1091
|
+
'mcp_resources.py': join(ROOT, 'src', 'mcp', 'mcp_resources.py'),
|
|
1092
|
+
'code_mode.py': join(ROOT, 'src', 'mcp', 'code_mode.py'),
|
|
1093
|
+
'memory_mcp.py': join(ROOT, 'src', 'mcp', 'memory_mcp.py'),
|
|
706
1094
|
'_pantheon_paths.py': join(ROOT, 'src', 'mcp', '_pantheon_paths.py'),
|
|
707
1095
|
'mcp_codemap_module.py': join(ROOT, 'src', 'mcp', 'mcp_codemap_module.py'),
|
|
708
|
-
'
|
|
709
|
-
'
|
|
1096
|
+
'mcp_persistence.py': join(ROOT, 'src', 'mcp', 'mcp_persistence.py'),
|
|
1097
|
+
'pantheon_vision.py': join(ROOT, 'src', 'mcp', 'pantheon_vision.py'),
|
|
710
1098
|
'eval_store.py': join(ROOT, 'src', 'mcp', 'eval_store.py'),
|
|
711
1099
|
}
|
|
712
1100
|
const dstScriptsDir = join(runtimeTarget, 'scripts')
|
|
@@ -798,7 +1186,7 @@ export async function installOpenCode(
|
|
|
798
1186
|
// on top. Preserves user's MCP, provider, plugins, compaction, theme.
|
|
799
1187
|
// -----------------------------------------------------------------------
|
|
800
1188
|
// --------------------------------------------------------------------
|
|
801
|
-
// A. Parse canonical agent config from agents/*.
|
|
1189
|
+
// A. Parse canonical agent config from src/agents/*.md frontmatter
|
|
802
1190
|
// and merge into opencode.json config.
|
|
803
1191
|
// --------------------------------------------------------------------
|
|
804
1192
|
function getAgentSources(agentPrefix) {
|
|
@@ -836,7 +1224,6 @@ export async function installOpenCode(
|
|
|
836
1224
|
if (fm.mode) agent.mode = fm.mode
|
|
837
1225
|
if (fm.hidden) agent.hidden = fm.hidden
|
|
838
1226
|
if (fm.temperature !== undefined) agent.temperature = fm.temperature
|
|
839
|
-
if (fm.steps !== undefined) agent.steps = fm.steps
|
|
840
1227
|
// Support both hyphen (YAML) and underscore (JSON) key variants
|
|
841
1228
|
if (fm['disable-model-invocation'] !== undefined)
|
|
842
1229
|
agent.disable_model_invocation = fm['disable-model-invocation']
|
|
@@ -847,6 +1234,12 @@ export async function installOpenCode(
|
|
|
847
1234
|
if (fm.permission) {
|
|
848
1235
|
agent.permission = JSON.parse(JSON.stringify(fm.permission))
|
|
849
1236
|
}
|
|
1237
|
+
if (fm.mcp_tools !== undefined) {
|
|
1238
|
+
agent.permission = {
|
|
1239
|
+
...(agent.permission ?? {}),
|
|
1240
|
+
...deriveNativeMcpPermissions(fm.mcp_tools),
|
|
1241
|
+
}
|
|
1242
|
+
}
|
|
850
1243
|
|
|
851
1244
|
config[name] = agent
|
|
852
1245
|
}
|
|
@@ -855,15 +1248,6 @@ export async function installOpenCode(
|
|
|
855
1248
|
|
|
856
1249
|
const canonicalAgentConfig = readAgentConfigFromCanonical()
|
|
857
1250
|
const agentSources = getAgentSources(agentPrefix)
|
|
858
|
-
const MANAGED_FIELDS = [
|
|
859
|
-
'steps',
|
|
860
|
-
'temperature',
|
|
861
|
-
'color',
|
|
862
|
-
'permission',
|
|
863
|
-
'mode',
|
|
864
|
-
'hidden',
|
|
865
|
-
'disable_model_invocation',
|
|
866
|
-
]
|
|
867
1251
|
|
|
868
1252
|
if (config.agent === undefined) config.agent = {}
|
|
869
1253
|
|
|
@@ -922,8 +1306,11 @@ export async function installOpenCode(
|
|
|
922
1306
|
// --------------------------------------------------------------------
|
|
923
1307
|
// B.5 Ensure critical top-level OpenCode config sections
|
|
924
1308
|
// --------------------------------------------------------------------
|
|
925
|
-
|
|
926
|
-
|
|
1309
|
+
// Seeded from code, not from a packaged template: see
|
|
1310
|
+
// PRODUCT_CONFIG_DEFAULTS. Only fills a gap — a user who already chose a
|
|
1311
|
+
// default agent keeps it.
|
|
1312
|
+
if (config.default_agent === undefined) {
|
|
1313
|
+
config.default_agent = PRODUCT_CONFIG_DEFAULTS.default_agent
|
|
927
1314
|
}
|
|
928
1315
|
// Apply only explicit top-level model overrides. Existing values remain
|
|
929
1316
|
// untouched and an install without either flag leaves both fields absent.
|
|
@@ -965,8 +1352,9 @@ export async function installOpenCode(
|
|
|
965
1352
|
// --------------------------------------------------------------------
|
|
966
1353
|
// B.5.1 Hermetic plugin resolution (packaging fix)
|
|
967
1354
|
// --------------------------------------------------------------------
|
|
968
|
-
//
|
|
969
|
-
// absolute paths (e.g.
|
|
1355
|
+
// A developer running from a checkout has a repository-root opencode.json
|
|
1356
|
+
// that may reference plugins via developer-machine absolute paths (e.g.
|
|
1357
|
+
// <dev>/pantheon/.../src/plugin.ts or
|
|
970
1358
|
// <dev>/pantheon/.../src/plugins/pantheon-hooks.ts). Copying those
|
|
971
1359
|
// verbatim into the user's config would break every global install on any
|
|
972
1360
|
// other machine. resolveInstalledPlugin() rewrites each entry to the plugin
|
|
@@ -985,7 +1373,7 @@ export async function installOpenCode(
|
|
|
985
1373
|
if (config.plugin === undefined) config.plugin = []
|
|
986
1374
|
if (Array.isArray(config.plugin)) {
|
|
987
1375
|
config.plugin = removePantheonPluginReferences(config.plugin)
|
|
988
|
-
if (Array.isArray(pantheonConfig
|
|
1376
|
+
if (Array.isArray(pantheonConfig?.plugin)) {
|
|
989
1377
|
for (const plugin of pantheonConfig.plugin) {
|
|
990
1378
|
if (!isPantheonPluginReference(plugin) && !hasPluginReference(config.plugin, plugin)) {
|
|
991
1379
|
config.plugin.push(deepClone(plugin))
|
|
@@ -1014,8 +1402,20 @@ export async function installOpenCode(
|
|
|
1014
1402
|
config.plugin = removePantheonPluginReferences(config.plugin)
|
|
1015
1403
|
}
|
|
1016
1404
|
if (!Array.isArray(config.plugins)) config.plugins = []
|
|
1405
|
+
// Preserve any options already set on the Pantheon V2 entry across the
|
|
1406
|
+
// remove/re-add below. The context_sandbox migration reads them, and a
|
|
1407
|
+
// silent drop would lose a hand-set sandbox limit or any other plugin
|
|
1408
|
+
// option the user chose. Only the object form can carry options.
|
|
1409
|
+
const existingV2Options =
|
|
1410
|
+
config.plugins
|
|
1411
|
+
.filter((ref) => pluginReferenceIdentity(ref) === PANTHEON_V2_PLUGIN)
|
|
1412
|
+
.map((ref) =>
|
|
1413
|
+
typeof ref === 'object' && ref !== null && !Array.isArray(ref) ? ref.options : null,
|
|
1414
|
+
)
|
|
1415
|
+
.find((options) => options && typeof options === 'object' && !Array.isArray(options)) ??
|
|
1416
|
+
null
|
|
1017
1417
|
config.plugins = deduplicatePluginReferences(removePantheonPluginReferences(config.plugins))
|
|
1018
|
-
if (Array.isArray(pantheonConfig
|
|
1418
|
+
if (Array.isArray(pantheonConfig?.plugins)) {
|
|
1019
1419
|
for (const plugin of pantheonConfig.plugins) {
|
|
1020
1420
|
if (!isPantheonPluginReference(plugin) && !hasPluginReference(config.plugins, plugin)) {
|
|
1021
1421
|
config.plugins.push(deepClone(plugin))
|
|
@@ -1038,36 +1438,75 @@ export async function installOpenCode(
|
|
|
1038
1438
|
config.plugins = config.plugins.filter(
|
|
1039
1439
|
(plugin) => pluginReferenceIdentity(plugin) !== pluginReferenceIdentity(resolvedV2),
|
|
1040
1440
|
)
|
|
1041
|
-
config.plugins.push(
|
|
1441
|
+
config.plugins.push(
|
|
1442
|
+
existingV2Options === null
|
|
1443
|
+
? resolvedV2
|
|
1444
|
+
: { package: resolvedV2, options: deepClone(existingV2Options) },
|
|
1445
|
+
)
|
|
1042
1446
|
}
|
|
1043
1447
|
|
|
1044
|
-
if (
|
|
1045
|
-
config.provider
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1448
|
+
if (pantheonConfig !== null) {
|
|
1449
|
+
if (config.provider === undefined && pantheonConfig.provider !== undefined) {
|
|
1450
|
+
config.provider = deepClone(pantheonConfig.provider)
|
|
1451
|
+
} else if (config.provider !== undefined && pantheonConfig.provider !== undefined) {
|
|
1452
|
+
mergeMissing(config.provider, pantheonConfig.provider)
|
|
1453
|
+
}
|
|
1049
1454
|
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1455
|
+
if (config.compaction === undefined && pantheonConfig.compaction !== undefined) {
|
|
1456
|
+
config.compaction = deepClone(pantheonConfig.compaction)
|
|
1457
|
+
} else if (config.compaction !== undefined && pantheonConfig.compaction !== undefined) {
|
|
1458
|
+
mergeMissing(config.compaction, pantheonConfig.compaction)
|
|
1459
|
+
}
|
|
1054
1460
|
}
|
|
1055
1461
|
|
|
1056
1462
|
// --------------------------------------------------------------------
|
|
1057
1463
|
// C. Merge permissions
|
|
1058
1464
|
// --------------------------------------------------------------------
|
|
1059
1465
|
if (config.permission === undefined) config.permission = {}
|
|
1060
|
-
if (pantheonConfig.permission !== undefined) {
|
|
1061
|
-
mergeMissing(config.permission, pantheonConfig.permission)
|
|
1062
|
-
}
|
|
1063
1466
|
if (
|
|
1064
1467
|
typeof config.permission === 'object' &&
|
|
1065
1468
|
config.permission !== null &&
|
|
1066
1469
|
!Array.isArray(config.permission)
|
|
1067
1470
|
) {
|
|
1068
|
-
if
|
|
1471
|
+
// Fill-if-absent, never overwrite. Pantheon's skills cannot be invoked
|
|
1472
|
+
// without this grant, so an install that just installed them seeds it —
|
|
1473
|
+
// but a user who deliberately set `permission.skill` (to `deny`, or to a
|
|
1474
|
+
// partial map of their own) keeps their choice. This was an unconditional
|
|
1475
|
+
// assignment nine lines below a comment that said "never overwrite", which
|
|
1476
|
+
// silently flipped an explicit deny to allow.
|
|
1477
|
+
//
|
|
1478
|
+
// Gated on the skills component AND absent-only, which is the whole point:
|
|
1479
|
+
// `skill` is a capability grant for something this install just wrote to
|
|
1480
|
+
// disk. A runtime-only or agents-only install has nothing to grant, so it
|
|
1481
|
+
// must not seed the key. It used to anyway, via the unconditional
|
|
1482
|
+
// PRODUCT_CONFIG_DEFAULTS.permission merge that sat above this block —
|
|
1483
|
+
// which also made this gate dead code.
|
|
1484
|
+
if (componentSet.has('skills') && config.permission.skill === undefined) {
|
|
1069
1485
|
config.permission.skill = { '*': 'allow' }
|
|
1070
1486
|
}
|
|
1487
|
+
// These five are the commands Pantheon's OWN agents must be able to run
|
|
1488
|
+
// unattended to do their job: version control, package execution, and the
|
|
1489
|
+
// test/lint pair every implementation agent reaches for. That is the whole
|
|
1490
|
+
// bar for a grant made on every install.
|
|
1491
|
+
//
|
|
1492
|
+
// Anything beyond it is the installing user's judgement about which
|
|
1493
|
+
// commands to trust on their machine, to be made in THEIR config — not ours
|
|
1494
|
+
// to grant silently on their behalf. The personal dev allowlist this grew
|
|
1495
|
+
// out of was the same category of decision, one layer further out of view.
|
|
1496
|
+
//
|
|
1497
|
+
// Specifically NOT granted, and why:
|
|
1498
|
+
// - `curl` — arbitrary network egress; the widest grant on the list and
|
|
1499
|
+
// not required to install or run anything here.
|
|
1500
|
+
// - `docker` — host/daemon access; a container runtime is not an
|
|
1501
|
+
// installation requirement.
|
|
1502
|
+
// - `gh` — third-party account access on someone's behalf; a GitHub CLI
|
|
1503
|
+
// login is never something an installer may assume.
|
|
1504
|
+
// - `pip`, `black`, `make` — ordinary toolchain commands with no Pantheon
|
|
1505
|
+
// agent dependency; users who want them can add them.
|
|
1506
|
+
//
|
|
1507
|
+
// NOTE: this grant is NOT gated on the skills component — unlike
|
|
1508
|
+
// permission.skill above, agents are installed by the agents component and
|
|
1509
|
+
// the test/lint commands are needed whichever components were selected.
|
|
1071
1510
|
if (config.permission.bash === undefined) {
|
|
1072
1511
|
config.permission.bash = {
|
|
1073
1512
|
'git *': 'allow',
|
|
@@ -1075,12 +1514,6 @@ export async function installOpenCode(
|
|
|
1075
1514
|
'npx *': 'allow',
|
|
1076
1515
|
'pytest *': 'allow',
|
|
1077
1516
|
'ruff *': 'allow',
|
|
1078
|
-
'black *': 'allow',
|
|
1079
|
-
'pip *': 'allow',
|
|
1080
|
-
'docker *': 'allow',
|
|
1081
|
-
'curl *': 'allow',
|
|
1082
|
-
'gh *': 'allow',
|
|
1083
|
-
'make *': 'allow',
|
|
1084
1517
|
}
|
|
1085
1518
|
}
|
|
1086
1519
|
}
|
|
@@ -1090,8 +1523,8 @@ export async function installOpenCode(
|
|
|
1090
1523
|
// --------------------------------------------------------------------
|
|
1091
1524
|
// AGENTS.md is the single instruction file both V1 and V2 load (V1
|
|
1092
1525
|
// auto-discovers it, V2 loads it explicitly). The generated AGENTS.md
|
|
1093
|
-
//
|
|
1094
|
-
//
|
|
1526
|
+
// contains shared rules; agent-scoped rules are in the installed agent
|
|
1527
|
+
// prompts. The `instructions/*.md`
|
|
1095
1528
|
// glob is no longer needed — keeping it would duplicate content under V1
|
|
1096
1529
|
// and is ignored under V2 anyway. Only ensure AGENTS.md is present.
|
|
1097
1530
|
const pantheonInstructions = ['AGENTS.md']
|
|
@@ -1137,82 +1570,18 @@ export async function installOpenCode(
|
|
|
1137
1570
|
// at a venv that does not exist would make every MCP fail to launch.
|
|
1138
1571
|
// --------------------------------------------------------------------
|
|
1139
1572
|
if (componentSet.has('runtime') && runtimeHealthy) {
|
|
1140
|
-
|
|
1141
|
-
//
|
|
1142
|
-
//
|
|
1143
|
-
//
|
|
1144
|
-
// legacy wrapper-shaped entries back to the flat map.
|
|
1145
|
-
// P1-3: derive the MCP python from the SAME venv layout setupVenv
|
|
1146
|
-
// creates (<target>/.venv via venvPythonPath). For project installs the
|
|
1147
|
-
// runtime payload lives under <target>/.opencode (runtimeTarget), but the
|
|
1148
|
-
// venv is created at <target>/.venv — pointing MCP commands at
|
|
1573
|
+
// P1-3: derive the MCP python from the SAME venv layout setupVenv creates
|
|
1574
|
+
// (<target>/.venv via venvPythonPath). For project installs the runtime
|
|
1575
|
+
// payload lives under <target>/.opencode (runtimeTarget), but the venv is
|
|
1576
|
+
// created at <target>/.venv — pointing MCP commands at
|
|
1149
1577
|
// runtimeTarget/.venv would reference an executable that never exists.
|
|
1150
|
-
const runtimeTarget = isGlobal ? target : join(target, '.opencode')
|
|
1151
|
-
const venvPython = venvPythonPath(target)
|
|
1152
1578
|
// Point the config at the venv even on a first install; setupVenv runs
|
|
1153
1579
|
// later in this function and creates this path before OpenCode starts.
|
|
1154
|
-
const
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
type: 'local',
|
|
1160
|
-
cwd: runtimeTarget,
|
|
1161
|
-
command: [memoryPython, 'scripts/mcp_resources_server.py'],
|
|
1162
|
-
enabled: true,
|
|
1163
|
-
}
|
|
1164
|
-
}
|
|
1165
|
-
if (!config.mcp['pantheon-code-mode']) {
|
|
1166
|
-
config.mcp['pantheon-code-mode'] = {
|
|
1167
|
-
type: 'local',
|
|
1168
|
-
cwd: runtimeTarget,
|
|
1169
|
-
command: [memoryPython, 'scripts/code_mode_server.py'],
|
|
1170
|
-
enabled: true,
|
|
1171
|
-
}
|
|
1172
|
-
}
|
|
1173
|
-
if (!config.mcp['pantheon-memory']) {
|
|
1174
|
-
config.mcp['pantheon-memory'] = {
|
|
1175
|
-
type: 'local',
|
|
1176
|
-
cwd: runtimeTarget,
|
|
1177
|
-
command: [memoryPython, 'scripts/memory_mcp_server.py'],
|
|
1178
|
-
enabled: true,
|
|
1179
|
-
}
|
|
1180
|
-
}
|
|
1181
|
-
if (!config.mcp['pantheon-persistence']) {
|
|
1182
|
-
config.mcp['pantheon-persistence'] = {
|
|
1183
|
-
type: 'local',
|
|
1184
|
-
cwd: runtimeTarget,
|
|
1185
|
-
command: [memoryPython, 'scripts/mcp_persistence_server.py'],
|
|
1186
|
-
enabled: true,
|
|
1187
|
-
}
|
|
1188
|
-
}
|
|
1189
|
-
if (!config.mcp['pantheon-vision']) {
|
|
1190
|
-
config.mcp['pantheon-vision'] = {
|
|
1191
|
-
type: 'local',
|
|
1192
|
-
cwd: runtimeTarget,
|
|
1193
|
-
command: [memoryPython, 'scripts/pantheon_vision_server.py'],
|
|
1194
|
-
enabled: true,
|
|
1195
|
-
}
|
|
1196
|
-
}
|
|
1197
|
-
|
|
1198
|
-
// Default MCP permissions
|
|
1199
|
-
config.permission = config.permission || {}
|
|
1200
|
-
config.permission.mcp = config.permission.mcp || {}
|
|
1201
|
-
if (!config.permission.mcp['pantheon-resources']) {
|
|
1202
|
-
config.permission.mcp['pantheon-resources'] = 'allow'
|
|
1203
|
-
}
|
|
1204
|
-
if (!config.permission.mcp['pantheon-code-mode']) {
|
|
1205
|
-
config.permission.mcp['pantheon-code-mode'] = 'ask'
|
|
1206
|
-
}
|
|
1207
|
-
if (!config.permission.mcp['pantheon-memory']) {
|
|
1208
|
-
config.permission.mcp['pantheon-memory'] = 'allow'
|
|
1209
|
-
}
|
|
1210
|
-
if (!config.permission.mcp['pantheon-persistence']) {
|
|
1211
|
-
config.permission.mcp['pantheon-persistence'] = 'allow'
|
|
1212
|
-
}
|
|
1213
|
-
if (!config.permission.mcp['pantheon-vision']) {
|
|
1214
|
-
config.permission.mcp['pantheon-vision'] = 'ask'
|
|
1215
|
-
}
|
|
1580
|
+
const runtimeTarget = isGlobal ? target : join(target, '.opencode')
|
|
1581
|
+
applyMcpServerEntries(config, {
|
|
1582
|
+
runtimeTarget,
|
|
1583
|
+
venvPython: venvPythonPath(target),
|
|
1584
|
+
})
|
|
1216
1585
|
}
|
|
1217
1586
|
|
|
1218
1587
|
// ── V2 native migration ──
|
|
@@ -1222,10 +1591,52 @@ export async function installOpenCode(
|
|
|
1222
1591
|
if (version === 'v2') {
|
|
1223
1592
|
info(S.v2Migrating)
|
|
1224
1593
|
configToWrite = migrateV1toV2(config)
|
|
1594
|
+
|
|
1595
|
+
// Pantheon no longer sets a step ceiling. Any `steps` sitting on a managed
|
|
1596
|
+
// agent was written by an earlier install and never chosen by the user —
|
|
1597
|
+
// the merge above is additive by design (no delete path), so drop it here
|
|
1598
|
+
// and let the host default apply. This runs after the migration because
|
|
1599
|
+
// migrateV1toV2 copies the V1 `agent` block across verbatim (including any
|
|
1600
|
+
// stale `steps`), which is exactly how the value reappears in V2.
|
|
1601
|
+
// User-defined agents are out of scope and keep their value; the V1
|
|
1602
|
+
// singular `agent` block is deliberately untouched (V1 is retiring).
|
|
1603
|
+
const v2Agents = configToWrite.agents
|
|
1604
|
+
if (v2Agents && typeof v2Agents === 'object' && !Array.isArray(v2Agents)) {
|
|
1605
|
+
for (const name of Object.keys(canonicalAgentConfig)) {
|
|
1606
|
+
const entry = v2Agents[name]
|
|
1607
|
+
if (entry && typeof entry === 'object' && !Array.isArray(entry)) delete entry.steps
|
|
1608
|
+
}
|
|
1609
|
+
}
|
|
1610
|
+
|
|
1611
|
+
// Carry the V1 top-level `context_sandbox` into the exact Pantheon V2
|
|
1612
|
+
// plugin entry's `options` — V2.0.25 silently drops the top-level key, so
|
|
1613
|
+
// without this the user's enabled/limits choice is lost on a V1→V2 install.
|
|
1614
|
+
// The V1 block itself is preserved by migrateV1toV2 for a downgrade.
|
|
1615
|
+
// A null/type-invalid block throws HERE, before any config write.
|
|
1616
|
+
configToWrite.plugins = applyContextSandboxToV2Plugins(
|
|
1617
|
+
configToWrite.plugins,
|
|
1618
|
+
config.context_sandbox,
|
|
1619
|
+
)
|
|
1225
1620
|
}
|
|
1226
1621
|
|
|
1227
1622
|
const configContent = `${JSON.stringify(configToWrite, null, 2)}\n`
|
|
1228
|
-
const status = writeConfigWithBackup(targetConfigPath, configContent, dryRun
|
|
1623
|
+
const status = writeConfigWithBackup(targetConfigPath, configContent, dryRun, {
|
|
1624
|
+
expectedContent: configRaw,
|
|
1625
|
+
// Test seam: lets a test simulate a concurrent writer landing between the
|
|
1626
|
+
// initial config read and the pre-swap recheck. Absent in production.
|
|
1627
|
+
beforeRecheck: opts.deps?.beforeRecheck,
|
|
1628
|
+
})
|
|
1629
|
+
if (status === 'conflict') {
|
|
1630
|
+
// The user's config changed under the installer between the read and the
|
|
1631
|
+
// swap. Treating this as an ordinary skipped write would report a
|
|
1632
|
+
// successful install over a file we never actually updated — fail loudly
|
|
1633
|
+
// and leave the target and its `.bak` byte-identical instead.
|
|
1634
|
+
throw new Error(
|
|
1635
|
+
`Installation aborted: ${targetConfigPath} changed on disk during install ` +
|
|
1636
|
+
'(it no longer matches the content that was read); refusing to overwrite it. ' +
|
|
1637
|
+
'Re-run the installer once the file is stable.',
|
|
1638
|
+
)
|
|
1639
|
+
}
|
|
1229
1640
|
if (status === 'created') stats.created++
|
|
1230
1641
|
else stats.skipped++
|
|
1231
1642
|
|
|
@@ -1304,9 +1715,46 @@ export async function installOpenCode(
|
|
|
1304
1715
|
writeActivePreset(target, opts.preset, { source: 'cli' })
|
|
1305
1716
|
}
|
|
1306
1717
|
|
|
1718
|
+
// -----------------------------------------------------------------------
|
|
1719
|
+
// 5.5 Optional vision prompt (interactive TTY only). The default — automatic
|
|
1720
|
+
// discovery — writes nothing, and non-interactive flows (no TTY, --yes,
|
|
1721
|
+
// --headless, automated sandboxes) never reach this block, so no run
|
|
1722
|
+
// blocks on stdin and the default behavior is unchanged. A failure here
|
|
1723
|
+
// must never fail the install: vision stays optional.
|
|
1724
|
+
// -----------------------------------------------------------------------
|
|
1725
|
+
if (interactive && !dryRun && !autoYes) {
|
|
1726
|
+
const { runVisionPrompt } = await import('./vision-prompt.mjs')
|
|
1727
|
+
try {
|
|
1728
|
+
await runVisionPrompt({ logger: console })
|
|
1729
|
+
} catch (err) {
|
|
1730
|
+
warning(`Vision setup skipped: ${err instanceof Error ? err.message : String(err)}`)
|
|
1731
|
+
}
|
|
1732
|
+
}
|
|
1733
|
+
|
|
1307
1734
|
// Fail-closed: accumulated stats.errors (bad sources, missing requirements,
|
|
1308
1735
|
// failed health checks) must never masquerade as a successful installation.
|
|
1309
1736
|
if (stats.errors > 0) {
|
|
1310
1737
|
throw new Error(`installation completed with ${stats.errors} error(s); see output above`)
|
|
1311
1738
|
}
|
|
1739
|
+
|
|
1740
|
+
// Record the component state only after the install has completed. The
|
|
1741
|
+
// postinstall marker can predate `init` and start with every component
|
|
1742
|
+
// pending; leaving it untouched made a healthy beta install look incomplete
|
|
1743
|
+
// to `doctor` forever. Unselected components retain their previous state.
|
|
1744
|
+
if (!dryRun) {
|
|
1745
|
+
const packageVersion = JSON.parse(readFileSync(join(ROOT, 'package.json'), 'utf8')).version
|
|
1746
|
+
let state = readState(target)
|
|
1747
|
+
if (!state && hasState(target)) {
|
|
1748
|
+
throw new Error('Installation state exists but cannot be read; refusing to replace it')
|
|
1749
|
+
}
|
|
1750
|
+
state ??= createInitialState(packageVersion)
|
|
1751
|
+
state.pantheon_version = packageVersion
|
|
1752
|
+
for (const component of ['agents', 'skills', 'plugins', 'runtime']) {
|
|
1753
|
+
if (!componentSet.has(component)) continue
|
|
1754
|
+
const status = component === 'runtime' && !runtimeHealthy ? 'failed' : 'installed'
|
|
1755
|
+
const componentVersion = state.components?.[component]?.version ?? 1
|
|
1756
|
+
setComponentState(state, component, componentVersion, status)
|
|
1757
|
+
}
|
|
1758
|
+
writeState(target, state)
|
|
1759
|
+
}
|
|
1312
1760
|
}
|