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.
@@ -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
- console.log(
23
- `[Pantheon Plugin] Board terminal: [${job.alias}] ${job.description} → ${job.state}${job.resultSummary ? ` — ${job.resultSummary}` : ''}`,
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
- 'chat.message': vision.chatMessage,
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: vision.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