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.
- package/README.md +115 -12
- package/bin/pantheon-init.mjs +160 -120
- package/opencode.json +0 -4
- package/package.json +7 -4
- package/scripts/doctor.mjs +56 -22
- package/scripts/hooks/on-subagent-delegation-stop.sh +22 -5
- package/scripts/install/opencode.mjs +108 -26
- package/scripts/install/plugin.mjs +112 -28
- package/scripts/install-mcp.mjs +1 -1
- package/scripts/redaction-gate.mjs +69 -0
- package/scripts/release-notes.mjs +317 -0
- package/scripts/test-context-compression.sh +5 -2
- package/scripts/validate-package.mjs +56 -0
- package/scripts/versioning.mjs +117 -18
- package/src/agents/zeus.md +6 -2
- package/src/pantheon/background-job-board.ts +24 -38
- package/src/pantheon/chat-reminders.ts +79 -0
- package/src/pantheon/compaction-assert.ts +160 -0
- package/src/pantheon/delegation-compaction.ts +143 -16
- package/src/pantheon/delegation-finalize.ts +8 -1
- package/src/pantheon/delegation-notify.ts +19 -157
- package/src/pantheon/delegation.ts +330 -28
- package/src/pantheon/dispatch-guard.ts +9 -4
- package/src/pantheon/goal-loop.ts +7 -4
- package/src/pantheon/hashline/tool.ts +2 -1
- package/src/pantheon/logger.ts +117 -0
- package/src/pantheon/plugin-once.ts +76 -0
- package/src/pantheon/preemptive-compact.ts +76 -0
- package/src/pantheon/presets.d.mts +31 -0
- package/src/pantheon/presets.mjs +105 -3
- package/src/pantheon/todo-enforcer.ts +79 -27
- package/src/pantheon/todo-preserve.ts +295 -0
- package/src/plugin.ts +177 -50
- package/src/plugins/pantheon-hooks.ts +35 -22
- package/src/plugins/tui/dist/server.d.ts +17 -0
- package/src/plugins/tui/dist/server.d.ts.map +1 -0
- package/src/plugins/tui/dist/server.js +20 -0
- package/src/plugins/tui/dist/server.js.map +1 -0
- package/src/plugins/tui/dist/tui.d.ts +303 -1
- package/src/plugins/tui/dist/tui.d.ts.map +1 -1
- package/src/plugins/tui/dist/tui.js +977 -350
- package/src/plugins/tui/dist/tui.tsx +1427 -150
- package/src/plugins/tui/package.json +4 -3
- package/src/plugins/tui/src/index.tsx +1427 -150
- package/src/plugins/tui/src/server.ts +15 -0
- package/src/plugins/tui/tsdown.config.ts +1 -1
- 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
|
|
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:
|
|
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
|
package/src/pantheon/presets.mjs
CHANGED
|
@@ -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
|
-
|
|
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 =
|
|
455
|
+
config.provider[id].options.apiKey = env[provider.apiKeyEnv]
|
|
354
456
|
}
|
|
355
457
|
|
|
356
458
|
for (const [agent, spec] of Object.entries(resolved.agents ?? {})) {
|