pantheon-opencode 1.3.5 → 1.3.6

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 (68) hide show
  1. package/README.md +33 -1
  2. package/package.json +3 -3
  3. package/scripts/_pantheon_paths.py +0 -1
  4. package/scripts/build-agents-md.mjs +4 -2
  5. package/scripts/code_mode_server.py +307 -21
  6. package/scripts/doctor.mjs +26 -13
  7. package/scripts/hooks/format-multi-language.sh +6 -3
  8. package/scripts/hooks/log-session-start.sh +6 -0
  9. package/scripts/hooks/on-subagent-delegation-start.sh +22 -6
  10. package/scripts/hooks/validate-post-conditions.sh +1 -1
  11. package/scripts/hooks/validate-tool-safety.sh +5 -2
  12. package/scripts/install/agents-md.mjs +3 -1
  13. package/scripts/install/cli-ui.mjs +24 -4
  14. package/scripts/install/interactive.mjs +18 -7
  15. package/scripts/install/migrate-config.mjs +13 -19
  16. package/scripts/install/migrate.mjs +8 -5
  17. package/scripts/install/model-picker.mjs +4 -1
  18. package/scripts/install/opencode.mjs +2 -0
  19. package/scripts/install/shared.mjs +6 -4
  20. package/scripts/install/state.mjs +9 -6
  21. package/scripts/mcp_persistence_server.py +23 -22
  22. package/scripts/mcp_resources_server.py +60 -1
  23. package/scripts/memory_mcp_server.py +80 -4
  24. package/scripts/postinstall.mjs +27 -25
  25. package/scripts/prune.mjs +1 -1
  26. package/scripts/redaction-gate.mjs +3 -1
  27. package/scripts/uninstall.mjs +0 -2
  28. package/scripts/validation-policy.mjs +6 -1
  29. package/scripts/version-check.mjs +9 -4
  30. package/src/agents/aphrodite.md +1 -1
  31. package/src/agents/demeter.md +1 -1
  32. package/src/agents/hephaestus.md +1 -1
  33. package/src/agents/hermes.md +1 -1
  34. package/src/agents/themis.md +4 -0
  35. package/src/mcp/code_mode_server.py +304 -18
  36. package/src/mcp/eval_store.py +233 -0
  37. package/src/mcp/mcp_persistence_server.py +23 -22
  38. package/src/mcp/mcp_resources_server.py +58 -0
  39. package/src/mcp/memory_mcp_server.py +80 -3
  40. package/src/pantheon/auto-wake.ts +2 -4
  41. package/src/pantheon/background-job-board.ts +17 -4
  42. package/src/pantheon/comment-checker.ts +48 -0
  43. package/src/pantheon/delegation-classifier.ts +307 -0
  44. package/src/pantheon/delegation-enforce.ts +21 -21
  45. package/src/pantheon/delegation-finalize.ts +44 -4
  46. package/src/pantheon/delegation.ts +403 -23
  47. package/src/pantheon/dispatch-guard.ts +2 -0
  48. package/src/pantheon/file-persistence.ts +7 -3
  49. package/src/pantheon/permission-eval.ts +284 -0
  50. package/src/pantheon/permission-globs.ts +86 -0
  51. package/src/pantheon/presets.d.mts +41 -0
  52. package/src/pantheon/presets.mjs +134 -0
  53. package/src/pantheon/retry-policy.ts +212 -0
  54. package/src/pantheon/session-guard.ts +1 -3
  55. package/src/pantheon/step-cap.ts +110 -0
  56. package/src/pantheon/vision.ts +7 -5
  57. package/src/pantheon/zeus-delegate-with-retry.ts +81 -12
  58. package/src/plugin.ts +28 -3
  59. package/src/plugins/hook-runner.ts +1 -1
  60. package/src/plugins/pantheon-hooks.ts +1 -1
  61. package/src/plugins/tui/dist/tui.d.ts +1 -1
  62. package/src/plugins/tui/dist/tui.d.ts.map +1 -1
  63. package/src/plugins/tui/dist/tui.js +19 -14
  64. package/src/plugins/tui/dist/tui.tsx +52 -18
  65. package/src/plugins/tui/package.json +1 -1
  66. package/src/plugins/tui/src/index.tsx +52 -18
  67. package/src/plugins/tui/tsdown.config.ts +1 -1
  68. package/src/routing.yml +42 -0
package/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
  <h1 align="center">Pantheon</h1>
3
3
 
4
4
  <p align="center">
5
- <a href="CHANGELOG.md"><img src="https://img.shields.io/badge/version-v1.3.4-blue" alt="Version"></a>
5
+ <a href="CHANGELOG.md"><img src="https://img.shields.io/badge/version-v1.3.6--beta-blue" alt="Version"></a>
6
6
  <a href="LICENSE"><img src="https://img.shields.io/badge/license-MIT-blue" alt="License"></a>
7
7
  <a href="docs/agents/README.md"><img src="https://img.shields.io/badge/agents-14-purple" alt="Agents"></a>
8
8
  <a href="src/skills/README.md"><img src="https://img.shields.io/badge/skills-21-orange" alt="Skills"></a>
@@ -278,6 +278,38 @@ agent in `background_delegation.read_only_agents` (`apollo`, `gaia`), registers
278
278
  the child session as read-only — the `tool.execute.before` guard denies
279
279
  `edit`/`write`/`bash`/`task` inside that session.
280
280
 
281
+ **R1 — Per-error-type retry + provider cooldown (LiteLLM pattern):**
282
+ `retry_policy` in `src/routing.yml` maps an error class (`auth`, `rate_limit`,
283
+ `timeout`, `other`) to max retries before escalating — `auth` is never retried
284
+ (0), `rate_limit` gets 3, `timeout` 2, `other` 1. Retries use exponential
285
+ backoff (base 1s, doubling, capped 30s). `cooldown` (`allowed_fails`,
286
+ `cooldown_time_seconds`) skips a provider for the configured window after that
287
+ many consecutive failures; a success resets the counter. Enforcement lives in
288
+ `RetryPolicyEngine` (`src/pantheon/retry-policy.ts`) and the R1 path of
289
+ `zeusDelegateWithRetry` (`src/pantheon/zeus-delegate-with-retry.ts`) — pass
290
+ `retryPolicy`/`cooldown`/`provider` to `createZeusRetryHelper`. In-memory only
291
+ (no Redis).
292
+
293
+ **R4 — Per-agent step caps:** `agents.<name>.max_steps` in `src/routing.yml`
294
+ gives each agent a step budget (e.g. `apollo: 25`, `themis: 25`, `athena: 15`).
295
+ When an agent reaches `max_steps` it is forced to summarize-and-stop: a
296
+ delegation to an already-capped agent is skipped with a `[STEP CAP REACHED]`
297
+ summary (no child session created), and a dispatch that hits the cap appends a
298
+ stop instruction to the prompt. Enforcement: `StepCapTracker`
299
+ (`src/pantheon/step-cap.ts`) wired into `pantheon_delegate`. The tracker is
300
+ **permanent-per-process** — counters accumulate for the process lifetime and
301
+ are intentionally not reset on success/session end (a per-session reset would
302
+ let an agent exceed its process budget).
303
+
304
+ **O5 — Permission globs for delegation:** `permission.task` in `src/routing.yml`
305
+ controls which subagents an agent may invoke via glob patterns (last matching
306
+ rule wins). A deny removes the agent from the delegate tool description
307
+ entirely (not just blocks the call) — preventing circular delegation at the
308
+ permission layer. Default `"*": allow` keeps the existing runtime matrix
309
+ (zeus → anyone; athena/hermes → apollo only). Enforcement:
310
+ `src/pantheon/permission-globs.ts` + the `permissionTask` option on
311
+ `createDelegationTools` / `createEnforcementGuard`.
312
+
281
313
  **Compaction carry-forward:** during context compaction, running and unread
282
314
  terminal delegations (capped at `background_delegation.max_compaction_items`,
283
315
  default 10) are carried into the compacted context so the parent doesn't lose
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pantheon-opencode",
3
- "version": "1.3.5",
3
+ "version": "1.3.6",
4
4
  "description": "Multi-agent orchestration for OpenCode — 14 specialized agents, TDD, Themis review gates.",
5
5
  "publishConfig": {
6
6
  "access": "public",
@@ -33,8 +33,8 @@
33
33
  "test:security": "npm run secret-scan && node tests/test_install_mcp_vision.mjs && node tests/test_doctor_layers.mjs",
34
34
  "test:node": "node --test tests/*.mjs tests/pantheon/*.mjs",
35
35
  "test:ts": "for f in tests/pantheon/*.test.ts; do npx tsx \"$f\" || exit 1; done",
36
- "test": "python3 -m pytest tests/ -q 2>&1 || echo \"No tests found\"",
37
- "test:ci": "python3 -m pytest tests/ -q --tb=short 2>&1 || true"
36
+ "test": "python3 -m pytest tests/ -q 2>&1; status=$?; if [ \"$status\" -eq 5 ]; then echo \"No tests found\"; fi; exit \"$status\"",
37
+ "test:ci": "python3 -m pytest tests/ -q --tb=short 2>&1"
38
38
  },
39
39
  "bin": {
40
40
  "pantheon-opencode": "bin/pantheon-init.mjs"
@@ -1,4 +1,3 @@
1
- # Auto-generated: resolved symlink from ../src/mcp/_pantheon_paths.py
2
1
  #!/usr/bin/env python3
3
2
  """Pantheon path resolution — global install + project root detection.
4
3
 
@@ -22,7 +22,7 @@
22
22
  * No runtime dependencies (Node >= 18, stdlib only). Idempotent: running it
23
23
  * twice produces byte-identical output.
24
24
  */
25
- import { existsSync, readFileSync, readdirSync, writeFileSync } from 'node:fs'
25
+ import { existsSync, readdirSync, readFileSync, writeFileSync } from 'node:fs'
26
26
  import { dirname, join } from 'node:path'
27
27
  import { fileURLToPath } from 'node:url'
28
28
 
@@ -189,7 +189,9 @@ function main() {
189
189
 
190
190
  writeFileSync(AGENTS_MD_PATH, content, 'utf8')
191
191
  const lines = content.split('\n').length
192
- console.log(`✅ Generated AGENTS.md (${instructions.length} instruction sections, ${lines} lines)`)
192
+ console.log(
193
+ `✅ Generated AGENTS.md (${instructions.length} instruction sections, ${lines} lines)`,
194
+ )
193
195
  }
194
196
 
195
197
  main()
@@ -16,10 +16,18 @@ Or via MCP client (stdio transport):
16
16
  from __future__ import annotations
17
17
 
18
18
  import asyncio
19
+ import logging
19
20
  import os
21
+ import signal
22
+ import shutil
20
23
  import stat
24
+ import sys
25
+ import time
21
26
  from contextlib import suppress
22
27
  from pathlib import Path
28
+ from typing import Any
29
+
30
+ import yaml
23
31
 
24
32
  from _pantheon_paths import pantheon_home, pantheon_project
25
33
  from mcp.server.fastmcp import FastMCP
@@ -27,12 +35,104 @@ from mcp.server.fastmcp import FastMCP
27
35
  # ── Constants ─────────────────────────────────────────────────────────────────
28
36
  ALLOWED_EXTENSIONS: frozenset[str] = frozenset({".sh", ".py"})
29
37
  SCRIPT_TIMEOUT: int = 30
38
+ # Hard ceiling for frontmatter `timeout:` overrides — no script may pin the
39
+ # executor for longer than 5 minutes.
40
+ MAX_SCRIPT_TIMEOUT: int = 300
41
+
42
+ # ── Env Allowlist ──────────────────────────────────────────────────────────────
43
+ # Only these variables are passed to script subprocesses. Everything else
44
+ # (secrets, cloud credentials, etc.) is stripped to limit blast radius.
45
+ _CODE_MODE_ENV_ALLOWLIST: frozenset[str] = frozenset({
46
+ "PATH",
47
+ "HOME",
48
+ "LANG",
49
+ "PANTHEON_HOME",
50
+ "PANTHEON_PROJECT",
51
+ "OPENAI_API_KEY",
52
+ "OPENAI_BASE_URL",
53
+ "EVAL_JUDGE_MODEL",
54
+ "SHELL",
55
+ "USER",
56
+ })
57
+
58
+ # Optional extra vars that callers may opt-in to pass through. Populate
59
+ # this set at startup if needed (e.g. ``CODE_MODE_EXTRA_ENV.add("MY_VAR")``).
60
+ CODE_MODE_EXTRA_ENV: set[str] = set()
61
+
62
+ # Safe defaults when the host env is completely empty.
63
+ _SAFE_DEFAULTS: dict[str, str] = {
64
+ "PATH": "/usr/local/bin:/usr/bin:/bin",
65
+ "HOME": "/tmp",
66
+ }
67
+
68
+ _log = logging.getLogger(__name__)
69
+
70
+
71
+ def _build_script_env(
72
+ host_env: dict[str, str] | None = None,
73
+ ) -> dict[str, str]:
74
+ """Construct a minimal env dict for script subprocesses.
75
+
76
+ Only allowlisted vars (plus any in ``CODE_MODE_EXTRA_ENV``) are
77
+ forwarded from *host_env* (defaults to ``os.environ``). Missing
78
+ allowlisted vars receive safe defaults from ``_SAFE_DEFAULTS``.
79
+
80
+ Returns a **new** dict — the input is never mutated.
81
+ """
82
+ if host_env is None:
83
+ host_env = dict(os.environ)
84
+
85
+ allowed = _CODE_MODE_ENV_ALLOWLIST | CODE_MODE_EXTRA_ENV
86
+ env: dict[str, str] = {}
87
+
88
+ for key in allowed:
89
+ if key in host_env:
90
+ env[key] = host_env[key]
91
+ elif key in _SAFE_DEFAULTS:
92
+ env[key] = _SAFE_DEFAULTS[key]
93
+
94
+ # Always ensure PATH is present even if allowlist + defaults both miss it.
95
+ if "PATH" not in env:
96
+ env["PATH"] = _SAFE_DEFAULTS.get("PATH", "/usr/local/bin:/usr/bin:/bin")
97
+
98
+ return env
99
+
100
+
101
+ def _prlimit_prefix(
102
+ prlimit_path: str | None,
103
+ timeout_s: int = SCRIPT_TIMEOUT,
104
+ ) -> list[str] | None:
105
+ """Return a ``prlimit`` command prefix to wrap a subprocess, or *None*.
106
+
107
+ On Linux with ``prlimit`` available the prefix adds:
108
+ --nproc=512 --as=1073741824 --cpu=<timeout_s + 5>
109
+
110
+ On non-Linux or when *prlimit_path* is falsy, returns *None* (fail-open).
111
+ """
112
+ if not prlimit_path:
113
+ return None
114
+
115
+ cpu_limit = timeout_s + 5
116
+ return [
117
+ prlimit_path,
118
+ "--nproc=512",
119
+ "--as=1073741824", # 1 GiB in bytes
120
+ f"--cpu={cpu_limit}",
121
+ "--",
122
+ ]
123
+
124
+
125
+ def _detect_prlimit() -> str | None:
126
+ """Return the path to ``prlimit`` if available, else *None*."""
127
+ if sys.platform != "linux":
128
+ return None
129
+ return shutil.which("prlimit")
30
130
 
31
131
  # ── Scripts Directory Resolution ─────────────────────────────────────────────
32
132
  # Priority:
33
- # 1. $PANTHEON_HOME/.pantheon/code-mode/ (global install)
34
- # 2. $PANTHEON_PROJECT/.opencode/.pantheon/code-mode/ (project install)
35
- # 3. $PANTHEON_PROJECT/.pantheon/code-mode/ (legacy fallback)
133
+ # 1. /.opencode/.pantheon/code-mode/ (project install)
134
+ # 2. /.pantheon/code-mode/ (legacy fallback)
135
+ # 3. /.pantheon/code-mode/ (global fallback)
36
136
  _PANTHEON_HOME: Path = pantheon_home()
37
137
  _SCRIPTS_DIR_CANDIDATES: list[Path] = []
38
138
  _proj = pantheon_project()
@@ -87,12 +187,16 @@ def _validate_script_name(script_name: str) -> Path:
87
187
 
88
188
 
89
189
  def _format_output(
90
- stdout: str, stderr: str, exit_code: int, timed_out: bool = False
190
+ stdout: str,
191
+ stderr: str,
192
+ exit_code: int,
193
+ timed_out: bool = False,
194
+ timeout_s: int = SCRIPT_TIMEOUT,
91
195
  ) -> str:
92
196
  """Format script execution output into a readable string."""
93
197
  parts: list[str] = []
94
198
  if timed_out:
95
- parts.append(f"[TIMEOUT] Script exceeded {SCRIPT_TIMEOUT}s limit")
199
+ parts.append(f"[TIMEOUT] Script exceeded {timeout_s}s limit")
96
200
  if stdout:
97
201
  parts.append(stdout)
98
202
  if stderr:
@@ -101,6 +205,123 @@ def _format_output(
101
205
  return "\n".join(parts)
102
206
 
103
207
 
208
+ # ── Script Metadata (YAML frontmatter) ────────────────────────────────────────
209
+ # Optional comment-style frontmatter at the top of a script (after an optional
210
+ # shebang). Comment lines keep the script a valid executable in both bash and
211
+ # python while carrying metadata:
212
+ #
213
+ # #!/usr/bin/env python3
214
+ # # ---
215
+ # # description: Runs the checkpoint save
216
+ # # timeout: 5
217
+ # # allowed_args:
218
+ # # - compress
219
+ # # - --text
220
+ # # ---
221
+ #
222
+ # Supported keys: description (str), timeout (int seconds, overrides the 30s
223
+ # default), allowed_args (list of allowed CLI args — validated on execute).
224
+
225
+
226
+ def _parse_frontmatter(script_path: Path) -> dict[str, Any]:
227
+ """Parse comment-style YAML frontmatter from a script.
228
+
229
+ Returns an empty dict when the script has no frontmatter or the block is
230
+ malformed (fail-open — never blocks execution).
231
+ """
232
+ try:
233
+ text = script_path.read_text(encoding="utf-8")
234
+ except OSError:
235
+ return {}
236
+ lines = text.splitlines()
237
+
238
+ # Locate the opening delimiter: a comment line that is exactly "# ---".
239
+ # Only scan the header region (before the first non-comment code line).
240
+ start: int | None = None
241
+ for i, line in enumerate(lines):
242
+ stripped = line.strip()
243
+ if stripped == "# ---":
244
+ start = i
245
+ break
246
+ if i > 0 and stripped and not stripped.startswith("#"):
247
+ break
248
+ if start is None:
249
+ return {}
250
+
251
+ body_lines: list[str] = []
252
+ for j in range(start + 1, len(lines)):
253
+ stripped = lines[j].strip()
254
+ if stripped == "# ---":
255
+ break
256
+ if not stripped.startswith("#"):
257
+ return {} # non-comment line inside the block → not frontmatter
258
+ body_lines.append(stripped[1:].lstrip() if stripped.startswith("# ") else stripped[1:])
259
+ else:
260
+ return {} # no closing delimiter
261
+
262
+ try:
263
+ data = yaml.safe_load("\n".join(body_lines))
264
+ except yaml.YAMLError:
265
+ return {}
266
+ return data if isinstance(data, dict) else {}
267
+
268
+
269
+ def _script_timeout(script_path: Path) -> int:
270
+ """Per-script timeout override from frontmatter, capped at MAX_SCRIPT_TIMEOUT.
271
+
272
+ Honors any positive integer `timeout:` from the script frontmatter, but
273
+ never returns more than 300s (5 min) regardless of what frontmatter asks.
274
+ """
275
+ timeout = _parse_frontmatter(script_path).get("timeout")
276
+ if isinstance(timeout, int) and timeout > 0:
277
+ return min(timeout, MAX_SCRIPT_TIMEOUT)
278
+ return SCRIPT_TIMEOUT
279
+
280
+
281
+ def _validate_args(script_path: Path, args: list[str]) -> str | None:
282
+ """Validate args against the frontmatter ``allowed_args`` allowlist.
283
+
284
+ Returns an error message when a passed arg is not allowed, else None.
285
+ A missing or malformed allowlist fails open (no restriction).
286
+ """
287
+ allowed = _parse_frontmatter(script_path).get("allowed_args")
288
+ if allowed is None:
289
+ return None
290
+ if not isinstance(allowed, list) or not all(isinstance(a, str) for a in allowed):
291
+ return None
292
+ denied = [a for a in args if a not in allowed]
293
+ if denied:
294
+ return (
295
+ f"Argument(s) {denied} not allowed for script '{script_path.name}'. "
296
+ f"Allowed: {allowed}"
297
+ )
298
+ return None
299
+
300
+
301
+ def _build_result(
302
+ stdout: str,
303
+ stderr: str,
304
+ exit_code: int,
305
+ timed_out: bool,
306
+ duration_ms: int,
307
+ metadata: dict[str, Any],
308
+ json_output: bool,
309
+ timeout_s: int,
310
+ ) -> str | dict[str, Any]:
311
+ """Build the tool result in plain-text or structured JSON form."""
312
+ if json_output:
313
+ return {
314
+ "stdout": stdout,
315
+ "stderr": stderr,
316
+ "exit_code": exit_code,
317
+ "timed_out": timed_out,
318
+ "duration_ms": duration_ms,
319
+ "timeout_s": timeout_s,
320
+ "metadata": metadata,
321
+ }
322
+ return _format_output(stdout, stderr, exit_code, timed_out, timeout_s)
323
+
324
+
104
325
  # ── Static Resources ──────────────────────────────────────────────────────────
105
326
 
106
327
 
@@ -126,15 +347,20 @@ async def list_code_mode_scripts() -> str:
126
347
 
127
348
  @mcp.resource(
128
349
  "pantheon://code-mode/scripts/{script_name}",
129
- description="Content of a code-mode script by name",
350
+ description="Content of a code-mode script by name, with parsed frontmatter metadata",
130
351
  )
131
352
  async def get_code_mode_script(script_name: str) -> str:
132
- """Return the source content of a code-mode script."""
353
+ """Return the source content of a code-mode script plus its metadata."""
133
354
  try:
134
355
  script_path = _validate_script_name(script_name)
135
- return script_path.read_text(encoding="utf-8")
136
356
  except ValueError as e:
137
357
  return str(e)
358
+ metadata = _parse_frontmatter(script_path)
359
+ source = script_path.read_text(encoding="utf-8")
360
+ if not metadata:
361
+ return source
362
+ meta_lines = "\n".join(f"{k}: {v}" for k, v in sorted(metadata.items()))
363
+ return f"# metadata\n{meta_lines}\n\n{source}"
138
364
 
139
365
 
140
366
  # ── Tools ─────────────────────────────────────────────────────────────────────
@@ -142,16 +368,27 @@ async def get_code_mode_script(script_name: str) -> str:
142
368
 
143
369
  @mcp.tool(
144
370
  name="execute_code_script",
145
- description="Run a .sh/.py script from .pantheon/code-mode/ "
146
- "with optional args. 30s timeout.",
371
+ description="Run a .sh/.py script from .pantheon/code-mode/ with optional args. "
372
+ "30s default timeout (override via YAML frontmatter `timeout`). "
373
+ "Set json_output=true for structured JSON output.",
147
374
  )
148
- async def execute_code_script(script_name: str, args: list[str] | None = None) -> str:
375
+ async def execute_code_script(
376
+ script_name: str,
377
+ args: list[str] | None = None,
378
+ json_output: bool = False,
379
+ ) -> str | dict[str, Any]:
149
380
  """Execute a code-mode script with confinement and timeout.
150
381
 
151
382
  Args:
152
383
  script_name: Name of the script in the code-mode directory.
153
384
  args: Optional CLI arguments forwarded to the subprocess (e.g.
154
385
  ``["compress", "--text", "..."]``). Defaults to no args.
386
+ json_output: When True, return a structured dict with stdout, stderr,
387
+ exit_code, duration_ms, timed_out and frontmatter metadata
388
+ instead of the plain-text summary.
389
+
390
+ Returns:
391
+ Plain-text summary (default) or structured dict (json_output=True).
155
392
  """
156
393
  args = args or []
157
394
  try:
@@ -159,6 +396,13 @@ async def execute_code_script(script_name: str, args: list[str] | None = None) -
159
396
  except ValueError as e:
160
397
  return str(e)
161
398
 
399
+ metadata = _parse_frontmatter(script_path)
400
+ denied = _validate_args(script_path, args)
401
+ if denied is not None:
402
+ return denied
403
+
404
+ timeout_s = _script_timeout(script_path)
405
+
162
406
  # Ensure script is executable
163
407
  if not os.access(script_path, os.X_OK):
164
408
  with suppress(OSError):
@@ -166,34 +410,76 @@ async def execute_code_script(script_name: str, args: list[str] | None = None) -
166
410
  script_path.chmod(mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)
167
411
 
168
412
  script_dir = script_path.parent
413
+ started = time.monotonic()
414
+
415
+ # ── Build sanitized env & optional prlimit prefix ────────────────────
416
+ env_dict = _build_script_env()
417
+ prlimit = _prlimit_prefix(_detect_prlimit(), timeout_s)
418
+
419
+ # Command: optionally wrapped with prlimit for resource limits.
420
+ if prlimit is not None:
421
+ cmd = [*prlimit, str(script_path), *args]
422
+ else:
423
+ cmd = [str(script_path), *args]
424
+
169
425
  try:
170
426
  proc = await asyncio.create_subprocess_exec(
171
- str(script_path),
172
- *args,
427
+ *cmd,
173
428
  cwd=str(script_dir),
174
429
  stdout=asyncio.subprocess.PIPE,
175
430
  stderr=asyncio.subprocess.PIPE,
431
+ env=env_dict,
432
+ start_new_session=True,
176
433
  )
177
434
  try:
178
435
  stdout, stderr = await asyncio.wait_for(
179
- proc.communicate(), timeout=SCRIPT_TIMEOUT
436
+ proc.communicate(), timeout=timeout_s
180
437
  )
181
- return _format_output(
438
+ duration_ms = int((time.monotonic() - started) * 1000)
439
+ return _build_result(
182
440
  stdout.decode("utf-8", errors="replace"),
183
441
  stderr.decode("utf-8", errors="replace"),
184
442
  proc.returncode or 0,
443
+ timed_out=False,
444
+ duration_ms=duration_ms,
445
+ metadata=metadata,
446
+ json_output=json_output,
447
+ timeout_s=timeout_s,
185
448
  )
186
449
  except TimeoutError:
187
- proc.kill()
450
+ # Kill the entire process group so child processes don't survive.
451
+ try:
452
+ os.killpg(os.getpgid(proc.pid), signal.SIGKILL)
453
+ except (ProcessLookupError, OSError):
454
+ proc.kill() # fallback: kill main process only
188
455
  await proc.wait()
189
- return _format_output("", "", -1, timed_out=True)
456
+ duration_ms = int((time.monotonic() - started) * 1000)
457
+ return _build_result(
458
+ "", "", -1, timed_out=True, duration_ms=duration_ms,
459
+ metadata=metadata, json_output=json_output, timeout_s=timeout_s,
460
+ )
190
461
  except FileNotFoundError:
191
- return (
192
- f"Script '{script_name}' not found "
193
- f"or interpreter missing.\n--- exit code: -1"
462
+ return _build_result(
463
+ "",
464
+ f"Script '{script_name}' not found or interpreter missing.",
465
+ -1,
466
+ timed_out=False,
467
+ duration_ms=0,
468
+ metadata=metadata,
469
+ json_output=json_output,
470
+ timeout_s=timeout_s,
194
471
  )
195
472
  except OSError as e:
196
- return f"Failed to execute script: {e}\n--- exit code: -1"
473
+ return _build_result(
474
+ "",
475
+ f"Failed to execute script: {e}",
476
+ -1,
477
+ timed_out=False,
478
+ duration_ms=0,
479
+ metadata=metadata,
480
+ json_output=json_output,
481
+ timeout_s=timeout_s,
482
+ )
197
483
 
198
484
 
199
485
  # ── Main Entrypoint ───────────────────────────────────────────────────────────
@@ -351,30 +351,41 @@ function checkAgentFiles(args) {
351
351
  // Check B: MCP Configuration
352
352
  // ---------------------------------------------------------------------------
353
353
 
354
+ /** Resolve the effective OpenCode user config directory. */
355
+ export function resolveOpenCodeConfigDir(env = process.env) {
356
+ if (env.PANTHEON_HOME) return resolve(env.PANTHEON_HOME)
357
+ const base = env.XDG_CONFIG_HOME || join(env.HOME ?? homedir(), '.config')
358
+ return join(base, 'opencode')
359
+ }
360
+
354
361
  /**
355
362
  * Collect every reachable opencode.json (project → repository → user config)
356
363
  * with its parsed content, for the config, spawn-path and runtime layers.
357
364
  * @returns {{label:string, path:string, data:object}[]}
358
365
  */
359
- function collectMcpConfigs(args) {
366
+ export function collectMcpConfigs(args) {
360
367
  const candidates = [
361
368
  { label: 'project root', path: join(args.target, 'opencode.json') },
362
369
  { label: 'repository root', path: join(ROOT, 'opencode.json') },
363
370
  ]
364
371
 
365
- // Also check user config if target matches Pantheon ROOT
366
- if (args.target === ROOT) {
367
- candidates.push({
368
- label: 'user config',
369
- path: join(ROOT, '.opencode', 'opencode.json'),
370
- })
371
- } else {
372
- const homeConfig = join(process.env.HOME ?? '~', '.config', 'opencode', 'opencode.json')
373
- candidates.push({ label: 'user config', path: homeConfig })
374
- }
372
+ // OpenCode resolves user configuration independently of cwd. Keep the
373
+ // project-local .opencode config for repository checks, but always include
374
+ // the effective user config so a sandbox HOME is not mistaken for cwd.
375
+ candidates.push({
376
+ label: 'project .opencode config',
377
+ path: join(args.target, '.opencode', 'opencode.json'),
378
+ })
379
+ candidates.push({
380
+ label: 'user config',
381
+ path: join(resolveOpenCodeConfigDir(args.env ?? process.env), 'opencode.json'),
382
+ })
375
383
 
376
384
  const configs = []
385
+ const seen = new Set()
377
386
  for (const c of candidates) {
387
+ if (seen.has(c.path)) continue
388
+ seen.add(c.path)
378
389
  const data = readJson(c.path)
379
390
  if (data) {
380
391
  configs.push({ label: c.label, data, path: c.path })
@@ -391,7 +402,9 @@ function checkMcpConfig(args) {
391
402
 
392
403
  if (configs.length === 0) {
393
404
  if (requiredMcpNames.length > 0) {
394
- error(`No opencode.json found; required MCPs missing: ${requiredMcpNames.join(', ')}`)
405
+ error(
406
+ `No opencode.json found (searched project/repository and ${resolveOpenCodeConfigDir(args.env ?? process.env)}); required MCPs missing: ${requiredMcpNames.join(', ')}`,
407
+ )
395
408
  } else {
396
409
  info(`No opencode.json found for ${args.profile} profile — MCP check skipped`)
397
410
  }
@@ -950,7 +963,7 @@ function checkRequirementLine(line, installed) {
950
963
  function checkVenvLayer(args) {
951
964
  section('F. Runtime Layer — Venv, Python & Dependencies')
952
965
 
953
- const configDir = join(homedir(), '.config', 'opencode')
966
+ const configDir = resolveOpenCodeConfigDir()
954
967
  const venvPython = join(configDir, '.venv', 'bin', 'python3')
955
968
 
956
969
  if (!existsSync(venvPython)) {
@@ -16,9 +16,12 @@ import sys, json
16
16
  d = json.load(sys.stdin)
17
17
  print(d.get('tool_name', ''))
18
18
  " 2>/dev/null || echo "")
19
+ # Normalize to lowercase — opencode sends lowercase (edit/write/bash),
20
+ # Claude Code protocol may send capitalized (Edit/Write/Bash).
21
+ TOOL_NAME_LOWER=$(echo "$TOOL_NAME" | tr '[:upper:]' '[:lower:]')
19
22
 
20
- case "$TOOL_NAME" in
21
- Edit|Write)
23
+ case "$TOOL_NAME_LOWER" in
24
+ edit|write)
22
25
  JSON_PATH=$(echo "$STDIN_JSON" | python3 -c "
23
26
  import sys, json
24
27
  d = json.load(sys.stdin)
@@ -28,7 +31,7 @@ print(d.get('tool_input', {}).get('file_path', ''))
28
31
  FILE_PATH="$JSON_PATH"
29
32
  fi
30
33
  ;;
31
- Bash)
34
+ bash)
32
35
  COMMAND=$(echo "$STDIN_JSON" | python3 -c "
33
36
  import sys, json
34
37
  d = json.load(sys.stdin)
@@ -4,10 +4,16 @@
4
4
  set -euo pipefail
5
5
 
6
6
  # Project-local by default; set XDG_STATE_HOME for system-wide logging
7
+ # Anchor to PANTHEON_HOME or PANTHEON_PROJECT when available to avoid
8
+ # CWD-dependent relative path ("logs/agent-sessions" wrote to cwd).
7
9
  if [ -n "${LOG_DIR:-}" ]; then
8
10
  LOG_DIR="$LOG_DIR"
9
11
  elif [ -n "${XDG_STATE_HOME:-}" ]; then
10
12
  LOG_DIR="$XDG_STATE_HOME/pantheon/hooks"
13
+ elif [ -n "${PANTHEON_HOME:-}" ]; then
14
+ LOG_DIR="$PANTHEON_HOME/logs/agent-sessions"
15
+ elif [ -n "${PANTHEON_PROJECT:-}" ]; then
16
+ LOG_DIR="$PANTHEON_PROJECT/logs/agent-sessions"
11
17
  else
12
18
  LOG_DIR="logs/agent-sessions"
13
19
  fi