pantheon-opencode 1.5.2 → 1.6.0-beta.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (146) hide show
  1. package/.pantheon/code-mode/README.md +4 -2
  2. package/.pantheon/code-mode/checkpoint_session.py +138 -102
  3. package/.pantheon/code-mode/compress-inline.py +102 -57
  4. package/.pantheon/code-mode/manifest.json +2 -8
  5. package/AGENTS.global.md +182 -0
  6. package/AGENTS.md +10 -891
  7. package/CHANGELOG.md +343 -0
  8. package/README.md +577 -79
  9. package/README.pt-BR.md +345 -60
  10. package/bin/pantheon-init.mjs +26 -3
  11. package/commands/pantheon-audit.md +7 -1
  12. package/commands/pantheon-consolidate.md +1 -1
  13. package/commands/pantheon-deepwork.md +14 -3
  14. package/commands/pantheon-focus.md +17 -0
  15. package/commands/pantheon-optimize.md +2 -2
  16. package/commands/pantheon-vision.md +61 -12
  17. package/package.json +30 -11
  18. package/plugin.json +2 -2
  19. package/pyproject.toml +11 -16
  20. package/scripts/build-agents-md.mjs +376 -58
  21. package/scripts/changelog-merge.mjs +161 -0
  22. package/scripts/check-plugin-v2-coverage.mjs +213 -0
  23. package/scripts/ci-validate-yaml.py +7 -5
  24. package/scripts/{code_mode_server.py → code_mode.py} +119 -27
  25. package/scripts/cost.mjs +244 -41
  26. package/scripts/doctor.mjs +236 -29
  27. package/scripts/install/agents-md.mjs +4 -4
  28. package/scripts/install/cli-ui.mjs +1 -1
  29. package/scripts/install/config-migration.mjs +319 -6
  30. package/scripts/install/health-check.mjs +26 -37
  31. package/scripts/install/interactive.mjs +7 -7
  32. package/scripts/install/migrate.mjs +1 -1
  33. package/scripts/install/opencode-version.mjs +246 -4
  34. package/scripts/install/opencode.mjs +627 -179
  35. package/scripts/install/plugin.mjs +43 -9
  36. package/scripts/install/shared.mjs +120 -12
  37. package/scripts/install/vision-prompt.mjs +161 -0
  38. package/scripts/install-mcp.mjs +28 -9
  39. package/scripts/{mcp_persistence_server.py → mcp_persistence.py} +392 -78
  40. package/{src/mcp/mcp_resources_server.py → scripts/mcp_resources.py} +2 -2
  41. package/scripts/{memory_mcp_server.py → memory_mcp.py} +372 -198
  42. package/scripts/postinstall.mjs +0 -7
  43. package/scripts/probe-context-rehydrate.mjs +36 -13
  44. package/scripts/probe-pantheon-cost.mjs +71 -15
  45. package/scripts/prune.mjs +2 -2
  46. package/scripts/run-node-tests.mjs +189 -0
  47. package/scripts/sync-artifacts.mjs +26 -13
  48. package/scripts/sync-tui.mjs +24 -3
  49. package/scripts/test-opencode-v2-sandbox.sh +1913 -0
  50. package/scripts/validate-routing.mjs +43 -3
  51. package/scripts/validate-sandbox-install.mjs +138 -0
  52. package/scripts/versioning.mjs +13 -1
  53. package/scripts/vision-status.mjs +682 -0
  54. package/skills-lock.json +15 -16
  55. package/src/agents/aphrodite.md +20 -37
  56. package/src/agents/apollo.md +7 -3
  57. package/src/agents/athena.md +63 -35
  58. package/src/agents/demeter.md +8 -28
  59. package/src/agents/gaia.md +1 -1
  60. package/src/agents/hephaestus.md +10 -21
  61. package/src/agents/hermes.md +15 -38
  62. package/src/agents/iris.md +1 -1
  63. package/src/agents/mnemosyne.md +23 -60
  64. package/src/agents/nyx.md +1 -1
  65. package/src/agents/prometheus.md +16 -20
  66. package/src/agents/talos.md +10 -2
  67. package/src/agents/themis.md +5 -5
  68. package/src/agents/zeus.md +99 -130
  69. package/src/instructions/agent-return-format.instructions.md +18 -3
  70. package/src/instructions/backend-standards.instructions.md +2 -0
  71. package/src/instructions/frontend-standards.instructions.md +3 -1
  72. package/src/instructions/memory-protocol.instructions.md +11 -110
  73. package/src/instructions/zeus-anti-stall.instructions.md +14 -61
  74. package/src/instructions/zeus-communication-rules.instructions.md +0 -1
  75. package/src/instructions/zeus-council-synthesis.instructions.md +32 -260
  76. package/src/instructions/zeus-memory-operations.instructions.md +26 -0
  77. package/src/instructions/zeus-timeout-retry.instructions.md +40 -87
  78. package/src/mcp/{code_mode_server.py → code_mode.py} +119 -27
  79. package/src/mcp/eval_store.py +2 -2
  80. package/src/mcp/mcp_codemap_module.py +559 -129
  81. package/src/mcp/{mcp_persistence_server.py → mcp_persistence.py} +392 -78
  82. package/{scripts/mcp_resources_server.py → src/mcp/mcp_resources.py} +2 -2
  83. package/src/mcp/{memory_mcp_server.py → memory_mcp.py} +373 -250
  84. package/src/mcp/{pantheon_vision_server.py → pantheon_vision.py} +396 -40
  85. package/src/mcp/requirements-mcp.txt +10 -6
  86. package/src/mcp/requirements-vision.txt +4 -2
  87. package/src/pantheon/cost-command.ts +399 -101
  88. package/src/pantheon/delegation-enforce.ts +10 -1
  89. package/src/pantheon/model-command.ts +18 -7
  90. package/src/pantheon/presets.d.mts +0 -8
  91. package/src/pantheon/presets.mjs +5 -32
  92. package/src/pantheon/todo-enforcer.ts +1 -1
  93. package/src/pantheon/v2-bounded-read.ts +640 -0
  94. package/src/pantheon/v2-delegation-enforce.ts +214 -0
  95. package/src/pantheon/v2-execute-after.ts +186 -0
  96. package/src/pantheon/v2-tools.ts +429 -0
  97. package/src/pantheon/v2-unsupported.d.mts +11 -0
  98. package/src/pantheon/v2-unsupported.mjs +90 -0
  99. package/src/pantheon/v2-vision.ts +622 -0
  100. package/src/pantheon/vision-local.ts +531 -0
  101. package/src/pantheon/vision.ts +1161 -116
  102. package/src/plugin-v2.ts +1244 -288
  103. package/src/plugin.ts +12 -9
  104. package/src/plugins/tui/dist/server.d.ts +1 -2
  105. package/src/plugins/tui/dist/server.d.ts.map +1 -1
  106. package/src/plugins/tui/dist/tui-v2.d.ts +154 -0
  107. package/src/plugins/tui/dist/tui-v2.d.ts.map +1 -0
  108. package/src/plugins/tui/dist/tui-v2.js +659 -0
  109. package/src/plugins/tui/dist/tui-v2.js.map +1 -0
  110. package/src/plugins/tui/dist/tui.d.ts +85 -82
  111. package/src/plugins/tui/dist/tui.d.ts.map +1 -1
  112. package/src/plugins/tui/dist/tui.js +72 -59
  113. package/src/plugins/tui/index-v2.ts +6 -0
  114. package/src/plugins/tui/package-lock.json +5504 -1341
  115. package/src/plugins/tui/package.json +25 -15
  116. package/src/plugins/tui/src/index-v2.tsx +734 -0
  117. package/src/plugins/tui/src/index.tsx +106 -85
  118. package/src/plugins/tui/src/opencode-v2.d.ts +13 -0
  119. package/src/plugins/tui/tsconfig.build.json +5 -0
  120. package/src/plugins/tui/tsconfig.typecheck.json +5 -0
  121. package/src/plugins/tui/tsdown.config.ts +22 -2
  122. package/src/plugins/tui/tui-v2.ts +2 -0
  123. package/src/plugins/tui/tui.ts +3 -0
  124. package/src/routing.yml +11 -22
  125. package/src/skills/README.md +34 -39
  126. package/src/skills/agent-coordination/SKILL.md +7 -2
  127. package/src/skills/artifact-management/SKILL.md +12 -5
  128. package/src/skills/auto-continue/SKILL.md +54 -95
  129. package/src/skills/code-review-checklist/SKILL.md +5 -5
  130. package/src/skills/context-compression/SKILL.md +26 -879
  131. package/src/skills/session-goal/SKILL.md +14 -5
  132. package/src/skills/spec-driven-development/SKILL.md +3 -1
  133. package/src/skills/tdd-with-agents/SKILL.md +18 -10
  134. package/.pantheon/code-mode/eval-llm-judge.py +0 -255
  135. package/.pantheon/code-mode/eval-monte-carlo.py +0 -368
  136. package/.pantheon/code-mode/eval-run.py +0 -197
  137. package/.pantheon/code-mode/eval-static.py +0 -321
  138. package/.pantheon/code-mode/session-end-save.py +0 -186
  139. package/.pantheon/code-mode/session-end-save.sh +0 -18
  140. package/opencode.json +0 -58
  141. package/scripts/recover-zenodo-deposition.mjs +0 -146
  142. package/scripts/test-opencode-v1-v2-sandbox.sh +0 -862
  143. package/scripts/validate-zenodo-release.mjs +0 -154
  144. package/scripts/zenodo-artifact.mjs +0 -94
  145. package/scripts/zenodo-release-state.mjs +0 -53
  146. package/src/skills/orchestration-workflow/SKILL.md +0 -311
@@ -0,0 +1,1913 @@
1
+ #!/usr/bin/env bash
2
+ # test-opencode-v2-sandbox.sh — Pantheon global-install sandbox validator for
3
+ # OpenCode V2.
4
+ #
5
+ # Validates the installed pantheon-opencode package AS A REAL USER inside an
6
+ # isolated sandbox (own HOME, npm prefix and venv), never the dev environment.
7
+ #
8
+ # WHAT "V2" MEANS HERE
9
+ # --------------------
10
+ # It does not mean a different binary. On hosts where both `opencode` and
11
+ # `opencode2` exist, `opencode2` is typically a two-line shim that execs the very
12
+ # same `opencode` binary, so a "V1 vs V2 side-by-side" comparison proved nothing
13
+ # about the binary. "V2" here means this script observes a specific hook canary
14
+ # against an OpenCode v2.0.18 host. It does not establish a stable
15
+ # `@opencode/plugin@2.0.18` SDK contract or compatibility across the full 2.x
16
+ # line. This project is V2-exclusive, so there is a single host leg to validate.
17
+ #
18
+ # Modes (combinable):
19
+ # --prepare Build tarball, install pantheon-opencode + the OpenCode V2
20
+ # binary in the sandbox prefix, headless init, and generate
21
+ # the project config and sandbox run-test.sh.
22
+ # --run v2 Base validation: opencode mcp list + doctor (config is
23
+ # regenerated first).
24
+ # --prompts Prompt battery via `opencode run --format json`.
25
+ # --cost Offline pantheon_cost probe.
26
+ # --rehydrate Offline context_rehydrate + context_session_summary probe.
27
+ # --hooks V2 hook canary: proves hook callbacks FIRE through the
28
+ # sandbox's V2 binary (not merely that a plugin loaded).
29
+ # This does not test transform callback effects or Pantheon
30
+ # execute.before security enforcement (deferred to S6).
31
+ # --reset Wipe the sandbox root.
32
+ # --help Usage.
33
+ #
34
+ # Env overrides:
35
+ # PANTHEON_SANDBOX_ROOT Sandbox root (default: ~/pantheon-sandbox)
36
+ # OPENCODE_V2_SPEC npm spec providing the V2 binary
37
+ # (default: @opencode/cli@latest). NOTE: this is an
38
+ # install spec only — it is not a dependency of this
39
+ # package, and no code imports it.
40
+ # PANTHEON_REPO Repository the sandbox is built from (default: the
41
+ # checkout this script lives in). Only read by the
42
+ # GENERATED run-test.sh, which cannot derive it from
43
+ # its own location and reads the .repo-dir file this
44
+ # script generates beside it. --reset refuses a
45
+ # sandbox root that overlaps either protected repo.
46
+ # PANTHEON_SANDBOX_MODEL Model used by init/prompts
47
+ # (default: opencode-go/mimo-v2.5)
48
+ # PANTHEON_PROMPT_TIMEOUT Per-prompt timeout in seconds (default: 300)
49
+ # Exit codes: 0 = every required check returned explicit PASS,
50
+ # 1 = any failure or untested check (see reports in the sandbox root),
51
+ # 2 = usage error, 3 = sandbox not prepared.
52
+ set -euo pipefail
53
+
54
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
55
+ # Derived from this script's own location so the sandbox is always built from
56
+ # the checkout that CONTAINS this harness. It must never be inferred from a
57
+ # sibling directory: a sibling may be a different checkout (or a different
58
+ # branch) with unrelated uncommitted work, and --prepare runs `npm pack` inside
59
+ # it, writing a tarball into that tree.
60
+ REPO_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
61
+
62
+ SANDBOX_ROOT="${PANTHEON_SANDBOX_ROOT:-$HOME/pantheon-sandbox}"
63
+ SANDBOX_HOME="$SANDBOX_ROOT/home"
64
+ NPM_PREFIX="$SANDBOX_HOME/.npm-global"
65
+
66
+ OPENCODE_V2_SPEC="${OPENCODE_V2_SPEC:-@opencode/cli@latest}"
67
+ PANTHEON_SANDBOX_MODEL="${PANTHEON_SANDBOX_MODEL:-opencode-go/mimo-v2.5}"
68
+ PANTHEON_PROMPT_TIMEOUT="${PANTHEON_PROMPT_TIMEOUT:-300}"
69
+
70
+ V2_BIN="${OPENCODE_V2_BIN:-opencode2}"
71
+
72
+ # OpenCode V2's CLI talks to its background service for `mcp list`. Keep the
73
+ # probe hermetic: an inherited config, database, or PORT can otherwise make
74
+ # the command query a different service and report a false result.
75
+ V2_CONFIG="${PANTHEON_V2_CONFIG:-$SANDBOX_ROOT/project-v2/opencode.json}"
76
+ V2_DB="${PANTHEON_V2_DB:-$SANDBOX_ROOT/opencode-v2.db}"
77
+ V2_PORT="${PANTHEON_V2_PORT:-49376}"
78
+ V2_LOOPBACK_HOST="${PANTHEON_V2_HOST:-127.0.0.1}"
79
+ V2_HANDSHAKE_TIMEOUT="${PANTHEON_V2_HANDSHAKE_TIMEOUT:-60}"
80
+ V2_MCP_LIST_TIMEOUT="${PANTHEON_V2_MCP_LIST_TIMEOUT:-15}"
81
+ V2_SERVER_PID=""
82
+ V2_SERVER_LOG="$SANDBOX_ROOT/v2-server.log"
83
+ V2_SERVICE_STATE="$SANDBOX_HOME/.local/state/opencode/service.json"
84
+
85
+ # The single target version. Kept as a name so reports and prompts keep their
86
+ # per-version labels without reintroducing a second leg.
87
+ TARGET_VERSION="v2"
88
+
89
+ REPORT_FILE="$SANDBOX_ROOT/prompts-report.md"
90
+ COST_REPORT_FILE="$SANDBOX_ROOT/pantheon-cost-report.md"
91
+ REHYDRATE_REPORT_FILE="$SANDBOX_ROOT/context-rehydrate-report.md"
92
+ EXTRACT_PY="$SANDBOX_ROOT/.prompt-extract-json.py"
93
+
94
+ MODE_PREPARE=0
95
+ MODE_RESET=0
96
+ MODE_PROMPTS=0
97
+ MODE_COST=0
98
+ MODE_REHYDRATE=0
99
+ MODE_HOOKS=0
100
+ RUN_VERSION=""
101
+
102
+ usage() {
103
+ sed -n '2,45p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//'
104
+ exit 2
105
+ }
106
+
107
+ log() { printf '%s\n' "$*"; }
108
+ warn() { printf 'WARN: %s\n' "$*" >&2; }
109
+ die() { printf 'ERROR: %s\n' "$*" >&2; exit 1; }
110
+
111
+ project_dir() { printf '%s/project-%s' "$SANDBOX_ROOT" "$TARGET_VERSION"; }
112
+
113
+ # Resolve a binary STRICTLY inside the sandbox npm prefix — never fall back to
114
+ # the dev PATH, otherwise a non-prepared sandbox would silently test the host
115
+ # installation.
116
+ sandbox_bin_for() { # name → prints path; rc 1 if not inside sandbox prefix
117
+ local p
118
+ p="$(command -v "$1" 2>/dev/null || true)"
119
+ case "$p" in
120
+ "$NPM_PREFIX"/*) printf '%s' "$p" ;;
121
+ *) return 1 ;;
122
+ esac
123
+ }
124
+
125
+ sandbox_bin() { # echoes the V2 binary path; rc 1 if missing
126
+ sandbox_bin_for "$V2_BIN"
127
+ }
128
+
129
+ # ── Sandbox environment isolation ─────────────────────────────────────────────
130
+
131
+ sandbox_env() {
132
+ export HOME="$SANDBOX_HOME"
133
+ export PATH="$NPM_PREFIX/bin:$HOME/.config/opencode/.venv/bin:$PATH"
134
+ export npm_config_prefix="$NPM_PREFIX"
135
+ export XDG_STATE_HOME="$SANDBOX_HOME/.local/state"
136
+ # Do not inherit an OpenChamber/developer-machine config selector. The V2
137
+ # project config is created by prepare_project and is the only config this
138
+ # gate is allowed to inspect.
139
+ export OPENCODE_CONFIG_DIR="$(dirname "$V2_CONFIG")"
140
+ export OPENCODE_DB="$V2_DB"
141
+ export PANTHEON_V2_PORT="$V2_PORT"
142
+ export PORT="$V2_PORT"
143
+ unset OPENCODE_CONFIG OPENCODE_CONFIG_CONTENT OPENCODE_CONFIG_PROJECT_DISABLE
144
+ mkdir -p "$SANDBOX_ROOT" "$(dirname "$V2_DB")" "$(dirname "$V2_SERVICE_STATE")"
145
+ }
146
+
147
+ stop_v2_service() {
148
+ if [ -n "$V2_SERVER_PID" ] && kill -0 "$V2_SERVER_PID" 2>/dev/null; then
149
+ kill "$V2_SERVER_PID" 2>/dev/null || true
150
+ wait "$V2_SERVER_PID" 2>/dev/null || true
151
+ fi
152
+ rm -f "$V2_SERVICE_STATE"
153
+ V2_SERVER_PID=""
154
+ }
155
+
156
+ print_v2_diagnostics() {
157
+ printf '%s\n' '--- V2 service registration ---' >&2
158
+ if [ -f "$V2_SERVICE_STATE" ]; then
159
+ cat "$V2_SERVICE_STATE" >&2 || true
160
+ printf '\n' >&2
161
+ else
162
+ printf 'missing service registration: %s\n' "$V2_SERVICE_STATE" >&2
163
+ fi
164
+ printf '%s\n' '--- V2 service log ---' >&2
165
+ if [ -f "$V2_SERVER_LOG" ]; then
166
+ tail -200 "$V2_SERVER_LOG" >&2 || true
167
+ else
168
+ printf 'missing service log: %s\n' "$V2_SERVER_LOG" >&2
169
+ fi
170
+ local runtime_log="$HOME/.local/share/opencode/log/opencode.log"
171
+ printf '%s\n' '--- OpenCode runtime log ---' >&2
172
+ if [ -f "$runtime_log" ]; then
173
+ tail -200 "$runtime_log" >&2 || true
174
+ else
175
+ printf 'missing runtime log: %s\n' "$runtime_log" >&2
176
+ fi
177
+ }
178
+
179
+ wait_for_v2_service_registration() {
180
+ local registration_url="http://$V2_LOOPBACK_HOST:$V2_PORT"
181
+ local registration_pid="$V2_SERVER_PID"
182
+ local attempt
183
+
184
+ for attempt in $(seq 1 "$V2_HANDSHAKE_TIMEOUT"); do
185
+ if ! kill -0 "$V2_SERVER_PID" 2>/dev/null; then
186
+ printf 'V2 service exited before registration was written.\n' >&2
187
+ print_v2_diagnostics
188
+ return 1
189
+ fi
190
+ if [ -f "$V2_SERVICE_STATE" ] \
191
+ && SERVICE_STATE="$V2_SERVICE_STATE" \
192
+ REGISTRATION_URL="$registration_url" \
193
+ REGISTRATION_PID="$registration_pid" \
194
+ python3 - <<'PY'
195
+ import json
196
+ import os
197
+ from pathlib import Path
198
+
199
+ path = Path(os.environ["SERVICE_STATE"])
200
+ try:
201
+ state = json.loads(path.read_text(encoding="utf-8"))
202
+ except (OSError, json.JSONDecodeError):
203
+ raise SystemExit(1)
204
+
205
+ expected_url = os.environ["REGISTRATION_URL"]
206
+ expected_pid = int(os.environ["REGISTRATION_PID"])
207
+ if not isinstance(state, dict):
208
+ raise SystemExit(1)
209
+ if state.get("url") != expected_url or state.get("pid") != expected_pid:
210
+ raise SystemExit(1)
211
+ if not all(isinstance(state.get(key), str) and state[key] for key in ("id", "version", "password")):
212
+ raise SystemExit(1)
213
+ PY
214
+ then
215
+ return 0
216
+ fi
217
+ sleep 1
218
+ done
219
+ printf 'Timed out after %ss waiting for V2 service registration matching url=%s pid=%s.\n' \
220
+ "$V2_HANDSHAKE_TIMEOUT" "$registration_url" "$registration_pid" >&2
221
+ print_v2_diagnostics
222
+ return 1
223
+ }
224
+
225
+ wait_for_v2_mcp_registrations() {
226
+ local runtime_dir="$1" python_bin="$2" attempt
227
+ for attempt in $(seq 1 "$V2_HANDSHAKE_TIMEOUT"); do
228
+ if ! kill -0 "$V2_SERVER_PID" 2>/dev/null; then
229
+ echo 'ERROR: V2 service exited before all five MCP registrations completed' >&2
230
+ print_v2_diagnostics
231
+ return 1
232
+ fi
233
+ if SERVICE_LOG="$V2_SERVER_LOG" RUNTIME_DIR="$runtime_dir" MCP_PYTHON="$python_bin" \
234
+ python3 - <<'PY'
235
+ import os
236
+ import re
237
+ from pathlib import Path
238
+
239
+ try:
240
+ log = Path(os.environ["SERVICE_LOG"]).read_text(encoding="utf-8")
241
+ except OSError:
242
+ raise SystemExit(1)
243
+ expected = {
244
+ "code_mode.py",
245
+ "memory_mcp.py",
246
+ "mcp_persistence.py",
247
+ "mcp_resources.py",
248
+ "pantheon_vision.py",
249
+ }
250
+ pattern = re.compile(
251
+ r"spawning process \{\s*"
252
+ r"command:\s*\"(?P<command>[^\"]+)\".*?"
253
+ r"args:\s*\[\s*\"(?P<script>[^\"]+)\"\s*\].*?"
254
+ r"cwd:\s*\"(?P<cwd>[^\"]+)\"",
255
+ re.DOTALL,
256
+ )
257
+ records = list(pattern.finditer(log))
258
+ runtime_dir = os.environ["RUNTIME_DIR"]
259
+ python_bin = os.environ["MCP_PYTHON"]
260
+ registered = set()
261
+ for record in records:
262
+ command = record.group("command")
263
+ script = Path(record.group("script")).name
264
+ cwd = record.group("cwd")
265
+ if command != python_bin or cwd != runtime_dir or script not in expected:
266
+ raise SystemExit(1)
267
+ registered.add(script)
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:
272
+ raise SystemExit(1)
273
+ PY
274
+ then
275
+ return 0
276
+ fi
277
+ sleep 1
278
+ done
279
+ printf 'ERROR: timed out after %ss waiting for exactly five MCP registrations in the active sandbox\n' \
280
+ "$V2_HANDSHAKE_TIMEOUT" >&2
281
+ print_v2_diagnostics
282
+ return 1
283
+ }
284
+
285
+ wait_for_v2_handshakes() {
286
+ local runtime_log="$HOME/.local/share/opencode/log/opencode.log"
287
+ local names=(
288
+ pantheon-code-mode
289
+ pantheon-memory
290
+ pantheon-persistence
291
+ pantheon-resources
292
+ pantheon-vision
293
+ )
294
+ local attempt name ready connected_names connected_count
295
+ for attempt in $(seq 1 "$V2_HANDSHAKE_TIMEOUT"); do
296
+ if ! kill -0 "$V2_SERVER_PID" 2>/dev/null; then
297
+ printf 'V2 service exited before all MCP handshakes completed.\n' >&2
298
+ print_v2_diagnostics
299
+ return 1
300
+ fi
301
+ ready=1
302
+ for name in "${names[@]}"; do
303
+ if [ ! -f "$runtime_log" ] || ! grep -Fq "message=\"mcp connected\" server=$name" "$runtime_log"; then
304
+ ready=0
305
+ break
306
+ fi
307
+ done
308
+ if [ "$ready" -eq 1 ]; then
309
+ # A stale or inherited MCP must not turn five expected handshakes into a
310
+ # false PASS. The service log is dedicated to this run and must contain
311
+ # exactly the five managed server names.
312
+ connected_names="$({ grep -oE 'message="mcp connected" server=[^ ]+' "$runtime_log" || true; } \
313
+ | sed 's/.*server=//' | sort -u)"
314
+ connected_count="$(printf '%s\n' "$connected_names" | sed '/^$/d' | wc -l)"
315
+ if [ "$connected_count" -eq 5 ] \
316
+ && ! printf '%s\n' "$connected_names" | grep -Evx \
317
+ 'pantheon-code-mode|pantheon-memory|pantheon-persistence|pantheon-resources|pantheon-vision' \
318
+ >/dev/null; then
319
+ return 0
320
+ fi
321
+ ready=0
322
+ fi
323
+ sleep 1
324
+ done
325
+ printf 'Timed out after %ss waiting for exactly five MCP handshakes.\n' \
326
+ "$V2_HANDSHAKE_TIMEOUT" >&2
327
+ print_v2_diagnostics
328
+ return 1
329
+ }
330
+
331
+ start_v2_service() { # binary project
332
+ local bin="$1" project="$2" ready=0 attempt
333
+ if [ -n "$V2_SERVER_PID" ] && kill -0 "$V2_SERVER_PID" 2>/dev/null; then
334
+ return 0
335
+ fi
336
+
337
+ # Never attach to an unrelated service on the dedicated port. A successful
338
+ # health probe before our process starts is an environmental collision, not
339
+ # a reason to silently reuse that service.
340
+ if curl --fail --silent --show-error --max-time 1 \
341
+ "http://$V2_LOOPBACK_HOST:${V2_PORT}/global/health" >/dev/null 2>&1; then
342
+ die "dedicated V2 port ${V2_PORT} is already in use; refusing to reuse an unrelated service"
343
+ fi
344
+
345
+ rm -f "$V2_SERVER_LOG" "$V2_SERVICE_STATE" "$HOME/.local/share/opencode/log/opencode.log"
346
+ (cd "$project" && "$bin" serve --hostname "$V2_LOOPBACK_HOST" --port "$V2_PORT" --service) \
347
+ >"$V2_SERVER_LOG" 2>&1 &
348
+ V2_SERVER_PID=$!
349
+
350
+ for attempt in $(seq 1 "$V2_HANDSHAKE_TIMEOUT"); do
351
+ if ! kill -0 "$V2_SERVER_PID" 2>/dev/null; then
352
+ printf 'V2 service exited before becoming healthy.\n' >&2
353
+ print_v2_diagnostics
354
+ return 1
355
+ fi
356
+ if curl --fail --silent --show-error --max-time 2 \
357
+ "http://$V2_LOOPBACK_HOST:${V2_PORT}/global/health" >/dev/null 2>&1; then
358
+ ready=1
359
+ break
360
+ fi
361
+ sleep 1
362
+ done
363
+ if [ "$ready" -ne 1 ]; then
364
+ printf 'Timed out after %ss waiting for V2 service health on port %s.\n' \
365
+ "$V2_HANDSHAKE_TIMEOUT" "$V2_PORT" >&2
366
+ print_v2_diagnostics
367
+ return 1
368
+ fi
369
+ if ! wait_for_v2_service_registration; then
370
+ return 1
371
+ fi
372
+ }
373
+
374
+ trap stop_v2_service EXIT
375
+
376
+ # ── Gate (b): sandbox run-test.sh with pantheon://agents content validation ──
377
+
378
+ write_run_test_sh() {
379
+ # Quoted heredoc: the body is emitted verbatim, so nothing inside is expanded
380
+ # at generation time. REPO_DIR is the one value the body needs, and it is
381
+ # handed over as its own generated file instead of a placeholder baked into
382
+ # the shell source: interpolating it meant escaping for TWO languages (sed
383
+ # replacement text AND the double-quoted shell context it lands in), and only
384
+ # the first set was escaped — a path containing `$`, a backtick, a backslash
385
+ # or a quote was silently rewritten or executed by the generated script.
386
+ printf '%s\n' "$REPO_DIR" > "$SANDBOX_ROOT/.repo-dir"
387
+ cat > "$SANDBOX_ROOT/run-test.sh" <<'RUNTEST'
388
+ #!/usr/bin/env bash
389
+ # Generated by pantheon-opencode scripts/test-opencode-v2-sandbox.sh --prepare
390
+ # Validates the global install: the V2 binary, exactly 5 connected MCPs, doctor
391
+ # 0 errors, AND the CONTENT of pantheon://agents (zeus/hermes present).
392
+ set -euo pipefail
393
+
394
+ SANDBOX_DIR="$(cd "$(dirname "$0")" && pwd)"
395
+ SANDBOX_HOME="$SANDBOX_DIR/home"
396
+ NPM_PREFIX="$SANDBOX_HOME/.npm-global"
397
+ PANTHEON_GLOBAL="$SANDBOX_HOME/.config/opencode"
398
+ SANDBOX_VENV="$PANTHEON_GLOBAL/.venv"
399
+ # The checkout this harness ran from, written by the generator into the
400
+ # .repo-dir file next to this script. It used to be a placeholder baked into
401
+ # this line and substituted with sed afterwards, but the sandbox lives outside
402
+ # any repo so the value cannot be derived from this file's own location either.
403
+ # Reading a file the generator wrote keeps the path out of the shell source, so
404
+ # no escaping can be wrong. PANTHEON_REPO may still override it; the generator's
405
+ # --reset guard refuses to run when that points inside a repo, so an override
406
+ # can never turn this into a repo-wiping target.
407
+ REPO_DIR="${PANTHEON_REPO:-$(cat "$SANDBOX_DIR/.repo-dir")}"
408
+
409
+ echo "=== Pantheon Sandbox Test (OpenCode V2) ==="
410
+ echo "Sandbox: $SANDBOX_DIR"
411
+ echo "Repo: $REPO_DIR"
412
+
413
+ export HOME="$SANDBOX_HOME"
414
+ export PATH="$NPM_PREFIX/bin:$SANDBOX_VENV/bin:$PATH"
415
+ export npm_config_prefix="$NPM_PREFIX"
416
+ export XDG_STATE_HOME="$SANDBOX_HOME/.local/state"
417
+ # The V2 CLI resolves `mcp list` through its service. Keep generated sandbox
418
+ # runs isolated from any config/database/port inherited from the host.
419
+ V2_CONFIG="$SANDBOX_DIR/project-v2/opencode.json"
420
+ V2_DB="${PANTHEON_V2_DB:-$SANDBOX_DIR/opencode-v2.db}"
421
+ V2_PORT="${PANTHEON_V2_PORT:-49376}"
422
+ V2_LOOPBACK_HOST="${PANTHEON_V2_HOST:-127.0.0.1}"
423
+ V2_HANDSHAKE_TIMEOUT="${PANTHEON_V2_HANDSHAKE_TIMEOUT:-60}"
424
+ V2_MCP_LIST_TIMEOUT="${PANTHEON_V2_MCP_LIST_TIMEOUT:-15}"
425
+ V2_SERVER_LOG="$SANDBOX_DIR/v2-server.log"
426
+ V2_SERVICE_STATE="$SANDBOX_HOME/.local/state/opencode/service.json"
427
+ V2_SERVER_PID=""
428
+ export OPENCODE_CONFIG_DIR="$(dirname "$V2_CONFIG")"
429
+ export OPENCODE_DB="$V2_DB"
430
+ export PANTHEON_V2_PORT="$V2_PORT"
431
+ export PORT="$V2_PORT"
432
+ unset OPENCODE_CONFIG OPENCODE_CONFIG_CONTENT OPENCODE_CONFIG_PROJECT_DISABLE
433
+ mkdir -p "$(dirname "$V2_SERVICE_STATE")"
434
+
435
+ stop_v2_service() {
436
+ if [ -n "$V2_SERVER_PID" ] && kill -0 "$V2_SERVER_PID" 2>/dev/null; then
437
+ kill "$V2_SERVER_PID" 2>/dev/null || true
438
+ wait "$V2_SERVER_PID" 2>/dev/null || true
439
+ fi
440
+ rm -f "$V2_SERVICE_STATE"
441
+ V2_SERVER_PID=""
442
+ }
443
+
444
+ print_v2_diagnostics() {
445
+ printf '%s\n' '--- V2 service registration ---' >&2
446
+ if [ -f "$V2_SERVICE_STATE" ]; then
447
+ cat "$V2_SERVICE_STATE" >&2 || true
448
+ printf '\n' >&2
449
+ else
450
+ printf 'missing service registration: %s\n' "$V2_SERVICE_STATE" >&2
451
+ fi
452
+ printf '%s\n' '--- V2 service log ---' >&2
453
+ [ -f "$V2_SERVER_LOG" ] && tail -200 "$V2_SERVER_LOG" >&2 || true
454
+ local runtime_log="$HOME/.local/share/opencode/log/opencode.log"
455
+ printf '%s\n' '--- OpenCode runtime log ---' >&2
456
+ [ -f "$runtime_log" ] && tail -200 "$runtime_log" >&2 || true
457
+ }
458
+
459
+ wait_for_v2_service_registration() {
460
+ local registration_url="http://$V2_LOOPBACK_HOST:$V2_PORT"
461
+ local registration_pid="$V2_SERVER_PID"
462
+ local attempt
463
+ for attempt in $(seq 1 "$V2_HANDSHAKE_TIMEOUT"); do
464
+ if ! kill -0 "$V2_SERVER_PID" 2>/dev/null; then
465
+ echo 'ERROR: V2 service exited before registration was written' >&2
466
+ print_v2_diagnostics
467
+ return 1
468
+ fi
469
+ if [ -f "$V2_SERVICE_STATE" ] \
470
+ && SERVICE_STATE="$V2_SERVICE_STATE" \
471
+ REGISTRATION_URL="$registration_url" \
472
+ REGISTRATION_PID="$registration_pid" \
473
+ python3 - <<'PY'
474
+ import json
475
+ import os
476
+ from pathlib import Path
477
+
478
+ path = Path(os.environ["SERVICE_STATE"])
479
+ try:
480
+ state = json.loads(path.read_text(encoding="utf-8"))
481
+ except (OSError, json.JSONDecodeError):
482
+ raise SystemExit(1)
483
+ expected_url = os.environ["REGISTRATION_URL"]
484
+ expected_pid = int(os.environ["REGISTRATION_PID"])
485
+ if not isinstance(state, dict):
486
+ raise SystemExit(1)
487
+ if state.get("url") != expected_url or state.get("pid") != expected_pid:
488
+ raise SystemExit(1)
489
+ if not all(isinstance(state.get(key), str) and state[key] for key in ("id", "version", "password")):
490
+ raise SystemExit(1)
491
+ PY
492
+ then
493
+ return 0
494
+ fi
495
+ sleep 1
496
+ done
497
+ printf 'ERROR: timed out after %ss waiting for V2 service registration matching url=%s pid=%s\n' \
498
+ "$V2_HANDSHAKE_TIMEOUT" "$registration_url" "$registration_pid" >&2
499
+ print_v2_diagnostics
500
+ return 1
501
+ }
502
+
503
+ wait_for_v2_mcp_registrations() {
504
+ local runtime_dir="$1" python_bin="$2" attempt
505
+ for attempt in $(seq 1 "$V2_HANDSHAKE_TIMEOUT"); do
506
+ if ! kill -0 "$V2_SERVER_PID" 2>/dev/null; then
507
+ echo 'ERROR: V2 service exited before all five MCP registrations completed' >&2
508
+ print_v2_diagnostics
509
+ return 1
510
+ fi
511
+ if SERVICE_LOG="$V2_SERVER_LOG" RUNTIME_DIR="$runtime_dir" MCP_PYTHON="$python_bin" \
512
+ python3 - <<'PY'
513
+ import os
514
+ import re
515
+ from pathlib import Path
516
+
517
+ try:
518
+ log = Path(os.environ["SERVICE_LOG"]).read_text(encoding="utf-8")
519
+ except OSError:
520
+ raise SystemExit(1)
521
+ expected = {
522
+ "code_mode.py",
523
+ "memory_mcp.py",
524
+ "mcp_persistence.py",
525
+ "mcp_resources.py",
526
+ "pantheon_vision.py",
527
+ }
528
+ pattern = re.compile(
529
+ r"spawning process \{\s*"
530
+ r"command:\s*\"(?P<command>[^\"]+)\".*?"
531
+ r"args:\s*\[\s*\"(?P<script>[^\"]+)\"\s*\].*?"
532
+ r"cwd:\s*\"(?P<cwd>[^\"]+)\"",
533
+ re.DOTALL,
534
+ )
535
+ records = list(pattern.finditer(log))
536
+ runtime_dir = os.environ["RUNTIME_DIR"]
537
+ python_bin = os.environ["MCP_PYTHON"]
538
+ registered = set()
539
+ for record in records:
540
+ command = record.group("command")
541
+ script = Path(record.group("script")).name
542
+ cwd = record.group("cwd")
543
+ if command != python_bin or cwd != runtime_dir or script not in expected:
544
+ raise SystemExit(1)
545
+ registered.add(script)
546
+ if len(records) != len(expected) or registered != expected:
547
+ raise SystemExit(1)
548
+ PY
549
+ then
550
+ return 0
551
+ fi
552
+ sleep 1
553
+ done
554
+ printf 'ERROR: timed out after %ss waiting for exactly five MCP registrations in the active sandbox\n' \
555
+ "$V2_HANDSHAKE_TIMEOUT" >&2
556
+ print_v2_diagnostics
557
+ return 1
558
+ }
559
+
560
+ start_v2_service() {
561
+ local bin="$1" project="$2" attempt ready=0
562
+ if [ -n "$V2_SERVER_PID" ] && kill -0 "$V2_SERVER_PID" 2>/dev/null; then
563
+ return 0
564
+ fi
565
+ if curl --fail --silent --show-error --max-time 1 \
566
+ "http://$V2_LOOPBACK_HOST:${V2_PORT}/global/health" >/dev/null 2>&1; then
567
+ echo "ERROR: dedicated V2 port $V2_PORT is already in use" >&2
568
+ return 1
569
+ fi
570
+ rm -f "$V2_SERVER_LOG" "$V2_SERVICE_STATE" "$HOME/.local/share/opencode/log/opencode.log"
571
+ (cd "$project" && "$bin" serve --hostname "$V2_LOOPBACK_HOST" --port "$V2_PORT" --service) \
572
+ >"$V2_SERVER_LOG" 2>&1 &
573
+ V2_SERVER_PID=$!
574
+ for attempt in $(seq 1 "$V2_HANDSHAKE_TIMEOUT"); do
575
+ if ! kill -0 "$V2_SERVER_PID" 2>/dev/null; then
576
+ echo 'ERROR: V2 service exited before becoming healthy' >&2
577
+ print_v2_diagnostics
578
+ return 1
579
+ fi
580
+ if curl --fail --silent --show-error --max-time 2 \
581
+ "http://$V2_LOOPBACK_HOST:${V2_PORT}/global/health" >/dev/null 2>&1; then
582
+ ready=1
583
+ break
584
+ fi
585
+ sleep 1
586
+ done
587
+ if [ "$ready" -ne 1 ]; then
588
+ echo "ERROR: timed out waiting for V2 service on port $V2_PORT" >&2
589
+ print_v2_diagnostics
590
+ return 1
591
+ fi
592
+ if ! wait_for_v2_service_registration; then
593
+ return 1
594
+ fi
595
+ }
596
+
597
+ wait_for_v2_handshakes() {
598
+ local runtime_log="$HOME/.local/share/opencode/log/opencode.log"
599
+ local names=(pantheon-code-mode pantheon-memory pantheon-persistence pantheon-resources pantheon-vision)
600
+ local attempt name ready connected_names connected_count
601
+ for attempt in $(seq 1 "$V2_HANDSHAKE_TIMEOUT"); do
602
+ if ! kill -0 "$V2_SERVER_PID" 2>/dev/null; then
603
+ echo 'ERROR: V2 service exited before all MCP handshakes completed' >&2
604
+ print_v2_diagnostics
605
+ return 1
606
+ fi
607
+ ready=1
608
+ for name in "${names[@]}"; do
609
+ if [ ! -f "$runtime_log" ] || ! grep -Fq "message=\"mcp connected\" server=$name" "$runtime_log"; then
610
+ ready=0
611
+ break
612
+ fi
613
+ done
614
+ if [ "$ready" -eq 1 ]; then
615
+ connected_names="$({ grep -oE 'message=\"mcp connected\" server=[^ ]+' "$runtime_log" || true; } \
616
+ | sed 's/.*server=//' | sort -u)"
617
+ connected_count="$(printf '%s\n' "$connected_names" | sed '/^$/d' | wc -l)"
618
+ if [ "$connected_count" -eq 5 ] \
619
+ && ! printf '%s\n' "$connected_names" | grep -Evx \
620
+ 'pantheon-code-mode|pantheon-memory|pantheon-persistence|pantheon-resources|pantheon-vision' \
621
+ >/dev/null; then
622
+ return 0
623
+ fi
624
+ ready=0
625
+ fi
626
+ sleep 1
627
+ done
628
+ echo "ERROR: timed out waiting for exactly five MCP handshakes" >&2
629
+ print_v2_diagnostics
630
+ return 1
631
+ }
632
+
633
+ rewrite_v2_mcp_config() {
634
+ local config_path="$V2_CONFIG"
635
+ local runtime_dir="$SANDBOX_DIR/project-v2/.opencode"
636
+ local python_bin="$SANDBOX_DIR/project-v2/.venv/bin/python3"
637
+ [ -f "$config_path" ] || { echo "ERROR: V2 config was not generated: $config_path" >&2; return 1; }
638
+ [ -x "$python_bin" ] || { echo "ERROR: V2 MCP Python is missing: $python_bin" >&2; return 1; }
639
+ CONFIG_PATH="$config_path" RUNTIME_DIR="$runtime_dir" MCP_PYTHON="$python_bin" \
640
+ python3 - <<'PY'
641
+ import json
642
+ import os
643
+ from pathlib import Path
644
+
645
+ config_path = Path(os.environ["CONFIG_PATH"])
646
+ runtime_dir = os.environ["RUNTIME_DIR"]
647
+ python_bin = os.environ["MCP_PYTHON"]
648
+ managed = {
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",
654
+ }
655
+ try:
656
+ config = json.loads(config_path.read_text(encoding="utf-8"))
657
+ except (OSError, json.JSONDecodeError) as exc:
658
+ raise SystemExit(f"cannot read V2 config {config_path}: {exc}")
659
+ if not isinstance(config, dict):
660
+ raise SystemExit(f"V2 config must be an object: {config_path}")
661
+ mcp = config.setdefault("mcp", {})
662
+ if not isinstance(mcp, dict):
663
+ raise SystemExit("V2 config mcp section must be an object")
664
+ for name, script in managed.items():
665
+ entry = mcp.get(name) if isinstance(mcp.get(name), dict) else {}
666
+ entry.pop("disabled", None)
667
+ entry.update(type="local", cwd=runtime_dir, command=[python_bin, f"scripts/{script}"], enabled=True)
668
+ mcp[name] = entry
669
+ tmp_path = config_path.with_name(config_path.name + ".sandbox-tmp")
670
+ tmp_path.write_text(json.dumps(config, indent=2) + "\n", encoding="utf-8")
671
+ os.replace(tmp_path, config_path)
672
+ validated = json.loads(config_path.read_text(encoding="utf-8"))
673
+ for name, script in managed.items():
674
+ entry = validated.get("mcp", {}).get(name)
675
+ expected = {"type": "local", "cwd": runtime_dir, "command": [python_bin, f"scripts/{script}"], "enabled": True}
676
+ if not isinstance(entry, dict) or any(entry.get(key) != value for key, value in expected.items()):
677
+ raise SystemExit(f"stale or invalid paths remain for {name} in {config_path}")
678
+ if not Path(entry["cwd"]).is_dir() or not Path(entry["command"][0]).is_file() or not (Path(entry["cwd"]) / entry["command"][1]).is_file():
679
+ raise SystemExit(f"active sandbox path is missing for {name}: {entry}")
680
+ PY
681
+ }
682
+
683
+ trap stop_v2_service EXIT
684
+
685
+ STATUS_FAIL=0
686
+ STATUS_PASS=0
687
+
688
+ record_status() { # label status detail
689
+ local label="$1" status="$2" detail="$3"
690
+ case "$status" in
691
+ PASS) STATUS_PASS=$((STATUS_PASS + 1)) ;;
692
+ FAIL) STATUS_FAIL=$((STATUS_FAIL + 1)) ;;
693
+ *) STATUS_FAIL=$((STATUS_FAIL + 1)); status="FAIL" ;;
694
+ esac
695
+ printf '%s: %s — %s\n' "$status" "$label" "$detail"
696
+ }
697
+
698
+ check_binary() { # label binary
699
+ local label="$1" bin="$2" out rc=0
700
+ if ! command -v "$bin" >/dev/null 2>&1; then
701
+ record_status "$label" "FAIL" "binary '$bin' is not installed in the sandbox prefix"
702
+ return 0
703
+ fi
704
+ set +e
705
+ out="$("$bin" --version 2>&1)"
706
+ rc=$?
707
+ set -e
708
+ if [ "$rc" -eq 0 ]; then
709
+ record_status "$label" "PASS" "${out:-version command exited 0}"
710
+ else
711
+ record_status "$label" "FAIL" "version check exited $rc"
712
+ fi
713
+ }
714
+
715
+ check_mcp() { # label binary project
716
+ local label="$1" bin="$2" project="$3" out connected_count rc=0 list_file list_pid
717
+ local final_file final_pid final_rc final_count
718
+ if ! command -v "$bin" >/dev/null 2>&1; then
719
+ record_status "$label MCP" "FAIL" "binary '$bin' is not installed in the sandbox prefix"
720
+ return 0
721
+ fi
722
+ if [ ! -d "$project" ]; then
723
+ record_status "$label MCP" "FAIL" "project directory is missing: $project"
724
+ return 0
725
+ fi
726
+ if ! start_v2_service "$bin" "$project"; then
727
+ record_status "$label MCP" "FAIL" "V2 service did not become healthy"
728
+ return 0
729
+ fi
730
+
731
+ # `mcp list` is the operation that causes OpenCode V2 to launch the MCP
732
+ # subprocesses. Start it immediately after health, before waiting for the
733
+ # handshakes; waiting first deadlocks forever because those subprocesses are
734
+ # otherwise never loaded.
735
+ list_file="$(mktemp "$SANDBOX_DIR/v2-mcp-list.XXXXXX")"
736
+ (cd "$project" && timeout --foreground "$V2_MCP_LIST_TIMEOUT" "$bin" mcp list) \
737
+ >"$list_file" 2>&1 &
738
+ list_pid=$!
739
+ if ! wait_for_v2_mcp_registrations "$project/.opencode" "$project/.venv/bin/python3"; then
740
+ kill "$list_pid" 2>/dev/null || true
741
+ wait "$list_pid" 2>/dev/null || true
742
+ echo "--- mcp list output (preserved at $list_file) ---" >&2
743
+ cat "$list_file" >&2 || true
744
+ record_status "$label MCP" "FAIL" "MCP registrations did not match the active sandbox"
745
+ return 0
746
+ fi
747
+ if wait_for_v2_handshakes; then
748
+ :
749
+ else
750
+ kill "$list_pid" 2>/dev/null || true
751
+ wait "$list_pid" 2>/dev/null || true
752
+ echo "--- mcp list output (preserved at $list_file) ---" >&2
753
+ cat "$list_file" >&2 || true
754
+ record_status "$label MCP" "FAIL" "MCP handshakes did not become ready after mcp list started"
755
+ return 0
756
+ fi
757
+ if wait "$list_pid"; then
758
+ rc=0
759
+ else
760
+ rc=$?
761
+ fi
762
+ out="$(cat "$list_file")"
763
+ rm -f "$list_file"
764
+ connected_count="$(printf '%s' "$out" | grep -Eic '(^|[^[:alnum:]_])connected([^[:alnum:]_]|$)' || true)"
765
+ if [ "$rc" -eq 0 ] && [ "$connected_count" -ne 5 ]; then
766
+ # The first V2 beta `mcp list` call is the loader trigger and may return
767
+ # before the service has populated its MCP catalog. Re-query only after
768
+ # the registration and handshake gates above have proved the active five.
769
+ final_file="$(mktemp "$SANDBOX_DIR/v2-mcp-list.XXXXXX")"
770
+ (cd "$project" && timeout --foreground "$V2_MCP_LIST_TIMEOUT" "$bin" mcp list) \
771
+ >"$final_file" 2>&1 &
772
+ final_pid=$!
773
+ if wait "$final_pid"; then
774
+ final_rc=0
775
+ else
776
+ final_rc=$?
777
+ fi
778
+ out="$(cat "$final_file")"
779
+ rm -f "$final_file"
780
+ if [ "$final_rc" -ne 0 ]; then
781
+ rc="$final_rc"
782
+ fi
783
+ fi
784
+ out="$(printf '%s' "$out" | sed -E $'s/\x1B\\[[0-?]*[ -/]*[@-~]//g')"
785
+ printf '%s\n' "$out"
786
+ if [ "$rc" -ne 0 ]; then
787
+ record_status "$label MCP" "FAIL" "mcp list exited $rc (see output above)"
788
+ return 0
789
+ fi
790
+ connected_count="$(printf '%s' "$out" | grep -Eic '(^|[^[:alnum:]_])connected([^[:alnum:]_]|$)')" || connected_count=0
791
+ if [ "$connected_count" -eq 5 ] \
792
+ && ! printf '%s' "$out" | grep -Eiq 'failed|✘' \
793
+ && printf '%s' "$out" | grep -Fq 'pantheon-code-mode' \
794
+ && printf '%s' "$out" | grep -Fq 'pantheon-memory' \
795
+ && printf '%s' "$out" | grep -Fq 'pantheon-persistence' \
796
+ && printf '%s' "$out" | grep -Fq 'pantheon-resources' \
797
+ && printf '%s' "$out" | grep -Fq 'pantheon-vision'; then
798
+ record_status "$label MCP" "PASS" "exactly 5 connected MCPs"
799
+ else
800
+ record_status "$label MCP" "FAIL" "expected exactly 5 connected MCPs; found $connected_count or failures reported"
801
+ fi
802
+ }
803
+
804
+ echo "--- Binaries ---"
805
+ check_binary "V2 binary" opencode2
806
+
807
+ echo "--- Building tarball ---"
808
+ cd "$REPO_DIR"
809
+ npm pack --ignore-scripts 2>/dev/null || { echo "ERROR: npm pack failed"; exit 1; }
810
+ TGZ=$(ls -t pantheon-opencode-*.tgz | head -1)
811
+ npm rm -g pantheon-opencode 2>/dev/null || true
812
+ npm install -g "$TGZ"
813
+ if ! git -C "$REPO_DIR" ls-files --error-unmatch -- "$TGZ" >/dev/null 2>&1; then rm -f -- "$TGZ"; fi
814
+
815
+ echo "--- Init (headless) ---"
816
+ pantheon-opencode init --headless -y
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"
823
+
824
+ echo "--- MCP Validation ---"
825
+ check_mcp "V2" opencode2 "$SANDBOX_DIR/project-v2"
826
+
827
+ echo "--- Doctor ---"
828
+ if command -v pantheon-opencode >/dev/null 2>&1; then
829
+ set +e
830
+ DOCTOR_OUTPUT="$(pantheon-opencode doctor --target "$SANDBOX_DIR" 2>&1)"
831
+ DOCTOR_RC=$?
832
+ set -e
833
+ if [ "$DOCTOR_RC" -eq 0 ]; then
834
+ record_status "doctor" "PASS" "exit 0"
835
+ else
836
+ record_status "doctor" "FAIL" "doctor exited $DOCTOR_RC"
837
+ fi
838
+ else
839
+ record_status "doctor" "FAIL" "pantheon-opencode is not installed in the sandbox prefix"
840
+ fi
841
+
842
+ echo "--- Gate (b): pantheon://agents CONTENT check ---"
843
+ RESOURCES_SRV="$PANTHEON_GLOBAL/scripts/mcp_resources.py"
844
+ if [ ! -f "$RESOURCES_SRV" ]; then
845
+ record_status "pantheon://agents resource" "FAIL" "resources server not found at $RESOURCES_SRV"
846
+ else
847
+ set +e
848
+ AGENTS_CONTENT="$("$SANDBOX_VENV/bin/python" - "$RESOURCES_SRV" <<'PYEOF'
849
+ import asyncio, importlib.util, sys
850
+ from pathlib import Path
851
+ resource_path = Path(sys.argv[1]).resolve()
852
+ sys.path.insert(0, str(resource_path.parent))
853
+ spec = importlib.util.spec_from_file_location("mrs", sys.argv[1])
854
+ mod = importlib.util.module_from_spec(spec)
855
+ spec.loader.exec_module(mod)
856
+ print(asyncio.run(mod.list_agents()))
857
+ PYEOF
858
+ )"
859
+ AGENTS_RC=$?
860
+ set -e
861
+ printf '%s\n' "$AGENTS_CONTENT" | head -8
862
+ if [ "$AGENTS_RC" -ne 0 ]; then
863
+ record_status "pantheon://agents resource" "FAIL" "resource probe exited $AGENTS_RC"
864
+ elif printf '%s\n' "$AGENTS_CONTENT" | grep -qi 'zeus' \
865
+ && printf '%s\n' "$AGENTS_CONTENT" | grep -qi 'hermes'; then
866
+ record_status "pantheon://agents resource" "PASS" "pantheon://agents content includes zeus and hermes"
867
+ else
868
+ record_status "pantheon://agents resource" "FAIL" "content missing zeus/hermes (connectivity alone is not enough)"
869
+ fi
870
+ fi
871
+ echo ""
872
+ echo "=== VALIDATION COMPLETE ==="
873
+ if [ "$STATUS_FAIL" -gt 0 ] || [ "$STATUS_PASS" -eq 0 ]; then
874
+ FINAL_VERDICT="FAIL"
875
+ else
876
+ FINAL_VERDICT="PASS"
877
+ fi
878
+ printf 'Final verdict: %s\n' "$FINAL_VERDICT"
879
+ if [ "$FINAL_VERDICT" != "PASS" ]; then
880
+ exit 1
881
+ fi
882
+ echo "Next: open the isolated TUI with: $SANDBOX_DIR/start-pantheon.sh"
883
+ RUNTEST
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"
1018
+ }
1019
+
1020
+ # ── Reset / Prepare ───────────────────────────────────────────────────────────
1021
+
1022
+ # Resolve existing symlinked directory components and normalize missing path
1023
+ # components without requiring the sandbox root to exist yet. If an existing
1024
+ # non-directory appears before the final component, resolution fails closed.
1025
+ canonicalize_path() {
1026
+ local path="$1" resolved="/" component candidate
1027
+ [ -n "$path" ] || return 1
1028
+ case "$path" in
1029
+ /*) ;;
1030
+ *) path="$PWD/$path" ;;
1031
+ esac
1032
+
1033
+ while [ -n "$path" ]; do
1034
+ case "$path" in
1035
+ */*) component="${path%%/*}"; path="${path#*/}" ;;
1036
+ *) component="$path"; path="" ;;
1037
+ esac
1038
+ case "$component" in
1039
+ "" | .) continue ;;
1040
+ ..)
1041
+ if [ "$resolved" != "/" ]; then
1042
+ resolved="${resolved%/*}"
1043
+ [ -n "$resolved" ] || resolved="/"
1044
+ fi
1045
+ ;;
1046
+ *)
1047
+ if [ "$resolved" = "/" ]; then
1048
+ candidate="/$component"
1049
+ else
1050
+ candidate="$resolved/$component"
1051
+ fi
1052
+ if [ -d "$candidate" ]; then
1053
+ resolved="$(cd -P -- "$candidate" 2>/dev/null && pwd -P)" || return 1
1054
+ elif [ -e "$candidate" ] || [ -L "$candidate" ]; then
1055
+ [ -z "$path" ] || return 1
1056
+ resolved="$candidate"
1057
+ else
1058
+ resolved="$candidate"
1059
+ fi
1060
+ ;;
1061
+ esac
1062
+ done
1063
+
1064
+ printf '%s\n' "$resolved"
1065
+ }
1066
+
1067
+ # Compare complete path components; a sibling such as project-extra is not
1068
+ # inside project. The root directory is a parent of every absolute path.
1069
+ path_is_same_or_descendant() {
1070
+ local candidate="$1" parent="$2"
1071
+ [ "$candidate" = "$parent" ] && return 0
1072
+ if [ "$parent" = "/" ]; then
1073
+ case "$candidate" in /*) return 0 ;; esac
1074
+ fi
1075
+ case "$candidate" in "$parent"/*) return 0 ;; esac
1076
+ return 1
1077
+ }
1078
+
1079
+ cmd_reset() {
1080
+ # Guard BOTH repos this harness can act on. REPO_DIR is where `--prepare`
1081
+ # runs `npm pack`; PANTHEON_REPO is the documented override the GENERATED
1082
+ # run-test.sh honours, and it may point somewhere else entirely. Refusing
1083
+ # only the default left an override able to name a checkout and have the
1084
+ # generated script pack a tarball into it.
1085
+ local protected canonical_root canonical_home canonical_protected
1086
+ canonical_root="$(canonicalize_path "$SANDBOX_ROOT")" \
1087
+ || die "refusing to reset sandbox root that cannot be canonicalized: $SANDBOX_ROOT"
1088
+ canonical_home="$(canonicalize_path "$HOME")" \
1089
+ || die "refusing to reset because HOME cannot be canonicalized: $HOME"
1090
+ [ "$canonical_root" != "/" ] \
1091
+ || die "refusing to reset unsafe sandbox root: $SANDBOX_ROOT (filesystem root)"
1092
+ [ "$canonical_root" != "$canonical_home" ] \
1093
+ || die "refusing to reset unsafe sandbox root: $SANDBOX_ROOT (HOME)"
1094
+ if path_is_same_or_descendant "$canonical_home" "$canonical_root"; then
1095
+ die "refusing to reset unsafe sandbox root: $SANDBOX_ROOT (it is an ancestor of HOME $HOME)"
1096
+ fi
1097
+
1098
+ for protected in "$REPO_DIR" "${PANTHEON_REPO:-}"; do
1099
+ [ -n "$protected" ] || continue
1100
+ canonical_protected="$(canonicalize_path "$protected")" \
1101
+ || die "refusing to reset because protected repo path cannot be canonicalized: $protected"
1102
+ if path_is_same_or_descendant "$canonical_root" "$canonical_protected"; then
1103
+ die "refusing to reset unsafe sandbox root: $SANDBOX_ROOT (it is equal to or inside the repo $protected)"
1104
+ fi
1105
+ if path_is_same_or_descendant "$canonical_protected" "$canonical_root"; then
1106
+ die "refusing to reset unsafe sandbox root: $SANDBOX_ROOT (it is an ancestor of the repo $protected)"
1107
+ fi
1108
+ done
1109
+ log "Resetting sandbox: $SANDBOX_ROOT"
1110
+ rm -rf "$SANDBOX_ROOT"
1111
+ log "Sandbox removed."
1112
+ }
1113
+
1114
+ install_binaries() {
1115
+ log "--- Installing pantheon-opencode (from repo tarball) ---"
1116
+ cd "$REPO_DIR"
1117
+ npm pack --ignore-scripts 2>/dev/null || die "npm pack failed"
1118
+ local tgz
1119
+ tgz=$(ls -t pantheon-opencode-*.tgz | head -1)
1120
+ npm rm -g pantheon-opencode 2>/dev/null || true
1121
+ npm install -g "$REPO_DIR/$tgz" || die "install of $tgz failed"
1122
+ if ! git -C "$REPO_DIR" ls-files --error-unmatch -- "$tgz" >/dev/null 2>&1; then rm -f -- "$tgz"; fi
1123
+ log "--- Installing OpenCode V2 ($OPENCODE_V2_SPEC) ---"
1124
+ npm install -g "$OPENCODE_V2_SPEC" || die "install of $OPENCODE_V2_SPEC failed"
1125
+ }
1126
+
1127
+ prepare_project() {
1128
+ local dir pan
1129
+ dir="$(project_dir)"
1130
+ mkdir -p "$dir"
1131
+ pan="$(sandbox_bin_for "pantheon-opencode")" \
1132
+ || die "pantheon-opencode not installed in sandbox prefix — run $0 --prepare first"
1133
+ log "--- Regenerating config in $dir ---"
1134
+ (cd "$dir" && "$pan" init --project --version "$TARGET_VERSION" --model "$PANTHEON_SANDBOX_MODEL" -y) \
1135
+ || die "init --project --version $TARGET_VERSION failed in $dir"
1136
+ rewrite_v2_mcp_config
1137
+ validate_sandbox_install
1138
+ }
1139
+
1140
+ # The installer merges an existing project config. A reused sandbox can
1141
+ # therefore contain five otherwise-valid MCP entries whose absolute paths
1142
+ # point at a different sandbox or host. Rewrite the managed entries after the
1143
+ # merge and validate the complete shape before any OpenCode process starts.
1144
+ rewrite_v2_mcp_config() {
1145
+ local runtime_dir="$(project_dir)/.opencode"
1146
+ local python_bin="$(project_dir)/.venv/bin/python3"
1147
+ [ -f "$V2_CONFIG" ] || die "V2 config was not generated: $V2_CONFIG"
1148
+ [ -x "$python_bin" ] || die "V2 MCP Python is missing: $python_bin"
1149
+
1150
+ CONFIG_PATH="$V2_CONFIG" RUNTIME_DIR="$runtime_dir" MCP_PYTHON="$python_bin" \
1151
+ python3 - <<'PY'
1152
+ import json
1153
+ import os
1154
+ import sys
1155
+ from pathlib import Path
1156
+
1157
+ config_path = Path(os.environ["CONFIG_PATH"])
1158
+ runtime_dir = os.environ["RUNTIME_DIR"]
1159
+ python_bin = os.environ["MCP_PYTHON"]
1160
+ managed = {
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",
1166
+ }
1167
+
1168
+ try:
1169
+ config = json.loads(config_path.read_text(encoding="utf-8"))
1170
+ except (OSError, json.JSONDecodeError) as exc:
1171
+ raise SystemExit(f"cannot read V2 config {config_path}: {exc}")
1172
+ if not isinstance(config, dict):
1173
+ raise SystemExit(f"V2 config must be an object: {config_path}")
1174
+ mcp = config.setdefault("mcp", {})
1175
+ if not isinstance(mcp, dict):
1176
+ raise SystemExit("V2 config mcp section must be an object")
1177
+
1178
+ for name, script in managed.items():
1179
+ entry = mcp.get(name)
1180
+ if not isinstance(entry, dict):
1181
+ entry = {}
1182
+ # Do not retain command/cwd values from the merged config. These are the
1183
+ # only paths the active project is allowed to use in this sandbox.
1184
+ entry.pop("disabled", None)
1185
+ entry.update(
1186
+ type="local",
1187
+ cwd=runtime_dir,
1188
+ command=[python_bin, f"scripts/{script}"],
1189
+ enabled=True,
1190
+ )
1191
+ mcp[name] = entry
1192
+
1193
+ tmp_path = config_path.with_name(config_path.name + ".sandbox-tmp")
1194
+ try:
1195
+ tmp_path.write_text(json.dumps(config, indent=2) + "\n", encoding="utf-8")
1196
+ os.replace(tmp_path, config_path)
1197
+ except OSError as exc:
1198
+ try:
1199
+ tmp_path.unlink()
1200
+ except OSError:
1201
+ pass
1202
+ raise SystemExit(f"cannot rewrite V2 config {config_path}: {exc}")
1203
+
1204
+ try:
1205
+ validated = json.loads(config_path.read_text(encoding="utf-8"))
1206
+ except (OSError, json.JSONDecodeError) as exc:
1207
+ raise SystemExit(f"cannot validate rewritten V2 config {config_path}: {exc}")
1208
+ for name, script in managed.items():
1209
+ entry = validated.get("mcp", {}).get(name)
1210
+ expected = {
1211
+ "type": "local",
1212
+ "cwd": runtime_dir,
1213
+ "command": [python_bin, f"scripts/{script}"],
1214
+ "enabled": True,
1215
+ }
1216
+ if not isinstance(entry, dict) or any(entry.get(key) != value for key, value in expected.items()):
1217
+ raise SystemExit(f"stale or invalid paths remain for {name} in {config_path}")
1218
+ if not Path(entry["cwd"]).is_dir() or not Path(entry["command"][0]).is_file():
1219
+ raise SystemExit(f"active sandbox path is missing for {name}: {entry}")
1220
+ if not (Path(entry["cwd"]) / entry["command"][1]).is_file():
1221
+ raise SystemExit(f"active sandbox MCP script is missing for {name}: {entry}")
1222
+ PY
1223
+ }
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
+
1234
+ cmd_prepare() {
1235
+ mkdir -p "$SANDBOX_HOME" "$NPM_PREFIX"
1236
+ write_run_test_sh
1237
+ sandbox_env
1238
+ install_binaries
1239
+ local pan
1240
+ pan="$(sandbox_bin_for "pantheon-opencode")" \
1241
+ || die "pantheon-opencode not found in sandbox prefix after install"
1242
+ log "--- Headless init (global venv + runtime) ---"
1243
+ "$pan" init --headless -y --model "$PANTHEON_SANDBOX_MODEL" \
1244
+ || die "pantheon-opencode init --headless failed"
1245
+ prepare_project
1246
+ write_extract_py
1247
+ log "Prepare complete. Sandbox: $SANDBOX_ROOT"
1248
+ log "Next: $0 --run v2 --prompts"
1249
+ }
1250
+
1251
+ # ── JSON text extraction (schema-agnostic) ────────────────────────────────────
1252
+
1253
+ write_extract_py() {
1254
+ cat > "$EXTRACT_PY" <<'PYEOF'
1255
+ """Collect every string value from opencode run --format json output."""
1256
+ import json
1257
+ import sys
1258
+
1259
+
1260
+ def walk(obj, out):
1261
+ if isinstance(obj, str):
1262
+ out.append(obj)
1263
+ elif isinstance(obj, dict):
1264
+ for value in obj.values():
1265
+ walk(value, out)
1266
+ elif isinstance(obj, list):
1267
+ for value in obj:
1268
+ walk(value, out)
1269
+
1270
+
1271
+ raw = sys.stdin.read()
1272
+ chunks = []
1273
+ try:
1274
+ walk(json.loads(raw), chunks)
1275
+ except Exception:
1276
+ for line in raw.splitlines():
1277
+ line = line.strip()
1278
+ if not line:
1279
+ continue
1280
+ try:
1281
+ walk(json.loads(line), chunks)
1282
+ except Exception:
1283
+ chunks.append(line)
1284
+ print("\n".join(chunks))
1285
+ PYEOF
1286
+ }
1287
+
1288
+ # ── Prompt battery ────────────────────────────────────────────────────────────
1289
+
1290
+ PROMPT_IDS=()
1291
+ PROMPT_TEXTS=()
1292
+ PROMPT_MARKERS=()
1293
+ PROMPT_VERIFY=()
1294
+
1295
+ add_prompt() { # id marker ignored verify text
1296
+ PROMPT_IDS+=("$1")
1297
+ PROMPT_MARKERS+=("$2")
1298
+ PROMPT_VERIFY+=("$4")
1299
+ PROMPT_TEXTS+=("$5")
1300
+ }
1301
+
1302
+ build_battery() {
1303
+ local v="$1"
1304
+ PROMPT_IDS=()
1305
+ PROMPT_TEXTS=()
1306
+ PROMPT_MARKERS=()
1307
+ PROMPT_VERIFY=()
1308
+ add_prompt "agents-resource" "AGENTS-OK" "" "" \
1309
+ "List your MCP resources and read the resource pantheon://agents. If the returned agent list includes an agent named 'zeus' and one named 'hermes', reply with exactly: AGENTS-OK. Otherwise reply with exactly: AGENTS-MISSING."
1310
+ add_prompt "memory-store" "MEMORY-STORE-OK" "" "" \
1311
+ "Use the pantheon-memory memory_store tool to store this exact entry: namespace 'default', key 'pantheon-sandbox-probe-$v', value 'alive'. Then reply with exactly: MEMORY-STORE-OK"
1312
+ add_prompt "memory-recall" "MEMORY-RECALL-OK" "" "" \
1313
+ "Use the pantheon-memory memory_recall tool (or memory_search) to look up key 'pantheon-sandbox-probe-$v' in namespace 'default'. If the stored value is 'alive', reply with exactly: MEMORY-RECALL-OK"
1314
+ add_prompt "tmp-write-read" "TMP-OK" "" \
1315
+ "${TMPDIR:-/tmp}/pantheon-sandbox-probe-$v.txt::TMP-OK" \
1316
+ "Using the bash tool, run a python3 -c command that writes the text TMP-OK to the file ${TMPDIR:-/tmp}/pantheon-sandbox-probe-$v.txt (overwrite it), then run another python3 -c command to read that file back. Do not use any other tool or command to write or read the file — only python3 -c. If what you read back is exactly TMP-OK, reply with exactly: TMP-OK"
1317
+ add_prompt "delegation" "DELEGATION-OK" "" "" \
1318
+ "Delegate a tiny task to the @talos agent via the task tool: ask it to reply with the single word PONG. If the delegated response contains PONG, reply with exactly: DELEGATION-OK. If you cannot delegate to agents in this context, reply with exactly: DELEGATION-UNAVAILABLE"
1319
+ }
1320
+
1321
+ declare -A RESULTS
1322
+ declare -A DETAILS
1323
+
1324
+ record() { # v id result detail
1325
+ RESULTS["$1:$2"]="$3"
1326
+ DETAILS["$1:$2"]="$4"
1327
+ }
1328
+
1329
+ extract_text() { "$HOME/.config/opencode/.venv/bin/python" "$EXTRACT_PY" < "$1"; }
1330
+
1331
+ ATTEMPT_RESULT=""
1332
+ ATTEMPT_DETAIL=""
1333
+
1334
+ run_prompt_attempt() { # v idx current_bin — sets ATTEMPT_RESULT / ATTEMPT_DETAIL
1335
+ local v="$1" idx="$2" bin="$3" id marker verify text
1336
+ id="${PROMPT_IDS[$idx]}"
1337
+ marker="${PROMPT_MARKERS[$idx]}"
1338
+ verify="${PROMPT_VERIFY[$idx]}"
1339
+ text="${PROMPT_TEXTS[$idx]}"
1340
+
1341
+ # Remove the probe file first so post-run verification reflects THIS run.
1342
+ if [ -n "$verify" ]; then
1343
+ rm -f "${verify%%::*}"
1344
+ fi
1345
+
1346
+ local out_file err_file rc=0
1347
+ out_file="$(mktemp)"
1348
+ err_file="$(mktemp)"
1349
+
1350
+ set +e
1351
+ (cd "$(project_dir)" \
1352
+ && timeout "$PANTHEON_PROMPT_TIMEOUT" "$bin" run --auto --format json "$text") \
1353
+ > "$out_file" 2> "$err_file"
1354
+ rc=$?
1355
+ set -e
1356
+
1357
+ local extracted
1358
+ extracted="$(extract_text "$out_file" || true)"
1359
+ local raw
1360
+ raw="$( { tr '\n' ' ' < "$out_file"; tr '\n' ' ' < "$err_file"; } | cut -c1-400)"
1361
+ rm -f "$out_file" "$err_file"
1362
+
1363
+ if [ "$rc" -eq 124 ]; then
1364
+ ATTEMPT_RESULT="FAIL"
1365
+ ATTEMPT_DETAIL="timeout after ${PANTHEON_PROMPT_TIMEOUT}s"
1366
+ elif [ "$rc" -ne 0 ]; then
1367
+ ATTEMPT_RESULT="FAIL"
1368
+ ATTEMPT_DETAIL="marker $marker absent; exit $rc; snippet: $(printf '%s' "$raw" | cut -c1-160)"
1369
+ elif printf '%s' "$extracted" | grep -q "$marker"; then
1370
+ ATTEMPT_RESULT="PASS"
1371
+ ATTEMPT_DETAIL="marker $marker found"
1372
+ elif [ -n "$verify" ] \
1373
+ && [ "$(cat "${verify%%::*}" 2>/dev/null)" = "${verify##*::}" ]; then
1374
+ ATTEMPT_RESULT="PASS"
1375
+ ATTEMPT_DETAIL="probe file verified: ${verify%%::*} contains ${verify##*::}"
1376
+ else
1377
+ ATTEMPT_RESULT="FAIL"
1378
+ ATTEMPT_DETAIL="marker $marker absent; rc=$rc; snippet: $(printf '%s' "$raw" | cut -c1-160)"
1379
+ fi
1380
+ }
1381
+
1382
+ run_prompt() { # v idx current_bin — exactly one attempt
1383
+ local v="$1" idx="$2" bin="$3" id
1384
+ id="${PROMPT_IDS[$idx]}"
1385
+ run_prompt_attempt "$v" "$idx" "$bin"
1386
+ record "$v" "$id" "$ATTEMPT_RESULT" "$ATTEMPT_DETAIL"
1387
+ }
1388
+
1389
+ check_mcp_list() { # v
1390
+ local v="$1" bin out connected_count rc=0 last_out='' last_rc=0
1391
+ local list_file list_pid final_file final_pid final_rc
1392
+ if ! bin="$(sandbox_bin)"; then
1393
+ record "$v" "mcp-list" "FAIL" "binary not installed in sandbox prefix"
1394
+ return 0
1395
+ fi
1396
+ if ! start_v2_service "$bin" "$(project_dir)"; then
1397
+ record "$v" "mcp-list" "FAIL" "V2 service did not become healthy"
1398
+ return 0
1399
+ fi
1400
+
1401
+ # Start the loader-triggering command before waiting for handshakes. The
1402
+ # previous order waited on log lines that `mcp list` itself had to create.
1403
+ list_file="$(mktemp "$SANDBOX_ROOT/v2-mcp-list.XXXXXX")"
1404
+ (cd "$(project_dir)" && timeout --foreground "$V2_MCP_LIST_TIMEOUT" "$bin" mcp list) \
1405
+ >"$list_file" 2>&1 &
1406
+ list_pid=$!
1407
+ if ! wait_for_v2_mcp_registrations "$(project_dir)/.opencode" "$(project_dir)/.venv/bin/python3"; then
1408
+ kill "$list_pid" 2>/dev/null || true
1409
+ wait "$list_pid" 2>/dev/null || true
1410
+ rm -f "$list_file"
1411
+ record "$v" "mcp-list" "FAIL" "MCP registrations did not match the active sandbox"
1412
+ return 0
1413
+ fi
1414
+ if wait_for_v2_handshakes; then
1415
+ :
1416
+ else
1417
+ kill "$list_pid" 2>/dev/null || true
1418
+ wait "$list_pid" 2>/dev/null || true
1419
+ rm -f "$list_file"
1420
+ record "$v" "mcp-list" "FAIL" "MCP handshakes did not become ready after mcp list started"
1421
+ return 0
1422
+ fi
1423
+ if wait "$list_pid"; then
1424
+ rc=0
1425
+ else
1426
+ rc=$?
1427
+ fi
1428
+ out="$(cat "$list_file")"
1429
+ rm -f "$list_file"
1430
+ connected_count="$(printf '%s' "$out" | grep -Eic '(^|[^[:alnum:]_])connected([^[:alnum:]_]|$)' || true)"
1431
+ if [ "$rc" -eq 0 ] && [ "$connected_count" -ne 5 ]; then
1432
+ # The first beta `mcp list` call triggers loading and can return before the
1433
+ # service catalog is populated. Re-query only after registrations and
1434
+ # handshakes have proved the active five servers.
1435
+ final_file="$(mktemp "$SANDBOX_ROOT/v2-mcp-list.XXXXXX")"
1436
+ (cd "$(project_dir)" && timeout --foreground "$V2_MCP_LIST_TIMEOUT" "$bin" mcp list) \
1437
+ >"$final_file" 2>&1 &
1438
+ final_pid=$!
1439
+ if wait "$final_pid"; then
1440
+ final_rc=0
1441
+ else
1442
+ final_rc=$?
1443
+ fi
1444
+ out="$(cat "$final_file")"
1445
+ rm -f "$final_file"
1446
+ if [ "$final_rc" -ne 0 ]; then
1447
+ rc="$final_rc"
1448
+ fi
1449
+ fi
1450
+ out="$(printf '%s' "$out" | sed -E $'s/\x1B\\[[0-?]*[ -/]*[@-~]//g')"
1451
+ last_out="$out"
1452
+ last_rc="$rc"
1453
+ connected_count="$(printf '%s' "$out" | grep -Eic '(^|[^[:alnum:]_])connected([^[:alnum:]_]|$)' || true)"
1454
+ if [ "$rc" -eq 0 ] \
1455
+ && [ "$connected_count" -eq 5 ] \
1456
+ && ! printf '%s' "$out" | grep -Eiq 'failed|✘' \
1457
+ && printf '%s' "$out" | grep -Fq 'pantheon-code-mode' \
1458
+ && printf '%s' "$out" | grep -Fq 'pantheon-memory' \
1459
+ && printf '%s' "$out" | grep -Fq 'pantheon-persistence' \
1460
+ && printf '%s' "$out" | grep -Fq 'pantheon-resources' \
1461
+ && printf '%s' "$out" | grep -Fq 'pantheon-vision'; then
1462
+ printf '%s\n' "$out"
1463
+ record "$v" "mcp-list" "PASS" "exactly 5 connected MCPs"
1464
+ return 0
1465
+ fi
1466
+
1467
+ printf '%s\n' '--- mcp list output (last attempt) ---' >&2
1468
+ printf '%s\n' "$last_out" >&2
1469
+ printf 'mcp list exit=%s; connected=%s; expected exactly 5\n' \
1470
+ "$last_rc" "$connected_count" >&2
1471
+ print_v2_diagnostics
1472
+ record "$v" "mcp-list" "FAIL" \
1473
+ "expected exactly 5 connected MCPs; last exit $last_rc found $connected_count"
1474
+ }
1475
+
1476
+ check_doctor() { # v
1477
+ local v="$1" pan rc=0 doctor_output=''
1478
+ if ! pan="$(sandbox_bin_for "pantheon-opencode")"; then
1479
+ record "$v" "doctor" "FAIL" "pantheon-opencode not installed in sandbox prefix"
1480
+ return 0
1481
+ fi
1482
+ if doctor_output="$(cd "$(project_dir)" && "$pan" doctor --target "$(project_dir)" 2>&1)"; then
1483
+ rc=0
1484
+ else
1485
+ rc=$?
1486
+ fi
1487
+ if [ "$rc" -eq 0 ]; then
1488
+ record "$v" "doctor" "PASS" "exit 0"
1489
+ else
1490
+ printf '%s\n' '--- doctor output ---' >&2
1491
+ printf '%s\n' "$doctor_output" >&2
1492
+ record "$v" "doctor" "FAIL" "doctor exited $rc (blocking errors)"
1493
+ fi
1494
+ }
1495
+
1496
+ installed_package_root() {
1497
+ local pan resolved
1498
+ if ! pan="$(sandbox_bin_for "pantheon-opencode")"; then
1499
+ return 1
1500
+ fi
1501
+ resolved="$(readlink -f "$pan" 2>/dev/null || true)"
1502
+ [ -n "$resolved" ] || return 1
1503
+ dirname "$(dirname "$resolved")"
1504
+ }
1505
+
1506
+ run_cost_probe() { # v
1507
+ local v="$1" package_root probe raw status detail
1508
+ if ! package_root="$(installed_package_root)"; then
1509
+ record "$v" "pantheon-cost" "FAIL" "sandbox package is not installed"
1510
+ return 0
1511
+ fi
1512
+ probe="$package_root/scripts/probe-pantheon-cost.mjs"
1513
+ if [ ! -f "$probe" ]; then
1514
+ record "$v" "pantheon-cost" "FAIL" "installed package has no offline cost probe"
1515
+ return 0
1516
+ fi
1517
+ if ! command -v node >/dev/null 2>&1; then
1518
+ record "$v" "pantheon-cost" "FAIL" "node runtime is not available"
1519
+ return 0
1520
+ fi
1521
+ set +e
1522
+ raw="$(node "$probe" --version "$v" --fixture --json 2>&1)"
1523
+ local rc=$?
1524
+ set -e
1525
+ case "$raw" in
1526
+ *'"status":"PASS"'*) status="PASS" ;;
1527
+ *'"status":"NOT_TESTED"'*) status="FAIL" ;;
1528
+ *'"status":"AMBIENTAL"'*) status="FAIL" ;;
1529
+ *) status="FAIL" ;;
1530
+ esac
1531
+ detail="$(printf '%s' "$raw" | tr '\n' ' ' | cut -c1-400)"
1532
+ if [ "$rc" -ne 0 ] && [ "$status" = "PASS" ]; then
1533
+ status="FAIL"
1534
+ detail="$detail (probe exit $rc)"
1535
+ elif [ "$rc" -ne 0 ] && [ "$status" != "FAIL" ]; then
1536
+ detail="$detail (probe exit $rc)"
1537
+ fi
1538
+ record "$v" "pantheon-cost" "$status" "$detail"
1539
+ }
1540
+
1541
+ run_rehydrate_probe() { # v
1542
+ local v="$1" package_root probe raw status detail
1543
+ if ! package_root="$(installed_package_root)"; then
1544
+ record "$v" "context-rehydrate" "FAIL" "sandbox package is not installed"
1545
+ return 0
1546
+ fi
1547
+ probe="$package_root/scripts/probe-context-rehydrate.mjs"
1548
+ if [ ! -f "$probe" ]; then
1549
+ record "$v" "context-rehydrate" "FAIL" "installed package has no offline context probe"
1550
+ return 0
1551
+ fi
1552
+ if ! command -v node >/dev/null 2>&1; then
1553
+ record "$v" "context-rehydrate" "FAIL" "node runtime is not available"
1554
+ return 0
1555
+ fi
1556
+ set +e
1557
+ raw="$(timeout 30 node "$probe" --version "$v" --json 2>&1)"
1558
+ local rc=$?
1559
+ set -e
1560
+ case "$raw" in
1561
+ *'"status":"PASS"'*) status="PASS" ;;
1562
+ *'"status":"NOT_TESTED"'*) status="FAIL" ;;
1563
+ *'"status":"AMBIENTAL"'*) status="FAIL" ;;
1564
+ *) status="FAIL" ;;
1565
+ esac
1566
+ detail="$(printf '%s' "$raw" | tr '\n' ' ' | cut -c1-400)"
1567
+ if [ "$rc" -eq 124 ]; then
1568
+ status="FAIL"
1569
+ detail="context probe timed out after 30s"
1570
+ elif [ "$rc" -ne 0 ] && [ "$status" = "PASS" ]; then
1571
+ # A non-zero probe exit must never be reported as PASS.
1572
+ status="FAIL"
1573
+ detail="$detail (probe exit $rc)"
1574
+ elif [ "$rc" -ne 0 ] && [ "$status" != "FAIL" ]; then
1575
+ detail="$detail (probe exit $rc)"
1576
+ fi
1577
+ record "$v" "context-rehydrate" "$status" "$detail"
1578
+ }
1579
+
1580
+ run_version_prompts() { # v
1581
+ local v="$1" bin
1582
+ if ! bin="$(sandbox_bin)"; then
1583
+ log "Binary '$V2_BIN' not in sandbox prefix — failing all $v checks."
1584
+ build_battery "$v"
1585
+ local i
1586
+ for i in "${!PROMPT_IDS[@]}"; do
1587
+ record "$v" "${PROMPT_IDS[$i]}" "FAIL" "binary $V2_BIN not installed in sandbox"
1588
+ done
1589
+ record "$v" "mcp-list" "FAIL" "binary $V2_BIN not installed in sandbox"
1590
+ record "$v" "doctor" "FAIL" "binary $V2_BIN not installed in sandbox"
1591
+ return 0
1592
+ fi
1593
+ prepare_project "$v"
1594
+ build_battery "$v"
1595
+ # check_mcp_list owns service startup and must launch `mcp list` before it
1596
+ # waits for the five MCP handshakes. Starting a service and waiting here
1597
+ # would recreate the V2 loader deadlock before the battery even begins.
1598
+ check_mcp_list "$v"
1599
+ local i
1600
+ for i in "${!PROMPT_IDS[@]}"; do
1601
+ log "[$v] prompt ${PROMPT_IDS[$i]} ..."
1602
+ run_prompt "$v" "$i" "$bin"
1603
+ log "[$v] prompt ${PROMPT_IDS[$i]} → ${RESULTS["$v:${PROMPT_IDS[$i]}"]}"
1604
+ done
1605
+ check_doctor "$v"
1606
+ }
1607
+
1608
+ probe_verdict() { # pass total → PASS when every required check was explicitly PASS
1609
+ local pass="$1" total="$2"
1610
+ if [ "$pass" -eq "$total" ] && [ "$pass" -gt 0 ]; then
1611
+ printf 'PASS'
1612
+ else
1613
+ printf 'FAIL'
1614
+ fi
1615
+ }
1616
+
1617
+ write_report() { # versions...
1618
+ local versions=("$@") total_fail=0 total_pass=0
1619
+ {
1620
+ printf '# Pantheon Sandbox Prompts Report\n\n'
1621
+ printf -- '- **Date:** %s\n' "$(date -u '+%Y-%m-%d %H:%M UTC')"
1622
+ printf -- '- **Model:** %s\n' "$PANTHEON_SANDBOX_MODEL"
1623
+ printf -- '- **Sandbox:** %s\n' "$SANDBOX_ROOT"
1624
+ printf -- '- **V2 binary:** %s (%s)\n\n' \
1625
+ "$V2_BIN" "$OPENCODE_V2_SPEC"
1626
+ local v id res
1627
+ for v in "${versions[@]}"; do
1628
+ printf '## Version %s (`%s`)\n\n' "$v" "$V2_BIN"
1629
+ printf '| Prompt | Result | Detail |\n|---|---|---|\n'
1630
+ for id in "${PROMPT_IDS[@]}" mcp-list doctor; do
1631
+ res="${RESULTS["$v:$id"]:-SKIP}"
1632
+ case "$res" in
1633
+ PASS) total_pass=$((total_pass + 1)) ;;
1634
+ *) total_fail=$((total_fail + 1)) ;;
1635
+ esac
1636
+ printf '| %s | %s | %s |\n' "$id" "$res" "${DETAILS["$v:$id"]:-—}"
1637
+ done
1638
+ printf '\n'
1639
+ done
1640
+ printf '## Summary\n\n'
1641
+ printf -- '- PASS: %d\n- Not PASS (blocking): %d\n\n' \
1642
+ "$total_pass" "$total_fail"
1643
+ printf '**Verdict: %s**\n' "$(probe_verdict "$total_pass" "$((total_pass + total_fail))")"
1644
+ } > "$REPORT_FILE"
1645
+ log "Report written to $REPORT_FILE"
1646
+ }
1647
+
1648
+ write_cost_report() { # versions...
1649
+ local versions=("$@") total_fail=0 total_pass=0
1650
+ {
1651
+ printf '# Pantheon Cost Probe Report\n\n'
1652
+ printf -- '- **Date:** %s\n' "$(date -u '+%Y-%m-%d %H:%M UTC')"
1653
+ printf -- '- **Sandbox:** %s\n\n' "$SANDBOX_ROOT"
1654
+ printf '| Version | Result | Detail |\n|---|---|---|\n'
1655
+ local v result
1656
+ for v in "${versions[@]}"; do
1657
+ result="${RESULTS["$v:pantheon-cost"]:-NOT_TESTED}"
1658
+ case "$result" in
1659
+ PASS) total_pass=$((total_pass + 1)) ;;
1660
+ *) total_fail=$((total_fail + 1)) ;;
1661
+ esac
1662
+ printf '| %s | %s | %s |\n' "$v" "$result" "${DETAILS["$v:pantheon-cost"]:-—}"
1663
+ done
1664
+ printf '\n## Summary\n\n'
1665
+ printf -- '- PASS: %d\n- Not PASS (blocking): %d\n\n' \
1666
+ "$total_pass" "$total_fail"
1667
+ printf '**Verdict: %s**\n' "$(probe_verdict "$total_pass" "$((total_pass + total_fail))")"
1668
+ } > "$COST_REPORT_FILE"
1669
+ log "Cost probe report written to $COST_REPORT_FILE"
1670
+ }
1671
+
1672
+ write_rehydrate_report() { # versions...
1673
+ local versions=("$@") total_fail=0 total_pass=0
1674
+ {
1675
+ printf '# Pantheon Context Rehydration Probe Report\n\n'
1676
+ printf -- '- **Date:** %s\n' "$(date -u '+%Y-%m-%d %H:%M UTC')"
1677
+ printf -- '- **Sandbox:** %s\n\n' "$SANDBOX_ROOT"
1678
+ printf '| Version | Probe | Result | Detail |\n|---|---|---|---|\n'
1679
+ local v result
1680
+ for v in "${versions[@]}"; do
1681
+ result="${RESULTS["$v:context-rehydrate"]:-NOT_TESTED}"
1682
+ case "$result" in
1683
+ PASS) total_pass=$((total_pass + 1)) ;;
1684
+ *) total_fail=$((total_fail + 1)) ;;
1685
+ esac
1686
+ printf '| %s | context_rehydrate + context_session_summary | %s | %s |\n' \
1687
+ "$v" "$result" "${DETAILS["$v:context-rehydrate"]:-—}"
1688
+ done
1689
+ printf '\n## Summary\n\n'
1690
+ printf -- '- PASS: %d\n- Not PASS (blocking): %d\n\n' \
1691
+ "$total_pass" "$total_fail"
1692
+ printf '**Verdict: %s**\n' "$(probe_verdict "$total_pass" "$((total_pass + total_fail))")"
1693
+ } > "$REHYDRATE_REPORT_FILE"
1694
+ log "Context probe report written to $REHYDRATE_REPORT_FILE"
1695
+ }
1696
+
1697
+ run_cost() { # versions...
1698
+ local versions=("$@") v fail=0
1699
+ mkdir -p "$SANDBOX_ROOT"
1700
+ for v in "${versions[@]}"; do
1701
+ log "[$v] pantheon_cost offline probe ..."
1702
+ run_cost_probe "$v"
1703
+ log "[$v] pantheon-cost → ${RESULTS["$v:pantheon-cost"]}"
1704
+ done
1705
+ write_cost_report "${versions[@]}"
1706
+ for v in "${versions[@]}"; do
1707
+ [ "${RESULTS["$v:pantheon-cost"]:-}" = "PASS" ] || fail=$((fail + 1))
1708
+ done
1709
+ if [ "$fail" -gt 0 ]; then
1710
+ log "Cost probe: $fail check(s) without explicit PASS."
1711
+ exit 1
1712
+ fi
1713
+ log "Cost probe: PASS."
1714
+ }
1715
+
1716
+ run_rehydrate() { # versions...
1717
+ local versions=("$@") v fail=0
1718
+ mkdir -p "$SANDBOX_ROOT"
1719
+ for v in "${versions[@]}"; do
1720
+ log "[$v] context_rehydrate/session_summary offline probe ..."
1721
+ run_rehydrate_probe "$v"
1722
+ log "[$v] context probe → ${RESULTS["$v:context-rehydrate"]}"
1723
+ done
1724
+ write_rehydrate_report "${versions[@]}"
1725
+ for v in "${versions[@]}"; do
1726
+ [ "${RESULTS["$v:context-rehydrate"]:-}" = "PASS" ] || fail=$((fail + 1))
1727
+ done
1728
+ if [ "$fail" -gt 0 ]; then
1729
+ log "Context probe: $fail check(s) without explicit PASS."
1730
+ exit 1
1731
+ fi
1732
+ log "Context probe: PASS."
1733
+ }
1734
+
1735
+ # ── Hook canary (proves callbacks FIRE, not just that a plugin loaded) ────────
1736
+
1737
+ run_hooks() {
1738
+ local bin agent
1739
+ if ! bin="$(sandbox_bin)"; then
1740
+ printf 'ERROR: sandbox not prepared for hooks (binary '\''%s'\'' missing) — run %s --prepare first\n' \
1741
+ "$V2_BIN" "$0" >&2
1742
+ exit 3
1743
+ fi
1744
+ agent="${PANTHEON_HOOK_CANARY_AGENT:-canary}"
1745
+ log "[hooks] V2 hook canary against $bin (model $PANTHEON_SANDBOX_MODEL, agent $agent) ..."
1746
+ # PANTHEON_HOOK_CANARY_MODE=real is the point of this path: it makes the host
1747
+ # load src/plugin-v2.ts itself, so a hook that registers under a dead name
1748
+ # fails here. Default `fixture` mode only proves the hook NAMES are real, so
1749
+ # without this the sandbox would never exercise the shipped plugin.
1750
+ if PANTHEON_HOOK_CANARY_BIN="$bin" \
1751
+ PANTHEON_HOOK_CANARY_MODEL="$PANTHEON_SANDBOX_MODEL" \
1752
+ PANTHEON_HOOK_CANARY_AGENT="$agent" \
1753
+ PANTHEON_HOOK_CANARY_MODE=real \
1754
+ node --test "$REPO_DIR/tests/pantheon/plugin-v2-hook-canary.test.mjs"; then
1755
+ log "Hook canary: PASS."
1756
+ else
1757
+ log "Hook canary: FAIL."
1758
+ exit 1
1759
+ fi
1760
+ }
1761
+
1762
+ run_battery() { # versions...
1763
+ local versions=("$@")
1764
+ mkdir -p "$SANDBOX_ROOT"
1765
+ write_extract_py
1766
+ local v
1767
+ for v in "${versions[@]}"; do
1768
+ run_version_prompts "$v"
1769
+ done
1770
+ write_report "${versions[@]}"
1771
+ local v fail=0 id
1772
+ for v in "${versions[@]}"; do
1773
+ for id in "${PROMPT_IDS[@]}" mcp-list doctor; do
1774
+ [ "${RESULTS["$v:$id"]:-}" = "PASS" ] || fail=$((fail + 1))
1775
+ done
1776
+ done
1777
+ if [ "$fail" -gt 0 ]; then
1778
+ log "Prompts battery: $fail check(s) without explicit PASS."
1779
+ exit 1
1780
+ fi
1781
+ log "Prompts battery: PASS."
1782
+ }
1783
+
1784
+ # ── Base validation (no prompts) ──────────────────────────────────────────────
1785
+
1786
+ run_base() { # v
1787
+ local v="$1" bin
1788
+ if ! bin="$(sandbox_bin)"; then
1789
+ printf 'ERROR: sandbox not prepared for %s (binary '\''%s'\'' missing from sandbox prefix) — run %s --prepare first\n' \
1790
+ "$v" "$V2_BIN" "$0" >&2
1791
+ exit 3
1792
+ fi
1793
+ prepare_project "$v"
1794
+ build_battery "$v"
1795
+ log "[$v] opencode mcp list"
1796
+ check_mcp_list "$v"
1797
+ log "[$v] mcp-list → ${RESULTS["$v:mcp-list"]}"
1798
+ log "[$v] doctor"
1799
+ check_doctor "$v"
1800
+ log "[$v] doctor → ${RESULTS["$v:doctor"]}"
1801
+ local fail=0
1802
+ [ "${RESULTS["$v:mcp-list"]}" = "PASS" ] || fail=$((fail + 1))
1803
+ [ "${RESULTS["$v:doctor"]}" = "PASS" ] || fail=$((fail + 1))
1804
+ if [ "$fail" -gt 0 ]; then
1805
+ log "Base validation $v: $fail check(s) without explicit PASS."
1806
+ exit 1
1807
+ fi
1808
+ log "Base validation $v: PASS."
1809
+ }
1810
+
1811
+ # ── CLI ───────────────────────────────────────────────────────────────────────
1812
+
1813
+ while [ $# -gt 0 ]; do
1814
+ case "$1" in
1815
+ --prepare) MODE_PREPARE=1 ;;
1816
+ --reset) MODE_RESET=1 ;;
1817
+ --prompts) MODE_PROMPTS=1 ;;
1818
+ --cost) MODE_COST=1 ;;
1819
+ --rehydrate) MODE_REHYDRATE=1 ;;
1820
+ --hooks) MODE_HOOKS=1 ;;
1821
+ --run)
1822
+ shift
1823
+ case "${1:-}" in
1824
+ v2) RUN_VERSION="$1" ;;
1825
+ v1)
1826
+ printf 'ERROR: the V1 leg was removed — this project is OpenCode V2-exclusive.\n' >&2
1827
+ exit 2
1828
+ ;;
1829
+ *) usage ;;
1830
+ esac
1831
+ ;;
1832
+ --help | -h) usage ;;
1833
+ *) usage ;;
1834
+ esac
1835
+ shift
1836
+ done
1837
+
1838
+ if [ "$MODE_RESET" -eq 1 ]; then
1839
+ cmd_reset
1840
+ exit 0
1841
+ fi
1842
+
1843
+ if [ "$MODE_PREPARE" -eq 0 ] && [ -z "$RUN_VERSION" ] \
1844
+ && [ "$MODE_PROMPTS" -eq 0 ] && [ "$MODE_COST" -eq 0 ] \
1845
+ && [ "$MODE_REHYDRATE" -eq 0 ] && [ "$MODE_HOOKS" -eq 0 ]; then
1846
+ usage
1847
+ fi
1848
+
1849
+ if [ "$MODE_PREPARE" -eq 1 ]; then
1850
+ cmd_prepare
1851
+ fi
1852
+
1853
+ if [ -n "$RUN_VERSION" ]; then
1854
+ if [ "$MODE_PROMPTS" -eq 1 ]; then
1855
+ sandbox_env
1856
+ run_battery "$RUN_VERSION"
1857
+ else
1858
+ sandbox_env
1859
+ run_base "$RUN_VERSION"
1860
+ fi
1861
+ if [ "$MODE_COST" -eq 1 ]; then
1862
+ sandbox_env
1863
+ run_cost "$RUN_VERSION"
1864
+ fi
1865
+ if [ "$MODE_REHYDRATE" -eq 1 ]; then
1866
+ sandbox_env
1867
+ run_rehydrate "$RUN_VERSION"
1868
+ fi
1869
+ if [ "$MODE_HOOKS" -eq 1 ]; then
1870
+ sandbox_env
1871
+ run_hooks
1872
+ fi
1873
+ elif [ "$MODE_PROMPTS" -eq 1 ]; then
1874
+ sandbox_env
1875
+ run_battery "$TARGET_VERSION"
1876
+ if [ "$MODE_COST" -eq 1 ]; then
1877
+ run_cost "$TARGET_VERSION"
1878
+ fi
1879
+ if [ "$MODE_REHYDRATE" -eq 1 ]; then
1880
+ run_rehydrate "$TARGET_VERSION"
1881
+ fi
1882
+ if [ "$MODE_HOOKS" -eq 1 ]; then
1883
+ run_hooks
1884
+ fi
1885
+ elif [ "$MODE_COST" -eq 1 ]; then
1886
+ sandbox_env
1887
+ run_cost "$TARGET_VERSION"
1888
+ if [ "$MODE_REHYDRATE" -eq 1 ]; then
1889
+ run_rehydrate "$TARGET_VERSION"
1890
+ fi
1891
+ if [ "$MODE_HOOKS" -eq 1 ]; then
1892
+ run_hooks
1893
+ fi
1894
+ elif [ "$MODE_REHYDRATE" -eq 1 ]; then
1895
+ sandbox_env
1896
+ rehydrate_status=0
1897
+ # run_rehydrate exits when its check fails; isolate it so the separately
1898
+ # requested hook canary still runs, then return a failing status afterward.
1899
+ (run_rehydrate "$TARGET_VERSION") || rehydrate_status=$?
1900
+ hooks_status=0
1901
+ if [ "$MODE_HOOKS" -eq 1 ]; then
1902
+ (run_hooks) || hooks_status=$?
1903
+ fi
1904
+ if [ "$rehydrate_status" -ne 0 ]; then
1905
+ exit "$rehydrate_status"
1906
+ fi
1907
+ if [ "$hooks_status" -ne 0 ]; then
1908
+ exit "$hooks_status"
1909
+ fi
1910
+ elif [ "$MODE_HOOKS" -eq 1 ]; then
1911
+ sandbox_env
1912
+ run_hooks
1913
+ fi