pantheon-opencode 1.3.5 → 1.3.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +33 -1
- package/package.json +3 -3
- package/scripts/_pantheon_paths.py +0 -1
- package/scripts/build-agents-md.mjs +4 -2
- package/scripts/code_mode_server.py +307 -21
- package/scripts/doctor.mjs +26 -13
- package/scripts/hooks/format-multi-language.sh +6 -3
- package/scripts/hooks/log-session-start.sh +6 -0
- package/scripts/hooks/on-subagent-delegation-start.sh +22 -6
- package/scripts/hooks/validate-post-conditions.sh +1 -1
- package/scripts/hooks/validate-tool-safety.sh +5 -2
- package/scripts/install/agents-md.mjs +3 -1
- package/scripts/install/cli-ui.mjs +24 -4
- package/scripts/install/interactive.mjs +18 -7
- package/scripts/install/migrate-config.mjs +13 -19
- package/scripts/install/migrate.mjs +8 -5
- package/scripts/install/model-picker.mjs +4 -1
- package/scripts/install/opencode.mjs +2 -0
- package/scripts/install/shared.mjs +6 -4
- package/scripts/install/state.mjs +9 -6
- package/scripts/mcp_persistence_server.py +23 -22
- package/scripts/mcp_resources_server.py +60 -1
- package/scripts/memory_mcp_server.py +80 -4
- package/scripts/postinstall.mjs +27 -25
- package/scripts/prune.mjs +1 -1
- package/scripts/redaction-gate.mjs +3 -1
- package/scripts/sync-tui.mjs +148 -0
- package/scripts/uninstall.mjs +0 -2
- package/scripts/validation-policy.mjs +6 -1
- package/scripts/version-check.mjs +9 -4
- package/src/agents/aphrodite.md +1 -1
- package/src/agents/demeter.md +1 -1
- package/src/agents/hephaestus.md +1 -1
- package/src/agents/hermes.md +1 -1
- package/src/agents/themis.md +4 -0
- package/src/mcp/code_mode_server.py +304 -18
- package/src/mcp/eval_store.py +233 -0
- package/src/mcp/mcp_persistence_server.py +23 -22
- package/src/mcp/mcp_resources_server.py +58 -0
- package/src/mcp/memory_mcp_server.py +80 -3
- package/src/pantheon/auto-wake.ts +2 -4
- package/src/pantheon/background-job-board.ts +17 -4
- package/src/pantheon/comment-checker.ts +48 -0
- package/src/pantheon/delegation-classifier.ts +307 -0
- package/src/pantheon/delegation-enforce.ts +21 -21
- package/src/pantheon/delegation-finalize.ts +44 -4
- package/src/pantheon/delegation-notify.ts +97 -0
- package/src/pantheon/delegation.ts +413 -23
- package/src/pantheon/dispatch-guard.ts +2 -0
- package/src/pantheon/file-persistence.ts +7 -3
- package/src/pantheon/permission-eval.ts +284 -0
- package/src/pantheon/permission-globs.ts +86 -0
- package/src/pantheon/presets.d.mts +41 -0
- package/src/pantheon/presets.mjs +134 -0
- package/src/pantheon/retry-policy.ts +212 -0
- package/src/pantheon/session-guard.ts +1 -3
- package/src/pantheon/step-cap.ts +110 -0
- package/src/pantheon/tui-version-check.ts +72 -0
- package/src/pantheon/vision.ts +7 -5
- package/src/pantheon/zeus-delegate-with-retry.ts +81 -12
- package/src/plugin.ts +57 -3
- package/src/plugins/hook-runner.ts +1 -1
- package/src/plugins/pantheon-hooks.ts +1 -1
- package/src/plugins/tui/dist/tui.d.ts +1 -1
- package/src/plugins/tui/dist/tui.d.ts.map +1 -1
- package/src/plugins/tui/dist/tui.js +19 -14
- package/src/plugins/tui/dist/tui.tsx +52 -18
- package/src/plugins/tui/package.json +1 -1
- package/src/plugins/tui/src/index.tsx +106 -24
- package/src/plugins/tui/tsdown.config.ts +1 -1
- 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.
|
|
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.
|
|
3
|
+
"version": "1.3.7",
|
|
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
|
|
37
|
-
"test:ci": "python3 -m pytest tests/ -q --tb=short 2>&1
|
|
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"
|
|
@@ -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,
|
|
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(
|
|
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.
|
|
34
|
-
# 2.
|
|
35
|
-
# 3.
|
|
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,
|
|
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 {
|
|
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
|
-
"
|
|
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(
|
|
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
|
-
|
|
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=
|
|
436
|
+
proc.communicate(), timeout=timeout_s
|
|
180
437
|
)
|
|
181
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
193
|
-
f"or interpreter missing
|
|
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
|
|
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 ───────────────────────────────────────────────────────────
|
package/scripts/doctor.mjs
CHANGED
|
@@ -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
|
-
//
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
}
|
|
372
|
-
|
|
373
|
-
|
|
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(
|
|
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 =
|
|
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 "$
|
|
21
|
-
|
|
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
|
-
|
|
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
|