pantheon-opencode 1.6.0-beta.1 → 1.6.0-beta.11
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 +206 -0
- package/AGENTS.md +10 -891
- package/CHANGELOG.md +350 -0
- package/README.md +561 -52
- package/README.pt-BR.md +285 -20
- package/bin/pantheon-init.mjs +70 -4
- 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 +24 -17
- package/plugin.json +2 -2
- package/pyproject.toml +8 -6
- package/scripts/build-agents-md.mjs +428 -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/backup.mjs +475 -0
- package/scripts/install/cli-ui.mjs +6 -1
- package/scripts/install/config-migration.mjs +317 -4
- package/scripts/install/exit-codes.mjs +26 -0
- package/scripts/install/health-check.mjs +21 -19
- package/scripts/install/interactive.mjs +16 -2
- package/scripts/install/migrate.mjs +41 -22
- package/scripts/install/opencode-version.mjs +246 -4
- package/scripts/install/opencode.mjs +1175 -215
- package/scripts/install/plan.mjs +287 -0
- package/scripts/install/plugin.mjs +62 -14
- package/scripts/install/shared.mjs +190 -48
- package/scripts/install/strings.mjs +20 -0
- package/scripts/install/venv.mjs +13 -3
- package/scripts/install/vision-prompt.mjs +171 -0
- package/scripts/install-mcp.mjs +28 -9
- package/scripts/{mcp_persistence_server.py → mcp_persistence.py} +52 -17
- 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/prune.mjs +9 -3
- package/scripts/restore.mjs +222 -0
- 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 +7 -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 -172
- package/src/instructions/agent-return-format.instructions.md +34 -17
- package/src/instructions/backend-standards.instructions.md +2 -0
- package/src/instructions/frontend-standards.instructions.md +3 -1
- package/src/instructions/memory-protocol.instructions.md +12 -110
- package/src/instructions/test-economy.instructions.md +21 -0
- package/src/instructions/zeus-anti-stall.instructions.md +20 -67
- package/src/instructions/zeus-communication-rules.instructions.md +0 -1
- package/src/instructions/zeus-council-synthesis.instructions.md +31 -260
- package/src/instructions/zeus-memory-operations.instructions.md +30 -0
- package/src/instructions/zeus-timeout-retry.instructions.md +61 -89
- 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} +52 -17
- 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 +403 -102
- package/src/pantheon/delegation-enforce.ts +10 -1
- package/src/pantheon/model-agents.ts +19 -0
- package/src/pantheon/model-command.ts +218 -56
- 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-model-overrides.ts +275 -0
- package/src/pantheon/v2-tools.ts +418 -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 +1158 -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 +52 -35
- 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
|
*/
|
|
@@ -15,7 +19,10 @@
|
|
|
15
19
|
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
|
-
import { dirname, isAbsolute, join, resolve } from 'node:path'
|
|
22
|
+
import { dirname, isAbsolute, join, relative, resolve } from 'node:path'
|
|
23
|
+
import { listV2UnsupportedFeatures } from '../../src/pantheon/v2-unsupported.mjs'
|
|
24
|
+
import { copyAgentPrompts, writeAgentsMdManaged } from '../build-agents-md.mjs'
|
|
25
|
+
import { backupSetPath, backupWarning, createBackupSet, nextBackupName } from './backup.mjs'
|
|
19
26
|
import {
|
|
20
27
|
bullet,
|
|
21
28
|
colors,
|
|
@@ -27,17 +34,32 @@ import {
|
|
|
27
34
|
spinner,
|
|
28
35
|
step,
|
|
29
36
|
success,
|
|
37
|
+
tag,
|
|
30
38
|
warning,
|
|
31
39
|
} from './cli-ui.mjs'
|
|
32
|
-
import {
|
|
40
|
+
import {
|
|
41
|
+
mergeContextSandboxBlocks,
|
|
42
|
+
migrateMcpEntrypointPaths,
|
|
43
|
+
migrateV1toV2,
|
|
44
|
+
} from './config-migration.mjs'
|
|
33
45
|
import { healthCheck } from './health-check.mjs'
|
|
34
46
|
import { detectVersion, runMigrations } from './migrate.mjs'
|
|
35
47
|
import { resolveOpenCodeVersion } from './opencode-version.mjs'
|
|
48
|
+
import {
|
|
49
|
+
ACTIONS,
|
|
50
|
+
createRecorder,
|
|
51
|
+
displayPath,
|
|
52
|
+
formatChangePlan,
|
|
53
|
+
isRecording,
|
|
54
|
+
planStatsMismatch,
|
|
55
|
+
recordChange,
|
|
56
|
+
snapshot,
|
|
57
|
+
summarizeChanges,
|
|
58
|
+
} from './plan.mjs'
|
|
36
59
|
import { copyPluginFiles, installPlugin, registerPlugin, unregisterPlugin } from './plugin.mjs'
|
|
37
60
|
import {
|
|
38
61
|
checkRuntimePrerequisites,
|
|
39
62
|
collectSkillNames,
|
|
40
|
-
copyFiles,
|
|
41
63
|
installSkills,
|
|
42
64
|
parseFrontmatter,
|
|
43
65
|
ROOT,
|
|
@@ -47,6 +69,7 @@ import {
|
|
|
47
69
|
writeConfigWithBackup,
|
|
48
70
|
writeIfChanged,
|
|
49
71
|
} from './shared.mjs'
|
|
72
|
+
import { createInitialState, hasState, readState, setComponentState, writeState } from './state.mjs'
|
|
50
73
|
import { strings } from './strings.mjs'
|
|
51
74
|
|
|
52
75
|
import { setupVenv, venvPythonPath } from './venv.mjs'
|
|
@@ -61,36 +84,343 @@ const COMPONENT_NAMES = [
|
|
|
61
84
|
'runtime',
|
|
62
85
|
]
|
|
63
86
|
|
|
87
|
+
/**
|
|
88
|
+
* MCP server scripts the runtime component installs. Hoisted so the backup-set
|
|
89
|
+
* enumeration and the runtime writer share one source of truth.
|
|
90
|
+
*/
|
|
91
|
+
const MCP_SCRIPTS = [
|
|
92
|
+
'mcp_resources.py',
|
|
93
|
+
'code_mode.py',
|
|
94
|
+
'memory_mcp.py',
|
|
95
|
+
'scrub-secrets.py',
|
|
96
|
+
'_pantheon_paths.py',
|
|
97
|
+
'mcp_codemap_module.py',
|
|
98
|
+
'mcp_persistence.py',
|
|
99
|
+
'pantheon_vision.py',
|
|
100
|
+
'eval_store.py',
|
|
101
|
+
'themis_heuristic_scan.py',
|
|
102
|
+
]
|
|
103
|
+
|
|
104
|
+
/** Recursively list regular files under `root`, relative to `root`. */
|
|
105
|
+
function walkFiles(root, base = root, out = []) {
|
|
106
|
+
for (const entry of readdirSync(root, { withFileTypes: true })) {
|
|
107
|
+
const full = join(root, entry.name)
|
|
108
|
+
if (entry.isDirectory()) walkFiles(full, base, out)
|
|
109
|
+
else if (entry.isFile()) out.push(relative(base, full))
|
|
110
|
+
}
|
|
111
|
+
return out
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Enumerate what the backup set must preserve, derived from the selected
|
|
116
|
+
* components. It returns:
|
|
117
|
+
* - `entries`: paths (relative to `target`) the run can CREATE/UPDATE/REMOVE.
|
|
118
|
+
* For every managed destination tree we take the UNION of the files that
|
|
119
|
+
* already exist there (so a `--clean`, a stale removal, or an
|
|
120
|
+
* instructions-only refresh is captured) and the source-derived destination
|
|
121
|
+
* paths (so a file that will be created is recorded as absent). The union is
|
|
122
|
+
* a superset of the plan's changes — unchanged files are backed up too.
|
|
123
|
+
* - `notRevertible`: paths the run may delete or write but whose byte-for-byte
|
|
124
|
+
* copy is not feasible or is out of the config dir (bulk `.venv` / plugin
|
|
125
|
+
* `node_modules`, the global `tui.json` copies and the global vision config).
|
|
126
|
+
* These are recorded explicitly in the manifest rather than silently ignored.
|
|
127
|
+
* It never reads file contents.
|
|
128
|
+
*
|
|
129
|
+
* @param {string} target
|
|
130
|
+
* @param {Set<string>} componentSet
|
|
131
|
+
* @param {string} version
|
|
132
|
+
* @param {boolean} isGlobal
|
|
133
|
+
* @returns {{ entries: string[], notRevertible: string[] }}
|
|
134
|
+
*/
|
|
135
|
+
function collectInstallBackupEntries(target, componentSet, version, isGlobal) {
|
|
136
|
+
const entries = []
|
|
137
|
+
const notRevertible = []
|
|
138
|
+
const rel = (abs) => relative(target, abs)
|
|
139
|
+
const addFile = (abs) => entries.push(rel(abs))
|
|
140
|
+
// Existing files under an in-target tree (covers removals / clean / refresh).
|
|
141
|
+
// `skipDirNames` prunes bulk subdirectories (e.g. plugin `node_modules`) so the
|
|
142
|
+
// walk stays cheap while everything else — including legacy files a clean pass
|
|
143
|
+
// deletes — is captured.
|
|
144
|
+
const addExisting = (dir, skipDirNames = null) => {
|
|
145
|
+
if (!existsSync(dir)) return
|
|
146
|
+
const walk = (base) => {
|
|
147
|
+
for (const entry of readdirSync(base, { withFileTypes: true })) {
|
|
148
|
+
if (skipDirNames && entry.isDirectory() && skipDirNames.has(entry.name)) continue
|
|
149
|
+
const full = join(base, entry.name)
|
|
150
|
+
if (entry.isDirectory()) walk(full)
|
|
151
|
+
else if (entry.isFile()) addFile(full)
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
walk(dir)
|
|
155
|
+
}
|
|
156
|
+
// Destination paths derived from a source tree (covers files to be created).
|
|
157
|
+
const addSource = (srcDir, dstDir, filter = null) => {
|
|
158
|
+
if (!existsSync(srcDir)) return
|
|
159
|
+
for (const r of walkFiles(srcDir)) {
|
|
160
|
+
if (filter && !filter(r)) continue
|
|
161
|
+
addFile(join(dstDir, r))
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const baseDir = isGlobal ? target : join(target, '.opencode')
|
|
166
|
+
const agentPrefix = isGlobal || version === 'v2' ? 'agents' : '.opencode/agents'
|
|
167
|
+
const agentDir = join(target, agentPrefix)
|
|
168
|
+
|
|
169
|
+
// Always touched.
|
|
170
|
+
addFile(join(target, 'opencode.json'))
|
|
171
|
+
addFile(join(target, '.pantheon', 'install-state.json'))
|
|
172
|
+
addFile(join(baseDir, 'opencode-vision.json'))
|
|
173
|
+
// The active-preset writer (writeActivePreset(target, …)) lives at
|
|
174
|
+
// <target>/.pantheon, NOT under <baseDir>, for both project and global scope.
|
|
175
|
+
addFile(join(target, '.pantheon', 'active-preset.json'))
|
|
176
|
+
addFile(join(target, '.pantheon', 'active-preset.json.bak'))
|
|
177
|
+
|
|
178
|
+
// The agent prompt dir is refreshed (existing-only) by an instructions-only
|
|
179
|
+
// run too, so enumerate it whenever EITHER component can write it.
|
|
180
|
+
if (componentSet.has('agents') || componentSet.has('instructions')) {
|
|
181
|
+
addExisting(agentDir)
|
|
182
|
+
}
|
|
183
|
+
if (componentSet.has('agents')) {
|
|
184
|
+
addFile(join(target, 'routing.yml'))
|
|
185
|
+
addSource(
|
|
186
|
+
join(ROOT, 'src', 'agents'),
|
|
187
|
+
agentDir,
|
|
188
|
+
(r) => r.endsWith('.md') && r.toLowerCase() !== 'readme.md',
|
|
189
|
+
)
|
|
190
|
+
}
|
|
191
|
+
if (componentSet.has('skills')) {
|
|
192
|
+
addExisting(join(baseDir, 'skills'))
|
|
193
|
+
addSource(join(ROOT, 'src', 'skills'), join(baseDir, 'skills'))
|
|
194
|
+
}
|
|
195
|
+
if (componentSet.has('instructions')) addFile(join(target, 'AGENTS.md'))
|
|
196
|
+
if (componentSet.has('prompts')) {
|
|
197
|
+
addExisting(join(target, 'prompts'))
|
|
198
|
+
addSource(join(ROOT, 'prompts'), join(target, 'prompts'))
|
|
199
|
+
}
|
|
200
|
+
if (componentSet.has('commands')) {
|
|
201
|
+
const commandDir = isGlobal || version === 'v2' ? 'commands' : '.opencode/commands'
|
|
202
|
+
addExisting(join(target, commandDir))
|
|
203
|
+
addSource(join(ROOT, 'commands'), join(target, commandDir), (r) => r.endsWith('.md'))
|
|
204
|
+
}
|
|
205
|
+
if (componentSet.has('plugins')) {
|
|
206
|
+
const dstPluginDir = isGlobal
|
|
207
|
+
? join(target, 'plugins', 'pantheon-tui')
|
|
208
|
+
: join(target, '.opencode', 'plugins', 'pantheon-tui')
|
|
209
|
+
// Existing files under the installed plugin dir (legacy `index.tsx` /
|
|
210
|
+
// `dist/tui.tsx` and any extras a `--clean` run deletes) are captured, like
|
|
211
|
+
// every sibling tree — EXCEPT the bulk `node_modules`, which is regenerated
|
|
212
|
+
// by `npm ci` and listed as not revertible.
|
|
213
|
+
addExisting(dstPluginDir, new Set(['node_modules']))
|
|
214
|
+
addSource(join(ROOT, 'src', 'plugins', 'tui', 'dist'), join(dstPluginDir, 'dist'))
|
|
215
|
+
addFile(join(dstPluginDir, 'package.json'))
|
|
216
|
+
addFile(join(dstPluginDir, 'package-lock.json'))
|
|
217
|
+
addFile(join(dstPluginDir, 'tui.ts'))
|
|
218
|
+
addFile(join(dstPluginDir, 'index.ts'))
|
|
219
|
+
notRevertible.push(rel(join(dstPluginDir, 'node_modules')))
|
|
220
|
+
if (version === 'v1') {
|
|
221
|
+
const projectTui = join(baseDir, 'tui.json')
|
|
222
|
+
addFile(projectTui)
|
|
223
|
+
// Cross-location cleanup touches copies OUTSIDE the config dir.
|
|
224
|
+
for (const loc of tuiConfigLocations(target)) {
|
|
225
|
+
if (resolve(loc) !== resolve(projectTui)) notRevertible.push(loc)
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
if (componentSet.has('runtime')) {
|
|
230
|
+
const runtimeTarget = isGlobal ? target : join(target, '.opencode')
|
|
231
|
+
addExisting(join(runtimeTarget, 'scripts'))
|
|
232
|
+
for (const script of MCP_SCRIPTS) addFile(join(runtimeTarget, 'scripts', script))
|
|
233
|
+
addFile(join(runtimeTarget, 'requirements-vision.txt'))
|
|
234
|
+
addSource(join(ROOT, '.pantheon', 'code-mode'), join(runtimeTarget, '.pantheon', 'code-mode'))
|
|
235
|
+
addExisting(join(runtimeTarget, '.pantheon', 'code-mode'))
|
|
236
|
+
addFile(join(runtimeTarget, '.pantheon', 'tiers.json'))
|
|
237
|
+
// `--force` recreates the venv from scratch: bulk, not copied.
|
|
238
|
+
notRevertible.push(rel(join(target, '.venv')))
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// The vision config is written GLOBALLY (outside the config dir).
|
|
242
|
+
const xdg = process.env.XDG_CONFIG_HOME || join(homedir(), '.config')
|
|
243
|
+
const globalVision = join(xdg, 'opencode', 'opencode-vision.json')
|
|
244
|
+
if (resolve(globalVision) !== resolve(join(baseDir, 'opencode-vision.json'))) {
|
|
245
|
+
notRevertible.push(globalVision)
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
return { entries, notRevertible }
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* The product defaults an install seeds into the user's `opencode.json`.
|
|
253
|
+
*
|
|
254
|
+
* They live HERE, in code, rather than in a template shipped in the npm
|
|
255
|
+
* tarball: that template also carried a maintainer's private MCP endpoint,
|
|
256
|
+
* provider models and per-developer permission overrides, and shipping it to
|
|
257
|
+
* every installer was the bug (#199). Reading defaults off it meant deleting it
|
|
258
|
+
* silently stopped the seeding, so the defaults are now stated next to the code
|
|
259
|
+
* that consumes them.
|
|
260
|
+
*
|
|
261
|
+
* PERSONAL CONFIGURATION MUST NEVER BE SEEDED HERE: this is the installer's
|
|
262
|
+
* contract with every user, and a value that is only right on one machine
|
|
263
|
+
* belongs in that machine's own `opencode.json`. Deliberately NOT carried over
|
|
264
|
+
* from the template, each because it was personal rather than a product
|
|
265
|
+
* default:
|
|
266
|
+
* - `provider` — pinned the maintainer's provider, model and modalities.
|
|
267
|
+
* - `mcp` — a private remote MCP endpoint.
|
|
268
|
+
* - `agent` — the maintainer's per-agent permission overrides.
|
|
269
|
+
* - `permission.websearch: "deny"` — a personal restriction; a user may rely
|
|
270
|
+
* on web search, so denying it is not ours to decide.
|
|
271
|
+
* - `permission.bash` — the template's dev allowlist (git, npm, ruff, …), a
|
|
272
|
+
* personal judgement about which commands to trust.
|
|
273
|
+
* Anything added here must be justifiable as "correct for a fresh install of
|
|
274
|
+
* the product, for anyone".
|
|
275
|
+
*
|
|
276
|
+
* Other seeding paths are untouched by this constant and still run: the config
|
|
277
|
+
* merge derives plugin refs, provider and compaction from a repository-root
|
|
278
|
+
* opencode.json when a contributor has one, and separately gates its own
|
|
279
|
+
* permission grants — `skill` on the skills component, `bash` unconditionally —
|
|
280
|
+
* on whether the user's own config already set them.
|
|
281
|
+
*
|
|
282
|
+
* Frozen, and copied only into a confirmed gap (never into a key the user
|
|
283
|
+
* already set), so nothing here can be mutated through a user's config.
|
|
284
|
+
*/
|
|
285
|
+
const PRODUCT_CONFIG_DEFAULTS = Object.freeze({
|
|
286
|
+
default_agent: 'zeus',
|
|
287
|
+
})
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* Per-agent fields owned by the framework: for an agent that already exists in
|
|
291
|
+
* the target config these are overwritten from the canonical source, and for a
|
|
292
|
+
* new agent they are the seed. Everything else on an existing entry is the
|
|
293
|
+
* user's and is left alone.
|
|
294
|
+
*
|
|
295
|
+
* `source` is written right next to these (for both new and existing agents) but
|
|
296
|
+
* is kept out of the loop so it can also be set independently — the migration in
|
|
297
|
+
* config-migration.mjs mirrors this list plus `source`, and tests assert the two
|
|
298
|
+
* stay equal as sets.
|
|
299
|
+
*
|
|
300
|
+
* KNOWN DEBT: `permission` is the V1 spelling. V2 reads `permissions`, so an
|
|
301
|
+
* agent can carry both keys after an install and the V1 one becomes dead weight.
|
|
302
|
+
* Tracked for the next slice; changing it here would change what an install
|
|
303
|
+
* writes.
|
|
304
|
+
*
|
|
305
|
+
* @type {readonly string[]}
|
|
306
|
+
*/
|
|
307
|
+
export const MANAGED_FIELDS = Object.freeze([
|
|
308
|
+
'temperature',
|
|
309
|
+
'color',
|
|
310
|
+
'permission',
|
|
311
|
+
'mode',
|
|
312
|
+
'hidden',
|
|
313
|
+
'disable_model_invocation',
|
|
314
|
+
])
|
|
315
|
+
|
|
316
|
+
const PANTHEON_MCP_SERVERS = Object.freeze([
|
|
317
|
+
'pantheon-resources',
|
|
318
|
+
'pantheon-memory',
|
|
319
|
+
'pantheon-code-mode',
|
|
320
|
+
'pantheon-persistence',
|
|
321
|
+
'pantheon-vision',
|
|
322
|
+
])
|
|
323
|
+
|
|
324
|
+
function normalizeMcpPermissionSegment(value) {
|
|
325
|
+
return value.replace(/[^a-zA-Z0-9_]/g, '_')
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/**
|
|
329
|
+
* Translate an agent's `mcp_tools` frontmatter into per-tool native
|
|
330
|
+
* permissions. V1 matches the original server-tool names; V2 normalizes
|
|
331
|
+
* punctuation to underscores. Emitting both spellings keeps the same source
|
|
332
|
+
* metadata valid for either host. Unlisted bundled Pantheon servers default to
|
|
333
|
+
* deny; unrelated user MCP servers are not touched.
|
|
334
|
+
*
|
|
335
|
+
* @param {Record<string, 'all' | string[]>} mcpTools
|
|
336
|
+
* @returns {Record<string, 'allow' | 'deny'>}
|
|
337
|
+
*/
|
|
338
|
+
export function deriveNativeMcpPermissions(mcpTools) {
|
|
339
|
+
if (mcpTools === null || typeof mcpTools !== 'object' || Array.isArray(mcpTools)) {
|
|
340
|
+
throw new TypeError('mcp_tools must be an object keyed by MCP server name')
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
const servers = new Set([...PANTHEON_MCP_SERVERS, ...Object.keys(mcpTools)])
|
|
344
|
+
const permissions = {}
|
|
345
|
+
|
|
346
|
+
for (const server of servers) {
|
|
347
|
+
if (!/^[a-zA-Z0-9._-]+$/.test(server)) {
|
|
348
|
+
throw new TypeError(`mcp_tools server name is invalid: ${server}`)
|
|
349
|
+
}
|
|
350
|
+
const tools = Object.hasOwn(mcpTools, server) ? mcpTools[server] : []
|
|
351
|
+
if (
|
|
352
|
+
tools !== 'all' &&
|
|
353
|
+
(!Array.isArray(tools) ||
|
|
354
|
+
tools.some((tool) => typeof tool !== 'string' || !/^[a-zA-Z0-9._-]+$/.test(tool)))
|
|
355
|
+
) {
|
|
356
|
+
throw new TypeError(`mcp_tools.${server} must be "all" or an array of tool names`)
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
const serverAliases = new Set([server, normalizeMcpPermissionSegment(server)])
|
|
360
|
+
for (const alias of serverAliases) permissions[`${alias}_*`] = 'deny'
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
for (const server of servers) {
|
|
364
|
+
const tools = Object.hasOwn(mcpTools, server) ? mcpTools[server] : []
|
|
365
|
+
const serverAliases = new Set([server, normalizeMcpPermissionSegment(server)])
|
|
366
|
+
if (tools === 'all') {
|
|
367
|
+
for (const alias of serverAliases) permissions[`${alias}_*`] = 'allow'
|
|
368
|
+
continue
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
for (const tool of tools) {
|
|
372
|
+
const toolAliases = new Set([tool, normalizeMcpPermissionSegment(tool)])
|
|
373
|
+
for (const serverAlias of serverAliases) {
|
|
374
|
+
for (const toolAlias of toolAliases) {
|
|
375
|
+
permissions[`${serverAlias}_${toolAlias}`] = 'allow'
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
return permissions
|
|
382
|
+
}
|
|
383
|
+
|
|
64
384
|
function deepClone(value) {
|
|
65
385
|
return JSON.parse(JSON.stringify(value))
|
|
66
386
|
}
|
|
67
387
|
|
|
68
388
|
/**
|
|
69
389
|
* (Re)generate the code-mode manifest with SHA-256 digests for every bundled
|
|
70
|
-
* script. This is the explicit installation
|
|
71
|
-
* executes scripts listed here whose on-disk hash
|
|
72
|
-
*
|
|
390
|
+
* script, and write it to the destination. This is the explicit installation
|
|
391
|
+
* opt-in: the MCP server only executes scripts listed here whose on-disk hash
|
|
392
|
+
* matches. Missing or empty source approves nothing — execution is never
|
|
393
|
+
* implicit.
|
|
394
|
+
*
|
|
395
|
+
* This function is the SINGLE owner of `manifest.json`: `syncDir` deliberately
|
|
396
|
+
* excludes it (see the runtime block), so exactly one writer and one stats
|
|
397
|
+
* count exist for that path. The payload is derived from the SOURCE scripts
|
|
398
|
+
* (what will be installed), then compared against the CURRENT installed
|
|
399
|
+
* manifest, so the preview action matches what apply writes — including on a
|
|
400
|
+
* script upgrade, where the installed manifest is stale.
|
|
401
|
+
*
|
|
402
|
+
* @returns {{ approved: number, status: 'created'|'skipped'|'conflict'|null }}
|
|
73
403
|
*/
|
|
74
|
-
function writeCodeModeManifest(
|
|
75
|
-
if (!existsSync(
|
|
404
|
+
function writeCodeModeManifest(srcDir, dstDir, dryRun, recorder = null) {
|
|
405
|
+
if (!existsSync(srcDir)) return { approved: 0, status: null }
|
|
76
406
|
let names
|
|
77
407
|
try {
|
|
78
|
-
names = readdirSync(
|
|
408
|
+
names = readdirSync(srcDir)
|
|
79
409
|
.filter((name) => /\.(sh|py)$/i.test(name) && !name.startsWith('.'))
|
|
80
410
|
.sort()
|
|
81
411
|
} catch {
|
|
82
|
-
return 0
|
|
412
|
+
return { approved: 0, status: null }
|
|
83
413
|
}
|
|
84
|
-
if (names.length === 0) return 0
|
|
414
|
+
if (names.length === 0) return { approved: 0, status: null }
|
|
85
415
|
const scripts = {}
|
|
86
416
|
for (const name of names) {
|
|
87
417
|
scripts[name] = createHash('sha256')
|
|
88
|
-
.update(readFileSync(join(
|
|
418
|
+
.update(readFileSync(join(srcDir, name)))
|
|
89
419
|
.digest('hex')
|
|
90
420
|
}
|
|
91
421
|
const payload = `${JSON.stringify({ version: 1, scripts }, null, 2)}\n`
|
|
92
|
-
writeIfChanged(join(
|
|
93
|
-
return names.length
|
|
422
|
+
const status = writeIfChanged(join(dstDir, 'manifest.json'), payload, dryRun, { recorder })
|
|
423
|
+
return { approved: names.length, status }
|
|
94
424
|
}
|
|
95
425
|
|
|
96
426
|
function readJsonConfig(filePath) {
|
|
@@ -103,6 +433,27 @@ function readJsonConfig(filePath) {
|
|
|
103
433
|
}
|
|
104
434
|
}
|
|
105
435
|
|
|
436
|
+
/**
|
|
437
|
+
* Read a JSON config AND the exact raw text it was parsed from.
|
|
438
|
+
*
|
|
439
|
+
* The raw text is threaded to {@link writeConfigWithBackup} as the
|
|
440
|
+
* expected-content guard, so the compare-before-rename uses the SAME bytes the
|
|
441
|
+
* caller parsed — no second read that could observe a different file.
|
|
442
|
+
*
|
|
443
|
+
* @param {string} filePath
|
|
444
|
+
* @returns {{ config: object, raw: string|null }}
|
|
445
|
+
* @throws {Error} on invalid JSON (before any write happens)
|
|
446
|
+
*/
|
|
447
|
+
function readJsonConfigWithRaw(filePath) {
|
|
448
|
+
if (!existsSync(filePath)) return { config: {}, raw: null }
|
|
449
|
+
const raw = readFileSync(filePath, 'utf8')
|
|
450
|
+
try {
|
|
451
|
+
return { config: JSON.parse(raw), raw }
|
|
452
|
+
} catch (cause) {
|
|
453
|
+
throw new Error(`Invalid JSON in ${filePath}`, { cause })
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
|
|
106
457
|
function mergeMissing(target, source) {
|
|
107
458
|
if (!source || typeof source !== 'object' || Array.isArray(source)) return target
|
|
108
459
|
if (!target || typeof target !== 'object' || Array.isArray(target)) return deepClone(source)
|
|
@@ -131,8 +482,8 @@ function mergeMissing(target, source) {
|
|
|
131
482
|
}
|
|
132
483
|
|
|
133
484
|
/**
|
|
134
|
-
* Resolve a plugin ref from the
|
|
135
|
-
* installed package.
|
|
485
|
+
* Resolve a plugin ref from the repository-root opencode.json to a path INSIDE
|
|
486
|
+
* the installed package.
|
|
136
487
|
*
|
|
137
488
|
* Only exact Pantheon plugin identities are resolved into the installed
|
|
138
489
|
* package. In particular, a third-party absolute path ending in
|
|
@@ -169,6 +520,7 @@ const PANTHEON_V2_PLUGIN = 'src/plugin-v2'
|
|
|
169
520
|
const PANTHEON_V2_LEGACY_FILE = 'src/plugin-v2.ts'
|
|
170
521
|
const PANTHEON_V1_EXPORT = 'pantheon-opencode/plugin'
|
|
171
522
|
const PANTHEON_V2_EXPORT = 'pantheon-opencode/plugin-v2'
|
|
523
|
+
const PANTHEON_PACKAGE_REF = /^pantheon-opencode(?:@[^/]+)?(?:\/(plugin|plugin-v2))?$/
|
|
172
524
|
const PANTHEON_PLUGIN_IDENTITIES = new Set([
|
|
173
525
|
PANTHEON_V1_PLUGIN,
|
|
174
526
|
PANTHEON_V1_HOOKS,
|
|
@@ -197,6 +549,17 @@ function managedPluginIdentity(ref) {
|
|
|
197
549
|
const normalized = normalizePluginRef(ref)
|
|
198
550
|
if (typeof normalized !== 'string') return null
|
|
199
551
|
|
|
552
|
+
// Older installs registered the package root itself (often pinned to a
|
|
553
|
+
// beta version) in OpenCode's plural `plugins` list. Treat all exact npm
|
|
554
|
+
// specs for this package as managed so an update removes the old cached
|
|
555
|
+
// copy before registering the current package path. Do not match similarly
|
|
556
|
+
// named third-party packages such as `pantheon-opencode-fork`.
|
|
557
|
+
const packageRef = normalized.match(PANTHEON_PACKAGE_REF)
|
|
558
|
+
if (packageRef !== null) {
|
|
559
|
+
if (packageRef[1] === 'plugin') return PANTHEON_V1_PLUGIN
|
|
560
|
+
return PANTHEON_V2_PLUGIN
|
|
561
|
+
}
|
|
562
|
+
|
|
200
563
|
if (PANTHEON_PLUGIN_IDENTITIES.has(normalized)) return normalized
|
|
201
564
|
if (normalized === PANTHEON_V2_LEGACY_FILE) return PANTHEON_V2_PLUGIN
|
|
202
565
|
if (normalized === PANTHEON_V1_EXPORT || normalized.startsWith(`${PANTHEON_V1_EXPORT}@`)) {
|
|
@@ -275,6 +638,71 @@ export function deduplicatePluginReferences(refs) {
|
|
|
275
638
|
})
|
|
276
639
|
}
|
|
277
640
|
|
|
641
|
+
/**
|
|
642
|
+
* Duplicate the V1 top-level `context_sandbox` block into the exact Pantheon V2
|
|
643
|
+
* plugin entry's `options`, converting a plain-string entry into the
|
|
644
|
+
* `{ package, options }` object form the host accepts.
|
|
645
|
+
*
|
|
646
|
+
* Why the copy is required: OpenCode V2.0.25 silently drops a top-level
|
|
647
|
+
* `context_sandbox` key (probe evidence in the module header of
|
|
648
|
+
* config-migration.mjs), so the V1 setting would be lost on a V1→V2 install.
|
|
649
|
+
* The V1 top-level block is LEFT IN PLACE so a downgrade keeps working.
|
|
650
|
+
*
|
|
651
|
+
* Precedence is delegated to {@link mergeContextSandboxBlocks}: an explicit V2
|
|
652
|
+
* option leaf wins, the legacy block fills only unset leaves, and the runtime
|
|
653
|
+
* resolver supplies defaults. Only the Pantheon V2 entry is touched — every
|
|
654
|
+
* other plugin entry, and every other option on this entry, is preserved.
|
|
655
|
+
*
|
|
656
|
+
* The managed V2 option is ALWAYS validated — even when no legacy V1 block is
|
|
657
|
+
* present — because the migration is the only place that validates it. An
|
|
658
|
+
* earlier version returned early on `legacySandbox === undefined`, so an
|
|
659
|
+
* explicit `plugins[].options.context_sandbox` of `null` (or any type-invalid
|
|
660
|
+
* known value) was never checked and persisted straight to disk.
|
|
661
|
+
*
|
|
662
|
+
* @param {unknown[]} plugins - V2 `plugins` array (mutated in place)
|
|
663
|
+
* @param {unknown} legacySandbox - V1 top-level `context_sandbox` (may be undefined)
|
|
664
|
+
* @returns {unknown[]} the same array
|
|
665
|
+
* @throws {Error} when the legacy block or the existing V2 option is null or
|
|
666
|
+
* type-invalid (checked before any config write)
|
|
667
|
+
*/
|
|
668
|
+
export function applyContextSandboxToV2Plugins(plugins, legacySandbox) {
|
|
669
|
+
if (!Array.isArray(plugins)) return plugins
|
|
670
|
+
|
|
671
|
+
const index = plugins.findIndex((ref) => pluginReferenceIdentity(ref) === PANTHEON_V2_PLUGIN)
|
|
672
|
+
if (index === -1) return plugins
|
|
673
|
+
|
|
674
|
+
const entry = plugins[index]
|
|
675
|
+
const isObjectEntry = typeof entry === 'object' && entry !== null && !Array.isArray(entry)
|
|
676
|
+
const hasOptions =
|
|
677
|
+
isObjectEntry &&
|
|
678
|
+
typeof entry.options === 'object' &&
|
|
679
|
+
entry.options !== null &&
|
|
680
|
+
!Array.isArray(entry.options)
|
|
681
|
+
const existingOptions = hasOptions ? entry.options : {}
|
|
682
|
+
const hasExistingSandbox = Object.hasOwn(existingOptions, 'context_sandbox')
|
|
683
|
+
|
|
684
|
+
// Nothing managed to migrate OR validate: leave the entry untouched so a
|
|
685
|
+
// re-install stays a true no-op. `legacySandbox === undefined` covers an
|
|
686
|
+
// absent V1 key; `!hasExistingSandbox` covers a V2 entry with no managed
|
|
687
|
+
// option yet. An explicit `null` is a PRESENT key (`hasExistingSandbox`), so
|
|
688
|
+
// it is deliberately NOT skipped and must be validated below.
|
|
689
|
+
if (legacySandbox === undefined && !hasExistingSandbox) return plugins
|
|
690
|
+
|
|
691
|
+
// Run the managed option through the validator unconditionally, merging the
|
|
692
|
+
// legacy block in only when it exists. A null/type-invalid existing option
|
|
693
|
+
// throws here, BEFORE the config is written.
|
|
694
|
+
const mergedSandbox = mergeContextSandboxBlocks(existingOptions.context_sandbox, legacySandbox)
|
|
695
|
+
const packageId = isObjectEntry ? entry.package : entry
|
|
696
|
+
|
|
697
|
+
// Rebuild the entry as an object so the options survive a V2 host (which
|
|
698
|
+
// preserves plugin option objects verbatim).
|
|
699
|
+
plugins[index] = {
|
|
700
|
+
package: packageId,
|
|
701
|
+
options: { ...existingOptions, context_sandbox: mergedSandbox },
|
|
702
|
+
}
|
|
703
|
+
return plugins
|
|
704
|
+
}
|
|
705
|
+
|
|
278
706
|
export { parseOpenCodeVersion, resolveOpenCodeVersion } from './opencode-version.mjs'
|
|
279
707
|
|
|
280
708
|
/**
|
|
@@ -301,15 +729,14 @@ export function isGlobalConfigDir(target) {
|
|
|
301
729
|
* package; the loader must NEVER be pointed at that in-package dir (a package
|
|
302
730
|
* reinstall/upgrade replaces the dir under a live registration and leaves
|
|
303
731
|
* diverging copies loading). Instead the installer always copies it into the
|
|
304
|
-
* config dir's plugins/ dir and
|
|
732
|
+
* config dir's plugins/ dir and uses THAT copy:
|
|
305
733
|
*
|
|
306
|
-
* <configDir>/plugins/pantheon-tui (dist/ + package
|
|
734
|
+
* <configDir>/plugins/pantheon-tui (tui.ts + dist/ + package manifests)
|
|
307
735
|
*
|
|
308
|
-
*
|
|
309
|
-
*
|
|
310
|
-
* exports["./server"] → dist/server.js (no-op server() stub)
|
|
736
|
+
* V1 registers that copy through tui.json. V2 discovers the local plugin's
|
|
737
|
+
* index.ts + tui.ts pair; the V2 shim re-exports dist/tui-v2.js.
|
|
311
738
|
*
|
|
312
|
-
* @param {string} configDir -
|
|
739
|
+
* @param {string} configDir - OpenCode config directory (target for global
|
|
313
740
|
* installs, <target>/.opencode for project installs)
|
|
314
741
|
* @returns {string} absolute path of the copied plugin directory
|
|
315
742
|
*/
|
|
@@ -335,42 +762,41 @@ export function tuiConfigLocations(target) {
|
|
|
335
762
|
}
|
|
336
763
|
|
|
337
764
|
/**
|
|
338
|
-
* Sync the Pantheon TUI plugin
|
|
339
|
-
*
|
|
765
|
+
* Sync the Pantheon TUI plugin payload and V1 registration. V2 discovers the
|
|
766
|
+
* copied local-plugin entrypoints and does not use a project tui.json.
|
|
340
767
|
*
|
|
341
768
|
* 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.
|
|
769
|
+
* 1. ALWAYS copy src/plugins/tui → <configDir>/plugins/pantheon-tui with the
|
|
770
|
+
* selected host entrypoints and compiled bundle. The copy is idempotent.
|
|
347
771
|
* 2. Clean stale Pantheon TUI refs from ALL tui.json locations OpenCode
|
|
348
772
|
* loads — ~/.opencode, $XDG_CONFIG_HOME/opencode and
|
|
349
773
|
* <target>/.opencode — covering every old reference shape: dist file
|
|
350
774
|
* paths, in-package src/plugins/tui dirs, bare plugins/pantheon-tui
|
|
351
775
|
* copies, and npx paths. A stale ref left in the non-target location
|
|
352
776
|
* would still be loaded and break the TUI.
|
|
353
|
-
* 3.
|
|
354
|
-
*
|
|
777
|
+
* 3. V1 registers the copied directory in the selected target. V2 relies on
|
|
778
|
+
* local-plugin discovery and does not write a project tui.json entry.
|
|
355
779
|
*
|
|
356
780
|
* @param {string} target - install target directory
|
|
357
781
|
* @param {object} [opts]
|
|
358
782
|
* @param {boolean} [opts.isGlobal=false] - flat global layout vs .opencode/
|
|
359
783
|
* @param {boolean} [opts.dryRun=false]
|
|
784
|
+
* @param {(change: object) => void} [opts.recorder] - optional change recorder for preview
|
|
360
785
|
* @returns {'created'|'skipped'} status for the target registration
|
|
361
786
|
*/
|
|
362
|
-
export function syncTuiRegistration(
|
|
787
|
+
export function syncTuiRegistration(
|
|
788
|
+
target,
|
|
789
|
+
{ isGlobal = false, dryRun = false, version = 'v1', recorder = null } = {},
|
|
790
|
+
) {
|
|
363
791
|
const configDir = isGlobal ? target : join(target, '.opencode')
|
|
364
792
|
const targetTuiConfigPath = join(configDir, 'tui.json')
|
|
365
793
|
|
|
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.
|
|
794
|
+
// 1. Copy the host-specific plugin into the target config's
|
|
795
|
+
// plugins/pantheon-tui. This keeps the installed entry hermetic.
|
|
370
796
|
const tuiSrcDir = join(ROOT, 'src', 'plugins', 'tui')
|
|
371
797
|
const tuiCopyDir = resolveTuiCopyTarget(configDir)
|
|
372
798
|
if (existsSync(tuiSrcDir)) {
|
|
373
|
-
copyPluginFiles(tuiSrcDir, tuiCopyDir, { dryRun })
|
|
799
|
+
copyPluginFiles(tuiSrcDir, tuiCopyDir, { dryRun, version, recorder })
|
|
374
800
|
}
|
|
375
801
|
|
|
376
802
|
// 2. Clean stale Pantheon TUI refs from every location OpenCode reads.
|
|
@@ -383,11 +809,17 @@ export function syncTuiRegistration(target, { isGlobal = false, dryRun = false }
|
|
|
383
809
|
unregisterPlugin(tuiConfigPath, 'plugins/pantheon-tui', {
|
|
384
810
|
dryRun,
|
|
385
811
|
knownPantheonPaths: [ownedCopy, tuiCopyDir],
|
|
812
|
+
recorder,
|
|
386
813
|
})
|
|
387
814
|
}
|
|
388
815
|
|
|
816
|
+
// OpenCode V2 discovers local plugins from the config plugins directories;
|
|
817
|
+
// its TUI plugin API is exposed by the sibling tui.ts entrypoint. V1 still
|
|
818
|
+
// requires the explicit tui.json registration.
|
|
819
|
+
if (version === 'v2') return 'skipped'
|
|
820
|
+
|
|
389
821
|
// 3. Register the copied directory in the selected target only.
|
|
390
|
-
return registerPlugin(targetTuiConfigPath, tuiCopyDir, { dryRun })
|
|
822
|
+
return registerPlugin(targetTuiConfigPath, tuiCopyDir, { dryRun, recorder })
|
|
391
823
|
}
|
|
392
824
|
|
|
393
825
|
/**
|
|
@@ -396,13 +828,18 @@ export function syncTuiRegistration(target, { isGlobal = false, dryRun = false }
|
|
|
396
828
|
* MCP entries from the config (entries pointing at a missing venv would
|
|
397
829
|
* make every MCP fail to launch). `deps` is injectable for tests.
|
|
398
830
|
*/
|
|
399
|
-
export function setupRuntimePhase(
|
|
831
|
+
export function setupRuntimePhase(
|
|
832
|
+
target,
|
|
833
|
+
{ dryRun, clean, isGlobal, recorder = null },
|
|
834
|
+
stats,
|
|
835
|
+
deps = {},
|
|
836
|
+
) {
|
|
400
837
|
const S = strings()
|
|
401
838
|
const setup = deps.setupVenv ?? setupVenv
|
|
402
839
|
const check = deps.healthCheck ?? healthCheck
|
|
403
840
|
try {
|
|
404
841
|
const venvSpinner = spinner(S.settingUpRuntime)
|
|
405
|
-
setup(target, { dryRun, force: clean })
|
|
842
|
+
setup(target, { dryRun, force: clean, recorder })
|
|
406
843
|
venvSpinner(true)
|
|
407
844
|
const runtimeTarget = isGlobal ? target : join(target, '.opencode')
|
|
408
845
|
const health = check(runtimeTarget, { dryRun, pythonTarget: target })
|
|
@@ -429,12 +866,206 @@ export function setupRuntimePhase(target, { dryRun, clean, isGlobal }, stats, de
|
|
|
429
866
|
}
|
|
430
867
|
}
|
|
431
868
|
|
|
869
|
+
/**
|
|
870
|
+
* Write the five managed MCP server entries into `config`, healing any stale
|
|
871
|
+
* entrypoint paths first.
|
|
872
|
+
*
|
|
873
|
+
* The heal runs on EVERY install, not only when an entry is missing: the
|
|
874
|
+
* per-server guards below are add-only, so an existing install whose
|
|
875
|
+
* `opencode.json` still carries a pre-rename `*_server.py` path would never be
|
|
876
|
+
* repaired. Healing first means a re-run of the installer fixes an orphaned
|
|
877
|
+
* config in place — the migration that makes the rename non-breaking.
|
|
878
|
+
*
|
|
879
|
+
* @param {object} config - full config object (mutated in place)
|
|
880
|
+
* @param {{ runtimeTarget: string, venvPython: string }} ctx
|
|
881
|
+
* @returns {{ healed: number }} number of stale path strings rewritten
|
|
882
|
+
*/
|
|
883
|
+
export function applyMcpServerEntries(config, { runtimeTarget, venvPython }) {
|
|
884
|
+
config.mcp = config.mcp || {}
|
|
885
|
+
// MCP stays a flat named-server map for BOTH V1 and V2: OpenCode 1.18.x has
|
|
886
|
+
// no `mcp.servers` wrapper and rejects one with "Missing key
|
|
887
|
+
// mcp.servers.enabled". config-migration.mjs normalizes any legacy
|
|
888
|
+
// wrapper-shaped entries back to the flat map.
|
|
889
|
+
const healed = migrateMcpEntrypointPaths(config.mcp)
|
|
890
|
+
|
|
891
|
+
// Only add if not already configured by user
|
|
892
|
+
if (!config.mcp['pantheon-resources']) {
|
|
893
|
+
config.mcp['pantheon-resources'] = {
|
|
894
|
+
type: 'local',
|
|
895
|
+
cwd: runtimeTarget,
|
|
896
|
+
command: [venvPython, 'scripts/mcp_resources.py'],
|
|
897
|
+
enabled: true,
|
|
898
|
+
}
|
|
899
|
+
}
|
|
900
|
+
if (!config.mcp['pantheon-code-mode']) {
|
|
901
|
+
config.mcp['pantheon-code-mode'] = {
|
|
902
|
+
type: 'local',
|
|
903
|
+
cwd: runtimeTarget,
|
|
904
|
+
command: [venvPython, 'scripts/code_mode.py'],
|
|
905
|
+
enabled: true,
|
|
906
|
+
}
|
|
907
|
+
}
|
|
908
|
+
if (!config.mcp['pantheon-memory']) {
|
|
909
|
+
config.mcp['pantheon-memory'] = {
|
|
910
|
+
type: 'local',
|
|
911
|
+
cwd: runtimeTarget,
|
|
912
|
+
command: [venvPython, 'scripts/memory_mcp.py'],
|
|
913
|
+
enabled: true,
|
|
914
|
+
}
|
|
915
|
+
}
|
|
916
|
+
if (!config.mcp['pantheon-persistence']) {
|
|
917
|
+
config.mcp['pantheon-persistence'] = {
|
|
918
|
+
type: 'local',
|
|
919
|
+
cwd: runtimeTarget,
|
|
920
|
+
command: [venvPython, 'scripts/mcp_persistence.py'],
|
|
921
|
+
enabled: true,
|
|
922
|
+
}
|
|
923
|
+
}
|
|
924
|
+
if (!config.mcp['pantheon-vision']) {
|
|
925
|
+
config.mcp['pantheon-vision'] = {
|
|
926
|
+
type: 'local',
|
|
927
|
+
cwd: runtimeTarget,
|
|
928
|
+
command: [venvPython, 'scripts/pantheon_vision.py'],
|
|
929
|
+
enabled: true,
|
|
930
|
+
}
|
|
931
|
+
}
|
|
932
|
+
|
|
933
|
+
// Default MCP permissions
|
|
934
|
+
config.permission = config.permission || {}
|
|
935
|
+
config.permission.mcp = config.permission.mcp || {}
|
|
936
|
+
if (!config.permission.mcp['pantheon-resources']) {
|
|
937
|
+
config.permission.mcp['pantheon-resources'] = 'allow'
|
|
938
|
+
}
|
|
939
|
+
if (!config.permission.mcp['pantheon-code-mode']) {
|
|
940
|
+
config.permission.mcp['pantheon-code-mode'] = 'ask'
|
|
941
|
+
}
|
|
942
|
+
if (!config.permission.mcp['pantheon-memory']) {
|
|
943
|
+
config.permission.mcp['pantheon-memory'] = 'allow'
|
|
944
|
+
}
|
|
945
|
+
if (!config.permission.mcp['pantheon-persistence']) {
|
|
946
|
+
config.permission.mcp['pantheon-persistence'] = 'allow'
|
|
947
|
+
}
|
|
948
|
+
if (!config.permission.mcp['pantheon-vision']) {
|
|
949
|
+
config.permission.mcp['pantheon-vision'] = 'ask'
|
|
950
|
+
}
|
|
951
|
+
|
|
952
|
+
return { healed }
|
|
953
|
+
}
|
|
954
|
+
|
|
955
|
+
/**
|
|
956
|
+
* Phase 4 drift detection: the planned targets whose on-disk state changed
|
|
957
|
+
* between the plan pass (snapshots) and the apply pass. A path counts as drifted
|
|
958
|
+
* when its existence, content hash, size, or mode differs — which includes a
|
|
959
|
+
* planned-NEW path that now exists. Returns the absolute paths (empty = no
|
|
960
|
+
* drift); callers print only these paths, never contents or hashes.
|
|
961
|
+
*/
|
|
962
|
+
function detectSnapshotDrift(snapshots) {
|
|
963
|
+
if (!Array.isArray(snapshots)) return []
|
|
964
|
+
const changed = []
|
|
965
|
+
for (const before of snapshots) {
|
|
966
|
+
const now = snapshot(before.path)
|
|
967
|
+
if (
|
|
968
|
+
now.exists !== before.exists ||
|
|
969
|
+
now.sha256 !== before.sha256 ||
|
|
970
|
+
now.size !== before.size ||
|
|
971
|
+
now.mode !== before.mode
|
|
972
|
+
) {
|
|
973
|
+
changed.push(before.path)
|
|
974
|
+
}
|
|
975
|
+
}
|
|
976
|
+
return changed
|
|
977
|
+
}
|
|
978
|
+
|
|
979
|
+
/**
|
|
980
|
+
* Remove lingering atomic-write temp siblings (`<file>.tmp-<pid>`) left behind
|
|
981
|
+
* when a writer aborted mid-write. Best-effort; skips the bulk dirs so a failure
|
|
982
|
+
* path stays cheap.
|
|
983
|
+
*/
|
|
984
|
+
/**
|
|
985
|
+
* Remove lingering atomic-write temp siblings (`<file>.tmp-<pid>`) left when a
|
|
986
|
+
* writer aborted mid-write. Only files matching the installer's own temp
|
|
987
|
+
* pattern are removed, and the bulk/foreign dirs are skipped so a failure path
|
|
988
|
+
* stays cheap and never touches unrelated files.
|
|
989
|
+
*/
|
|
990
|
+
function cleanupTempSiblings(root) {
|
|
991
|
+
if (!root) return
|
|
992
|
+
const suffix = `.tmp-${process.pid}`
|
|
993
|
+
const skip = new Set(['.git', '.pantheon', 'node_modules', '.venv'])
|
|
994
|
+
const walk = (dir) => {
|
|
995
|
+
let entries
|
|
996
|
+
try {
|
|
997
|
+
entries = readdirSync(dir, { withFileTypes: true })
|
|
998
|
+
} catch {
|
|
999
|
+
return
|
|
1000
|
+
}
|
|
1001
|
+
for (const entry of entries) {
|
|
1002
|
+
if (entry.isDirectory()) {
|
|
1003
|
+
if (skip.has(entry.name)) continue
|
|
1004
|
+
walk(join(dir, entry.name))
|
|
1005
|
+
} else if (entry.isFile() && entry.name.endsWith(suffix)) {
|
|
1006
|
+
try {
|
|
1007
|
+
rmSync(join(dir, entry.name), { force: true })
|
|
1008
|
+
} catch {
|
|
1009
|
+
// best-effort cleanup
|
|
1010
|
+
}
|
|
1011
|
+
}
|
|
1012
|
+
}
|
|
1013
|
+
}
|
|
1014
|
+
walk(root)
|
|
1015
|
+
}
|
|
1016
|
+
|
|
1017
|
+
/**
|
|
1018
|
+
* Phase 5 failure semantics. `installPass` is the whole installer; this thin
|
|
1019
|
+
* wrapper reports a mid-apply failure HONESTLY: it aborts the remaining writes
|
|
1020
|
+
* (the throw), cleans up any lingering atomic-write temp siblings, reports the
|
|
1021
|
+
* PATHS already applied (never contents), prints the recovery path, and
|
|
1022
|
+
* re-throws so the CLI exits non-zero. There is NO automatic rollback — the
|
|
1023
|
+
* exact backup set + `pantheon-opencode restore` is the recovery.
|
|
1024
|
+
*
|
|
1025
|
+
* Progress is tracked in `_lastRun` (set only by a non-dry pass, so the nested
|
|
1026
|
+
* dry plan pass never clobbers it). The RESOLVED target is read back from
|
|
1027
|
+
* `_lastRun.target` so the report and the sweep work when the caller passed
|
|
1028
|
+
* `undefined` (the default global install resolves to `~/.config/opencode`).
|
|
1029
|
+
*/
|
|
1030
|
+
let _lastRun = null
|
|
1031
|
+
|
|
432
1032
|
export async function installOpenCode(
|
|
433
1033
|
target,
|
|
434
1034
|
dryRun = false,
|
|
435
1035
|
clean = false,
|
|
436
1036
|
components = ['agents', 'skills', 'instructions', 'commands', 'plugins', 'runtime'],
|
|
437
1037
|
opts = {},
|
|
1038
|
+
) {
|
|
1039
|
+
// Reset per-call so a stale run can never leak into a fresh failure report.
|
|
1040
|
+
if (!dryRun) _lastRun = null
|
|
1041
|
+
try {
|
|
1042
|
+
return await installPass(target, dryRun, clean, components, opts)
|
|
1043
|
+
} catch (err) {
|
|
1044
|
+
const run = dryRun ? null : _lastRun
|
|
1045
|
+
if (run) {
|
|
1046
|
+
const S = strings()
|
|
1047
|
+
const paths = summarizeChanges(run.entries ?? [])
|
|
1048
|
+
.files.filter((f) => f.action !== ACTIONS.UNCHANGED && f.action !== ACTIONS.CONFLICT)
|
|
1049
|
+
.map((f) => displayPath(run.target, f.path))
|
|
1050
|
+
error(S.applyFailed(err instanceof Error ? err.message : String(err)))
|
|
1051
|
+
if (paths.length > 0) {
|
|
1052
|
+
warning(S.alreadyApplied)
|
|
1053
|
+
for (const p of paths) warning(` ${p}`)
|
|
1054
|
+
}
|
|
1055
|
+
warning(run.backupDir ? S.restoreRecoveryHint(run.backupDir) : S.restoreNoBackup)
|
|
1056
|
+
// Sweep only on the failure path (never a full walk on a clean apply).
|
|
1057
|
+
cleanupTempSiblings(run.target)
|
|
1058
|
+
}
|
|
1059
|
+
throw err
|
|
1060
|
+
}
|
|
1061
|
+
}
|
|
1062
|
+
|
|
1063
|
+
async function installPass(
|
|
1064
|
+
target,
|
|
1065
|
+
dryRun = false,
|
|
1066
|
+
clean = false,
|
|
1067
|
+
components = ['agents', 'skills', 'instructions', 'commands', 'plugins', 'runtime'],
|
|
1068
|
+
opts = {},
|
|
438
1069
|
) {
|
|
439
1070
|
// Default target if not provided (global ~/.config/opencode)
|
|
440
1071
|
if (!target) {
|
|
@@ -443,19 +1074,69 @@ export async function installOpenCode(
|
|
|
443
1074
|
|
|
444
1075
|
const componentSet = new Set(components)
|
|
445
1076
|
const stats = summary.opencode
|
|
1077
|
+
// Per-call baseline. `stats` is the module-level cumulative summary, so the
|
|
1078
|
+
// consistency guard must compare against THIS call's delta, not the total.
|
|
1079
|
+
const statsBefore = { created: stats.created, skipped: stats.skipped }
|
|
1080
|
+
// Number of planned changes, filled by a dry plan pass (used by the gate).
|
|
1081
|
+
let plannedChanges = 0
|
|
1082
|
+
// Phase 4: planned-target snapshots from the plan pass, used for drift checks.
|
|
1083
|
+
let plannedSnapshots = null
|
|
446
1084
|
const S = strings()
|
|
447
1085
|
|
|
1086
|
+
// Beta 11 (Setup phase 1): a dry pass records every writer's intended change
|
|
1087
|
+
// so we can print the REAL per-file plan without writing anything. A recorder
|
|
1088
|
+
// ALWAYS runs now: a dry pass builds the preview/plan from it, and an apply
|
|
1089
|
+
// pass uses it to report which PATHS were already written if a writer throws
|
|
1090
|
+
// (Phase 5 failure semantics). The default is the module-level no-op recorder,
|
|
1091
|
+
// so an uninstrumented caller stays byte-identical.
|
|
1092
|
+
const ownRecorder = opts.recorder ? null : createRecorder()
|
|
1093
|
+
const preview = dryRun && ownRecorder ? ownRecorder : null
|
|
1094
|
+
let recorder = opts.recorder ?? ownRecorder.recorder
|
|
1095
|
+
// Test-only failure seam: throw after N recorded writes so the failure path is
|
|
1096
|
+
// deterministic. Never reachable from production callers (opts.deps is not a
|
|
1097
|
+
// CLI flag) and never applied to the internal dry plan pass.
|
|
1098
|
+
if (!dryRun && typeof opts.deps?.failAfter === 'number') {
|
|
1099
|
+
const base = recorder
|
|
1100
|
+
const limit = opts.deps.failAfter
|
|
1101
|
+
let applied = 0
|
|
1102
|
+
recorder = (change) => {
|
|
1103
|
+
base(change)
|
|
1104
|
+
if (++applied >= limit) throw new Error('injected failure (test-only failAfter seam)')
|
|
1105
|
+
}
|
|
1106
|
+
}
|
|
1107
|
+
// Track this apply so the wrapper can report the paths written on a failure.
|
|
1108
|
+
if (!dryRun) _lastRun = { target, entries: ownRecorder?.entries ?? [], backupDir: null }
|
|
1109
|
+
|
|
448
1110
|
// V1/V2 dual-version awareness (Phase 3): the installer builds a unified
|
|
449
1111
|
// config, then applies V2-native migration (migrateV1toV2) when
|
|
450
1112
|
// --opencode-version=v2. V2 normalizes V1 fields in memory (no rewrite) and
|
|
451
1113
|
// isolates its state DB via OPENCODE_DB (~/.local/share/opencode/opencode-v2.db)
|
|
452
1114
|
// with a distinct service port (49375 vs V1's 49374).
|
|
453
|
-
|
|
1115
|
+
// Default to 'auto' so a flagless run resolves against the actual host. A
|
|
1116
|
+
// hardcoded 'v1' here (or in bin/pantheon-init.mjs) is what shipped the bug:
|
|
1117
|
+
// the V1 branch writes .ts file paths that a 2.x host drops from the config.
|
|
1118
|
+
// `opts` is forwarded as the resolver's options bag so a caller (and CI) can
|
|
1119
|
+
// pin env/probe instead of spawning a real host binary.
|
|
1120
|
+
const version = resolveOpenCodeVersion(opts.version ?? 'auto', opts)
|
|
454
1121
|
if (version === 'v2') {
|
|
455
1122
|
info(
|
|
456
1123
|
'OpenCode V2 target — shared config; state isolated via OPENCODE_DB ' +
|
|
457
1124
|
'(~/.local/share/opencode/opencode-v2.db), service port 49375',
|
|
458
1125
|
)
|
|
1126
|
+
// Say what the V2 contract does NOT give you, at the moment the user picks
|
|
1127
|
+
// it. The V2 surface is 3 tools (hashline_edit, pantheon_cost,
|
|
1128
|
+
// pantheon_model); V1's is 6 plus the BackgroundJobBoard, the three goal
|
|
1129
|
+
// tools and the caller/target delegation matrix. This list was previously
|
|
1130
|
+
// reachable only through `getUnsupportedFeatures()`, which no production
|
|
1131
|
+
// code called — a user migrating hit the reduction with no warning at all.
|
|
1132
|
+
//
|
|
1133
|
+
// The markers are read from the same seed `getUnsupportedFeatures()` is
|
|
1134
|
+
// built from (src/pantheon/v2-unsupported.mjs), so this notice cannot drift
|
|
1135
|
+
// from what the plugin reports. Advisory: a V2 install is a supported
|
|
1136
|
+
// configuration, not a failure.
|
|
1137
|
+
info(
|
|
1138
|
+
`V2 plugin generation — reduced surface. Unsupported features: ${listV2UnsupportedFeatures().join(', ')}`,
|
|
1139
|
+
)
|
|
459
1140
|
}
|
|
460
1141
|
|
|
461
1142
|
// Interactive mode handling
|
|
@@ -474,7 +1155,7 @@ export async function installOpenCode(
|
|
|
474
1155
|
|
|
475
1156
|
if (!result.confirmed && !autoYes) {
|
|
476
1157
|
info('Installation canceled.')
|
|
477
|
-
return stats
|
|
1158
|
+
return { ...stats, applied: false, reason: 'canceled' }
|
|
478
1159
|
}
|
|
479
1160
|
|
|
480
1161
|
// Map target selection to actual path
|
|
@@ -487,10 +1168,24 @@ export async function installOpenCode(
|
|
|
487
1168
|
}
|
|
488
1169
|
}
|
|
489
1170
|
|
|
490
|
-
|
|
1171
|
+
// Product defaults live in code (PRODUCT_CONFIG_DEFAULTS). A repository-root
|
|
1172
|
+
// opencode.json is a contributor's PERSONAL dev harness, not product data, so
|
|
1173
|
+
// it must never be read just because it happens to exist: doing so silently
|
|
1174
|
+
// injects the developer's provider/plugins/compaction into every config an
|
|
1175
|
+
// install generates. The merge is therefore opt-in only, via
|
|
1176
|
+
// --merge-dev-config <path>. Absent the flag, no root-level config is read.
|
|
1177
|
+
const mergeDevConfigOpt = opts.mergeDevConfig ?? null
|
|
1178
|
+
if (mergeDevConfigOpt !== null && typeof mergeDevConfigOpt !== 'string') {
|
|
1179
|
+
throw new Error('mergeDevConfig requires a file path string')
|
|
1180
|
+
}
|
|
1181
|
+
const pantheonConfigPath =
|
|
1182
|
+
typeof mergeDevConfigOpt === 'string' ? resolve(mergeDevConfigOpt) : null
|
|
491
1183
|
const targetConfigPath = join(target, 'opencode.json')
|
|
492
|
-
const config =
|
|
493
|
-
const pantheonConfig =
|
|
1184
|
+
const { config, raw: configRaw } = readJsonConfigWithRaw(targetConfigPath)
|
|
1185
|
+
const pantheonConfig =
|
|
1186
|
+
pantheonConfigPath !== null && existsSync(pantheonConfigPath)
|
|
1187
|
+
? readJsonConfig(pantheonConfigPath)
|
|
1188
|
+
: null
|
|
494
1189
|
|
|
495
1190
|
// beta.5 preflight: fail BEFORE any files are written when the runtime
|
|
496
1191
|
// component needs toolchain pieces that are missing. Nothing has been
|
|
@@ -503,16 +1198,116 @@ export async function installOpenCode(
|
|
|
503
1198
|
}
|
|
504
1199
|
}
|
|
505
1200
|
|
|
506
|
-
//
|
|
507
|
-
//
|
|
508
|
-
//
|
|
509
|
-
//
|
|
510
|
-
// .opencode/agents/ .opencode/skills/ .opencode/commands/
|
|
1201
|
+
// V1 project installs use the legacy .opencode/agents layout. OpenCode V2's
|
|
1202
|
+
// active config directory is the project root and its agent loader scans
|
|
1203
|
+
// <config-dir>/{agent,agents}/**, so V2 project agents must be at ./agents.
|
|
1204
|
+
// Global installs are flat for both versions.
|
|
511
1205
|
const isGlobal = isGlobalConfigDir(target)
|
|
512
1206
|
const _subDir = isGlobal ? '' : '.opencode'
|
|
513
|
-
const agentPrefix = isGlobal ? 'agents' : '.opencode/agents'
|
|
1207
|
+
const agentPrefix = isGlobal || version === 'v2' ? 'agents' : '.opencode/agents'
|
|
1208
|
+
|
|
1209
|
+
configure({ dryRun, quiet: opts.quiet === true })
|
|
1210
|
+
|
|
1211
|
+
// -----------------------------------------------------------------------
|
|
1212
|
+
// Plan pass + apply gate + drift check (Beta 11 Setup phases 3-4).
|
|
1213
|
+
// * the plan pass (a dry run — writes nothing) prints the plan and
|
|
1214
|
+
// snapshots every planned target;
|
|
1215
|
+
// * applying requires explicit authorization (Phase 3): a declined or
|
|
1216
|
+
// unauthorized run writes NOTHING;
|
|
1217
|
+
// * drift (Phase 4) aborts BEFORE any write if a planned target changed
|
|
1218
|
+
// between the plan pass and now.
|
|
1219
|
+
// A result with `applied: false` means the caller must not print success.
|
|
1220
|
+
// -----------------------------------------------------------------------
|
|
1221
|
+
const applyRequested = !dryRun
|
|
1222
|
+
if (applyRequested) {
|
|
1223
|
+
const savedStats = { ...stats }
|
|
1224
|
+
let planned
|
|
1225
|
+
try {
|
|
1226
|
+
// Use the LIVE selection: the wizard above may have changed componentSet.
|
|
1227
|
+
// An AUTHORIZED apply (`--yes`) is not a preview: compute the plan +
|
|
1228
|
+
// snapshots but stay silent. The gate path (no `--yes`) still prints the
|
|
1229
|
+
// plan for the review / decline preview.
|
|
1230
|
+
planned = await installOpenCode(target, true, clean, [...componentSet], {
|
|
1231
|
+
...opts,
|
|
1232
|
+
planOnly: true,
|
|
1233
|
+
quiet: autoYes,
|
|
1234
|
+
quietPlan: autoYes,
|
|
1235
|
+
})
|
|
1236
|
+
} finally {
|
|
1237
|
+
Object.assign(stats, savedStats)
|
|
1238
|
+
// The plan pass left the UI in dry-run mode; restore this run's mode.
|
|
1239
|
+
configure({ dryRun, quiet: opts.quiet === true })
|
|
1240
|
+
}
|
|
1241
|
+
plannedSnapshots = planned?.snapshots ?? null
|
|
1242
|
+
|
|
1243
|
+
if (!autoYes) {
|
|
1244
|
+
const injected = opts.deps?.promptReview
|
|
1245
|
+
const canPrompt = typeof injected === 'function' || interactive
|
|
1246
|
+
if (canPrompt) {
|
|
1247
|
+
const { promptReview } = await import('./interactive.mjs')
|
|
1248
|
+
const ask = typeof injected === 'function' ? injected : promptReview
|
|
1249
|
+
const accepted = await ask(`Apply these ${planned?.planned ?? 0} changes?`, {
|
|
1250
|
+
default: false,
|
|
1251
|
+
})
|
|
1252
|
+
if (!accepted) {
|
|
1253
|
+
info(S.nothingApplied)
|
|
1254
|
+
return { ...stats, applied: false, reason: 'declined' }
|
|
1255
|
+
}
|
|
1256
|
+
} else {
|
|
1257
|
+
info(S.planOnlyNoYes)
|
|
1258
|
+
return { ...stats, applied: false, reason: 'not-authorized' }
|
|
1259
|
+
}
|
|
1260
|
+
}
|
|
514
1261
|
|
|
515
|
-
|
|
1262
|
+
// Test seam: mutate a target between the plan/authorization and the drift
|
|
1263
|
+
// check. Absent in production callers.
|
|
1264
|
+
if (typeof opts.deps?.betweenPlanAndApply === 'function') opts.deps.betweenPlanAndApply()
|
|
1265
|
+
|
|
1266
|
+
const drifted = detectSnapshotDrift(plannedSnapshots)
|
|
1267
|
+
if (drifted.length > 0) {
|
|
1268
|
+
warning(S.driftHeader)
|
|
1269
|
+
for (const p of drifted) warning(` ${displayPath(target, p)}`)
|
|
1270
|
+
warning(S.driftAbort)
|
|
1271
|
+
return {
|
|
1272
|
+
...stats,
|
|
1273
|
+
applied: false,
|
|
1274
|
+
reason: 'drift',
|
|
1275
|
+
drift: drifted.map((p) => displayPath(target, p)),
|
|
1276
|
+
}
|
|
1277
|
+
}
|
|
1278
|
+
}
|
|
1279
|
+
|
|
1280
|
+
// -----------------------------------------------------------------------
|
|
1281
|
+
// 0. Exact backup (Beta 11 Setup phase 2): preserve the CURRENT state of
|
|
1282
|
+
// every regular file this run can modify/remove INSIDE the config dir,
|
|
1283
|
+
// byte-for-byte, BEFORE the first modifying write. Bulk directories and
|
|
1284
|
+
// out-of-target global files are recorded in the manifest as
|
|
1285
|
+
// `notRevertible`. A dry run records the planned backup-manifest path only.
|
|
1286
|
+
// -----------------------------------------------------------------------
|
|
1287
|
+
const backupManifestPath = join(
|
|
1288
|
+
backupSetPath(target, nextBackupName(target, new Date())),
|
|
1289
|
+
'manifest.json',
|
|
1290
|
+
)
|
|
1291
|
+
if (dryRun) {
|
|
1292
|
+
if (isRecording(recorder)) {
|
|
1293
|
+
recordChange(
|
|
1294
|
+
{ path: backupManifestPath, action: ACTIONS.CREATE, bytes: 0, scored: false },
|
|
1295
|
+
recorder,
|
|
1296
|
+
)
|
|
1297
|
+
}
|
|
1298
|
+
} else {
|
|
1299
|
+
const backupPlan = collectInstallBackupEntries(target, componentSet, version, isGlobal)
|
|
1300
|
+
const pkgVersion = JSON.parse(readFileSync(join(ROOT, 'package.json'), 'utf8')).version
|
|
1301
|
+
const { dir: backupDir } = createBackupSet({
|
|
1302
|
+
configDir: target,
|
|
1303
|
+
entries: backupPlan.entries,
|
|
1304
|
+
notRevertible: backupPlan.notRevertible,
|
|
1305
|
+
packageVersion: pkgVersion,
|
|
1306
|
+
})
|
|
1307
|
+
if (_lastRun && _lastRun.target === target) _lastRun.backupDir = backupDir
|
|
1308
|
+
// Secret-safe: the path only. The exact wording is fixed (once per apply).
|
|
1309
|
+
console.warn(backupWarning(backupDir))
|
|
1310
|
+
}
|
|
516
1311
|
|
|
517
1312
|
if (isGlobal) {
|
|
518
1313
|
info(S.globalLayout)
|
|
@@ -529,7 +1324,7 @@ export async function installOpenCode(
|
|
|
529
1324
|
stats.errors++
|
|
530
1325
|
} else {
|
|
531
1326
|
const agentStep = step(S.installingAgents)
|
|
532
|
-
const dstDir =
|
|
1327
|
+
const dstDir = join(target, agentPrefix)
|
|
533
1328
|
if (!dryRun) mkdirSync(dstDir, { recursive: true })
|
|
534
1329
|
if (clean && existsSync(dstDir) && !dryRun) {
|
|
535
1330
|
const existing = readdirSync(dstDir)
|
|
@@ -537,7 +1332,7 @@ export async function installOpenCode(
|
|
|
537
1332
|
rmSync(join(dstDir, f), { recursive: true, force: true })
|
|
538
1333
|
}
|
|
539
1334
|
}
|
|
540
|
-
const { created, skipped } =
|
|
1335
|
+
const { created, skipped } = copyAgentPrompts(srcDir, dstDir, dryRun, false, recorder)
|
|
541
1336
|
stats.created += created
|
|
542
1337
|
stats.skipped += skipped
|
|
543
1338
|
agentStep(true)
|
|
@@ -551,7 +1346,9 @@ export async function installOpenCode(
|
|
|
551
1346
|
const srcRouting = join(ROOT, 'src', 'routing.yml')
|
|
552
1347
|
const dstRouting = join(target, 'routing.yml')
|
|
553
1348
|
if (existsSync(srcRouting)) {
|
|
554
|
-
const routingStatus = writeIfChanged(dstRouting, readFileSync(srcRouting, 'utf8'), dryRun
|
|
1349
|
+
const routingStatus = writeIfChanged(dstRouting, readFileSync(srcRouting, 'utf8'), dryRun, {
|
|
1350
|
+
recorder,
|
|
1351
|
+
})
|
|
555
1352
|
if (routingStatus === 'created') stats.created++
|
|
556
1353
|
else stats.skipped++
|
|
557
1354
|
}
|
|
@@ -578,6 +1375,7 @@ export async function installOpenCode(
|
|
|
578
1375
|
target,
|
|
579
1376
|
dryRun,
|
|
580
1377
|
installSubDir,
|
|
1378
|
+
recorder,
|
|
581
1379
|
)
|
|
582
1380
|
stats.created += sCreated
|
|
583
1381
|
stats.skipped += sSkipped
|
|
@@ -586,23 +1384,45 @@ export async function installOpenCode(
|
|
|
586
1384
|
}
|
|
587
1385
|
|
|
588
1386
|
// -----------------------------------------------------------------------
|
|
589
|
-
// 2.5 Install instructions: AGENTS.md
|
|
1387
|
+
// 2.5 Install shared instructions: AGENTS.md (--components instructions)
|
|
590
1388
|
// -----------------------------------------------------------------------
|
|
591
|
-
// AGENTS.md
|
|
592
|
-
//
|
|
593
|
-
//
|
|
594
|
-
//
|
|
595
|
-
//
|
|
1389
|
+
// Both AGENTS.md artifacts are managed regions (scripts/build-agents-md.mjs).
|
|
1390
|
+
// A GLOBAL config dir receives the repo-agnostic global artifact (agent
|
|
1391
|
+
// table + shared rules); a PROJECT layout keeps the repository artifact.
|
|
1392
|
+
// writeAgentsMdManaged is conservative: content outside the managed region
|
|
1393
|
+
// is never deleted, and a legacy fully-generated file upgrades in place.
|
|
596
1394
|
if (componentSet.has('instructions')) {
|
|
597
1395
|
section('\uD83D\uDCCB Instructions')
|
|
598
|
-
|
|
599
|
-
const srcAgentsMd = join(ROOT, 'AGENTS.md')
|
|
1396
|
+
const srcAgentsMd = isGlobal ? join(ROOT, 'AGENTS.global.md') : join(ROOT, 'AGENTS.md')
|
|
600
1397
|
const dstAgentsMd = join(target, 'AGENTS.md')
|
|
601
1398
|
if (existsSync(srcAgentsMd)) {
|
|
602
1399
|
const content = readFileSync(srcAgentsMd, 'utf8')
|
|
603
|
-
const status =
|
|
604
|
-
if (status === '
|
|
605
|
-
else
|
|
1400
|
+
const status = writeAgentsMdManaged(dstAgentsMd, content, dryRun, recorder)
|
|
1401
|
+
if (status === 'skipped') stats.skipped++
|
|
1402
|
+
else {
|
|
1403
|
+
stats.created++
|
|
1404
|
+
if (status === 'merged') {
|
|
1405
|
+
warning(
|
|
1406
|
+
`AGENTS.md had non-generated content — it was preserved around the ` +
|
|
1407
|
+
`Pantheon managed region in ${dstAgentsMd}. Review and merge manually.`,
|
|
1408
|
+
)
|
|
1409
|
+
}
|
|
1410
|
+
}
|
|
1411
|
+
} else {
|
|
1412
|
+
warning(`AGENTS.md artifact not found in the package: ${srcAgentsMd}`)
|
|
1413
|
+
stats.errors++
|
|
1414
|
+
}
|
|
1415
|
+
// An instruction-only upgrade must also refresh selective guidance on
|
|
1416
|
+
// already-installed Pantheon agents, but must not create agents the user
|
|
1417
|
+
// did not install. Full agent installs do this in phase 1 above.
|
|
1418
|
+
if (!componentSet.has('agents')) {
|
|
1419
|
+
const installedAgents = join(target, agentPrefix)
|
|
1420
|
+
const sourceAgents = join(ROOT, 'src', 'agents')
|
|
1421
|
+
if (existsSync(installedAgents) && existsSync(sourceAgents)) {
|
|
1422
|
+
const refreshed = copyAgentPrompts(sourceAgents, installedAgents, dryRun, true, recorder)
|
|
1423
|
+
stats.created += refreshed.created
|
|
1424
|
+
stats.skipped += refreshed.skipped
|
|
1425
|
+
}
|
|
606
1426
|
}
|
|
607
1427
|
}
|
|
608
1428
|
|
|
@@ -614,7 +1434,7 @@ export async function installOpenCode(
|
|
|
614
1434
|
const srcPrompts = join(ROOT, 'prompts')
|
|
615
1435
|
const dstPrompts = join(target, 'prompts')
|
|
616
1436
|
if (existsSync(srcPrompts)) {
|
|
617
|
-
const promptsResult = syncDir(srcPrompts, dstPrompts, dryRun, clean)
|
|
1437
|
+
const promptsResult = syncDir(srcPrompts, dstPrompts, dryRun, clean, null, recorder)
|
|
618
1438
|
stats.created += promptsResult.created
|
|
619
1439
|
stats.skipped += promptsResult.skipped
|
|
620
1440
|
}
|
|
@@ -626,9 +1446,13 @@ export async function installOpenCode(
|
|
|
626
1446
|
if (componentSet.has('commands')) {
|
|
627
1447
|
section('\u26A1 Commands')
|
|
628
1448
|
const srcCmds = join(ROOT, 'commands')
|
|
629
|
-
|
|
1449
|
+
// V2 project sandboxes use OPENCODE_CONFIG_DIR=<project root>, so OpenCode
|
|
1450
|
+
// discovers custom commands from <target>/commands. V1 project installs
|
|
1451
|
+
// keep the standard .opencode/commands layout; global installs are flat.
|
|
1452
|
+
const commandDir = isGlobal || version === 'v2' ? 'commands' : '.opencode/commands'
|
|
1453
|
+
const dstCmds = join(target, commandDir)
|
|
630
1454
|
if (existsSync(srcCmds)) {
|
|
631
|
-
const cmdResult = syncDir(srcCmds, dstCmds, dryRun, clean, (f) => f.endsWith('.md'))
|
|
1455
|
+
const cmdResult = syncDir(srcCmds, dstCmds, dryRun, clean, (f) => f.endsWith('.md'), recorder)
|
|
632
1456
|
stats.created += cmdResult.created
|
|
633
1457
|
stats.skipped += cmdResult.skipped
|
|
634
1458
|
}
|
|
@@ -651,7 +1475,12 @@ export async function installOpenCode(
|
|
|
651
1475
|
const dstPluginDir = isGlobal
|
|
652
1476
|
? join(target, 'plugins', 'pantheon-tui')
|
|
653
1477
|
: join(target, '.opencode', 'plugins', 'pantheon-tui')
|
|
654
|
-
const pluginStats = installPlugin(srcPluginDir, dstPluginDir, {
|
|
1478
|
+
const pluginStats = installPlugin(srcPluginDir, dstPluginDir, {
|
|
1479
|
+
dryRun,
|
|
1480
|
+
clean,
|
|
1481
|
+
version,
|
|
1482
|
+
recorder,
|
|
1483
|
+
})
|
|
655
1484
|
stats.created += pluginStats.created
|
|
656
1485
|
stats.skipped += pluginStats.skipped
|
|
657
1486
|
stats.errors += pluginStats.errors
|
|
@@ -663,7 +1492,7 @@ export async function installOpenCode(
|
|
|
663
1492
|
if (componentSet.has('plugins')) {
|
|
664
1493
|
let tuiStatus
|
|
665
1494
|
try {
|
|
666
|
-
tuiStatus = syncTuiRegistration(target, { isGlobal, dryRun })
|
|
1495
|
+
tuiStatus = syncTuiRegistration(target, { isGlobal, dryRun, version, recorder })
|
|
667
1496
|
} catch (cause) {
|
|
668
1497
|
error(`TUI plugin setup failed: ${cause.message}`)
|
|
669
1498
|
stats.errors++
|
|
@@ -682,31 +1511,21 @@ export async function installOpenCode(
|
|
|
682
1511
|
const runtimeTarget = isGlobal ? target : join(target, '.opencode')
|
|
683
1512
|
|
|
684
1513
|
// ── MCP server scripts ──
|
|
685
|
-
const mcpScripts =
|
|
686
|
-
'mcp_resources_server.py',
|
|
687
|
-
'code_mode_server.py',
|
|
688
|
-
'memory_mcp_server.py',
|
|
689
|
-
'scrub-secrets.py',
|
|
690
|
-
'_pantheon_paths.py',
|
|
691
|
-
'mcp_codemap_module.py',
|
|
692
|
-
'mcp_persistence_server.py',
|
|
693
|
-
'pantheon_vision_server.py',
|
|
694
|
-
'eval_store.py',
|
|
695
|
-
]
|
|
1514
|
+
const mcpScripts = MCP_SCRIPTS
|
|
696
1515
|
const srcScriptsDir = join(ROOT, 'scripts')
|
|
697
1516
|
// Canonical MCP server sources live in src/mcp/. Map them explicitly so the
|
|
698
1517
|
// sync always copies from the canonical source, never from the stale root
|
|
699
1518
|
// scripts/ copies (which previously propagated bugs like the missing
|
|
700
|
-
// `import uuid` in
|
|
1519
|
+
// `import uuid` in mcp_persistence.py). scrub-secrets.py is not in
|
|
701
1520
|
// src/mcp/ and intentionally falls back to join(ROOT, 'scripts').
|
|
702
1521
|
const canonicalMcpScripts = {
|
|
703
|
-
'
|
|
704
|
-
'
|
|
705
|
-
'
|
|
1522
|
+
'mcp_resources.py': join(ROOT, 'src', 'mcp', 'mcp_resources.py'),
|
|
1523
|
+
'code_mode.py': join(ROOT, 'src', 'mcp', 'code_mode.py'),
|
|
1524
|
+
'memory_mcp.py': join(ROOT, 'src', 'mcp', 'memory_mcp.py'),
|
|
706
1525
|
'_pantheon_paths.py': join(ROOT, 'src', 'mcp', '_pantheon_paths.py'),
|
|
707
1526
|
'mcp_codemap_module.py': join(ROOT, 'src', 'mcp', 'mcp_codemap_module.py'),
|
|
708
|
-
'
|
|
709
|
-
'
|
|
1527
|
+
'mcp_persistence.py': join(ROOT, 'src', 'mcp', 'mcp_persistence.py'),
|
|
1528
|
+
'pantheon_vision.py': join(ROOT, 'src', 'mcp', 'pantheon_vision.py'),
|
|
710
1529
|
'eval_store.py': join(ROOT, 'src', 'mcp', 'eval_store.py'),
|
|
711
1530
|
}
|
|
712
1531
|
const dstScriptsDir = join(runtimeTarget, 'scripts')
|
|
@@ -720,7 +1539,7 @@ export async function installOpenCode(
|
|
|
720
1539
|
}
|
|
721
1540
|
const content = readFileSync(src, 'utf8')
|
|
722
1541
|
const dst = join(dstScriptsDir, script)
|
|
723
|
-
const status = writeIfChanged(dst, content, dryRun)
|
|
1542
|
+
const status = writeIfChanged(dst, content, dryRun, { recorder })
|
|
724
1543
|
if (status === 'created') {
|
|
725
1544
|
stats.created++
|
|
726
1545
|
// Make executable
|
|
@@ -738,7 +1557,9 @@ export async function installOpenCode(
|
|
|
738
1557
|
const visionRequirements = join(ROOT, 'src', 'mcp', 'requirements-vision.txt')
|
|
739
1558
|
if (existsSync(visionRequirements)) {
|
|
740
1559
|
const destination = join(runtimeTarget, 'requirements-vision.txt')
|
|
741
|
-
const status = writeIfChanged(destination, readFileSync(visionRequirements, 'utf8'), dryRun
|
|
1560
|
+
const status = writeIfChanged(destination, readFileSync(visionRequirements, 'utf8'), dryRun, {
|
|
1561
|
+
recorder,
|
|
1562
|
+
})
|
|
742
1563
|
if (status === 'created') stats.created++
|
|
743
1564
|
else stats.skipped++
|
|
744
1565
|
} else {
|
|
@@ -750,7 +1571,17 @@ export async function installOpenCode(
|
|
|
750
1571
|
const srcCodeModeDir = join(ROOT, '.pantheon', 'code-mode')
|
|
751
1572
|
const dstCodeModeDir = join(runtimeTarget, '.pantheon', 'code-mode')
|
|
752
1573
|
if (existsSync(srcCodeModeDir)) {
|
|
753
|
-
|
|
1574
|
+
// manifest.json is owned by writeCodeModeManifest (below), not by the dir
|
|
1575
|
+
// sync. Excluding it here keeps exactly ONE writer/count for that path,
|
|
1576
|
+
// so the preview action and the install summary can never diverge.
|
|
1577
|
+
const cmResult = syncDir(
|
|
1578
|
+
srcCodeModeDir,
|
|
1579
|
+
dstCodeModeDir,
|
|
1580
|
+
dryRun,
|
|
1581
|
+
clean,
|
|
1582
|
+
(f) => f !== 'manifest.json',
|
|
1583
|
+
recorder,
|
|
1584
|
+
)
|
|
754
1585
|
stats.created += cmResult.created
|
|
755
1586
|
stats.skipped += cmResult.skipped
|
|
756
1587
|
} else {
|
|
@@ -761,13 +1592,18 @@ export async function installOpenCode(
|
|
|
761
1592
|
warning(`Code-mode source not found: ${srcCodeModeDir} — creating empty ${dstCodeModeDir}`)
|
|
762
1593
|
if (!dryRun) mkdirSync(dstCodeModeDir, { recursive: true })
|
|
763
1594
|
stats.created++
|
|
1595
|
+
// The empty runtime dir is a real create counted by `stats.created`, so
|
|
1596
|
+
// record it too (or the preview under-reports on this fallback path).
|
|
1597
|
+
recordChange({ path: dstCodeModeDir, action: ACTIONS.CREATE, bytes: 0 }, recorder)
|
|
764
1598
|
}
|
|
765
1599
|
|
|
766
1600
|
// B3-08: explicit installation mode — seed the manifest with SHA-256
|
|
767
1601
|
// digests so the server never executes a script implicitly.
|
|
768
|
-
const
|
|
769
|
-
if (
|
|
770
|
-
|
|
1602
|
+
const manifest = writeCodeModeManifest(srcCodeModeDir, dstCodeModeDir, dryRun, recorder)
|
|
1603
|
+
if (manifest.status === 'created') stats.created++
|
|
1604
|
+
else if (manifest.status === 'skipped') stats.skipped++
|
|
1605
|
+
if (manifest.approved > 0) {
|
|
1606
|
+
info(`Code-mode manifest: ${manifest.approved} script(s) approved`)
|
|
771
1607
|
}
|
|
772
1608
|
|
|
773
1609
|
// ── tiers.json ──
|
|
@@ -775,7 +1611,7 @@ export async function installOpenCode(
|
|
|
775
1611
|
const dstTiers = join(runtimeTarget, '.pantheon', 'tiers.json')
|
|
776
1612
|
if (existsSync(srcTiers)) {
|
|
777
1613
|
const content = readFileSync(srcTiers, 'utf8')
|
|
778
|
-
const status = writeIfChanged(dstTiers, content, dryRun)
|
|
1614
|
+
const status = writeIfChanged(dstTiers, content, dryRun, { recorder })
|
|
779
1615
|
if (status === 'created') stats.created++
|
|
780
1616
|
else stats.skipped++
|
|
781
1617
|
}
|
|
@@ -789,7 +1625,7 @@ export async function installOpenCode(
|
|
|
789
1625
|
// -----------------------------------------------------------------------
|
|
790
1626
|
let runtimeHealthy = !componentSet.has('runtime')
|
|
791
1627
|
if (componentSet.has('runtime')) {
|
|
792
|
-
runtimeHealthy = setupRuntimePhase(target, { dryRun, clean, isGlobal }, stats)
|
|
1628
|
+
runtimeHealthy = setupRuntimePhase(target, { dryRun, clean, isGlobal, recorder }, stats)
|
|
793
1629
|
}
|
|
794
1630
|
|
|
795
1631
|
// -----------------------------------------------------------------------
|
|
@@ -798,7 +1634,7 @@ export async function installOpenCode(
|
|
|
798
1634
|
// on top. Preserves user's MCP, provider, plugins, compaction, theme.
|
|
799
1635
|
// -----------------------------------------------------------------------
|
|
800
1636
|
// --------------------------------------------------------------------
|
|
801
|
-
// A. Parse canonical agent config from agents/*.
|
|
1637
|
+
// A. Parse canonical agent config from src/agents/*.md frontmatter
|
|
802
1638
|
// and merge into opencode.json config.
|
|
803
1639
|
// --------------------------------------------------------------------
|
|
804
1640
|
function getAgentSources(agentPrefix) {
|
|
@@ -836,7 +1672,6 @@ export async function installOpenCode(
|
|
|
836
1672
|
if (fm.mode) agent.mode = fm.mode
|
|
837
1673
|
if (fm.hidden) agent.hidden = fm.hidden
|
|
838
1674
|
if (fm.temperature !== undefined) agent.temperature = fm.temperature
|
|
839
|
-
if (fm.steps !== undefined) agent.steps = fm.steps
|
|
840
1675
|
// Support both hyphen (YAML) and underscore (JSON) key variants
|
|
841
1676
|
if (fm['disable-model-invocation'] !== undefined)
|
|
842
1677
|
agent.disable_model_invocation = fm['disable-model-invocation']
|
|
@@ -847,6 +1682,12 @@ export async function installOpenCode(
|
|
|
847
1682
|
if (fm.permission) {
|
|
848
1683
|
agent.permission = JSON.parse(JSON.stringify(fm.permission))
|
|
849
1684
|
}
|
|
1685
|
+
if (fm.mcp_tools !== undefined) {
|
|
1686
|
+
agent.permission = {
|
|
1687
|
+
...(agent.permission ?? {}),
|
|
1688
|
+
...deriveNativeMcpPermissions(fm.mcp_tools),
|
|
1689
|
+
}
|
|
1690
|
+
}
|
|
850
1691
|
|
|
851
1692
|
config[name] = agent
|
|
852
1693
|
}
|
|
@@ -855,15 +1696,6 @@ export async function installOpenCode(
|
|
|
855
1696
|
|
|
856
1697
|
const canonicalAgentConfig = readAgentConfigFromCanonical()
|
|
857
1698
|
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
1699
|
|
|
868
1700
|
if (config.agent === undefined) config.agent = {}
|
|
869
1701
|
|
|
@@ -922,8 +1754,11 @@ export async function installOpenCode(
|
|
|
922
1754
|
// --------------------------------------------------------------------
|
|
923
1755
|
// B.5 Ensure critical top-level OpenCode config sections
|
|
924
1756
|
// --------------------------------------------------------------------
|
|
925
|
-
|
|
926
|
-
|
|
1757
|
+
// Seeded from code, not from a packaged template: see
|
|
1758
|
+
// PRODUCT_CONFIG_DEFAULTS. Only fills a gap — a user who already chose a
|
|
1759
|
+
// default agent keeps it.
|
|
1760
|
+
if (config.default_agent === undefined) {
|
|
1761
|
+
config.default_agent = PRODUCT_CONFIG_DEFAULTS.default_agent
|
|
927
1762
|
}
|
|
928
1763
|
// Apply only explicit top-level model overrides. Existing values remain
|
|
929
1764
|
// untouched and an install without either flag leaves both fields absent.
|
|
@@ -965,8 +1800,9 @@ export async function installOpenCode(
|
|
|
965
1800
|
// --------------------------------------------------------------------
|
|
966
1801
|
// B.5.1 Hermetic plugin resolution (packaging fix)
|
|
967
1802
|
// --------------------------------------------------------------------
|
|
968
|
-
//
|
|
969
|
-
// absolute paths (e.g.
|
|
1803
|
+
// A developer running from a checkout has a repository-root opencode.json
|
|
1804
|
+
// that may reference plugins via developer-machine absolute paths (e.g.
|
|
1805
|
+
// <dev>/pantheon/.../src/plugin.ts or
|
|
970
1806
|
// <dev>/pantheon/.../src/plugins/pantheon-hooks.ts). Copying those
|
|
971
1807
|
// verbatim into the user's config would break every global install on any
|
|
972
1808
|
// other machine. resolveInstalledPlugin() rewrites each entry to the plugin
|
|
@@ -985,7 +1821,7 @@ export async function installOpenCode(
|
|
|
985
1821
|
if (config.plugin === undefined) config.plugin = []
|
|
986
1822
|
if (Array.isArray(config.plugin)) {
|
|
987
1823
|
config.plugin = removePantheonPluginReferences(config.plugin)
|
|
988
|
-
if (Array.isArray(pantheonConfig
|
|
1824
|
+
if (Array.isArray(pantheonConfig?.plugin)) {
|
|
989
1825
|
for (const plugin of pantheonConfig.plugin) {
|
|
990
1826
|
if (!isPantheonPluginReference(plugin) && !hasPluginReference(config.plugin, plugin)) {
|
|
991
1827
|
config.plugin.push(deepClone(plugin))
|
|
@@ -1014,8 +1850,20 @@ export async function installOpenCode(
|
|
|
1014
1850
|
config.plugin = removePantheonPluginReferences(config.plugin)
|
|
1015
1851
|
}
|
|
1016
1852
|
if (!Array.isArray(config.plugins)) config.plugins = []
|
|
1853
|
+
// Preserve any options already set on the Pantheon V2 entry across the
|
|
1854
|
+
// remove/re-add below. The context_sandbox migration reads them, and a
|
|
1855
|
+
// silent drop would lose a hand-set sandbox limit or any other plugin
|
|
1856
|
+
// option the user chose. Only the object form can carry options.
|
|
1857
|
+
const existingV2Options =
|
|
1858
|
+
config.plugins
|
|
1859
|
+
.filter((ref) => pluginReferenceIdentity(ref) === PANTHEON_V2_PLUGIN)
|
|
1860
|
+
.map((ref) =>
|
|
1861
|
+
typeof ref === 'object' && ref !== null && !Array.isArray(ref) ? ref.options : null,
|
|
1862
|
+
)
|
|
1863
|
+
.find((options) => options && typeof options === 'object' && !Array.isArray(options)) ??
|
|
1864
|
+
null
|
|
1017
1865
|
config.plugins = deduplicatePluginReferences(removePantheonPluginReferences(config.plugins))
|
|
1018
|
-
if (Array.isArray(pantheonConfig
|
|
1866
|
+
if (Array.isArray(pantheonConfig?.plugins)) {
|
|
1019
1867
|
for (const plugin of pantheonConfig.plugins) {
|
|
1020
1868
|
if (!isPantheonPluginReference(plugin) && !hasPluginReference(config.plugins, plugin)) {
|
|
1021
1869
|
config.plugins.push(deepClone(plugin))
|
|
@@ -1038,36 +1886,75 @@ export async function installOpenCode(
|
|
|
1038
1886
|
config.plugins = config.plugins.filter(
|
|
1039
1887
|
(plugin) => pluginReferenceIdentity(plugin) !== pluginReferenceIdentity(resolvedV2),
|
|
1040
1888
|
)
|
|
1041
|
-
config.plugins.push(
|
|
1889
|
+
config.plugins.push(
|
|
1890
|
+
existingV2Options === null
|
|
1891
|
+
? resolvedV2
|
|
1892
|
+
: { package: resolvedV2, options: deepClone(existingV2Options) },
|
|
1893
|
+
)
|
|
1042
1894
|
}
|
|
1043
1895
|
|
|
1044
|
-
if (
|
|
1045
|
-
config.provider
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1896
|
+
if (pantheonConfig !== null) {
|
|
1897
|
+
if (config.provider === undefined && pantheonConfig.provider !== undefined) {
|
|
1898
|
+
config.provider = deepClone(pantheonConfig.provider)
|
|
1899
|
+
} else if (config.provider !== undefined && pantheonConfig.provider !== undefined) {
|
|
1900
|
+
mergeMissing(config.provider, pantheonConfig.provider)
|
|
1901
|
+
}
|
|
1049
1902
|
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1903
|
+
if (config.compaction === undefined && pantheonConfig.compaction !== undefined) {
|
|
1904
|
+
config.compaction = deepClone(pantheonConfig.compaction)
|
|
1905
|
+
} else if (config.compaction !== undefined && pantheonConfig.compaction !== undefined) {
|
|
1906
|
+
mergeMissing(config.compaction, pantheonConfig.compaction)
|
|
1907
|
+
}
|
|
1054
1908
|
}
|
|
1055
1909
|
|
|
1056
1910
|
// --------------------------------------------------------------------
|
|
1057
1911
|
// C. Merge permissions
|
|
1058
1912
|
// --------------------------------------------------------------------
|
|
1059
1913
|
if (config.permission === undefined) config.permission = {}
|
|
1060
|
-
if (pantheonConfig.permission !== undefined) {
|
|
1061
|
-
mergeMissing(config.permission, pantheonConfig.permission)
|
|
1062
|
-
}
|
|
1063
1914
|
if (
|
|
1064
1915
|
typeof config.permission === 'object' &&
|
|
1065
1916
|
config.permission !== null &&
|
|
1066
1917
|
!Array.isArray(config.permission)
|
|
1067
1918
|
) {
|
|
1068
|
-
if
|
|
1919
|
+
// Fill-if-absent, never overwrite. Pantheon's skills cannot be invoked
|
|
1920
|
+
// without this grant, so an install that just installed them seeds it —
|
|
1921
|
+
// but a user who deliberately set `permission.skill` (to `deny`, or to a
|
|
1922
|
+
// partial map of their own) keeps their choice. This was an unconditional
|
|
1923
|
+
// assignment nine lines below a comment that said "never overwrite", which
|
|
1924
|
+
// silently flipped an explicit deny to allow.
|
|
1925
|
+
//
|
|
1926
|
+
// Gated on the skills component AND absent-only, which is the whole point:
|
|
1927
|
+
// `skill` is a capability grant for something this install just wrote to
|
|
1928
|
+
// disk. A runtime-only or agents-only install has nothing to grant, so it
|
|
1929
|
+
// must not seed the key. It used to anyway, via the unconditional
|
|
1930
|
+
// PRODUCT_CONFIG_DEFAULTS.permission merge that sat above this block —
|
|
1931
|
+
// which also made this gate dead code.
|
|
1932
|
+
if (componentSet.has('skills') && config.permission.skill === undefined) {
|
|
1069
1933
|
config.permission.skill = { '*': 'allow' }
|
|
1070
1934
|
}
|
|
1935
|
+
// These five are the commands Pantheon's OWN agents must be able to run
|
|
1936
|
+
// unattended to do their job: version control, package execution, and the
|
|
1937
|
+
// test/lint pair every implementation agent reaches for. That is the whole
|
|
1938
|
+
// bar for a grant made on every install.
|
|
1939
|
+
//
|
|
1940
|
+
// Anything beyond it is the installing user's judgement about which
|
|
1941
|
+
// commands to trust on their machine, to be made in THEIR config — not ours
|
|
1942
|
+
// to grant silently on their behalf. The personal dev allowlist this grew
|
|
1943
|
+
// out of was the same category of decision, one layer further out of view.
|
|
1944
|
+
//
|
|
1945
|
+
// Specifically NOT granted, and why:
|
|
1946
|
+
// - `curl` — arbitrary network egress; the widest grant on the list and
|
|
1947
|
+
// not required to install or run anything here.
|
|
1948
|
+
// - `docker` — host/daemon access; a container runtime is not an
|
|
1949
|
+
// installation requirement.
|
|
1950
|
+
// - `gh` — third-party account access on someone's behalf; a GitHub CLI
|
|
1951
|
+
// login is never something an installer may assume.
|
|
1952
|
+
// - `pip`, `black`, `make` — ordinary toolchain commands with no Pantheon
|
|
1953
|
+
// agent dependency; users who want them can add them.
|
|
1954
|
+
//
|
|
1955
|
+
// NOTE: this grant is NOT gated on the skills component — unlike
|
|
1956
|
+
// permission.skill above, agents are installed by the agents component and
|
|
1957
|
+
// the test/lint commands are needed whichever components were selected.
|
|
1071
1958
|
if (config.permission.bash === undefined) {
|
|
1072
1959
|
config.permission.bash = {
|
|
1073
1960
|
'git *': 'allow',
|
|
@@ -1075,12 +1962,6 @@ export async function installOpenCode(
|
|
|
1075
1962
|
'npx *': 'allow',
|
|
1076
1963
|
'pytest *': 'allow',
|
|
1077
1964
|
'ruff *': 'allow',
|
|
1078
|
-
'black *': 'allow',
|
|
1079
|
-
'pip *': 'allow',
|
|
1080
|
-
'docker *': 'allow',
|
|
1081
|
-
'curl *': 'allow',
|
|
1082
|
-
'gh *': 'allow',
|
|
1083
|
-
'make *': 'allow',
|
|
1084
1965
|
}
|
|
1085
1966
|
}
|
|
1086
1967
|
}
|
|
@@ -1090,8 +1971,8 @@ export async function installOpenCode(
|
|
|
1090
1971
|
// --------------------------------------------------------------------
|
|
1091
1972
|
// AGENTS.md is the single instruction file both V1 and V2 load (V1
|
|
1092
1973
|
// auto-discovers it, V2 loads it explicitly). The generated AGENTS.md
|
|
1093
|
-
//
|
|
1094
|
-
//
|
|
1974
|
+
// contains shared rules; agent-scoped rules are in the installed agent
|
|
1975
|
+
// prompts. The `instructions/*.md`
|
|
1095
1976
|
// glob is no longer needed — keeping it would duplicate content under V1
|
|
1096
1977
|
// and is ignored under V2 anyway. Only ensure AGENTS.md is present.
|
|
1097
1978
|
const pantheonInstructions = ['AGENTS.md']
|
|
@@ -1137,82 +2018,18 @@ export async function installOpenCode(
|
|
|
1137
2018
|
// at a venv that does not exist would make every MCP fail to launch.
|
|
1138
2019
|
// --------------------------------------------------------------------
|
|
1139
2020
|
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
|
|
2021
|
+
// P1-3: derive the MCP python from the SAME venv layout setupVenv creates
|
|
2022
|
+
// (<target>/.venv via venvPythonPath). For project installs the runtime
|
|
2023
|
+
// payload lives under <target>/.opencode (runtimeTarget), but the venv is
|
|
2024
|
+
// created at <target>/.venv — pointing MCP commands at
|
|
1149
2025
|
// runtimeTarget/.venv would reference an executable that never exists.
|
|
1150
|
-
const runtimeTarget = isGlobal ? target : join(target, '.opencode')
|
|
1151
|
-
const venvPython = venvPythonPath(target)
|
|
1152
2026
|
// Point the config at the venv even on a first install; setupVenv runs
|
|
1153
2027
|
// 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
|
-
}
|
|
2028
|
+
const runtimeTarget = isGlobal ? target : join(target, '.opencode')
|
|
2029
|
+
applyMcpServerEntries(config, {
|
|
2030
|
+
runtimeTarget,
|
|
2031
|
+
venvPython: venvPythonPath(target),
|
|
2032
|
+
})
|
|
1216
2033
|
}
|
|
1217
2034
|
|
|
1218
2035
|
// ── V2 native migration ──
|
|
@@ -1222,10 +2039,53 @@ export async function installOpenCode(
|
|
|
1222
2039
|
if (version === 'v2') {
|
|
1223
2040
|
info(S.v2Migrating)
|
|
1224
2041
|
configToWrite = migrateV1toV2(config)
|
|
2042
|
+
|
|
2043
|
+
// Pantheon no longer sets a step ceiling. Any `steps` sitting on a managed
|
|
2044
|
+
// agent was written by an earlier install and never chosen by the user —
|
|
2045
|
+
// the merge above is additive by design (no delete path), so drop it here
|
|
2046
|
+
// and let the host default apply. This runs after the migration because
|
|
2047
|
+
// migrateV1toV2 copies the V1 `agent` block across verbatim (including any
|
|
2048
|
+
// stale `steps`), which is exactly how the value reappears in V2.
|
|
2049
|
+
// User-defined agents are out of scope and keep their value; the V1
|
|
2050
|
+
// singular `agent` block is deliberately untouched (V1 is retiring).
|
|
2051
|
+
const v2Agents = configToWrite.agents
|
|
2052
|
+
if (v2Agents && typeof v2Agents === 'object' && !Array.isArray(v2Agents)) {
|
|
2053
|
+
for (const name of Object.keys(canonicalAgentConfig)) {
|
|
2054
|
+
const entry = v2Agents[name]
|
|
2055
|
+
if (entry && typeof entry === 'object' && !Array.isArray(entry)) delete entry.steps
|
|
2056
|
+
}
|
|
2057
|
+
}
|
|
2058
|
+
|
|
2059
|
+
// Carry the V1 top-level `context_sandbox` into the exact Pantheon V2
|
|
2060
|
+
// plugin entry's `options` — V2.0.25 silently drops the top-level key, so
|
|
2061
|
+
// without this the user's enabled/limits choice is lost on a V1→V2 install.
|
|
2062
|
+
// The V1 block itself is preserved by migrateV1toV2 for a downgrade.
|
|
2063
|
+
// A null/type-invalid block throws HERE, before any config write.
|
|
2064
|
+
configToWrite.plugins = applyContextSandboxToV2Plugins(
|
|
2065
|
+
configToWrite.plugins,
|
|
2066
|
+
config.context_sandbox,
|
|
2067
|
+
)
|
|
1225
2068
|
}
|
|
1226
2069
|
|
|
1227
2070
|
const configContent = `${JSON.stringify(configToWrite, null, 2)}\n`
|
|
1228
|
-
const status = writeConfigWithBackup(targetConfigPath, configContent, dryRun
|
|
2071
|
+
const status = writeConfigWithBackup(targetConfigPath, configContent, dryRun, {
|
|
2072
|
+
expectedContent: configRaw,
|
|
2073
|
+
// Test seam: lets a test simulate a concurrent writer landing between the
|
|
2074
|
+
// initial config read and the pre-swap recheck. Absent in production.
|
|
2075
|
+
beforeRecheck: opts.deps?.beforeRecheck,
|
|
2076
|
+
recorder,
|
|
2077
|
+
})
|
|
2078
|
+
if (status === 'conflict') {
|
|
2079
|
+
// The user's config changed under the installer between the read and the
|
|
2080
|
+
// swap. Treating this as an ordinary skipped write would report a
|
|
2081
|
+
// successful install over a file we never actually updated — fail loudly
|
|
2082
|
+
// and leave the target byte-identical instead.
|
|
2083
|
+
throw new Error(
|
|
2084
|
+
`Installation aborted: ${targetConfigPath} changed on disk during install ` +
|
|
2085
|
+
'(it no longer matches the content that was read); refusing to overwrite it. ' +
|
|
2086
|
+
'Re-run the installer once the file is stable.',
|
|
2087
|
+
)
|
|
2088
|
+
}
|
|
1229
2089
|
if (status === 'created') stats.created++
|
|
1230
2090
|
else stats.skipped++
|
|
1231
2091
|
|
|
@@ -1235,7 +2095,7 @@ export async function installOpenCode(
|
|
|
1235
2095
|
if (componentSet.has('runtime')) {
|
|
1236
2096
|
const currentVersion = detectVersion(target)
|
|
1237
2097
|
if (currentVersion) {
|
|
1238
|
-
const migration = runMigrations(target, currentVersion, { dryRun })
|
|
2098
|
+
const migration = runMigrations(target, currentVersion, { dryRun, recorder })
|
|
1239
2099
|
if (migration.applied > 0) {
|
|
1240
2100
|
success(`Applied ${migration.applied} migration(s)`)
|
|
1241
2101
|
for (const msg of migration.messages) {
|
|
@@ -1245,6 +2105,56 @@ export async function installOpenCode(
|
|
|
1245
2105
|
}
|
|
1246
2106
|
}
|
|
1247
2107
|
|
|
2108
|
+
// -----------------------------------------------------------------------
|
|
2109
|
+
// 3.75 Preview summary (Beta 11 Setup phase 1): print the REAL per-file
|
|
2110
|
+
// change set this same pass would apply. Paths + action + byte size
|
|
2111
|
+
// ONLY — never file contents or hashes. Nothing was written.
|
|
2112
|
+
// -----------------------------------------------------------------------
|
|
2113
|
+
if (preview) {
|
|
2114
|
+
// Apply always writes the install-state file (terminal step); record the
|
|
2115
|
+
// planned state-only write here so the printed plan is complete. Unscored:
|
|
2116
|
+
// it is bookkeeping, not part of the install summary.
|
|
2117
|
+
recordChange(
|
|
2118
|
+
{
|
|
2119
|
+
path: join(target, '.pantheon', 'install-state.json'),
|
|
2120
|
+
action: hasState(target) ? ACTIONS.UPDATE : ACTIONS.CREATE,
|
|
2121
|
+
bytes: 0,
|
|
2122
|
+
scored: false,
|
|
2123
|
+
},
|
|
2124
|
+
recorder,
|
|
2125
|
+
)
|
|
2126
|
+
const plan = summarizeChanges(preview.entries)
|
|
2127
|
+
plannedChanges = plan.totals.create + plan.totals.update + plan.totals.remove
|
|
2128
|
+
// Phase 4: snapshot each planned target now, so the apply pass can detect
|
|
2129
|
+
// any change between plan and apply (drift) before the first write.
|
|
2130
|
+
plannedSnapshots = plan.files.map((f) => ({ path: f.path, ...snapshot(f.path) }))
|
|
2131
|
+
const statsDelta = {
|
|
2132
|
+
created: stats.created - statsBefore.created,
|
|
2133
|
+
skipped: stats.skipped - statsBefore.skipped,
|
|
2134
|
+
}
|
|
2135
|
+
const mismatch = planStatsMismatch(plan, statsDelta)
|
|
2136
|
+
if (mismatch) {
|
|
2137
|
+
// Development safety net: a writer that incremented THIS call's install
|
|
2138
|
+
// stats but never recorded a change would otherwise make the preview
|
|
2139
|
+
// silently incomplete. Surface it loudly instead of hiding it.
|
|
2140
|
+
warning(
|
|
2141
|
+
`Change-plan consistency mismatch: plan reports ${mismatch.changed} changed / ` +
|
|
2142
|
+
`${mismatch.unchanged} unchanged vs stats ${mismatch.recordedChanged}/${mismatch.recordedUnchanged}`,
|
|
2143
|
+
)
|
|
2144
|
+
}
|
|
2145
|
+
// Tag the plan like every other dry-run line so the output reads uniformly.
|
|
2146
|
+
// An internal snapshot pass on the apply path (`quietPlan`, e.g. an
|
|
2147
|
+
// authorized `--yes` run) computes the plan and snapshots but must NOT echo
|
|
2148
|
+
// it — a real apply is not a dry run.
|
|
2149
|
+
if (!opts.quietPlan) {
|
|
2150
|
+
console.log('')
|
|
2151
|
+
for (const line of formatChangePlan(target, plan).split('\n')) {
|
|
2152
|
+
console.log(tag(line))
|
|
2153
|
+
}
|
|
2154
|
+
console.log('')
|
|
2155
|
+
}
|
|
2156
|
+
}
|
|
2157
|
+
|
|
1248
2158
|
// -----------------------------------------------------------------------
|
|
1249
2159
|
// 4. (runtime setup moved to 2.11 — before the config write — so MCP
|
|
1250
2160
|
// entries are only added when the Python runtime is actually healthy)
|
|
@@ -1284,7 +2194,7 @@ export async function installOpenCode(
|
|
|
1284
2194
|
` ${colors.dim('\u2022')} Run 'opencode' to start using Pantheon agents\n`,
|
|
1285
2195
|
)
|
|
1286
2196
|
process.stdout.write('\n')
|
|
1287
|
-
} else {
|
|
2197
|
+
} else if (!opts.planOnly) {
|
|
1288
2198
|
printSummary(target, ['opencode'], stats)
|
|
1289
2199
|
}
|
|
1290
2200
|
|
|
@@ -1304,9 +2214,59 @@ export async function installOpenCode(
|
|
|
1304
2214
|
writeActivePreset(target, opts.preset, { source: 'cli' })
|
|
1305
2215
|
}
|
|
1306
2216
|
|
|
2217
|
+
// -----------------------------------------------------------------------
|
|
2218
|
+
// 5.5 Optional vision prompt (interactive TTY only). The default — automatic
|
|
2219
|
+
// discovery — writes nothing, and non-interactive flows (no TTY, --yes,
|
|
2220
|
+
// --headless, automated sandboxes) never reach this block, so no run
|
|
2221
|
+
// blocks on stdin and the default behavior is unchanged. A failure here
|
|
2222
|
+
// must never fail the install: vision stays optional.
|
|
2223
|
+
// -----------------------------------------------------------------------
|
|
2224
|
+
if (interactive && !dryRun && !autoYes) {
|
|
2225
|
+
const { runVisionPrompt } = await import('./vision-prompt.mjs')
|
|
2226
|
+
try {
|
|
2227
|
+
await runVisionPrompt({ logger: console })
|
|
2228
|
+
} catch (err) {
|
|
2229
|
+
warning(`Vision setup skipped: ${err instanceof Error ? err.message : String(err)}`)
|
|
2230
|
+
}
|
|
2231
|
+
}
|
|
2232
|
+
|
|
1307
2233
|
// Fail-closed: accumulated stats.errors (bad sources, missing requirements,
|
|
1308
2234
|
// failed health checks) must never masquerade as a successful installation.
|
|
1309
2235
|
if (stats.errors > 0) {
|
|
1310
2236
|
throw new Error(`installation completed with ${stats.errors} error(s); see output above`)
|
|
1311
2237
|
}
|
|
2238
|
+
|
|
2239
|
+
// Record the component state only after the install has completed. The
|
|
2240
|
+
// postinstall marker can predate `init` and start with every component
|
|
2241
|
+
// pending; leaving it untouched made a healthy beta install look incomplete
|
|
2242
|
+
// to `doctor` forever. Unselected components retain their previous state.
|
|
2243
|
+
if (!dryRun) {
|
|
2244
|
+
const packageVersion = JSON.parse(readFileSync(join(ROOT, 'package.json'), 'utf8')).version
|
|
2245
|
+
let state = readState(target)
|
|
2246
|
+
if (!state && hasState(target)) {
|
|
2247
|
+
throw new Error('Installation state exists but cannot be read; refusing to replace it')
|
|
2248
|
+
}
|
|
2249
|
+
state ??= createInitialState(packageVersion)
|
|
2250
|
+
state.pantheon_version = packageVersion
|
|
2251
|
+
for (const component of ['agents', 'skills', 'plugins', 'runtime']) {
|
|
2252
|
+
if (!componentSet.has(component)) continue
|
|
2253
|
+
const status = component === 'runtime' && !runtimeHealthy ? 'failed' : 'installed'
|
|
2254
|
+
const componentVersion = state.components?.[component]?.version ?? 1
|
|
2255
|
+
setComponentState(state, component, componentVersion, status)
|
|
2256
|
+
}
|
|
2257
|
+
writeState(target, state)
|
|
2258
|
+
}
|
|
2259
|
+
|
|
2260
|
+
// Beta 11 Setup phase 3: report whether this call APPLIED, so the caller can
|
|
2261
|
+
// avoid a success banner for a plan-only / unauthorized run. The dry pass also
|
|
2262
|
+
// returns the planned-target snapshots for the apply pass's drift check.
|
|
2263
|
+
return dryRun
|
|
2264
|
+
? {
|
|
2265
|
+
...stats,
|
|
2266
|
+
applied: false,
|
|
2267
|
+
reason: 'plan',
|
|
2268
|
+
planned: plannedChanges,
|
|
2269
|
+
snapshots: plannedSnapshots,
|
|
2270
|
+
}
|
|
2271
|
+
: { ...stats, applied: true, reason: 'applied' }
|
|
1312
2272
|
}
|