pantheon-opencode 1.5.1 → 1.6.0-beta.1

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 (62) 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/CHANGELOG.md +22 -0
  6. package/README.md +86 -52
  7. package/README.pt-BR.md +63 -43
  8. package/commands/pantheon-optimize.md +2 -2
  9. package/package.json +13 -1
  10. package/plugin.json +1 -1
  11. package/pyproject.toml +8 -15
  12. package/scripts/build-agents-md.mjs +2 -2
  13. package/scripts/check-plugin-v2-coverage.mjs +213 -0
  14. package/scripts/ci-validate-yaml.py +7 -5
  15. package/scripts/code_mode_server.py +116 -24
  16. package/scripts/doctor.mjs +85 -28
  17. package/scripts/install/cli-ui.mjs +1 -1
  18. package/scripts/install/config-migration.mjs +2 -2
  19. package/scripts/install/health-check.mjs +6 -19
  20. package/scripts/install/interactive.mjs +6 -6
  21. package/scripts/install/migrate.mjs +1 -1
  22. package/scripts/install/state.mjs +27 -2
  23. package/scripts/mcp_persistence_server.py +390 -76
  24. package/scripts/memory_mcp_server.py +370 -196
  25. package/scripts/postinstall.mjs +0 -7
  26. package/scripts/probe-context-rehydrate.mjs +28 -11
  27. package/scripts/prune.mjs +2 -2
  28. package/scripts/sync-tui.mjs +11 -5
  29. package/scripts/test-opencode-v2-sandbox.sh +1759 -0
  30. package/scripts/validate-routing.mjs +40 -0
  31. package/skills-lock.json +9 -10
  32. package/src/agents/mnemosyne.md +15 -16
  33. package/src/agents/zeus.md +1 -2
  34. package/src/mcp/code_mode_server.py +116 -24
  35. package/src/mcp/mcp_codemap_module.py +559 -129
  36. package/src/mcp/mcp_persistence_server.py +390 -76
  37. package/src/mcp/memory_mcp_server.py +371 -248
  38. package/src/mcp/pantheon_vision_server.py +136 -24
  39. package/src/mcp/requirements-mcp.txt +5 -3
  40. package/src/mcp/requirements-vision.txt +2 -0
  41. package/src/pantheon/model-command.ts +18 -7
  42. package/src/pantheon/todo-enforcer.ts +1 -1
  43. package/src/pantheon/vision.ts +1 -1
  44. package/src/plugin-v2.ts +134 -104
  45. package/src/plugins/tui/dist/tui.js +1 -1
  46. package/src/plugins/tui/package-lock.json +20 -20
  47. package/src/plugins/tui/package.json +12 -12
  48. package/src/routing.yml +0 -1
  49. package/src/skills/README.md +31 -36
  50. package/src/skills/auto-continue/SKILL.md +25 -75
  51. package/.pantheon/code-mode/eval-llm-judge.py +0 -255
  52. package/.pantheon/code-mode/eval-monte-carlo.py +0 -368
  53. package/.pantheon/code-mode/eval-run.py +0 -197
  54. package/.pantheon/code-mode/eval-static.py +0 -321
  55. package/.pantheon/code-mode/session-end-save.py +0 -186
  56. package/.pantheon/code-mode/session-end-save.sh +0 -18
  57. package/scripts/recover-zenodo-deposition.mjs +0 -146
  58. package/scripts/test-opencode-v1-v2-sandbox.sh +0 -862
  59. package/scripts/validate-zenodo-release.mjs +0 -154
  60. package/scripts/zenodo-artifact.mjs +0 -21
  61. package/scripts/zenodo-release-state.mjs +0 -53
  62. package/src/skills/orchestration-workflow/SKILL.md +0 -311
@@ -0,0 +1,1759 @@
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-ai/cli@beta). 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-ai/cli@beta}"
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_server.py",
245
+ "memory_mcp_server.py",
246
+ "mcp_persistence_server.py",
247
+ "mcp_resources_server.py",
248
+ "pantheon_vision_server.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
+ if len(records) != len(expected) or registered != expected:
269
+ raise SystemExit(1)
270
+ PY
271
+ then
272
+ return 0
273
+ fi
274
+ sleep 1
275
+ done
276
+ printf 'ERROR: timed out after %ss waiting for exactly five MCP registrations in the active sandbox\n' \
277
+ "$V2_HANDSHAKE_TIMEOUT" >&2
278
+ print_v2_diagnostics
279
+ return 1
280
+ }
281
+
282
+ wait_for_v2_handshakes() {
283
+ local runtime_log="$HOME/.local/share/opencode/log/opencode.log"
284
+ local names=(
285
+ pantheon-code-mode
286
+ pantheon-memory
287
+ pantheon-persistence
288
+ pantheon-resources
289
+ pantheon-vision
290
+ )
291
+ local attempt name ready connected_names connected_count
292
+ for attempt in $(seq 1 "$V2_HANDSHAKE_TIMEOUT"); do
293
+ if ! kill -0 "$V2_SERVER_PID" 2>/dev/null; then
294
+ printf 'V2 service exited before all MCP handshakes completed.\n' >&2
295
+ print_v2_diagnostics
296
+ return 1
297
+ fi
298
+ ready=1
299
+ for name in "${names[@]}"; do
300
+ if [ ! -f "$runtime_log" ] || ! grep -Fq "message=\"mcp connected\" server=$name" "$runtime_log"; then
301
+ ready=0
302
+ break
303
+ fi
304
+ done
305
+ if [ "$ready" -eq 1 ]; then
306
+ # A stale or inherited MCP must not turn five expected handshakes into a
307
+ # false PASS. The service log is dedicated to this run and must contain
308
+ # exactly the five managed server names.
309
+ connected_names="$({ grep -oE 'message="mcp connected" server=[^ ]+' "$runtime_log" || true; } \
310
+ | sed 's/.*server=//' | sort -u)"
311
+ connected_count="$(printf '%s\n' "$connected_names" | sed '/^$/d' | wc -l)"
312
+ if [ "$connected_count" -eq 5 ] \
313
+ && ! printf '%s\n' "$connected_names" | grep -Evx \
314
+ 'pantheon-code-mode|pantheon-memory|pantheon-persistence|pantheon-resources|pantheon-vision' \
315
+ >/dev/null; then
316
+ return 0
317
+ fi
318
+ ready=0
319
+ fi
320
+ sleep 1
321
+ done
322
+ printf 'Timed out after %ss waiting for exactly five MCP handshakes.\n' \
323
+ "$V2_HANDSHAKE_TIMEOUT" >&2
324
+ print_v2_diagnostics
325
+ return 1
326
+ }
327
+
328
+ start_v2_service() { # binary project
329
+ local bin="$1" project="$2" ready=0 attempt
330
+ if [ -n "$V2_SERVER_PID" ] && kill -0 "$V2_SERVER_PID" 2>/dev/null; then
331
+ return 0
332
+ fi
333
+
334
+ # Never attach to an unrelated service on the dedicated port. A successful
335
+ # health probe before our process starts is an environmental collision, not
336
+ # a reason to silently reuse that service.
337
+ if curl --fail --silent --show-error --max-time 1 \
338
+ "http://$V2_LOOPBACK_HOST:${V2_PORT}/global/health" >/dev/null 2>&1; then
339
+ die "dedicated V2 port ${V2_PORT} is already in use; refusing to reuse an unrelated service"
340
+ fi
341
+
342
+ rm -f "$V2_SERVER_LOG" "$V2_SERVICE_STATE" "$HOME/.local/share/opencode/log/opencode.log"
343
+ (cd "$project" && "$bin" serve --hostname "$V2_LOOPBACK_HOST" --port "$V2_PORT" --service) \
344
+ >"$V2_SERVER_LOG" 2>&1 &
345
+ V2_SERVER_PID=$!
346
+
347
+ for attempt in $(seq 1 "$V2_HANDSHAKE_TIMEOUT"); do
348
+ if ! kill -0 "$V2_SERVER_PID" 2>/dev/null; then
349
+ printf 'V2 service exited before becoming healthy.\n' >&2
350
+ print_v2_diagnostics
351
+ return 1
352
+ fi
353
+ if curl --fail --silent --show-error --max-time 2 \
354
+ "http://$V2_LOOPBACK_HOST:${V2_PORT}/global/health" >/dev/null 2>&1; then
355
+ ready=1
356
+ break
357
+ fi
358
+ sleep 1
359
+ done
360
+ if [ "$ready" -ne 1 ]; then
361
+ printf 'Timed out after %ss waiting for V2 service health on port %s.\n' \
362
+ "$V2_HANDSHAKE_TIMEOUT" "$V2_PORT" >&2
363
+ print_v2_diagnostics
364
+ return 1
365
+ fi
366
+ if ! wait_for_v2_service_registration; then
367
+ return 1
368
+ fi
369
+ }
370
+
371
+ trap stop_v2_service EXIT
372
+
373
+ # ── Gate (b): sandbox run-test.sh with pantheon://agents content validation ──
374
+
375
+ write_run_test_sh() {
376
+ # Quoted heredoc: the body is emitted verbatim, so nothing inside is expanded
377
+ # at generation time. REPO_DIR is the one value the body needs, and it is
378
+ # handed over as its own generated file instead of a placeholder baked into
379
+ # the shell source: interpolating it meant escaping for TWO languages (sed
380
+ # replacement text AND the double-quoted shell context it lands in), and only
381
+ # the first set was escaped — a path containing `$`, a backtick, a backslash
382
+ # or a quote was silently rewritten or executed by the generated script.
383
+ printf '%s\n' "$REPO_DIR" > "$SANDBOX_ROOT/.repo-dir"
384
+ cat > "$SANDBOX_ROOT/run-test.sh" <<'RUNTEST'
385
+ #!/usr/bin/env bash
386
+ # Generated by pantheon-opencode scripts/test-opencode-v2-sandbox.sh --prepare
387
+ # Validates the global install: the V2 binary, exactly 5 connected MCPs, doctor
388
+ # 0 errors, AND the CONTENT of pantheon://agents (zeus/hermes present).
389
+ set -euo pipefail
390
+
391
+ SANDBOX_DIR="$(cd "$(dirname "$0")" && pwd)"
392
+ SANDBOX_HOME="$SANDBOX_DIR/home"
393
+ NPM_PREFIX="$SANDBOX_HOME/.npm-global"
394
+ PANTHEON_GLOBAL="$SANDBOX_HOME/.config/opencode"
395
+ SANDBOX_VENV="$PANTHEON_GLOBAL/.venv"
396
+ # The checkout this harness ran from, written by the generator into the
397
+ # .repo-dir file next to this script. It used to be a placeholder baked into
398
+ # this line and substituted with sed afterwards, but the sandbox lives outside
399
+ # any repo so the value cannot be derived from this file's own location either.
400
+ # Reading a file the generator wrote keeps the path out of the shell source, so
401
+ # no escaping can be wrong. PANTHEON_REPO may still override it; the generator's
402
+ # --reset guard refuses to run when that points inside a repo, so an override
403
+ # can never turn this into a repo-wiping target.
404
+ REPO_DIR="${PANTHEON_REPO:-$(cat "$SANDBOX_DIR/.repo-dir")}"
405
+
406
+ echo "=== Pantheon Sandbox Test (OpenCode V2) ==="
407
+ echo "Sandbox: $SANDBOX_DIR"
408
+ echo "Repo: $REPO_DIR"
409
+
410
+ export HOME="$SANDBOX_HOME"
411
+ export PATH="$NPM_PREFIX/bin:$SANDBOX_VENV/bin:$PATH"
412
+ export npm_config_prefix="$NPM_PREFIX"
413
+ export XDG_STATE_HOME="$SANDBOX_HOME/.local/state"
414
+ # The V2 CLI resolves `mcp list` through its service. Keep generated sandbox
415
+ # runs isolated from any config/database/port inherited from the host.
416
+ V2_CONFIG="$SANDBOX_DIR/project-v2/opencode.json"
417
+ V2_DB="${PANTHEON_V2_DB:-$SANDBOX_DIR/opencode-v2.db}"
418
+ V2_PORT="${PANTHEON_V2_PORT:-49376}"
419
+ V2_LOOPBACK_HOST="${PANTHEON_V2_HOST:-127.0.0.1}"
420
+ V2_HANDSHAKE_TIMEOUT="${PANTHEON_V2_HANDSHAKE_TIMEOUT:-60}"
421
+ V2_SERVER_LOG="$SANDBOX_DIR/v2-server.log"
422
+ V2_SERVICE_STATE="$SANDBOX_HOME/.local/state/opencode/service.json"
423
+ V2_SERVER_PID=""
424
+ export OPENCODE_CONFIG_DIR="$(dirname "$V2_CONFIG")"
425
+ export OPENCODE_DB="$V2_DB"
426
+ export PANTHEON_V2_PORT="$V2_PORT"
427
+ export PORT="$V2_PORT"
428
+ unset OPENCODE_CONFIG OPENCODE_CONFIG_CONTENT OPENCODE_CONFIG_PROJECT_DISABLE
429
+ mkdir -p "$(dirname "$V2_SERVICE_STATE")"
430
+
431
+ stop_v2_service() {
432
+ if [ -n "$V2_SERVER_PID" ] && kill -0 "$V2_SERVER_PID" 2>/dev/null; then
433
+ kill "$V2_SERVER_PID" 2>/dev/null || true
434
+ wait "$V2_SERVER_PID" 2>/dev/null || true
435
+ fi
436
+ rm -f "$V2_SERVICE_STATE"
437
+ V2_SERVER_PID=""
438
+ }
439
+
440
+ print_v2_diagnostics() {
441
+ printf '%s\n' '--- V2 service registration ---' >&2
442
+ if [ -f "$V2_SERVICE_STATE" ]; then
443
+ cat "$V2_SERVICE_STATE" >&2 || true
444
+ printf '\n' >&2
445
+ else
446
+ printf 'missing service registration: %s\n' "$V2_SERVICE_STATE" >&2
447
+ fi
448
+ printf '%s\n' '--- V2 service log ---' >&2
449
+ [ -f "$V2_SERVER_LOG" ] && tail -200 "$V2_SERVER_LOG" >&2 || true
450
+ local runtime_log="$HOME/.local/share/opencode/log/opencode.log"
451
+ printf '%s\n' '--- OpenCode runtime log ---' >&2
452
+ [ -f "$runtime_log" ] && tail -200 "$runtime_log" >&2 || true
453
+ }
454
+
455
+ wait_for_v2_service_registration() {
456
+ local registration_url="http://$V2_LOOPBACK_HOST:$V2_PORT"
457
+ local registration_pid="$V2_SERVER_PID"
458
+ local attempt
459
+ for attempt in $(seq 1 "$V2_HANDSHAKE_TIMEOUT"); do
460
+ if ! kill -0 "$V2_SERVER_PID" 2>/dev/null; then
461
+ echo 'ERROR: V2 service exited before registration was written' >&2
462
+ print_v2_diagnostics
463
+ return 1
464
+ fi
465
+ if [ -f "$V2_SERVICE_STATE" ] \
466
+ && SERVICE_STATE="$V2_SERVICE_STATE" \
467
+ REGISTRATION_URL="$registration_url" \
468
+ REGISTRATION_PID="$registration_pid" \
469
+ python3 - <<'PY'
470
+ import json
471
+ import os
472
+ from pathlib import Path
473
+
474
+ path = Path(os.environ["SERVICE_STATE"])
475
+ try:
476
+ state = json.loads(path.read_text(encoding="utf-8"))
477
+ except (OSError, json.JSONDecodeError):
478
+ raise SystemExit(1)
479
+ expected_url = os.environ["REGISTRATION_URL"]
480
+ expected_pid = int(os.environ["REGISTRATION_PID"])
481
+ if not isinstance(state, dict):
482
+ raise SystemExit(1)
483
+ if state.get("url") != expected_url or state.get("pid") != expected_pid:
484
+ raise SystemExit(1)
485
+ if not all(isinstance(state.get(key), str) and state[key] for key in ("id", "version", "password")):
486
+ raise SystemExit(1)
487
+ PY
488
+ then
489
+ return 0
490
+ fi
491
+ sleep 1
492
+ done
493
+ printf 'ERROR: timed out after %ss waiting for V2 service registration matching url=%s pid=%s\n' \
494
+ "$V2_HANDSHAKE_TIMEOUT" "$registration_url" "$registration_pid" >&2
495
+ print_v2_diagnostics
496
+ return 1
497
+ }
498
+
499
+ wait_for_v2_mcp_registrations() {
500
+ local runtime_dir="$1" python_bin="$2" attempt
501
+ for attempt in $(seq 1 "$V2_HANDSHAKE_TIMEOUT"); do
502
+ if ! kill -0 "$V2_SERVER_PID" 2>/dev/null; then
503
+ echo 'ERROR: V2 service exited before all five MCP registrations completed' >&2
504
+ print_v2_diagnostics
505
+ return 1
506
+ fi
507
+ if SERVICE_LOG="$V2_SERVER_LOG" RUNTIME_DIR="$runtime_dir" MCP_PYTHON="$python_bin" \
508
+ python3 - <<'PY'
509
+ import os
510
+ import re
511
+ from pathlib import Path
512
+
513
+ try:
514
+ log = Path(os.environ["SERVICE_LOG"]).read_text(encoding="utf-8")
515
+ except OSError:
516
+ raise SystemExit(1)
517
+ 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",
523
+ }
524
+ pattern = re.compile(
525
+ r"spawning process \{\s*"
526
+ r"command:\s*\"(?P<command>[^\"]+)\".*?"
527
+ r"args:\s*\[\s*\"(?P<script>[^\"]+)\"\s*\].*?"
528
+ r"cwd:\s*\"(?P<cwd>[^\"]+)\"",
529
+ re.DOTALL,
530
+ )
531
+ records = list(pattern.finditer(log))
532
+ runtime_dir = os.environ["RUNTIME_DIR"]
533
+ python_bin = os.environ["MCP_PYTHON"]
534
+ registered = set()
535
+ for record in records:
536
+ command = record.group("command")
537
+ script = Path(record.group("script")).name
538
+ cwd = record.group("cwd")
539
+ if command != python_bin or cwd != runtime_dir or script not in expected:
540
+ raise SystemExit(1)
541
+ registered.add(script)
542
+ if len(records) != len(expected) or registered != expected:
543
+ raise SystemExit(1)
544
+ PY
545
+ then
546
+ return 0
547
+ fi
548
+ sleep 1
549
+ done
550
+ printf 'ERROR: timed out after %ss waiting for exactly five MCP registrations in the active sandbox\n' \
551
+ "$V2_HANDSHAKE_TIMEOUT" >&2
552
+ print_v2_diagnostics
553
+ return 1
554
+ }
555
+
556
+ start_v2_service() {
557
+ local bin="$1" project="$2" attempt ready=0
558
+ if [ -n "$V2_SERVER_PID" ] && kill -0 "$V2_SERVER_PID" 2>/dev/null; then
559
+ return 0
560
+ fi
561
+ if curl --fail --silent --show-error --max-time 1 \
562
+ "http://$V2_LOOPBACK_HOST:${V2_PORT}/global/health" >/dev/null 2>&1; then
563
+ echo "ERROR: dedicated V2 port $V2_PORT is already in use" >&2
564
+ return 1
565
+ fi
566
+ rm -f "$V2_SERVER_LOG" "$V2_SERVICE_STATE" "$HOME/.local/share/opencode/log/opencode.log"
567
+ (cd "$project" && "$bin" serve --hostname "$V2_LOOPBACK_HOST" --port "$V2_PORT" --service) \
568
+ >"$V2_SERVER_LOG" 2>&1 &
569
+ V2_SERVER_PID=$!
570
+ for attempt in $(seq 1 "$V2_HANDSHAKE_TIMEOUT"); do
571
+ if ! kill -0 "$V2_SERVER_PID" 2>/dev/null; then
572
+ echo 'ERROR: V2 service exited before becoming healthy' >&2
573
+ print_v2_diagnostics
574
+ return 1
575
+ fi
576
+ if curl --fail --silent --show-error --max-time 2 \
577
+ "http://$V2_LOOPBACK_HOST:${V2_PORT}/global/health" >/dev/null 2>&1; then
578
+ ready=1
579
+ break
580
+ fi
581
+ sleep 1
582
+ done
583
+ if [ "$ready" -ne 1 ]; then
584
+ echo "ERROR: timed out waiting for V2 service on port $V2_PORT" >&2
585
+ print_v2_diagnostics
586
+ return 1
587
+ fi
588
+ if ! wait_for_v2_service_registration; then
589
+ return 1
590
+ fi
591
+ }
592
+
593
+ wait_for_v2_handshakes() {
594
+ local runtime_log="$HOME/.local/share/opencode/log/opencode.log"
595
+ local names=(pantheon-code-mode pantheon-memory pantheon-persistence pantheon-resources pantheon-vision)
596
+ local attempt name ready connected_names connected_count
597
+ for attempt in $(seq 1 "$V2_HANDSHAKE_TIMEOUT"); do
598
+ if ! kill -0 "$V2_SERVER_PID" 2>/dev/null; then
599
+ echo 'ERROR: V2 service exited before all MCP handshakes completed' >&2
600
+ print_v2_diagnostics
601
+ return 1
602
+ fi
603
+ ready=1
604
+ for name in "${names[@]}"; do
605
+ if [ ! -f "$runtime_log" ] || ! grep -Fq "message=\"mcp connected\" server=$name" "$runtime_log"; then
606
+ ready=0
607
+ break
608
+ fi
609
+ done
610
+ if [ "$ready" -eq 1 ]; then
611
+ connected_names="$({ grep -oE 'message=\"mcp connected\" server=[^ ]+' "$runtime_log" || true; } \
612
+ | sed 's/.*server=//' | sort -u)"
613
+ connected_count="$(printf '%s\n' "$connected_names" | sed '/^$/d' | wc -l)"
614
+ if [ "$connected_count" -eq 5 ] \
615
+ && ! printf '%s\n' "$connected_names" | grep -Evx \
616
+ 'pantheon-code-mode|pantheon-memory|pantheon-persistence|pantheon-resources|pantheon-vision' \
617
+ >/dev/null; then
618
+ return 0
619
+ fi
620
+ ready=0
621
+ fi
622
+ sleep 1
623
+ done
624
+ echo "ERROR: timed out waiting for exactly five MCP handshakes" >&2
625
+ print_v2_diagnostics
626
+ return 1
627
+ }
628
+
629
+ rewrite_v2_mcp_config() {
630
+ local config_path="$V2_CONFIG"
631
+ local runtime_dir="$SANDBOX_DIR/project-v2/.opencode"
632
+ local python_bin="$SANDBOX_DIR/project-v2/.venv/bin/python3"
633
+ [ -f "$config_path" ] || { echo "ERROR: V2 config was not generated: $config_path" >&2; return 1; }
634
+ [ -x "$python_bin" ] || { echo "ERROR: V2 MCP Python is missing: $python_bin" >&2; return 1; }
635
+ CONFIG_PATH="$config_path" RUNTIME_DIR="$runtime_dir" MCP_PYTHON="$python_bin" \
636
+ python3 - <<'PY'
637
+ import json
638
+ import os
639
+ from pathlib import Path
640
+
641
+ config_path = Path(os.environ["CONFIG_PATH"])
642
+ runtime_dir = os.environ["RUNTIME_DIR"]
643
+ python_bin = os.environ["MCP_PYTHON"]
644
+ 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",
650
+ }
651
+ try:
652
+ config = json.loads(config_path.read_text(encoding="utf-8"))
653
+ except (OSError, json.JSONDecodeError) as exc:
654
+ raise SystemExit(f"cannot read V2 config {config_path}: {exc}")
655
+ if not isinstance(config, dict):
656
+ raise SystemExit(f"V2 config must be an object: {config_path}")
657
+ mcp = config.setdefault("mcp", {})
658
+ if not isinstance(mcp, dict):
659
+ raise SystemExit("V2 config mcp section must be an object")
660
+ for name, script in managed.items():
661
+ entry = mcp.get(name) if isinstance(mcp.get(name), dict) else {}
662
+ entry.pop("disabled", None)
663
+ entry.update(type="local", cwd=runtime_dir, command=[python_bin, f"scripts/{script}"], enabled=True)
664
+ mcp[name] = entry
665
+ tmp_path = config_path.with_name(config_path.name + ".sandbox-tmp")
666
+ tmp_path.write_text(json.dumps(config, indent=2) + "\n", encoding="utf-8")
667
+ os.replace(tmp_path, config_path)
668
+ validated = json.loads(config_path.read_text(encoding="utf-8"))
669
+ for name, script in managed.items():
670
+ entry = validated.get("mcp", {}).get(name)
671
+ expected = {"type": "local", "cwd": runtime_dir, "command": [python_bin, f"scripts/{script}"], "enabled": True}
672
+ if not isinstance(entry, dict) or any(entry.get(key) != value for key, value in expected.items()):
673
+ raise SystemExit(f"stale or invalid paths remain for {name} in {config_path}")
674
+ 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():
675
+ raise SystemExit(f"active sandbox path is missing for {name}: {entry}")
676
+ PY
677
+ }
678
+
679
+ trap stop_v2_service EXIT
680
+
681
+ STATUS_FAIL=0
682
+ STATUS_PASS=0
683
+
684
+ record_status() { # label status detail
685
+ local label="$1" status="$2" detail="$3"
686
+ case "$status" in
687
+ PASS) STATUS_PASS=$((STATUS_PASS + 1)) ;;
688
+ FAIL) STATUS_FAIL=$((STATUS_FAIL + 1)) ;;
689
+ *) STATUS_FAIL=$((STATUS_FAIL + 1)); status="FAIL" ;;
690
+ esac
691
+ printf '%s: %s — %s\n' "$status" "$label" "$detail"
692
+ }
693
+
694
+ check_binary() { # label binary
695
+ local label="$1" bin="$2" out rc=0
696
+ if ! command -v "$bin" >/dev/null 2>&1; then
697
+ record_status "$label" "FAIL" "binary '$bin' is not installed in the sandbox prefix"
698
+ return 0
699
+ fi
700
+ set +e
701
+ out="$("$bin" --version 2>&1)"
702
+ rc=$?
703
+ set -e
704
+ if [ "$rc" -eq 0 ]; then
705
+ record_status "$label" "PASS" "${out:-version command exited 0}"
706
+ else
707
+ record_status "$label" "FAIL" "version check exited $rc"
708
+ fi
709
+ }
710
+
711
+ check_mcp() { # label binary project
712
+ local label="$1" bin="$2" project="$3" out connected_count rc=0 list_file list_pid
713
+ local final_file final_pid final_rc final_count
714
+ if ! command -v "$bin" >/dev/null 2>&1; then
715
+ record_status "$label MCP" "FAIL" "binary '$bin' is not installed in the sandbox prefix"
716
+ return 0
717
+ fi
718
+ if [ ! -d "$project" ]; then
719
+ record_status "$label MCP" "FAIL" "project directory is missing: $project"
720
+ return 0
721
+ fi
722
+ if ! start_v2_service "$bin" "$project"; then
723
+ record_status "$label MCP" "FAIL" "V2 service did not become healthy"
724
+ return 0
725
+ fi
726
+
727
+ # `mcp list` is the operation that causes OpenCode V2 to launch the MCP
728
+ # subprocesses. Start it immediately after health, before waiting for the
729
+ # handshakes; waiting first deadlocks forever because those subprocesses are
730
+ # otherwise never loaded.
731
+ list_file="$(mktemp "$SANDBOX_DIR/v2-mcp-list.XXXXXX")"
732
+ (cd "$project" && timeout --foreground "$V2_MCP_LIST_TIMEOUT" "$bin" mcp list) \
733
+ >"$list_file" 2>&1 &
734
+ list_pid=$!
735
+ if ! wait_for_v2_mcp_registrations "$project/.opencode" "$project/.venv/bin/python3"; then
736
+ kill "$list_pid" 2>/dev/null || true
737
+ wait "$list_pid" 2>/dev/null || true
738
+ rm -f "$list_file"
739
+ record_status "$label MCP" "FAIL" "MCP registrations did not match the active sandbox"
740
+ return 0
741
+ fi
742
+ if wait_for_v2_handshakes; then
743
+ :
744
+ else
745
+ kill "$list_pid" 2>/dev/null || true
746
+ wait "$list_pid" 2>/dev/null || true
747
+ rm -f "$list_file"
748
+ record_status "$label MCP" "FAIL" "MCP handshakes did not become ready after mcp list started"
749
+ return 0
750
+ fi
751
+ if wait "$list_pid"; then
752
+ rc=0
753
+ else
754
+ rc=$?
755
+ fi
756
+ out="$(cat "$list_file")"
757
+ rm -f "$list_file"
758
+ connected_count="$(printf '%s' "$out" | grep -Eic '(^|[^[:alnum:]_])connected([^[:alnum:]_]|$)' || true)"
759
+ if [ "$rc" -eq 0 ] && [ "$connected_count" -ne 5 ]; then
760
+ # The first V2 beta `mcp list` call is the loader trigger and may return
761
+ # before the service has populated its MCP catalog. Re-query only after
762
+ # the registration and handshake gates above have proved the active five.
763
+ final_file="$(mktemp "$SANDBOX_DIR/v2-mcp-list.XXXXXX")"
764
+ (cd "$project" && timeout --foreground "$V2_MCP_LIST_TIMEOUT" "$bin" mcp list) \
765
+ >"$final_file" 2>&1 &
766
+ final_pid=$!
767
+ if wait "$final_pid"; then
768
+ final_rc=0
769
+ else
770
+ final_rc=$?
771
+ fi
772
+ out="$(cat "$final_file")"
773
+ rm -f "$final_file"
774
+ if [ "$final_rc" -ne 0 ]; then
775
+ rc="$final_rc"
776
+ fi
777
+ fi
778
+ out="$(printf '%s' "$out" | sed -E $'s/\x1B\\[[0-?]*[ -/]*[@-~]//g')"
779
+ printf '%s\n' "$out"
780
+ if [ "$rc" -ne 0 ]; then
781
+ record_status "$label MCP" "FAIL" "mcp list exited $rc (see output above)"
782
+ return 0
783
+ fi
784
+ connected_count="$(printf '%s' "$out" | grep -Eic '(^|[^[:alnum:]_])connected([^[:alnum:]_]|$)')" || connected_count=0
785
+ if [ "$connected_count" -eq 5 ] \
786
+ && ! printf '%s' "$out" | grep -Eiq 'failed|✘' \
787
+ && printf '%s' "$out" | grep -Fq 'pantheon-code-mode' \
788
+ && printf '%s' "$out" | grep -Fq 'pantheon-memory' \
789
+ && printf '%s' "$out" | grep -Fq 'pantheon-persistence' \
790
+ && printf '%s' "$out" | grep -Fq 'pantheon-resources' \
791
+ && printf '%s' "$out" | grep -Fq 'pantheon-vision'; then
792
+ record_status "$label MCP" "PASS" "exactly 5 connected MCPs"
793
+ else
794
+ record_status "$label MCP" "FAIL" "expected exactly 5 connected MCPs; found $connected_count or failures reported"
795
+ fi
796
+ }
797
+
798
+ echo "--- Binaries ---"
799
+ check_binary "V2 binary" opencode2
800
+
801
+ echo "--- Building tarball ---"
802
+ cd "$REPO_DIR"
803
+ npm pack --ignore-scripts 2>/dev/null || { echo "ERROR: npm pack failed"; exit 1; }
804
+ TGZ=$(ls -t pantheon-opencode-*.tgz | head -1)
805
+ npm rm -g pantheon-opencode 2>/dev/null || true
806
+ npm install -g "$TGZ"
807
+ if ! git -C "$REPO_DIR" ls-files --error-unmatch -- "$TGZ" >/dev/null 2>&1; then rm -f -- "$TGZ"; fi
808
+
809
+ echo "--- Init (headless) ---"
810
+ pantheon-opencode init --headless -y
811
+ rewrite_v2_mcp_config
812
+
813
+ echo "--- MCP Validation ---"
814
+ check_mcp "V2" opencode2 "$SANDBOX_DIR/project-v2"
815
+
816
+ echo "--- Doctor ---"
817
+ if command -v pantheon-opencode >/dev/null 2>&1; then
818
+ set +e
819
+ DOCTOR_OUTPUT="$(pantheon-opencode doctor --target "$SANDBOX_DIR" 2>&1)"
820
+ DOCTOR_RC=$?
821
+ set -e
822
+ if [ "$DOCTOR_RC" -eq 0 ]; then
823
+ record_status "doctor" "PASS" "exit 0"
824
+ else
825
+ record_status "doctor" "FAIL" "doctor exited $DOCTOR_RC"
826
+ fi
827
+ else
828
+ record_status "doctor" "FAIL" "pantheon-opencode is not installed in the sandbox prefix"
829
+ fi
830
+
831
+ echo "--- Gate (b): pantheon://agents CONTENT check ---"
832
+ RESOURCES_SRV="$PANTHEON_GLOBAL/scripts/mcp_resources_server.py"
833
+ if [ ! -f "$RESOURCES_SRV" ]; then
834
+ record_status "pantheon://agents resource" "FAIL" "resources server not found at $RESOURCES_SRV"
835
+ else
836
+ set +e
837
+ AGENTS_CONTENT="$("$SANDBOX_VENV/bin/python" - "$RESOURCES_SRV" <<'PYEOF'
838
+ import asyncio, importlib.util, sys
839
+ from pathlib import Path
840
+ resource_path = Path(sys.argv[1]).resolve()
841
+ sys.path.insert(0, str(resource_path.parent))
842
+ spec = importlib.util.spec_from_file_location("mrs", sys.argv[1])
843
+ mod = importlib.util.module_from_spec(spec)
844
+ spec.loader.exec_module(mod)
845
+ print(asyncio.run(mod.list_agents()))
846
+ PYEOF
847
+ )"
848
+ AGENTS_RC=$?
849
+ set -e
850
+ printf '%s\n' "$AGENTS_CONTENT" | head -8
851
+ if [ "$AGENTS_RC" -ne 0 ]; then
852
+ record_status "pantheon://agents resource" "FAIL" "resource probe exited $AGENTS_RC"
853
+ elif printf '%s\n' "$AGENTS_CONTENT" | grep -qi 'zeus' \
854
+ && printf '%s\n' "$AGENTS_CONTENT" | grep -qi 'hermes'; then
855
+ record_status "pantheon://agents resource" "PASS" "pantheon://agents content includes zeus and hermes"
856
+ else
857
+ record_status "pantheon://agents resource" "FAIL" "content missing zeus/hermes (connectivity alone is not enough)"
858
+ fi
859
+ fi
860
+ echo ""
861
+ echo "=== VALIDATION COMPLETE ==="
862
+ if [ "$STATUS_FAIL" -gt 0 ] || [ "$STATUS_PASS" -eq 0 ]; then
863
+ FINAL_VERDICT="FAIL"
864
+ else
865
+ FINAL_VERDICT="PASS"
866
+ fi
867
+ printf 'Final verdict: %s\n' "$FINAL_VERDICT"
868
+ if [ "$FINAL_VERDICT" != "PASS" ]; then
869
+ exit 1
870
+ fi
871
+ echo "Next: open the isolated TUI with: $SANDBOX_DIR/start-pantheon.sh"
872
+ RUNTEST
873
+ chmod +x "$SANDBOX_ROOT/run-test.sh"
874
+ }
875
+
876
+ # ── Reset / Prepare ───────────────────────────────────────────────────────────
877
+
878
+ # Resolve existing symlinked directory components and normalize missing path
879
+ # components without requiring the sandbox root to exist yet. If an existing
880
+ # non-directory appears before the final component, resolution fails closed.
881
+ canonicalize_path() {
882
+ local path="$1" resolved="/" component candidate
883
+ [ -n "$path" ] || return 1
884
+ case "$path" in
885
+ /*) ;;
886
+ *) path="$PWD/$path" ;;
887
+ esac
888
+
889
+ while [ -n "$path" ]; do
890
+ case "$path" in
891
+ */*) component="${path%%/*}"; path="${path#*/}" ;;
892
+ *) component="$path"; path="" ;;
893
+ esac
894
+ case "$component" in
895
+ "" | .) continue ;;
896
+ ..)
897
+ if [ "$resolved" != "/" ]; then
898
+ resolved="${resolved%/*}"
899
+ [ -n "$resolved" ] || resolved="/"
900
+ fi
901
+ ;;
902
+ *)
903
+ if [ "$resolved" = "/" ]; then
904
+ candidate="/$component"
905
+ else
906
+ candidate="$resolved/$component"
907
+ fi
908
+ if [ -d "$candidate" ]; then
909
+ resolved="$(cd -P -- "$candidate" 2>/dev/null && pwd -P)" || return 1
910
+ elif [ -e "$candidate" ] || [ -L "$candidate" ]; then
911
+ [ -z "$path" ] || return 1
912
+ resolved="$candidate"
913
+ else
914
+ resolved="$candidate"
915
+ fi
916
+ ;;
917
+ esac
918
+ done
919
+
920
+ printf '%s\n' "$resolved"
921
+ }
922
+
923
+ # Compare complete path components; a sibling such as project-extra is not
924
+ # inside project. The root directory is a parent of every absolute path.
925
+ path_is_same_or_descendant() {
926
+ local candidate="$1" parent="$2"
927
+ [ "$candidate" = "$parent" ] && return 0
928
+ if [ "$parent" = "/" ]; then
929
+ case "$candidate" in /*) return 0 ;; esac
930
+ fi
931
+ case "$candidate" in "$parent"/*) return 0 ;; esac
932
+ return 1
933
+ }
934
+
935
+ cmd_reset() {
936
+ # Guard BOTH repos this harness can act on. REPO_DIR is where `--prepare`
937
+ # runs `npm pack`; PANTHEON_REPO is the documented override the GENERATED
938
+ # run-test.sh honours, and it may point somewhere else entirely. Refusing
939
+ # only the default left an override able to name a checkout and have the
940
+ # generated script pack a tarball into it.
941
+ local protected canonical_root canonical_home canonical_protected
942
+ canonical_root="$(canonicalize_path "$SANDBOX_ROOT")" \
943
+ || die "refusing to reset sandbox root that cannot be canonicalized: $SANDBOX_ROOT"
944
+ canonical_home="$(canonicalize_path "$HOME")" \
945
+ || die "refusing to reset because HOME cannot be canonicalized: $HOME"
946
+ [ "$canonical_root" != "/" ] \
947
+ || die "refusing to reset unsafe sandbox root: $SANDBOX_ROOT (filesystem root)"
948
+ [ "$canonical_root" != "$canonical_home" ] \
949
+ || die "refusing to reset unsafe sandbox root: $SANDBOX_ROOT (HOME)"
950
+ if path_is_same_or_descendant "$canonical_home" "$canonical_root"; then
951
+ die "refusing to reset unsafe sandbox root: $SANDBOX_ROOT (it is an ancestor of HOME $HOME)"
952
+ fi
953
+
954
+ for protected in "$REPO_DIR" "${PANTHEON_REPO:-}"; do
955
+ [ -n "$protected" ] || continue
956
+ canonical_protected="$(canonicalize_path "$protected")" \
957
+ || die "refusing to reset because protected repo path cannot be canonicalized: $protected"
958
+ if path_is_same_or_descendant "$canonical_root" "$canonical_protected"; then
959
+ die "refusing to reset unsafe sandbox root: $SANDBOX_ROOT (it is equal to or inside the repo $protected)"
960
+ fi
961
+ if path_is_same_or_descendant "$canonical_protected" "$canonical_root"; then
962
+ die "refusing to reset unsafe sandbox root: $SANDBOX_ROOT (it is an ancestor of the repo $protected)"
963
+ fi
964
+ done
965
+ log "Resetting sandbox: $SANDBOX_ROOT"
966
+ rm -rf "$SANDBOX_ROOT"
967
+ log "Sandbox removed."
968
+ }
969
+
970
+ install_binaries() {
971
+ log "--- Installing pantheon-opencode (from repo tarball) ---"
972
+ cd "$REPO_DIR"
973
+ npm pack --ignore-scripts 2>/dev/null || die "npm pack failed"
974
+ local tgz
975
+ tgz=$(ls -t pantheon-opencode-*.tgz | head -1)
976
+ npm rm -g pantheon-opencode 2>/dev/null || true
977
+ npm install -g "$REPO_DIR/$tgz" || die "install of $tgz failed"
978
+ if ! git -C "$REPO_DIR" ls-files --error-unmatch -- "$tgz" >/dev/null 2>&1; then rm -f -- "$tgz"; fi
979
+ log "--- Installing OpenCode V2 ($OPENCODE_V2_SPEC) ---"
980
+ npm install -g "$OPENCODE_V2_SPEC" || die "install of $OPENCODE_V2_SPEC failed"
981
+ }
982
+
983
+ prepare_project() {
984
+ local dir pan
985
+ dir="$(project_dir)"
986
+ mkdir -p "$dir"
987
+ pan="$(sandbox_bin_for "pantheon-opencode")" \
988
+ || die "pantheon-opencode not installed in sandbox prefix — run $0 --prepare first"
989
+ log "--- Regenerating config in $dir ---"
990
+ (cd "$dir" && "$pan" init --project --version "$TARGET_VERSION" --model "$PANTHEON_SANDBOX_MODEL" -y) \
991
+ || die "init --project --version $TARGET_VERSION failed in $dir"
992
+ rewrite_v2_mcp_config
993
+ }
994
+
995
+ # The installer merges an existing project config. A reused sandbox can
996
+ # therefore contain five otherwise-valid MCP entries whose absolute paths
997
+ # point at a different sandbox or host. Rewrite the managed entries after the
998
+ # merge and validate the complete shape before any OpenCode process starts.
999
+ rewrite_v2_mcp_config() {
1000
+ local runtime_dir="$(project_dir)/.opencode"
1001
+ local python_bin="$(project_dir)/.venv/bin/python3"
1002
+ [ -f "$V2_CONFIG" ] || die "V2 config was not generated: $V2_CONFIG"
1003
+ [ -x "$python_bin" ] || die "V2 MCP Python is missing: $python_bin"
1004
+
1005
+ CONFIG_PATH="$V2_CONFIG" RUNTIME_DIR="$runtime_dir" MCP_PYTHON="$python_bin" \
1006
+ python3 - <<'PY'
1007
+ import json
1008
+ import os
1009
+ import sys
1010
+ from pathlib import Path
1011
+
1012
+ config_path = Path(os.environ["CONFIG_PATH"])
1013
+ runtime_dir = os.environ["RUNTIME_DIR"]
1014
+ python_bin = os.environ["MCP_PYTHON"]
1015
+ 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",
1021
+ }
1022
+
1023
+ try:
1024
+ config = json.loads(config_path.read_text(encoding="utf-8"))
1025
+ except (OSError, json.JSONDecodeError) as exc:
1026
+ raise SystemExit(f"cannot read V2 config {config_path}: {exc}")
1027
+ if not isinstance(config, dict):
1028
+ raise SystemExit(f"V2 config must be an object: {config_path}")
1029
+ mcp = config.setdefault("mcp", {})
1030
+ if not isinstance(mcp, dict):
1031
+ raise SystemExit("V2 config mcp section must be an object")
1032
+
1033
+ for name, script in managed.items():
1034
+ entry = mcp.get(name)
1035
+ if not isinstance(entry, dict):
1036
+ entry = {}
1037
+ # Do not retain command/cwd values from the merged config. These are the
1038
+ # only paths the active project is allowed to use in this sandbox.
1039
+ entry.pop("disabled", None)
1040
+ entry.update(
1041
+ type="local",
1042
+ cwd=runtime_dir,
1043
+ command=[python_bin, f"scripts/{script}"],
1044
+ enabled=True,
1045
+ )
1046
+ mcp[name] = entry
1047
+
1048
+ tmp_path = config_path.with_name(config_path.name + ".sandbox-tmp")
1049
+ try:
1050
+ tmp_path.write_text(json.dumps(config, indent=2) + "\n", encoding="utf-8")
1051
+ os.replace(tmp_path, config_path)
1052
+ except OSError as exc:
1053
+ try:
1054
+ tmp_path.unlink()
1055
+ except OSError:
1056
+ pass
1057
+ raise SystemExit(f"cannot rewrite V2 config {config_path}: {exc}")
1058
+
1059
+ try:
1060
+ validated = json.loads(config_path.read_text(encoding="utf-8"))
1061
+ except (OSError, json.JSONDecodeError) as exc:
1062
+ raise SystemExit(f"cannot validate rewritten V2 config {config_path}: {exc}")
1063
+ for name, script in managed.items():
1064
+ entry = validated.get("mcp", {}).get(name)
1065
+ expected = {
1066
+ "type": "local",
1067
+ "cwd": runtime_dir,
1068
+ "command": [python_bin, f"scripts/{script}"],
1069
+ "enabled": True,
1070
+ }
1071
+ if not isinstance(entry, dict) or any(entry.get(key) != value for key, value in expected.items()):
1072
+ raise SystemExit(f"stale or invalid paths remain for {name} in {config_path}")
1073
+ if not Path(entry["cwd"]).is_dir() or not Path(entry["command"][0]).is_file():
1074
+ raise SystemExit(f"active sandbox path is missing for {name}: {entry}")
1075
+ if not (Path(entry["cwd"]) / entry["command"][1]).is_file():
1076
+ raise SystemExit(f"active sandbox MCP script is missing for {name}: {entry}")
1077
+ PY
1078
+ }
1079
+
1080
+ cmd_prepare() {
1081
+ mkdir -p "$SANDBOX_HOME" "$NPM_PREFIX"
1082
+ write_run_test_sh
1083
+ sandbox_env
1084
+ install_binaries
1085
+ local pan
1086
+ pan="$(sandbox_bin_for "pantheon-opencode")" \
1087
+ || die "pantheon-opencode not found in sandbox prefix after install"
1088
+ log "--- Headless init (global venv + runtime) ---"
1089
+ "$pan" init --headless -y --model "$PANTHEON_SANDBOX_MODEL" \
1090
+ || die "pantheon-opencode init --headless failed"
1091
+ prepare_project
1092
+ write_extract_py
1093
+ log "Prepare complete. Sandbox: $SANDBOX_ROOT"
1094
+ log "Next: $0 --run v2 --prompts"
1095
+ }
1096
+
1097
+ # ── JSON text extraction (schema-agnostic) ────────────────────────────────────
1098
+
1099
+ write_extract_py() {
1100
+ cat > "$EXTRACT_PY" <<'PYEOF'
1101
+ """Collect every string value from opencode run --format json output."""
1102
+ import json
1103
+ import sys
1104
+
1105
+
1106
+ def walk(obj, out):
1107
+ if isinstance(obj, str):
1108
+ out.append(obj)
1109
+ elif isinstance(obj, dict):
1110
+ for value in obj.values():
1111
+ walk(value, out)
1112
+ elif isinstance(obj, list):
1113
+ for value in obj:
1114
+ walk(value, out)
1115
+
1116
+
1117
+ raw = sys.stdin.read()
1118
+ chunks = []
1119
+ try:
1120
+ walk(json.loads(raw), chunks)
1121
+ except Exception:
1122
+ for line in raw.splitlines():
1123
+ line = line.strip()
1124
+ if not line:
1125
+ continue
1126
+ try:
1127
+ walk(json.loads(line), chunks)
1128
+ except Exception:
1129
+ chunks.append(line)
1130
+ print("\n".join(chunks))
1131
+ PYEOF
1132
+ }
1133
+
1134
+ # ── Prompt battery ────────────────────────────────────────────────────────────
1135
+
1136
+ PROMPT_IDS=()
1137
+ PROMPT_TEXTS=()
1138
+ PROMPT_MARKERS=()
1139
+ PROMPT_VERIFY=()
1140
+
1141
+ add_prompt() { # id marker ignored verify text
1142
+ PROMPT_IDS+=("$1")
1143
+ PROMPT_MARKERS+=("$2")
1144
+ PROMPT_VERIFY+=("$4")
1145
+ PROMPT_TEXTS+=("$5")
1146
+ }
1147
+
1148
+ build_battery() {
1149
+ local v="$1"
1150
+ PROMPT_IDS=()
1151
+ PROMPT_TEXTS=()
1152
+ PROMPT_MARKERS=()
1153
+ PROMPT_VERIFY=()
1154
+ add_prompt "agents-resource" "AGENTS-OK" "" "" \
1155
+ "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."
1156
+ add_prompt "memory-store" "MEMORY-STORE-OK" "" "" \
1157
+ "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"
1158
+ add_prompt "memory-recall" "MEMORY-RECALL-OK" "" "" \
1159
+ "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"
1160
+ add_prompt "tmp-write-read" "TMP-OK" "" \
1161
+ "${TMPDIR:-/tmp}/pantheon-sandbox-probe-$v.txt::TMP-OK" \
1162
+ "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"
1163
+ add_prompt "delegation" "DELEGATION-OK" "" "" \
1164
+ "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"
1165
+ }
1166
+
1167
+ declare -A RESULTS
1168
+ declare -A DETAILS
1169
+
1170
+ record() { # v id result detail
1171
+ RESULTS["$1:$2"]="$3"
1172
+ DETAILS["$1:$2"]="$4"
1173
+ }
1174
+
1175
+ extract_text() { "$HOME/.config/opencode/.venv/bin/python" "$EXTRACT_PY" < "$1"; }
1176
+
1177
+ ATTEMPT_RESULT=""
1178
+ ATTEMPT_DETAIL=""
1179
+
1180
+ run_prompt_attempt() { # v idx current_bin — sets ATTEMPT_RESULT / ATTEMPT_DETAIL
1181
+ local v="$1" idx="$2" bin="$3" id marker verify text
1182
+ id="${PROMPT_IDS[$idx]}"
1183
+ marker="${PROMPT_MARKERS[$idx]}"
1184
+ verify="${PROMPT_VERIFY[$idx]}"
1185
+ text="${PROMPT_TEXTS[$idx]}"
1186
+
1187
+ # Remove the probe file first so post-run verification reflects THIS run.
1188
+ if [ -n "$verify" ]; then
1189
+ rm -f "${verify%%::*}"
1190
+ fi
1191
+
1192
+ local out_file err_file rc=0
1193
+ out_file="$(mktemp)"
1194
+ err_file="$(mktemp)"
1195
+
1196
+ set +e
1197
+ (cd "$(project_dir)" \
1198
+ && timeout "$PANTHEON_PROMPT_TIMEOUT" "$bin" run --auto --format json "$text") \
1199
+ > "$out_file" 2> "$err_file"
1200
+ rc=$?
1201
+ set -e
1202
+
1203
+ local extracted
1204
+ extracted="$(extract_text "$out_file" || true)"
1205
+ local raw
1206
+ raw="$( { tr '\n' ' ' < "$out_file"; tr '\n' ' ' < "$err_file"; } | cut -c1-400)"
1207
+ rm -f "$out_file" "$err_file"
1208
+
1209
+ if [ "$rc" -eq 124 ]; then
1210
+ ATTEMPT_RESULT="FAIL"
1211
+ ATTEMPT_DETAIL="timeout after ${PANTHEON_PROMPT_TIMEOUT}s"
1212
+ elif [ "$rc" -ne 0 ]; then
1213
+ ATTEMPT_RESULT="FAIL"
1214
+ ATTEMPT_DETAIL="marker $marker absent; exit $rc; snippet: $(printf '%s' "$raw" | cut -c1-160)"
1215
+ elif printf '%s' "$extracted" | grep -q "$marker"; then
1216
+ ATTEMPT_RESULT="PASS"
1217
+ ATTEMPT_DETAIL="marker $marker found"
1218
+ elif [ -n "$verify" ] \
1219
+ && [ "$(cat "${verify%%::*}" 2>/dev/null)" = "${verify##*::}" ]; then
1220
+ ATTEMPT_RESULT="PASS"
1221
+ ATTEMPT_DETAIL="probe file verified: ${verify%%::*} contains ${verify##*::}"
1222
+ else
1223
+ ATTEMPT_RESULT="FAIL"
1224
+ ATTEMPT_DETAIL="marker $marker absent; rc=$rc; snippet: $(printf '%s' "$raw" | cut -c1-160)"
1225
+ fi
1226
+ }
1227
+
1228
+ run_prompt() { # v idx current_bin — exactly one attempt
1229
+ local v="$1" idx="$2" bin="$3" id
1230
+ id="${PROMPT_IDS[$idx]}"
1231
+ run_prompt_attempt "$v" "$idx" "$bin"
1232
+ record "$v" "$id" "$ATTEMPT_RESULT" "$ATTEMPT_DETAIL"
1233
+ }
1234
+
1235
+ check_mcp_list() { # v
1236
+ local v="$1" bin out connected_count rc=0 last_out='' last_rc=0
1237
+ local list_file list_pid final_file final_pid final_rc
1238
+ if ! bin="$(sandbox_bin)"; then
1239
+ record "$v" "mcp-list" "FAIL" "binary not installed in sandbox prefix"
1240
+ return 0
1241
+ fi
1242
+ if ! start_v2_service "$bin" "$(project_dir)"; then
1243
+ record "$v" "mcp-list" "FAIL" "V2 service did not become healthy"
1244
+ return 0
1245
+ fi
1246
+
1247
+ # Start the loader-triggering command before waiting for handshakes. The
1248
+ # previous order waited on log lines that `mcp list` itself had to create.
1249
+ list_file="$(mktemp "$SANDBOX_ROOT/v2-mcp-list.XXXXXX")"
1250
+ (cd "$(project_dir)" && timeout --foreground "$V2_MCP_LIST_TIMEOUT" "$bin" mcp list) \
1251
+ >"$list_file" 2>&1 &
1252
+ list_pid=$!
1253
+ if ! wait_for_v2_mcp_registrations "$(project_dir)/.opencode" "$(project_dir)/.venv/bin/python3"; then
1254
+ kill "$list_pid" 2>/dev/null || true
1255
+ wait "$list_pid" 2>/dev/null || true
1256
+ rm -f "$list_file"
1257
+ record "$v" "mcp-list" "FAIL" "MCP registrations did not match the active sandbox"
1258
+ return 0
1259
+ fi
1260
+ if wait_for_v2_handshakes; then
1261
+ :
1262
+ else
1263
+ kill "$list_pid" 2>/dev/null || true
1264
+ wait "$list_pid" 2>/dev/null || true
1265
+ rm -f "$list_file"
1266
+ record "$v" "mcp-list" "FAIL" "MCP handshakes did not become ready after mcp list started"
1267
+ return 0
1268
+ fi
1269
+ if wait "$list_pid"; then
1270
+ rc=0
1271
+ else
1272
+ rc=$?
1273
+ fi
1274
+ out="$(cat "$list_file")"
1275
+ rm -f "$list_file"
1276
+ connected_count="$(printf '%s' "$out" | grep -Eic '(^|[^[:alnum:]_])connected([^[:alnum:]_]|$)' || true)"
1277
+ if [ "$rc" -eq 0 ] && [ "$connected_count" -ne 5 ]; then
1278
+ # The first beta `mcp list` call triggers loading and can return before the
1279
+ # service catalog is populated. Re-query only after registrations and
1280
+ # handshakes have proved the active five servers.
1281
+ final_file="$(mktemp "$SANDBOX_ROOT/v2-mcp-list.XXXXXX")"
1282
+ (cd "$(project_dir)" && timeout --foreground "$V2_MCP_LIST_TIMEOUT" "$bin" mcp list) \
1283
+ >"$final_file" 2>&1 &
1284
+ final_pid=$!
1285
+ if wait "$final_pid"; then
1286
+ final_rc=0
1287
+ else
1288
+ final_rc=$?
1289
+ fi
1290
+ out="$(cat "$final_file")"
1291
+ rm -f "$final_file"
1292
+ if [ "$final_rc" -ne 0 ]; then
1293
+ rc="$final_rc"
1294
+ fi
1295
+ fi
1296
+ out="$(printf '%s' "$out" | sed -E $'s/\x1B\\[[0-?]*[ -/]*[@-~]//g')"
1297
+ last_out="$out"
1298
+ last_rc="$rc"
1299
+ connected_count="$(printf '%s' "$out" | grep -Eic '(^|[^[:alnum:]_])connected([^[:alnum:]_]|$)' || true)"
1300
+ if [ "$rc" -eq 0 ] \
1301
+ && [ "$connected_count" -eq 5 ] \
1302
+ && ! printf '%s' "$out" | grep -Eiq 'failed|✘' \
1303
+ && printf '%s' "$out" | grep -Fq 'pantheon-code-mode' \
1304
+ && printf '%s' "$out" | grep -Fq 'pantheon-memory' \
1305
+ && printf '%s' "$out" | grep -Fq 'pantheon-persistence' \
1306
+ && printf '%s' "$out" | grep -Fq 'pantheon-resources' \
1307
+ && printf '%s' "$out" | grep -Fq 'pantheon-vision'; then
1308
+ printf '%s\n' "$out"
1309
+ record "$v" "mcp-list" "PASS" "exactly 5 connected MCPs"
1310
+ return 0
1311
+ fi
1312
+
1313
+ printf '%s\n' '--- mcp list output (last attempt) ---' >&2
1314
+ printf '%s\n' "$last_out" >&2
1315
+ printf 'mcp list exit=%s; connected=%s; expected exactly 5\n' \
1316
+ "$last_rc" "$connected_count" >&2
1317
+ print_v2_diagnostics
1318
+ record "$v" "mcp-list" "FAIL" \
1319
+ "expected exactly 5 connected MCPs; last exit $last_rc found $connected_count"
1320
+ }
1321
+
1322
+ check_doctor() { # v
1323
+ local v="$1" pan rc=0 doctor_output=''
1324
+ if ! pan="$(sandbox_bin_for "pantheon-opencode")"; then
1325
+ record "$v" "doctor" "FAIL" "pantheon-opencode not installed in sandbox prefix"
1326
+ return 0
1327
+ fi
1328
+ if doctor_output="$(cd "$(project_dir)" && "$pan" doctor --target "$(project_dir)" 2>&1)"; then
1329
+ rc=0
1330
+ else
1331
+ rc=$?
1332
+ fi
1333
+ if [ "$rc" -eq 0 ]; then
1334
+ record "$v" "doctor" "PASS" "exit 0"
1335
+ else
1336
+ printf '%s\n' '--- doctor output ---' >&2
1337
+ printf '%s\n' "$doctor_output" >&2
1338
+ record "$v" "doctor" "FAIL" "doctor exited $rc (blocking errors)"
1339
+ fi
1340
+ }
1341
+
1342
+ installed_package_root() {
1343
+ local pan resolved
1344
+ if ! pan="$(sandbox_bin_for "pantheon-opencode")"; then
1345
+ return 1
1346
+ fi
1347
+ resolved="$(readlink -f "$pan" 2>/dev/null || true)"
1348
+ [ -n "$resolved" ] || return 1
1349
+ dirname "$(dirname "$resolved")"
1350
+ }
1351
+
1352
+ run_cost_probe() { # v
1353
+ local v="$1" package_root probe raw status detail
1354
+ if ! package_root="$(installed_package_root)"; then
1355
+ record "$v" "pantheon-cost" "FAIL" "sandbox package is not installed"
1356
+ return 0
1357
+ fi
1358
+ probe="$package_root/scripts/probe-pantheon-cost.mjs"
1359
+ if [ ! -f "$probe" ]; then
1360
+ record "$v" "pantheon-cost" "FAIL" "installed package has no offline cost probe"
1361
+ return 0
1362
+ fi
1363
+ if ! command -v node >/dev/null 2>&1; then
1364
+ record "$v" "pantheon-cost" "FAIL" "node runtime is not available"
1365
+ return 0
1366
+ fi
1367
+ set +e
1368
+ raw="$(node "$probe" --version "$v" --fixture --json 2>&1)"
1369
+ local rc=$?
1370
+ set -e
1371
+ case "$raw" in
1372
+ *'"status":"PASS"'*) status="PASS" ;;
1373
+ *'"status":"NOT_TESTED"'*) status="FAIL" ;;
1374
+ *'"status":"AMBIENTAL"'*) status="FAIL" ;;
1375
+ *) status="FAIL" ;;
1376
+ esac
1377
+ detail="$(printf '%s' "$raw" | tr '\n' ' ' | cut -c1-400)"
1378
+ if [ "$rc" -ne 0 ] && [ "$status" = "PASS" ]; then
1379
+ status="FAIL"
1380
+ detail="$detail (probe exit $rc)"
1381
+ elif [ "$rc" -ne 0 ] && [ "$status" != "FAIL" ]; then
1382
+ detail="$detail (probe exit $rc)"
1383
+ fi
1384
+ record "$v" "pantheon-cost" "$status" "$detail"
1385
+ }
1386
+
1387
+ run_rehydrate_probe() { # v
1388
+ local v="$1" package_root probe raw status detail
1389
+ if ! package_root="$(installed_package_root)"; then
1390
+ record "$v" "context-rehydrate" "FAIL" "sandbox package is not installed"
1391
+ return 0
1392
+ fi
1393
+ probe="$package_root/scripts/probe-context-rehydrate.mjs"
1394
+ if [ ! -f "$probe" ]; then
1395
+ record "$v" "context-rehydrate" "FAIL" "installed package has no offline context probe"
1396
+ return 0
1397
+ fi
1398
+ if ! command -v node >/dev/null 2>&1; then
1399
+ record "$v" "context-rehydrate" "FAIL" "node runtime is not available"
1400
+ return 0
1401
+ fi
1402
+ set +e
1403
+ raw="$(timeout 30 node "$probe" --version "$v" --json 2>&1)"
1404
+ local rc=$?
1405
+ set -e
1406
+ case "$raw" in
1407
+ *'"status":"PASS"'*) status="PASS" ;;
1408
+ *'"status":"NOT_TESTED"'*) status="FAIL" ;;
1409
+ *'"status":"AMBIENTAL"'*) status="FAIL" ;;
1410
+ *) status="FAIL" ;;
1411
+ esac
1412
+ detail="$(printf '%s' "$raw" | tr '\n' ' ' | cut -c1-400)"
1413
+ if [ "$rc" -eq 124 ]; then
1414
+ status="FAIL"
1415
+ detail="context probe timed out after 30s"
1416
+ elif [ "$rc" -ne 0 ] && [ "$status" = "PASS" ]; then
1417
+ # A non-zero probe exit must never be reported as PASS.
1418
+ status="FAIL"
1419
+ detail="$detail (probe exit $rc)"
1420
+ elif [ "$rc" -ne 0 ] && [ "$status" != "FAIL" ]; then
1421
+ detail="$detail (probe exit $rc)"
1422
+ fi
1423
+ record "$v" "context-rehydrate" "$status" "$detail"
1424
+ }
1425
+
1426
+ run_version_prompts() { # v
1427
+ local v="$1" bin
1428
+ if ! bin="$(sandbox_bin)"; then
1429
+ log "Binary '$V2_BIN' not in sandbox prefix — failing all $v checks."
1430
+ build_battery "$v"
1431
+ local i
1432
+ for i in "${!PROMPT_IDS[@]}"; do
1433
+ record "$v" "${PROMPT_IDS[$i]}" "FAIL" "binary $V2_BIN not installed in sandbox"
1434
+ done
1435
+ record "$v" "mcp-list" "FAIL" "binary $V2_BIN not installed in sandbox"
1436
+ record "$v" "doctor" "FAIL" "binary $V2_BIN not installed in sandbox"
1437
+ return 0
1438
+ fi
1439
+ prepare_project "$v"
1440
+ build_battery "$v"
1441
+ # check_mcp_list owns service startup and must launch `mcp list` before it
1442
+ # waits for the five MCP handshakes. Starting a service and waiting here
1443
+ # would recreate the V2 loader deadlock before the battery even begins.
1444
+ check_mcp_list "$v"
1445
+ local i
1446
+ for i in "${!PROMPT_IDS[@]}"; do
1447
+ log "[$v] prompt ${PROMPT_IDS[$i]} ..."
1448
+ run_prompt "$v" "$i" "$bin"
1449
+ log "[$v] prompt ${PROMPT_IDS[$i]} → ${RESULTS["$v:${PROMPT_IDS[$i]}"]}"
1450
+ done
1451
+ check_doctor "$v"
1452
+ }
1453
+
1454
+ probe_verdict() { # pass total → PASS when every required check was explicitly PASS
1455
+ local pass="$1" total="$2"
1456
+ if [ "$pass" -eq "$total" ] && [ "$pass" -gt 0 ]; then
1457
+ printf 'PASS'
1458
+ else
1459
+ printf 'FAIL'
1460
+ fi
1461
+ }
1462
+
1463
+ write_report() { # versions...
1464
+ local versions=("$@") total_fail=0 total_pass=0
1465
+ {
1466
+ printf '# Pantheon Sandbox Prompts Report\n\n'
1467
+ printf -- '- **Date:** %s\n' "$(date -u '+%Y-%m-%d %H:%M UTC')"
1468
+ printf -- '- **Model:** %s\n' "$PANTHEON_SANDBOX_MODEL"
1469
+ printf -- '- **Sandbox:** %s\n' "$SANDBOX_ROOT"
1470
+ printf -- '- **V2 binary:** %s (%s)\n\n' \
1471
+ "$V2_BIN" "$OPENCODE_V2_SPEC"
1472
+ local v id res
1473
+ for v in "${versions[@]}"; do
1474
+ printf '## Version %s (`%s`)\n\n' "$v" "$V2_BIN"
1475
+ printf '| Prompt | Result | Detail |\n|---|---|---|\n'
1476
+ for id in "${PROMPT_IDS[@]}" mcp-list doctor; do
1477
+ res="${RESULTS["$v:$id"]:-SKIP}"
1478
+ case "$res" in
1479
+ PASS) total_pass=$((total_pass + 1)) ;;
1480
+ *) total_fail=$((total_fail + 1)) ;;
1481
+ esac
1482
+ printf '| %s | %s | %s |\n' "$id" "$res" "${DETAILS["$v:$id"]:-—}"
1483
+ done
1484
+ printf '\n'
1485
+ done
1486
+ printf '## Summary\n\n'
1487
+ printf -- '- PASS: %d\n- Not PASS (blocking): %d\n\n' \
1488
+ "$total_pass" "$total_fail"
1489
+ printf '**Verdict: %s**\n' "$(probe_verdict "$total_pass" "$((total_pass + total_fail))")"
1490
+ } > "$REPORT_FILE"
1491
+ log "Report written to $REPORT_FILE"
1492
+ }
1493
+
1494
+ write_cost_report() { # versions...
1495
+ local versions=("$@") total_fail=0 total_pass=0
1496
+ {
1497
+ printf '# Pantheon Cost Probe Report\n\n'
1498
+ printf -- '- **Date:** %s\n' "$(date -u '+%Y-%m-%d %H:%M UTC')"
1499
+ printf -- '- **Sandbox:** %s\n\n' "$SANDBOX_ROOT"
1500
+ printf '| Version | Result | Detail |\n|---|---|---|\n'
1501
+ local v result
1502
+ for v in "${versions[@]}"; do
1503
+ result="${RESULTS["$v:pantheon-cost"]:-NOT_TESTED}"
1504
+ case "$result" in
1505
+ PASS) total_pass=$((total_pass + 1)) ;;
1506
+ *) total_fail=$((total_fail + 1)) ;;
1507
+ esac
1508
+ printf '| %s | %s | %s |\n' "$v" "$result" "${DETAILS["$v:pantheon-cost"]:-—}"
1509
+ done
1510
+ printf '\n## Summary\n\n'
1511
+ printf -- '- PASS: %d\n- Not PASS (blocking): %d\n\n' \
1512
+ "$total_pass" "$total_fail"
1513
+ printf '**Verdict: %s**\n' "$(probe_verdict "$total_pass" "$((total_pass + total_fail))")"
1514
+ } > "$COST_REPORT_FILE"
1515
+ log "Cost probe report written to $COST_REPORT_FILE"
1516
+ }
1517
+
1518
+ write_rehydrate_report() { # versions...
1519
+ local versions=("$@") total_fail=0 total_pass=0
1520
+ {
1521
+ printf '# Pantheon Context Rehydration Probe Report\n\n'
1522
+ printf -- '- **Date:** %s\n' "$(date -u '+%Y-%m-%d %H:%M UTC')"
1523
+ printf -- '- **Sandbox:** %s\n\n' "$SANDBOX_ROOT"
1524
+ printf '| Version | Probe | Result | Detail |\n|---|---|---|---|\n'
1525
+ local v result
1526
+ for v in "${versions[@]}"; do
1527
+ result="${RESULTS["$v:context-rehydrate"]:-NOT_TESTED}"
1528
+ case "$result" in
1529
+ PASS) total_pass=$((total_pass + 1)) ;;
1530
+ *) total_fail=$((total_fail + 1)) ;;
1531
+ esac
1532
+ printf '| %s | context_rehydrate + context_session_summary | %s | %s |\n' \
1533
+ "$v" "$result" "${DETAILS["$v:context-rehydrate"]:-—}"
1534
+ done
1535
+ printf '\n## Summary\n\n'
1536
+ printf -- '- PASS: %d\n- Not PASS (blocking): %d\n\n' \
1537
+ "$total_pass" "$total_fail"
1538
+ printf '**Verdict: %s**\n' "$(probe_verdict "$total_pass" "$((total_pass + total_fail))")"
1539
+ } > "$REHYDRATE_REPORT_FILE"
1540
+ log "Context probe report written to $REHYDRATE_REPORT_FILE"
1541
+ }
1542
+
1543
+ run_cost() { # versions...
1544
+ local versions=("$@") v fail=0
1545
+ mkdir -p "$SANDBOX_ROOT"
1546
+ for v in "${versions[@]}"; do
1547
+ log "[$v] pantheon_cost offline probe ..."
1548
+ run_cost_probe "$v"
1549
+ log "[$v] pantheon-cost → ${RESULTS["$v:pantheon-cost"]}"
1550
+ done
1551
+ write_cost_report "${versions[@]}"
1552
+ for v in "${versions[@]}"; do
1553
+ [ "${RESULTS["$v:pantheon-cost"]:-}" = "PASS" ] || fail=$((fail + 1))
1554
+ done
1555
+ if [ "$fail" -gt 0 ]; then
1556
+ log "Cost probe: $fail check(s) without explicit PASS."
1557
+ exit 1
1558
+ fi
1559
+ log "Cost probe: PASS."
1560
+ }
1561
+
1562
+ run_rehydrate() { # versions...
1563
+ local versions=("$@") v fail=0
1564
+ mkdir -p "$SANDBOX_ROOT"
1565
+ for v in "${versions[@]}"; do
1566
+ log "[$v] context_rehydrate/session_summary offline probe ..."
1567
+ run_rehydrate_probe "$v"
1568
+ log "[$v] context probe → ${RESULTS["$v:context-rehydrate"]}"
1569
+ done
1570
+ write_rehydrate_report "${versions[@]}"
1571
+ for v in "${versions[@]}"; do
1572
+ [ "${RESULTS["$v:context-rehydrate"]:-}" = "PASS" ] || fail=$((fail + 1))
1573
+ done
1574
+ if [ "$fail" -gt 0 ]; then
1575
+ log "Context probe: $fail check(s) without explicit PASS."
1576
+ exit 1
1577
+ fi
1578
+ log "Context probe: PASS."
1579
+ }
1580
+
1581
+ # ── Hook canary (proves callbacks FIRE, not just that a plugin loaded) ────────
1582
+
1583
+ run_hooks() {
1584
+ local bin agent
1585
+ if ! bin="$(sandbox_bin)"; then
1586
+ printf 'ERROR: sandbox not prepared for hooks (binary '\''%s'\'' missing) — run %s --prepare first\n' \
1587
+ "$V2_BIN" "$0" >&2
1588
+ exit 3
1589
+ fi
1590
+ agent="${PANTHEON_HOOK_CANARY_AGENT:-canary}"
1591
+ log "[hooks] V2 hook canary against $bin (model $PANTHEON_SANDBOX_MODEL, agent $agent) ..."
1592
+ # PANTHEON_HOOK_CANARY_MODE=real is the point of this path: it makes the host
1593
+ # load src/plugin-v2.ts itself, so a hook that registers under a dead name
1594
+ # fails here. Default `fixture` mode only proves the hook NAMES are real, so
1595
+ # without this the sandbox would never exercise the shipped plugin.
1596
+ if PANTHEON_HOOK_CANARY_BIN="$bin" \
1597
+ PANTHEON_HOOK_CANARY_MODEL="$PANTHEON_SANDBOX_MODEL" \
1598
+ PANTHEON_HOOK_CANARY_AGENT="$agent" \
1599
+ PANTHEON_HOOK_CANARY_MODE=real \
1600
+ node --test "$REPO_DIR/tests/pantheon/plugin-v2-hook-canary.test.mjs"; then
1601
+ log "Hook canary: PASS."
1602
+ else
1603
+ log "Hook canary: FAIL."
1604
+ exit 1
1605
+ fi
1606
+ }
1607
+
1608
+ run_battery() { # versions...
1609
+ local versions=("$@")
1610
+ mkdir -p "$SANDBOX_ROOT"
1611
+ write_extract_py
1612
+ local v
1613
+ for v in "${versions[@]}"; do
1614
+ run_version_prompts "$v"
1615
+ done
1616
+ write_report "${versions[@]}"
1617
+ local v fail=0 id
1618
+ for v in "${versions[@]}"; do
1619
+ for id in "${PROMPT_IDS[@]}" mcp-list doctor; do
1620
+ [ "${RESULTS["$v:$id"]:-}" = "PASS" ] || fail=$((fail + 1))
1621
+ done
1622
+ done
1623
+ if [ "$fail" -gt 0 ]; then
1624
+ log "Prompts battery: $fail check(s) without explicit PASS."
1625
+ exit 1
1626
+ fi
1627
+ log "Prompts battery: PASS."
1628
+ }
1629
+
1630
+ # ── Base validation (no prompts) ──────────────────────────────────────────────
1631
+
1632
+ run_base() { # v
1633
+ local v="$1" bin
1634
+ if ! bin="$(sandbox_bin)"; then
1635
+ printf 'ERROR: sandbox not prepared for %s (binary '\''%s'\'' missing from sandbox prefix) — run %s --prepare first\n' \
1636
+ "$v" "$V2_BIN" "$0" >&2
1637
+ exit 3
1638
+ fi
1639
+ prepare_project "$v"
1640
+ build_battery "$v"
1641
+ log "[$v] opencode mcp list"
1642
+ check_mcp_list "$v"
1643
+ log "[$v] mcp-list → ${RESULTS["$v:mcp-list"]}"
1644
+ log "[$v] doctor"
1645
+ check_doctor "$v"
1646
+ log "[$v] doctor → ${RESULTS["$v:doctor"]}"
1647
+ local fail=0
1648
+ [ "${RESULTS["$v:mcp-list"]}" = "PASS" ] || fail=$((fail + 1))
1649
+ [ "${RESULTS["$v:doctor"]}" = "PASS" ] || fail=$((fail + 1))
1650
+ if [ "$fail" -gt 0 ]; then
1651
+ log "Base validation $v: $fail check(s) without explicit PASS."
1652
+ exit 1
1653
+ fi
1654
+ log "Base validation $v: PASS."
1655
+ }
1656
+
1657
+ # ── CLI ───────────────────────────────────────────────────────────────────────
1658
+
1659
+ while [ $# -gt 0 ]; do
1660
+ case "$1" in
1661
+ --prepare) MODE_PREPARE=1 ;;
1662
+ --reset) MODE_RESET=1 ;;
1663
+ --prompts) MODE_PROMPTS=1 ;;
1664
+ --cost) MODE_COST=1 ;;
1665
+ --rehydrate) MODE_REHYDRATE=1 ;;
1666
+ --hooks) MODE_HOOKS=1 ;;
1667
+ --run)
1668
+ shift
1669
+ case "${1:-}" in
1670
+ v2) RUN_VERSION="$1" ;;
1671
+ v1)
1672
+ printf 'ERROR: the V1 leg was removed — this project is OpenCode V2-exclusive.\n' >&2
1673
+ exit 2
1674
+ ;;
1675
+ *) usage ;;
1676
+ esac
1677
+ ;;
1678
+ --help | -h) usage ;;
1679
+ *) usage ;;
1680
+ esac
1681
+ shift
1682
+ done
1683
+
1684
+ if [ "$MODE_RESET" -eq 1 ]; then
1685
+ cmd_reset
1686
+ exit 0
1687
+ fi
1688
+
1689
+ if [ "$MODE_PREPARE" -eq 0 ] && [ -z "$RUN_VERSION" ] \
1690
+ && [ "$MODE_PROMPTS" -eq 0 ] && [ "$MODE_COST" -eq 0 ] \
1691
+ && [ "$MODE_REHYDRATE" -eq 0 ] && [ "$MODE_HOOKS" -eq 0 ]; then
1692
+ usage
1693
+ fi
1694
+
1695
+ if [ "$MODE_PREPARE" -eq 1 ]; then
1696
+ cmd_prepare
1697
+ fi
1698
+
1699
+ if [ -n "$RUN_VERSION" ]; then
1700
+ if [ "$MODE_PROMPTS" -eq 1 ]; then
1701
+ sandbox_env
1702
+ run_battery "$RUN_VERSION"
1703
+ else
1704
+ sandbox_env
1705
+ run_base "$RUN_VERSION"
1706
+ fi
1707
+ if [ "$MODE_COST" -eq 1 ]; then
1708
+ sandbox_env
1709
+ run_cost "$RUN_VERSION"
1710
+ fi
1711
+ if [ "$MODE_REHYDRATE" -eq 1 ]; then
1712
+ sandbox_env
1713
+ run_rehydrate "$RUN_VERSION"
1714
+ fi
1715
+ if [ "$MODE_HOOKS" -eq 1 ]; then
1716
+ sandbox_env
1717
+ run_hooks
1718
+ fi
1719
+ elif [ "$MODE_PROMPTS" -eq 1 ]; then
1720
+ sandbox_env
1721
+ run_battery "$TARGET_VERSION"
1722
+ if [ "$MODE_COST" -eq 1 ]; then
1723
+ run_cost "$TARGET_VERSION"
1724
+ fi
1725
+ if [ "$MODE_REHYDRATE" -eq 1 ]; then
1726
+ run_rehydrate "$TARGET_VERSION"
1727
+ fi
1728
+ if [ "$MODE_HOOKS" -eq 1 ]; then
1729
+ run_hooks
1730
+ fi
1731
+ elif [ "$MODE_COST" -eq 1 ]; then
1732
+ sandbox_env
1733
+ run_cost "$TARGET_VERSION"
1734
+ if [ "$MODE_REHYDRATE" -eq 1 ]; then
1735
+ run_rehydrate "$TARGET_VERSION"
1736
+ fi
1737
+ if [ "$MODE_HOOKS" -eq 1 ]; then
1738
+ run_hooks
1739
+ fi
1740
+ elif [ "$MODE_REHYDRATE" -eq 1 ]; then
1741
+ sandbox_env
1742
+ rehydrate_status=0
1743
+ # run_rehydrate exits when its check fails; isolate it so the separately
1744
+ # requested hook canary still runs, then return a failing status afterward.
1745
+ (run_rehydrate "$TARGET_VERSION") || rehydrate_status=$?
1746
+ hooks_status=0
1747
+ if [ "$MODE_HOOKS" -eq 1 ]; then
1748
+ (run_hooks) || hooks_status=$?
1749
+ fi
1750
+ if [ "$rehydrate_status" -ne 0 ]; then
1751
+ exit "$rehydrate_status"
1752
+ fi
1753
+ if [ "$hooks_status" -ne 0 ]; then
1754
+ exit "$hooks_status"
1755
+ fi
1756
+ elif [ "$MODE_HOOKS" -eq 1 ]; then
1757
+ sandbox_env
1758
+ run_hooks
1759
+ fi