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