pantheon-opencode 1.3.2 → 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 +154 -1
- package/bin/pantheon-init.mjs +160 -120
- package/opencode.json +0 -1
- package/package.json +10 -5
- package/scripts/cost.mjs +69 -0
- package/scripts/doctor.mjs +56 -22
- package/scripts/hooks/on-subagent-delegation-stop.sh +22 -5
- package/scripts/install/opencode.mjs +132 -32
- 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 +80 -0
- package/src/pantheon/background-job-board.ts +115 -40
- package/src/pantheon/chat-reminders.ts +79 -0
- package/src/pantheon/compaction-assert.ts +160 -0
- package/src/pantheon/cost-command.ts +208 -0
- package/src/pantheon/cost-tracker.ts +108 -0
- package/src/pantheon/delegation-compaction.ts +237 -0
- package/src/pantheon/delegation-enforce.ts +144 -0
- package/src/pantheon/delegation-finalize.ts +277 -0
- package/src/pantheon/delegation-notify.ts +100 -0
- package/src/pantheon/delegation.ts +690 -0
- package/src/pantheon/dispatch-guard.ts +107 -0
- package/src/pantheon/file-persistence.ts +10 -6
- package/src/pantheon/goal-loop.ts +300 -0
- package/src/pantheon/goal-store.ts +97 -0
- package/src/pantheon/hashline/core.ts +91 -0
- package/src/pantheon/hashline/read-enhancer.ts +66 -0
- package/src/pantheon/hashline/tool.ts +260 -0
- package/src/pantheon/hashline/xxhash.ts +53 -0
- package/src/pantheon/idle-continuation.ts +54 -0
- 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 +421 -0
- package/src/pantheon/todo-preserve.ts +295 -0
- package/src/plugin.ts +406 -13
- package/src/plugins/pantheon-hooks.ts +52 -30
- 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 +35 -2
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 ?? {})) {
|
|
@@ -0,0 +1,421 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TODO Enforcer (Wave 1) — keeps root/non-board sessions working on their
|
|
3
|
+
* todo list when the session goes idle.
|
|
4
|
+
*
|
|
5
|
+
* The plugin's `event` hook dispatches `session.idle`: board-child sessions go
|
|
6
|
+
* to the delegation finalize path (`handleDelegationEvent`), everything else
|
|
7
|
+
* (roots, non-board sessions) goes to `TodoEnforcer.onIdle`. The enforcer
|
|
8
|
+
* re-injects a continuation prompt when the session went idle with incomplete
|
|
9
|
+
* todos, so the agent keeps working on its task list instead of stopping.
|
|
10
|
+
*
|
|
11
|
+
* Guards (council-approved cut — latch/skipAgents/countdown-toast removed):
|
|
12
|
+
* 1. user-activity — a session whose user sent a message within
|
|
13
|
+
* `userActivityQuietMs` is being driven interactively → skip;
|
|
14
|
+
* 2. board-running — a session with a running background job is skipped
|
|
15
|
+
* (the parent is busy; the child session itself is never routed here);
|
|
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;
|
|
27
|
+
* 4. in-flight — one injection per idle, never concurrent;
|
|
28
|
+
* 5. cooldown — exponential per-session backoff `cooldownBaseMs *
|
|
29
|
+
* 2^min(failures, max)`, failures increment when an injection does not
|
|
30
|
+
* clear todos (the next idle shows the same-or-worse incomplete count);
|
|
31
|
+
* 6. max-consecutive-failures — stop injecting after the cap; the failure
|
|
32
|
+
* counter resets after a quiet period (`failureResetMs`) and when an
|
|
33
|
+
* injection makes progress (streak semantics).
|
|
34
|
+
*
|
|
35
|
+
* DONE detection: all todos `completed`/`cancelled` (or no todos at all) →
|
|
36
|
+
* no injection this idle. The todo re-fetch is cheap, so no done-latch is
|
|
37
|
+
* needed.
|
|
38
|
+
*
|
|
39
|
+
* The enforcer NEVER touches the TUI (no toast — council cut: toasts are
|
|
40
|
+
* no-ops on opencode 1.18.13). It depends only on `session.todo`,
|
|
41
|
+
* `session.messages` (agent/model inheritance via the last assistant
|
|
42
|
+
* message) and `session.promptAsync`
|
|
43
|
+
* (injection).
|
|
44
|
+
*
|
|
45
|
+
* Pure TypeScript — zero runtime dependencies beyond the board types.
|
|
46
|
+
*
|
|
47
|
+
* @module todo-enforcer
|
|
48
|
+
*/
|
|
49
|
+
|
|
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' })
|
|
57
|
+
|
|
58
|
+
// ─── Constants ─────────────────────────────────────────────────────────
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Version-controlled continuation text injected into the idle session.
|
|
62
|
+
* Exported for tests and so the routing.yml comment stays in sync.
|
|
63
|
+
*/
|
|
64
|
+
export const TODO_CONTINUATION_PROMPT = 'Continue: pending todos remain — review and proceed.'
|
|
65
|
+
|
|
66
|
+
/** Defaults matching routing.yml `todo_enforcer`. */
|
|
67
|
+
export const TODO_ENFORCER_DEFAULTS = {
|
|
68
|
+
enabled: true,
|
|
69
|
+
cooldownBaseMs: 5000,
|
|
70
|
+
maxConsecutiveFailures: 5,
|
|
71
|
+
failureResetMs: 300000,
|
|
72
|
+
/** Skip injection this long after a user message in the session. */
|
|
73
|
+
userActivityQuietMs: 30000,
|
|
74
|
+
/** A native (non-board) child whose `time.updated` is newer than this is still running. */
|
|
75
|
+
childActiveMs: 120000,
|
|
76
|
+
} as const
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Runtime kill-switch: `PANTHEON_TODO_ENFORCER=off` disables the enforcer.
|
|
80
|
+
* Read at plugin construction — routing.yml is a doc mirror only, this env
|
|
81
|
+
* var is the real runtime switch (COMPACTION_MAX_ITEMS precedent).
|
|
82
|
+
*/
|
|
83
|
+
export function todoEnforcerEnabledFromEnv(
|
|
84
|
+
env: Record<string, string | undefined> = process.env,
|
|
85
|
+
): boolean {
|
|
86
|
+
return (env.PANTHEON_TODO_ENFORCER ?? '').trim().toLowerCase() !== 'off'
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// ─── Types ─────────────────────────────────────────────────────────────
|
|
90
|
+
|
|
91
|
+
/** Structural view of a session todo item (SDK Todo: status is a string). */
|
|
92
|
+
export interface TodoLike {
|
|
93
|
+
content?: string
|
|
94
|
+
status: string
|
|
95
|
+
priority?: string
|
|
96
|
+
id?: string
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Structural view of a child session from `session.children()` (SDK Session).
|
|
101
|
+
* Only `time.updated` is used — a running native background task keeps
|
|
102
|
+
* updating its session while streaming; a completed one freezes.
|
|
103
|
+
*/
|
|
104
|
+
export interface TodoEnforcerChild {
|
|
105
|
+
id: string
|
|
106
|
+
time?: { updated?: number }
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Structural view of one message bundle from `session.messages()`. The SDK
|
|
111
|
+
* returns `{ info: Message, parts: Part[] }` — user messages carry `agent`
|
|
112
|
+
* on `info`, assistant messages carry `providerID`/`modelID` (or a `model`
|
|
113
|
+
* object).
|
|
114
|
+
*/
|
|
115
|
+
export interface TodoEnforcerMessage {
|
|
116
|
+
info?: {
|
|
117
|
+
role?: string
|
|
118
|
+
agent?: string
|
|
119
|
+
model?: { providerID?: string; modelID?: string }
|
|
120
|
+
providerID?: string
|
|
121
|
+
modelID?: string
|
|
122
|
+
}
|
|
123
|
+
parts?: Array<{ type?: string; text?: string }>
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** Structural client surface — the subset of the SDK the enforcer uses. */
|
|
127
|
+
export interface TodoEnforcerClient {
|
|
128
|
+
session: {
|
|
129
|
+
todo(input: { path: { id: string } }): Promise<TodoLike[]>
|
|
130
|
+
messages(input: { path: { id: string } }): Promise<TodoEnforcerMessage[]>
|
|
131
|
+
children(input: { path: { id: string } }): Promise<TodoEnforcerChild[]>
|
|
132
|
+
promptAsync(input: {
|
|
133
|
+
path: { id: string }
|
|
134
|
+
body: {
|
|
135
|
+
agent?: string
|
|
136
|
+
model?: { providerID: string; modelID: string }
|
|
137
|
+
parts: Array<{ type: 'text'; text: string }>
|
|
138
|
+
}
|
|
139
|
+
}): Promise<unknown>
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** Tunables (all optional — see TODO_ENFORCER_DEFAULTS). */
|
|
144
|
+
export interface TodoEnforcerOptions {
|
|
145
|
+
enabled?: boolean
|
|
146
|
+
/** Base cooldown after a failed injection; doubles per failure. */
|
|
147
|
+
cooldownBaseMs?: number
|
|
148
|
+
/** Stop injecting after this many consecutive failures. */
|
|
149
|
+
maxConsecutiveFailures?: number
|
|
150
|
+
/** Quiet period after which the failure counter resets. */
|
|
151
|
+
failureResetMs?: number
|
|
152
|
+
/** Skip injection while a user message is more recent than this. */
|
|
153
|
+
userActivityQuietMs?: number
|
|
154
|
+
/** Native (non-board) child sessions newer than this are considered active/running. */
|
|
155
|
+
childActiveMs?: number
|
|
156
|
+
/** Injectable clock (testable), defaults to `Date.now`. */
|
|
157
|
+
now?: () => number
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** Dependencies threaded to the enforcer (structural board — list + get). */
|
|
161
|
+
export interface TodoEnforcerDeps {
|
|
162
|
+
client: TodoEnforcerClient
|
|
163
|
+
board: Pick<BackgroundJobBoard, 'list' | 'get'>
|
|
164
|
+
options?: TodoEnforcerOptions
|
|
165
|
+
logger?: { warn: (message: string) => void }
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
type InheritedContext = {
|
|
169
|
+
agent?: string
|
|
170
|
+
model?: { providerID: string; modelID: string }
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// ─── TodoEnforcer ──────────────────────────────────────────────────────
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Re-inject a continuation prompt into idle sessions that still have
|
|
177
|
+
* incomplete todos. Per-session state: last injection time, the incomplete
|
|
178
|
+
* count at injection, the pending outcome check, and the consecutive-failure
|
|
179
|
+
* counter. All guards are best-effort — `onIdle` never throws, so the event
|
|
180
|
+
* hook can never break the session.
|
|
181
|
+
*/
|
|
182
|
+
export class TodoEnforcer {
|
|
183
|
+
private readonly client: TodoEnforcerClient
|
|
184
|
+
private readonly board: Pick<BackgroundJobBoard, 'list' | 'get'>
|
|
185
|
+
private readonly options: Required<TodoEnforcerOptions>
|
|
186
|
+
private readonly warn: (message: string) => void
|
|
187
|
+
|
|
188
|
+
/** Guard 2: sessions with an in-flight onIdle (reserved synchronously). */
|
|
189
|
+
private readonly inFlight = new Set<string>()
|
|
190
|
+
/** Last injection timestamp per session. */
|
|
191
|
+
private readonly lastInjectionAt = new Map<string, number>()
|
|
192
|
+
/** Incomplete-todo count recorded at the last injection per session. */
|
|
193
|
+
private readonly lastInjectedIncomplete = new Map<string, number>()
|
|
194
|
+
/** True when the last injection's outcome has NOT been evaluated yet. */
|
|
195
|
+
private readonly pendingCheck = new Map<string, boolean>()
|
|
196
|
+
/** Consecutive failures per session. */
|
|
197
|
+
private readonly failures = new Map<string, number>()
|
|
198
|
+
/** Last user-message timestamp per session (wired from chat.message). */
|
|
199
|
+
private readonly lastUserMessageAt = new Map<string, number>()
|
|
200
|
+
|
|
201
|
+
constructor(deps: TodoEnforcerDeps) {
|
|
202
|
+
this.client = deps.client
|
|
203
|
+
this.board = deps.board
|
|
204
|
+
const opts = deps.options ?? {}
|
|
205
|
+
this.options = {
|
|
206
|
+
enabled: opts.enabled ?? TODO_ENFORCER_DEFAULTS.enabled,
|
|
207
|
+
cooldownBaseMs: opts.cooldownBaseMs ?? TODO_ENFORCER_DEFAULTS.cooldownBaseMs,
|
|
208
|
+
maxConsecutiveFailures:
|
|
209
|
+
opts.maxConsecutiveFailures ?? TODO_ENFORCER_DEFAULTS.maxConsecutiveFailures,
|
|
210
|
+
failureResetMs: opts.failureResetMs ?? TODO_ENFORCER_DEFAULTS.failureResetMs,
|
|
211
|
+
userActivityQuietMs: opts.userActivityQuietMs ?? TODO_ENFORCER_DEFAULTS.userActivityQuietMs,
|
|
212
|
+
childActiveMs: opts.childActiveMs ?? TODO_ENFORCER_DEFAULTS.childActiveMs,
|
|
213
|
+
now: opts.now ?? Date.now,
|
|
214
|
+
}
|
|
215
|
+
this.warn = deps.logger?.warn ?? ((message: string) => log.warn(message))
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Record that the user just sent a message in `sessionID` (wired from the
|
|
220
|
+
* plugin `chat.message` hook). Suppresses injection for `userActivityQuietMs`
|
|
221
|
+
* — an interactive session is being driven by the user, don't nag it.
|
|
222
|
+
*/
|
|
223
|
+
noteUserActivity(sessionID: string): void {
|
|
224
|
+
this.lastUserMessageAt.set(sessionID, this.options.now())
|
|
225
|
+
}
|
|
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
|
+
|
|
246
|
+
/**
|
|
247
|
+
* Handle a `session.idle` event for a non-board session. Evaluates the
|
|
248
|
+
* previous injection's outcome, applies the guards, and injects the
|
|
249
|
+
* continuation prompt when incomplete todos remain and the guards allow.
|
|
250
|
+
* Never throws — internal failures are logged and swallowed.
|
|
251
|
+
*/
|
|
252
|
+
async onIdle(sessionID: string): Promise<void> {
|
|
253
|
+
if (!this.options.enabled) return
|
|
254
|
+
if (this.inFlight.has(sessionID)) return
|
|
255
|
+
this.inFlight.add(sessionID)
|
|
256
|
+
try {
|
|
257
|
+
await this.handleIdle(sessionID)
|
|
258
|
+
} catch (err: unknown) {
|
|
259
|
+
const reason = err instanceof Error ? err.message : String(err)
|
|
260
|
+
this.warn(`todo injection failed for session ${sessionID}: ${reason}`)
|
|
261
|
+
} finally {
|
|
262
|
+
this.inFlight.delete(sessionID)
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
private async handleIdle(sessionID: string): Promise<void> {
|
|
267
|
+
const now = this.options.now()
|
|
268
|
+
|
|
269
|
+
// Guard 1: user activity — a fresh user message means the session is being
|
|
270
|
+
// driven interactively; don't nag it.
|
|
271
|
+
const lastUser = this.lastUserMessageAt.get(sessionID)
|
|
272
|
+
if (lastUser !== undefined && now - lastUser < this.options.userActivityQuietMs) return
|
|
273
|
+
|
|
274
|
+
// Guard 2: a session with a running board job is busy — skip.
|
|
275
|
+
if (this.board.list(sessionID).some((job) => job.state === 'running')) return
|
|
276
|
+
|
|
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.
|
|
281
|
+
if ((await this.activeChildren(sessionID, now)) > 0) return
|
|
282
|
+
|
|
283
|
+
const todos = await this.client.session.todo({ path: { id: sessionID } })
|
|
284
|
+
const incomplete = todos.filter(
|
|
285
|
+
(todo) => todo.status !== 'completed' && todo.status !== 'cancelled',
|
|
286
|
+
).length
|
|
287
|
+
|
|
288
|
+
// DONE detection: nothing left to enforce. Drop any pending outcome check.
|
|
289
|
+
if (incomplete === 0) {
|
|
290
|
+
this.pendingCheck.delete(sessionID)
|
|
291
|
+
return
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// Evaluate the previous injection: same-or-worse count → failure; making
|
|
295
|
+
// progress breaks the streak. Only one evaluation per injection.
|
|
296
|
+
if (this.pendingCheck.get(sessionID) === true) {
|
|
297
|
+
const before = this.lastInjectedIncomplete.get(sessionID)
|
|
298
|
+
if (before !== undefined && incomplete >= before) {
|
|
299
|
+
this.failures.set(sessionID, (this.failures.get(sessionID) ?? 0) + 1)
|
|
300
|
+
} else {
|
|
301
|
+
this.failures.set(sessionID, 0)
|
|
302
|
+
}
|
|
303
|
+
this.pendingCheck.delete(sessionID)
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// Quiet-period reset: a long silence since the last injection clears the
|
|
307
|
+
// failure counter (prevents permanent lock-out).
|
|
308
|
+
const lastInjection = this.lastInjectionAt.get(sessionID)
|
|
309
|
+
if (
|
|
310
|
+
lastInjection !== undefined &&
|
|
311
|
+
(this.failures.get(sessionID) ?? 0) > 0 &&
|
|
312
|
+
now - lastInjection >= this.options.failureResetMs
|
|
313
|
+
) {
|
|
314
|
+
this.failures.set(sessionID, 0)
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
const failures = this.failures.get(sessionID) ?? 0
|
|
318
|
+
|
|
319
|
+
// Guard 4: consecutive-failure cap reached → stop injecting.
|
|
320
|
+
if (failures >= this.options.maxConsecutiveFailures) return
|
|
321
|
+
|
|
322
|
+
// Guard 3: exponential cooldown since the last injection.
|
|
323
|
+
if (lastInjection !== undefined && now - lastInjection < this.cooldownMs(failures)) {
|
|
324
|
+
return
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
// Inject. The attempt timestamp is recorded BEFORE the call so a failed
|
|
328
|
+
// (throwing) injection still applies the cooldown — no hot retry loop.
|
|
329
|
+
this.lastInjectionAt.set(sessionID, now)
|
|
330
|
+
const inherited = await this.inheritAgentOrModel(sessionID)
|
|
331
|
+
await this.client.session.promptAsync({
|
|
332
|
+
path: { id: sessionID },
|
|
333
|
+
body: {
|
|
334
|
+
...(inherited.agent !== undefined ? { agent: inherited.agent } : {}),
|
|
335
|
+
...(inherited.model !== undefined ? { model: inherited.model } : {}),
|
|
336
|
+
parts: [{ type: 'text', text: TODO_CONTINUATION_PROMPT }],
|
|
337
|
+
},
|
|
338
|
+
})
|
|
339
|
+
this.lastInjectedIncomplete.set(sessionID, incomplete)
|
|
340
|
+
this.pendingCheck.set(sessionID, true)
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/** Cooldown for a given failure count: base * 2^min(failures, cap). */
|
|
344
|
+
private cooldownMs(failures: number): number {
|
|
345
|
+
return (
|
|
346
|
+
this.options.cooldownBaseMs * 2 ** Math.min(failures, this.options.maxConsecutiveFailures)
|
|
347
|
+
)
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/**
|
|
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.
|
|
369
|
+
*/
|
|
370
|
+
private async activeChildren(sessionID: string, now: number): Promise<number> {
|
|
371
|
+
try {
|
|
372
|
+
const children = await this.client.session.children({ path: { id: sessionID } })
|
|
373
|
+
return children.filter((child) => this.isChildActive(child, now)).length
|
|
374
|
+
} catch (err: unknown) {
|
|
375
|
+
const reason = err instanceof Error ? err.message : String(err)
|
|
376
|
+
this.warn(`todo children check failed for session ${sessionID}: ${reason}`)
|
|
377
|
+
return 0
|
|
378
|
+
}
|
|
379
|
+
}
|
|
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
|
+
|
|
394
|
+
/**
|
|
395
|
+
* Inherit the agent (or model) from the LAST assistant message so the
|
|
396
|
+
* continuation runs in the same context as the session's last turn. Returns
|
|
397
|
+
* an empty object when no assistant message carries usable context — the
|
|
398
|
+
* SDK then applies the session default.
|
|
399
|
+
*/
|
|
400
|
+
private async inheritAgentOrModel(sessionID: string): Promise<InheritedContext> {
|
|
401
|
+
const messages = await this.client.session.messages({ path: { id: sessionID } })
|
|
402
|
+
for (const msg of [...messages].reverse()) {
|
|
403
|
+
const info = msg.info
|
|
404
|
+
if (info === undefined || info.role !== 'assistant') continue
|
|
405
|
+
if (typeof info.agent === 'string' && info.agent !== '') {
|
|
406
|
+
return { agent: info.agent }
|
|
407
|
+
}
|
|
408
|
+
const providerID = info.model?.providerID ?? info.providerID
|
|
409
|
+
const modelID = info.model?.modelID ?? info.modelID
|
|
410
|
+
if (
|
|
411
|
+
typeof providerID === 'string' &&
|
|
412
|
+
providerID !== '' &&
|
|
413
|
+
typeof modelID === 'string' &&
|
|
414
|
+
modelID !== ''
|
|
415
|
+
) {
|
|
416
|
+
return { model: { providerID, modelID } }
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
return {}
|
|
420
|
+
}
|
|
421
|
+
}
|