pantheon-opencode 1.3.2 → 1.3.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +154 -1
- package/bin/pantheon-init.mjs +160 -120
- package/opencode.json +0 -1
- package/package.json +10 -5
- package/scripts/cost.mjs +69 -0
- package/scripts/doctor.mjs +56 -22
- package/scripts/hooks/on-subagent-delegation-stop.sh +22 -5
- package/scripts/install/opencode.mjs +132 -32
- package/scripts/install/plugin.mjs +112 -28
- package/scripts/install-mcp.mjs +1 -1
- package/scripts/redaction-gate.mjs +69 -0
- package/scripts/release-notes.mjs +317 -0
- package/scripts/test-context-compression.sh +5 -2
- package/scripts/validate-package.mjs +56 -0
- package/scripts/versioning.mjs +117 -18
- package/src/agents/zeus.md +80 -0
- package/src/pantheon/background-job-board.ts +115 -40
- package/src/pantheon/chat-reminders.ts +79 -0
- package/src/pantheon/compaction-assert.ts +160 -0
- package/src/pantheon/cost-command.ts +208 -0
- package/src/pantheon/cost-tracker.ts +108 -0
- package/src/pantheon/delegation-compaction.ts +237 -0
- package/src/pantheon/delegation-enforce.ts +144 -0
- package/src/pantheon/delegation-finalize.ts +277 -0
- package/src/pantheon/delegation-notify.ts +100 -0
- package/src/pantheon/delegation.ts +690 -0
- package/src/pantheon/dispatch-guard.ts +107 -0
- package/src/pantheon/file-persistence.ts +10 -6
- package/src/pantheon/goal-loop.ts +300 -0
- package/src/pantheon/goal-store.ts +97 -0
- package/src/pantheon/hashline/core.ts +91 -0
- package/src/pantheon/hashline/read-enhancer.ts +66 -0
- package/src/pantheon/hashline/tool.ts +260 -0
- package/src/pantheon/hashline/xxhash.ts +53 -0
- package/src/pantheon/idle-continuation.ts +54 -0
- package/src/pantheon/logger.ts +117 -0
- package/src/pantheon/plugin-once.ts +76 -0
- package/src/pantheon/preemptive-compact.ts +76 -0
- package/src/pantheon/presets.d.mts +31 -0
- package/src/pantheon/presets.mjs +105 -3
- package/src/pantheon/todo-enforcer.ts +421 -0
- package/src/pantheon/todo-preserve.ts +295 -0
- package/src/plugin.ts +406 -13
- package/src/plugins/pantheon-hooks.ts +52 -30
- package/src/plugins/tui/dist/server.d.ts +17 -0
- package/src/plugins/tui/dist/server.d.ts.map +1 -0
- package/src/plugins/tui/dist/server.js +20 -0
- package/src/plugins/tui/dist/server.js.map +1 -0
- package/src/plugins/tui/dist/tui.d.ts +303 -1
- package/src/plugins/tui/dist/tui.d.ts.map +1 -1
- package/src/plugins/tui/dist/tui.js +977 -350
- package/src/plugins/tui/dist/tui.tsx +1427 -150
- package/src/plugins/tui/package.json +4 -3
- package/src/plugins/tui/src/index.tsx +1427 -150
- package/src/plugins/tui/src/server.ts +15 -0
- package/src/plugins/tui/tsdown.config.ts +1 -1
- package/src/routing.yml +35 -2
|
@@ -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
|
+
}
|