pantheon-opencode 1.1.3-beta.0 → 1.2.0-beta.9.7bef03e

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 (38) hide show
  1. package/README.md +74 -1
  2. package/bin/pantheon-init.mjs +79 -0
  3. package/commands/pantheon-vision.md +67 -0
  4. package/opencode.json +12 -41
  5. package/package.json +5 -1
  6. package/scripts/doctor.mjs +4 -2
  7. package/scripts/install/health-check.mjs +101 -1
  8. package/scripts/install/model-picker.mjs +118 -0
  9. package/scripts/install/opencode.mjs +98 -17
  10. package/scripts/install-mcp.mjs +98 -13
  11. package/scripts/mcp_persistence_server.py +331 -1
  12. package/scripts/secret-scan.mjs +76 -0
  13. package/scripts/validate-routing.mjs +15 -0
  14. package/src/agents/aphrodite.md +7 -0
  15. package/src/agents/demeter.md +7 -0
  16. package/src/agents/hephaestus.md +7 -0
  17. package/src/agents/hermes.md +7 -0
  18. package/src/agents/zeus.md +31 -18
  19. package/src/instructions/agent-return-format.instructions.md +1 -0
  20. package/src/instructions/zeus-anti-stall.instructions.md +43 -5
  21. package/src/instructions/zeus-timeout-retry.instructions.md +47 -10
  22. package/src/mcp/install-mcp.mjs +6 -913
  23. package/src/mcp/mcp_persistence_server.py +332 -0
  24. package/src/mcp/pantheon_vision_server.py +495 -0
  25. package/src/mcp/requirements-vision.txt +3 -0
  26. package/src/pantheon/auto-wake.ts +91 -0
  27. package/src/pantheon/background-job-board.ts +568 -0
  28. package/src/pantheon/file-persistence.ts +95 -0
  29. package/src/pantheon/presets.d.mts +99 -0
  30. package/src/pantheon/presets.mjs +491 -0
  31. package/src/pantheon/vision.ts +1340 -0
  32. package/src/plugin.ts +40 -25
  33. package/src/plugins/tui/dist/tui.d.ts.map +1 -1
  34. package/src/plugins/tui/dist/tui.js +806 -222
  35. package/src/plugins/tui/dist/tui.tsx +866 -121
  36. package/src/plugins/tui/package.json +2 -2
  37. package/src/plugins/tui/src/index.tsx +866 -121
  38. package/src/routing.yml +179 -0
package/README.md CHANGED
@@ -172,6 +172,9 @@ npx pantheon-opencode init
172
172
  # Headless mode — for CI and automation
173
173
  npx pantheon-opencode init --headless
174
174
 
175
+ # (Optional) Install + activate a model routing preset
176
+ npx pantheon-opencode init --preset go-fast
177
+
175
178
  # (Optional) Install MCP servers + skills + TUI plugin
176
179
  npm run setup
177
180
 
@@ -182,7 +185,7 @@ opencode
182
185
 
183
186
  > **Modes:** Interactive TUI with checkbox selection (default TTY), or `--headless` for scripts/CI.
184
187
  > **Minimal:** `--headless --no-mcp` installs only agents (~2s).
185
- > **Full:** `--headless` also creates Python venv + MCP servers (memory, persistence, KV).
188
+ > **Full:** `--headless` also creates Python venv + MCP servers (memory, persistence, KV, vision).
186
189
 
187
190
  ---
188
191
 
@@ -418,6 +421,76 @@ subscription (OpenCode Go, Copilot Pro, Claude Pro, etc.).
418
421
 
419
422
  ---
420
423
 
424
+ ## Model Routing Presets
425
+
426
+ The abstract tiers above are pinned to concrete `provider/model` IDs by **6 built-in model presets**. Each preset maps all 14 agents to a specific model plus a reasoning effort per role (planners/reviewers, implementers, scouts). **Zero-mutation default**: without an active preset, agents run on OpenCode's default model — behavior is unchanged.
427
+
428
+ ### Presets
429
+
430
+ | Preset | Provider | Planners/Reviewers | Implementers | Scouts |
431
+ |--------|----------|--------------------|--------------|--------|
432
+ | `go-deepseek` | opencode (Zen) | `opencode/deepseek-v4-pro` | `opencode/deepseek-v4-flash` | `opencode/deepseek-v4-flash-free` |
433
+ | `go-fast` | opencode-go | `opencode-go/deepseek-v4-flash` — all 14 agents, effort `low` | | |
434
+ | `go-claude` | anthropic | `anthropic/claude-opus-4-8` | `anthropic/claude-sonnet-5` | `anthropic/claude-haiku-4-5` |
435
+ | `go-openai` | openai | `openai/gpt-5.6-sol` | `openai/gpt-5.6-terra` | `openai/gpt-5.6-luna-fast` |
436
+ | `go-premium` | opencode-go | GLM-5.1 (zeus), DeepSeek V4 Pro (athena), Qwen3.7 Max (themis) | MiniMax M2.7 (hermes, hephaestus), Kimi K2.6 (aphrodite), Qwen3.7 Plus (demeter), GLM-5.2 (prometheus) | DeepSeek V4 Flash (scouts) |
437
+ | `go-free` | opencode (Zen free, $0) | Big Pickle (zeus), Nemotron 3 Ultra Free (athena) | DeepSeek V4 Flash Free | North Mini Code Free (scouts) |
438
+
439
+ The exact per-agent mapping (model + reasoning effort + fallbacks) lives in the `presets:` block of [src/routing.yml](src/routing.yml).
440
+
441
+ ### Requirements
442
+
443
+ - **Env vars** — checked fail-fast (CLI and plugin) when the selected preset uses the provider:
444
+ - `PANTHEON_OPENCODE_API_KEY` — `opencode` + `opencode-go` providers (`go-deepseek`, `go-fast`, `go-premium`, `go-free`)
445
+ - `PANTHEON_ANTHROPIC_API_KEY` — `anthropic` provider (`go-claude`)
446
+ - `PANTHEON_OPENAI_API_KEY` — `openai` provider (`go-openai`)
447
+ - **OpenCode Go subscription** required for `go-fast` / `go-premium`.
448
+ - **OpenCode Zen free tier** for `go-free` — note `nemotron-3-ultra-free` has known intermittent failures ([opencode#38028](https://github.com/opencode/opencode/issues/38028)); fall back to `go-fast` if flaky.
449
+
450
+ ### Commands
451
+
452
+ ```bash
453
+ # Install + activate a preset
454
+ npx pantheon-opencode init --preset go-fast
455
+
456
+ # Switch preset (global config; --project for project-local; --dry-run to preview)
457
+ pantheon-opencode set-tier go-deepseek
458
+ pantheon-opencode set-tier go-fast --project
459
+ pantheon-opencode set-tier go-fast --dry-run
460
+
461
+ # Clear preset — back to the zero-mutation default
462
+ pantheon-opencode set-tier none
463
+
464
+ # No name → lists available presets (none, go-deepseek, go-fast, go-claude, go-openai, go-premium, go-free)
465
+ pantheon-opencode set-tier
466
+ ```
467
+
468
+ **Env override (CI/headless)** — wins over the file:
469
+
470
+ ```bash
471
+ export PANTHEON_MODEL_PRESET=go-deepseek
472
+ ```
473
+
474
+ ### How it works
475
+
476
+ - The active selection is persisted to `.pantheon/active-preset.json` — `{version, preset, source, updated_at}` — written atomically (tmp file + rename) with a `.bak` backup of the previous file. It applies on the next OpenCode startup (no hot-swap); the plugin's `hooks.config` injects it.
477
+ - **Resolution precedence**: `PANTHEON_MODEL_PRESET` env → first existing `.pantheon/active-preset.json` (project → `~/.config/opencode` → `~/.opencode`) → **none** (defaults).
478
+ - **Partial semantics**: a preset overrides only the agents it lists; unlisted agents inherit the invoking primary agent's model.
479
+ - Interactive picker during `init`: one prompt listing the 6 presets (select by number or name, blank to skip). It never touches your `opencode.json` — the choice is injected at startup instead. The file format also supports an optional `overrides` block (per-agent `model`/`variant`, per-provider `baseURL`/`apiKeyEnv`) merged over the preset definition.
480
+ - **Capability normalization**: requested reasoning effort is clamped to each model family's ceiling (`deepseek-v4-flash` max `medium`; claude models strip the variant entirely — Anthropic uses thinking).
481
+ - **fallback_models**: per-agent ordered fallback list where configured (e.g. `go-deepseek` sets `[opencode/mimo-v2.5]` for every agent).
482
+
483
+ ### Quick smoke test
484
+
485
+ ```bash
486
+ export PANTHEON_OPENCODE_API_KEY=...
487
+ pantheon-opencode set-tier go-fast --project
488
+ opencode run "hello" 2>&1 | grep "Pantheon"
489
+ # expect: [Pantheon Plugin] Model preset active: go-fast (source: file)
490
+ ```
491
+
492
+ ---
493
+
421
494
  ## Quick Start
422
495
 
423
496
  ### 1. Install Pantheon
@@ -8,6 +8,7 @@
8
8
  * installOpenCode() from scripts/install/opencode.mjs.
9
9
  */
10
10
  import fs from 'fs';
11
+ import os from 'os';
11
12
  import path from 'path';
12
13
  import { fileURLToPath } from 'url';
13
14
 
@@ -33,6 +34,9 @@ function printUsage() {
33
34
  console.log(' npx pantheon-opencode init --interactive # Force interactive TUI mode');
34
35
  console.log(' npx pantheon-opencode init --headless # Force non-interactive mode');
35
36
  console.log(' npx pantheon-opencode init -y # Skip confirmations, use defaults');
37
+ console.log(' npx pantheon-opencode init --preset <name> # Install and activate model preset');
38
+ console.log(' npx pantheon-opencode set-tier <name> # Set active model preset (global)');
39
+ console.log(' npx pantheon-opencode set-tier <name> --project # Set active model preset (project)');
36
40
  console.log(' npx pantheon-opencode --help # Show this help');
37
41
  }
38
42
 
@@ -42,6 +46,78 @@ async function main() {
42
46
 
43
47
  if (args.includes('--help')) { printUsage(); process.exit(0); }
44
48
 
49
+ if (command === 'set-tier') {
50
+ const isProject = args.includes('--project');
51
+ const isDryRun = args.includes('--dry-run');
52
+ const name = args[1];
53
+
54
+ const { loadPresetDefs } = await import('../src/pantheon/presets.mjs');
55
+ const { writeActivePreset } = await import('../scripts/install/model-picker.mjs');
56
+ const presets = loadPresetDefs();
57
+ const presetList = `none, ${Object.keys(presets).join(', ')}`;
58
+
59
+ if (!name) {
60
+ console.error('❌ set-tier requires a preset name');
61
+ console.error(` Available: ${presetList}`);
62
+ process.exit(1);
63
+ }
64
+ if (name !== 'none' && !presets[name]) {
65
+ console.error(`❌ Unknown preset "${name}"`);
66
+ console.error(` Available: ${presetList}`);
67
+ process.exit(1);
68
+ }
69
+
70
+ // Fail-fast: every provider's API key env var must be set before writing
71
+ if (name !== 'none') {
72
+ for (const [pid, provider] of Object.entries(presets[name].providers || {})) {
73
+ if (!process.env[provider.apiKeyEnv]) {
74
+ console.error(`❌ Missing required API key configuration for provider "${pid}"`);
75
+ console.error(' Set the provider API key environment variable and retry.');
76
+ process.exit(1);
77
+ }
78
+ }
79
+ }
80
+
81
+ const presetDir = isProject
82
+ ? process.cwd()
83
+ : path.join(process.env.XDG_CONFIG_HOME || path.join(os.homedir(), '.config'), 'opencode');
84
+ const filePath = path.join(presetDir, '.pantheon', 'active-preset.json');
85
+ const bakPath = path.join(presetDir, '.pantheon', 'active-preset.json.bak');
86
+
87
+ let prevName = '(none)';
88
+ if (fs.existsSync(filePath)) {
89
+ try {
90
+ prevName = JSON.parse(fs.readFileSync(filePath, 'utf8')).preset || '(unknown)';
91
+ } catch { prevName = '(unknown)'; }
92
+ }
93
+
94
+ if (name === 'none') {
95
+ if (isDryRun) {
96
+ console.log('[dry-run] Would clear model preset');
97
+ } else {
98
+ if (fs.existsSync(filePath)) {
99
+ fs.mkdirSync(path.dirname(bakPath), { recursive: true });
100
+ fs.copyFileSync(filePath, bakPath);
101
+ fs.rmSync(filePath, { force: true });
102
+ }
103
+ console.log('Model preset cleared. Using opencode defaults.');
104
+ console.log(` Backup: ${bakPath}`);
105
+ }
106
+ return;
107
+ }
108
+
109
+ const result = writeActivePreset(presetDir, name, { dryRun: isDryRun, source: 'cli' });
110
+
111
+ console.log(`Model preset set: ${prevName} → ${name}`);
112
+ for (const [agent, spec] of Object.entries(presets[name].agents || {})) {
113
+ console.log(` ${agent}: ${spec.model} [${spec.reasoning_effort}]`);
114
+ }
115
+ const providerNames = Object.keys(presets[name].providers || {});
116
+ console.log(` Providers: ${providerNames.join(', ') || 'none'}`);
117
+ if (result.backupPath) console.log(` Backup: ${result.backupPath}`);
118
+ return;
119
+ }
120
+
45
121
  if (command === 'init' || !command) {
46
122
  const isProject = args.includes('--project');
47
123
  const isDryRun = args.includes('--dry-run');
@@ -51,6 +127,8 @@ async function main() {
51
127
  const forceInteractive = args.includes('--interactive');
52
128
  const forceHeadless = args.includes('--headless');
53
129
  const autoYes = args.includes('--yes') || args.includes('-y');
130
+ const presetIndex = args.indexOf('--preset');
131
+ const presetOpt = presetIndex >= 0 ? (args[presetIndex + 1] ?? null) : null;
54
132
 
55
133
  const components = ['agents', 'skills', 'instructions', 'commands', 'plugins'];
56
134
  if (!skipMCP) components.push('runtime');
@@ -75,6 +153,7 @@ async function main() {
75
153
  interactive: forceInteractive,
76
154
  headless: forceHeadless,
77
155
  yes: autoYes,
156
+ preset: presetOpt,
78
157
  });
79
158
  } catch (err) {
80
159
  console.error(`❌ Installation failed: ${err.message}\n Run with --no-mcp to skip Python dependencies:\n npx pantheon-opencode init --no-mcp\n Or retry with --force to recreate the venv:\n npx pantheon-opencode init --force`);
@@ -0,0 +1,67 @@
1
+ ---
2
+ description: "Configure Pantheon vision interactively: auth check, mode (native|tool|auto), vision model, tool fallback. Saves to opencode-vision.json. Usage: /pantheon-vision"
3
+ agent: hermes
4
+ ---
5
+ # /pantheon-vision — Configure Vision
6
+
7
+ **What:** Walks the user through configuring Pantheon's image-vision plugin
8
+ (native multimodal model via the opencode Zen endpoint, or MCP tool fallback).
9
+ **Usage:** `/pantheon-vision [--global]`
10
+ **When:** First-time setup, changing the vision model, troubleshooting images
11
+ not being described, switching between native and tool mode.
12
+
13
+ ## Steps
14
+
15
+ 1. **Check connectivity & key source**
16
+ - Read the opencode auth store: `<data dir>/auth.json`
17
+ (default `~/.local/share/opencode/auth.json`, provider entries
18
+ `opencode-go` then `opencode`) via `readOpencodeAuthToken`.
19
+ - Report whether the user is connected (`opencode auth login`) or has an
20
+ env key (`PANTHEON_OPENCODE_API_KEY` / `OPENCODE_API_KEY`).
21
+ - **Never log or echo the token itself** — only connected/not connected.
22
+
23
+ 2. **Show current status**
24
+ - Effective mode precedence: env `PANTHEON_VISION_MODE` > config file
25
+ `mode` > default `auto`.
26
+ - Read the current config file if present (project `.opencode/opencode-vision.json`
27
+ then user `~/.config/opencode/opencode-vision.json`).
28
+
29
+ 3. **Ask what to set (interactive)**
30
+ - **Mode:** `native` | `tool` | `auto`
31
+ - `native` — describe images via the multimodal model directly (needs key)
32
+ - `tool` — always use the MCP tool fallback (`imageAnalysisTool`)
33
+ - `auto` — try native first, fall back to the tool (default)
34
+ - **Vision model:** e.g. `opencode-go/mimo-v2.5`, `opencode-go/minimax-m3`,
35
+ or an explicit override (precedence: env `PANTHEON_VISION_MODEL` > config
36
+ `visionModel` > active preset `vision.model` > default `opencode-go/mimo-v2.5`).
37
+ - **Tool fallback:** `imageAnalysisTool` (default
38
+ `pantheon_vision_vision_describe`). Bifrost is opt-in only when
39
+ explicitly selected here or through `PANTHEON_VISION_TOOL`.
40
+
41
+ 4. **Save the config**
42
+ - Default: project config `.opencode/opencode-vision.json`
43
+ (merged with the user config `~/.config/opencode/opencode-vision.json`;
44
+ project wins on conflicts).
45
+ - `--global` → write to `~/.config/opencode/opencode-vision.json`.
46
+ - Shape:
47
+ ```json
48
+ {
49
+ "mode": "auto",
50
+ "visionModel": "opencode-go/mimo-v2.5",
51
+ "imageAnalysisTool": "pantheon_vision_vision_describe"
52
+ }
53
+ ```
54
+ - Optional fields also supported: `models` (model wildcard patterns),
55
+ `promptTemplate` (with `{imageList}`/`{imageCount}`/`{toolName}`/`{userText}`).
56
+
57
+ 5. **Wrap up**
58
+ - Tell the user to restart opencode (config is read at startup / per turn)
59
+ and test by pasting an image with a question.
60
+ - If no key and no auth store entry exist, recommend `opencode auth login`
61
+ (native mode) or leaving the tool fallback enabled.
62
+
63
+ ## Rules
64
+
65
+ - Do NOT print tokens or auth.json contents.
66
+ - Do NOT overwrite existing config keys not being changed — merge.
67
+ - If the config file is corrupted JSON, warn and ask before overwriting.
package/opencode.json CHANGED
@@ -1,5 +1,15 @@
1
1
  {
2
2
  "$schema": "https://opencode.ai/config.json",
3
+ "provider": {
4
+ "opencode-go": {
5
+ "models": {
6
+ "mimo-v2.5": {
7
+ "attachment": true,
8
+ "modalities": { "input": ["text", "image"] }
9
+ }
10
+ }
11
+ }
12
+ },
3
13
  "default_agent": "zeus",
4
14
  "subagent_depth": 2,
5
15
  "model": "opencode-go/deepseek-v4-flash",
@@ -18,19 +28,13 @@
18
28
  "pip *": "allow"
19
29
  }
20
30
  },
21
- "instructions": [
22
- "AGENTS.md",
23
- "src/instructions/*.instructions.md"
24
- ],
31
+ "instructions": ["AGENTS.md", "src/instructions/*.instructions.md"],
25
32
  "plugin": [],
26
33
  "mcp": {
27
34
  "bifrost": {
28
35
  "type": "remote",
29
36
  "url": "https://llm.ofertachina.cloud/mcp",
30
- "enabled": true,
31
- "headers": {
32
- "x-bf-vk": "sk-bf-a5a9b5b0-45fc-414d-8e4d-f9beb8c49a6d"
33
- }
37
+ "enabled": true
34
38
  }
35
39
  },
36
40
  "agent": {
@@ -40,41 +44,8 @@
40
44
  "themis": {
41
45
  "model": "opencode-go/deepseek-v4-pro"
42
46
  },
43
- "zeus": {
44
- "model": "opencode-go/deepseek-v4-pro"
45
- },
46
47
  "hephaestus": {
47
48
  "model": "opencode-go/deepseek-v4-pro"
48
- },
49
- "hermes": {
50
- "model": "opencode-go/deepseek-v4-flash"
51
- },
52
- "aphrodite": {
53
- "model": "opencode-go/deepseek-v4-flash"
54
- },
55
- "demeter": {
56
- "model": "opencode-go/deepseek-v4-flash"
57
- },
58
- "prometheus": {
59
- "model": "opencode-go/deepseek-v4-flash"
60
- },
61
- "apollo": {
62
- "model": "opencode-go/deepseek-v4-flash"
63
- },
64
- "nyx": {
65
- "model": "opencode-go/deepseek-v4-flash"
66
- },
67
- "gaia": {
68
- "model": "opencode-go/deepseek-v4-flash"
69
- },
70
- "iris": {
71
- "model": "opencode-go/deepseek-v4-flash"
72
- },
73
- "mnemosyne": {
74
- "model": "opencode-go/deepseek-v4-flash"
75
- },
76
- "talos": {
77
- "model": "opencode-go/deepseek-v4-flash"
78
49
  }
79
50
  }
80
51
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pantheon-opencode",
3
- "version": "1.1.3-beta.0",
3
+ "version": "1.2.0-beta.9.7bef03e",
4
4
  "description": "Multi-agent orchestration for OpenCode — 14 specialized agents, TDD, Themis review gates.",
5
5
  "publishConfig": {
6
6
  "access": "public",
@@ -23,6 +23,8 @@
23
23
  "lint": "npx biome ci .",
24
24
  "typecheck": "npx tsc --noEmit",
25
25
  "audit": "npm audit --audit-level=high",
26
+ "secret-scan": "node scripts/secret-scan.mjs",
27
+ "test:security": "npm run secret-scan && node tests/test_install_mcp_vision.mjs",
26
28
  "test": "python3 -m pytest tests/ -q 2>&1 || echo \"No tests found\"",
27
29
  "test:ci": "python3 -m pytest tests/ -q --tb=short 2>&1 || true"
28
30
  },
@@ -50,6 +52,7 @@
50
52
  "scripts/*.mjs",
51
53
  "src/mcp/*.sh",
52
54
  "src/routing.yml",
55
+ "src/pantheon/**",
53
56
  "src/mcp/requirements*",
54
57
  "src/plugins/tui/**",
55
58
  "src/skills/**"
@@ -60,6 +63,7 @@
60
63
  "devDependencies": {
61
64
  "@commitlint/cli": "^19.8.0",
62
65
  "@commitlint/config-conventional": "^19.8.0",
66
+ "@opencode-ai/plugin": "1.18.11",
63
67
  "husky": "^9.1.7"
64
68
  },
65
69
  "repository": {
@@ -44,8 +44,6 @@ const _ALL_PLATFORMS = ['opencode']
44
44
  const PLATFORM_LABELS = {
45
45
  opencode: 'OpenCode',
46
46
  }
47
- opencode: 'OpenCode',
48
- }
49
47
 
50
48
  // ---------------------------------------------------------------------------
51
49
  // State
@@ -236,6 +234,10 @@ function checkAgentFiles(args) {
236
234
  pass(`${canonical.length} canonical agents found in agents/`)
237
235
 
238
236
  // Determine which platforms to check
237
+ if (!existsSync(PLATFORM_DIR)) {
238
+ warn('Platform component not distributed in this package — skipping platform agent check')
239
+ return
240
+ }
239
241
  const platformDirs = readdirSync(PLATFORM_DIR, { withFileTypes: true })
240
242
  .filter((d) => d.isDirectory() && d.name !== '_template' && d.name !== 'plans')
241
243
  .map((d) => d.name)
@@ -9,7 +9,7 @@
9
9
  */
10
10
 
11
11
  import { spawnSync } from 'node:child_process'
12
- import { existsSync } from 'node:fs'
12
+ import { existsSync, readFileSync } from 'node:fs'
13
13
  import { join } from 'node:path'
14
14
 
15
15
  /**
@@ -24,6 +24,7 @@ import { join } from 'node:path'
24
24
  export function healthCheck(target, { dryRun = false } = {}) {
25
25
  const results = { passed: [], failed: [], warnings: [] }
26
26
  const python = findPython(target)
27
+ const visionServer = resolveVisionServer(target)
27
28
 
28
29
  // Check 1: Critical runtime scripts exist
29
30
  const scripts = ['mcp_persistence_server.py', '_pantheon_paths.py']
@@ -38,9 +39,70 @@ export function healthCheck(target, { dryRun = false } = {}) {
38
39
  })
39
40
  }
40
41
  }
42
+ if (visionServer) {
43
+ results.passed.push({
44
+ check: visionServer.check,
45
+ detail: visionServer.detail,
46
+ })
47
+ const installedVisionServer = join(target, 'scripts', 'pantheon_vision_server.py')
48
+ const canonicalVisionServer = join(target, 'src', 'mcp', 'pantheon_vision_server.py')
49
+ if (existsSync(installedVisionServer) && existsSync(canonicalVisionServer)) {
50
+ try {
51
+ if (
52
+ readFileSync(installedVisionServer, 'utf8') !==
53
+ readFileSync(canonicalVisionServer, 'utf8')
54
+ ) {
55
+ results.failed.push({
56
+ check: 'pantheon-vision source drift',
57
+ detail: 'installed script differs from src/mcp/pantheon_vision_server.py',
58
+ })
59
+ }
60
+ } catch {
61
+ results.failed.push({
62
+ check: 'pantheon-vision source drift',
63
+ detail: 'could not compare installed script with src/mcp source',
64
+ })
65
+ }
66
+ }
67
+ } else {
68
+ results.failed.push({
69
+ check: 'src/mcp/pantheon_vision_server.py',
70
+ detail: 'NOT FOUND — canonical vision server source is unavailable',
71
+ })
72
+ }
41
73
 
42
74
  if (dryRun) return results
43
75
 
76
+ // Check 1.5: Vision keeps a small, isolated dependency manifest.
77
+ const visionRequirements = [
78
+ join(target, 'src', 'mcp', 'requirements-vision.txt'),
79
+ join(target, 'requirements-vision.txt'),
80
+ join(target, '.opencode', 'requirements-vision.txt'),
81
+ ].find((path) => existsSync(path))
82
+ if (!visionRequirements) {
83
+ results.failed.push({
84
+ check: 'requirements-vision.txt',
85
+ detail: 'NOT FOUND — vision dependency isolation is incomplete',
86
+ })
87
+ } else {
88
+ const requirements = readFileSync(visionRequirements, 'utf8').toLowerCase()
89
+ const forbidden = ['pillow', 'paddle', 'gemini', 'torch']
90
+ const unwanted = forbidden.filter((name) =>
91
+ new RegExp(`^${name}(?:[<>=!~]|$)`, 'm').test(requirements),
92
+ )
93
+ if (unwanted.length > 0) {
94
+ results.failed.push({
95
+ check: 'requirements-vision.txt',
96
+ detail: `forbidden MVP dependencies: ${unwanted.join(', ')}`,
97
+ })
98
+ } else {
99
+ results.passed.push({
100
+ check: 'requirements-vision.txt',
101
+ detail: 'isolated and free of heavyweight vision runtimes',
102
+ })
103
+ }
104
+ }
105
+
44
106
  // Check 2: Syntax check on each MCP server script
45
107
  const mcpScripts = [
46
108
  'mcp_persistence_server.py',
@@ -67,6 +129,22 @@ export function healthCheck(target, { dryRun = false } = {}) {
67
129
  }
68
130
  }
69
131
  }
132
+ if (visionServer && python) {
133
+ const result = spawnSync(python, ['-m', 'py_compile', visionServer.path], {
134
+ stdio: 'pipe',
135
+ })
136
+ if (result.status === 0) {
137
+ results.passed.push({
138
+ check: 'syntax:pantheon_vision_server.py',
139
+ detail: 'valid',
140
+ })
141
+ } else {
142
+ results.failed.push({
143
+ check: 'syntax:pantheon_vision_server.py',
144
+ detail: result.stderr.toString().trim().split('\n').slice(-1)[0],
145
+ })
146
+ }
147
+ }
70
148
 
71
149
  // Check 3: _pantheon_paths import works
72
150
  if (python) {
@@ -150,6 +228,28 @@ export function healthCheck(target, { dryRun = false } = {}) {
150
228
  return results
151
229
  }
152
230
 
231
+ /**
232
+ * Resolve the canonical source when checking the repository, or the generated
233
+ * runtime copy when checking an installed target.
234
+ * @param {string} target
235
+ * @returns {{path: string, check: string, detail: string}|null}
236
+ */
237
+ function resolveVisionServer(target) {
238
+ const candidates = [
239
+ {
240
+ path: join(target, 'src', 'mcp', 'pantheon_vision_server.py'),
241
+ check: 'src/mcp/pantheon_vision_server.py',
242
+ detail: 'canonical source exists',
243
+ },
244
+ {
245
+ path: join(target, 'scripts', 'pantheon_vision_server.py'),
246
+ check: 'scripts/pantheon_vision_server.py',
247
+ detail: 'installed copy exists (deployed from src/mcp)',
248
+ },
249
+ ]
250
+ return candidates.find(({ path }) => existsSync(path)) ?? null
251
+ }
252
+
153
253
  /**
154
254
  * Find the target's venv python, falling back to system python3.
155
255
  * @param {string} target
@@ -0,0 +1,118 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * model-picker.mjs — interactive model preset selection + persistence
4
+ *
5
+ * Used by the installer (interactive prompt) and the `pantheon-opencode`
6
+ * CLI. Writes the active preset marker to <presetDir>/.pantheon/active-preset.json
7
+ * which the plugin (src/plugin.ts) reads on startup via resolveActivePreset().
8
+ */
9
+ import { copyFileSync, existsSync, mkdirSync, renameSync, writeFileSync } from 'node:fs'
10
+ import { join } from 'node:path'
11
+ import { createInterface } from 'node:readline/promises'
12
+ import { loadPresetDefs } from '../../src/pantheon/presets.mjs'
13
+
14
+ /**
15
+ * Build the active-preset.json payload (version 1).
16
+ *
17
+ * @param {string} presetName
18
+ * @param {string} [source] 'cli' | 'interactive'
19
+ * @returns {{version: number, preset: string, source: string, updated_at: string}}
20
+ */
21
+ export function buildActivePresetFile(presetName, source = 'cli') {
22
+ return {
23
+ version: 1,
24
+ preset: presetName,
25
+ source,
26
+ updated_at: new Date().toISOString(),
27
+ }
28
+ }
29
+
30
+ /**
31
+ * Persist the active preset marker, backing up any previous file to .bak.
32
+ *
33
+ * @param {string} presetDir install target (project root or global opencode dir)
34
+ * @param {string} presetName
35
+ * @param {{dryRun?: boolean, source?: string, logger?: object}} [opts]
36
+ * @returns {{written: boolean, path: string, backupPath: string|null}}
37
+ */
38
+ export function writeActivePreset(
39
+ presetDir,
40
+ presetName,
41
+ { dryRun = false, source = 'cli', logger = console } = {},
42
+ ) {
43
+ const dir = join(presetDir, '.pantheon')
44
+ const filePath = join(dir, 'active-preset.json')
45
+ const backupPath = join(dir, 'active-preset.json.bak')
46
+
47
+ if (dryRun) {
48
+ logger.log?.(`[dry-run] Would write model preset "${presetName}" to ${filePath}`)
49
+ return { written: false, path: filePath, backupPath: null }
50
+ }
51
+
52
+ mkdirSync(dir, { recursive: true })
53
+ let backupMade = false
54
+ if (existsSync(filePath)) {
55
+ copyFileSync(filePath, backupPath)
56
+ backupMade = true
57
+ }
58
+ writeFileSync(`${filePath}.tmp-${process.pid}`, `${JSON.stringify(buildActivePresetFile(presetName, source), null, 2)}\n`)
59
+ renameSync(`${filePath}.tmp-${process.pid}`, filePath)
60
+ return { written: true, path: filePath, backupPath: backupMade ? backupPath : null }
61
+ }
62
+
63
+ /**
64
+ * Interactive model preset picker. Asks the user to choose a preset; persists
65
+ * the choice unless dryRun. `rl` may be injected for tests; otherwise a
66
+ * readline/promises interface on stdin/stdout is created and closed.
67
+ *
68
+ * @param {{presetDir?: string, presets?: object, dryRun?: boolean, logger?: object, rl?: object}} [opts]
69
+ * @returns {Promise<string|null>} chosen preset name or null (skipped/unknown)
70
+ */
71
+ export async function runModelPicker({
72
+ presetDir,
73
+ presets,
74
+ dryRun = false,
75
+ logger = console,
76
+ rl,
77
+ } = {}) {
78
+ const defs = presets ?? loadPresetDefs()
79
+ const names = Object.keys(defs)
80
+ if (names.length === 0) {
81
+ logger.warn?.('Model presets: no presets defined in routing.yml')
82
+ return null
83
+ }
84
+
85
+ const list = names.map((n, i) => ` ${i + 1}) ${n} — ${defs[n].description ?? ''}`).join('\n')
86
+ const rli = rl ?? createInterface({ input: process.stdin, output: process.stdout })
87
+ try {
88
+ const answer = await rli.question(
89
+ `\nModel presets (number or name; blank to skip):\n${list}\n> `,
90
+ )
91
+ const trimmed = (answer ?? '').trim()
92
+ if (trimmed === '' || trimmed.toLowerCase() === 'none') {
93
+ logger.log?.('Skipping model preset selection.')
94
+ return null
95
+ }
96
+ let name = null
97
+ if (names.includes(trimmed)) {
98
+ name = trimmed
99
+ } else {
100
+ const idx = Number(trimmed)
101
+ if (Number.isInteger(idx) && idx >= 1 && idx <= names.length) {
102
+ name = names[idx - 1]
103
+ }
104
+ }
105
+ if (!name) {
106
+ logger.warn?.(`Unknown preset "${trimmed}" — skipping model preset selection.`)
107
+ return null
108
+ }
109
+ if (!dryRun) {
110
+ writeActivePreset(presetDir, name, { dryRun: false, source: 'interactive', logger })
111
+ } else {
112
+ logger.log?.(`[dry-run] Would set model preset "${name}"`)
113
+ }
114
+ return name
115
+ } finally {
116
+ if (!rl) rli.close()
117
+ }
118
+ }