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.
Files changed (37) hide show
  1. package/AGENTS.md +10 -0
  2. package/README.md +53 -15
  3. package/bin/pantheon-init.mjs +14 -0
  4. package/opencode.json +17 -1
  5. package/package.json +13 -4
  6. package/scripts/changelog-extract.mjs +112 -0
  7. package/scripts/doctor.mjs +303 -13
  8. package/scripts/hooks/audit-imports.sh +43 -0
  9. package/scripts/hooks/format-multi-language.sh +96 -0
  10. package/scripts/hooks/log-session-start.sh +42 -0
  11. package/scripts/hooks/on-subagent-delegation-start.sh +201 -0
  12. package/scripts/hooks/on-subagent-delegation-stop.sh +419 -0
  13. package/scripts/hooks/run-type-check.sh +21 -0
  14. package/scripts/hooks/scan-secrets.sh +97 -0
  15. package/scripts/hooks/validate-post-conditions.sh +21 -0
  16. package/scripts/hooks/validate-talos-scope.sh +50 -0
  17. package/scripts/hooks/validate-tool-safety.sh +46 -0
  18. package/scripts/install/opencode.mjs +58 -29
  19. package/scripts/install/plugin.mjs +4 -2
  20. package/scripts/install-mcp.mjs +104 -56
  21. package/scripts/mcp_persistence_server.py +1 -0
  22. package/scripts/prune.mjs +359 -0
  23. package/scripts/version-check.mjs +146 -0
  24. package/scripts/versioning.mjs +13 -14
  25. package/src/agents/zeus.md +33 -5
  26. package/src/mcp/requirements-mcp.txt +8 -5
  27. package/src/mcp/requirements-vision.txt +3 -3
  28. package/src/plugins/hook-runner.ts +186 -0
  29. package/src/plugins/pantheon-hooks.ts +1503 -0
  30. package/src/plugins/tui/dist/tui.d.ts +8 -0
  31. package/src/plugins/tui/dist/tui.d.ts.map +1 -0
  32. package/src/plugins/tui/dist/tui.js +1198 -0
  33. package/src/plugins/tui/dist/tui.js.map +1 -0
  34. package/src/plugins/tui/dist/tui.tsx +1204 -0
  35. package/src/plugins/tui/package.json +2 -2
  36. package/src/plugins/tui/src/index.tsx +16 -1
  37. 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 Ensure subagent_depth in opencode.json
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
- const pluginRef = 'plugins/pantheon-tui'
326
- unregisterPlugin(targetTuiConfigPath, pluginRef, { dryRun })
327
- const tuiStatus = registerPlugin(targetTuiConfigPath, pluginRef, { dryRun })
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
- if (!config.plugin.includes(plugin)) {
575
- config.plugin.push(plugin)
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 plugin refs (old dist/tui.tsx path)
132
- const staleRefs = [`${pluginId}/dist/tui.tsx`, `${pluginId}/dist/tui.js`]
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)
@@ -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: join(ROOT, '.venv/bin/python3'),
177
- args: [join(ROOT, 'scripts/mcp_resources_server.py')],
224
+ command: VENV_PYTHON,
225
+ args: [MCP_RESOURCES_SERVER],
178
226
  },
179
227
  vscode: {
180
228
  type: 'stdio',
181
- command: join(ROOT, '.venv/bin/python3'),
182
- args: [join(ROOT, 'scripts/mcp_resources_server.py')],
229
+ command: VENV_PYTHON,
230
+ args: [MCP_RESOURCES_SERVER],
183
231
  },
184
232
  cursor: {
185
233
  type: 'local',
186
- command: join(ROOT, '.venv/bin/python3'),
187
- args: [join(ROOT, 'scripts/mcp_resources_server.py')],
234
+ command: VENV_PYTHON,
235
+ args: [MCP_RESOURCES_SERVER],
188
236
  },
189
237
  claude: {
190
238
  type: 'local',
191
- command: join(ROOT, '.venv/bin/python3'),
192
- args: [join(ROOT, 'scripts/mcp_resources_server.py')],
239
+ command: VENV_PYTHON,
240
+ args: [MCP_RESOURCES_SERVER],
193
241
  },
194
242
  windsurf: {
195
243
  type: 'local',
196
- command: join(ROOT, '.venv/bin/python3'),
197
- args: [join(ROOT, 'scripts/mcp_resources_server.py')],
244
+ command: VENV_PYTHON,
245
+ args: [MCP_RESOURCES_SERVER],
198
246
  },
199
247
  },
200
248
  env: [],
201
249
  validate: async () => {
202
- const scriptPath = join(ROOT, 'scripts', 'mcp_resources_server.py')
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: 'scripts/mcp_resources_server.py not found' }
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: join(ROOT, '.venv/bin/python3'),
217
- args: [join(ROOT, 'scripts/code_mode_server.py')],
264
+ command: VENV_PYTHON,
265
+ args: [CODE_MODE_SERVER],
218
266
  },
219
267
  vscode: {
220
268
  type: 'stdio',
221
- command: join(ROOT, '.venv/bin/python3'),
222
- args: [join(ROOT, 'scripts/code_mode_server.py')],
269
+ command: VENV_PYTHON,
270
+ args: [CODE_MODE_SERVER],
223
271
  },
224
272
  cursor: {
225
273
  type: 'local',
226
- command: join(ROOT, '.venv/bin/python3'),
227
- args: [join(ROOT, 'scripts/code_mode_server.py')],
274
+ command: VENV_PYTHON,
275
+ args: [CODE_MODE_SERVER],
228
276
  },
229
277
  claude: {
230
278
  type: 'local',
231
- command: join(ROOT, '.venv/bin/python3'),
232
- args: [join(ROOT, 'scripts/code_mode_server.py')],
279
+ command: VENV_PYTHON,
280
+ args: [CODE_MODE_SERVER],
233
281
  },
234
282
  windsurf: {
235
283
  type: 'local',
236
- command: join(ROOT, '.venv/bin/python3'),
237
- args: [join(ROOT, 'scripts/code_mode_server.py')],
284
+ command: VENV_PYTHON,
285
+ args: [CODE_MODE_SERVER],
238
286
  },
239
287
  },
240
288
  env: [],
241
289
  validate: async () => {
242
- const scriptPath = join(ROOT, 'scripts', 'code_mode_server.py')
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: 'scripts/code_mode_server.py not found' }
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: join(ROOT, '.venv/bin/python3'),
258
- args: [join(ROOT, 'scripts/memory_mcp_server.py')],
305
+ command: VENV_PYTHON,
306
+ args: [MEMORY_MCP_SERVER],
259
307
  },
260
308
  claude: {
261
309
  type: 'local',
262
- command: join(ROOT, '.venv/bin/python3'),
263
- args: [join(ROOT, 'scripts/memory_mcp_server.py')],
310
+ command: VENV_PYTHON,
311
+ args: [MEMORY_MCP_SERVER],
264
312
  },
265
313
  cursor: {
266
314
  type: 'local',
267
- command: join(ROOT, '.venv/bin/python3'),
268
- args: [join(ROOT, 'scripts/memory_mcp_server.py')],
315
+ command: VENV_PYTHON,
316
+ args: [MEMORY_MCP_SERVER],
269
317
  },
270
318
  windsurf: {
271
319
  type: 'local',
272
- command: join(ROOT, '.venv/bin/python3'),
273
- args: [join(ROOT, 'scripts/memory_mcp_server.py')],
320
+ command: VENV_PYTHON,
321
+ args: [MEMORY_MCP_SERVER],
274
322
  },
275
323
  },
276
324
  env: [],
277
325
  validate: async () => {
278
- const scriptPath = join(ROOT, 'scripts', 'memory_mcp_server.py')
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: 'scripts/memory_mcp_server.py not found' }
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: join(ROOT, '.venv/bin/python3'),
294
- args: [join(ROOT, 'scripts/mcp_persistence_server.py')],
341
+ command: VENV_PYTHON,
342
+ args: [MCP_PERSISTENCE_SERVER],
295
343
  },
296
344
  claude: {
297
345
  type: 'local',
298
- command: join(ROOT, '.venv/bin/python3'),
299
- args: [join(ROOT, 'scripts/mcp_persistence_server.py')],
346
+ command: VENV_PYTHON,
347
+ args: [MCP_PERSISTENCE_SERVER],
300
348
  },
301
349
  cursor: {
302
350
  type: 'local',
303
- command: join(ROOT, '.venv/bin/python3'),
304
- args: [join(ROOT, 'scripts/mcp_persistence_server.py')],
351
+ command: VENV_PYTHON,
352
+ args: [MCP_PERSISTENCE_SERVER],
305
353
  },
306
354
  windsurf: {
307
355
  type: 'local',
308
- command: join(ROOT, '.venv/bin/python3'),
309
- args: [join(ROOT, 'scripts/mcp_persistence_server.py')],
356
+ command: VENV_PYTHON,
357
+ args: [MCP_PERSISTENCE_SERVER],
310
358
  },
311
359
  },
312
360
  env: [],
313
361
  validate: async () => {
314
- const scriptPath = join(ROOT, 'scripts', 'mcp_persistence_server.py')
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: 'scripts/mcp_persistence_server.py not found' }
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: join(ROOT, '.venv/bin/python3'),
331
- args: [join(ROOT, 'src', 'mcp', 'pantheon_vision_server.py')],
378
+ command: VENV_PYTHON,
379
+ args: [PANTHEON_VISION_SERVER],
332
380
  },
333
381
  vscode: {
334
382
  type: 'stdio',
335
- command: join(ROOT, '.venv/bin/python3'),
336
- args: [join(ROOT, 'src', 'mcp', 'pantheon_vision_server.py')],
383
+ command: VENV_PYTHON,
384
+ args: [PANTHEON_VISION_SERVER],
337
385
  },
338
386
  claude: {
339
387
  type: 'local',
340
- command: join(ROOT, '.venv/bin/python3'),
341
- args: [join(ROOT, 'src', 'mcp', 'pantheon_vision_server.py')],
388
+ command: VENV_PYTHON,
389
+ args: [PANTHEON_VISION_SERVER],
342
390
  },
343
391
  cursor: {
344
392
  type: 'local',
345
- command: join(ROOT, '.venv/bin/python3'),
346
- args: [join(ROOT, 'src', 'mcp', 'pantheon_vision_server.py')],
393
+ command: VENV_PYTHON,
394
+ args: [PANTHEON_VISION_SERVER],
347
395
  },
348
396
  windsurf: {
349
397
  type: 'local',
350
- command: join(ROOT, '.venv/bin/python3'),
351
- args: [join(ROOT, 'src', 'mcp', 'pantheon_vision_server.py')],
398
+ command: VENV_PYTHON,
399
+ args: [PANTHEON_VISION_SERVER],
352
400
  },
353
401
  },
354
402
  env: [],
355
403
  validate: async () => {
356
- const sourcePath = join(ROOT, 'src', 'mcp', 'pantheon_vision_server.py')
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' }
@@ -14,6 +14,7 @@ import argparse
14
14
  import re
15
15
  import sqlite3
16
16
  import sys
17
+ import uuid
17
18
  from datetime import datetime, timedelta, timezone
18
19
  from pathlib import Path
19
20