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.
Files changed (52) hide show
  1. package/AGENTS.md +919 -0
  2. package/README.md +157 -15
  3. package/bin/pantheon-init.mjs +177 -120
  4. package/opencode.json +3 -6
  5. package/package.json +8 -4
  6. package/scripts/build-agents-md.mjs +195 -0
  7. package/scripts/cost.mjs +17 -0
  8. package/scripts/doctor.mjs +56 -22
  9. package/scripts/hooks/on-subagent-delegation-stop.sh +22 -5
  10. package/scripts/install/opencode.mjs +189 -38
  11. package/scripts/install/plugin.mjs +112 -28
  12. package/scripts/install-mcp.mjs +1 -1
  13. package/scripts/redaction-gate.mjs +69 -0
  14. package/scripts/release-beta-version.mjs +80 -0
  15. package/scripts/release-notes.mjs +317 -0
  16. package/scripts/test-context-compression.sh +5 -2
  17. package/scripts/validate-package.mjs +56 -0
  18. package/scripts/versioning.mjs +117 -18
  19. package/src/agents/zeus.md +6 -2
  20. package/src/pantheon/background-job-board.ts +24 -38
  21. package/src/pantheon/chat-reminders.ts +79 -0
  22. package/src/pantheon/compaction-assert.ts +160 -0
  23. package/src/pantheon/cost-command.ts +57 -18
  24. package/src/pantheon/delegation-compaction.ts +143 -16
  25. package/src/pantheon/delegation-finalize.ts +8 -1
  26. package/src/pantheon/delegation-notify.ts +19 -157
  27. package/src/pantheon/delegation.ts +330 -28
  28. package/src/pantheon/dispatch-guard.ts +9 -4
  29. package/src/pantheon/goal-loop.ts +7 -4
  30. package/src/pantheon/hashline/tool.ts +2 -1
  31. package/src/pantheon/logger.ts +117 -0
  32. package/src/pantheon/plugin-once.ts +76 -0
  33. package/src/pantheon/preemptive-compact.ts +76 -0
  34. package/src/pantheon/presets.d.mts +31 -0
  35. package/src/pantheon/presets.mjs +105 -3
  36. package/src/pantheon/todo-enforcer.ts +79 -27
  37. package/src/pantheon/todo-preserve.ts +295 -0
  38. package/src/plugin.ts +177 -50
  39. package/src/plugins/pantheon-hooks.ts +35 -22
  40. package/src/plugins/tui/dist/server.d.ts +17 -0
  41. package/src/plugins/tui/dist/server.d.ts.map +1 -0
  42. package/src/plugins/tui/dist/server.js +20 -0
  43. package/src/plugins/tui/dist/server.js.map +1 -0
  44. package/src/plugins/tui/dist/tui.d.ts +303 -1
  45. package/src/plugins/tui/dist/tui.d.ts.map +1 -1
  46. package/src/plugins/tui/dist/tui.js +977 -350
  47. package/src/plugins/tui/dist/tui.tsx +1427 -150
  48. package/src/plugins/tui/package.json +4 -3
  49. package/src/plugins/tui/src/index.tsx +1427 -150
  50. package/src/plugins/tui/src/server.ts +15 -0
  51. package/src/plugins/tui/tsdown.config.ts +1 -1
  52. 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
@@ -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 ?? {})) {
@@ -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. native-children — opencode `task(background=true)` children are NOT on
17
- * our board, so `session.children()` is checked: an active child (its
18
- * `time.updated` newer than `childActiveMs`) means the parent is still
19
- * waiting on a native background task → skip. Throws fail open (log +
20
- * inject) so version-sensitive APIs never break the enforcer;
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 only). */
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: native background task() children are NOT on our board — an
249
- // active child means the parent is still waiting on it, so skip.
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 native child sessions. opencode `task(background=true)`
321
- * children are NOT registered on our board, so the only way to see them is
322
- * `session.children()`. A child is active while its `time.updated` is newer
323
- * than `childActiveMs` — a running background task keeps updating its
324
- * session while streaming; a completed one freezes. Fail-open: when the
325
- * children API is unavailable (older opencode / permission), log and return
326
- * 0 so the enforcer still works on version-sensitive runtimes.
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