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.
Files changed (52) hide show
  1. package/AGENTS.md +919 -0
  2. package/README.md +157 -15
  3. package/bin/pantheon-init.mjs +177 -120
  4. package/opencode.json +3 -6
  5. package/package.json +8 -4
  6. package/scripts/build-agents-md.mjs +195 -0
  7. package/scripts/cost.mjs +17 -0
  8. package/scripts/doctor.mjs +56 -22
  9. package/scripts/hooks/on-subagent-delegation-stop.sh +22 -5
  10. package/scripts/install/opencode.mjs +189 -38
  11. package/scripts/install/plugin.mjs +112 -28
  12. package/scripts/install-mcp.mjs +1 -1
  13. package/scripts/redaction-gate.mjs +69 -0
  14. package/scripts/release-beta-version.mjs +80 -0
  15. package/scripts/release-notes.mjs +317 -0
  16. package/scripts/test-context-compression.sh +5 -2
  17. package/scripts/validate-package.mjs +56 -0
  18. package/scripts/versioning.mjs +117 -18
  19. package/src/agents/zeus.md +6 -2
  20. package/src/pantheon/background-job-board.ts +24 -38
  21. package/src/pantheon/chat-reminders.ts +79 -0
  22. package/src/pantheon/compaction-assert.ts +160 -0
  23. package/src/pantheon/cost-command.ts +57 -18
  24. package/src/pantheon/delegation-compaction.ts +143 -16
  25. package/src/pantheon/delegation-finalize.ts +8 -1
  26. package/src/pantheon/delegation-notify.ts +19 -157
  27. package/src/pantheon/delegation.ts +330 -28
  28. package/src/pantheon/dispatch-guard.ts +9 -4
  29. package/src/pantheon/goal-loop.ts +7 -4
  30. package/src/pantheon/hashline/tool.ts +2 -1
  31. package/src/pantheon/logger.ts +117 -0
  32. package/src/pantheon/plugin-once.ts +76 -0
  33. package/src/pantheon/preemptive-compact.ts +76 -0
  34. package/src/pantheon/presets.d.mts +31 -0
  35. package/src/pantheon/presets.mjs +105 -3
  36. package/src/pantheon/todo-enforcer.ts +79 -27
  37. package/src/pantheon/todo-preserve.ts +295 -0
  38. package/src/plugin.ts +177 -50
  39. package/src/plugins/pantheon-hooks.ts +35 -22
  40. package/src/plugins/tui/dist/server.d.ts +17 -0
  41. package/src/plugins/tui/dist/server.d.ts.map +1 -0
  42. package/src/plugins/tui/dist/server.js +20 -0
  43. package/src/plugins/tui/dist/server.js.map +1 -0
  44. package/src/plugins/tui/dist/tui.d.ts +303 -1
  45. package/src/plugins/tui/dist/tui.d.ts.map +1 -1
  46. package/src/plugins/tui/dist/tui.js +977 -350
  47. package/src/plugins/tui/dist/tui.tsx +1427 -150
  48. package/src/plugins/tui/package.json +4 -3
  49. package/src/plugins/tui/src/index.tsx +1427 -150
  50. package/src/plugins/tui/src/server.ts +15 -0
  51. package/src/plugins/tui/tsdown.config.ts +1 -1
  52. 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 { createDelegationTools, type DelegationClient } from './pantheon/delegation.ts'
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 { DelegationNotifier, handleDelegationEvent } from './pantheon/delegation-notify.ts'
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 { applyActivePresetToConfig } from './pantheon/presets.mjs'
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) => 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'
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
- console.log(
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 `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.
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: { rootSessions, readOnlyAgents: new Set(['apollo', 'gaia']) },
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
- console.log(
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
- console.warn('[plugin] preset application failed (see logs for details)')
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
- // 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)
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 → notifier.notifyParent (the
323
- // single notification point). Unknown sessions are a no-op.
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
- console.error('[Pantheon Plugin] Delegation event handling failed:', err)
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': enforcementGuard,
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 in-flight background delegations visible across
353
- // compaction (running + unread terminal jobs). Guarded: only pushes when
354
- // there is something to preserve.
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
- const blocks = buildCompactionContext(board, {
357
- sessionID: _input.sessionID,
358
- maxItems: COMPACTION_MAX_ITEMS,
359
- })
360
- if (blocks.length > 0) {
361
- output.context.push(...blocks)
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
  }