pantheon-opencode 1.2.1 → 1.3.0
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/AGENTS.md +10 -0
- package/README.md +53 -15
- package/bin/pantheon-init.mjs +14 -0
- package/opencode.json +17 -1
- package/package.json +13 -4
- package/scripts/changelog-extract.mjs +112 -0
- package/scripts/doctor.mjs +303 -13
- package/scripts/hooks/audit-imports.sh +43 -0
- package/scripts/hooks/format-multi-language.sh +96 -0
- package/scripts/hooks/log-session-start.sh +42 -0
- package/scripts/hooks/on-subagent-delegation-start.sh +201 -0
- package/scripts/hooks/on-subagent-delegation-stop.sh +419 -0
- package/scripts/hooks/run-type-check.sh +21 -0
- package/scripts/hooks/scan-secrets.sh +97 -0
- package/scripts/hooks/validate-post-conditions.sh +21 -0
- package/scripts/hooks/validate-talos-scope.sh +50 -0
- package/scripts/hooks/validate-tool-safety.sh +46 -0
- package/scripts/install/opencode.mjs +58 -29
- package/scripts/install/plugin.mjs +4 -2
- package/scripts/install-mcp.mjs +104 -56
- package/scripts/mcp_persistence_server.py +1 -0
- package/scripts/prune.mjs +359 -0
- package/scripts/version-check.mjs +146 -0
- package/scripts/versioning.mjs +13 -14
- package/src/agents/zeus.md +33 -5
- package/src/mcp/requirements-mcp.txt +8 -5
- package/src/mcp/requirements-vision.txt +3 -3
- package/src/plugins/hook-runner.ts +186 -0
- package/src/plugins/pantheon-hooks.ts +1503 -0
- package/src/plugins/tui/dist/tui.d.ts +8 -0
- package/src/plugins/tui/dist/tui.d.ts.map +1 -0
- package/src/plugins/tui/dist/tui.js +1198 -0
- package/src/plugins/tui/dist/tui.js.map +1 -0
- package/src/plugins/tui/dist/tui.tsx +1204 -0
- package/src/plugins/tui/package.json +2 -2
- package/src/plugins/tui/src/index.tsx +16 -1
- package/src/mcp/requirements-mcp-core.txt +0 -5
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# scan-secrets.sh — Pantheon PreToolUse Secret Scanning Hook
|
|
3
|
+
# Detects hardcoded secrets in tool input. Logs ALWAYS (masked); the exit
|
|
4
|
+
# code drives the plugin's hybrid blocking (see pantheon-hooks.ts):
|
|
5
|
+
#
|
|
6
|
+
# exit 0 — no match.
|
|
7
|
+
# exit 1 — LOW_CONFIDENCE match only (header/KEY NAMES such as the Bifrost header
|
|
8
|
+
# alone, api_key=/password=/secret= without a recognizable token
|
|
9
|
+
# value). Advisory: the plugin logs + toasts, does NOT block.
|
|
10
|
+
# exit 2 — HIGH_CONFIDENCE match (real token formats: sk-bf-<token>,
|
|
11
|
+
# AKIA..., ghp_..., glpat-..., sk_live_/sk_test_..., xox[baprs]-...,
|
|
12
|
+
# Bearer <token>, JWT). The plugin BLOCKS the tool call (throws
|
|
13
|
+
# after logging — the only deliberate throw in the plugin).
|
|
14
|
+
#
|
|
15
|
+
# Matches are always MASKED (first 4 + last 4 chars for len>8, else "****")
|
|
16
|
+
# before being echoed — the hook trail must never contain a raw secret value.
|
|
17
|
+
set -euo pipefail
|
|
18
|
+
|
|
19
|
+
HIGH_CONFIDENCE_PATTERNS=(
|
|
20
|
+
"AKIA[0-9A-Z]{16}"
|
|
21
|
+
"gh[pousr]_[A-Za-z0-9_]{36,}"
|
|
22
|
+
"glpat-[A-Za-z0-9_\\-]{20}"
|
|
23
|
+
"sk-[a-zA-Z0-9]{20,}"
|
|
24
|
+
"sk_live_[a-zA-Z0-9]{20,}"
|
|
25
|
+
"sk_test_[a-zA-Z0-9]{20,}"
|
|
26
|
+
"xox[baprs]-[0-9]{10,13}-[0-9]{10,13}-[a-zA-Z0-9]{24}"
|
|
27
|
+
"bearer\\s+[a-zA-Z0-9_\\-\\.]{20,}"
|
|
28
|
+
"eyJ[A-Za-z0-9_-]*\\.eyJ[A-Za-z0-9_-]*\\.[A-Za-z0-9_-]*"
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
LOW_CONFIDENCE_PATTERNS=(
|
|
32
|
+
# KEY/header NAMES without a recognizable token value — high false-positive
|
|
33
|
+
# rate (e.g. the Bifrost header alone, api_key=longvalue, password="x", secret="y"):
|
|
34
|
+
# advisory only, never blocks.
|
|
35
|
+
"[a-zA-Z0-9_-]*api[_-]?key[a-zA-Z0-9_-]*\\s*[:=]\\s*[\"']?[a-zA-Z0-9_\\-]{16,}[\"']?"
|
|
36
|
+
"[a-zA-Z0-9_-]*password[a-zA-Z0-9_-]*\\s*[:=]\\s*[\"'][^\"']{8,}[\"']"
|
|
37
|
+
"[a-zA-Z0-9_-]*secret[a-zA-Z0-9_-]*\\s*[:=]\\s*[\"'][^\"']{8,}[\"']"
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
# Keep provider-specific markers assembled so this scanner is not self-matched
|
|
41
|
+
# by the repository secret test.
|
|
42
|
+
BIFROST_HEADER="x""-bf-""vk"
|
|
43
|
+
BIFROST_TOKEN_PREFIX="sk""-bf-"
|
|
44
|
+
HIGH_CONFIDENCE_PATTERNS+=("${BIFROST_TOKEN_PREFIX}[A-Za-z0-9_-]{8,}")
|
|
45
|
+
LOW_CONFIDENCE_PATTERNS+=("$BIFROST_HEADER")
|
|
46
|
+
|
|
47
|
+
# Mask a matched secret for safe logging: first 4 + last 4 chars (len > 8),
|
|
48
|
+
# else fully hidden — short matches must not be reconstructable by
|
|
49
|
+
# concatenating the visible slices.
|
|
50
|
+
mask_secret() {
|
|
51
|
+
local match="$1"
|
|
52
|
+
if [[ ${#match} -le 8 ]]; then
|
|
53
|
+
echo "****"
|
|
54
|
+
else
|
|
55
|
+
echo "${match:0:4}****${match: -4}"
|
|
56
|
+
fi
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
INPUT="${1:-${TOOL_INPUT:-}}"
|
|
60
|
+
if [[ -z "$INPUT" ]] && [[ ! -t 0 ]]; then
|
|
61
|
+
INPUT=$(cat)
|
|
62
|
+
fi
|
|
63
|
+
|
|
64
|
+
if [[ -z "$INPUT" ]]; then
|
|
65
|
+
exit 0
|
|
66
|
+
fi
|
|
67
|
+
|
|
68
|
+
FOUND_HIGH=0
|
|
69
|
+
FOUND_LOW=0
|
|
70
|
+
|
|
71
|
+
for pattern in "${HIGH_CONFIDENCE_PATTERNS[@]}"; do
|
|
72
|
+
if echo "$INPUT" | grep -iqE "$pattern"; then
|
|
73
|
+
MATCH=$(echo "$INPUT" | grep -ioE "$pattern" | head -1)
|
|
74
|
+
echo "[SECRET SCAN] High-confidence secret detected: $(mask_secret "$MATCH") (pattern: $pattern)" >&2
|
|
75
|
+
FOUND_HIGH=1
|
|
76
|
+
fi
|
|
77
|
+
done
|
|
78
|
+
|
|
79
|
+
for pattern in "${LOW_CONFIDENCE_PATTERNS[@]}"; do
|
|
80
|
+
if echo "$INPUT" | grep -iqE "$pattern"; then
|
|
81
|
+
MATCH=$(echo "$INPUT" | grep -ioE "$pattern" | head -1)
|
|
82
|
+
echo "[SECRET SCAN] Potential secret detected (low confidence): $(mask_secret "$MATCH") (pattern: $pattern)" >&2
|
|
83
|
+
FOUND_LOW=1
|
|
84
|
+
fi
|
|
85
|
+
done
|
|
86
|
+
|
|
87
|
+
if [[ $FOUND_HIGH -eq 1 ]]; then
|
|
88
|
+
echo "[SECRET SCAN] High-confidence hardcoded secret detected. Tool call will be BLOCKED." >&2
|
|
89
|
+
exit 2
|
|
90
|
+
fi
|
|
91
|
+
|
|
92
|
+
if [[ $FOUND_LOW -eq 1 ]]; then
|
|
93
|
+
echo "[SECRET SCAN] Possible secret detected. Remove it and use environment variables or a vault." >&2
|
|
94
|
+
exit 1
|
|
95
|
+
fi
|
|
96
|
+
|
|
97
|
+
exit 0
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# validate-post-conditions.sh — Stop hook
|
|
3
|
+
# Checks that Themis review was called after implementation
|
|
4
|
+
set -euo pipefail
|
|
5
|
+
|
|
6
|
+
# Read JSON input from stdin
|
|
7
|
+
INPUT=$(cat)
|
|
8
|
+
SESSION_ID=$(echo "$INPUT" | python3 -c "import sys,json; print(json.load(sys.stdin).get('session_id',''))" 2>/dev/null || echo "")
|
|
9
|
+
|
|
10
|
+
REVIEW_DIR=".pantheon/memory-bank/.tmp"
|
|
11
|
+
if [ -d "$REVIEW_DIR" ]; then
|
|
12
|
+
REVIEW_COUNT=$(ls "$REVIEW_DIR"/REVIEW-* 2>/dev/null | wc -l)
|
|
13
|
+
if [ "$REVIEW_COUNT" -eq 0 ]; then
|
|
14
|
+
echo "[POST-CONDITION] ⚠️ No REVIEW artifact found in $REVIEW_DIR/" >&2
|
|
15
|
+
echo "[POST-CONDITION] 💡 Remember: implementation agents MUST call @themis for review" >&2
|
|
16
|
+
else
|
|
17
|
+
echo "[POST-CONDITION] ✅ $REVIEW_COUNT REVIEW artifact(s) found" >&2
|
|
18
|
+
fi
|
|
19
|
+
fi
|
|
20
|
+
|
|
21
|
+
exit 0
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# validate-talos-scope.sh — PreToolUse hook for Talos
|
|
3
|
+
# Receives JSON on stdin, enforces Talos boundaries
|
|
4
|
+
set -euo pipefail
|
|
5
|
+
|
|
6
|
+
# Read JSON input from stdin (Claude Code hook protocol)
|
|
7
|
+
INPUT=$(cat)
|
|
8
|
+
|
|
9
|
+
# Extract tool info from JSON input
|
|
10
|
+
TOOL_NAME=$(echo "$INPUT" | python3 -c "import sys,json; print(json.load(sys.stdin).get('tool_name',''))" 2>/dev/null || echo "")
|
|
11
|
+
TOOL_INPUT=$(echo "$INPUT" | python3 -c "import sys,json; d=json.load(sys.stdin); print(json.dumps(d.get('tool_input',{})))" 2>/dev/null || echo "")
|
|
12
|
+
|
|
13
|
+
# Only validate for Talos agent
|
|
14
|
+
AGENT_NAME=$(echo "$INPUT" | python3 -c "import sys,json; print(json.load(sys.stdin).get('agent_id',''))" 2>/dev/null || echo "")
|
|
15
|
+
if [ "$AGENT_NAME" != "talos" ]; then
|
|
16
|
+
# Not Talos — allow pass-through
|
|
17
|
+
echo "[TALOS SCOPE] Not Talos agent, skipping" >&2
|
|
18
|
+
exit 0
|
|
19
|
+
fi
|
|
20
|
+
|
|
21
|
+
# Get changed files from the tool input
|
|
22
|
+
if echo "$TOOL_INPUT" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('command','') or d.get('filePath','') or d.get('file_path',''))" 2>/dev/null | grep -qE '(migration|schema\.sql|alembic|models/.*\.py|auth|security|password|token|secret)'; then
|
|
23
|
+
echo "[TALOS SCOPE] ❌ Blocked: Talos cannot modify schema/security files" >&2
|
|
24
|
+
exit 2
|
|
25
|
+
fi
|
|
26
|
+
|
|
27
|
+
# Count files in the edit/write operation
|
|
28
|
+
FILE_COUNT=$(echo "$TOOL_INPUT" | python3 -c "
|
|
29
|
+
import sys, json
|
|
30
|
+
try:
|
|
31
|
+
d = json.load(sys.stdin)
|
|
32
|
+
# Count unique file paths in the input
|
|
33
|
+
files = set()
|
|
34
|
+
for key in ['filePath', 'file_path', 'command', 'content']:
|
|
35
|
+
if key in d and d[key]:
|
|
36
|
+
files.add(str(d[key]))
|
|
37
|
+
print(len(files))
|
|
38
|
+
except:
|
|
39
|
+
print('0')
|
|
40
|
+
" 2>/dev/null || echo "0")
|
|
41
|
+
|
|
42
|
+
# P0-4: enforce the Talos max-files boundary (default 3, overridable)
|
|
43
|
+
TALOS_MAX_FILES="${TALOS_MAX_FILES:-3}"
|
|
44
|
+
if [ "$FILE_COUNT" -gt "$TALOS_MAX_FILES" ]; then
|
|
45
|
+
echo "[TALOS SCOPE] ❌ Blocked: operation touches $FILE_COUNT files (max $TALOS_MAX_FILES)" >&2
|
|
46
|
+
exit 2
|
|
47
|
+
fi
|
|
48
|
+
|
|
49
|
+
echo "[TALOS SCOPE] ✅ Scope check passed" >&2
|
|
50
|
+
exit 0
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# validate-tool-safety.sh — Pantheon PreToolUse Security Hook
|
|
3
|
+
# Blocks destructive commands: rm -rf, DROP TABLE, TRUNCATE, etc.
|
|
4
|
+
set -euo pipefail
|
|
5
|
+
|
|
6
|
+
BLOCKED_PATTERNS=(
|
|
7
|
+
# "rm -rf /" appears mid-JSON in the hook stdin protocol (e.g.
|
|
8
|
+
# {"tool_input":{"command":"rm -rf /"}}), so the old `/$` end-of-line
|
|
9
|
+
# anchor never matched. Match "/" followed by a JSON delimiter (", },
|
|
10
|
+
# comma), whitespace, or end of line — catches bare "rm -rf /" anywhere
|
|
11
|
+
# in the payload while keeping "rm -rf /some/path" (safe) unblocked.
|
|
12
|
+
"rm\s+-rf\s+/([\"},\t[:space:]]|$)" # rm -rf / (root only)
|
|
13
|
+
"rm\s+-rf\s+/\*" # rm -rf /*
|
|
14
|
+
"rm\s+-rf\s+~([\",[:space:]]|$)" # rm -rf ~ (home only)
|
|
15
|
+
"rm\s+-rf\s+~/" # rm -rf ~/...
|
|
16
|
+
"rm\s+-rf\s+\.\.?/" # rm -rf ./ or rm -rf ../
|
|
17
|
+
"DROP\s+TABLE"
|
|
18
|
+
"DROP\s+DATABASE"
|
|
19
|
+
"TRUNCATE\s+TABLE"
|
|
20
|
+
"DELETE\s+FROM\s+.*;?\s*$"
|
|
21
|
+
"rm\s+.*\s+--no-preserve-root"
|
|
22
|
+
":(){ :|:& };:"
|
|
23
|
+
">\s*/dev/\(sd[a-z]\|hd[a-z]\|disk[0-9]*\)"
|
|
24
|
+
"dd\s+if=.*of=/dev/"
|
|
25
|
+
"mkfs\."
|
|
26
|
+
"fdisk\s+/dev/"
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
INPUT="${1:-${TOOL_INPUT:-}}"
|
|
30
|
+
if [[ -z "$INPUT" ]] && [[ ! -t 0 ]]; then
|
|
31
|
+
INPUT=$(cat)
|
|
32
|
+
fi
|
|
33
|
+
|
|
34
|
+
if [[ -z "$INPUT" ]]; then
|
|
35
|
+
exit 0
|
|
36
|
+
fi
|
|
37
|
+
|
|
38
|
+
for pattern in "${BLOCKED_PATTERNS[@]}"; do
|
|
39
|
+
if echo "$INPUT" | grep -iqE "$pattern"; then
|
|
40
|
+
echo "[SECURITY BLOCKED] Pattern matched: $pattern" >&2
|
|
41
|
+
echo "[SECURITY BLOCKED] Destructive command detected and blocked by Pantheon hook." >&2
|
|
42
|
+
exit 1
|
|
43
|
+
fi
|
|
44
|
+
done
|
|
45
|
+
|
|
46
|
+
exit 0
|
|
@@ -13,7 +13,7 @@ import {
|
|
|
13
13
|
writeFileSync,
|
|
14
14
|
} from 'node:fs'
|
|
15
15
|
import { homedir } from 'node:os'
|
|
16
|
-
import { join, resolve } from 'node:path'
|
|
16
|
+
import { basename, join, resolve } from 'node:path'
|
|
17
17
|
import {
|
|
18
18
|
bullet,
|
|
19
19
|
colors,
|
|
@@ -193,8 +193,9 @@ export async function installOpenCode(
|
|
|
193
193
|
const srcRouting = join(ROOT, 'src', 'routing.yml')
|
|
194
194
|
const dstRouting = join(target, 'routing.yml')
|
|
195
195
|
if (existsSync(srcRouting)) {
|
|
196
|
-
writeIfChanged(dstRouting, readFileSync(srcRouting, 'utf8'), dryRun)
|
|
197
|
-
stats.created++
|
|
196
|
+
const routingStatus = writeIfChanged(dstRouting, readFileSync(srcRouting, 'utf8'), dryRun)
|
|
197
|
+
if (routingStatus === 'created') stats.created++
|
|
198
|
+
else stats.skipped++
|
|
198
199
|
}
|
|
199
200
|
}
|
|
200
201
|
|
|
@@ -280,27 +281,12 @@ export async function installOpenCode(
|
|
|
280
281
|
|
|
281
282
|
// -----------------------------------------------------------------------
|
|
282
283
|
// -----------------------------------------------------------------------
|
|
283
|
-
// 2.7
|
|
284
|
+
// 2.7 subagent_depth is merged into opencode.json in the config section
|
|
285
|
+
// below (always runs) — see "Ensure critical top-level config sections".
|
|
286
|
+
// Previously this was a separate block gated on the config file already
|
|
287
|
+
// existing, so a FRESH install only gained subagent_depth on the SECOND
|
|
288
|
+
// init run (idempotence bug, issue #19).
|
|
284
289
|
// -----------------------------------------------------------------------
|
|
285
|
-
if (componentSet.has('agents')) {
|
|
286
|
-
const cfgPath = join(target, 'opencode.json')
|
|
287
|
-
if (existsSync(cfgPath)) {
|
|
288
|
-
try {
|
|
289
|
-
const cfg = JSON.parse(readFileSync(cfgPath, 'utf8'))
|
|
290
|
-
let changed = false
|
|
291
|
-
if (cfg.subagent_depth === undefined) {
|
|
292
|
-
cfg.subagent_depth = 2
|
|
293
|
-
changed = true
|
|
294
|
-
}
|
|
295
|
-
if (changed && !dryRun) {
|
|
296
|
-
writeFileSync(cfgPath, `${JSON.stringify(cfg, null, 2)}\n`)
|
|
297
|
-
success('subagent_depth: 2 added to opencode.json')
|
|
298
|
-
}
|
|
299
|
-
} catch (e) {
|
|
300
|
-
warning(`Could not update opencode.json: ${e.message}`)
|
|
301
|
-
}
|
|
302
|
-
}
|
|
303
|
-
}
|
|
304
290
|
|
|
305
291
|
// 2.8 Install TUI plugins (--components plugins)
|
|
306
292
|
// -----------------------------------------------------------------------
|
|
@@ -322,9 +308,16 @@ export async function installOpenCode(
|
|
|
322
308
|
const targetTuiConfigPath = isGlobal
|
|
323
309
|
? join(target, 'tui.json')
|
|
324
310
|
: join(target, '.opencode', 'tui.json')
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
311
|
+
// Hermetic TUI plugin registration: opencode's tui loader treats relative
|
|
312
|
+
// entries ("plugins/pantheon-tui") as npm/github package specs and fails
|
|
313
|
+
// with NpmInstallFailedError ("Repository not found"). Register the plugin
|
|
314
|
+
// by the ABSOLUTE path of the built dist inside the installed package
|
|
315
|
+
// (derived from ROOT — never hardcoded), mirroring the loader's
|
|
316
|
+
// absolute-path support (same pattern as the hooks plugin fix).
|
|
317
|
+
const tuiPluginRef = join(ROOT, 'src', 'plugins', 'tui', 'dist', 'tui.tsx')
|
|
318
|
+
// Clean up the old broken relative ref (and stale dist refs) on upgrade.
|
|
319
|
+
unregisterPlugin(targetTuiConfigPath, 'plugins/pantheon-tui', { dryRun })
|
|
320
|
+
const tuiStatus = registerPlugin(targetTuiConfigPath, tuiPluginRef, { dryRun })
|
|
328
321
|
if (tuiStatus === 'created') stats.created++
|
|
329
322
|
else stats.skipped++
|
|
330
323
|
|
|
@@ -347,7 +340,17 @@ export async function installOpenCode(
|
|
|
347
340
|
'pantheon_vision_server.py',
|
|
348
341
|
]
|
|
349
342
|
const srcScriptsDir = join(ROOT, 'scripts')
|
|
343
|
+
// Canonical MCP server sources live in src/mcp/. Map them explicitly so the
|
|
344
|
+
// sync always copies from the canonical source, never from the stale root
|
|
345
|
+
// scripts/ copies (which previously propagated bugs like the missing
|
|
346
|
+
// `import uuid` in mcp_persistence_server.py). scrub-secrets.py is not in
|
|
347
|
+
// src/mcp/ and intentionally falls back to join(ROOT, 'scripts').
|
|
350
348
|
const canonicalMcpScripts = {
|
|
349
|
+
'mcp_resources_server.py': join(ROOT, 'src', 'mcp', 'mcp_resources_server.py'),
|
|
350
|
+
'code_mode_server.py': join(ROOT, 'src', 'mcp', 'code_mode_server.py'),
|
|
351
|
+
'memory_mcp_server.py': join(ROOT, 'src', 'mcp', 'memory_mcp_server.py'),
|
|
352
|
+
'_pantheon_paths.py': join(ROOT, 'src', 'mcp', '_pantheon_paths.py'),
|
|
353
|
+
'mcp_persistence_server.py': join(ROOT, 'src', 'mcp', 'mcp_persistence_server.py'),
|
|
351
354
|
'pantheon_vision_server.py': join(ROOT, 'src', 'mcp', 'pantheon_vision_server.py'),
|
|
352
355
|
}
|
|
353
356
|
const dstScriptsDir = join(runtimeTarget, 'scripts')
|
|
@@ -565,15 +568,41 @@ export async function installOpenCode(
|
|
|
565
568
|
if (!config.default_agent && pantheonConfig.default_agent) {
|
|
566
569
|
config.default_agent = pantheonConfig.default_agent
|
|
567
570
|
}
|
|
571
|
+
// Merge subagent_depth HERE (the config merge always runs, including on a
|
|
572
|
+
// fresh install) instead of a file-existence-gated block, so run #1 already
|
|
573
|
+
// produces the final config — run #2 must be a byte-identical no-op (#19).
|
|
574
|
+
if (config.subagent_depth === undefined) {
|
|
575
|
+
config.subagent_depth = 2
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
// --------------------------------------------------------------------
|
|
579
|
+
// B.5.1 Hermetic plugin resolution (packaging fix)
|
|
580
|
+
// --------------------------------------------------------------------
|
|
581
|
+
// The packaged opencode.json may reference the pantheon-hooks plugin via a
|
|
582
|
+
// developer-machine absolute path (e.g. /home/<dev>/pantheon/src/plugins/...).
|
|
583
|
+
// Copying that verbatim into the user's config would break every global
|
|
584
|
+
// install on any other machine. Option A: rewrite the entry to the plugin
|
|
585
|
+
// file inside the INSTALLED package (derived from ROOT — never hardcoded),
|
|
586
|
+
// so both dev and global installs resolve to a path that actually exists.
|
|
587
|
+
// The source opencode.json keeps its dev path for local development; the
|
|
588
|
+
// transform happens at install/sync time only.
|
|
589
|
+
function resolveInstalledPlugin(plugin) {
|
|
590
|
+
const file = basename(plugin)
|
|
591
|
+
const packaged = join(ROOT, 'src', 'plugins', file)
|
|
592
|
+
return existsSync(packaged) ? packaged : plugin
|
|
593
|
+
}
|
|
568
594
|
|
|
569
595
|
if (!Array.isArray(config.plugin)) {
|
|
570
596
|
config.plugin = []
|
|
571
597
|
}
|
|
572
598
|
if (Array.isArray(pantheonConfig.plugin)) {
|
|
573
599
|
for (const plugin of pantheonConfig.plugin) {
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
600
|
+
const resolved = resolveInstalledPlugin(plugin)
|
|
601
|
+
const file = basename(resolved)
|
|
602
|
+
// Replace any pre-existing entry for the same plugin file (e.g. a stale
|
|
603
|
+
// dev-machine path from an earlier install) so upgrades stay hermetic.
|
|
604
|
+
config.plugin = config.plugin.filter((p) => basename(p) !== file)
|
|
605
|
+
config.plugin.push(resolved)
|
|
577
606
|
}
|
|
578
607
|
}
|
|
579
608
|
|
|
@@ -128,8 +128,10 @@ export function unregisterPlugin(tuiJsonPath, pluginId, { dryRun = false } = {})
|
|
|
128
128
|
if (!Array.isArray(tuiConfig.plugin)) {
|
|
129
129
|
tuiConfig.plugin = []
|
|
130
130
|
}
|
|
131
|
-
// Remove stale
|
|
132
|
-
|
|
131
|
+
// Remove stale refs: the bare relative id (openCode < 1.19 wrote
|
|
132
|
+
// "plugins/pantheon-tui", which its tui loader misreads as an npm/github
|
|
133
|
+
// spec → NpmInstallFailedError) and the old dist paths.
|
|
134
|
+
const staleRefs = [pluginId, `${pluginId}/dist/tui.tsx`, `${pluginId}/dist/tui.js`]
|
|
133
135
|
for (const stale of staleRefs) {
|
|
134
136
|
const idx = tuiConfig.plugin.indexOf(stale)
|
|
135
137
|
if (idx !== -1) tuiConfig.plugin.splice(idx, 1)
|
package/scripts/install-mcp.mjs
CHANGED
|
@@ -23,13 +23,61 @@
|
|
|
23
23
|
import { execSync } from 'node:child_process'
|
|
24
24
|
import { copyFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
|
|
25
25
|
import { homedir } from 'node:os'
|
|
26
|
-
import { dirname, join, resolve } from 'node:path'
|
|
26
|
+
import { basename, dirname, join, resolve } from 'node:path'
|
|
27
27
|
import { createInterface } from 'node:readline'
|
|
28
28
|
import { fileURLToPath } from 'node:url'
|
|
29
29
|
|
|
30
30
|
const __dirname = dirname(fileURLToPath(import.meta.url))
|
|
31
31
|
const ROOT = join(__dirname, '..')
|
|
32
32
|
|
|
33
|
+
// ---------------------------------------------------------------------------
|
|
34
|
+
// Hermetic path resolution (P2 — ENOENT-on-spawn elimination)
|
|
35
|
+
//
|
|
36
|
+
// Pantheon MCP commands MUST be generated with ABSOLUTE paths for both the
|
|
37
|
+
// venv python3 and the server script. ROOT alone is ambiguous: when this
|
|
38
|
+
// installer runs from a global npm install, `join(ROOT, '.venv/bin/python3')`
|
|
39
|
+
// points at a venv that does not exist there, and a stale config with a
|
|
40
|
+
// hardcoded path to a migrated home (e.g. /home/admin) fails with
|
|
41
|
+
// `posix_spawn ENOENT`. Resolution order:
|
|
42
|
+
// 1. Canonical user install: ~/.config/opencode/.venv + ~/.config/opencode/scripts/
|
|
43
|
+
// 2. Local checkout: <ROOT>/.venv + <ROOT>/<relPath>
|
|
44
|
+
// 3. Fallback: PATH python3 / ROOT path, with a warning
|
|
45
|
+
// ---------------------------------------------------------------------------
|
|
46
|
+
const CONFIG_OPENCODE = join(homedir(), '.config', 'opencode')
|
|
47
|
+
|
|
48
|
+
function resolveVenvPython() {
|
|
49
|
+
const candidates = [
|
|
50
|
+
join(CONFIG_OPENCODE, '.venv', 'bin', 'python3'),
|
|
51
|
+
join(ROOT, '.venv', 'bin', 'python3'),
|
|
52
|
+
]
|
|
53
|
+
for (const p of candidates) {
|
|
54
|
+
if (existsSync(p)) return p
|
|
55
|
+
}
|
|
56
|
+
console.warn(
|
|
57
|
+
` ⚠️ Pantheon venv python3 not found (checked ${candidates.join(', ')}). Using PATH 'python3'.`,
|
|
58
|
+
)
|
|
59
|
+
return 'python3'
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function resolveServerScript(relPath) {
|
|
63
|
+
const base = basename(relPath)
|
|
64
|
+
const candidates = [join(CONFIG_OPENCODE, 'scripts', base), join(ROOT, relPath)]
|
|
65
|
+
for (const p of candidates) {
|
|
66
|
+
if (existsSync(p)) return p
|
|
67
|
+
}
|
|
68
|
+
console.warn(
|
|
69
|
+
` ⚠️ Pantheon MCP server script not found (checked ${candidates.join(', ')}). Using ${join(ROOT, relPath)}.`,
|
|
70
|
+
)
|
|
71
|
+
return join(ROOT, relPath)
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const VENV_PYTHON = resolveVenvPython()
|
|
75
|
+
const MCP_RESOURCES_SERVER = resolveServerScript('scripts/mcp_resources_server.py')
|
|
76
|
+
const CODE_MODE_SERVER = resolveServerScript('scripts/code_mode_server.py')
|
|
77
|
+
const MEMORY_MCP_SERVER = resolveServerScript('scripts/memory_mcp_server.py')
|
|
78
|
+
const MCP_PERSISTENCE_SERVER = resolveServerScript('scripts/mcp_persistence_server.py')
|
|
79
|
+
const PANTHEON_VISION_SERVER = resolveServerScript('src/mcp/pantheon_vision_server.py')
|
|
80
|
+
|
|
33
81
|
// ---------------------------------------------------------------------------
|
|
34
82
|
// MCP Definitions
|
|
35
83
|
// ---------------------------------------------------------------------------
|
|
@@ -173,37 +221,37 @@ const MCPS = {
|
|
|
173
221
|
platforms: {
|
|
174
222
|
opencode: {
|
|
175
223
|
type: 'local',
|
|
176
|
-
command:
|
|
177
|
-
args: [
|
|
224
|
+
command: VENV_PYTHON,
|
|
225
|
+
args: [MCP_RESOURCES_SERVER],
|
|
178
226
|
},
|
|
179
227
|
vscode: {
|
|
180
228
|
type: 'stdio',
|
|
181
|
-
command:
|
|
182
|
-
args: [
|
|
229
|
+
command: VENV_PYTHON,
|
|
230
|
+
args: [MCP_RESOURCES_SERVER],
|
|
183
231
|
},
|
|
184
232
|
cursor: {
|
|
185
233
|
type: 'local',
|
|
186
|
-
command:
|
|
187
|
-
args: [
|
|
234
|
+
command: VENV_PYTHON,
|
|
235
|
+
args: [MCP_RESOURCES_SERVER],
|
|
188
236
|
},
|
|
189
237
|
claude: {
|
|
190
238
|
type: 'local',
|
|
191
|
-
command:
|
|
192
|
-
args: [
|
|
239
|
+
command: VENV_PYTHON,
|
|
240
|
+
args: [MCP_RESOURCES_SERVER],
|
|
193
241
|
},
|
|
194
242
|
windsurf: {
|
|
195
243
|
type: 'local',
|
|
196
|
-
command:
|
|
197
|
-
args: [
|
|
244
|
+
command: VENV_PYTHON,
|
|
245
|
+
args: [MCP_RESOURCES_SERVER],
|
|
198
246
|
},
|
|
199
247
|
},
|
|
200
248
|
env: [],
|
|
201
249
|
validate: async () => {
|
|
202
|
-
const scriptPath =
|
|
250
|
+
const scriptPath = MCP_RESOURCES_SERVER
|
|
203
251
|
if (existsSync(scriptPath)) {
|
|
204
252
|
return { ok: true, message: 'server script present' }
|
|
205
253
|
}
|
|
206
|
-
return { ok: false, message: '
|
|
254
|
+
return { ok: false, message: 'mcp_resources_server.py not found at resolved path' }
|
|
207
255
|
},
|
|
208
256
|
},
|
|
209
257
|
'pantheon-code-mode': {
|
|
@@ -213,37 +261,37 @@ const MCPS = {
|
|
|
213
261
|
platforms: {
|
|
214
262
|
opencode: {
|
|
215
263
|
type: 'local',
|
|
216
|
-
command:
|
|
217
|
-
args: [
|
|
264
|
+
command: VENV_PYTHON,
|
|
265
|
+
args: [CODE_MODE_SERVER],
|
|
218
266
|
},
|
|
219
267
|
vscode: {
|
|
220
268
|
type: 'stdio',
|
|
221
|
-
command:
|
|
222
|
-
args: [
|
|
269
|
+
command: VENV_PYTHON,
|
|
270
|
+
args: [CODE_MODE_SERVER],
|
|
223
271
|
},
|
|
224
272
|
cursor: {
|
|
225
273
|
type: 'local',
|
|
226
|
-
command:
|
|
227
|
-
args: [
|
|
274
|
+
command: VENV_PYTHON,
|
|
275
|
+
args: [CODE_MODE_SERVER],
|
|
228
276
|
},
|
|
229
277
|
claude: {
|
|
230
278
|
type: 'local',
|
|
231
|
-
command:
|
|
232
|
-
args: [
|
|
279
|
+
command: VENV_PYTHON,
|
|
280
|
+
args: [CODE_MODE_SERVER],
|
|
233
281
|
},
|
|
234
282
|
windsurf: {
|
|
235
283
|
type: 'local',
|
|
236
|
-
command:
|
|
237
|
-
args: [
|
|
284
|
+
command: VENV_PYTHON,
|
|
285
|
+
args: [CODE_MODE_SERVER],
|
|
238
286
|
},
|
|
239
287
|
},
|
|
240
288
|
env: [],
|
|
241
289
|
validate: async () => {
|
|
242
|
-
const scriptPath =
|
|
290
|
+
const scriptPath = CODE_MODE_SERVER
|
|
243
291
|
if (existsSync(scriptPath)) {
|
|
244
292
|
return { ok: true, message: 'server script present' }
|
|
245
293
|
}
|
|
246
|
-
return { ok: false, message: '
|
|
294
|
+
return { ok: false, message: 'code_mode_server.py not found at resolved path' }
|
|
247
295
|
},
|
|
248
296
|
},
|
|
249
297
|
'pantheon-memory': {
|
|
@@ -254,32 +302,32 @@ const MCPS = {
|
|
|
254
302
|
platforms: {
|
|
255
303
|
opencode: {
|
|
256
304
|
type: 'local',
|
|
257
|
-
command:
|
|
258
|
-
args: [
|
|
305
|
+
command: VENV_PYTHON,
|
|
306
|
+
args: [MEMORY_MCP_SERVER],
|
|
259
307
|
},
|
|
260
308
|
claude: {
|
|
261
309
|
type: 'local',
|
|
262
|
-
command:
|
|
263
|
-
args: [
|
|
310
|
+
command: VENV_PYTHON,
|
|
311
|
+
args: [MEMORY_MCP_SERVER],
|
|
264
312
|
},
|
|
265
313
|
cursor: {
|
|
266
314
|
type: 'local',
|
|
267
|
-
command:
|
|
268
|
-
args: [
|
|
315
|
+
command: VENV_PYTHON,
|
|
316
|
+
args: [MEMORY_MCP_SERVER],
|
|
269
317
|
},
|
|
270
318
|
windsurf: {
|
|
271
319
|
type: 'local',
|
|
272
|
-
command:
|
|
273
|
-
args: [
|
|
320
|
+
command: VENV_PYTHON,
|
|
321
|
+
args: [MEMORY_MCP_SERVER],
|
|
274
322
|
},
|
|
275
323
|
},
|
|
276
324
|
env: [],
|
|
277
325
|
validate: async () => {
|
|
278
|
-
const scriptPath =
|
|
326
|
+
const scriptPath = MEMORY_MCP_SERVER
|
|
279
327
|
if (existsSync(scriptPath)) {
|
|
280
328
|
return { ok: true, message: 'server script present' }
|
|
281
329
|
}
|
|
282
|
-
return { ok: false, message: '
|
|
330
|
+
return { ok: false, message: 'memory_mcp_server.py not found at resolved path' }
|
|
283
331
|
},
|
|
284
332
|
},
|
|
285
333
|
'pantheon-persistence': {
|
|
@@ -290,32 +338,32 @@ const MCPS = {
|
|
|
290
338
|
platforms: {
|
|
291
339
|
opencode: {
|
|
292
340
|
type: 'local',
|
|
293
|
-
command:
|
|
294
|
-
args: [
|
|
341
|
+
command: VENV_PYTHON,
|
|
342
|
+
args: [MCP_PERSISTENCE_SERVER],
|
|
295
343
|
},
|
|
296
344
|
claude: {
|
|
297
345
|
type: 'local',
|
|
298
|
-
command:
|
|
299
|
-
args: [
|
|
346
|
+
command: VENV_PYTHON,
|
|
347
|
+
args: [MCP_PERSISTENCE_SERVER],
|
|
300
348
|
},
|
|
301
349
|
cursor: {
|
|
302
350
|
type: 'local',
|
|
303
|
-
command:
|
|
304
|
-
args: [
|
|
351
|
+
command: VENV_PYTHON,
|
|
352
|
+
args: [MCP_PERSISTENCE_SERVER],
|
|
305
353
|
},
|
|
306
354
|
windsurf: {
|
|
307
355
|
type: 'local',
|
|
308
|
-
command:
|
|
309
|
-
args: [
|
|
356
|
+
command: VENV_PYTHON,
|
|
357
|
+
args: [MCP_PERSISTENCE_SERVER],
|
|
310
358
|
},
|
|
311
359
|
},
|
|
312
360
|
env: [],
|
|
313
361
|
validate: async () => {
|
|
314
|
-
const scriptPath =
|
|
362
|
+
const scriptPath = MCP_PERSISTENCE_SERVER
|
|
315
363
|
if (existsSync(scriptPath)) {
|
|
316
364
|
return { ok: true, message: 'server script present' }
|
|
317
365
|
}
|
|
318
|
-
return { ok: false, message: '
|
|
366
|
+
return { ok: false, message: 'mcp_persistence_server.py not found at resolved path' }
|
|
319
367
|
},
|
|
320
368
|
},
|
|
321
369
|
'pantheon-vision': {
|
|
@@ -327,33 +375,33 @@ const MCPS = {
|
|
|
327
375
|
platforms: {
|
|
328
376
|
opencode: {
|
|
329
377
|
type: 'local',
|
|
330
|
-
command:
|
|
331
|
-
args: [
|
|
378
|
+
command: VENV_PYTHON,
|
|
379
|
+
args: [PANTHEON_VISION_SERVER],
|
|
332
380
|
},
|
|
333
381
|
vscode: {
|
|
334
382
|
type: 'stdio',
|
|
335
|
-
command:
|
|
336
|
-
args: [
|
|
383
|
+
command: VENV_PYTHON,
|
|
384
|
+
args: [PANTHEON_VISION_SERVER],
|
|
337
385
|
},
|
|
338
386
|
claude: {
|
|
339
387
|
type: 'local',
|
|
340
|
-
command:
|
|
341
|
-
args: [
|
|
388
|
+
command: VENV_PYTHON,
|
|
389
|
+
args: [PANTHEON_VISION_SERVER],
|
|
342
390
|
},
|
|
343
391
|
cursor: {
|
|
344
392
|
type: 'local',
|
|
345
|
-
command:
|
|
346
|
-
args: [
|
|
393
|
+
command: VENV_PYTHON,
|
|
394
|
+
args: [PANTHEON_VISION_SERVER],
|
|
347
395
|
},
|
|
348
396
|
windsurf: {
|
|
349
397
|
type: 'local',
|
|
350
|
-
command:
|
|
351
|
-
args: [
|
|
398
|
+
command: VENV_PYTHON,
|
|
399
|
+
args: [PANTHEON_VISION_SERVER],
|
|
352
400
|
},
|
|
353
401
|
},
|
|
354
402
|
env: [],
|
|
355
403
|
validate: async () => {
|
|
356
|
-
const sourcePath =
|
|
404
|
+
const sourcePath = PANTHEON_VISION_SERVER
|
|
357
405
|
const requirementsPath = join(ROOT, 'src', 'mcp', 'requirements-vision.txt')
|
|
358
406
|
if (existsSync(sourcePath) && existsSync(requirementsPath)) {
|
|
359
407
|
return { ok: true, message: 'canonical server source and isolated requirements present' }
|