pantheon-opencode 1.3.3 → 1.3.4

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 (47) hide show
  1. package/README.md +115 -12
  2. package/bin/pantheon-init.mjs +160 -120
  3. package/opencode.json +0 -4
  4. package/package.json +7 -4
  5. package/scripts/doctor.mjs +56 -22
  6. package/scripts/hooks/on-subagent-delegation-stop.sh +22 -5
  7. package/scripts/install/opencode.mjs +108 -26
  8. package/scripts/install/plugin.mjs +112 -28
  9. package/scripts/install-mcp.mjs +1 -1
  10. package/scripts/redaction-gate.mjs +69 -0
  11. package/scripts/release-notes.mjs +317 -0
  12. package/scripts/test-context-compression.sh +5 -2
  13. package/scripts/validate-package.mjs +56 -0
  14. package/scripts/versioning.mjs +117 -18
  15. package/src/agents/zeus.md +6 -2
  16. package/src/pantheon/background-job-board.ts +24 -38
  17. package/src/pantheon/chat-reminders.ts +79 -0
  18. package/src/pantheon/compaction-assert.ts +160 -0
  19. package/src/pantheon/delegation-compaction.ts +143 -16
  20. package/src/pantheon/delegation-finalize.ts +8 -1
  21. package/src/pantheon/delegation-notify.ts +19 -157
  22. package/src/pantheon/delegation.ts +330 -28
  23. package/src/pantheon/dispatch-guard.ts +9 -4
  24. package/src/pantheon/goal-loop.ts +7 -4
  25. package/src/pantheon/hashline/tool.ts +2 -1
  26. package/src/pantheon/logger.ts +117 -0
  27. package/src/pantheon/plugin-once.ts +76 -0
  28. package/src/pantheon/preemptive-compact.ts +76 -0
  29. package/src/pantheon/presets.d.mts +31 -0
  30. package/src/pantheon/presets.mjs +105 -3
  31. package/src/pantheon/todo-enforcer.ts +79 -27
  32. package/src/pantheon/todo-preserve.ts +295 -0
  33. package/src/plugin.ts +177 -50
  34. package/src/plugins/pantheon-hooks.ts +35 -22
  35. package/src/plugins/tui/dist/server.d.ts +17 -0
  36. package/src/plugins/tui/dist/server.d.ts.map +1 -0
  37. package/src/plugins/tui/dist/server.js +20 -0
  38. package/src/plugins/tui/dist/server.js.map +1 -0
  39. package/src/plugins/tui/dist/tui.d.ts +303 -1
  40. package/src/plugins/tui/dist/tui.d.ts.map +1 -1
  41. package/src/plugins/tui/dist/tui.js +977 -350
  42. package/src/plugins/tui/dist/tui.tsx +1427 -150
  43. package/src/plugins/tui/package.json +4 -3
  44. package/src/plugins/tui/src/index.tsx +1427 -150
  45. package/src/plugins/tui/src/server.ts +15 -0
  46. package/src/plugins/tui/tsdown.config.ts +1 -1
  47. package/src/routing.yml +3 -1
@@ -19,6 +19,13 @@
19
19
  * @module dispatch-guard
20
20
  */
21
21
 
22
+ import { createPantheonLogger } from './logger.ts'
23
+
24
+ // Silence-by-default TUI policy (pantheon-hooks L42-58): the default warn
25
+ // fallback logs to .pantheon/logs/hooks.log; console echo is opt-in via
26
+ // PANTHEON_HOOKS_LOG=1. `logger` injection stays for tests.
27
+ const log = createPantheonLogger({ module: 'pantheon-dispatch-guard' })
28
+
22
29
  /** A dispatch result — the minimal shape needed to classify emptiness. */
23
30
  export interface DispatchResultLike {
24
31
  /** Text content of the result (task_result / report markdown). */
@@ -35,7 +42,7 @@ export type DispatchClassification = 'content' | 'empty-mode1' | 'empty-mode2'
35
42
  export interface DispatchGuardOptions {
36
43
  /** Enable auto-retry on empty results. Default: true. */
37
44
  retryOnEmpty?: boolean
38
- /** Injectable logger (defaults to console.warn with a prefix). */
45
+ /** Injectable logger (defaults to the Pantheon file log, env-gated echo). */
39
46
  logger?: { warn: (message: string) => void }
40
47
  }
41
48
 
@@ -72,9 +79,7 @@ function hasTokens(result: DispatchResultLike): boolean {
72
79
 
73
80
  export function createDispatchGuard(options?: DispatchGuardOptions): DispatchGuard {
74
81
  const retryOnEmpty = options?.retryOnEmpty ?? true
75
- const warn =
76
- options?.logger?.warn ??
77
- ((message: string) => console.warn(`[pantheon-dispatch-guard] ${message}`))
82
+ const warn = options?.logger?.warn ?? ((message: string) => log.warn(message))
78
83
  // Per-instance retry budget — the "cap 1" enforcement point.
79
84
  let retriesUsed = 0
80
85
 
@@ -19,16 +19,20 @@ import { z } from 'zod'
19
19
  import type { BackgroundJobBoard } from './background-job-board.ts'
20
20
  import type { ToolContextLike } from './delegation.ts'
21
21
  import { GoalStore } from './goal-store.ts'
22
+ import { createPantheonLogger } from './logger.ts'
22
23
  import type { TodoEnforcerMessage } from './todo-enforcer.ts'
23
24
 
25
+ // Silence-by-default TUI policy (pantheon-hooks L42-58): warn → hooks.log,
26
+ // console echo opt-in via PANTHEON_HOOKS_LOG=1. `deps.logger` stays for tests.
27
+ const log = createPantheonLogger({ module: 'pantheon-goal' })
28
+
24
29
  export type { Goal, GoalStatus } from './goal-store.ts'
25
30
  export { GoalStore }
26
31
 
27
32
  // ─── Constants ─────────────────────────────────────────────────────────
28
33
 
29
34
  /** Continuation text injected into idle sessions. `{objective}` is substituted. */
30
- export const GOAL_CONTINUATION_PROMPT =
31
- "Active goal: {objective}. Continue working toward it. Do not stop until you update the goal to done. If you believe it's complete, call pantheon_goal_update with status done."
35
+ export const GOAL_CONTINUATION_PROMPT = 'Continue goal: {objective}'
32
36
 
33
37
  /** Defaults matching routing.yml `full_auto`. */
34
38
  export const GOAL_LOOP_DEFAULTS = {
@@ -141,8 +145,7 @@ export class GoalLoop {
141
145
  maxContinuations: opts.maxContinuations ?? GOAL_LOOP_DEFAULTS.maxContinuations,
142
146
  now: opts.now ?? Date.now,
143
147
  }
144
- this.warn =
145
- deps.logger?.warn ?? ((message: string) => console.warn(`[pantheon-goal] ${message}`))
148
+ this.warn = deps.logger?.warn ?? ((message: string) => log.warn(message))
146
149
  }
147
150
 
148
151
  /** True when the session has an active (non-done) goal. Used by the idle dispatcher. */
@@ -105,7 +105,8 @@ function resolveFile(
105
105
  if (isAbsolute(file)) return { ok: true, file: resolve(file) }
106
106
  const base = resolve(ctx.worktree ?? ctx.directory ?? process.cwd())
107
107
  const resolved = resolve(base, file)
108
- // Prefix check with a path.sep boundary: `/tmp/x` must not match `/tmp2/x`.
108
+ // Prefix check with a path.sep boundary: base + sep + name must not
109
+ // match a sibling prefix (e.g. `/data/x` vs `/data2/x`).
109
110
  if (resolved !== base && !resolved.startsWith(base + sep)) {
110
111
  return {
111
112
  ok: false,
@@ -0,0 +1,117 @@
1
+ /**
2
+ * Pantheon plugin logger — silence-by-default TUI policy (shared with
3
+ * pantheon-hooks, see src/plugins/pantheon-hooks.ts L42-58).
4
+ *
5
+ * WHY: `console.log`/`console.error` in an opencode plugin writes to the
6
+ * process stdout/stderr, which the TUI renders DIRECTLY into the terminal —
7
+ * the user-visible "lixo" (e.g. `[Pantheon Plugin] Board terminal: ...` in
8
+ * the TUI footer). Every Pantheon module therefore routes its logs through
9
+ * this helper:
10
+ *
11
+ * 1. ALWAYS append one ISO-stamped, module-prefixed line to the
12
+ * project-local `.pantheon/logs/hooks.log` (same file pantheon-hooks
13
+ * uses, so the plugin trail stays auditable on disk);
14
+ * 2. echo to the console ONLY when `PANTHEON_HOOKS_LOG=1` (or any truthy
15
+ * value — same env var pantheon-hooks uses; read at creation time).
16
+ *
17
+ * All channels are best-effort and fire-and-forget: a logging failure must
18
+ * never break the plugin, and an unhandled rejection could itself pollute
19
+ * the TUI, so writes are swallowed.
20
+ *
21
+ * Pure TypeScript — zero external deps beyond Node.js builtins (fs/path).
22
+ *
23
+ * @module logger
24
+ */
25
+
26
+ import { appendFile, mkdir } from 'node:fs/promises'
27
+ import { dirname, join } from 'node:path'
28
+
29
+ export interface PantheonLoggerOptions {
30
+ /** Module tag prefixed to every line (e.g. 'pantheon-plugin'). */
31
+ module: string
32
+ /**
33
+ * Env gate for the console echo — truthy means echo in addition to the
34
+ * log file. Defaults to `process.env.PANTHEON_HOOKS_LOG` (read at
35
+ * creation), matching the pantheon-hooks policy. Injectable for tests.
36
+ */
37
+ env?: string | undefined
38
+ /**
39
+ * Project directory — the log lands at `<logDir>/.pantheon/logs/hooks.log`.
40
+ * Defaults to `process.cwd()` (opencode runs plugins with cwd = project,
41
+ * consistent with the plugin's other relative `.pantheon/...` paths).
42
+ */
43
+ logDir?: string | undefined
44
+ }
45
+
46
+ export interface PantheonLogger {
47
+ info(message: string, ...args: unknown[]): void
48
+ warn(message: string, ...args: unknown[]): void
49
+ error(message: string, ...args: unknown[]): void
50
+ }
51
+
52
+ const LOG_LEVELS = ['info', 'warn', 'error'] as const
53
+ type LogLevel = (typeof LOG_LEVELS)[number]
54
+
55
+ /** Console method per level — info maps to console.log (the only console touch). */
56
+ const CONSOLE_METHODS: Record<LogLevel, 'log' | 'warn' | 'error'> = {
57
+ info: 'log',
58
+ warn: 'warn',
59
+ error: 'error',
60
+ }
61
+
62
+ /** Serialize a log arg — Errors to stack/message, never "[object Object]". */
63
+ function formatArg(arg: unknown): string {
64
+ if (arg instanceof Error) return arg.stack ?? arg.message
65
+ if (typeof arg === 'string') return arg
66
+ try {
67
+ return JSON.stringify(arg)
68
+ } catch {
69
+ return String(arg)
70
+ }
71
+ }
72
+
73
+ /**
74
+ * Append one ISO-stamped line to the hooks.log. Multi-line messages are
75
+ * split so EVERY line carries the stamp (pantheon-hooks appendHookLog
76
+ * pattern). Throws only on IO failure — callers swallow.
77
+ */
78
+ async function appendToLog(logPath: string, prefix: string, args: unknown[]): Promise<void> {
79
+ const body = [prefix, ...args.map(formatArg)].join(' ')
80
+ const stamp = new Date().toISOString()
81
+ const lines = body
82
+ .split('\n')
83
+ .map((p) => p.trim())
84
+ .filter((p) => p !== '')
85
+ .map((p) => `[${stamp}] ${p}`)
86
+ if (lines.length === 0) return
87
+ await mkdir(dirname(logPath), { recursive: true })
88
+ await appendFile(logPath, `${lines.join('\n')}\n`, 'utf8')
89
+ }
90
+
91
+ /**
92
+ * Create a module logger. Every level writes to the log file ALWAYS and
93
+ * echoes to the console only when the env gate is truthy. Fire-and-forget:
94
+ * returns void, never throws.
95
+ */
96
+ export function createPantheonLogger(options: PantheonLoggerOptions): PantheonLogger {
97
+ const echo = (options.env ?? process.env.PANTHEON_HOOKS_LOG ?? '').trim() !== ''
98
+ const logPath = join(options.logDir ?? process.cwd(), '.pantheon', 'logs', 'hooks.log')
99
+
100
+ const write = (level: LogLevel, message: string, args: unknown[]): void => {
101
+ void appendToLog(logPath, `[${options.module}] ${message}`, args).catch(() => {
102
+ // Best-effort file log — never break the plugin over logging.
103
+ })
104
+ if (echo) {
105
+ // Opt-in debug echo (PANTHEON_HOOKS_LOG=1). Off by default: console
106
+ // output in a plugin renders into the opencode TUI (the pollution bug).
107
+ const consoleMethod = CONSOLE_METHODS[level]
108
+ console[consoleMethod](`[${options.module}] ${message}`, ...args)
109
+ }
110
+ }
111
+
112
+ return {
113
+ info: (message, ...args) => write('info', message, args),
114
+ warn: (message, ...args) => write('warn', message, args),
115
+ error: (message, ...args) => write('error', message, args),
116
+ }
117
+ }
@@ -0,0 +1,76 @@
1
+ /**
2
+ * Runtime double-registration guard for the Pantheon opencode plugins.
3
+ *
4
+ * PROBLEM (2026-08-12, live logs — duplicate toasts + duplicate task_ids in
5
+ * ONE process): opencode MERGES the global `~/.config/opencode/opencode.json`
6
+ * (which points at the INSTALLED npm package's plugin paths) with the project
7
+ * `opencode.json` (which points at the REPO source paths). Both list the SAME
8
+ * two plugins — `src/plugin.ts` and `src/plugins/pantheon-hooks.ts` — so each
9
+ * module is LOADED TWICE from two different filesystem paths in the same
10
+ * process. Each load is a separate module instance with its own module scope;
11
+ * the plugin factory then runs twice, registering every hook twice (duplicate
12
+ * toasts, duplicate delegation correlation, duplicated tool registrations).
13
+ *
14
+ * FIX: claim a stable per-plugin key on a process-global Set
15
+ * (`globalThis.__pantheonPluginsLoaded`). The second factory invocation sees
16
+ * its key already claimed and returns an empty hooks object — a no-op — so
17
+ * hooks register exactly once. The guard is PROCESS-global BY DESIGN: only a
18
+ * shared global can dedupe two separate module instances.
19
+ *
20
+ * Fail-safe: access to globalThis is wrapped — if the runtime ever lacks it
21
+ * (never in bun/node), the helper returns false and the plugin always runs
22
+ * (double registration returns, but the plugin never breaks startup).
23
+ *
24
+ * TEST ESCAPE HATCH: set PANTHEON_PLUGIN_ONCE=off to disable the guard. The
25
+ * multi-phase regression suites (pantheon-hooks-chat.test.mjs,
26
+ * plugin-log-policy.test.mjs) deliberately invoke the hooks factory once per
27
+ * phase in the SAME process via cache-busted imports, and the process-global
28
+ * guard would turn every phase after the first into a no-op. Production
29
+ * never sets this — the guard stays active and dedupes the npm-package +
30
+ * repo double load.
31
+ *
32
+ * USAGE — at the TOP of the plugin factory:
33
+ *
34
+ * const plugin: Plugin = async (input: PluginInput) => {
35
+ * if (pantheonPluginOnce('pantheon:plugin')) return {}
36
+ * ... // full factory body — runs exactly once per process
37
+ * }
38
+ *
39
+ * NOTE: this module is imported (never re-exported) by the plugin modules —
40
+ * the opencode legacy loader invokes every FUNCTION-valued export of a plugin
41
+ * module as a plugin factory, so helpers must live in a separate module.
42
+ */
43
+ const GLOBAL_KEY = '__pantheonPluginsLoaded'
44
+
45
+ /** Read (or lazily create) the shared guard Set. Null on any failure. */
46
+ function guardSet(): Set<string> | null {
47
+ try {
48
+ const g = globalThis as Record<string, unknown>
49
+ if (g[GLOBAL_KEY] === undefined) {
50
+ g[GLOBAL_KEY] = new Set<string>()
51
+ }
52
+ const set = g[GLOBAL_KEY]
53
+ return set instanceof Set ? (set as Set<string>) : null
54
+ } catch {
55
+ return null // fail-open: no globalThis support — see header
56
+ }
57
+ }
58
+
59
+ /**
60
+ * Claim `key` for this process. Returns TRUE when the key was ALREADY claimed
61
+ * by another module instance — the caller MUST become a no-op (return an
62
+ * empty hooks object). Returns FALSE on the FIRST claim — the caller runs its
63
+ * full factory body. Fail-open: without globalThis support every call returns
64
+ * false, so the plugin always runs (never breaks startup).
65
+ */
66
+ export function pantheonPluginOnce(key: string): boolean {
67
+ // Test escape hatch (see header): the multi-phase regression suites invoke
68
+ // the factory once per phase in one process — the dedup must not apply.
69
+ // Production never sets it, so the npm+repo double load stays deduped.
70
+ if (process.env.PANTHEON_PLUGIN_ONCE === 'off') return false
71
+ const set = guardSet()
72
+ if (set === null) return false
73
+ if (set.has(key)) return true
74
+ set.add(key)
75
+ return false
76
+ }
@@ -0,0 +1,76 @@
1
+ /**
2
+ * Preemptive Compaction Check (release-134 Phase 5) — dormant, ready-to-wire.
3
+ *
4
+ * WHY: the SDK 1.18.x exposes NO runtime source of context-usage percentage,
5
+ * so there is nothing to observe at runtime yet. This module is the pure
6
+ * decision core that warns the model BEFORE the context fills up — ready to
7
+ * be wired to a future usage source, but deliberately NOT wired today.
8
+ *
9
+ * HOW: `preemptiveCompactCheck` is a pure function: given the current usage
10
+ * fraction and the last fraction at which a warning was emitted, it decides
11
+ * whether to warn again (threshold + re-warn step) and returns the effective
12
+ * `lastWarnedPct`. The `enqueue` callback is INJECTED by the caller — this
13
+ * module stays standalone and does NOT import the shared chat-reminders
14
+ * buffer.
15
+ *
16
+ * YAGNI: no background timers, no polling, no plugin wiring. When (if) the
17
+ * SDK exposes a usage source, the caller wires it into this check.
18
+ *
19
+ * FAIL-OPEN: the whole check is wrapped — an enqueue failure returns the
20
+ * previous state and never throws.
21
+ *
22
+ * @module preemptive-compact
23
+ */
24
+
25
+ // ─── Constants ─────────────────────────────────────────────────────────
26
+
27
+ /** Warn when context usage reaches this fraction (78%). */
28
+ export const PREEMPTIVE_COMPACTION_THRESHOLD = 0.78
29
+
30
+ /** Re-warn only when usage rose at least this much since the last warning (5pp). */
31
+ export const PREEMPTIVE_REWARN_STEP = 0.05
32
+
33
+ // ─── Types ─────────────────────────────────────────────────────────────
34
+
35
+ /** Dependencies threaded to preemptiveCompactCheck (wired from plugin.ts). */
36
+ export interface PreemptiveCompactDeps {
37
+ /** The session whose context usage is being checked. */
38
+ sessionID: string
39
+ /** Current context usage as a fraction (0..1). */
40
+ usagePct: number
41
+ /** Last fraction at which a warning was emitted (undefined = never). */
42
+ lastWarnedPct: number | undefined
43
+ /** Injectable enqueue (testable). The module stays standalone. */
44
+ enqueue: (text: string) => void
45
+ }
46
+
47
+ // ─── Decision ──────────────────────────────────────────────────────────
48
+
49
+ /**
50
+ * Pure decision: should a warning be emitted at the given usage?
51
+ * True when usage is at/above the threshold AND either no warning was
52
+ * emitted yet or usage rose by at least the re-warn step since the last one.
53
+ */
54
+ export function shouldWarn(usagePct: number, lastWarnedPct: number | undefined): boolean {
55
+ if (usagePct < PREEMPTIVE_COMPACTION_THRESHOLD) return false
56
+ if (lastWarnedPct === undefined) return true
57
+ return usagePct >= lastWarnedPct + PREEMPTIVE_REWARN_STEP
58
+ }
59
+
60
+ /**
61
+ * Preemptive compaction check: when a warning should fire, enqueue the
62
+ * reminder and return the usage fraction as the new `lastWarnedPct`.
63
+ * Otherwise (or on any failure) return the previous `lastWarnedPct`.
64
+ * NEVER throws — the hook must never break the session.
65
+ */
66
+ export function preemptiveCompactCheck(deps: PreemptiveCompactDeps): number | undefined {
67
+ try {
68
+ if (!shouldWarn(deps.usagePct, deps.lastWarnedPct)) return deps.lastWarnedPct
69
+ deps.enqueue(
70
+ `context usage at ${Math.round(deps.usagePct * 100)}% — consider compacting before continuing`,
71
+ )
72
+ return deps.usagePct
73
+ } catch {
74
+ return deps.lastWarnedPct
75
+ }
76
+ }
@@ -84,6 +84,20 @@ export declare function visionBrokenOnGateway(model: string, providerID: string)
84
84
 
85
85
  export declare function loadPresetDefs(routingPath?: string): Record<string, PresetDef>
86
86
 
87
+ export interface RoutingAgentModelsOptions {
88
+ routingPath?: string
89
+ logger?: { warn?: (msg: string) => void }
90
+ }
91
+
92
+ /**
93
+ * Lowercase agent → "provider/model" mapping from routing.yml's FIRST
94
+ * (default) preset — the static source for the delegation toolset's
95
+ * options.agentModels. Fail-open: missing/corrupt routing.yml → {}.
96
+ */
97
+ export declare function loadRoutingAgentModels(
98
+ options?: RoutingAgentModelsOptions,
99
+ ): Record<string, string>
100
+
87
101
  export declare function resolveActivePreset(options?: ResolveOptions): ResolvedPreset | null
88
102
 
89
103
  export declare function applyActivePresetToConfig<C extends object>(
@@ -91,6 +105,23 @@ export declare function applyActivePresetToConfig<C extends object>(
91
105
  options?: ResolveOptions & { env?: Record<string, string | undefined> },
92
106
  ): ResolvedPreset | null
93
107
 
108
+ export interface ProviderKeyOptions {
109
+ env?: Record<string, string | undefined>
110
+ routingPath?: string
111
+ }
112
+
113
+ /** Name of the env var whose value is missing for a provider, or undefined when usable. */
114
+ export declare function missingProviderKeyEnv(
115
+ providerID: string,
116
+ options?: ProviderKeyOptions,
117
+ ): string | undefined
118
+
119
+ /** Whether the provider's API key is configured (apiKeyEnv set + non-empty, or no gate). */
120
+ export declare function providerKeyConfigured(
121
+ providerID: string,
122
+ options?: ProviderKeyOptions,
123
+ ): boolean
124
+
94
125
  export interface MissingApiKeyError extends Error {
95
126
  code: 'PANTHEON_MISSING_API_KEY'
96
127
  envVar: string
@@ -210,6 +210,49 @@ export function loadPresetDefs(routingPath) {
210
210
  return (routing && typeof routing === 'object' && routing.presets) || {}
211
211
  }
212
212
 
213
+ /**
214
+ * Load the DEFAULT agent → model mapping from routing.yml for the delegation
215
+ * toolset (Fase 6 — wiring agentModels).
216
+ *
217
+ * routing.yml keeps per-agent models ONLY inside presets
218
+ * (`presets.<name>.agents.<agent>.model`) — the top-level `agents:` section
219
+ * carries no model field. The FIRST-listed preset is the static default
220
+ * (go-deepseek today): a mapping INDEPENDENT of the active preset, so a
221
+ * delegated child always gets a sane per-agent model even when no preset is
222
+ * active — delegation no longer depends 100% on the active preset (branch (b)
223
+ * of resolveChildModel, which outranks the active-preset branch (c)).
224
+ *
225
+ * Fail-open: a missing or unparseable routing.yml yields {} (the caller keeps
226
+ * the previous behavior — active preset / opencode default) and warns via the
227
+ * optional logger; it NEVER throws at startup.
228
+ *
229
+ * @param {object} [opts]
230
+ * @param {string} [opts.routingPath]
231
+ * @param {{warn?: Function}} [opts.logger]
232
+ * @returns {Record<string, string>} lowercase agent → "provider/model"
233
+ */
234
+ export function loadRoutingAgentModels({ routingPath, logger = console } = {}) {
235
+ let defs
236
+ try {
237
+ defs = loadPresetDefs(routingPath)
238
+ } catch (err) {
239
+ const reason = err instanceof Error ? err.message : String(err)
240
+ logger.warn?.(
241
+ `presets: routing.yml agent models unavailable (${reason}) — delegation falls back to the active preset/default`,
242
+ )
243
+ return {}
244
+ }
245
+ const names = Object.keys(defs)
246
+ if (names.length === 0) return {}
247
+ const models = {}
248
+ for (const [agent, spec] of Object.entries(defs[names[0]]?.agents ?? {})) {
249
+ if (spec && typeof spec === 'object' && typeof spec.model === 'string' && spec.model !== '') {
250
+ models[agent.toLowerCase()] = spec.model
251
+ }
252
+ }
253
+ return models
254
+ }
255
+
213
256
  /**
214
257
  * Resolve the active preset: env PANTHEON_MODEL_PRESET > first existing
215
258
  * candidate file > null. "none" disables. Unknown names warn and return null
@@ -326,6 +369,66 @@ function buildResolved(defs, name, source, rawFile) {
326
369
  return { name, source, agents, providers, overrides, vision }
327
370
  }
328
371
 
372
+ /**
373
+ * Whether a single provider DEF has its API key configured: no apiKeyEnv
374
+ * (native provider, no external key gate) → always configured; otherwise the
375
+ * env var must be set and non-empty. Shared by applyPreset (startup config
376
+ * path) and providerKeyConfigured / missingProviderKeyEnv (delegate dispatch).
377
+ *
378
+ * @param {object|null|undefined} provider provider def ({baseURL, apiKeyEnv})
379
+ * @param {Record<string, string|undefined>} env
380
+ * @returns {boolean}
381
+ */
382
+ function providerDefKeyConfigured(provider, env) {
383
+ if (!provider || typeof provider !== 'object') return true
384
+ const envVar = provider.apiKeyEnv
385
+ if (typeof envVar !== 'string' || envVar === '') return true
386
+ const key = env[envVar]
387
+ return typeof key === 'string' && key.trim() !== ''
388
+ }
389
+
390
+ /**
391
+ * Name of the env var whose value is missing for a provider, or undefined
392
+ * when the provider is usable. A provider is usable when it declares no
393
+ * apiKeyEnv (native providers like opencode Zen) or its apiKeyEnv env var is
394
+ * set and non-empty. Provider defs come from routing.yml preset definitions —
395
+ * the SAME source applyPreset enforces at startup, so the delegate dispatch
396
+ * path and the config path can never disagree.
397
+ *
398
+ * @param {string} providerID e.g. 'openai' | 'opencode' | 'opencode-go'
399
+ * @param {object} [opts]
400
+ * @param {Record<string, string|undefined>} [opts.env]
401
+ * @param {string} [opts.routingPath]
402
+ * @returns {string|undefined} missing env var name, or undefined when usable
403
+ */
404
+ export function missingProviderKeyEnv(providerID, { env = process.env, routingPath } = {}) {
405
+ const defs = loadPresetDefs(routingPath)
406
+ for (const def of Object.values(defs)) {
407
+ const provider = def?.providers?.[providerID]
408
+ if (provider === undefined) continue
409
+ if (providerDefKeyConfigured(provider, env)) return undefined
410
+ return provider.apiKeyEnv
411
+ }
412
+ // Provider not declared in any preset → no external key gate (opencode
413
+ // manages its own auth) → usable.
414
+ return undefined
415
+ }
416
+
417
+ /**
418
+ * Whether the given provider's API key is configured (see
419
+ * missingProviderKeyEnv). Convenience boolean for callers that only need the
420
+ * yes/no answer.
421
+ *
422
+ * @param {string} providerID
423
+ * @param {object} [opts]
424
+ * @param {Record<string, string|undefined>} [opts.env]
425
+ * @param {string} [opts.routingPath]
426
+ * @returns {boolean}
427
+ */
428
+ export function providerKeyConfigured(providerID, opts) {
429
+ return missingProviderKeyEnv(providerID, opts) === undefined
430
+ }
431
+
329
432
  /**
330
433
  * Apply a resolved preset to an opencode config object (mutates `config`).
331
434
  * - Providers: config.provider[id].options.baseURL + apiKey (env-gated).
@@ -339,8 +442,7 @@ export function applyPreset(config, resolved, { env = process.env } = {}) {
339
442
  if (!resolved) return config
340
443
 
341
444
  for (const [id, provider] of Object.entries(resolved.providers ?? {})) {
342
- const apiKey = env[provider.apiKeyEnv]
343
- if (!apiKey) {
445
+ if (!providerDefKeyConfigured(provider, env)) {
344
446
  const err = new Error(`presets: provider "${id}" requires env var ${provider.apiKeyEnv}`)
345
447
  err.code = 'PANTHEON_MISSING_API_KEY'
346
448
  err.envVar = provider.apiKeyEnv
@@ -350,7 +452,7 @@ export function applyPreset(config, resolved, { env = process.env } = {}) {
350
452
  config.provider[id] ??= {}
351
453
  config.provider[id].options ??= {}
352
454
  config.provider[id].options.baseURL = provider.baseURL
353
- config.provider[id].options.apiKey = apiKey
455
+ config.provider[id].options.apiKey = env[provider.apiKeyEnv]
354
456
  }
355
457
 
356
458
  for (const [agent, spec] of Object.entries(resolved.agents ?? {})) {