pantheon-opencode 1.3.3 → 1.3.5-beta.52.c75b04d
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/AGENTS.md +919 -0
- package/README.md +157 -15
- package/bin/pantheon-init.mjs +177 -120
- package/opencode.json +3 -6
- package/package.json +8 -4
- package/scripts/build-agents-md.mjs +195 -0
- package/scripts/cost.mjs +17 -0
- package/scripts/doctor.mjs +56 -22
- package/scripts/hooks/on-subagent-delegation-stop.sh +22 -5
- package/scripts/install/opencode.mjs +189 -38
- package/scripts/install/plugin.mjs +112 -28
- package/scripts/install-mcp.mjs +1 -1
- package/scripts/redaction-gate.mjs +69 -0
- package/scripts/release-beta-version.mjs +80 -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/cost-command.ts +57 -18
- 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
|
@@ -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 ?? {})) {
|
|
@@ -13,11 +13,17 @@
|
|
|
13
13
|
* `userActivityQuietMs` is being driven interactively → skip;
|
|
14
14
|
* 2. board-running — a session with a running background job is skipped
|
|
15
15
|
* (the parent is busy; the child session itself is never routed here);
|
|
16
|
-
* 3.
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
16
|
+
* 3. children — `session.children()` is checked: a delegated child's
|
|
17
|
+
* session ID IS its board task ID, so `board.get(child.id)` resolves
|
|
18
|
+
* and the board state machine is authoritative — the child is active
|
|
19
|
+
* ONLY while the job is `running`; terminal jobs (completed/error/
|
|
20
|
+
* cancelled/reconciled) never suppress. Native children (opencode
|
|
21
|
+
* `task(background=true)`, NOT on our board) fall back to the
|
|
22
|
+
* `time.updated` heuristic (`childActiveMs`). The cross-ref fixes the
|
|
23
|
+
* T6 bug: a completed delegation's frozen `time.updated` looked "still
|
|
24
|
+
* running" for `childActiveMs` and suppressed every injection in that
|
|
25
|
+
* window. Throws fail open (log + inject) so version-sensitive APIs
|
|
26
|
+
* never break the enforcer;
|
|
21
27
|
* 4. in-flight — one injection per idle, never concurrent;
|
|
22
28
|
* 5. cooldown — exponential per-session backoff `cooldownBaseMs *
|
|
23
29
|
* 2^min(failures, max)`, failures increment when an injection does not
|
|
@@ -42,6 +48,12 @@
|
|
|
42
48
|
*/
|
|
43
49
|
|
|
44
50
|
import type { BackgroundJobBoard } from './background-job-board.ts'
|
|
51
|
+
import { createPantheonLogger } from './logger.ts'
|
|
52
|
+
|
|
53
|
+
// Silence-by-default TUI policy (pantheon-hooks L42-58): the default warn
|
|
54
|
+
// fallback logs to .pantheon/logs/hooks.log; console echo is opt-in via
|
|
55
|
+
// PANTHEON_HOOKS_LOG=1. `deps.logger` injection stays for tests.
|
|
56
|
+
const log = createPantheonLogger({ module: 'pantheon-todo' })
|
|
45
57
|
|
|
46
58
|
// ─── Constants ─────────────────────────────────────────────────────────
|
|
47
59
|
|
|
@@ -49,8 +61,7 @@ import type { BackgroundJobBoard } from './background-job-board.ts'
|
|
|
49
61
|
* Version-controlled continuation text injected into the idle session.
|
|
50
62
|
* Exported for tests and so the routing.yml comment stays in sync.
|
|
51
63
|
*/
|
|
52
|
-
export const TODO_CONTINUATION_PROMPT =
|
|
53
|
-
'Incomplete tasks remain in your todo list. Continue working on them. Do not stop until all are done. If you believe all work is complete, critically re-examine whether the completion claim is verifiable.'
|
|
64
|
+
export const TODO_CONTINUATION_PROMPT = 'Continue: pending todos remain — review and proceed.'
|
|
54
65
|
|
|
55
66
|
/** Defaults matching routing.yml `todo_enforcer`. */
|
|
56
67
|
export const TODO_ENFORCER_DEFAULTS = {
|
|
@@ -60,7 +71,7 @@ export const TODO_ENFORCER_DEFAULTS = {
|
|
|
60
71
|
failureResetMs: 300000,
|
|
61
72
|
/** Skip injection this long after a user message in the session. */
|
|
62
73
|
userActivityQuietMs: 30000,
|
|
63
|
-
/** A native child whose `time.updated` is newer than this is still running. */
|
|
74
|
+
/** A native (non-board) child whose `time.updated` is newer than this is still running. */
|
|
64
75
|
childActiveMs: 120000,
|
|
65
76
|
} as const
|
|
66
77
|
|
|
@@ -140,16 +151,16 @@ export interface TodoEnforcerOptions {
|
|
|
140
151
|
failureResetMs?: number
|
|
141
152
|
/** Skip injection while a user message is more recent than this. */
|
|
142
153
|
userActivityQuietMs?: number
|
|
143
|
-
/** Native child sessions newer than this are considered active/running. */
|
|
154
|
+
/** Native (non-board) child sessions newer than this are considered active/running. */
|
|
144
155
|
childActiveMs?: number
|
|
145
156
|
/** Injectable clock (testable), defaults to `Date.now`. */
|
|
146
157
|
now?: () => number
|
|
147
158
|
}
|
|
148
159
|
|
|
149
|
-
/** Dependencies threaded to the enforcer (structural board — list
|
|
160
|
+
/** Dependencies threaded to the enforcer (structural board — list + get). */
|
|
150
161
|
export interface TodoEnforcerDeps {
|
|
151
162
|
client: TodoEnforcerClient
|
|
152
|
-
board: Pick<BackgroundJobBoard, 'list'>
|
|
163
|
+
board: Pick<BackgroundJobBoard, 'list' | 'get'>
|
|
153
164
|
options?: TodoEnforcerOptions
|
|
154
165
|
logger?: { warn: (message: string) => void }
|
|
155
166
|
}
|
|
@@ -170,7 +181,7 @@ type InheritedContext = {
|
|
|
170
181
|
*/
|
|
171
182
|
export class TodoEnforcer {
|
|
172
183
|
private readonly client: TodoEnforcerClient
|
|
173
|
-
private readonly board: Pick<BackgroundJobBoard, 'list'>
|
|
184
|
+
private readonly board: Pick<BackgroundJobBoard, 'list' | 'get'>
|
|
174
185
|
private readonly options: Required<TodoEnforcerOptions>
|
|
175
186
|
private readonly warn: (message: string) => void
|
|
176
187
|
|
|
@@ -201,8 +212,7 @@ export class TodoEnforcer {
|
|
|
201
212
|
childActiveMs: opts.childActiveMs ?? TODO_ENFORCER_DEFAULTS.childActiveMs,
|
|
202
213
|
now: opts.now ?? Date.now,
|
|
203
214
|
}
|
|
204
|
-
this.warn =
|
|
205
|
-
deps.logger?.warn ?? ((message: string) => console.warn(`[pantheon-todo] ${message}`))
|
|
215
|
+
this.warn = deps.logger?.warn ?? ((message: string) => log.warn(message))
|
|
206
216
|
}
|
|
207
217
|
|
|
208
218
|
/**
|
|
@@ -214,6 +224,25 @@ export class TodoEnforcer {
|
|
|
214
224
|
this.lastUserMessageAt.set(sessionID, this.options.now())
|
|
215
225
|
}
|
|
216
226
|
|
|
227
|
+
/**
|
|
228
|
+
* List the session's PENDING todos (not completed/cancelled) — the same
|
|
229
|
+
* incomplete filter as the idle continuation, exposed for the compaction
|
|
230
|
+
* context builder. Returns [] when the enforcer is disabled or the todo
|
|
231
|
+
* API fails (fail-open: the compaction hook must never break the
|
|
232
|
+
* session).
|
|
233
|
+
*/
|
|
234
|
+
async listPendingTodos(sessionID: string): Promise<TodoLike[]> {
|
|
235
|
+
if (!this.options.enabled) return []
|
|
236
|
+
try {
|
|
237
|
+
const todos = await this.client.session.todo({ path: { id: sessionID } })
|
|
238
|
+
return todos.filter((todo) => todo.status !== 'completed' && todo.status !== 'cancelled')
|
|
239
|
+
} catch (err: unknown) {
|
|
240
|
+
const reason = err instanceof Error ? err.message : String(err)
|
|
241
|
+
this.warn(`todo list failed for session ${sessionID}: ${reason}`)
|
|
242
|
+
return []
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
217
246
|
/**
|
|
218
247
|
* Handle a `session.idle` event for a non-board session. Evaluates the
|
|
219
248
|
* previous injection's outcome, applies the guards, and injects the
|
|
@@ -245,8 +274,10 @@ export class TodoEnforcer {
|
|
|
245
274
|
// Guard 2: a session with a running board job is busy — skip.
|
|
246
275
|
if (this.board.list(sessionID).some((job) => job.state === 'running')) return
|
|
247
276
|
|
|
248
|
-
// Guard 3:
|
|
249
|
-
// active
|
|
277
|
+
// Guard 3: children — a delegated child (session ID == board task ID) is
|
|
278
|
+
// active only while its job is `running`; a native child is active while
|
|
279
|
+
// its time.updated is fresh. Either active child means the parent is
|
|
280
|
+
// still waiting on it, so skip.
|
|
250
281
|
if ((await this.activeChildren(sessionID, now)) > 0) return
|
|
251
282
|
|
|
252
283
|
const todos = await this.client.session.todo({ path: { id: sessionID } })
|
|
@@ -317,21 +348,29 @@ export class TodoEnforcer {
|
|
|
317
348
|
}
|
|
318
349
|
|
|
319
350
|
/**
|
|
320
|
-
* Count active
|
|
321
|
-
*
|
|
322
|
-
*
|
|
323
|
-
*
|
|
324
|
-
*
|
|
325
|
-
*
|
|
326
|
-
*
|
|
351
|
+
* Count active child sessions. Children fall into two classes:
|
|
352
|
+
*
|
|
353
|
+
* - BOARD children (delegated via the toolset): the child session ID IS
|
|
354
|
+
* the board task ID, so `board.get(child.id)` resolves. The board state
|
|
355
|
+
* machine is authoritative — active ONLY while `state === 'running'`;
|
|
356
|
+
* a terminal job (completed/error/cancelled/reconciled) is NOT active,
|
|
357
|
+
* even though its `time.updated` froze at completion (the frozen
|
|
358
|
+
* timestamp is exactly the T6 bug that made a finished delegation look
|
|
359
|
+
* "still running" for `childActiveMs`).
|
|
360
|
+
* - NATIVE children (opencode `task(background=true)`, NOT on our board):
|
|
361
|
+
* `board.get(child.id)` is undefined, so the heuristic applies — active
|
|
362
|
+
* while its `time.updated` is newer than `childActiveMs` (a running
|
|
363
|
+
* native task keeps updating its session while streaming; a completed
|
|
364
|
+
* one freezes).
|
|
365
|
+
*
|
|
366
|
+
* Fail-open: when the children API is unavailable (older opencode /
|
|
367
|
+
* permission), log and return 0 so the enforcer still works on
|
|
368
|
+
* version-sensitive runtimes.
|
|
327
369
|
*/
|
|
328
370
|
private async activeChildren(sessionID: string, now: number): Promise<number> {
|
|
329
371
|
try {
|
|
330
372
|
const children = await this.client.session.children({ path: { id: sessionID } })
|
|
331
|
-
return children.filter((child) =>
|
|
332
|
-
const updated = child.time?.updated
|
|
333
|
-
return typeof updated === 'number' && now - updated < this.options.childActiveMs
|
|
334
|
-
}).length
|
|
373
|
+
return children.filter((child) => this.isChildActive(child, now)).length
|
|
335
374
|
} catch (err: unknown) {
|
|
336
375
|
const reason = err instanceof Error ? err.message : String(err)
|
|
337
376
|
this.warn(`todo children check failed for session ${sessionID}: ${reason}`)
|
|
@@ -339,6 +378,19 @@ export class TodoEnforcer {
|
|
|
339
378
|
}
|
|
340
379
|
}
|
|
341
380
|
|
|
381
|
+
/**
|
|
382
|
+
* Whether a single child counts as active. Board children (the child's
|
|
383
|
+
* session ID matches a board task ID) are active only while the job is
|
|
384
|
+
* `running` — terminal board states never suppress. Native children
|
|
385
|
+
* (no board job) use the `time.updated` freshness heuristic.
|
|
386
|
+
*/
|
|
387
|
+
private isChildActive(child: TodoEnforcerChild, now: number): boolean {
|
|
388
|
+
const job = this.board.get(child.id)
|
|
389
|
+
if (job !== undefined) return job.state === 'running'
|
|
390
|
+
const updated = child.time?.updated
|
|
391
|
+
return typeof updated === 'number' && now - updated < this.options.childActiveMs
|
|
392
|
+
}
|
|
393
|
+
|
|
342
394
|
/**
|
|
343
395
|
* Inherit the agent (or model) from the LAST assistant message so the
|
|
344
396
|
* continuation runs in the same context as the session's last turn. Returns
|