pantheon-opencode 1.5.2 → 1.6.0-beta.10

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 (146) hide show
  1. package/.pantheon/code-mode/README.md +4 -2
  2. package/.pantheon/code-mode/checkpoint_session.py +138 -102
  3. package/.pantheon/code-mode/compress-inline.py +102 -57
  4. package/.pantheon/code-mode/manifest.json +2 -8
  5. package/AGENTS.global.md +182 -0
  6. package/AGENTS.md +10 -891
  7. package/CHANGELOG.md +343 -0
  8. package/README.md +577 -79
  9. package/README.pt-BR.md +345 -60
  10. package/bin/pantheon-init.mjs +26 -3
  11. package/commands/pantheon-audit.md +7 -1
  12. package/commands/pantheon-consolidate.md +1 -1
  13. package/commands/pantheon-deepwork.md +14 -3
  14. package/commands/pantheon-focus.md +17 -0
  15. package/commands/pantheon-optimize.md +2 -2
  16. package/commands/pantheon-vision.md +61 -12
  17. package/package.json +30 -11
  18. package/plugin.json +2 -2
  19. package/pyproject.toml +11 -16
  20. package/scripts/build-agents-md.mjs +376 -58
  21. package/scripts/changelog-merge.mjs +161 -0
  22. package/scripts/check-plugin-v2-coverage.mjs +213 -0
  23. package/scripts/ci-validate-yaml.py +7 -5
  24. package/scripts/{code_mode_server.py → code_mode.py} +119 -27
  25. package/scripts/cost.mjs +244 -41
  26. package/scripts/doctor.mjs +236 -29
  27. package/scripts/install/agents-md.mjs +4 -4
  28. package/scripts/install/cli-ui.mjs +1 -1
  29. package/scripts/install/config-migration.mjs +319 -6
  30. package/scripts/install/health-check.mjs +26 -37
  31. package/scripts/install/interactive.mjs +7 -7
  32. package/scripts/install/migrate.mjs +1 -1
  33. package/scripts/install/opencode-version.mjs +246 -4
  34. package/scripts/install/opencode.mjs +627 -179
  35. package/scripts/install/plugin.mjs +43 -9
  36. package/scripts/install/shared.mjs +120 -12
  37. package/scripts/install/vision-prompt.mjs +161 -0
  38. package/scripts/install-mcp.mjs +28 -9
  39. package/scripts/{mcp_persistence_server.py → mcp_persistence.py} +392 -78
  40. package/{src/mcp/mcp_resources_server.py → scripts/mcp_resources.py} +2 -2
  41. package/scripts/{memory_mcp_server.py → memory_mcp.py} +372 -198
  42. package/scripts/postinstall.mjs +0 -7
  43. package/scripts/probe-context-rehydrate.mjs +36 -13
  44. package/scripts/probe-pantheon-cost.mjs +71 -15
  45. package/scripts/prune.mjs +2 -2
  46. package/scripts/run-node-tests.mjs +189 -0
  47. package/scripts/sync-artifacts.mjs +26 -13
  48. package/scripts/sync-tui.mjs +24 -3
  49. package/scripts/test-opencode-v2-sandbox.sh +1913 -0
  50. package/scripts/validate-routing.mjs +43 -3
  51. package/scripts/validate-sandbox-install.mjs +138 -0
  52. package/scripts/versioning.mjs +13 -1
  53. package/scripts/vision-status.mjs +682 -0
  54. package/skills-lock.json +15 -16
  55. package/src/agents/aphrodite.md +20 -37
  56. package/src/agents/apollo.md +7 -3
  57. package/src/agents/athena.md +63 -35
  58. package/src/agents/demeter.md +8 -28
  59. package/src/agents/gaia.md +1 -1
  60. package/src/agents/hephaestus.md +10 -21
  61. package/src/agents/hermes.md +15 -38
  62. package/src/agents/iris.md +1 -1
  63. package/src/agents/mnemosyne.md +23 -60
  64. package/src/agents/nyx.md +1 -1
  65. package/src/agents/prometheus.md +16 -20
  66. package/src/agents/talos.md +10 -2
  67. package/src/agents/themis.md +5 -5
  68. package/src/agents/zeus.md +99 -130
  69. package/src/instructions/agent-return-format.instructions.md +18 -3
  70. package/src/instructions/backend-standards.instructions.md +2 -0
  71. package/src/instructions/frontend-standards.instructions.md +3 -1
  72. package/src/instructions/memory-protocol.instructions.md +11 -110
  73. package/src/instructions/zeus-anti-stall.instructions.md +14 -61
  74. package/src/instructions/zeus-communication-rules.instructions.md +0 -1
  75. package/src/instructions/zeus-council-synthesis.instructions.md +32 -260
  76. package/src/instructions/zeus-memory-operations.instructions.md +26 -0
  77. package/src/instructions/zeus-timeout-retry.instructions.md +40 -87
  78. package/src/mcp/{code_mode_server.py → code_mode.py} +119 -27
  79. package/src/mcp/eval_store.py +2 -2
  80. package/src/mcp/mcp_codemap_module.py +559 -129
  81. package/src/mcp/{mcp_persistence_server.py → mcp_persistence.py} +392 -78
  82. package/{scripts/mcp_resources_server.py → src/mcp/mcp_resources.py} +2 -2
  83. package/src/mcp/{memory_mcp_server.py → memory_mcp.py} +373 -250
  84. package/src/mcp/{pantheon_vision_server.py → pantheon_vision.py} +396 -40
  85. package/src/mcp/requirements-mcp.txt +10 -6
  86. package/src/mcp/requirements-vision.txt +4 -2
  87. package/src/pantheon/cost-command.ts +399 -101
  88. package/src/pantheon/delegation-enforce.ts +10 -1
  89. package/src/pantheon/model-command.ts +18 -7
  90. package/src/pantheon/presets.d.mts +0 -8
  91. package/src/pantheon/presets.mjs +5 -32
  92. package/src/pantheon/todo-enforcer.ts +1 -1
  93. package/src/pantheon/v2-bounded-read.ts +640 -0
  94. package/src/pantheon/v2-delegation-enforce.ts +214 -0
  95. package/src/pantheon/v2-execute-after.ts +186 -0
  96. package/src/pantheon/v2-tools.ts +429 -0
  97. package/src/pantheon/v2-unsupported.d.mts +11 -0
  98. package/src/pantheon/v2-unsupported.mjs +90 -0
  99. package/src/pantheon/v2-vision.ts +622 -0
  100. package/src/pantheon/vision-local.ts +531 -0
  101. package/src/pantheon/vision.ts +1161 -116
  102. package/src/plugin-v2.ts +1244 -288
  103. package/src/plugin.ts +12 -9
  104. package/src/plugins/tui/dist/server.d.ts +1 -2
  105. package/src/plugins/tui/dist/server.d.ts.map +1 -1
  106. package/src/plugins/tui/dist/tui-v2.d.ts +154 -0
  107. package/src/plugins/tui/dist/tui-v2.d.ts.map +1 -0
  108. package/src/plugins/tui/dist/tui-v2.js +659 -0
  109. package/src/plugins/tui/dist/tui-v2.js.map +1 -0
  110. package/src/plugins/tui/dist/tui.d.ts +85 -82
  111. package/src/plugins/tui/dist/tui.d.ts.map +1 -1
  112. package/src/plugins/tui/dist/tui.js +72 -59
  113. package/src/plugins/tui/index-v2.ts +6 -0
  114. package/src/plugins/tui/package-lock.json +5504 -1341
  115. package/src/plugins/tui/package.json +25 -15
  116. package/src/plugins/tui/src/index-v2.tsx +734 -0
  117. package/src/plugins/tui/src/index.tsx +106 -85
  118. package/src/plugins/tui/src/opencode-v2.d.ts +13 -0
  119. package/src/plugins/tui/tsconfig.build.json +5 -0
  120. package/src/plugins/tui/tsconfig.typecheck.json +5 -0
  121. package/src/plugins/tui/tsdown.config.ts +22 -2
  122. package/src/plugins/tui/tui-v2.ts +2 -0
  123. package/src/plugins/tui/tui.ts +3 -0
  124. package/src/routing.yml +11 -22
  125. package/src/skills/README.md +34 -39
  126. package/src/skills/agent-coordination/SKILL.md +7 -2
  127. package/src/skills/artifact-management/SKILL.md +12 -5
  128. package/src/skills/auto-continue/SKILL.md +54 -95
  129. package/src/skills/code-review-checklist/SKILL.md +5 -5
  130. package/src/skills/context-compression/SKILL.md +26 -879
  131. package/src/skills/session-goal/SKILL.md +14 -5
  132. package/src/skills/spec-driven-development/SKILL.md +3 -1
  133. package/src/skills/tdd-with-agents/SKILL.md +18 -10
  134. package/.pantheon/code-mode/eval-llm-judge.py +0 -255
  135. package/.pantheon/code-mode/eval-monte-carlo.py +0 -368
  136. package/.pantheon/code-mode/eval-run.py +0 -197
  137. package/.pantheon/code-mode/eval-static.py +0 -321
  138. package/.pantheon/code-mode/session-end-save.py +0 -186
  139. package/.pantheon/code-mode/session-end-save.sh +0 -18
  140. package/opencode.json +0 -58
  141. package/scripts/recover-zenodo-deposition.mjs +0 -146
  142. package/scripts/test-opencode-v1-v2-sandbox.sh +0 -862
  143. package/scripts/validate-zenodo-release.mjs +0 -154
  144. package/scripts/zenodo-artifact.mjs +0 -94
  145. package/scripts/zenodo-release-state.mjs +0 -53
  146. package/src/skills/orchestration-workflow/SKILL.md +0 -311
@@ -0,0 +1,682 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * vision-status.mjs — report the effective vision mode and the resolved
4
+ * vision target WITH its origin, for the `/pantheon-vision` status step.
5
+ *
6
+ * Resolution is deterministic and offline, mirroring the plugin's model
7
+ * precedence: env `PANTHEON_VISION_MODEL` > config `visionModel` (project
8
+ * `.opencode/opencode-vision.json`, then global) > active preset `vision.model`
9
+ * > discovery > built-in default.
10
+ *
11
+ * Discovery needs the live host catalog (`GET /config/providers`), which only
12
+ * exists inside the plugin runtime. When no explicit model is configured this
13
+ * prints `auto (resolved at session start)` rather than inventing a value; the
14
+ * built-in default is shown separately as the fallback.
15
+ *
16
+ * Never prints tokens — only whether a credential source is present.
17
+ *
18
+ * Two extra modes support the interactive picker:
19
+ * --list enumerate image-capable candidates from the best
20
+ * available source (live host catalog when supplied,
21
+ * else an offline `opencode models` name heuristic),
22
+ * always printing WHICH source was used.
23
+ * --set <provider/model> merge `visionModel` into the project (default) or
24
+ * global config, reusing the installer's atomic merge.
25
+ *
26
+ * Usage:
27
+ * node scripts/vision-status.mjs [--dir <project> | --dir=<project>] [--json]
28
+ * node scripts/vision-status.mjs --list [--catalog <file>] [--json]
29
+ * node scripts/vision-status.mjs --set <provider/model> [--global|--project]
30
+ */
31
+ import { spawnSync } from 'node:child_process'
32
+ import { existsSync, readFileSync } from 'node:fs'
33
+ import { createRequire } from 'node:module'
34
+ import { homedir } from 'node:os'
35
+ import { join } from 'node:path'
36
+ import { hasVision, resolveActivePreset } from '../src/pantheon/presets.mjs'
37
+ import { mergeVisionModel } from './install/vision-prompt.mjs'
38
+
39
+ const require = createRequire(import.meta.url)
40
+
41
+ const CONFIG_FILENAME = 'opencode-vision.json'
42
+ /** Built-in fallback when nothing explicit is configured and discovery finds none. */
43
+ export const DEFAULT_VISION_MODEL = 'opencode-go/mimo-v2.5'
44
+ const VISION_MODES = new Set(['native', 'tool', 'auto'])
45
+ const LEGACY_ZEN_IDS = ['opencode-go', 'opencode']
46
+ const silentLogger = { log() {}, warn() {}, error() {} }
47
+
48
+ /** Global vision config path, honoring `XDG_CONFIG_HOME` (same rule as the plugin). */
49
+ export function globalVisionConfigPath(env = process.env) {
50
+ const home = env.HOME?.trim() || homedir()
51
+ const xdg = env.XDG_CONFIG_HOME?.trim() || join(home, '.config')
52
+ return join(xdg, 'opencode', CONFIG_FILENAME)
53
+ }
54
+
55
+ /** Active-preset candidate order — same list the plugin passes to the resolver. */
56
+ export function activePresetCandidates(dir = process.cwd(), env = process.env) {
57
+ const home = env.HOME?.trim() || ''
58
+ const xdg = env.XDG_CONFIG_HOME?.trim() || `${home}/.config`
59
+ return [
60
+ join(dir, '.pantheon', 'active-preset.json'),
61
+ join(xdg, 'opencode', '.pantheon', 'active-preset.json'),
62
+ join(home, '.opencode', '.pantheon', 'active-preset.json'),
63
+ ]
64
+ }
65
+
66
+ function readVisionField(configPath, field) {
67
+ let raw
68
+ try {
69
+ raw = readFileSync(configPath, 'utf8')
70
+ } catch {
71
+ return null // missing/unreadable
72
+ }
73
+ try {
74
+ const parsed = JSON.parse(raw)
75
+ const value = parsed?.[field]
76
+ return typeof value === 'string' && value.trim() !== '' ? value.trim() : null
77
+ } catch {
78
+ return null // corrupted JSON → treated as absent
79
+ }
80
+ }
81
+
82
+ /** True when a V1 auth.json entry carries a non-empty credential. */
83
+ function hasAuthCredential(entry) {
84
+ if (!entry || typeof entry !== 'object') return false
85
+ for (const key of ['access', 'key', 'apiKey']) {
86
+ if (typeof entry[key] === 'string' && entry[key].trim() !== '') return true
87
+ }
88
+ return false
89
+ }
90
+
91
+ /**
92
+ * OpenCode data directory, honoring `XDG_DATA_HOME` (same rule as the plugin,
93
+ * the native vision path, and the MCP): `$XDG_DATA_HOME/opencode` when set,
94
+ * else `~/.local/share/opencode`. Keeping the V1 `auth.json` and the V2
95
+ * credential db on this base means a host with a relocated data root resolves
96
+ * the same stores as every other vision consumer.
97
+ */
98
+ function opencodeDataDir(env, home) {
99
+ const dataHome = env.XDG_DATA_HOME?.trim() || join(home, '.local', 'share')
100
+ return join(dataHome, 'opencode')
101
+ }
102
+
103
+ /**
104
+ * Provider ids with an ACTIVE credential in the V2 SQLite store (the
105
+ * `credential` table). The database is the file named by `OPENCODE_DB` when
106
+ * set, else the default `<data dir>/opencode.db`. Best-effort and read-only: a
107
+ * missing DB/table, a locked file, or an unavailable `node:sqlite` yields
108
+ * `[]`. Never returns secret material — only the integration ids.
109
+ */
110
+ function credentialDbProviderIds(home, env = process.env) {
111
+ const override = (env.OPENCODE_DB ?? '').trim()
112
+ const dbPath = override || join(opencodeDataDir(env, home), 'opencode.db')
113
+ if (!existsSync(dbPath)) return []
114
+ let sqlite
115
+ try {
116
+ sqlite = require('node:sqlite')
117
+ } catch {
118
+ return []
119
+ }
120
+ const DatabaseSync = sqlite?.DatabaseSync
121
+ if (typeof DatabaseSync !== 'function') return []
122
+ let db
123
+ try {
124
+ db = new DatabaseSync(`file:${dbPath}?mode=ro`, { readOnly: true })
125
+ const rows = db.prepare('SELECT integration_id FROM credential WHERE active = 1').all()
126
+ const ids = []
127
+ for (const row of rows) {
128
+ const id = row?.integration_id
129
+ if (typeof id === 'string' && id.trim() !== '' && !ids.includes(id)) ids.push(id)
130
+ }
131
+ return ids
132
+ } catch {
133
+ return []
134
+ } finally {
135
+ try {
136
+ db?.close()
137
+ } catch {
138
+ // best-effort close
139
+ }
140
+ }
141
+ }
142
+
143
+ /**
144
+ * Describe credential availability without exposing any secret.
145
+ * @returns {{
146
+ * envKey: boolean, authStorePath: string, authStoreIds: string[],
147
+ * authStoreProviderIds: string[]
148
+ * }}
149
+ */
150
+ export function describeVisionAuth(env = process.env, home = env.HOME?.trim() || homedir()) {
151
+ const envKey = Boolean(
152
+ (env.PANTHEON_OPENCODE_API_KEY ?? '').trim() || (env.OPENCODE_API_KEY ?? '').trim(),
153
+ )
154
+ const authStorePath = join(opencodeDataDir(env, home), 'auth.json')
155
+ let authStoreIds = []
156
+ const authStoreProviderIds = []
157
+ try {
158
+ const auth = JSON.parse(readFileSync(authStorePath, 'utf8'))
159
+ authStoreIds = LEGACY_ZEN_IDS.filter((id) => {
160
+ const entry = auth?.[id]
161
+ return entry && typeof entry.access === 'string' && entry.access.trim() !== ''
162
+ })
163
+ if (auth && typeof auth === 'object' && !Array.isArray(auth)) {
164
+ for (const [id, entry] of Object.entries(auth)) {
165
+ if (hasAuthCredential(entry)) authStoreProviderIds.push(id)
166
+ }
167
+ }
168
+ } catch {
169
+ authStoreIds = []
170
+ }
171
+ for (const id of credentialDbProviderIds(home, env)) {
172
+ if (!authStoreProviderIds.includes(id)) authStoreProviderIds.push(id)
173
+ }
174
+ return { envKey, authStorePath, authStoreIds, authStoreProviderIds }
175
+ }
176
+
177
+ /** Zen endpoints share the env keys and the legacy auth-store entries. */
178
+ function isZenProvider(providerID) {
179
+ return providerID === 'opencode' || providerID.startsWith('opencode-go')
180
+ }
181
+
182
+ /**
183
+ * Credential presence for a provider, mirroring the plugin's rule: Zen
184
+ * endpoints accept the shared env keys then the auth store (the provider's own
185
+ * entry, then the legacy `opencode-go` / `opencode` entries); non-Zen endpoints
186
+ * accept ONLY their own auth-store entry. Returns `'env' | 'store' | null` —
187
+ * never a token.
188
+ */
189
+ export function visionCredentialSource(providerID, auth) {
190
+ if (!providerID || !auth) return null
191
+ const ids = auth.authStoreProviderIds ?? []
192
+ if (isZenProvider(providerID)) {
193
+ if (auth.envKey) return 'env'
194
+ if (ids.includes(providerID)) return 'store'
195
+ if (ids.includes('opencode-go') || ids.includes('opencode')) return 'store'
196
+ return null
197
+ }
198
+ return ids.includes(providerID) ? 'store' : null
199
+ }
200
+
201
+ /**
202
+ * Resolve mode + target + origin offline.
203
+ * @returns {{
204
+ * mode: string, modeOrigin: 'env'|'config'|'default',
205
+ * model: string|null, origin: 'env'|'config'|'preset'|'auto', sourcePath: string|null,
206
+ * fallbackModel: string, auth: {envKey: boolean, authStorePath: string, authStoreIds: string[],
207
+ * authStoreProviderIds: string[]}
208
+ * }}
209
+ */
210
+ export function resolveVisionStatus({ dir = process.cwd(), env = process.env } = {}) {
211
+ const projectConfigPath = join(dir, '.opencode', CONFIG_FILENAME)
212
+ const globalConfigPath = globalVisionConfigPath(env)
213
+
214
+ const envModel = (env.PANTHEON_VISION_MODEL ?? '').trim()
215
+ const projectModel = readVisionField(projectConfigPath, 'visionModel')
216
+ const globalModel = readVisionField(globalConfigPath, 'visionModel')
217
+
218
+ let model = null
219
+ let origin = 'auto'
220
+ let sourcePath = null
221
+ if (envModel) {
222
+ model = envModel
223
+ origin = 'env'
224
+ } else if (projectModel) {
225
+ model = projectModel
226
+ origin = 'config'
227
+ sourcePath = projectConfigPath
228
+ } else if (globalModel) {
229
+ model = globalModel
230
+ origin = 'config'
231
+ sourcePath = globalConfigPath
232
+ } else {
233
+ const preset = resolveActivePreset({
234
+ env,
235
+ candidates: activePresetCandidates(dir, env),
236
+ logger: silentLogger,
237
+ })
238
+ const presetModel = preset?.vision?.model
239
+ if (typeof presetModel === 'string' && presetModel.trim() !== '') {
240
+ model = presetModel.trim()
241
+ origin = 'preset'
242
+ }
243
+ }
244
+
245
+ const envMode = (env.PANTHEON_VISION_MODE ?? '').trim()
246
+ const projectMode = readVisionField(projectConfigPath, 'mode')
247
+ const globalMode = readVisionField(globalConfigPath, 'mode')
248
+ let mode = 'auto'
249
+ let modeOrigin = 'default'
250
+ if (VISION_MODES.has(envMode)) {
251
+ mode = envMode
252
+ modeOrigin = 'env'
253
+ } else if (VISION_MODES.has(projectMode)) {
254
+ mode = projectMode
255
+ modeOrigin = 'config'
256
+ } else if (VISION_MODES.has(globalMode)) {
257
+ mode = globalMode
258
+ modeOrigin = 'config'
259
+ }
260
+
261
+ return {
262
+ mode,
263
+ modeOrigin,
264
+ model,
265
+ origin,
266
+ sourcePath,
267
+ fallbackModel: DEFAULT_VISION_MODEL,
268
+ auth: describeVisionAuth(env),
269
+ }
270
+ }
271
+
272
+ function formatStatus(status) {
273
+ const lines = ['Pantheon Vision — status', '']
274
+ lines.push(`Mode: ${status.mode} (origin: ${status.modeOrigin})`)
275
+ if (status.model) {
276
+ lines.push(`Target: ${status.model} (origin: ${status.origin})`)
277
+ if (status.sourcePath) lines.push(` from ${status.sourcePath}`)
278
+ } else {
279
+ lines.push('Target: auto (resolved at session start — host catalog discovery)')
280
+ lines.push(` fallback default: ${status.fallbackModel}`)
281
+ }
282
+ // Extended detection: env keys PLUS every provider with a credential in the
283
+ // V1 auth.json and the V2 credential db (best-effort). A V2 host with a db
284
+ // credential must never read as "none" here — same rule as the picker's
285
+ // `Credential` column.
286
+ const credentialProviders = status.auth.authStoreProviderIds ?? []
287
+ lines.push(
288
+ `Auth: env key ${status.auth.envKey ? 'set' : 'not set'}` +
289
+ `; credential store ${
290
+ credentialProviders.length > 0 ? credentialProviders.join(', ') : 'none'
291
+ }`,
292
+ )
293
+ return lines.join('\n')
294
+ }
295
+
296
+ // ─── Candidate enumeration (`--list`) ─────────────────────────────────────
297
+
298
+ /** Name-based vision families — a GUESS, used only when no catalog exists. */
299
+ const VISION_NAME_PATTERNS = [
300
+ { label: 'vl', re: /(?:^|[/-])vl(?:[/.-]|$)/i },
301
+ { label: 'glm-v', re: /(?:^|\/)glm-[\w.]*v/i },
302
+ { label: 'mimo', re: /mimo/i },
303
+ { label: 'gemini', re: /gemini/i },
304
+ { label: 'gpt-4o/4.1/5', re: /gpt-(?:4o|4\.1|5)/i },
305
+ { label: 'claude', re: /claude/i },
306
+ { label: 'qwen-vl', re: /qwen.*vl/i },
307
+ ]
308
+
309
+ function asRecord(value) {
310
+ return value && typeof value === 'object' && !Array.isArray(value) ? value : null
311
+ }
312
+
313
+ function visionNameMatch(model) {
314
+ return VISION_NAME_PATTERNS.some((pattern) => pattern.re.test(model))
315
+ }
316
+
317
+ /**
318
+ * Vision support for a model from OFFLINE knowledge. Prefers the repo's
319
+ * CAPABILITY_TABLE (known models, `confidence: 'table'`); otherwise guesses
320
+ * from the model name (`confidence: 'name'`). A guess, never a guarantee.
321
+ */
322
+ export function offlineVisionFor(model) {
323
+ try {
324
+ return { vision: hasVision(model), confidence: 'table' }
325
+ } catch {
326
+ return { vision: visionNameMatch(model), confidence: 'name' }
327
+ }
328
+ }
329
+
330
+ /**
331
+ * Parse `opencode models` output into `provider/model` ids (one per line).
332
+ * Ignores blank lines and lines containing whitespace.
333
+ */
334
+ export function parseModelsOutput(text) {
335
+ if (typeof text !== 'string') return []
336
+ const seen = new Set()
337
+ for (const line of text.split('\n')) {
338
+ const model = line.trim()
339
+ if (model === '' || !model.includes('/') || /\s/.test(model)) continue
340
+ seen.add(model)
341
+ }
342
+ return [...seen]
343
+ }
344
+
345
+ /** Best-effort `opencode models`; null when the CLI is missing or fails. */
346
+ function readModelsOutput(env = process.env) {
347
+ try {
348
+ const result = spawnSync('opencode', ['models'], { encoding: 'utf8', timeout: 20_000, env })
349
+ if (result.error || result.status !== 0 || typeof result.stdout !== 'string') return null
350
+ return result.stdout
351
+ } catch {
352
+ return null
353
+ }
354
+ }
355
+
356
+ function accessOf(costInput) {
357
+ if (costInput === 0) return 'free'
358
+ if (typeof costInput === 'number' && costInput > 0) return 'paid'
359
+ return 'unknown'
360
+ }
361
+
362
+ /**
363
+ * Flatten a `GET /config/providers` payload (canonical `{providers:[{id,
364
+ * models:{...}}]}` or a top-level `{models:[...]}`) into image-capable,
365
+ * non-deprecated candidates. Mirrors the plugin's discovery predicate
366
+ * (`capabilities.input.image === true`) without importing the plugin runtime.
367
+ */
368
+ export function catalogImageCandidates(catalog) {
369
+ const root = asRecord(catalog)
370
+ if (!root) return []
371
+ const out = []
372
+ const add = (raw, providerID) => {
373
+ const model = asRecord(raw)
374
+ if (!model) return
375
+ const id = typeof model.id === 'string' && model.id.trim() !== '' ? model.id.trim() : null
376
+ const pid =
377
+ typeof model.providerID === 'string' && model.providerID.trim() !== ''
378
+ ? model.providerID.trim()
379
+ : providerID
380
+ if (!id || !pid) return
381
+ if (asRecord(asRecord(model.capabilities)?.input)?.image !== true) return
382
+ if (model.status === 'deprecated') return
383
+ const cost = asRecord(model.cost)
384
+ const costInput =
385
+ typeof cost?.input === 'number' && Number.isFinite(cost.input) ? cost.input : null
386
+ out.push({ model: `${pid}/${id}`, providerID: pid, costInput, confidence: 'catalog' })
387
+ }
388
+ if (Array.isArray(root.providers)) {
389
+ for (const provider of root.providers) {
390
+ const record = asRecord(provider)
391
+ if (!record) continue
392
+ const pid = typeof record.id === 'string' ? record.id : ''
393
+ const models = asRecord(record.models)
394
+ if (!models) continue
395
+ for (const model of Object.values(models)) add(model, pid)
396
+ }
397
+ }
398
+ if (Array.isArray(root.models)) {
399
+ for (const model of root.models) add(model, '')
400
+ }
401
+ return out
402
+ }
403
+
404
+ function emptyList(dir, reason) {
405
+ return { source: 'none', reason, candidates: [], status: resolveVisionStatus({ dir }) }
406
+ }
407
+
408
+ /**
409
+ * Enumerate image-capable candidates from the best available source:
410
+ * - `catalog` — an injected/loaded `GET /config/providers` payload
411
+ * (`capabilities.input.image === true`); authoritative.
412
+ * - `heuristic`— offline `opencode models` filtered by known vision families;
413
+ * a guess, explicitly marked as such.
414
+ * - `none` — nothing could be enumerated; `reason` explains why.
415
+ *
416
+ * @param {{dir?: string, env?: Record<string,string|undefined>, catalog?: unknown,
417
+ * modelsText?: string|null, runModels?: (env?: object) => string|null}} [opts]
418
+ */
419
+ export function listVisionCandidates({
420
+ dir = process.cwd(),
421
+ env = process.env,
422
+ catalog,
423
+ modelsText,
424
+ runModels,
425
+ } = {}) {
426
+ const status = resolveVisionStatus({ dir, env })
427
+ const auth = status.auth
428
+ const hasCatalog = catalog !== undefined && catalog !== null
429
+
430
+ let source
431
+ let reason = null
432
+ let raw
433
+ if (hasCatalog) {
434
+ source = 'catalog'
435
+ raw = catalogImageCandidates(catalog)
436
+ if (raw.length === 0) reason = 'host catalog has no image-capable, non-deprecated entries'
437
+ } else {
438
+ const text = modelsText !== undefined ? modelsText : (runModels ?? readModelsOutput)(env)
439
+ if (typeof text === 'string') {
440
+ source = 'heuristic'
441
+ raw = []
442
+ for (const model of parseModelsOutput(text)) {
443
+ const { vision, confidence } = offlineVisionFor(model)
444
+ if (vision) {
445
+ raw.push({
446
+ model,
447
+ providerID: model.slice(0, model.indexOf('/')),
448
+ costInput: null,
449
+ confidence,
450
+ })
451
+ }
452
+ }
453
+ if (raw.length === 0) reason = 'no vision-family models found in `opencode models` output'
454
+ } else {
455
+ source = 'none'
456
+ raw = []
457
+ reason = '`opencode models` unavailable — cannot enumerate the host model list'
458
+ }
459
+ }
460
+
461
+ const candidates = raw
462
+ .map((candidate) => {
463
+ const credentialSource = visionCredentialSource(candidate.providerID, auth)
464
+ return {
465
+ model: candidate.model,
466
+ providerID: candidate.providerID,
467
+ access: accessOf(candidate.costInput),
468
+ costInput: candidate.costInput,
469
+ credential: credentialSource !== null,
470
+ credentialSource,
471
+ confidence: candidate.confidence,
472
+ target: status.model === candidate.model,
473
+ }
474
+ })
475
+ .sort((left, right) => {
476
+ const free = (entry) => (entry.access === 'free' ? 0 : 1)
477
+ // Codepoint comparison (not localeCompare) keeps numbering stable across
478
+ // machines/locales for the same input.
479
+ const byName = left.model < right.model ? -1 : left.model > right.model ? 1 : 0
480
+ return free(left) - free(right) || byName
481
+ })
482
+
483
+ return { source, reason, candidates, status }
484
+ }
485
+
486
+ /** Render the `--list` output: numbered, stable, with an honest source line. */
487
+ export function formatVisionList(list) {
488
+ const lines = ['Pantheon Vision — model candidates', '']
489
+ if (list.source === 'catalog') {
490
+ lines.push('Source: host catalog (capabilities.input.image) — authoritative')
491
+ } else if (list.source === 'heuristic') {
492
+ lines.push('Source: heuristic (`opencode models` + known vision families) — NOT authoritative')
493
+ } else {
494
+ lines.push('Source: none')
495
+ }
496
+ lines.push('')
497
+
498
+ const candidates = list.candidates ?? []
499
+ if (candidates.length === 0) {
500
+ lines.push(`No vision-capable models enumerated: ${list.reason ?? 'unknown reason'}`)
501
+ } else {
502
+ const modelWidth = Math.max(5, ...candidates.map((candidate) => candidate.model.length))
503
+ lines.push(` # ${'Model'.padEnd(modelWidth)} ${'Access'.padEnd(7)} Credential`)
504
+ candidates.forEach((candidate, index) => {
505
+ const num = String(index + 1).padStart(3)
506
+ const credential = candidate.credential ? `yes (${candidate.credentialSource})` : 'no'
507
+ const marker = candidate.target ? ' <- current target' : ''
508
+ lines.push(
509
+ ` ${num} ${candidate.model.padEnd(modelWidth)} ${candidate.access.padEnd(7)} ${credential}${marker}`,
510
+ )
511
+ })
512
+ }
513
+
514
+ lines.push('')
515
+ if (list.status.model) {
516
+ lines.push(`Target: ${list.status.model} (origin: ${list.status.origin})`)
517
+ } else {
518
+ lines.push('Target: auto (resolved at session start — host catalog discovery)')
519
+ }
520
+ lines.push(
521
+ 'Choose a number or type provider/model, then run: node scripts/vision-status.mjs --set <provider/model> [--global|--project]',
522
+ )
523
+ return lines.join('\n')
524
+ }
525
+
526
+ // ─── Config write (`--set`) ────────────────────────────────────────────────
527
+
528
+ /**
529
+ * A `provider/model` id the plugin can address: non-empty, at least one slash,
530
+ * no whitespace. Multi-slash ids (e.g. `openrouter/google/gemini-2.5-flash`)
531
+ * are valid opencode model ids, so they are accepted here — the picker must
532
+ * never list an unpinnable candidate.
533
+ */
534
+ function isPinnableVisionModel(value) {
535
+ return typeof value === 'string' && /^\S+\/\S+$/.test(value.trim())
536
+ }
537
+
538
+ /**
539
+ * Merge `visionModel` into the project (default) or global vision config,
540
+ * reusing the installer's atomic `mergeVisionModel` (backup, merge, corrupt
541
+ * JSON left untouched). Never throws: a write failure is reported and the
542
+ * target file is left intact.
543
+ *
544
+ * @param {string} model `provider/model`
545
+ * @param {{dir?: string, env?: object, target?: 'project'|'global', dryRun?: boolean,
546
+ * beforeRecheck?: (existing: string|null) => void, configPath?: string}} [opts]
547
+ * @returns {{ok: boolean, wrote?: boolean, reason?: string, path?: string,
548
+ * model?: string, error?: string, detail?: string}}
549
+ */
550
+ export function setVisionModel(
551
+ model,
552
+ {
553
+ dir = process.cwd(),
554
+ env = process.env,
555
+ target = 'project',
556
+ dryRun = false,
557
+ beforeRecheck,
558
+ configPath,
559
+ } = {},
560
+ ) {
561
+ const value = typeof model === 'string' ? model.trim() : ''
562
+ if (!isPinnableVisionModel(value)) {
563
+ return {
564
+ ok: false,
565
+ error: 'invalid-format',
566
+ detail: `"${value}" is not provider/model (no spaces, at least one slash)`,
567
+ }
568
+ }
569
+ const resolvedPath =
570
+ configPath ??
571
+ (target === 'global' ? globalVisionConfigPath(env) : join(dir, '.opencode', CONFIG_FILENAME))
572
+ let result
573
+ try {
574
+ result = mergeVisionModel(value, { env, dryRun, beforeRecheck, configPath: resolvedPath })
575
+ } catch (err) {
576
+ return {
577
+ ok: false,
578
+ error: 'write-failed',
579
+ detail: err instanceof Error ? err.message : String(err),
580
+ path: resolvedPath,
581
+ }
582
+ }
583
+ if (result.reason === 'corrupt') return { ok: false, error: 'corrupt', path: result.path }
584
+ if (result.reason === 'conflict') return { ok: false, error: 'conflict', path: result.path }
585
+ return { ok: true, wrote: result.wrote, reason: result.reason, path: result.path, model: value }
586
+ }
587
+
588
+ function formatSetError(result) {
589
+ switch (result.error) {
590
+ case 'invalid-format':
591
+ return `Vision: not saved — ${result.detail}.`
592
+ case 'corrupt':
593
+ return `Vision: not saved — config at ${result.path} is not valid JSON; left untouched. Fix it and re-run.`
594
+ case 'conflict':
595
+ return `Vision: not saved — config at ${result.path} changed concurrently; re-run to retry.`
596
+ default:
597
+ return `Vision: not saved — write failed for ${result.path ?? 'config'}: ${result.detail ?? 'unknown error'}.`
598
+ }
599
+ }
600
+
601
+ /**
602
+ * Resolve the `--dir` value from argv, accepting BOTH `--dir <path>` and
603
+ * `--dir=<path>` (the command spec uses the `=` form). A missing/empty value
604
+ * falls back to the current working directory.
605
+ */
606
+ function parseDirArg(argv) {
607
+ const equals = argv.find((arg) => arg.startsWith('--dir='))
608
+ if (equals) {
609
+ const value = equals.slice('--dir='.length)
610
+ if (value) return value
611
+ }
612
+ const dirFlag = argv.indexOf('--dir')
613
+ return dirFlag !== -1 && argv[dirFlag + 1] ? argv[dirFlag + 1] : process.cwd()
614
+ }
615
+
616
+ function parseCatalogArg(argv) {
617
+ const equals = argv.find((arg) => arg.startsWith('--catalog='))
618
+ if (equals) return equals.slice('--catalog='.length) || null
619
+ const dirFlag = argv.indexOf('--catalog')
620
+ return dirFlag !== -1 && argv[dirFlag + 1] ? argv[dirFlag + 1] : null
621
+ }
622
+
623
+ function parseSetArg(argv) {
624
+ const equals = argv.find((arg) => arg.startsWith('--set='))
625
+ if (equals) return equals.slice('--set='.length)
626
+ const dirFlag = argv.indexOf('--set')
627
+ return dirFlag !== -1 && argv[dirFlag + 1] && !argv[dirFlag + 1].startsWith('--')
628
+ ? argv[dirFlag + 1]
629
+ : ''
630
+ }
631
+
632
+ function main(argv = process.argv.slice(2)) {
633
+ const dir = parseDirArg(argv)
634
+ const json = argv.includes('--json')
635
+
636
+ const hasSet = argv.some((arg) => arg === '--set' || arg.startsWith('--set='))
637
+ if (hasSet) {
638
+ const model = parseSetArg(argv)
639
+ if (!model) {
640
+ process.stdout.write('Usage: vision-status.mjs --set <provider/model> [--global|--project]\n')
641
+ return 1
642
+ }
643
+ const target = argv.includes('--global') ? 'global' : 'project'
644
+ const result = setVisionModel(model, { dir, target })
645
+ if (json) process.stdout.write(`${JSON.stringify(result, null, 2)}\n`)
646
+ else if (result.ok) {
647
+ process.stdout.write(`Vision: pinned ${result.model} in ${result.path} (${result.reason}).\n`)
648
+ } else process.stdout.write(`${formatSetError(result)}\n`)
649
+ return result.ok ? 0 : 1
650
+ }
651
+
652
+ if (argv.includes('--list')) {
653
+ const catalogPath = parseCatalogArg(argv)
654
+ let list
655
+ if (catalogPath) {
656
+ try {
657
+ const catalog = JSON.parse(readFileSync(catalogPath, 'utf8'))
658
+ list = listVisionCandidates({ dir, catalog })
659
+ } catch (err) {
660
+ const detail = err instanceof Error ? err.message : String(err)
661
+ list = emptyList(dir, `could not read catalog ${catalogPath}: ${detail}`)
662
+ }
663
+ } else {
664
+ list = listVisionCandidates({ dir })
665
+ }
666
+ if (json) process.stdout.write(`${JSON.stringify(list, null, 2)}\n`)
667
+ else process.stdout.write(`${formatVisionList(list)}\n`)
668
+ return 0
669
+ }
670
+
671
+ const status = resolveVisionStatus({ dir })
672
+ if (json) process.stdout.write(`${JSON.stringify(status, null, 2)}\n`)
673
+ else process.stdout.write(`${formatStatus(status)}\n`)
674
+ return 0
675
+ }
676
+
677
+ // Only run when invoked directly, so tests can import the resolvers.
678
+ if (process.argv[1] && import.meta.url === `file://${process.argv[1]}`) {
679
+ process.exit(main())
680
+ }
681
+
682
+ export { formatStatus, main, parseDirArg }