pantheon-opencode 1.3.2 → 1.3.3
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 +50 -0
- package/opencode.json +4 -1
- package/package.json +4 -2
- package/scripts/cost.mjs +69 -0
- package/scripts/install/opencode.mjs +27 -9
- package/src/agents/zeus.md +76 -0
- package/src/pantheon/background-job-board.ts +95 -6
- package/src/pantheon/cost-command.ts +208 -0
- package/src/pantheon/cost-tracker.ts +108 -0
- package/src/pantheon/delegation-compaction.ts +110 -0
- package/src/pantheon/delegation-enforce.ts +144 -0
- package/src/pantheon/delegation-finalize.ts +270 -0
- package/src/pantheon/delegation-notify.ts +238 -0
- package/src/pantheon/delegation.ts +388 -0
- package/src/pantheon/dispatch-guard.ts +102 -0
- package/src/pantheon/file-persistence.ts +10 -6
- package/src/pantheon/goal-loop.ts +297 -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 +259 -0
- package/src/pantheon/hashline/xxhash.ts +53 -0
- package/src/pantheon/idle-continuation.ts +54 -0
- package/src/pantheon/todo-enforcer.ts +369 -0
- package/src/plugin.ts +272 -6
- package/src/plugins/pantheon-hooks.ts +17 -8
- package/src/plugins/tui/package.json +1 -1
- package/src/routing.yml +33 -2
|
@@ -0,0 +1,369 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TODO Enforcer (Wave 1) — keeps root/non-board sessions working on their
|
|
3
|
+
* todo list when the session goes idle.
|
|
4
|
+
*
|
|
5
|
+
* The plugin's `event` hook dispatches `session.idle`: board-child sessions go
|
|
6
|
+
* to the delegation finalize path (`handleDelegationEvent`), everything else
|
|
7
|
+
* (roots, non-board sessions) goes to `TodoEnforcer.onIdle`. The enforcer
|
|
8
|
+
* re-injects a continuation prompt when the session went idle with incomplete
|
|
9
|
+
* todos, so the agent keeps working on its task list instead of stopping.
|
|
10
|
+
*
|
|
11
|
+
* Guards (council-approved cut — latch/skipAgents/countdown-toast removed):
|
|
12
|
+
* 1. user-activity — a session whose user sent a message within
|
|
13
|
+
* `userActivityQuietMs` is being driven interactively → skip;
|
|
14
|
+
* 2. board-running — a session with a running background job is skipped
|
|
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;
|
|
21
|
+
* 4. in-flight — one injection per idle, never concurrent;
|
|
22
|
+
* 5. cooldown — exponential per-session backoff `cooldownBaseMs *
|
|
23
|
+
* 2^min(failures, max)`, failures increment when an injection does not
|
|
24
|
+
* clear todos (the next idle shows the same-or-worse incomplete count);
|
|
25
|
+
* 6. max-consecutive-failures — stop injecting after the cap; the failure
|
|
26
|
+
* counter resets after a quiet period (`failureResetMs`) and when an
|
|
27
|
+
* injection makes progress (streak semantics).
|
|
28
|
+
*
|
|
29
|
+
* DONE detection: all todos `completed`/`cancelled` (or no todos at all) →
|
|
30
|
+
* no injection this idle. The todo re-fetch is cheap, so no done-latch is
|
|
31
|
+
* needed.
|
|
32
|
+
*
|
|
33
|
+
* The enforcer NEVER touches the TUI (no toast — council cut: toasts are
|
|
34
|
+
* no-ops on opencode 1.18.13). It depends only on `session.todo`,
|
|
35
|
+
* `session.messages` (agent/model inheritance via the last assistant
|
|
36
|
+
* message) and `session.promptAsync`
|
|
37
|
+
* (injection).
|
|
38
|
+
*
|
|
39
|
+
* Pure TypeScript — zero runtime dependencies beyond the board types.
|
|
40
|
+
*
|
|
41
|
+
* @module todo-enforcer
|
|
42
|
+
*/
|
|
43
|
+
|
|
44
|
+
import type { BackgroundJobBoard } from './background-job-board.ts'
|
|
45
|
+
|
|
46
|
+
// ─── Constants ─────────────────────────────────────────────────────────
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Version-controlled continuation text injected into the idle session.
|
|
50
|
+
* Exported for tests and so the routing.yml comment stays in sync.
|
|
51
|
+
*/
|
|
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.'
|
|
54
|
+
|
|
55
|
+
/** Defaults matching routing.yml `todo_enforcer`. */
|
|
56
|
+
export const TODO_ENFORCER_DEFAULTS = {
|
|
57
|
+
enabled: true,
|
|
58
|
+
cooldownBaseMs: 5000,
|
|
59
|
+
maxConsecutiveFailures: 5,
|
|
60
|
+
failureResetMs: 300000,
|
|
61
|
+
/** Skip injection this long after a user message in the session. */
|
|
62
|
+
userActivityQuietMs: 30000,
|
|
63
|
+
/** A native child whose `time.updated` is newer than this is still running. */
|
|
64
|
+
childActiveMs: 120000,
|
|
65
|
+
} as const
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Runtime kill-switch: `PANTHEON_TODO_ENFORCER=off` disables the enforcer.
|
|
69
|
+
* Read at plugin construction — routing.yml is a doc mirror only, this env
|
|
70
|
+
* var is the real runtime switch (COMPACTION_MAX_ITEMS precedent).
|
|
71
|
+
*/
|
|
72
|
+
export function todoEnforcerEnabledFromEnv(
|
|
73
|
+
env: Record<string, string | undefined> = process.env,
|
|
74
|
+
): boolean {
|
|
75
|
+
return (env.PANTHEON_TODO_ENFORCER ?? '').trim().toLowerCase() !== 'off'
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// ─── Types ─────────────────────────────────────────────────────────────
|
|
79
|
+
|
|
80
|
+
/** Structural view of a session todo item (SDK Todo: status is a string). */
|
|
81
|
+
export interface TodoLike {
|
|
82
|
+
content?: string
|
|
83
|
+
status: string
|
|
84
|
+
priority?: string
|
|
85
|
+
id?: string
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Structural view of a child session from `session.children()` (SDK Session).
|
|
90
|
+
* Only `time.updated` is used — a running native background task keeps
|
|
91
|
+
* updating its session while streaming; a completed one freezes.
|
|
92
|
+
*/
|
|
93
|
+
export interface TodoEnforcerChild {
|
|
94
|
+
id: string
|
|
95
|
+
time?: { updated?: number }
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Structural view of one message bundle from `session.messages()`. The SDK
|
|
100
|
+
* returns `{ info: Message, parts: Part[] }` — user messages carry `agent`
|
|
101
|
+
* on `info`, assistant messages carry `providerID`/`modelID` (or a `model`
|
|
102
|
+
* object).
|
|
103
|
+
*/
|
|
104
|
+
export interface TodoEnforcerMessage {
|
|
105
|
+
info?: {
|
|
106
|
+
role?: string
|
|
107
|
+
agent?: string
|
|
108
|
+
model?: { providerID?: string; modelID?: string }
|
|
109
|
+
providerID?: string
|
|
110
|
+
modelID?: string
|
|
111
|
+
}
|
|
112
|
+
parts?: Array<{ type?: string; text?: string }>
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Structural client surface — the subset of the SDK the enforcer uses. */
|
|
116
|
+
export interface TodoEnforcerClient {
|
|
117
|
+
session: {
|
|
118
|
+
todo(input: { path: { id: string } }): Promise<TodoLike[]>
|
|
119
|
+
messages(input: { path: { id: string } }): Promise<TodoEnforcerMessage[]>
|
|
120
|
+
children(input: { path: { id: string } }): Promise<TodoEnforcerChild[]>
|
|
121
|
+
promptAsync(input: {
|
|
122
|
+
path: { id: string }
|
|
123
|
+
body: {
|
|
124
|
+
agent?: string
|
|
125
|
+
model?: { providerID: string; modelID: string }
|
|
126
|
+
parts: Array<{ type: 'text'; text: string }>
|
|
127
|
+
}
|
|
128
|
+
}): Promise<unknown>
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** Tunables (all optional — see TODO_ENFORCER_DEFAULTS). */
|
|
133
|
+
export interface TodoEnforcerOptions {
|
|
134
|
+
enabled?: boolean
|
|
135
|
+
/** Base cooldown after a failed injection; doubles per failure. */
|
|
136
|
+
cooldownBaseMs?: number
|
|
137
|
+
/** Stop injecting after this many consecutive failures. */
|
|
138
|
+
maxConsecutiveFailures?: number
|
|
139
|
+
/** Quiet period after which the failure counter resets. */
|
|
140
|
+
failureResetMs?: number
|
|
141
|
+
/** Skip injection while a user message is more recent than this. */
|
|
142
|
+
userActivityQuietMs?: number
|
|
143
|
+
/** Native child sessions newer than this are considered active/running. */
|
|
144
|
+
childActiveMs?: number
|
|
145
|
+
/** Injectable clock (testable), defaults to `Date.now`. */
|
|
146
|
+
now?: () => number
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** Dependencies threaded to the enforcer (structural board — list only). */
|
|
150
|
+
export interface TodoEnforcerDeps {
|
|
151
|
+
client: TodoEnforcerClient
|
|
152
|
+
board: Pick<BackgroundJobBoard, 'list'>
|
|
153
|
+
options?: TodoEnforcerOptions
|
|
154
|
+
logger?: { warn: (message: string) => void }
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
type InheritedContext = {
|
|
158
|
+
agent?: string
|
|
159
|
+
model?: { providerID: string; modelID: string }
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// ─── TodoEnforcer ──────────────────────────────────────────────────────
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Re-inject a continuation prompt into idle sessions that still have
|
|
166
|
+
* incomplete todos. Per-session state: last injection time, the incomplete
|
|
167
|
+
* count at injection, the pending outcome check, and the consecutive-failure
|
|
168
|
+
* counter. All guards are best-effort — `onIdle` never throws, so the event
|
|
169
|
+
* hook can never break the session.
|
|
170
|
+
*/
|
|
171
|
+
export class TodoEnforcer {
|
|
172
|
+
private readonly client: TodoEnforcerClient
|
|
173
|
+
private readonly board: Pick<BackgroundJobBoard, 'list'>
|
|
174
|
+
private readonly options: Required<TodoEnforcerOptions>
|
|
175
|
+
private readonly warn: (message: string) => void
|
|
176
|
+
|
|
177
|
+
/** Guard 2: sessions with an in-flight onIdle (reserved synchronously). */
|
|
178
|
+
private readonly inFlight = new Set<string>()
|
|
179
|
+
/** Last injection timestamp per session. */
|
|
180
|
+
private readonly lastInjectionAt = new Map<string, number>()
|
|
181
|
+
/** Incomplete-todo count recorded at the last injection per session. */
|
|
182
|
+
private readonly lastInjectedIncomplete = new Map<string, number>()
|
|
183
|
+
/** True when the last injection's outcome has NOT been evaluated yet. */
|
|
184
|
+
private readonly pendingCheck = new Map<string, boolean>()
|
|
185
|
+
/** Consecutive failures per session. */
|
|
186
|
+
private readonly failures = new Map<string, number>()
|
|
187
|
+
/** Last user-message timestamp per session (wired from chat.message). */
|
|
188
|
+
private readonly lastUserMessageAt = new Map<string, number>()
|
|
189
|
+
|
|
190
|
+
constructor(deps: TodoEnforcerDeps) {
|
|
191
|
+
this.client = deps.client
|
|
192
|
+
this.board = deps.board
|
|
193
|
+
const opts = deps.options ?? {}
|
|
194
|
+
this.options = {
|
|
195
|
+
enabled: opts.enabled ?? TODO_ENFORCER_DEFAULTS.enabled,
|
|
196
|
+
cooldownBaseMs: opts.cooldownBaseMs ?? TODO_ENFORCER_DEFAULTS.cooldownBaseMs,
|
|
197
|
+
maxConsecutiveFailures:
|
|
198
|
+
opts.maxConsecutiveFailures ?? TODO_ENFORCER_DEFAULTS.maxConsecutiveFailures,
|
|
199
|
+
failureResetMs: opts.failureResetMs ?? TODO_ENFORCER_DEFAULTS.failureResetMs,
|
|
200
|
+
userActivityQuietMs: opts.userActivityQuietMs ?? TODO_ENFORCER_DEFAULTS.userActivityQuietMs,
|
|
201
|
+
childActiveMs: opts.childActiveMs ?? TODO_ENFORCER_DEFAULTS.childActiveMs,
|
|
202
|
+
now: opts.now ?? Date.now,
|
|
203
|
+
}
|
|
204
|
+
this.warn =
|
|
205
|
+
deps.logger?.warn ?? ((message: string) => console.warn(`[pantheon-todo] ${message}`))
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Record that the user just sent a message in `sessionID` (wired from the
|
|
210
|
+
* plugin `chat.message` hook). Suppresses injection for `userActivityQuietMs`
|
|
211
|
+
* — an interactive session is being driven by the user, don't nag it.
|
|
212
|
+
*/
|
|
213
|
+
noteUserActivity(sessionID: string): void {
|
|
214
|
+
this.lastUserMessageAt.set(sessionID, this.options.now())
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Handle a `session.idle` event for a non-board session. Evaluates the
|
|
219
|
+
* previous injection's outcome, applies the guards, and injects the
|
|
220
|
+
* continuation prompt when incomplete todos remain and the guards allow.
|
|
221
|
+
* Never throws — internal failures are logged and swallowed.
|
|
222
|
+
*/
|
|
223
|
+
async onIdle(sessionID: string): Promise<void> {
|
|
224
|
+
if (!this.options.enabled) return
|
|
225
|
+
if (this.inFlight.has(sessionID)) return
|
|
226
|
+
this.inFlight.add(sessionID)
|
|
227
|
+
try {
|
|
228
|
+
await this.handleIdle(sessionID)
|
|
229
|
+
} catch (err: unknown) {
|
|
230
|
+
const reason = err instanceof Error ? err.message : String(err)
|
|
231
|
+
this.warn(`todo injection failed for session ${sessionID}: ${reason}`)
|
|
232
|
+
} finally {
|
|
233
|
+
this.inFlight.delete(sessionID)
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
private async handleIdle(sessionID: string): Promise<void> {
|
|
238
|
+
const now = this.options.now()
|
|
239
|
+
|
|
240
|
+
// Guard 1: user activity — a fresh user message means the session is being
|
|
241
|
+
// driven interactively; don't nag it.
|
|
242
|
+
const lastUser = this.lastUserMessageAt.get(sessionID)
|
|
243
|
+
if (lastUser !== undefined && now - lastUser < this.options.userActivityQuietMs) return
|
|
244
|
+
|
|
245
|
+
// Guard 2: a session with a running board job is busy — skip.
|
|
246
|
+
if (this.board.list(sessionID).some((job) => job.state === 'running')) return
|
|
247
|
+
|
|
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.
|
|
250
|
+
if ((await this.activeChildren(sessionID, now)) > 0) return
|
|
251
|
+
|
|
252
|
+
const todos = await this.client.session.todo({ path: { id: sessionID } })
|
|
253
|
+
const incomplete = todos.filter(
|
|
254
|
+
(todo) => todo.status !== 'completed' && todo.status !== 'cancelled',
|
|
255
|
+
).length
|
|
256
|
+
|
|
257
|
+
// DONE detection: nothing left to enforce. Drop any pending outcome check.
|
|
258
|
+
if (incomplete === 0) {
|
|
259
|
+
this.pendingCheck.delete(sessionID)
|
|
260
|
+
return
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// Evaluate the previous injection: same-or-worse count → failure; making
|
|
264
|
+
// progress breaks the streak. Only one evaluation per injection.
|
|
265
|
+
if (this.pendingCheck.get(sessionID) === true) {
|
|
266
|
+
const before = this.lastInjectedIncomplete.get(sessionID)
|
|
267
|
+
if (before !== undefined && incomplete >= before) {
|
|
268
|
+
this.failures.set(sessionID, (this.failures.get(sessionID) ?? 0) + 1)
|
|
269
|
+
} else {
|
|
270
|
+
this.failures.set(sessionID, 0)
|
|
271
|
+
}
|
|
272
|
+
this.pendingCheck.delete(sessionID)
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// Quiet-period reset: a long silence since the last injection clears the
|
|
276
|
+
// failure counter (prevents permanent lock-out).
|
|
277
|
+
const lastInjection = this.lastInjectionAt.get(sessionID)
|
|
278
|
+
if (
|
|
279
|
+
lastInjection !== undefined &&
|
|
280
|
+
(this.failures.get(sessionID) ?? 0) > 0 &&
|
|
281
|
+
now - lastInjection >= this.options.failureResetMs
|
|
282
|
+
) {
|
|
283
|
+
this.failures.set(sessionID, 0)
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
const failures = this.failures.get(sessionID) ?? 0
|
|
287
|
+
|
|
288
|
+
// Guard 4: consecutive-failure cap reached → stop injecting.
|
|
289
|
+
if (failures >= this.options.maxConsecutiveFailures) return
|
|
290
|
+
|
|
291
|
+
// Guard 3: exponential cooldown since the last injection.
|
|
292
|
+
if (lastInjection !== undefined && now - lastInjection < this.cooldownMs(failures)) {
|
|
293
|
+
return
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// Inject. The attempt timestamp is recorded BEFORE the call so a failed
|
|
297
|
+
// (throwing) injection still applies the cooldown — no hot retry loop.
|
|
298
|
+
this.lastInjectionAt.set(sessionID, now)
|
|
299
|
+
const inherited = await this.inheritAgentOrModel(sessionID)
|
|
300
|
+
await this.client.session.promptAsync({
|
|
301
|
+
path: { id: sessionID },
|
|
302
|
+
body: {
|
|
303
|
+
...(inherited.agent !== undefined ? { agent: inherited.agent } : {}),
|
|
304
|
+
...(inherited.model !== undefined ? { model: inherited.model } : {}),
|
|
305
|
+
parts: [{ type: 'text', text: TODO_CONTINUATION_PROMPT }],
|
|
306
|
+
},
|
|
307
|
+
})
|
|
308
|
+
this.lastInjectedIncomplete.set(sessionID, incomplete)
|
|
309
|
+
this.pendingCheck.set(sessionID, true)
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/** Cooldown for a given failure count: base * 2^min(failures, cap). */
|
|
313
|
+
private cooldownMs(failures: number): number {
|
|
314
|
+
return (
|
|
315
|
+
this.options.cooldownBaseMs * 2 ** Math.min(failures, this.options.maxConsecutiveFailures)
|
|
316
|
+
)
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/**
|
|
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.
|
|
327
|
+
*/
|
|
328
|
+
private async activeChildren(sessionID: string, now: number): Promise<number> {
|
|
329
|
+
try {
|
|
330
|
+
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
|
|
335
|
+
} catch (err: unknown) {
|
|
336
|
+
const reason = err instanceof Error ? err.message : String(err)
|
|
337
|
+
this.warn(`todo children check failed for session ${sessionID}: ${reason}`)
|
|
338
|
+
return 0
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/**
|
|
343
|
+
* Inherit the agent (or model) from the LAST assistant message so the
|
|
344
|
+
* continuation runs in the same context as the session's last turn. Returns
|
|
345
|
+
* an empty object when no assistant message carries usable context — the
|
|
346
|
+
* SDK then applies the session default.
|
|
347
|
+
*/
|
|
348
|
+
private async inheritAgentOrModel(sessionID: string): Promise<InheritedContext> {
|
|
349
|
+
const messages = await this.client.session.messages({ path: { id: sessionID } })
|
|
350
|
+
for (const msg of [...messages].reverse()) {
|
|
351
|
+
const info = msg.info
|
|
352
|
+
if (info === undefined || info.role !== 'assistant') continue
|
|
353
|
+
if (typeof info.agent === 'string' && info.agent !== '') {
|
|
354
|
+
return { agent: info.agent }
|
|
355
|
+
}
|
|
356
|
+
const providerID = info.model?.providerID ?? info.providerID
|
|
357
|
+
const modelID = info.model?.modelID ?? info.modelID
|
|
358
|
+
if (
|
|
359
|
+
typeof providerID === 'string' &&
|
|
360
|
+
providerID !== '' &&
|
|
361
|
+
typeof modelID === 'string' &&
|
|
362
|
+
modelID !== ''
|
|
363
|
+
) {
|
|
364
|
+
return { model: { providerID, modelID } }
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
return {}
|
|
368
|
+
}
|
|
369
|
+
}
|
package/src/plugin.ts
CHANGED
|
@@ -1,8 +1,23 @@
|
|
|
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 { createCostCommand } from './pantheon/cost-command.ts'
|
|
5
|
+
import { createDelegationTools, type DelegationClient } from './pantheon/delegation.ts'
|
|
6
|
+
import { buildCompactionContext } from './pantheon/delegation-compaction.ts'
|
|
7
|
+
import { createEnforcementGuard, readOnlyRegistry } from './pantheon/delegation-enforce.ts'
|
|
8
|
+
import { DelegationNotifier, handleDelegationEvent } from './pantheon/delegation-notify.ts'
|
|
4
9
|
import { FilePersistenceAdapter } from './pantheon/file-persistence.ts'
|
|
10
|
+
import { GOAL_LOOP_DEFAULTS, GoalLoop, GoalStore } from './pantheon/goal-loop.ts'
|
|
11
|
+
import { createReadEnhancer } from './pantheon/hashline/read-enhancer.ts'
|
|
12
|
+
import { createHashlineEditTool } from './pantheon/hashline/tool.ts'
|
|
13
|
+
import { createIdleDispatcher } from './pantheon/idle-continuation.ts'
|
|
5
14
|
import { applyActivePresetToConfig } from './pantheon/presets.mjs'
|
|
15
|
+
import {
|
|
16
|
+
TODO_ENFORCER_DEFAULTS,
|
|
17
|
+
TodoEnforcer,
|
|
18
|
+
type TodoEnforcerClient,
|
|
19
|
+
todoEnforcerEnabledFromEnv,
|
|
20
|
+
} from './pantheon/todo-enforcer.ts'
|
|
6
21
|
import { activePresetCandidates, createVisionHandler } from './pantheon/vision.ts'
|
|
7
22
|
|
|
8
23
|
// ─── Background Job Board Singleton ────────────────────────────────────
|
|
@@ -16,15 +31,146 @@ board.setPersistence(persistence)
|
|
|
16
31
|
board
|
|
17
32
|
.recoverRunningJobs()
|
|
18
33
|
.catch((err) => console.error('[Pantheon Plugin] Failed to recover running jobs:', err))
|
|
34
|
+
// ─── Phase 3: Completion Notifications ────────────────────────────────
|
|
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'
|
|
43
|
+
|
|
19
44
|
board.onTerminal((taskID: string) => {
|
|
20
45
|
const job = board.get(taskID)
|
|
21
|
-
if (job)
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
46
|
+
if (!job) return
|
|
47
|
+
console.log(
|
|
48
|
+
`[Pantheon Plugin] Board terminal: [${job.alias}] ${job.description} → ${job.state}${job.resultSummary ? ` — ${job.resultSummary}` : ''}`,
|
|
49
|
+
)
|
|
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)
|
|
25
57
|
}
|
|
26
58
|
})
|
|
27
59
|
|
|
60
|
+
// Periodically prune terminal/reconciled jobs (24h TTL, every 30 min).
|
|
61
|
+
// pruneExpired is async and internally swallows persistence errors, so the
|
|
62
|
+
// void-catch here is purely defensive. .unref() ensures the timer never
|
|
63
|
+
// keeps the process alive on its own.
|
|
64
|
+
setInterval(
|
|
65
|
+
() => {
|
|
66
|
+
void board
|
|
67
|
+
.pruneExpired(86_400_000)
|
|
68
|
+
.catch((err: unknown) =>
|
|
69
|
+
console.error('[Pantheon Plugin] Background board prune failed:', err),
|
|
70
|
+
)
|
|
71
|
+
},
|
|
72
|
+
30 * 60 * 1000,
|
|
73
|
+
).unref()
|
|
74
|
+
|
|
75
|
+
// ─── Phase 4: Read-Only Enforcement + Compaction Context ───────────────
|
|
76
|
+
|
|
77
|
+
// The read-only registry is populated by createDelegationTools (delegation.ts)
|
|
78
|
+
// when a delegate is read-only (explicit flag or agent ∈ readOnlyAgents).
|
|
79
|
+
// The guard denies edit/write/bash/task inside those sessions by throwing
|
|
80
|
+
// from tool.execute.before; unknown sessions are allowed (safe default).
|
|
81
|
+
const enforcementGuard = createEnforcementGuard({
|
|
82
|
+
getReadOnlySessions: () => readOnlyRegistry.sessionIDs(),
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
// Mirrors routing.yml background_delegation.max_compaction_items.
|
|
86
|
+
const COMPACTION_MAX_ITEMS = 10
|
|
87
|
+
|
|
88
|
+
// Root-session registry for the delegation depth guard: sessions created
|
|
89
|
+
// without a parentID are roots. Populated from `session.created` events in the
|
|
90
|
+
// event hook — the authoritative complement to the knownChildren default
|
|
91
|
+
// ("any session WE created is a sub-session") in delegation.ts.
|
|
92
|
+
const rootSessions = new Set<string>()
|
|
93
|
+
|
|
94
|
+
/** Extract the SDK error message ({name, data: {message}}) or a fallback. */
|
|
95
|
+
function sdkErrorMessage(error: { data?: { message?: string } } | null | undefined): string {
|
|
96
|
+
const message = error?.data?.message
|
|
97
|
+
return typeof message === 'string' && message.trim() !== '' ? message : 'request failed'
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Adapt the opencode SDK client to the structural DelegationClient the
|
|
102
|
+
* delegation toolset expects: the SDK wraps every call in a
|
|
103
|
+
* `{data, error, request, response}` result while delegation.ts uses direct
|
|
104
|
+
* returns. Errors surface as throws so the toolset's own error handling
|
|
105
|
+
* (tool errors returned as TEXT) keeps working.
|
|
106
|
+
*/
|
|
107
|
+
function adaptDelegationClient(client: PluginInput['client']): DelegationClient {
|
|
108
|
+
return {
|
|
109
|
+
session: {
|
|
110
|
+
create: async (input) => {
|
|
111
|
+
const body: {
|
|
112
|
+
parentID?: string
|
|
113
|
+
title?: string
|
|
114
|
+
model?: { id: string; providerID: string }
|
|
115
|
+
} = { parentID: input.body.parentID }
|
|
116
|
+
if (input.body.title !== undefined) body.title = input.body.title
|
|
117
|
+
// Forward the routed child model (E2E fix): without it the child
|
|
118
|
+
// falls back to the key-gated default model and dies at startup.
|
|
119
|
+
if (input.body.model !== undefined) body.model = input.body.model
|
|
120
|
+
const result = await client.session.create({ body })
|
|
121
|
+
if (result.error) throw new Error(sdkErrorMessage(result.error))
|
|
122
|
+
return { id: result.data.id }
|
|
123
|
+
},
|
|
124
|
+
promptAsync: async (input) => {
|
|
125
|
+
const result = await client.session.promptAsync({
|
|
126
|
+
path: input.path,
|
|
127
|
+
body: { agent: input.body.agent, parts: input.body.parts },
|
|
128
|
+
})
|
|
129
|
+
if (result.error) throw new Error(sdkErrorMessage(result.error))
|
|
130
|
+
return result.data
|
|
131
|
+
},
|
|
132
|
+
messages: async (input) => {
|
|
133
|
+
const result = await client.session.messages({ path: input.path })
|
|
134
|
+
if (result.error) throw new Error(sdkErrorMessage(result.error))
|
|
135
|
+
return result.data
|
|
136
|
+
},
|
|
137
|
+
},
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Adapt the opencode SDK client to the structural TodoEnforcerClient the
|
|
143
|
+
* todo enforcer expects: unwraps the SDK `{data, error, request, response}`
|
|
144
|
+
* result and surfaces errors as throws (the enforcer swallows + logs them,
|
|
145
|
+
* so the event hook can never break the session).
|
|
146
|
+
*/
|
|
147
|
+
function adaptTodoEnforcerClient(client: PluginInput['client']): TodoEnforcerClient {
|
|
148
|
+
return {
|
|
149
|
+
session: {
|
|
150
|
+
todo: async (input) => {
|
|
151
|
+
const result = await client.session.todo({ path: input.path })
|
|
152
|
+
if (result.error) throw new Error(sdkErrorMessage(result.error))
|
|
153
|
+
return result.data
|
|
154
|
+
},
|
|
155
|
+
messages: async (input) => {
|
|
156
|
+
const result = await client.session.messages({ path: input.path })
|
|
157
|
+
if (result.error) throw new Error(sdkErrorMessage(result.error))
|
|
158
|
+
return result.data
|
|
159
|
+
},
|
|
160
|
+
children: async (input) => {
|
|
161
|
+
const result = await client.session.children({ path: input.path })
|
|
162
|
+
if (result.error) throw new Error(sdkErrorMessage(result.error))
|
|
163
|
+
return result.data
|
|
164
|
+
},
|
|
165
|
+
promptAsync: async (input) => {
|
|
166
|
+
const result = await client.session.promptAsync({ path: input.path, body: input.body })
|
|
167
|
+
if (result.error) throw new Error(sdkErrorMessage(result.error))
|
|
168
|
+
return result.data
|
|
169
|
+
},
|
|
170
|
+
},
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
28
174
|
/**
|
|
29
175
|
* Pantheon plugin for OpenCode. Pasted images are intercepted via the
|
|
30
176
|
* `chat.message` hook (proven to fire in opencode 1.18.11). When a provider
|
|
@@ -53,6 +199,55 @@ board.onTerminal((taskID: string) => {
|
|
|
53
199
|
*/
|
|
54
200
|
const plugin: Plugin = async (input: PluginInput) => {
|
|
55
201
|
const vision = createVisionHandler(input)
|
|
202
|
+
// Phase 2/3: background delegation toolset + the bound finalize lifecycle
|
|
203
|
+
// hook. Completion is observed through the event hook below
|
|
204
|
+
// (session.idle / session.error on a child) → finalizeDelegation.
|
|
205
|
+
const delegation = createDelegationTools({
|
|
206
|
+
board,
|
|
207
|
+
client: adaptDelegationClient(input.client),
|
|
208
|
+
options: { rootSessions, readOnlyAgents: new Set(['apollo', 'gaia']) },
|
|
209
|
+
})
|
|
210
|
+
|
|
211
|
+
// Wave 1 (PR #46): TODO continuation enforcer for root/non-board sessions.
|
|
212
|
+
// Mirrors routing.yml `todo_enforcer` (plugin scope has no routing.yml
|
|
213
|
+
// access — COMPACTION_MAX_ITEMS pattern). The event hook routes non-board
|
|
214
|
+
// session.idle events here; board-child idles go to finalizeDelegation.
|
|
215
|
+
// Runtime kill-switch: PANTHEON_TODO_ENFORCER=off (routing.yml is a doc
|
|
216
|
+
// mirror only — the env var is the real switch, PANTHEON_TOASTS pattern).
|
|
217
|
+
const todoEnforcer = new TodoEnforcer({
|
|
218
|
+
client: adaptTodoEnforcerClient(input.client),
|
|
219
|
+
board,
|
|
220
|
+
options: { ...TODO_ENFORCER_DEFAULTS, enabled: todoEnforcerEnabledFromEnv() },
|
|
221
|
+
})
|
|
222
|
+
|
|
223
|
+
// Wave 3 (PR #46): full-auto goal loop — opt-in (`full_auto.enabled:
|
|
224
|
+
// false` default, mirrored by GOAL_LOOP_DEFAULTS). File-based GoalStore
|
|
225
|
+
// (`.pantheon/goals/<sessionID>.json` — the plugin layer cannot reach MCP
|
|
226
|
+
// KV). Idle routing: an active goal owns the idle (goal loop); otherwise
|
|
227
|
+
// the todo enforcer gets it (see idle-continuation.ts).
|
|
228
|
+
const goalStore = new GoalStore({ dir: '.pantheon/goals' })
|
|
229
|
+
const goalLoop = new GoalLoop({
|
|
230
|
+
store: goalStore,
|
|
231
|
+
client: adaptTodoEnforcerClient(input.client),
|
|
232
|
+
board,
|
|
233
|
+
options: GOAL_LOOP_DEFAULTS,
|
|
234
|
+
})
|
|
235
|
+
const goalTools = goalLoop.tools()
|
|
236
|
+
const idleDispatcher = createIdleDispatcher({ goalLoop, todoEnforcer })
|
|
237
|
+
|
|
238
|
+
// Wave 2 (PR #46): hashline — tag-anchored edits. The read enhancer
|
|
239
|
+
// (tool.execute.after) augments `read` output with per-line sha256 tags
|
|
240
|
+
// (`12#XJ|content`); hashline_edit anchors edits to those refs. Additive —
|
|
241
|
+
// the plugin has no tool.execute.after yet, and pantheon-hooks.ts (a
|
|
242
|
+
// separate plugin instance) keeps its own. Mirrors routing.yml `hashline`.
|
|
243
|
+
const hashlineEdit = createHashlineEditTool()
|
|
244
|
+
const readEnhancer = createReadEnhancer()
|
|
245
|
+
|
|
246
|
+
// Wave 4 (PR #46): /cost — delegation cost + token visibility. Reads
|
|
247
|
+
// opencode.db read-only (node:sqlite, falls back to scripts/cost.mjs).
|
|
248
|
+
// Fully wired (unlike dispatch-guard, which is manual-orchestration-only
|
|
249
|
+
// because opencode 1.18.x cannot intercept task completion via hooks).
|
|
250
|
+
const costCommand = createCostCommand()
|
|
56
251
|
|
|
57
252
|
return {
|
|
58
253
|
config: async (config: PluginConfig) => {
|
|
@@ -92,9 +287,80 @@ const plugin: Plugin = async (input: PluginInput) => {
|
|
|
92
287
|
console.warn('[plugin] preset application failed (see logs for details)')
|
|
93
288
|
}
|
|
94
289
|
},
|
|
95
|
-
|
|
290
|
+
// Phase 2: background delegation tools (structural — matches the `tool`
|
|
291
|
+
// hook field shape; read_only sessions feed the Phase 4 registry).
|
|
292
|
+
tool: {
|
|
293
|
+
pantheon_delegate: delegation.pantheon_delegate,
|
|
294
|
+
pantheon_delegation_read: delegation.pantheon_delegation_read,
|
|
295
|
+
pantheon_delegation_list: delegation.pantheon_delegation_list,
|
|
296
|
+
hashline_edit: hashlineEdit,
|
|
297
|
+
// Wave 3 (PR #46): full-auto goal tools (single active goal/session).
|
|
298
|
+
pantheon_goal_create: goalTools.pantheon_goal_create,
|
|
299
|
+
pantheon_goal_update: goalTools.pantheon_goal_update,
|
|
300
|
+
pantheon_goal_get: goalTools.pantheon_goal_get,
|
|
301
|
+
// Wave 4 (PR #46): /cost — delegation cost report from opencode.db.
|
|
302
|
+
pantheon_cost: costCommand.pantheon_cost,
|
|
303
|
+
},
|
|
304
|
+
'chat.message': async (hookInput, output) => {
|
|
305
|
+
await vision.chatMessage(hookInput, output)
|
|
306
|
+
// Phase 3: deliver queued completion notifications into the parent's
|
|
307
|
+
// context (prepended onto the first text part). No-op when the queue is
|
|
308
|
+
// empty or the parent session has no pending notifications.
|
|
309
|
+
notifier.flushQueue(hookInput.sessionID, output)
|
|
310
|
+
// Wave 1: a user message is activity — the todo enforcer's
|
|
311
|
+
// user-activity gate skips injection for userActivityQuietMs afterwards.
|
|
312
|
+
todoEnforcer.noteUserActivity(hookInput.sessionID)
|
|
313
|
+
},
|
|
96
314
|
'experimental.chat.messages.transform': vision.messagesTransform,
|
|
97
|
-
event:
|
|
315
|
+
event: async ({ event: ev }) => {
|
|
316
|
+
// Phase 3: sessions created without a parent are roots for the depth guard.
|
|
317
|
+
if (ev.type === 'session.created') {
|
|
318
|
+
const info = ev.properties.info
|
|
319
|
+
if (info && info.parentID === undefined) rootSessions.add(info.id)
|
|
320
|
+
}
|
|
321
|
+
// Phase 3: observe completion on child sessions → finalizeDelegation.
|
|
322
|
+
// The board transition fires onTerminal → notifier.notifyParent (the
|
|
323
|
+
// single notification point). Unknown sessions are a no-op.
|
|
324
|
+
try {
|
|
325
|
+
const delegated = await handleDelegationEvent(ev, {
|
|
326
|
+
board,
|
|
327
|
+
finalize: (childSessionID, opts) => delegation.finalizeDelegation(childSessionID, opts),
|
|
328
|
+
})
|
|
329
|
+
// Wave 1/3: non-board idle → idle dispatcher. Board children are
|
|
330
|
+
// handled by handleDelegationEvent above; everything else (roots,
|
|
331
|
+
// non-board sessions) is routed by priority: an active goal owns the
|
|
332
|
+
// idle (goal loop), otherwise the todo enforcer re-injects. Neither
|
|
333
|
+
// onIdle ever throws (internal failures are logged + swallowed), so
|
|
334
|
+
// the session is safe.
|
|
335
|
+
if (!delegated && ev.type === 'session.idle') {
|
|
336
|
+
await idleDispatcher.onIdle(ev.properties.sessionID)
|
|
337
|
+
}
|
|
338
|
+
} catch (err) {
|
|
339
|
+
// The event hook must never break the session.
|
|
340
|
+
console.error('[Pantheon Plugin] Delegation event handling failed:', err)
|
|
341
|
+
}
|
|
342
|
+
await vision.event({ event: ev })
|
|
343
|
+
},
|
|
344
|
+
// Phase 4: deny mutating tools in read-only delegated sessions. Additive
|
|
345
|
+
// key — does not touch Phase 3's tools/event/onTerminal/chat.message.
|
|
346
|
+
'tool.execute.before': enforcementGuard,
|
|
347
|
+
// Wave 2 (PR #46): augment `read` output with hashline tags. Additive —
|
|
348
|
+
// pantheon-hooks.ts (a separate plugin instance) owns its own
|
|
349
|
+
// tool.execute.after, so there is no key collision. Non-read tools pass
|
|
350
|
+
// through untouched.
|
|
351
|
+
'tool.execute.after': readEnhancer,
|
|
352
|
+
// Phase 4: keep in-flight background delegations visible across
|
|
353
|
+
// compaction (running + unread terminal jobs). Guarded: only pushes when
|
|
354
|
+
// there is something to preserve.
|
|
355
|
+
'experimental.session.compacting': async (_input, output) => {
|
|
356
|
+
const blocks = buildCompactionContext(board, {
|
|
357
|
+
sessionID: _input.sessionID,
|
|
358
|
+
maxItems: COMPACTION_MAX_ITEMS,
|
|
359
|
+
})
|
|
360
|
+
if (blocks.length > 0) {
|
|
361
|
+
output.context.push(...blocks)
|
|
362
|
+
}
|
|
363
|
+
},
|
|
98
364
|
}
|
|
99
365
|
}
|
|
100
366
|
|