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.
Files changed (57) hide show
  1. package/README.md +154 -1
  2. package/bin/pantheon-init.mjs +160 -120
  3. package/opencode.json +0 -1
  4. package/package.json +10 -5
  5. package/scripts/cost.mjs +69 -0
  6. package/scripts/doctor.mjs +56 -22
  7. package/scripts/hooks/on-subagent-delegation-stop.sh +22 -5
  8. package/scripts/install/opencode.mjs +132 -32
  9. package/scripts/install/plugin.mjs +112 -28
  10. package/scripts/install-mcp.mjs +1 -1
  11. package/scripts/redaction-gate.mjs +69 -0
  12. package/scripts/release-notes.mjs +317 -0
  13. package/scripts/test-context-compression.sh +5 -2
  14. package/scripts/validate-package.mjs +56 -0
  15. package/scripts/versioning.mjs +117 -18
  16. package/src/agents/zeus.md +80 -0
  17. package/src/pantheon/background-job-board.ts +115 -40
  18. package/src/pantheon/chat-reminders.ts +79 -0
  19. package/src/pantheon/compaction-assert.ts +160 -0
  20. package/src/pantheon/cost-command.ts +208 -0
  21. package/src/pantheon/cost-tracker.ts +108 -0
  22. package/src/pantheon/delegation-compaction.ts +237 -0
  23. package/src/pantheon/delegation-enforce.ts +144 -0
  24. package/src/pantheon/delegation-finalize.ts +277 -0
  25. package/src/pantheon/delegation-notify.ts +100 -0
  26. package/src/pantheon/delegation.ts +690 -0
  27. package/src/pantheon/dispatch-guard.ts +107 -0
  28. package/src/pantheon/file-persistence.ts +10 -6
  29. package/src/pantheon/goal-loop.ts +300 -0
  30. package/src/pantheon/goal-store.ts +97 -0
  31. package/src/pantheon/hashline/core.ts +91 -0
  32. package/src/pantheon/hashline/read-enhancer.ts +66 -0
  33. package/src/pantheon/hashline/tool.ts +260 -0
  34. package/src/pantheon/hashline/xxhash.ts +53 -0
  35. package/src/pantheon/idle-continuation.ts +54 -0
  36. package/src/pantheon/logger.ts +117 -0
  37. package/src/pantheon/plugin-once.ts +76 -0
  38. package/src/pantheon/preemptive-compact.ts +76 -0
  39. package/src/pantheon/presets.d.mts +31 -0
  40. package/src/pantheon/presets.mjs +105 -3
  41. package/src/pantheon/todo-enforcer.ts +421 -0
  42. package/src/pantheon/todo-preserve.ts +295 -0
  43. package/src/plugin.ts +406 -13
  44. package/src/plugins/pantheon-hooks.ts +52 -30
  45. package/src/plugins/tui/dist/server.d.ts +17 -0
  46. package/src/plugins/tui/dist/server.d.ts.map +1 -0
  47. package/src/plugins/tui/dist/server.js +20 -0
  48. package/src/plugins/tui/dist/server.js.map +1 -0
  49. package/src/plugins/tui/dist/tui.d.ts +303 -1
  50. package/src/plugins/tui/dist/tui.d.ts.map +1 -1
  51. package/src/plugins/tui/dist/tui.js +977 -350
  52. package/src/plugins/tui/dist/tui.tsx +1427 -150
  53. package/src/plugins/tui/package.json +4 -3
  54. package/src/plugins/tui/src/index.tsx +1427 -150
  55. package/src/plugins/tui/src/server.ts +15 -0
  56. package/src/plugins/tui/tsdown.config.ts +1 -1
  57. package/src/routing.yml +35 -2
@@ -210,6 +210,49 @@ export function loadPresetDefs(routingPath) {
210
210
  return (routing && typeof routing === 'object' && routing.presets) || {}
211
211
  }
212
212
 
213
+ /**
214
+ * Load the DEFAULT agent → model mapping from routing.yml for the delegation
215
+ * toolset (Fase 6 — wiring agentModels).
216
+ *
217
+ * routing.yml keeps per-agent models ONLY inside presets
218
+ * (`presets.<name>.agents.<agent>.model`) — the top-level `agents:` section
219
+ * carries no model field. The FIRST-listed preset is the static default
220
+ * (go-deepseek today): a mapping INDEPENDENT of the active preset, so a
221
+ * delegated child always gets a sane per-agent model even when no preset is
222
+ * active — delegation no longer depends 100% on the active preset (branch (b)
223
+ * of resolveChildModel, which outranks the active-preset branch (c)).
224
+ *
225
+ * Fail-open: a missing or unparseable routing.yml yields {} (the caller keeps
226
+ * the previous behavior — active preset / opencode default) and warns via the
227
+ * optional logger; it NEVER throws at startup.
228
+ *
229
+ * @param {object} [opts]
230
+ * @param {string} [opts.routingPath]
231
+ * @param {{warn?: Function}} [opts.logger]
232
+ * @returns {Record<string, string>} lowercase agent → "provider/model"
233
+ */
234
+ export function loadRoutingAgentModels({ routingPath, logger = console } = {}) {
235
+ let defs
236
+ try {
237
+ defs = loadPresetDefs(routingPath)
238
+ } catch (err) {
239
+ const reason = err instanceof Error ? err.message : String(err)
240
+ logger.warn?.(
241
+ `presets: routing.yml agent models unavailable (${reason}) — delegation falls back to the active preset/default`,
242
+ )
243
+ return {}
244
+ }
245
+ const names = Object.keys(defs)
246
+ if (names.length === 0) return {}
247
+ const models = {}
248
+ for (const [agent, spec] of Object.entries(defs[names[0]]?.agents ?? {})) {
249
+ if (spec && typeof spec === 'object' && typeof spec.model === 'string' && spec.model !== '') {
250
+ models[agent.toLowerCase()] = spec.model
251
+ }
252
+ }
253
+ return models
254
+ }
255
+
213
256
  /**
214
257
  * Resolve the active preset: env PANTHEON_MODEL_PRESET > first existing
215
258
  * candidate file > null. "none" disables. Unknown names warn and return null
@@ -326,6 +369,66 @@ function buildResolved(defs, name, source, rawFile) {
326
369
  return { name, source, agents, providers, overrides, vision }
327
370
  }
328
371
 
372
+ /**
373
+ * Whether a single provider DEF has its API key configured: no apiKeyEnv
374
+ * (native provider, no external key gate) → always configured; otherwise the
375
+ * env var must be set and non-empty. Shared by applyPreset (startup config
376
+ * path) and providerKeyConfigured / missingProviderKeyEnv (delegate dispatch).
377
+ *
378
+ * @param {object|null|undefined} provider provider def ({baseURL, apiKeyEnv})
379
+ * @param {Record<string, string|undefined>} env
380
+ * @returns {boolean}
381
+ */
382
+ function providerDefKeyConfigured(provider, env) {
383
+ if (!provider || typeof provider !== 'object') return true
384
+ const envVar = provider.apiKeyEnv
385
+ if (typeof envVar !== 'string' || envVar === '') return true
386
+ const key = env[envVar]
387
+ return typeof key === 'string' && key.trim() !== ''
388
+ }
389
+
390
+ /**
391
+ * Name of the env var whose value is missing for a provider, or undefined
392
+ * when the provider is usable. A provider is usable when it declares no
393
+ * apiKeyEnv (native providers like opencode Zen) or its apiKeyEnv env var is
394
+ * set and non-empty. Provider defs come from routing.yml preset definitions —
395
+ * the SAME source applyPreset enforces at startup, so the delegate dispatch
396
+ * path and the config path can never disagree.
397
+ *
398
+ * @param {string} providerID e.g. 'openai' | 'opencode' | 'opencode-go'
399
+ * @param {object} [opts]
400
+ * @param {Record<string, string|undefined>} [opts.env]
401
+ * @param {string} [opts.routingPath]
402
+ * @returns {string|undefined} missing env var name, or undefined when usable
403
+ */
404
+ export function missingProviderKeyEnv(providerID, { env = process.env, routingPath } = {}) {
405
+ const defs = loadPresetDefs(routingPath)
406
+ for (const def of Object.values(defs)) {
407
+ const provider = def?.providers?.[providerID]
408
+ if (provider === undefined) continue
409
+ if (providerDefKeyConfigured(provider, env)) return undefined
410
+ return provider.apiKeyEnv
411
+ }
412
+ // Provider not declared in any preset → no external key gate (opencode
413
+ // manages its own auth) → usable.
414
+ return undefined
415
+ }
416
+
417
+ /**
418
+ * Whether the given provider's API key is configured (see
419
+ * missingProviderKeyEnv). Convenience boolean for callers that only need the
420
+ * yes/no answer.
421
+ *
422
+ * @param {string} providerID
423
+ * @param {object} [opts]
424
+ * @param {Record<string, string|undefined>} [opts.env]
425
+ * @param {string} [opts.routingPath]
426
+ * @returns {boolean}
427
+ */
428
+ export function providerKeyConfigured(providerID, opts) {
429
+ return missingProviderKeyEnv(providerID, opts) === undefined
430
+ }
431
+
329
432
  /**
330
433
  * Apply a resolved preset to an opencode config object (mutates `config`).
331
434
  * - Providers: config.provider[id].options.baseURL + apiKey (env-gated).
@@ -339,8 +442,7 @@ export function applyPreset(config, resolved, { env = process.env } = {}) {
339
442
  if (!resolved) return config
340
443
 
341
444
  for (const [id, provider] of Object.entries(resolved.providers ?? {})) {
342
- const apiKey = env[provider.apiKeyEnv]
343
- if (!apiKey) {
445
+ if (!providerDefKeyConfigured(provider, env)) {
344
446
  const err = new Error(`presets: provider "${id}" requires env var ${provider.apiKeyEnv}`)
345
447
  err.code = 'PANTHEON_MISSING_API_KEY'
346
448
  err.envVar = provider.apiKeyEnv
@@ -350,7 +452,7 @@ export function applyPreset(config, resolved, { env = process.env } = {}) {
350
452
  config.provider[id] ??= {}
351
453
  config.provider[id].options ??= {}
352
454
  config.provider[id].options.baseURL = provider.baseURL
353
- config.provider[id].options.apiKey = apiKey
455
+ config.provider[id].options.apiKey = env[provider.apiKeyEnv]
354
456
  }
355
457
 
356
458
  for (const [agent, spec] of Object.entries(resolved.agents ?? {})) {
@@ -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
+ }