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.
Files changed (133) hide show
  1. package/AGENTS.global.md +206 -0
  2. package/AGENTS.md +10 -891
  3. package/CHANGELOG.md +350 -0
  4. package/README.md +561 -52
  5. package/README.pt-BR.md +285 -20
  6. package/bin/pantheon-init.mjs +70 -4
  7. package/commands/pantheon-audit.md +7 -1
  8. package/commands/pantheon-consolidate.md +1 -1
  9. package/commands/pantheon-deepwork.md +14 -3
  10. package/commands/pantheon-focus.md +17 -0
  11. package/commands/pantheon-vision.md +61 -12
  12. package/package.json +24 -17
  13. package/plugin.json +2 -2
  14. package/pyproject.toml +8 -6
  15. package/scripts/build-agents-md.mjs +428 -56
  16. package/scripts/changelog-merge.mjs +161 -0
  17. package/scripts/{code_mode_server.py → code_mode.py} +3 -3
  18. package/scripts/cost.mjs +244 -41
  19. package/scripts/doctor.mjs +151 -1
  20. package/scripts/install/agents-md.mjs +4 -4
  21. package/scripts/install/backup.mjs +475 -0
  22. package/scripts/install/cli-ui.mjs +6 -1
  23. package/scripts/install/config-migration.mjs +317 -4
  24. package/scripts/install/exit-codes.mjs +26 -0
  25. package/scripts/install/health-check.mjs +21 -19
  26. package/scripts/install/interactive.mjs +16 -2
  27. package/scripts/install/migrate.mjs +41 -22
  28. package/scripts/install/opencode-version.mjs +246 -4
  29. package/scripts/install/opencode.mjs +1175 -215
  30. package/scripts/install/plan.mjs +287 -0
  31. package/scripts/install/plugin.mjs +62 -14
  32. package/scripts/install/shared.mjs +190 -48
  33. package/scripts/install/strings.mjs +20 -0
  34. package/scripts/install/venv.mjs +13 -3
  35. package/scripts/install/vision-prompt.mjs +171 -0
  36. package/scripts/install-mcp.mjs +28 -9
  37. package/scripts/{mcp_persistence_server.py → mcp_persistence.py} +52 -17
  38. package/{src/mcp/mcp_resources_server.py → scripts/mcp_resources.py} +2 -2
  39. package/scripts/{memory_mcp_server.py → memory_mcp.py} +2 -2
  40. package/scripts/probe-context-rehydrate.mjs +8 -2
  41. package/scripts/probe-pantheon-cost.mjs +71 -15
  42. package/scripts/prune.mjs +9 -3
  43. package/scripts/restore.mjs +222 -0
  44. package/scripts/run-node-tests.mjs +189 -0
  45. package/scripts/sync-artifacts.mjs +26 -13
  46. package/scripts/sync-tui.mjs +24 -3
  47. package/scripts/test-opencode-v2-sandbox.sh +180 -26
  48. package/scripts/validate-routing.mjs +3 -3
  49. package/scripts/validate-sandbox-install.mjs +138 -0
  50. package/scripts/versioning.mjs +13 -1
  51. package/scripts/vision-status.mjs +682 -0
  52. package/skills-lock.json +8 -8
  53. package/src/agents/aphrodite.md +20 -37
  54. package/src/agents/apollo.md +7 -3
  55. package/src/agents/athena.md +63 -35
  56. package/src/agents/demeter.md +8 -28
  57. package/src/agents/gaia.md +1 -1
  58. package/src/agents/hephaestus.md +10 -21
  59. package/src/agents/hermes.md +15 -38
  60. package/src/agents/iris.md +7 -1
  61. package/src/agents/mnemosyne.md +18 -54
  62. package/src/agents/nyx.md +1 -1
  63. package/src/agents/prometheus.md +16 -20
  64. package/src/agents/talos.md +10 -2
  65. package/src/agents/themis.md +5 -5
  66. package/src/agents/zeus.md +99 -172
  67. package/src/instructions/agent-return-format.instructions.md +34 -17
  68. package/src/instructions/backend-standards.instructions.md +2 -0
  69. package/src/instructions/frontend-standards.instructions.md +3 -1
  70. package/src/instructions/memory-protocol.instructions.md +12 -110
  71. package/src/instructions/test-economy.instructions.md +21 -0
  72. package/src/instructions/zeus-anti-stall.instructions.md +20 -67
  73. package/src/instructions/zeus-communication-rules.instructions.md +0 -1
  74. package/src/instructions/zeus-council-synthesis.instructions.md +31 -260
  75. package/src/instructions/zeus-memory-operations.instructions.md +30 -0
  76. package/src/instructions/zeus-timeout-retry.instructions.md +61 -89
  77. package/src/mcp/{code_mode_server.py → code_mode.py} +3 -3
  78. package/src/mcp/eval_store.py +2 -2
  79. package/src/mcp/{mcp_persistence_server.py → mcp_persistence.py} +52 -17
  80. package/{scripts/mcp_resources_server.py → src/mcp/mcp_resources.py} +2 -2
  81. package/src/mcp/{memory_mcp_server.py → memory_mcp.py} +2 -2
  82. package/src/mcp/{pantheon_vision_server.py → pantheon_vision.py} +286 -42
  83. package/src/mcp/requirements-mcp.txt +6 -4
  84. package/src/mcp/requirements-vision.txt +2 -2
  85. package/src/pantheon/cost-command.ts +403 -102
  86. package/src/pantheon/delegation-enforce.ts +10 -1
  87. package/src/pantheon/model-agents.ts +19 -0
  88. package/src/pantheon/model-command.ts +218 -56
  89. package/src/pantheon/presets.d.mts +0 -8
  90. package/src/pantheon/presets.mjs +5 -32
  91. package/src/pantheon/v2-bounded-read.ts +640 -0
  92. package/src/pantheon/v2-delegation-enforce.ts +214 -0
  93. package/src/pantheon/v2-execute-after.ts +186 -0
  94. package/src/pantheon/v2-model-overrides.ts +275 -0
  95. package/src/pantheon/v2-tools.ts +418 -0
  96. package/src/pantheon/v2-unsupported.d.mts +11 -0
  97. package/src/pantheon/v2-unsupported.mjs +90 -0
  98. package/src/pantheon/v2-vision.ts +622 -0
  99. package/src/pantheon/vision-local.ts +531 -0
  100. package/src/pantheon/vision.ts +1160 -115
  101. package/src/plugin-v2.ts +1158 -219
  102. package/src/plugin.ts +12 -9
  103. package/src/plugins/tui/dist/server.d.ts +1 -2
  104. package/src/plugins/tui/dist/server.d.ts.map +1 -1
  105. package/src/plugins/tui/dist/tui-v2.d.ts +154 -0
  106. package/src/plugins/tui/dist/tui-v2.d.ts.map +1 -0
  107. package/src/plugins/tui/dist/tui-v2.js +659 -0
  108. package/src/plugins/tui/dist/tui-v2.js.map +1 -0
  109. package/src/plugins/tui/dist/tui.d.ts +85 -82
  110. package/src/plugins/tui/dist/tui.d.ts.map +1 -1
  111. package/src/plugins/tui/dist/tui.js +72 -59
  112. package/src/plugins/tui/index-v2.ts +6 -0
  113. package/src/plugins/tui/package-lock.json +5516 -1353
  114. package/src/plugins/tui/package.json +22 -12
  115. package/src/plugins/tui/src/index-v2.tsx +734 -0
  116. package/src/plugins/tui/src/index.tsx +106 -85
  117. package/src/plugins/tui/src/opencode-v2.d.ts +13 -0
  118. package/src/plugins/tui/tsconfig.build.json +5 -0
  119. package/src/plugins/tui/tsconfig.typecheck.json +5 -0
  120. package/src/plugins/tui/tsdown.config.ts +22 -2
  121. package/src/plugins/tui/tui-v2.ts +2 -0
  122. package/src/plugins/tui/tui.ts +3 -0
  123. package/src/routing.yml +11 -21
  124. package/src/skills/README.md +3 -3
  125. package/src/skills/agent-coordination/SKILL.md +7 -2
  126. package/src/skills/artifact-management/SKILL.md +12 -5
  127. package/src/skills/auto-continue/SKILL.md +52 -35
  128. package/src/skills/code-review-checklist/SKILL.md +5 -5
  129. package/src/skills/context-compression/SKILL.md +26 -879
  130. package/src/skills/session-goal/SKILL.md +14 -5
  131. package/src/skills/spec-driven-development/SKILL.md +3 -1
  132. package/src/skills/tdd-with-agents/SKILL.md +18 -10
  133. package/opencode.json +0 -58
@@ -0,0 +1,189 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Run `node --test` over an explicit file list, and FAIL when zero tests are collected.
4
+ *
5
+ * Why this exists: `node --test <glob>` exits 0 when the glob matches nothing (Node >= 22).
6
+ * A test script whose file set is absent from the checkout -- or absent from the published
7
+ * package, because the `files` allow-list omits it -- therefore reports success while
8
+ * collecting nothing. That is the worst possible failure mode for a release gate: it
9
+ * manufactures confidence.
10
+ *
11
+ * This runner closes that hole from two independent sides:
12
+ * 1. It expands the globs ITSELF and refuses to start the runner with zero files, so the
13
+ * runner is never handed an unexpanded pattern that silently matches nothing.
14
+ * 2. After a run that exited 0, it re-reads the collected count from the machine-readable
15
+ * TAP summary and fails closed when that count is 0, unparseable, or missing.
16
+ *
17
+ * Side 2 is deliberately independent of side 1: a future Node change to `--test` glob
18
+ * handling, a glob that expands to a directory of empty files, or an emptied test file all
19
+ * still get caught.
20
+ *
21
+ * Human-readable output is unchanged: the default `spec` reporter still goes to stdout, and
22
+ * the TAP reporter is diverted to a scratch file that is read once and deleted.
23
+ *
24
+ * Usage:
25
+ * node scripts/run-node-tests.mjs [--label <name>] [-- <node flags...>] <glob>...
26
+ *
27
+ * Examples:
28
+ * node scripts/run-node-tests.mjs 'tests/*.mjs' 'tests/pantheon/*.mjs'
29
+ * node scripts/run-node-tests.mjs -- --experimental-test-coverage 'tests/*.mjs'
30
+ */
31
+ import { spawnSync } from 'node:child_process'
32
+ import { globSync, mkdtempSync, readFileSync, rmSync } from 'node:fs'
33
+ import { tmpdir } from 'node:os'
34
+ import { join } from 'node:path'
35
+ import { fileURLToPath } from 'node:url'
36
+
37
+ const ROOT = join(fileURLToPath(new URL('.', import.meta.url)), '..')
38
+
39
+ function usage(message) {
40
+ console.error(`zero-test-guard: ${message}`)
41
+ console.error(
42
+ 'usage: node scripts/run-node-tests.mjs [--label <name>] [-- <node flags...>] <glob>...',
43
+ )
44
+ process.exit(1)
45
+ }
46
+
47
+ /** Parse `[--label <name>] [-- <node flags...>] <glob>...`. */
48
+ function parseArgs(argv) {
49
+ const separator = argv.indexOf('--')
50
+ const own = separator === -1 ? argv : argv.slice(0, separator)
51
+ const nodeFlags = separator === -1 ? [] : argv.slice(separator + 1)
52
+
53
+ let label = 'node tests'
54
+ const globs = []
55
+ for (let i = 0; i < own.length; i++) {
56
+ if (own[i] === '--label') {
57
+ const value = own[i + 1]
58
+ if (value === undefined) usage('--label requires a value')
59
+ label = value
60
+ i += 1
61
+ continue
62
+ }
63
+ if (own[i].startsWith('-')) usage(`unknown option ${own[i]}`)
64
+ globs.push(own[i])
65
+ }
66
+ if (globs.length === 0) usage('at least one glob is required')
67
+ return { label, globs, nodeFlags }
68
+ }
69
+
70
+ /**
71
+ * Expand globs to a deduped, ordered file list.
72
+ * Order follows the positional order of the globs so that the suite runs in the same order
73
+ * as the shell used to; within a glob, matches are sorted for determinism.
74
+ */
75
+ function expand(globs) {
76
+ const files = []
77
+ const seen = new Set()
78
+ for (const pattern of globs) {
79
+ const matches = globSync(pattern, { cwd: ROOT })
80
+ .filter((match) => !match.includes('node_modules') && !match.includes('__pycache__'))
81
+ .sort()
82
+ for (const match of matches) {
83
+ if (seen.has(match)) continue
84
+ seen.add(match)
85
+ files.push(match)
86
+ }
87
+ }
88
+ return files
89
+ }
90
+
91
+ function reportMissingFiles(label, globs) {
92
+ console.error(`zero-test-guard: ${label} matched 0 test files — refusing to report success.`)
93
+ console.error(` globs: ${globs.join(' ')}`)
94
+ console.error(' This is the "test suite silently passes when it collects nothing" failure.')
95
+ console.error(' Either the test files are missing from this checkout, or they are missing')
96
+ console.error(' from the published package (check the "files" allow-list in package.json).')
97
+ }
98
+
99
+ function reportZeroCollected(label, files) {
100
+ console.error(`zero-test-guard: ${label} collected 0 tests — refusing to report success.`)
101
+ console.error(` ${files.length} file(s) matched, but not one test case was collected:`)
102
+ for (const file of files) console.error(` ${file}`)
103
+ }
104
+
105
+ /**
106
+ * Count the real test cases collected in a TAP summary.
107
+ *
108
+ * Node counts a test FILE that declares no test cases as one passing "test"
109
+ * (`ok 1 - tests/foo.test.mjs`), so the summary's `# tests N` is >= the file count and
110
+ * can never reach 0 once any file matched. Relying on it alone would let a suite whose
111
+ * every test file was emptied out report success.
112
+ *
113
+ * The reliable discriminator is the subtest NAME: a file-level placeholder is named with
114
+ * the file path that was passed to the runner, a real test case is named with its test
115
+ * name. So a top-level subtest whose name is one of our matched paths is a placeholder
116
+ * and is not counted as a collected case.
117
+ *
118
+ * Returns null when the summary cannot be read or parsed -- the caller fails closed.
119
+ */
120
+ function countCollectedTests(tapPath, files) {
121
+ let tap = ''
122
+ try {
123
+ tap = readFileSync(tapPath, 'utf8')
124
+ } catch {
125
+ return null
126
+ }
127
+ // The summary count is still asserted to exist: without it the stream is not TAP.
128
+ if (!/^#\s*tests\s+\d+\s*$/m.test(tap)) return null
129
+
130
+ const placeholders = new Set(files)
131
+ let collected = 0
132
+ for (const match of tap.matchAll(/^# Subtest: (.+)$/gm)) {
133
+ if (placeholders.has(match[1].trim())) continue
134
+ collected += 1
135
+ }
136
+ return collected
137
+ }
138
+
139
+ const { label, globs, nodeFlags } = parseArgs(process.argv.slice(2))
140
+ const files = expand(globs)
141
+
142
+ if (files.length === 0) {
143
+ reportMissingFiles(label, globs)
144
+ process.exit(1)
145
+ }
146
+
147
+ const scratch = mkdtempSync(join(tmpdir(), 'pantheon-node-tests-'))
148
+ const tapPath = join(scratch, 'summary.tap')
149
+
150
+ try {
151
+ const run = spawnSync(
152
+ process.execPath,
153
+ [
154
+ '--test',
155
+ '--test-reporter=spec',
156
+ '--test-reporter-destination=stdout',
157
+ '--test-reporter=tap',
158
+ `--test-reporter-destination=${tapPath}`,
159
+ ...nodeFlags,
160
+ ...files,
161
+ ],
162
+ { cwd: ROOT, stdio: 'inherit' },
163
+ )
164
+
165
+ if (run.error) {
166
+ console.error(`zero-test-guard: could not start the test runner: ${run.error.message}`)
167
+ process.exit(1)
168
+ }
169
+ if (run.signal) {
170
+ console.error(`zero-test-guard: the test runner was killed by ${run.signal}`)
171
+ process.exit(1)
172
+ }
173
+ // Real failures keep the runner's own exit code; the guard only speaks up when the
174
+ // runner claimed success it cannot justify.
175
+ if (run.status !== 0) process.exit(run.status)
176
+
177
+ const collected = countCollectedTests(tapPath, files)
178
+ if (collected === null) {
179
+ console.error('zero-test-guard: could not read the collected test count from the TAP summary.')
180
+ console.error(' Failing closed: an unreadable summary cannot certify that any test ran.')
181
+ process.exit(1)
182
+ }
183
+ if (collected === 0) {
184
+ reportZeroCollected(label, files)
185
+ process.exit(1)
186
+ }
187
+ } finally {
188
+ rmSync(scratch, { recursive: true, force: true })
189
+ }
@@ -19,10 +19,10 @@
19
19
  */
20
20
  import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync } from 'node:fs'
21
21
  import { join } from 'node:path'
22
+ import { copyAgentPrompts, writeAgentsMdManaged } from './build-agents-md.mjs'
22
23
 
23
24
  import {
24
25
  collectSkillNames,
25
- copyFiles,
26
26
  installSkills,
27
27
  ROOT,
28
28
  syncDir,
@@ -31,15 +31,16 @@ import {
31
31
 
32
32
  /** Canonical MCP script sources — mirrors installOpenCode phase 2.10. */
33
33
  const MCP_SCRIPTS = {
34
- 'mcp_resources_server.py': () => join(ROOT, 'src', 'mcp', 'mcp_resources_server.py'),
35
- 'code_mode_server.py': () => join(ROOT, 'src', 'mcp', 'code_mode_server.py'),
36
- 'memory_mcp_server.py': () => join(ROOT, 'src', 'mcp', 'memory_mcp_server.py'),
37
- 'mcp_persistence_server.py': () => join(ROOT, 'src', 'mcp', 'mcp_persistence_server.py'),
34
+ 'mcp_resources.py': () => join(ROOT, 'src', 'mcp', 'mcp_resources.py'),
35
+ 'code_mode.py': () => join(ROOT, 'src', 'mcp', 'code_mode.py'),
36
+ 'memory_mcp.py': () => join(ROOT, 'src', 'mcp', 'memory_mcp.py'),
37
+ 'mcp_persistence.py': () => join(ROOT, 'src', 'mcp', 'mcp_persistence.py'),
38
38
  '_pantheon_paths.py': () => join(ROOT, 'src', 'mcp', '_pantheon_paths.py'),
39
39
  'mcp_codemap_module.py': () => join(ROOT, 'src', 'mcp', 'mcp_codemap_module.py'),
40
- 'pantheon_vision_server.py': () => join(ROOT, 'src', 'mcp', 'pantheon_vision_server.py'),
40
+ 'pantheon_vision.py': () => join(ROOT, 'src', 'mcp', 'pantheon_vision.py'),
41
41
  'eval_store.py': () => join(ROOT, 'src', 'mcp', 'eval_store.py'),
42
42
  'scrub-secrets.py': () => join(ROOT, 'scripts', 'scrub-secrets.py'),
43
+ 'themis_heuristic_scan.py': () => join(ROOT, 'scripts', 'themis_heuristic_scan.py'),
43
44
  }
44
45
 
45
46
  /**
@@ -59,7 +60,7 @@ export function syncCopyArtifacts(configDir) {
59
60
  const dstAgents = join(configDir, 'agents')
60
61
  if (existsSync(join(ROOT, 'src', 'agents'))) {
61
62
  mkdirSync(dstAgents, { recursive: true })
62
- bump(copyFiles(join(ROOT, 'src', 'agents'), dstAgents, false))
63
+ bump(copyAgentPrompts(join(ROOT, 'src', 'agents'), dstAgents, false))
63
64
  const routing = join(ROOT, 'src', 'routing.yml')
64
65
  if (existsSync(routing)) {
65
66
  if (
@@ -82,15 +83,27 @@ export function syncCopyArtifacts(configDir) {
82
83
  result.errors.push(`skills: ${err.message}`)
83
84
  }
84
85
 
85
- // ── instructions (AGENTS.md) ──
86
+ // ── instructions (global AGENTS.md artifact) ──
87
+ // The config dir is GLOBAL context: it receives the repo-agnostic global
88
+ // artifact, never the repository artifact. Managed-region writer keeps any
89
+ // user content around the markers intact.
86
90
  try {
87
- const src = join(ROOT, 'AGENTS.md')
91
+ const src = join(ROOT, 'AGENTS.global.md')
88
92
  if (existsSync(src)) {
89
- if (
90
- writeIfChanged(join(configDir, 'AGENTS.md'), readFileSync(src, 'utf8'), false) === 'created'
91
- ) {
93
+ const status = writeAgentsMdManaged(
94
+ join(configDir, 'AGENTS.md'),
95
+ readFileSync(src, 'utf8'),
96
+ false,
97
+ )
98
+ if (status === 'skipped') result.skipped++
99
+ else {
92
100
  result.created++
93
- } else result.skipped++
101
+ if (status === 'merged') {
102
+ result.errors.push(
103
+ 'instructions: non-generated content found in AGENTS.md — preserved around the Pantheon managed region',
104
+ )
105
+ }
106
+ }
94
107
  }
95
108
  } catch (err) {
96
109
  result.errors.push(`instructions: ${err.message}`)
@@ -14,6 +14,7 @@ import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync
14
14
  import { homedir } from 'node:os'
15
15
  import { join } from 'node:path'
16
16
  import { fileURLToPath, pathToFileURL } from 'node:url'
17
+ import { resolveOpenCodeVersion } from './install/opencode-version.mjs'
17
18
 
18
19
  const __dirname = fileURLToPath(new URL('.', import.meta.url))
19
20
  const ROOT = join(__dirname, '..')
@@ -63,7 +64,7 @@ function resolveConfigDir() {
63
64
  * The raw TSX source is never copied — the loader consumes only the bundle.
64
65
  * @returns {{ created: number, skipped: number }}
65
66
  */
66
- function copyPluginFiles(srcDir, dstDir) {
67
+ function copyPluginFiles(srcDir, dstDir, version) {
67
68
  const result = { created: 0, skipped: 0 }
68
69
 
69
70
  mkdirSync(dstDir, { recursive: true })
@@ -87,11 +88,30 @@ function copyPluginFiles(srcDir, dstDir) {
87
88
  // package.json
88
89
  const pkgSrc = join(srcDir, 'package.json')
89
90
  if (existsSync(pkgSrc)) {
90
- const content = readFileSync(pkgSrc, 'utf8')
91
+ const packageJson = JSON.parse(readFileSync(pkgSrc, 'utf8'))
92
+ if (version === 'v2' && packageJson.exports?.['./tui-v2']) {
93
+ packageJson.exports['./tui'] = packageJson.exports['./tui-v2']
94
+ }
95
+ const content = `${JSON.stringify(packageJson, null, 2)}\n`
91
96
  if (writeIfChanged(join(dstDir, 'package.json'), content)) result.created++
92
97
  else result.skipped++
93
98
  }
94
99
 
100
+ const tuiEntry = join(srcDir, version === 'v2' ? 'tui-v2.ts' : 'tui.ts')
101
+ if (existsSync(tuiEntry)) {
102
+ if (writeIfChanged(join(dstDir, 'tui.ts'), readFileSync(tuiEntry, 'utf8'))) result.created++
103
+ else result.skipped++
104
+ }
105
+
106
+ const v2ServerEntry = join(srcDir, 'index-v2.ts')
107
+ const v2ServerDestination = join(dstDir, 'index.ts')
108
+ if (version === 'v2' && existsSync(v2ServerEntry)) {
109
+ if (writeIfChanged(v2ServerDestination, readFileSync(v2ServerEntry, 'utf8'))) result.created++
110
+ else result.skipped++
111
+ } else if (existsSync(v2ServerDestination)) {
112
+ rmSync(v2ServerDestination, { force: true })
113
+ }
114
+
95
115
  // Keep the copied plugin installable with deterministic npm ci.
96
116
  const lockSrc = join(srcDir, 'package-lock.json')
97
117
  if (existsSync(lockSrc)) {
@@ -169,7 +189,8 @@ async function main() {
169
189
  const sourceVersion = readVersion(join(tuiSrcDir, 'package.json'))
170
190
 
171
191
  // Copy fresh files
172
- const { created } = copyPluginFiles(tuiSrcDir, tuiCopyDir)
192
+ const version = resolveOpenCodeVersion('auto', { warn: (message) => console.warn(message) })
193
+ const { created } = copyPluginFiles(tuiSrcDir, tuiCopyDir, version)
173
194
 
174
195
  // Lockfile installation is the only accepted dependency path. Strip
175
196
  // global-context npm vars so a global postinstall can't trigger ECIGLOBAL.
@@ -34,7 +34,7 @@
34
34
  # Env overrides:
35
35
  # PANTHEON_SANDBOX_ROOT Sandbox root (default: ~/pantheon-sandbox)
36
36
  # OPENCODE_V2_SPEC npm spec providing the V2 binary
37
- # (default: @opencode-ai/cli@beta). NOTE: this is an
37
+ # (default: @opencode/cli@latest). NOTE: this is an
38
38
  # install spec only — it is not a dependency of this
39
39
  # package, and no code imports it.
40
40
  # PANTHEON_REPO Repository the sandbox is built from (default: the
@@ -63,7 +63,7 @@ SANDBOX_ROOT="${PANTHEON_SANDBOX_ROOT:-$HOME/pantheon-sandbox}"
63
63
  SANDBOX_HOME="$SANDBOX_ROOT/home"
64
64
  NPM_PREFIX="$SANDBOX_HOME/.npm-global"
65
65
 
66
- OPENCODE_V2_SPEC="${OPENCODE_V2_SPEC:-@opencode-ai/cli@beta}"
66
+ OPENCODE_V2_SPEC="${OPENCODE_V2_SPEC:-@opencode/cli@latest}"
67
67
  PANTHEON_SANDBOX_MODEL="${PANTHEON_SANDBOX_MODEL:-opencode-go/mimo-v2.5}"
68
68
  PANTHEON_PROMPT_TIMEOUT="${PANTHEON_PROMPT_TIMEOUT:-300}"
69
69
 
@@ -241,11 +241,11 @@ try:
241
241
  except OSError:
242
242
  raise SystemExit(1)
243
243
  expected = {
244
- "code_mode_server.py",
245
- "memory_mcp_server.py",
246
- "mcp_persistence_server.py",
247
- "mcp_resources_server.py",
248
- "pantheon_vision_server.py",
244
+ "code_mode.py",
245
+ "memory_mcp.py",
246
+ "mcp_persistence.py",
247
+ "mcp_resources.py",
248
+ "pantheon_vision.py",
249
249
  }
250
250
  pattern = re.compile(
251
251
  r"spawning process \{\s*"
@@ -265,7 +265,10 @@ for record in records:
265
265
  if command != python_bin or cwd != runtime_dir or script not in expected:
266
266
  raise SystemExit(1)
267
267
  registered.add(script)
268
- if len(records) != len(expected) or registered != expected:
268
+ # OpenCode can initialize the same configured workspace more than once during
269
+ # service startup and `mcp list`. Count the logical server set here; the later
270
+ # `mcp list` assertion still requires exactly five connected MCP names.
271
+ if registered != expected:
269
272
  raise SystemExit(1)
270
273
  PY
271
274
  then
@@ -418,6 +421,7 @@ V2_DB="${PANTHEON_V2_DB:-$SANDBOX_DIR/opencode-v2.db}"
418
421
  V2_PORT="${PANTHEON_V2_PORT:-49376}"
419
422
  V2_LOOPBACK_HOST="${PANTHEON_V2_HOST:-127.0.0.1}"
420
423
  V2_HANDSHAKE_TIMEOUT="${PANTHEON_V2_HANDSHAKE_TIMEOUT:-60}"
424
+ V2_MCP_LIST_TIMEOUT="${PANTHEON_V2_MCP_LIST_TIMEOUT:-15}"
421
425
  V2_SERVER_LOG="$SANDBOX_DIR/v2-server.log"
422
426
  V2_SERVICE_STATE="$SANDBOX_HOME/.local/state/opencode/service.json"
423
427
  V2_SERVER_PID=""
@@ -515,11 +519,11 @@ try:
515
519
  except OSError:
516
520
  raise SystemExit(1)
517
521
  expected = {
518
- "code_mode_server.py",
519
- "memory_mcp_server.py",
520
- "mcp_persistence_server.py",
521
- "mcp_resources_server.py",
522
- "pantheon_vision_server.py",
522
+ "code_mode.py",
523
+ "memory_mcp.py",
524
+ "mcp_persistence.py",
525
+ "mcp_resources.py",
526
+ "pantheon_vision.py",
523
527
  }
524
528
  pattern = re.compile(
525
529
  r"spawning process \{\s*"
@@ -642,11 +646,11 @@ config_path = Path(os.environ["CONFIG_PATH"])
642
646
  runtime_dir = os.environ["RUNTIME_DIR"]
643
647
  python_bin = os.environ["MCP_PYTHON"]
644
648
  managed = {
645
- "pantheon-code-mode": "code_mode_server.py",
646
- "pantheon-memory": "memory_mcp_server.py",
647
- "pantheon-persistence": "mcp_persistence_server.py",
648
- "pantheon-resources": "mcp_resources_server.py",
649
- "pantheon-vision": "pantheon_vision_server.py",
649
+ "pantheon-code-mode": "code_mode.py",
650
+ "pantheon-memory": "memory_mcp.py",
651
+ "pantheon-persistence": "mcp_persistence.py",
652
+ "pantheon-resources": "mcp_resources.py",
653
+ "pantheon-vision": "pantheon_vision.py",
650
654
  }
651
655
  try:
652
656
  config = json.loads(config_path.read_text(encoding="utf-8"))
@@ -735,7 +739,8 @@ check_mcp() { # label binary project
735
739
  if ! wait_for_v2_mcp_registrations "$project/.opencode" "$project/.venv/bin/python3"; then
736
740
  kill "$list_pid" 2>/dev/null || true
737
741
  wait "$list_pid" 2>/dev/null || true
738
- rm -f "$list_file"
742
+ echo "--- mcp list output (preserved at $list_file) ---" >&2
743
+ cat "$list_file" >&2 || true
739
744
  record_status "$label MCP" "FAIL" "MCP registrations did not match the active sandbox"
740
745
  return 0
741
746
  fi
@@ -744,7 +749,8 @@ check_mcp() { # label binary project
744
749
  else
745
750
  kill "$list_pid" 2>/dev/null || true
746
751
  wait "$list_pid" 2>/dev/null || true
747
- rm -f "$list_file"
752
+ echo "--- mcp list output (preserved at $list_file) ---" >&2
753
+ cat "$list_file" >&2 || true
748
754
  record_status "$label MCP" "FAIL" "MCP handshakes did not become ready after mcp list started"
749
755
  return 0
750
756
  fi
@@ -809,6 +815,11 @@ if ! git -C "$REPO_DIR" ls-files --error-unmatch -- "$TGZ" >/dev/null 2>&1; then
809
815
  echo "--- Init (headless) ---"
810
816
  pantheon-opencode init --headless -y
811
817
  rewrite_v2_mcp_config
818
+ node "$REPO_DIR/scripts/validate-sandbox-install.mjs" \
819
+ --repo "$REPO_DIR" \
820
+ --install "$NPM_PREFIX/lib/node_modules/pantheon-opencode" \
821
+ --project-config "$V2_CONFIG" \
822
+ --config "$PANTHEON_GLOBAL/opencode.json"
812
823
 
813
824
  echo "--- MCP Validation ---"
814
825
  check_mcp "V2" opencode2 "$SANDBOX_DIR/project-v2"
@@ -829,7 +840,7 @@ else
829
840
  fi
830
841
 
831
842
  echo "--- Gate (b): pantheon://agents CONTENT check ---"
832
- RESOURCES_SRV="$PANTHEON_GLOBAL/scripts/mcp_resources_server.py"
843
+ RESOURCES_SRV="$PANTHEON_GLOBAL/scripts/mcp_resources.py"
833
844
  if [ ! -f "$RESOURCES_SRV" ]; then
834
845
  record_status "pantheon://agents resource" "FAIL" "resources server not found at $RESOURCES_SRV"
835
846
  else
@@ -871,6 +882,139 @@ fi
871
882
  echo "Next: open the isolated TUI with: $SANDBOX_DIR/start-pantheon.sh"
872
883
  RUNTEST
873
884
  chmod +x "$SANDBOX_ROOT/run-test.sh"
885
+
886
+ cat > "$SANDBOX_ROOT/start-pantheon.sh" <<'STARTPANTHEON'
887
+ #!/usr/bin/env bash
888
+ # Generated by pantheon-opencode scripts/test-opencode-v2-sandbox.sh --prepare
889
+ # Launches the installed OpenCode beta with only this sandbox's HOME/config/db.
890
+ set -euo pipefail
891
+
892
+ SANDBOX_DIR="$(cd "$(dirname "$0")" && pwd)"
893
+ SANDBOX_HOME="$SANDBOX_DIR/home"
894
+ NPM_PREFIX="$SANDBOX_HOME/.npm-global"
895
+ SANDBOX_VENV="$SANDBOX_HOME/.config/opencode/.venv"
896
+ V2_PORT="${PANTHEON_V2_PORT:-49376}"
897
+ if [[ ! "$V2_PORT" =~ ^[0-9]{1,5}$ ]]; then
898
+ echo "ERROR: PANTHEON_V2_PORT must be an integer from 1 through 65535 (got '$V2_PORT')" >&2
899
+ exit 1
900
+ fi
901
+ V2_PORT=$((10#$V2_PORT))
902
+ if [ "$V2_PORT" -lt 1 ] || [ "$V2_PORT" -gt 65535 ]; then
903
+ echo "ERROR: PANTHEON_V2_PORT must be an integer from 1 through 65535 (got '$V2_PORT')" >&2
904
+ exit 1
905
+ fi
906
+ if ! python3 - "$V2_PORT" "$NPM_PREFIX" <<'CHECKPORT'
907
+ import errno
908
+ import os
909
+ import socket
910
+ import sys
911
+ from urllib.request import urlopen
912
+
913
+ port = int(sys.argv[1])
914
+ cli_prefix = os.path.realpath(
915
+ os.path.join(sys.argv[2], "lib", "node_modules", "@opencode", "cli")
916
+ )
917
+ try:
918
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as probe:
919
+ # A just-stopped service can leave a harmless TIME_WAIT socket behind.
920
+ # SO_REUSEADDR permits the new service to bind that port without
921
+ # masking a live listener or an unrelated server.
922
+ probe.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
923
+ probe.bind(("127.0.0.1", port))
924
+ except OSError as exc:
925
+ if exc.errno != errno.EADDRINUSE:
926
+ print(f"ERROR: cannot reserve dedicated V2 port 127.0.0.1:{port}: {exc}", file=sys.stderr)
927
+ raise SystemExit(1)
928
+
929
+ # OpenCode intentionally keeps its managed service alive after the TUI exits.
930
+ # Reuse it only when /proc proves the listener is this sandbox's CLI binary.
931
+ inodes = set()
932
+ for table in ("/proc/net/tcp", "/proc/net/tcp6"):
933
+ try:
934
+ with open(table, encoding="ascii") as entries:
935
+ next(entries, None)
936
+ for line in entries:
937
+ fields = line.split()
938
+ if len(fields) >= 10 and int(fields[1].rsplit(":", 1)[1], 16) == port and fields[3] == "0A":
939
+ inodes.add(fields[9])
940
+ except OSError:
941
+ pass
942
+
943
+ listener_pids = set()
944
+ for pid in (entry for entry in os.listdir("/proc") if entry.isdigit()):
945
+ fd_dir = f"/proc/{pid}/fd"
946
+ try:
947
+ for fd in os.listdir(fd_dir):
948
+ try:
949
+ target = os.readlink(os.path.join(fd_dir, fd))
950
+ except OSError:
951
+ continue
952
+ if target.startswith("socket:[") and target[8:-1] in inodes:
953
+ listener_pids.add(pid)
954
+ break
955
+ except OSError:
956
+ continue
957
+
958
+ owns_listener = False
959
+ for pid in listener_pids:
960
+ try:
961
+ with open(f"/proc/{pid}/cmdline", "rb") as command_line:
962
+ args = [arg.decode(errors="replace") for arg in command_line.read().split(b"\0") if arg]
963
+ except OSError:
964
+ continue
965
+ executable = os.path.realpath(args[0]) if args else ""
966
+ if (
967
+ executable.startswith(cli_prefix + os.sep)
968
+ and "serve" in args
969
+ and "--service" in args
970
+ and len(listener_pids) == 1
971
+ ):
972
+ owns_listener = True
973
+ break
974
+
975
+ healthy = False
976
+ if owns_listener:
977
+ try:
978
+ scheme = "http"
979
+ loopback = ".".join(("127", "0", "0", "1"))
980
+ with urlopen(f"{scheme}://{loopback}:{port}/global/health", timeout=2) as response:
981
+ healthy = response.status == 200
982
+ except Exception:
983
+ pass
984
+
985
+ if not healthy:
986
+ print(
987
+ f"ERROR: dedicated V2 port 127.0.0.1:{port} is occupied by a different or unverifiable service; no service was stopped",
988
+ file=sys.stderr,
989
+ )
990
+ raise SystemExit(1)
991
+ print(f"Reusing the existing beta sandbox OpenCode service on 127.0.0.1:{port} (PID {next(iter(listener_pids))}).")
992
+ CHECKPORT
993
+ then
994
+ exit 1
995
+ fi
996
+
997
+ export HOME="$SANDBOX_HOME"
998
+ export PATH="$NPM_PREFIX/bin:$SANDBOX_VENV/bin:$PATH"
999
+ export npm_config_prefix="$NPM_PREFIX"
1000
+ export XDG_CONFIG_HOME="$SANDBOX_HOME/.config"
1001
+ export XDG_CONFIG_DIRS="$SANDBOX_HOME/.config/xdg"
1002
+ export XDG_DATA_HOME="$SANDBOX_HOME/.local/share"
1003
+ export XDG_DATA_DIRS="$SANDBOX_HOME/.local/share/xdg"
1004
+ export XDG_STATE_HOME="$SANDBOX_HOME/.local/state"
1005
+ export XDG_CACHE_HOME="$SANDBOX_HOME/.cache"
1006
+ export OPENCODE_CONFIG_DIR="$SANDBOX_DIR/project-v2"
1007
+ export OPENCODE_DB="${PANTHEON_V2_DB:-$SANDBOX_DIR/opencode-v2.db}"
1008
+ export PANTHEON_V2_PORT="$V2_PORT"
1009
+ export PORT="$PANTHEON_V2_PORT"
1010
+ unset OPENCODE_CONFIG OPENCODE_CONFIG_CONTENT OPENCODE_CONFIG_PROJECT_DISABLE \
1011
+ OPENCODE_DATA_DIR OPENCODE_STATE_DIR OPENCODE_CACHE_DIR OPENCODE_STORAGE_PATH
1012
+
1013
+ "$NPM_PREFIX/bin/opencode2" service set port "$V2_PORT"
1014
+ cd "$SANDBOX_DIR/project-v2"
1015
+ exec "$NPM_PREFIX/bin/opencode2" "$@"
1016
+ STARTPANTHEON
1017
+ chmod +x "$SANDBOX_ROOT/start-pantheon.sh"
874
1018
  }
875
1019
 
876
1020
  # ── Reset / Prepare ───────────────────────────────────────────────────────────
@@ -990,6 +1134,7 @@ prepare_project() {
990
1134
  (cd "$dir" && "$pan" init --project --version "$TARGET_VERSION" --model "$PANTHEON_SANDBOX_MODEL" -y) \
991
1135
  || die "init --project --version $TARGET_VERSION failed in $dir"
992
1136
  rewrite_v2_mcp_config
1137
+ validate_sandbox_install
993
1138
  }
994
1139
 
995
1140
  # The installer merges an existing project config. A reused sandbox can
@@ -1013,11 +1158,11 @@ config_path = Path(os.environ["CONFIG_PATH"])
1013
1158
  runtime_dir = os.environ["RUNTIME_DIR"]
1014
1159
  python_bin = os.environ["MCP_PYTHON"]
1015
1160
  managed = {
1016
- "pantheon-code-mode": "code_mode_server.py",
1017
- "pantheon-memory": "memory_mcp_server.py",
1018
- "pantheon-persistence": "mcp_persistence_server.py",
1019
- "pantheon-resources": "mcp_resources_server.py",
1020
- "pantheon-vision": "pantheon_vision_server.py",
1161
+ "pantheon-code-mode": "code_mode.py",
1162
+ "pantheon-memory": "memory_mcp.py",
1163
+ "pantheon-persistence": "mcp_persistence.py",
1164
+ "pantheon-resources": "mcp_resources.py",
1165
+ "pantheon-vision": "pantheon_vision.py",
1021
1166
  }
1022
1167
 
1023
1168
  try:
@@ -1077,6 +1222,15 @@ for name, script in managed.items():
1077
1222
  PY
1078
1223
  }
1079
1224
 
1225
+ validate_sandbox_install() {
1226
+ node "$REPO_DIR/scripts/validate-sandbox-install.mjs" \
1227
+ --repo "$REPO_DIR" \
1228
+ --install "$NPM_PREFIX/lib/node_modules/pantheon-opencode" \
1229
+ --project-config "$V2_CONFIG" \
1230
+ --config "$SANDBOX_HOME/.config/opencode/opencode.json" \
1231
+ || die "sandbox package/config validation failed"
1232
+ }
1233
+
1080
1234
  cmd_prepare() {
1081
1235
  mkdir -p "$SANDBOX_HOME" "$NPM_PREFIX"
1082
1236
  write_run_test_sh
@@ -19,7 +19,7 @@ import { createHash } from 'node:crypto'
19
19
  import { existsSync, readdirSync, readFileSync } from 'node:fs'
20
20
  import { dirname, join } from 'node:path'
21
21
  import { fileURLToPath } from 'node:url'
22
- import yaml from 'js-yaml'
22
+ import { load } from 'js-yaml'
23
23
  import { validatePresetDefs } from '../src/pantheon/presets.mjs'
24
24
 
25
25
  const __dirname = dirname(fileURLToPath(import.meta.url))
@@ -52,7 +52,7 @@ if (!existsSync(routingPath)) {
52
52
  console.error('❌ routing.yml not found')
53
53
  process.exit(1)
54
54
  }
55
- const routing = yaml.load(readFileSync(routingPath, 'utf8'))
55
+ const routing = load(readFileSync(routingPath, 'utf8'))
56
56
 
57
57
  console.log('🔍 Validating routing.yml...\n')
58
58
 
@@ -253,7 +253,7 @@ function parseFrontmatter(filePath) {
253
253
  const match = content.match(FRONTMATTER_RE)
254
254
  if (!match) return null
255
255
  try {
256
- return yaml.load(match[1])
256
+ return load(match[1])
257
257
  } catch {
258
258
  return null
259
259
  }