pantheon-opencode 1.3.3 → 1.3.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/README.md +115 -12
  2. package/bin/pantheon-init.mjs +160 -120
  3. package/opencode.json +0 -4
  4. package/package.json +7 -4
  5. package/scripts/doctor.mjs +56 -22
  6. package/scripts/hooks/on-subagent-delegation-stop.sh +22 -5
  7. package/scripts/install/opencode.mjs +108 -26
  8. package/scripts/install/plugin.mjs +112 -28
  9. package/scripts/install-mcp.mjs +1 -1
  10. package/scripts/redaction-gate.mjs +69 -0
  11. package/scripts/release-notes.mjs +317 -0
  12. package/scripts/test-context-compression.sh +5 -2
  13. package/scripts/validate-package.mjs +56 -0
  14. package/scripts/versioning.mjs +117 -18
  15. package/src/agents/zeus.md +6 -2
  16. package/src/pantheon/background-job-board.ts +24 -38
  17. package/src/pantheon/chat-reminders.ts +79 -0
  18. package/src/pantheon/compaction-assert.ts +160 -0
  19. package/src/pantheon/delegation-compaction.ts +143 -16
  20. package/src/pantheon/delegation-finalize.ts +8 -1
  21. package/src/pantheon/delegation-notify.ts +19 -157
  22. package/src/pantheon/delegation.ts +330 -28
  23. package/src/pantheon/dispatch-guard.ts +9 -4
  24. package/src/pantheon/goal-loop.ts +7 -4
  25. package/src/pantheon/hashline/tool.ts +2 -1
  26. package/src/pantheon/logger.ts +117 -0
  27. package/src/pantheon/plugin-once.ts +76 -0
  28. package/src/pantheon/preemptive-compact.ts +76 -0
  29. package/src/pantheon/presets.d.mts +31 -0
  30. package/src/pantheon/presets.mjs +105 -3
  31. package/src/pantheon/todo-enforcer.ts +79 -27
  32. package/src/pantheon/todo-preserve.ts +295 -0
  33. package/src/plugin.ts +177 -50
  34. package/src/plugins/pantheon-hooks.ts +35 -22
  35. package/src/plugins/tui/dist/server.d.ts +17 -0
  36. package/src/plugins/tui/dist/server.d.ts.map +1 -0
  37. package/src/plugins/tui/dist/server.js +20 -0
  38. package/src/plugins/tui/dist/server.js.map +1 -0
  39. package/src/plugins/tui/dist/tui.d.ts +303 -1
  40. package/src/plugins/tui/dist/tui.d.ts.map +1 -1
  41. package/src/plugins/tui/dist/tui.js +977 -350
  42. package/src/plugins/tui/dist/tui.tsx +1427 -150
  43. package/src/plugins/tui/package.json +4 -3
  44. package/src/plugins/tui/src/index.tsx +1427 -150
  45. package/src/plugins/tui/src/server.ts +15 -0
  46. package/src/plugins/tui/tsdown.config.ts +1 -1
  47. package/src/routing.yml +3 -1
@@ -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
@@ -0,0 +1,295 @@
1
+ /**
2
+ * Todo Preserver (release-134 Phase 3) — post-compaction todo restore.
3
+ *
4
+ * WHY: opencode 1.18.11 exposes NO todo write API (SessionTodoData.body?:
5
+ * never — the `/session/{id}/todo` route is GET-only), so a session's todo
6
+ * list cannot be restored by a direct SDK call after compaction. The model's
7
+ * `todowrite` tool performs a FULL replace server-side (todos.update
8
+ * delete+insert), so a model that re-emits a partial list right after
9
+ * compaction silently clobbers the pre-compaction list.
10
+ *
11
+ * HOW: three phases, all additive to the existing plugin hooks:
12
+ * 1. CAPTURE — `experimental.session.compacting` calls `capture(sessionID)`
13
+ * (a GET of the todo list) alongside the existing context build.
14
+ * 2. RESTORE — the `event` hook routes `session.compacted` to
15
+ * `onCompacted(sessionID)`, activating the pending snapshot. The FIRST
16
+ * `todowrite` of that session within `restoreWindowMs` has its
17
+ * `output.args.todos` rewritten with the EXACT captured list — no model
18
+ * cooperation, zero transcript noise. Subsequent writes inside the
19
+ * window are denied with a clear "retry in a moment" message so the
20
+ * restored list stays stable.
21
+ * 3. GUARD — `beforeTodoWrite` chains with the read-only enforcement guard
22
+ * on `tool.execute.before` (enforcement first, restore second).
23
+ *
24
+ * FAIL-OPEN: every step is wrapped — a capture GET failure, a missing event,
25
+ * a malformed hook output, or an expired snapshot degrades to a logged warn
26
+ * and pass-through. The ONLY deliberate throw is the restore-window denial
27
+ * (TodoRestoreInProgressError), and only for the exact session being
28
+ * restored.
29
+ *
30
+ * STATE: transient in-memory Map<sessionID, {todos, capturedAt, restored}>
31
+ * (no files, no KV). `snapshotTtlMs` (60s) bounds how old a snapshot may be;
32
+ * `restoreWindowMs` (5s) bounds interception after capture. An empty captured
33
+ * list stores nothing → no restore, zero noise. Snapshots are anchored on
34
+ * `capturedAt`: capture and the compacted event are adjacent (the session is
35
+ * paused mid-compaction), so one timestamp covers both the TTL and the
36
+ * window — a second timestamp would be YAGNI.
37
+ *
38
+ * VERSION-SENSITIVE WRITE HOOK: `tryWriteApi` attempts a direct todo write
39
+ * when an adapter is injected (options.tryWriteApi). SDK 1.18.11 ships no
40
+ * adapter (the route is GET-only) → restore goes through interception. If a
41
+ * future SDK exposes a write, inject an adapter built on a dynamic
42
+ * `await import('@opencode-ai/sdk')` probe and the preserver prefers it: a
43
+ * successful direct write drops the snapshot and skips interception entirely.
44
+ *
45
+ * Pure TypeScript — zero runtime deps beyond the structural client type.
46
+ *
47
+ * @module todo-preserve
48
+ */
49
+
50
+ import { createPantheonLogger } from './logger.ts'
51
+ import type { TodoLike } from './todo-enforcer.ts'
52
+
53
+ // Silence-by-default TUI policy (pantheon-hooks L42-58): console echo is
54
+ // opt-in via PANTHEON_HOOKS_LOG=1. `deps.logger` injection stays for tests.
55
+ const log = createPantheonLogger({ module: 'pantheon-todo-preserve' })
56
+
57
+ // ─── Constants ─────────────────────────────────────────────────────────
58
+
59
+ /** How long a captured snapshot stays valid (bounds staleness). */
60
+ export const SNAPSHOT_TTL_MS = 60_000
61
+
62
+ /** Interception window after capture: only the FIRST todowrite is restored. */
63
+ export const RESTORE_WINDOW_MS = 5_000
64
+
65
+ // ─── Types ─────────────────────────────────────────────────────────────
66
+
67
+ /**
68
+ * Structural client surface — the subset of the SDK the preserver uses
69
+ * (mirrors TodoEnforcerClient; only the GET is needed for capture).
70
+ */
71
+ export interface TodoPreserverClient {
72
+ session: {
73
+ todo(input: { path: { id: string } }): Promise<TodoLike[]>
74
+ }
75
+ }
76
+
77
+ /** Tunables (all optional — see the constants). */
78
+ export interface TodoPreserverOptions {
79
+ snapshotTtlMs?: number
80
+ restoreWindowMs?: number
81
+ /** Injectable clock (testable), defaults to `Date.now`. */
82
+ now?: () => number
83
+ /**
84
+ * Version-sensitive direct-write adapter. SDK 1.18.11 exposes no todo
85
+ * write (SessionTodoData.body?: never) — restore happens via
86
+ * `tool.execute.before` interception (beforeTodoWrite). If a future SDK
87
+ * adds a write endpoint, inject an adapter here (e.g. built on a dynamic
88
+ * `await import('@opencode-ai/sdk')` probe) and the preserver prefers the
89
+ * direct API restore: onCompacted calls this first and, when it resolves
90
+ * true, drops the snapshot so interception is skipped entirely. Absence
91
+ * or failure falls back to interception. Never throws (wrapped).
92
+ */
93
+ tryWriteApi?: (sessionID: string, todos: TodoLike[]) => Promise<boolean>
94
+ }
95
+
96
+ /** Dependencies threaded to the preserver. */
97
+ export interface TodoPreserverDeps {
98
+ client: TodoPreserverClient
99
+ options?: TodoPreserverOptions
100
+ logger?: { warn: (message: string) => void }
101
+ }
102
+
103
+ /** Input the opencode `tool.execute.before` hook passes. */
104
+ export interface TodoWriteBeforeInput {
105
+ tool: string
106
+ sessionID: string
107
+ callID: string
108
+ }
109
+
110
+ /** The `tool.execute.before` output — args mutated to restore the list. */
111
+ export interface TodoWriteBeforeOutput {
112
+ args?: { todos?: unknown }
113
+ }
114
+
115
+ /** A captured (full) todo list awaiting post-compaction restore. */
116
+ interface SnapshotEntry {
117
+ todos: TodoLike[]
118
+ /** Epoch ms of the capture — anchor for both the TTL and the window. */
119
+ capturedAt: number
120
+ /** True once the FIRST todowrite was intercepted (rewrite done). */
121
+ restored: boolean
122
+ }
123
+
124
+ /**
125
+ * Thrown to deny a `todowrite` while the post-compaction restore is settling
126
+ * (the list was just rewritten; a competing model write would clobber it).
127
+ */
128
+ export class TodoRestoreInProgressError extends Error {
129
+ constructor(sessionID: string) {
130
+ super(`todo list being restored after compaction — retry in a moment (session ${sessionID})`)
131
+ this.name = 'TodoRestoreInProgressError'
132
+ }
133
+ }
134
+
135
+ /** Resolved tunables — tryWriteApi stays `| undefined` (optional adapter). */
136
+ interface ResolvedTodoPreserverOptions {
137
+ snapshotTtlMs: number
138
+ restoreWindowMs: number
139
+ now: () => number
140
+ tryWriteApi: TodoPreserverOptions['tryWriteApi']
141
+ }
142
+
143
+ // ─── TodoPreserver ─────────────────────────────────────────────────────
144
+
145
+ /**
146
+ * Preserve a session's todo list across compaction by intercepting the first
147
+ * post-compaction `todowrite`. All public methods are best-effort — the
148
+ * hooks can never break the session; failures are logged and swallowed.
149
+ */
150
+ export class TodoPreserver {
151
+ private readonly client: TodoPreserverClient
152
+ private readonly options: ResolvedTodoPreserverOptions
153
+ private readonly warn: (message: string) => void
154
+ private readonly snapshots = new Map<string, SnapshotEntry>()
155
+
156
+ constructor(deps: TodoPreserverDeps) {
157
+ this.client = deps.client
158
+ const opts = deps.options ?? {}
159
+ this.options = {
160
+ snapshotTtlMs: opts.snapshotTtlMs ?? SNAPSHOT_TTL_MS,
161
+ restoreWindowMs: opts.restoreWindowMs ?? RESTORE_WINDOW_MS,
162
+ now: opts.now ?? Date.now,
163
+ tryWriteApi: opts.tryWriteApi,
164
+ }
165
+ this.warn = deps.logger?.warn ?? ((message: string) => log.warn(message))
166
+ }
167
+
168
+ /**
169
+ * CAPTURE (wired from `experimental.session.compacting`): GET the session's
170
+ * full todo list and store it as the restore snapshot. An empty list
171
+ * stores nothing (zero noise). Never throws — a GET failure is logged and
172
+ * the session compacts normally.
173
+ */
174
+ async capture(sessionID: string): Promise<void> {
175
+ this.pruneExpired()
176
+ try {
177
+ const todos = await this.client.session.todo({ path: { id: sessionID } })
178
+ if (todos.length === 0) {
179
+ // Nothing to preserve → drop any stale entry so no restore happens.
180
+ this.snapshots.delete(sessionID)
181
+ return
182
+ }
183
+ this.snapshots.set(sessionID, {
184
+ todos,
185
+ capturedAt: this.now(),
186
+ restored: false,
187
+ })
188
+ } catch (err: unknown) {
189
+ const reason = err instanceof Error ? err.message : String(err)
190
+ this.warn(`todo snapshot capture failed for session ${sessionID}: ${reason}`)
191
+ }
192
+ }
193
+
194
+ /**
195
+ * RESTORE activation (wired from the `event` hook on `session.compacted`):
196
+ * mark the captured snapshot as restore-pending. Without a fresh snapshot
197
+ * (no capture, empty list, or expired TTL) this is a no-op — fail-open.
198
+ * When a direct-write adapter is injected and succeeds, the snapshot is
199
+ * dropped so interception never runs. Never throws.
200
+ */
201
+ async onCompacted(sessionID: string): Promise<void> {
202
+ try {
203
+ const entry = this.snapshots.get(sessionID)
204
+ if (entry === undefined) return
205
+ if (this.expired(entry)) {
206
+ this.snapshots.delete(sessionID)
207
+ return
208
+ }
209
+ if (await this.tryWriteApi(sessionID, entry.todos)) {
210
+ this.snapshots.delete(sessionID)
211
+ }
212
+ // Otherwise the entry stays pending: the first todowrite within the
213
+ // restore window is rewritten with the snapshot (beforeTodoWrite).
214
+ } catch (err: unknown) {
215
+ const reason = err instanceof Error ? err.message : String(err)
216
+ this.warn(`todo restore activation failed for session ${sessionID}: ${reason}`)
217
+ }
218
+ }
219
+
220
+ /**
221
+ * GUARD (wired on `tool.execute.before`, chained after the read-only
222
+ * enforcement guard): intercept `todowrite` calls for a session whose
223
+ * snapshot is pending. The FIRST call inside the restore window has
224
+ * `output.args.todos` rewritten with the exact captured list; subsequent
225
+ * calls inside the window throw TodoRestoreInProgressError; calls outside
226
+ * the window, for other sessions, or for other tools pass through
227
+ * untouched. Fail-open: the only throw is the intentional denial.
228
+ */
229
+ async beforeTodoWrite(input: TodoWriteBeforeInput, output: TodoWriteBeforeOutput): Promise<void> {
230
+ if (input.tool !== 'todowrite') return
231
+ const entry = this.snapshots.get(input.sessionID)
232
+ if (entry === undefined) return
233
+ try {
234
+ const now = this.now()
235
+ if (now - entry.capturedAt >= this.options.snapshotTtlMs) {
236
+ this.snapshots.delete(input.sessionID)
237
+ return
238
+ }
239
+ if (now - entry.capturedAt >= this.options.restoreWindowMs) {
240
+ this.snapshots.delete(input.sessionID)
241
+ return
242
+ }
243
+ if (entry.restored) {
244
+ throw new TodoRestoreInProgressError(input.sessionID)
245
+ }
246
+ if (output.args === undefined) {
247
+ this.warn(
248
+ `todo restore: hook output.args missing for session ${input.sessionID} — passing through`,
249
+ )
250
+ return
251
+ }
252
+ output.args.todos = entry.todos
253
+ entry.restored = true
254
+ } catch (err: unknown) {
255
+ // Rethrow ONLY the intentional denial; everything else is fail-open.
256
+ if (err instanceof TodoRestoreInProgressError) throw err
257
+ const reason = err instanceof Error ? err.message : String(err)
258
+ this.warn(`todo restore interception failed for session ${input.sessionID}: ${reason}`)
259
+ }
260
+ }
261
+
262
+ /**
263
+ * Version-sensitive direct-write attempt. Returns false (fast) when no
264
+ * adapter is injected — the SDK 1.18.11 reality — so restore proceeds via
265
+ * interception. With an adapter, a resolved true means the list is already
266
+ * restored server-side; a throw or false falls back to interception.
267
+ * Never throws.
268
+ */
269
+ async tryWriteApi(sessionID: string, todos: TodoLike[]): Promise<boolean> {
270
+ const write = this.options.tryWriteApi
271
+ if (write === undefined) return false
272
+ try {
273
+ return await write(sessionID, todos)
274
+ } catch (err: unknown) {
275
+ const reason = err instanceof Error ? err.message : String(err)
276
+ this.warn(`todo write API attempt failed for session ${sessionID}: ${reason}`)
277
+ return false
278
+ }
279
+ }
280
+
281
+ private now(): number {
282
+ return this.options.now()
283
+ }
284
+
285
+ private expired(entry: SnapshotEntry): boolean {
286
+ return this.now() - entry.capturedAt >= this.options.snapshotTtlMs
287
+ }
288
+
289
+ /** Drop expired snapshots (bounded by capture frequency — no timers). */
290
+ private pruneExpired(): void {
291
+ for (const [sessionID, entry] of this.snapshots) {
292
+ if (this.expired(entry)) this.snapshots.delete(sessionID)
293
+ }
294
+ }
295
+ }