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.
- package/AGENTS.md +919 -0
- package/README.md +157 -15
- package/bin/pantheon-init.mjs +177 -120
- package/opencode.json +3 -6
- package/package.json +8 -4
- package/scripts/build-agents-md.mjs +195 -0
- package/scripts/cost.mjs +17 -0
- package/scripts/doctor.mjs +56 -22
- package/scripts/hooks/on-subagent-delegation-stop.sh +22 -5
- package/scripts/install/opencode.mjs +189 -38
- package/scripts/install/plugin.mjs +112 -28
- package/scripts/install-mcp.mjs +1 -1
- package/scripts/redaction-gate.mjs +69 -0
- package/scripts/release-beta-version.mjs +80 -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/cost-command.ts +57 -18
- 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
|
@@ -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
|
+
}
|
package/src/plugin.ts
CHANGED
|
@@ -1,27 +1,52 @@
|
|
|
1
1
|
import type { Plugin, PluginInput } from '@opencode-ai/plugin'
|
|
2
2
|
import type { PluginConfig } from 'opencode'
|
|
3
3
|
import { BackgroundJobBoard } from './pantheon/background-job-board.ts'
|
|
4
|
+
import { reassertAfterCompaction } from './pantheon/compaction-assert.ts'
|
|
4
5
|
import { createCostCommand } from './pantheon/cost-command.ts'
|
|
5
|
-
import {
|
|
6
|
+
import {
|
|
7
|
+
collectRootSessionIDs,
|
|
8
|
+
createDelegationTools,
|
|
9
|
+
type DelegationClient,
|
|
10
|
+
} from './pantheon/delegation.ts'
|
|
6
11
|
import { buildCompactionContext } from './pantheon/delegation-compaction.ts'
|
|
7
12
|
import { createEnforcementGuard, readOnlyRegistry } from './pantheon/delegation-enforce.ts'
|
|
8
|
-
import {
|
|
13
|
+
import { handleDelegationEvent } from './pantheon/delegation-notify.ts'
|
|
9
14
|
import { FilePersistenceAdapter } from './pantheon/file-persistence.ts'
|
|
10
15
|
import { GOAL_LOOP_DEFAULTS, GoalLoop, GoalStore } from './pantheon/goal-loop.ts'
|
|
11
16
|
import { createReadEnhancer } from './pantheon/hashline/read-enhancer.ts'
|
|
12
17
|
import { createHashlineEditTool } from './pantheon/hashline/tool.ts'
|
|
13
18
|
import { createIdleDispatcher } from './pantheon/idle-continuation.ts'
|
|
14
|
-
import {
|
|
19
|
+
import { createPantheonLogger } from './pantheon/logger.ts'
|
|
20
|
+
import { pantheonPluginOnce } from './pantheon/plugin-once.ts'
|
|
21
|
+
import { applyActivePresetToConfig, loadRoutingAgentModels } from './pantheon/presets.mjs'
|
|
15
22
|
import {
|
|
16
23
|
TODO_ENFORCER_DEFAULTS,
|
|
17
24
|
TodoEnforcer,
|
|
18
25
|
type TodoEnforcerClient,
|
|
19
26
|
todoEnforcerEnabledFromEnv,
|
|
20
27
|
} from './pantheon/todo-enforcer.ts'
|
|
28
|
+
import { TodoPreserver } from './pantheon/todo-preserve.ts'
|
|
21
29
|
import { activePresetCandidates, createVisionHandler } from './pantheon/vision.ts'
|
|
22
30
|
|
|
23
31
|
// ─── Background Job Board Singleton ────────────────────────────────────
|
|
24
32
|
|
|
33
|
+
// Silence-by-default TUI policy (pantheon-hooks L42-58): console output in a
|
|
34
|
+
// plugin writes to the process stdout/stderr, which the opencode TUI renders
|
|
35
|
+
// directly into the terminal — the "lixo". Every line goes to
|
|
36
|
+
// .pantheon/logs/hooks.log; the console echo is opt-in via PANTHEON_HOOKS_LOG=1.
|
|
37
|
+
const log = createPantheonLogger({ module: 'pantheon-plugin' })
|
|
38
|
+
|
|
39
|
+
// Fase 6 (release-134): the delegation toolset's options.agentModels (branch
|
|
40
|
+
// (b) of resolveChildModel) is wired from routing.yml's default (first)
|
|
41
|
+
// preset — a STATIC per-agent model mapping independent of the active preset,
|
|
42
|
+
// so delegated children get a sane model even without an active preset (the
|
|
43
|
+
// previous production wiring left branch (b) dead: delegation depended 100%
|
|
44
|
+
// on the active preset). Built ONCE at module load, never per dispatch.
|
|
45
|
+
// Fail-open: a missing/corrupt routing.yml yields {} (warned by the helper)
|
|
46
|
+
// and delegation falls back to the active preset / opencode default — the
|
|
47
|
+
// plugin never throws at startup.
|
|
48
|
+
const routingAgentModels = loadRoutingAgentModels({ logger: log })
|
|
49
|
+
|
|
25
50
|
const board = new BackgroundJobBoard({
|
|
26
51
|
maxConcurrentPerAgent: 3,
|
|
27
52
|
signalDir: '.pantheon/deepwork/board-signals',
|
|
@@ -30,31 +55,22 @@ const persistence = new FilePersistenceAdapter('.pantheon/board/state.json')
|
|
|
30
55
|
board.setPersistence(persistence)
|
|
31
56
|
board
|
|
32
57
|
.recoverRunningJobs()
|
|
33
|
-
.catch((err) =>
|
|
34
|
-
// ─── Phase 3:
|
|
35
|
-
|
|
36
|
-
// The notifier is the completion channel: the spike refuted client push
|
|
37
|
-
// (noReply delivers nothing to the parent), so terminal-job notifications are
|
|
38
|
-
// QUEUED here and injected into the parent's next chat.message by the flush in
|
|
39
|
-
// the 'chat.message' hook below (graceful-degradation path). Gated by the same
|
|
40
|
-
// env as pantheon-hooks toasts: PANTHEON_TOASTS=off disables queueing.
|
|
41
|
-
const notifier = new DelegationNotifier()
|
|
42
|
-
const notificationsEnabled = (process.env.PANTHEON_TOASTS ?? '').trim().toLowerCase() !== 'off'
|
|
58
|
+
.catch((err) => log.error('[Pantheon Plugin] Failed to recover running jobs:', err))
|
|
59
|
+
// ─── Phase 3: Board terminal audit log ─────────────────────────────────
|
|
43
60
|
|
|
61
|
+
// The onTerminal listener is the completion AUDIT point: it writes a
|
|
62
|
+
// file-only log line (console echo opt-in via PANTHEON_HOOKS_LOG). There is
|
|
63
|
+
// deliberately NO chat delivery — the user policy is zero delegation
|
|
64
|
+
// notifications in the transcript. Completion visibility lives in the board
|
|
65
|
+
// `[unread]` marker (pantheon_delegation_list), pantheon_delegation_read,
|
|
66
|
+
// TUI toasts (pantheon-hooks, PANTHEON_TOASTS gate) and compaction
|
|
67
|
+
// carry-forward.
|
|
44
68
|
board.onTerminal((taskID: string) => {
|
|
45
69
|
const job = board.get(taskID)
|
|
46
70
|
if (!job) return
|
|
47
|
-
|
|
71
|
+
log.info(
|
|
48
72
|
`[Pantheon Plugin] Board terminal: [${job.alias}] ${job.description} → ${job.state}${job.resultSummary ? ` — ${job.resultSummary}` : ''}`,
|
|
49
73
|
)
|
|
50
|
-
// Queue the completion notification for the job's parent session. The
|
|
51
|
-
// onTerminal listener is the SINGLE notification point — the timeout path
|
|
52
|
-
// (timeout finalize → updateStatus) and the event path (session.idle →
|
|
53
|
-
// finalizeDelegation → updateStatus) both transition the board and fire
|
|
54
|
-
// here, so every terminal job (incl. timedOut) gets exactly one notification.
|
|
55
|
-
if (notificationsEnabled) {
|
|
56
|
-
notifier.notifyParent(job)
|
|
57
|
-
}
|
|
58
74
|
})
|
|
59
75
|
|
|
60
76
|
// Periodically prune terminal/reconciled jobs (24h TTL, every 30 min).
|
|
@@ -65,9 +81,7 @@ setInterval(
|
|
|
65
81
|
() => {
|
|
66
82
|
void board
|
|
67
83
|
.pruneExpired(86_400_000)
|
|
68
|
-
.catch((err: unknown) =>
|
|
69
|
-
console.error('[Pantheon Plugin] Background board prune failed:', err),
|
|
70
|
-
)
|
|
84
|
+
.catch((err: unknown) => log.error('[Pantheon Plugin] Background board prune failed:', err))
|
|
71
85
|
},
|
|
72
86
|
30 * 60 * 1000,
|
|
73
87
|
).unref()
|
|
@@ -86,11 +100,49 @@ const enforcementGuard = createEnforcementGuard({
|
|
|
86
100
|
const COMPACTION_MAX_ITEMS = 10
|
|
87
101
|
|
|
88
102
|
// Root-session registry for the delegation depth guard: sessions created
|
|
89
|
-
// without a parentID are roots. Populated from
|
|
90
|
-
// event hook
|
|
91
|
-
//
|
|
103
|
+
// without a parentID are roots. Populated from TWO sources:
|
|
104
|
+
// 1. the `session.created` event hook (live roots — fires for sessions
|
|
105
|
+
// created while the plugin runs);
|
|
106
|
+
// 2. a fail-open startup seed from client.session.list() in the config
|
|
107
|
+
// hook (compaction-134) — opencode does NOT replay session.created
|
|
108
|
+
// events for sessions that exist before plugin load, so without the
|
|
109
|
+
// seed a resumed root would never enter this set after a restart.
|
|
110
|
+
// delegation.ts additionally falls back to knownChildren ("any session WE
|
|
111
|
+
// created is a sub-session; unknown sessions are roots") so the depth guard
|
|
112
|
+
// works even when this set is incomplete.
|
|
92
113
|
const rootSessions = new Set<string>()
|
|
93
114
|
|
|
115
|
+
/**
|
|
116
|
+
* Seed resumed root sessions without making OpenCode startup depend on the
|
|
117
|
+
* session API responding. Some OpenCode environments do not have the API
|
|
118
|
+
* ready while plugin config hooks run; awaiting session.list() there leaves
|
|
119
|
+
* the TUI stuck before it can open. The delegation layer already treats an
|
|
120
|
+
* unknown session as a root, so this enrichment is deliberately best-effort.
|
|
121
|
+
*/
|
|
122
|
+
function seedRootSessionsInBackground(input: PluginInput): void {
|
|
123
|
+
// Fail-open: environments without the session API (client.session absent or
|
|
124
|
+
// not yet wired while config hooks run) must NOT break startup. `session`
|
|
125
|
+
// is captured and the method invoked on it so `this` stays bound; the
|
|
126
|
+
// delegation layer already treats unknown sessions as roots, so skipping
|
|
127
|
+
// the seed is always safe.
|
|
128
|
+
const session = input.client.session
|
|
129
|
+
if (typeof session?.list !== 'function') return
|
|
130
|
+
void session
|
|
131
|
+
.list()
|
|
132
|
+
.then((result) => {
|
|
133
|
+
if (!result.error && Array.isArray(result.data)) {
|
|
134
|
+
const seeded = collectRootSessionIDs(result.data)
|
|
135
|
+
if (seeded.size > 0) {
|
|
136
|
+
for (const id of seeded) rootSessions.add(id)
|
|
137
|
+
log.info(`[Pantheon Plugin] Seeded ${seeded.size} root session(s) from session.list`)
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
})
|
|
141
|
+
.catch((err) => {
|
|
142
|
+
log.warn('[Pantheon Plugin] Root session seed failed (fail-open):', err)
|
|
143
|
+
})
|
|
144
|
+
}
|
|
145
|
+
|
|
94
146
|
/** Extract the SDK error message ({name, data: {message}}) or a fallback. */
|
|
95
147
|
function sdkErrorMessage(error: { data?: { message?: string } } | null | undefined): string {
|
|
96
148
|
const message = error?.data?.message
|
|
@@ -198,6 +250,16 @@ function adaptTodoEnforcerClient(client: PluginInput['client']): TodoEnforcerCli
|
|
|
198
250
|
* Helpers live in src/pantheon/vision.ts and are imported from there directly.
|
|
199
251
|
*/
|
|
200
252
|
const plugin: Plugin = async (input: PluginInput) => {
|
|
253
|
+
// release-134: runtime guard against DOUBLE registration (duplicate toasts +
|
|
254
|
+
// duplicate task_ids in one process — 2026-08-12 live logs). opencode
|
|
255
|
+
// merges the global config (npm-package plugin paths) with the project
|
|
256
|
+
// config (repo source paths), so this module loads from TWO filesystem
|
|
257
|
+
// paths in the same process and the factory would otherwise run twice. The
|
|
258
|
+
// second invocation becomes a no-op: an empty hooks object (every Hooks
|
|
259
|
+
// field is optional) → hooks/tools register exactly once.
|
|
260
|
+
if (pantheonPluginOnce('pantheon:plugin')) {
|
|
261
|
+
return {}
|
|
262
|
+
}
|
|
201
263
|
const vision = createVisionHandler(input)
|
|
202
264
|
// Phase 2/3: background delegation toolset + the bound finalize lifecycle
|
|
203
265
|
// hook. Completion is observed through the event hook below
|
|
@@ -205,7 +267,11 @@ const plugin: Plugin = async (input: PluginInput) => {
|
|
|
205
267
|
const delegation = createDelegationTools({
|
|
206
268
|
board,
|
|
207
269
|
client: adaptDelegationClient(input.client),
|
|
208
|
-
options: {
|
|
270
|
+
options: {
|
|
271
|
+
rootSessions,
|
|
272
|
+
readOnlyAgents: new Set(['apollo', 'gaia']),
|
|
273
|
+
agentModels: routingAgentModels,
|
|
274
|
+
},
|
|
209
275
|
})
|
|
210
276
|
|
|
211
277
|
// Wave 1 (PR #46): TODO continuation enforcer for root/non-board sessions.
|
|
@@ -220,6 +286,15 @@ const plugin: Plugin = async (input: PluginInput) => {
|
|
|
220
286
|
options: { ...TODO_ENFORCER_DEFAULTS, enabled: todoEnforcerEnabledFromEnv() },
|
|
221
287
|
})
|
|
222
288
|
|
|
289
|
+
// release-134 Phase 3: post-compaction todo restore. Captures the session's
|
|
290
|
+
// todo list in 'experimental.session.compacting', activates the snapshot on
|
|
291
|
+
// 'session.compacted', and rewrites the first post-compaction `todowrite`
|
|
292
|
+
// with the exact list (todo-preserve.ts). Additive + fail-open: every step
|
|
293
|
+
// degrades to a logged warn, never throws in a hook.
|
|
294
|
+
const todoPreserver = new TodoPreserver({
|
|
295
|
+
client: adaptTodoEnforcerClient(input.client),
|
|
296
|
+
})
|
|
297
|
+
|
|
223
298
|
// Wave 3 (PR #46): full-auto goal loop — opt-in (`full_auto.enabled:
|
|
224
299
|
// false` default, mirrored by GOAL_LOOP_DEFAULTS). File-based GoalStore
|
|
225
300
|
// (`.pantheon/goals/<sessionID>.json` — the plugin layer cannot reach MCP
|
|
@@ -251,6 +326,15 @@ const plugin: Plugin = async (input: PluginInput) => {
|
|
|
251
326
|
|
|
252
327
|
return {
|
|
253
328
|
config: async (config: PluginConfig) => {
|
|
329
|
+
// compaction-134 seed: opencode does NOT replay `session.created`
|
|
330
|
+
// events for sessions that exist before plugin load, so after a
|
|
331
|
+
// restart the resumed ROOT session never enters rootSessions and the
|
|
332
|
+
// depth guard would reject its pantheon_delegate calls. Seed the
|
|
333
|
+
// registry from session.list() in the background — every session
|
|
334
|
+
// WITHOUT a parentID is a root. Fail-open: a failed/throttled list
|
|
335
|
+
// leaves the set untouched (delegation.ts treats unknown sessions as
|
|
336
|
+
// roots), and config setup must never wait for the session API.
|
|
337
|
+
seedRootSessionsInBackground(input)
|
|
254
338
|
config.agentsPath = config.agentsPath ?? []
|
|
255
339
|
config.agentsPath.push(new URL('./agents', import.meta.url).pathname)
|
|
256
340
|
config.skillsPaths = config.skillsPaths ?? []
|
|
@@ -269,9 +353,7 @@ const plugin: Plugin = async (input: PluginInput) => {
|
|
|
269
353
|
candidates: activePresetCandidates(),
|
|
270
354
|
})
|
|
271
355
|
if (resolved) {
|
|
272
|
-
|
|
273
|
-
`[Pantheon Plugin] Applied model preset: ${resolved.name} (${resolved.source})`,
|
|
274
|
-
)
|
|
356
|
+
log.info(`[Pantheon Plugin] Applied model preset: ${resolved.name} (${resolved.source})`)
|
|
275
357
|
}
|
|
276
358
|
} catch {
|
|
277
359
|
// Fully STATIC warning — the thrown error object is tainted because
|
|
@@ -284,7 +366,7 @@ const plugin: Plugin = async (input: PluginInput) => {
|
|
|
284
366
|
// emitted by applyActivePresetToConfig's own logger before it throws.
|
|
285
367
|
// The catch binding is intentionally omitted so no tainted value can
|
|
286
368
|
// ever reach this log line (CodeQL alert #11).
|
|
287
|
-
|
|
369
|
+
log.warn('[plugin] preset application failed (see logs for details)')
|
|
288
370
|
}
|
|
289
371
|
},
|
|
290
372
|
// Phase 2: background delegation tools (structural — matches the `tool`
|
|
@@ -303,10 +385,10 @@ const plugin: Plugin = async (input: PluginInput) => {
|
|
|
303
385
|
},
|
|
304
386
|
'chat.message': async (hookInput, output) => {
|
|
305
387
|
await vision.chatMessage(hookInput, output)
|
|
306
|
-
//
|
|
307
|
-
//
|
|
308
|
-
//
|
|
309
|
-
|
|
388
|
+
// No delegation notification delivery here — the user policy is ZERO
|
|
389
|
+
// notification text injected into the chat transcript (previously the
|
|
390
|
+
// notifier.flushQueue prepend). pantheon-hooks' chat-reminders.ts keeps
|
|
391
|
+
// its own <system-reminder> channel, untouched.
|
|
310
392
|
// Wave 1: a user message is activity — the todo enforcer's
|
|
311
393
|
// user-activity gate skips injection for userActivityQuietMs afterwards.
|
|
312
394
|
todoEnforcer.noteUserActivity(hookInput.sessionID)
|
|
@@ -318,9 +400,27 @@ const plugin: Plugin = async (input: PluginInput) => {
|
|
|
318
400
|
const info = ev.properties.info
|
|
319
401
|
if (info && info.parentID === undefined) rootSessions.add(info.id)
|
|
320
402
|
}
|
|
403
|
+
// release-134 Phase 3: activate the todo snapshot captured at
|
|
404
|
+
// compacting time — the first todowrite within the restore window is
|
|
405
|
+
// rewritten with the exact list (see todo-preserve.ts). Fail-open.
|
|
406
|
+
if (ev.type === 'session.compacted') {
|
|
407
|
+
await todoPreserver.onCompacted(ev.properties.sessionID)
|
|
408
|
+
// release-134 Phase 4: re-assert post-compaction state — enqueue a
|
|
409
|
+
// fresh-state reminder (running/unread board jobs + active goals) for
|
|
410
|
+
// the session's next chat.message delivery. Fail-open (never throws).
|
|
411
|
+
await reassertAfterCompaction({
|
|
412
|
+
sessionID: ev.properties.sessionID,
|
|
413
|
+
board,
|
|
414
|
+
goals: {
|
|
415
|
+
enabled: GOAL_LOOP_DEFAULTS.enabled,
|
|
416
|
+
list: (s: string) => goalStore.list(s),
|
|
417
|
+
},
|
|
418
|
+
})
|
|
419
|
+
}
|
|
321
420
|
// Phase 3: observe completion on child sessions → finalizeDelegation.
|
|
322
|
-
// The board transition fires onTerminal →
|
|
323
|
-
//
|
|
421
|
+
// The board transition fires onTerminal → the file-only audit log (no
|
|
422
|
+
// chat delivery — see the onTerminal listener above). Unknown sessions
|
|
423
|
+
// are a no-op.
|
|
324
424
|
try {
|
|
325
425
|
const delegated = await handleDelegationEvent(ev, {
|
|
326
426
|
board,
|
|
@@ -337,28 +437,55 @@ const plugin: Plugin = async (input: PluginInput) => {
|
|
|
337
437
|
}
|
|
338
438
|
} catch (err) {
|
|
339
439
|
// The event hook must never break the session.
|
|
340
|
-
|
|
440
|
+
log.error('[Pantheon Plugin] Delegation event handling failed:', err)
|
|
341
441
|
}
|
|
342
442
|
await vision.event({ event: ev })
|
|
343
443
|
},
|
|
344
444
|
// Phase 4: deny mutating tools in read-only delegated sessions. Additive
|
|
345
445
|
// key — does not touch Phase 3's tools/event/onTerminal/chat.message.
|
|
346
|
-
'tool.execute.before':
|
|
446
|
+
'tool.execute.before': async (input, output) => {
|
|
447
|
+
// Chain: the read-only enforcement guard runs first (denies mutating
|
|
448
|
+
// tools in read-only sessions), then release-134 Phase 3 rewrites the
|
|
449
|
+
// first post-compaction `todowrite` with the captured todo snapshot.
|
|
450
|
+
// Both are no-ops for non-matching sessions/tools; the preserver is
|
|
451
|
+
// fail-open (only its intentional restore denial throws).
|
|
452
|
+
await enforcementGuard(input)
|
|
453
|
+
await todoPreserver.beforeTodoWrite(input, output)
|
|
454
|
+
},
|
|
347
455
|
// Wave 2 (PR #46): augment `read` output with hashline tags. Additive —
|
|
348
456
|
// pantheon-hooks.ts (a separate plugin instance) owns its own
|
|
349
457
|
// tool.execute.after, so there is no key collision. Non-read tools pass
|
|
350
458
|
// through untouched.
|
|
351
459
|
'tool.execute.after': readEnhancer,
|
|
352
|
-
// Phase 4: keep
|
|
353
|
-
// compaction
|
|
354
|
-
//
|
|
460
|
+
// Phase 4 + release-134 Phase 2: keep the session's working state across
|
|
461
|
+
// compaction — preservation directive, active goals (<mission_context>),
|
|
462
|
+
// pending todos (<todo_context>), and in-flight background delegations
|
|
463
|
+
// (running + unread terminal ≤ max_compaction_items). Guarded: only
|
|
464
|
+
// pushes when there is something to preserve; a build failure must never
|
|
465
|
+
// break the experimental compaction hook.
|
|
355
466
|
'experimental.session.compacting': async (_input, output) => {
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
467
|
+
try {
|
|
468
|
+
const blocks = await buildCompactionContext(board, {
|
|
469
|
+
sessionID: _input.sessionID,
|
|
470
|
+
maxItems: COMPACTION_MAX_ITEMS,
|
|
471
|
+
goals: {
|
|
472
|
+
enabled: GOAL_LOOP_DEFAULTS.enabled,
|
|
473
|
+
list: (sessionID: string) => goalStore.list(sessionID),
|
|
474
|
+
},
|
|
475
|
+
todos: {
|
|
476
|
+
enabled: todoEnforcerEnabledFromEnv(),
|
|
477
|
+
list: (sessionID: string) => todoEnforcer.listPendingTodos(sessionID),
|
|
478
|
+
},
|
|
479
|
+
})
|
|
480
|
+
if (blocks.length > 0) {
|
|
481
|
+
output.context.push(...blocks)
|
|
482
|
+
}
|
|
483
|
+
// release-134 Phase 3 (additive): snapshot the session's full todo
|
|
484
|
+
// list for post-compaction restore. Best-effort — a GET failure is
|
|
485
|
+
// logged and the compaction proceeds normally.
|
|
486
|
+
await todoPreserver.capture(_input.sessionID)
|
|
487
|
+
} catch (err: unknown) {
|
|
488
|
+
log.warn('[Pantheon Plugin] Compaction context build failed:', err)
|
|
362
489
|
}
|
|
363
490
|
},
|
|
364
491
|
}
|