pantheon-opencode 1.6.0-beta.10 → 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 +42 -18
- package/AGENTS.md +1 -1
- package/CHANGELOG.md +24 -0
- package/README.md +66 -21
- package/bin/pantheon-init.mjs +44 -1
- package/package.json +4 -4
- package/plugin.json +1 -1
- package/pyproject.toml +1 -1
- package/scripts/build-agents-md.mjs +60 -6
- package/scripts/install/backup.mjs +475 -0
- package/scripts/install/cli-ui.mjs +6 -1
- package/scripts/install/exit-codes.mjs +26 -0
- package/scripts/install/interactive.mjs +15 -1
- package/scripts/install/migrate.mjs +41 -22
- package/scripts/install/opencode.mjs +562 -50
- package/scripts/install/plan.mjs +287 -0
- package/scripts/install/plugin.mjs +22 -8
- package/scripts/install/shared.mjs +89 -55
- package/scripts/install/strings.mjs +20 -0
- package/scripts/install/venv.mjs +13 -3
- package/scripts/install/vision-prompt.mjs +11 -1
- package/scripts/mcp_persistence.py +50 -15
- package/scripts/prune.mjs +9 -3
- package/scripts/restore.mjs +222 -0
- package/skills-lock.json +1 -1
- package/src/agents/iris.md +6 -0
- package/src/agents/zeus.md +19 -62
- package/src/instructions/agent-return-format.instructions.md +17 -15
- package/src/instructions/memory-protocol.instructions.md +3 -2
- package/src/instructions/test-economy.instructions.md +21 -0
- package/src/instructions/zeus-anti-stall.instructions.md +6 -6
- package/src/instructions/zeus-council-synthesis.instructions.md +10 -11
- package/src/instructions/zeus-memory-operations.instructions.md +10 -6
- package/src/instructions/zeus-timeout-retry.instructions.md +28 -9
- package/src/mcp/mcp_persistence.py +50 -15
- package/src/pantheon/cost-command.ts +5 -2
- package/src/pantheon/model-agents.ts +19 -0
- package/src/pantheon/model-command.ts +218 -56
- package/src/pantheon/v2-model-overrides.ts +275 -0
- package/src/pantheon/v2-tools.ts +18 -29
- package/src/plugin-v2.ts +13 -0
- package/src/plugins/tui/dist/tui-v2.js +2 -2
- package/src/plugins/tui/dist/tui.js +1 -1
- package/src/plugins/tui/package-lock.json +2 -2
- package/src/plugins/tui/package.json +1 -1
- package/src/skills/auto-continue/SKILL.md +14 -6
|
@@ -19,9 +19,10 @@
|
|
|
19
19
|
import { createHash } from 'node:crypto'
|
|
20
20
|
import { chmodSync, existsSync, mkdirSync, readdirSync, readFileSync, rmSync } from 'node:fs'
|
|
21
21
|
import { homedir } from 'node:os'
|
|
22
|
-
import { dirname, isAbsolute, join, resolve } from 'node:path'
|
|
22
|
+
import { dirname, isAbsolute, join, relative, resolve } from 'node:path'
|
|
23
23
|
import { listV2UnsupportedFeatures } from '../../src/pantheon/v2-unsupported.mjs'
|
|
24
24
|
import { copyAgentPrompts, writeAgentsMdManaged } from '../build-agents-md.mjs'
|
|
25
|
+
import { backupSetPath, backupWarning, createBackupSet, nextBackupName } from './backup.mjs'
|
|
25
26
|
import {
|
|
26
27
|
bullet,
|
|
27
28
|
colors,
|
|
@@ -33,6 +34,7 @@ import {
|
|
|
33
34
|
spinner,
|
|
34
35
|
step,
|
|
35
36
|
success,
|
|
37
|
+
tag,
|
|
36
38
|
warning,
|
|
37
39
|
} from './cli-ui.mjs'
|
|
38
40
|
import {
|
|
@@ -43,6 +45,17 @@ import {
|
|
|
43
45
|
import { healthCheck } from './health-check.mjs'
|
|
44
46
|
import { detectVersion, runMigrations } from './migrate.mjs'
|
|
45
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'
|
|
46
59
|
import { copyPluginFiles, installPlugin, registerPlugin, unregisterPlugin } from './plugin.mjs'
|
|
47
60
|
import {
|
|
48
61
|
checkRuntimePrerequisites,
|
|
@@ -71,6 +84,170 @@ const COMPONENT_NAMES = [
|
|
|
71
84
|
'runtime',
|
|
72
85
|
]
|
|
73
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
|
+
|
|
74
251
|
/**
|
|
75
252
|
* The product defaults an install seeds into the user's `opencode.json`.
|
|
76
253
|
*
|
|
@@ -210,30 +387,40 @@ function deepClone(value) {
|
|
|
210
387
|
|
|
211
388
|
/**
|
|
212
389
|
* (Re)generate the code-mode manifest with SHA-256 digests for every bundled
|
|
213
|
-
* script. This is the explicit installation
|
|
214
|
-
* executes scripts listed here whose on-disk hash
|
|
215
|
-
*
|
|
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 }}
|
|
216
403
|
*/
|
|
217
|
-
function writeCodeModeManifest(
|
|
218
|
-
if (!existsSync(
|
|
404
|
+
function writeCodeModeManifest(srcDir, dstDir, dryRun, recorder = null) {
|
|
405
|
+
if (!existsSync(srcDir)) return { approved: 0, status: null }
|
|
219
406
|
let names
|
|
220
407
|
try {
|
|
221
|
-
names = readdirSync(
|
|
408
|
+
names = readdirSync(srcDir)
|
|
222
409
|
.filter((name) => /\.(sh|py)$/i.test(name) && !name.startsWith('.'))
|
|
223
410
|
.sort()
|
|
224
411
|
} catch {
|
|
225
|
-
return 0
|
|
412
|
+
return { approved: 0, status: null }
|
|
226
413
|
}
|
|
227
|
-
if (names.length === 0) return 0
|
|
414
|
+
if (names.length === 0) return { approved: 0, status: null }
|
|
228
415
|
const scripts = {}
|
|
229
416
|
for (const name of names) {
|
|
230
417
|
scripts[name] = createHash('sha256')
|
|
231
|
-
.update(readFileSync(join(
|
|
418
|
+
.update(readFileSync(join(srcDir, name)))
|
|
232
419
|
.digest('hex')
|
|
233
420
|
}
|
|
234
421
|
const payload = `${JSON.stringify({ version: 1, scripts }, null, 2)}\n`
|
|
235
|
-
writeIfChanged(join(
|
|
236
|
-
return names.length
|
|
422
|
+
const status = writeIfChanged(join(dstDir, 'manifest.json'), payload, dryRun, { recorder })
|
|
423
|
+
return { approved: names.length, status }
|
|
237
424
|
}
|
|
238
425
|
|
|
239
426
|
function readJsonConfig(filePath) {
|
|
@@ -594,11 +781,12 @@ export function tuiConfigLocations(target) {
|
|
|
594
781
|
* @param {object} [opts]
|
|
595
782
|
* @param {boolean} [opts.isGlobal=false] - flat global layout vs .opencode/
|
|
596
783
|
* @param {boolean} [opts.dryRun=false]
|
|
784
|
+
* @param {(change: object) => void} [opts.recorder] - optional change recorder for preview
|
|
597
785
|
* @returns {'created'|'skipped'} status for the target registration
|
|
598
786
|
*/
|
|
599
787
|
export function syncTuiRegistration(
|
|
600
788
|
target,
|
|
601
|
-
{ isGlobal = false, dryRun = false, version = 'v1' } = {},
|
|
789
|
+
{ isGlobal = false, dryRun = false, version = 'v1', recorder = null } = {},
|
|
602
790
|
) {
|
|
603
791
|
const configDir = isGlobal ? target : join(target, '.opencode')
|
|
604
792
|
const targetTuiConfigPath = join(configDir, 'tui.json')
|
|
@@ -608,7 +796,7 @@ export function syncTuiRegistration(
|
|
|
608
796
|
const tuiSrcDir = join(ROOT, 'src', 'plugins', 'tui')
|
|
609
797
|
const tuiCopyDir = resolveTuiCopyTarget(configDir)
|
|
610
798
|
if (existsSync(tuiSrcDir)) {
|
|
611
|
-
copyPluginFiles(tuiSrcDir, tuiCopyDir, { dryRun, version })
|
|
799
|
+
copyPluginFiles(tuiSrcDir, tuiCopyDir, { dryRun, version, recorder })
|
|
612
800
|
}
|
|
613
801
|
|
|
614
802
|
// 2. Clean stale Pantheon TUI refs from every location OpenCode reads.
|
|
@@ -621,6 +809,7 @@ export function syncTuiRegistration(
|
|
|
621
809
|
unregisterPlugin(tuiConfigPath, 'plugins/pantheon-tui', {
|
|
622
810
|
dryRun,
|
|
623
811
|
knownPantheonPaths: [ownedCopy, tuiCopyDir],
|
|
812
|
+
recorder,
|
|
624
813
|
})
|
|
625
814
|
}
|
|
626
815
|
|
|
@@ -630,7 +819,7 @@ export function syncTuiRegistration(
|
|
|
630
819
|
if (version === 'v2') return 'skipped'
|
|
631
820
|
|
|
632
821
|
// 3. Register the copied directory in the selected target only.
|
|
633
|
-
return registerPlugin(targetTuiConfigPath, tuiCopyDir, { dryRun })
|
|
822
|
+
return registerPlugin(targetTuiConfigPath, tuiCopyDir, { dryRun, recorder })
|
|
634
823
|
}
|
|
635
824
|
|
|
636
825
|
/**
|
|
@@ -639,13 +828,18 @@ export function syncTuiRegistration(
|
|
|
639
828
|
* MCP entries from the config (entries pointing at a missing venv would
|
|
640
829
|
* make every MCP fail to launch). `deps` is injectable for tests.
|
|
641
830
|
*/
|
|
642
|
-
export function setupRuntimePhase(
|
|
831
|
+
export function setupRuntimePhase(
|
|
832
|
+
target,
|
|
833
|
+
{ dryRun, clean, isGlobal, recorder = null },
|
|
834
|
+
stats,
|
|
835
|
+
deps = {},
|
|
836
|
+
) {
|
|
643
837
|
const S = strings()
|
|
644
838
|
const setup = deps.setupVenv ?? setupVenv
|
|
645
839
|
const check = deps.healthCheck ?? healthCheck
|
|
646
840
|
try {
|
|
647
841
|
const venvSpinner = spinner(S.settingUpRuntime)
|
|
648
|
-
setup(target, { dryRun, force: clean })
|
|
842
|
+
setup(target, { dryRun, force: clean, recorder })
|
|
649
843
|
venvSpinner(true)
|
|
650
844
|
const runtimeTarget = isGlobal ? target : join(target, '.opencode')
|
|
651
845
|
const health = check(runtimeTarget, { dryRun, pythonTarget: target })
|
|
@@ -758,12 +952,120 @@ export function applyMcpServerEntries(config, { runtimeTarget, venvPython }) {
|
|
|
758
952
|
return { healed }
|
|
759
953
|
}
|
|
760
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
|
+
|
|
761
1032
|
export async function installOpenCode(
|
|
762
1033
|
target,
|
|
763
1034
|
dryRun = false,
|
|
764
1035
|
clean = false,
|
|
765
1036
|
components = ['agents', 'skills', 'instructions', 'commands', 'plugins', 'runtime'],
|
|
766
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 = {},
|
|
767
1069
|
) {
|
|
768
1070
|
// Default target if not provided (global ~/.config/opencode)
|
|
769
1071
|
if (!target) {
|
|
@@ -772,8 +1074,39 @@ export async function installOpenCode(
|
|
|
772
1074
|
|
|
773
1075
|
const componentSet = new Set(components)
|
|
774
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
|
|
775
1084
|
const S = strings()
|
|
776
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
|
+
|
|
777
1110
|
// V1/V2 dual-version awareness (Phase 3): the installer builds a unified
|
|
778
1111
|
// config, then applies V2-native migration (migrateV1toV2) when
|
|
779
1112
|
// --opencode-version=v2. V2 normalizes V1 fields in memory (no rewrite) and
|
|
@@ -822,7 +1155,7 @@ export async function installOpenCode(
|
|
|
822
1155
|
|
|
823
1156
|
if (!result.confirmed && !autoYes) {
|
|
824
1157
|
info('Installation canceled.')
|
|
825
|
-
return stats
|
|
1158
|
+
return { ...stats, applied: false, reason: 'canceled' }
|
|
826
1159
|
}
|
|
827
1160
|
|
|
828
1161
|
// Map target selection to actual path
|
|
@@ -873,7 +1206,108 @@ export async function installOpenCode(
|
|
|
873
1206
|
const _subDir = isGlobal ? '' : '.opencode'
|
|
874
1207
|
const agentPrefix = isGlobal || version === 'v2' ? 'agents' : '.opencode/agents'
|
|
875
1208
|
|
|
876
|
-
configure({ dryRun })
|
|
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
|
+
}
|
|
1261
|
+
|
|
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
|
+
}
|
|
877
1311
|
|
|
878
1312
|
if (isGlobal) {
|
|
879
1313
|
info(S.globalLayout)
|
|
@@ -898,7 +1332,7 @@ export async function installOpenCode(
|
|
|
898
1332
|
rmSync(join(dstDir, f), { recursive: true, force: true })
|
|
899
1333
|
}
|
|
900
1334
|
}
|
|
901
|
-
const { created, skipped } = copyAgentPrompts(srcDir, dstDir, dryRun)
|
|
1335
|
+
const { created, skipped } = copyAgentPrompts(srcDir, dstDir, dryRun, false, recorder)
|
|
902
1336
|
stats.created += created
|
|
903
1337
|
stats.skipped += skipped
|
|
904
1338
|
agentStep(true)
|
|
@@ -912,7 +1346,9 @@ export async function installOpenCode(
|
|
|
912
1346
|
const srcRouting = join(ROOT, 'src', 'routing.yml')
|
|
913
1347
|
const dstRouting = join(target, 'routing.yml')
|
|
914
1348
|
if (existsSync(srcRouting)) {
|
|
915
|
-
const routingStatus = writeIfChanged(dstRouting, readFileSync(srcRouting, 'utf8'), dryRun
|
|
1349
|
+
const routingStatus = writeIfChanged(dstRouting, readFileSync(srcRouting, 'utf8'), dryRun, {
|
|
1350
|
+
recorder,
|
|
1351
|
+
})
|
|
916
1352
|
if (routingStatus === 'created') stats.created++
|
|
917
1353
|
else stats.skipped++
|
|
918
1354
|
}
|
|
@@ -939,6 +1375,7 @@ export async function installOpenCode(
|
|
|
939
1375
|
target,
|
|
940
1376
|
dryRun,
|
|
941
1377
|
installSubDir,
|
|
1378
|
+
recorder,
|
|
942
1379
|
)
|
|
943
1380
|
stats.created += sCreated
|
|
944
1381
|
stats.skipped += sSkipped
|
|
@@ -960,7 +1397,7 @@ export async function installOpenCode(
|
|
|
960
1397
|
const dstAgentsMd = join(target, 'AGENTS.md')
|
|
961
1398
|
if (existsSync(srcAgentsMd)) {
|
|
962
1399
|
const content = readFileSync(srcAgentsMd, 'utf8')
|
|
963
|
-
const status = writeAgentsMdManaged(dstAgentsMd, content, dryRun)
|
|
1400
|
+
const status = writeAgentsMdManaged(dstAgentsMd, content, dryRun, recorder)
|
|
964
1401
|
if (status === 'skipped') stats.skipped++
|
|
965
1402
|
else {
|
|
966
1403
|
stats.created++
|
|
@@ -982,7 +1419,7 @@ export async function installOpenCode(
|
|
|
982
1419
|
const installedAgents = join(target, agentPrefix)
|
|
983
1420
|
const sourceAgents = join(ROOT, 'src', 'agents')
|
|
984
1421
|
if (existsSync(installedAgents) && existsSync(sourceAgents)) {
|
|
985
|
-
const refreshed = copyAgentPrompts(sourceAgents, installedAgents, dryRun, true)
|
|
1422
|
+
const refreshed = copyAgentPrompts(sourceAgents, installedAgents, dryRun, true, recorder)
|
|
986
1423
|
stats.created += refreshed.created
|
|
987
1424
|
stats.skipped += refreshed.skipped
|
|
988
1425
|
}
|
|
@@ -997,7 +1434,7 @@ export async function installOpenCode(
|
|
|
997
1434
|
const srcPrompts = join(ROOT, 'prompts')
|
|
998
1435
|
const dstPrompts = join(target, 'prompts')
|
|
999
1436
|
if (existsSync(srcPrompts)) {
|
|
1000
|
-
const promptsResult = syncDir(srcPrompts, dstPrompts, dryRun, clean)
|
|
1437
|
+
const promptsResult = syncDir(srcPrompts, dstPrompts, dryRun, clean, null, recorder)
|
|
1001
1438
|
stats.created += promptsResult.created
|
|
1002
1439
|
stats.skipped += promptsResult.skipped
|
|
1003
1440
|
}
|
|
@@ -1015,7 +1452,7 @@ export async function installOpenCode(
|
|
|
1015
1452
|
const commandDir = isGlobal || version === 'v2' ? 'commands' : '.opencode/commands'
|
|
1016
1453
|
const dstCmds = join(target, commandDir)
|
|
1017
1454
|
if (existsSync(srcCmds)) {
|
|
1018
|
-
const cmdResult = syncDir(srcCmds, dstCmds, dryRun, clean, (f) => f.endsWith('.md'))
|
|
1455
|
+
const cmdResult = syncDir(srcCmds, dstCmds, dryRun, clean, (f) => f.endsWith('.md'), recorder)
|
|
1019
1456
|
stats.created += cmdResult.created
|
|
1020
1457
|
stats.skipped += cmdResult.skipped
|
|
1021
1458
|
}
|
|
@@ -1038,7 +1475,12 @@ export async function installOpenCode(
|
|
|
1038
1475
|
const dstPluginDir = isGlobal
|
|
1039
1476
|
? join(target, 'plugins', 'pantheon-tui')
|
|
1040
1477
|
: join(target, '.opencode', 'plugins', 'pantheon-tui')
|
|
1041
|
-
const pluginStats = installPlugin(srcPluginDir, dstPluginDir, {
|
|
1478
|
+
const pluginStats = installPlugin(srcPluginDir, dstPluginDir, {
|
|
1479
|
+
dryRun,
|
|
1480
|
+
clean,
|
|
1481
|
+
version,
|
|
1482
|
+
recorder,
|
|
1483
|
+
})
|
|
1042
1484
|
stats.created += pluginStats.created
|
|
1043
1485
|
stats.skipped += pluginStats.skipped
|
|
1044
1486
|
stats.errors += pluginStats.errors
|
|
@@ -1050,7 +1492,7 @@ export async function installOpenCode(
|
|
|
1050
1492
|
if (componentSet.has('plugins')) {
|
|
1051
1493
|
let tuiStatus
|
|
1052
1494
|
try {
|
|
1053
|
-
tuiStatus = syncTuiRegistration(target, { isGlobal, dryRun, version })
|
|
1495
|
+
tuiStatus = syncTuiRegistration(target, { isGlobal, dryRun, version, recorder })
|
|
1054
1496
|
} catch (cause) {
|
|
1055
1497
|
error(`TUI plugin setup failed: ${cause.message}`)
|
|
1056
1498
|
stats.errors++
|
|
@@ -1069,18 +1511,7 @@ export async function installOpenCode(
|
|
|
1069
1511
|
const runtimeTarget = isGlobal ? target : join(target, '.opencode')
|
|
1070
1512
|
|
|
1071
1513
|
// ── MCP server scripts ──
|
|
1072
|
-
const mcpScripts =
|
|
1073
|
-
'mcp_resources.py',
|
|
1074
|
-
'code_mode.py',
|
|
1075
|
-
'memory_mcp.py',
|
|
1076
|
-
'scrub-secrets.py',
|
|
1077
|
-
'_pantheon_paths.py',
|
|
1078
|
-
'mcp_codemap_module.py',
|
|
1079
|
-
'mcp_persistence.py',
|
|
1080
|
-
'pantheon_vision.py',
|
|
1081
|
-
'eval_store.py',
|
|
1082
|
-
'themis_heuristic_scan.py',
|
|
1083
|
-
]
|
|
1514
|
+
const mcpScripts = MCP_SCRIPTS
|
|
1084
1515
|
const srcScriptsDir = join(ROOT, 'scripts')
|
|
1085
1516
|
// Canonical MCP server sources live in src/mcp/. Map them explicitly so the
|
|
1086
1517
|
// sync always copies from the canonical source, never from the stale root
|
|
@@ -1108,7 +1539,7 @@ export async function installOpenCode(
|
|
|
1108
1539
|
}
|
|
1109
1540
|
const content = readFileSync(src, 'utf8')
|
|
1110
1541
|
const dst = join(dstScriptsDir, script)
|
|
1111
|
-
const status = writeIfChanged(dst, content, dryRun)
|
|
1542
|
+
const status = writeIfChanged(dst, content, dryRun, { recorder })
|
|
1112
1543
|
if (status === 'created') {
|
|
1113
1544
|
stats.created++
|
|
1114
1545
|
// Make executable
|
|
@@ -1126,7 +1557,9 @@ export async function installOpenCode(
|
|
|
1126
1557
|
const visionRequirements = join(ROOT, 'src', 'mcp', 'requirements-vision.txt')
|
|
1127
1558
|
if (existsSync(visionRequirements)) {
|
|
1128
1559
|
const destination = join(runtimeTarget, 'requirements-vision.txt')
|
|
1129
|
-
const status = writeIfChanged(destination, readFileSync(visionRequirements, 'utf8'), dryRun
|
|
1560
|
+
const status = writeIfChanged(destination, readFileSync(visionRequirements, 'utf8'), dryRun, {
|
|
1561
|
+
recorder,
|
|
1562
|
+
})
|
|
1130
1563
|
if (status === 'created') stats.created++
|
|
1131
1564
|
else stats.skipped++
|
|
1132
1565
|
} else {
|
|
@@ -1138,7 +1571,17 @@ export async function installOpenCode(
|
|
|
1138
1571
|
const srcCodeModeDir = join(ROOT, '.pantheon', 'code-mode')
|
|
1139
1572
|
const dstCodeModeDir = join(runtimeTarget, '.pantheon', 'code-mode')
|
|
1140
1573
|
if (existsSync(srcCodeModeDir)) {
|
|
1141
|
-
|
|
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
|
+
)
|
|
1142
1585
|
stats.created += cmResult.created
|
|
1143
1586
|
stats.skipped += cmResult.skipped
|
|
1144
1587
|
} else {
|
|
@@ -1149,13 +1592,18 @@ export async function installOpenCode(
|
|
|
1149
1592
|
warning(`Code-mode source not found: ${srcCodeModeDir} — creating empty ${dstCodeModeDir}`)
|
|
1150
1593
|
if (!dryRun) mkdirSync(dstCodeModeDir, { recursive: true })
|
|
1151
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)
|
|
1152
1598
|
}
|
|
1153
1599
|
|
|
1154
1600
|
// B3-08: explicit installation mode — seed the manifest with SHA-256
|
|
1155
1601
|
// digests so the server never executes a script implicitly.
|
|
1156
|
-
const
|
|
1157
|
-
if (
|
|
1158
|
-
|
|
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`)
|
|
1159
1607
|
}
|
|
1160
1608
|
|
|
1161
1609
|
// ── tiers.json ──
|
|
@@ -1163,7 +1611,7 @@ export async function installOpenCode(
|
|
|
1163
1611
|
const dstTiers = join(runtimeTarget, '.pantheon', 'tiers.json')
|
|
1164
1612
|
if (existsSync(srcTiers)) {
|
|
1165
1613
|
const content = readFileSync(srcTiers, 'utf8')
|
|
1166
|
-
const status = writeIfChanged(dstTiers, content, dryRun)
|
|
1614
|
+
const status = writeIfChanged(dstTiers, content, dryRun, { recorder })
|
|
1167
1615
|
if (status === 'created') stats.created++
|
|
1168
1616
|
else stats.skipped++
|
|
1169
1617
|
}
|
|
@@ -1177,7 +1625,7 @@ export async function installOpenCode(
|
|
|
1177
1625
|
// -----------------------------------------------------------------------
|
|
1178
1626
|
let runtimeHealthy = !componentSet.has('runtime')
|
|
1179
1627
|
if (componentSet.has('runtime')) {
|
|
1180
|
-
runtimeHealthy = setupRuntimePhase(target, { dryRun, clean, isGlobal }, stats)
|
|
1628
|
+
runtimeHealthy = setupRuntimePhase(target, { dryRun, clean, isGlobal, recorder }, stats)
|
|
1181
1629
|
}
|
|
1182
1630
|
|
|
1183
1631
|
// -----------------------------------------------------------------------
|
|
@@ -1625,12 +2073,13 @@ export async function installOpenCode(
|
|
|
1625
2073
|
// Test seam: lets a test simulate a concurrent writer landing between the
|
|
1626
2074
|
// initial config read and the pre-swap recheck. Absent in production.
|
|
1627
2075
|
beforeRecheck: opts.deps?.beforeRecheck,
|
|
2076
|
+
recorder,
|
|
1628
2077
|
})
|
|
1629
2078
|
if (status === 'conflict') {
|
|
1630
2079
|
// The user's config changed under the installer between the read and the
|
|
1631
2080
|
// swap. Treating this as an ordinary skipped write would report a
|
|
1632
2081
|
// successful install over a file we never actually updated — fail loudly
|
|
1633
|
-
// and leave the target
|
|
2082
|
+
// and leave the target byte-identical instead.
|
|
1634
2083
|
throw new Error(
|
|
1635
2084
|
`Installation aborted: ${targetConfigPath} changed on disk during install ` +
|
|
1636
2085
|
'(it no longer matches the content that was read); refusing to overwrite it. ' +
|
|
@@ -1646,7 +2095,7 @@ export async function installOpenCode(
|
|
|
1646
2095
|
if (componentSet.has('runtime')) {
|
|
1647
2096
|
const currentVersion = detectVersion(target)
|
|
1648
2097
|
if (currentVersion) {
|
|
1649
|
-
const migration = runMigrations(target, currentVersion, { dryRun })
|
|
2098
|
+
const migration = runMigrations(target, currentVersion, { dryRun, recorder })
|
|
1650
2099
|
if (migration.applied > 0) {
|
|
1651
2100
|
success(`Applied ${migration.applied} migration(s)`)
|
|
1652
2101
|
for (const msg of migration.messages) {
|
|
@@ -1656,6 +2105,56 @@ export async function installOpenCode(
|
|
|
1656
2105
|
}
|
|
1657
2106
|
}
|
|
1658
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
|
+
|
|
1659
2158
|
// -----------------------------------------------------------------------
|
|
1660
2159
|
// 4. (runtime setup moved to 2.11 — before the config write — so MCP
|
|
1661
2160
|
// entries are only added when the Python runtime is actually healthy)
|
|
@@ -1695,7 +2194,7 @@ export async function installOpenCode(
|
|
|
1695
2194
|
` ${colors.dim('\u2022')} Run 'opencode' to start using Pantheon agents\n`,
|
|
1696
2195
|
)
|
|
1697
2196
|
process.stdout.write('\n')
|
|
1698
|
-
} else {
|
|
2197
|
+
} else if (!opts.planOnly) {
|
|
1699
2198
|
printSummary(target, ['opencode'], stats)
|
|
1700
2199
|
}
|
|
1701
2200
|
|
|
@@ -1757,4 +2256,17 @@ export async function installOpenCode(
|
|
|
1757
2256
|
}
|
|
1758
2257
|
writeState(target, state)
|
|
1759
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' }
|
|
1760
2272
|
}
|