pantheon-opencode 1.3.2 → 1.3.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/README.md +154 -1
  2. package/bin/pantheon-init.mjs +160 -120
  3. package/opencode.json +0 -1
  4. package/package.json +10 -5
  5. package/scripts/cost.mjs +69 -0
  6. package/scripts/doctor.mjs +56 -22
  7. package/scripts/hooks/on-subagent-delegation-stop.sh +22 -5
  8. package/scripts/install/opencode.mjs +132 -32
  9. package/scripts/install/plugin.mjs +112 -28
  10. package/scripts/install-mcp.mjs +1 -1
  11. package/scripts/redaction-gate.mjs +69 -0
  12. package/scripts/release-notes.mjs +317 -0
  13. package/scripts/test-context-compression.sh +5 -2
  14. package/scripts/validate-package.mjs +56 -0
  15. package/scripts/versioning.mjs +117 -18
  16. package/src/agents/zeus.md +80 -0
  17. package/src/pantheon/background-job-board.ts +115 -40
  18. package/src/pantheon/chat-reminders.ts +79 -0
  19. package/src/pantheon/compaction-assert.ts +160 -0
  20. package/src/pantheon/cost-command.ts +208 -0
  21. package/src/pantheon/cost-tracker.ts +108 -0
  22. package/src/pantheon/delegation-compaction.ts +237 -0
  23. package/src/pantheon/delegation-enforce.ts +144 -0
  24. package/src/pantheon/delegation-finalize.ts +277 -0
  25. package/src/pantheon/delegation-notify.ts +100 -0
  26. package/src/pantheon/delegation.ts +690 -0
  27. package/src/pantheon/dispatch-guard.ts +107 -0
  28. package/src/pantheon/file-persistence.ts +10 -6
  29. package/src/pantheon/goal-loop.ts +300 -0
  30. package/src/pantheon/goal-store.ts +97 -0
  31. package/src/pantheon/hashline/core.ts +91 -0
  32. package/src/pantheon/hashline/read-enhancer.ts +66 -0
  33. package/src/pantheon/hashline/tool.ts +260 -0
  34. package/src/pantheon/hashline/xxhash.ts +53 -0
  35. package/src/pantheon/idle-continuation.ts +54 -0
  36. package/src/pantheon/logger.ts +117 -0
  37. package/src/pantheon/plugin-once.ts +76 -0
  38. package/src/pantheon/preemptive-compact.ts +76 -0
  39. package/src/pantheon/presets.d.mts +31 -0
  40. package/src/pantheon/presets.mjs +105 -3
  41. package/src/pantheon/todo-enforcer.ts +421 -0
  42. package/src/pantheon/todo-preserve.ts +295 -0
  43. package/src/plugin.ts +406 -13
  44. package/src/plugins/pantheon-hooks.ts +52 -30
  45. package/src/plugins/tui/dist/server.d.ts +17 -0
  46. package/src/plugins/tui/dist/server.d.ts.map +1 -0
  47. package/src/plugins/tui/dist/server.js +20 -0
  48. package/src/plugins/tui/dist/server.js.map +1 -0
  49. package/src/plugins/tui/dist/tui.d.ts +303 -1
  50. package/src/plugins/tui/dist/tui.d.ts.map +1 -1
  51. package/src/plugins/tui/dist/tui.js +977 -350
  52. package/src/plugins/tui/dist/tui.tsx +1427 -150
  53. package/src/plugins/tui/package.json +4 -3
  54. package/src/plugins/tui/src/index.tsx +1427 -150
  55. package/src/plugins/tui/src/server.ts +15 -0
  56. package/src/plugins/tui/tsdown.config.ts +1 -1
  57. package/src/routing.yml +35 -2
package/src/plugin.ts CHANGED
@@ -1,12 +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'
5
+ import { createCostCommand } from './pantheon/cost-command.ts'
6
+ import {
7
+ collectRootSessionIDs,
8
+ createDelegationTools,
9
+ type DelegationClient,
10
+ } from './pantheon/delegation.ts'
11
+ import { buildCompactionContext } from './pantheon/delegation-compaction.ts'
12
+ import { createEnforcementGuard, readOnlyRegistry } from './pantheon/delegation-enforce.ts'
13
+ import { handleDelegationEvent } from './pantheon/delegation-notify.ts'
4
14
  import { FilePersistenceAdapter } from './pantheon/file-persistence.ts'
5
- import { applyActivePresetToConfig } from './pantheon/presets.mjs'
15
+ import { GOAL_LOOP_DEFAULTS, GoalLoop, GoalStore } from './pantheon/goal-loop.ts'
16
+ import { createReadEnhancer } from './pantheon/hashline/read-enhancer.ts'
17
+ import { createHashlineEditTool } from './pantheon/hashline/tool.ts'
18
+ import { createIdleDispatcher } from './pantheon/idle-continuation.ts'
19
+ import { createPantheonLogger } from './pantheon/logger.ts'
20
+ import { pantheonPluginOnce } from './pantheon/plugin-once.ts'
21
+ import { applyActivePresetToConfig, loadRoutingAgentModels } from './pantheon/presets.mjs'
22
+ import {
23
+ TODO_ENFORCER_DEFAULTS,
24
+ TodoEnforcer,
25
+ type TodoEnforcerClient,
26
+ todoEnforcerEnabledFromEnv,
27
+ } from './pantheon/todo-enforcer.ts'
28
+ import { TodoPreserver } from './pantheon/todo-preserve.ts'
6
29
  import { activePresetCandidates, createVisionHandler } from './pantheon/vision.ts'
7
30
 
8
31
  // ─── Background Job Board Singleton ────────────────────────────────────
9
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
+
10
50
  const board = new BackgroundJobBoard({
11
51
  maxConcurrentPerAgent: 3,
12
52
  signalDir: '.pantheon/deepwork/board-signals',
@@ -15,16 +55,174 @@ const persistence = new FilePersistenceAdapter('.pantheon/board/state.json')
15
55
  board.setPersistence(persistence)
16
56
  board
17
57
  .recoverRunningJobs()
18
- .catch((err) => console.error('[Pantheon Plugin] Failed to recover running jobs:', err))
58
+ .catch((err) => log.error('[Pantheon Plugin] Failed to recover running jobs:', err))
59
+ // ─── Phase 3: Board terminal audit log ─────────────────────────────────
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.
19
68
  board.onTerminal((taskID: string) => {
20
69
  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
- )
25
- }
70
+ if (!job) return
71
+ log.info(
72
+ `[Pantheon Plugin] Board terminal: [${job.alias}] ${job.description} → ${job.state}${job.resultSummary ? ` — ${job.resultSummary}` : ''}`,
73
+ )
74
+ })
75
+
76
+ // Periodically prune terminal/reconciled jobs (24h TTL, every 30 min).
77
+ // pruneExpired is async and internally swallows persistence errors, so the
78
+ // void-catch here is purely defensive. .unref() ensures the timer never
79
+ // keeps the process alive on its own.
80
+ setInterval(
81
+ () => {
82
+ void board
83
+ .pruneExpired(86_400_000)
84
+ .catch((err: unknown) => log.error('[Pantheon Plugin] Background board prune failed:', err))
85
+ },
86
+ 30 * 60 * 1000,
87
+ ).unref()
88
+
89
+ // ─── Phase 4: Read-Only Enforcement + Compaction Context ───────────────
90
+
91
+ // The read-only registry is populated by createDelegationTools (delegation.ts)
92
+ // when a delegate is read-only (explicit flag or agent ∈ readOnlyAgents).
93
+ // The guard denies edit/write/bash/task inside those sessions by throwing
94
+ // from tool.execute.before; unknown sessions are allowed (safe default).
95
+ const enforcementGuard = createEnforcementGuard({
96
+ getReadOnlySessions: () => readOnlyRegistry.sessionIDs(),
26
97
  })
27
98
 
99
+ // Mirrors routing.yml background_delegation.max_compaction_items.
100
+ const COMPACTION_MAX_ITEMS = 10
101
+
102
+ // Root-session registry for the delegation depth guard: sessions created
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.
113
+ const rootSessions = new Set<string>()
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
+
146
+ /** Extract the SDK error message ({name, data: {message}}) or a fallback. */
147
+ function sdkErrorMessage(error: { data?: { message?: string } } | null | undefined): string {
148
+ const message = error?.data?.message
149
+ return typeof message === 'string' && message.trim() !== '' ? message : 'request failed'
150
+ }
151
+
152
+ /**
153
+ * Adapt the opencode SDK client to the structural DelegationClient the
154
+ * delegation toolset expects: the SDK wraps every call in a
155
+ * `{data, error, request, response}` result while delegation.ts uses direct
156
+ * returns. Errors surface as throws so the toolset's own error handling
157
+ * (tool errors returned as TEXT) keeps working.
158
+ */
159
+ function adaptDelegationClient(client: PluginInput['client']): DelegationClient {
160
+ return {
161
+ session: {
162
+ create: async (input) => {
163
+ const body: {
164
+ parentID?: string
165
+ title?: string
166
+ model?: { id: string; providerID: string }
167
+ } = { parentID: input.body.parentID }
168
+ if (input.body.title !== undefined) body.title = input.body.title
169
+ // Forward the routed child model (E2E fix): without it the child
170
+ // falls back to the key-gated default model and dies at startup.
171
+ if (input.body.model !== undefined) body.model = input.body.model
172
+ const result = await client.session.create({ body })
173
+ if (result.error) throw new Error(sdkErrorMessage(result.error))
174
+ return { id: result.data.id }
175
+ },
176
+ promptAsync: async (input) => {
177
+ const result = await client.session.promptAsync({
178
+ path: input.path,
179
+ body: { agent: input.body.agent, parts: input.body.parts },
180
+ })
181
+ if (result.error) throw new Error(sdkErrorMessage(result.error))
182
+ return result.data
183
+ },
184
+ messages: async (input) => {
185
+ const result = await client.session.messages({ path: input.path })
186
+ if (result.error) throw new Error(sdkErrorMessage(result.error))
187
+ return result.data
188
+ },
189
+ },
190
+ }
191
+ }
192
+
193
+ /**
194
+ * Adapt the opencode SDK client to the structural TodoEnforcerClient the
195
+ * todo enforcer expects: unwraps the SDK `{data, error, request, response}`
196
+ * result and surfaces errors as throws (the enforcer swallows + logs them,
197
+ * so the event hook can never break the session).
198
+ */
199
+ function adaptTodoEnforcerClient(client: PluginInput['client']): TodoEnforcerClient {
200
+ return {
201
+ session: {
202
+ todo: async (input) => {
203
+ const result = await client.session.todo({ path: input.path })
204
+ if (result.error) throw new Error(sdkErrorMessage(result.error))
205
+ return result.data
206
+ },
207
+ messages: async (input) => {
208
+ const result = await client.session.messages({ path: input.path })
209
+ if (result.error) throw new Error(sdkErrorMessage(result.error))
210
+ return result.data
211
+ },
212
+ children: async (input) => {
213
+ const result = await client.session.children({ path: input.path })
214
+ if (result.error) throw new Error(sdkErrorMessage(result.error))
215
+ return result.data
216
+ },
217
+ promptAsync: async (input) => {
218
+ const result = await client.session.promptAsync({ path: input.path, body: input.body })
219
+ if (result.error) throw new Error(sdkErrorMessage(result.error))
220
+ return result.data
221
+ },
222
+ },
223
+ }
224
+ }
225
+
28
226
  /**
29
227
  * Pantheon plugin for OpenCode. Pasted images are intercepted via the
30
228
  * `chat.message` hook (proven to fire in opencode 1.18.11). When a provider
@@ -52,10 +250,91 @@ board.onTerminal((taskID: string) => {
52
250
  * Helpers live in src/pantheon/vision.ts and are imported from there directly.
53
251
  */
54
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
+ }
55
263
  const vision = createVisionHandler(input)
264
+ // Phase 2/3: background delegation toolset + the bound finalize lifecycle
265
+ // hook. Completion is observed through the event hook below
266
+ // (session.idle / session.error on a child) → finalizeDelegation.
267
+ const delegation = createDelegationTools({
268
+ board,
269
+ client: adaptDelegationClient(input.client),
270
+ options: {
271
+ rootSessions,
272
+ readOnlyAgents: new Set(['apollo', 'gaia']),
273
+ agentModels: routingAgentModels,
274
+ },
275
+ })
276
+
277
+ // Wave 1 (PR #46): TODO continuation enforcer for root/non-board sessions.
278
+ // Mirrors routing.yml `todo_enforcer` (plugin scope has no routing.yml
279
+ // access — COMPACTION_MAX_ITEMS pattern). The event hook routes non-board
280
+ // session.idle events here; board-child idles go to finalizeDelegation.
281
+ // Runtime kill-switch: PANTHEON_TODO_ENFORCER=off (routing.yml is a doc
282
+ // mirror only — the env var is the real switch, PANTHEON_TOASTS pattern).
283
+ const todoEnforcer = new TodoEnforcer({
284
+ client: adaptTodoEnforcerClient(input.client),
285
+ board,
286
+ options: { ...TODO_ENFORCER_DEFAULTS, enabled: todoEnforcerEnabledFromEnv() },
287
+ })
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
+
298
+ // Wave 3 (PR #46): full-auto goal loop — opt-in (`full_auto.enabled:
299
+ // false` default, mirrored by GOAL_LOOP_DEFAULTS). File-based GoalStore
300
+ // (`.pantheon/goals/<sessionID>.json` — the plugin layer cannot reach MCP
301
+ // KV). Idle routing: an active goal owns the idle (goal loop); otherwise
302
+ // the todo enforcer gets it (see idle-continuation.ts).
303
+ const goalStore = new GoalStore({ dir: '.pantheon/goals' })
304
+ const goalLoop = new GoalLoop({
305
+ store: goalStore,
306
+ client: adaptTodoEnforcerClient(input.client),
307
+ board,
308
+ options: GOAL_LOOP_DEFAULTS,
309
+ })
310
+ const goalTools = goalLoop.tools()
311
+ const idleDispatcher = createIdleDispatcher({ goalLoop, todoEnforcer })
312
+
313
+ // Wave 2 (PR #46): hashline — tag-anchored edits. The read enhancer
314
+ // (tool.execute.after) augments `read` output with per-line sha256 tags
315
+ // (`12#XJ|content`); hashline_edit anchors edits to those refs. Additive —
316
+ // the plugin has no tool.execute.after yet, and pantheon-hooks.ts (a
317
+ // separate plugin instance) keeps its own. Mirrors routing.yml `hashline`.
318
+ const hashlineEdit = createHashlineEditTool()
319
+ const readEnhancer = createReadEnhancer()
320
+
321
+ // Wave 4 (PR #46): /cost — delegation cost + token visibility. Reads
322
+ // opencode.db read-only (node:sqlite, falls back to scripts/cost.mjs).
323
+ // Fully wired (unlike dispatch-guard, which is manual-orchestration-only
324
+ // because opencode 1.18.x cannot intercept task completion via hooks).
325
+ const costCommand = createCostCommand()
56
326
 
57
327
  return {
58
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)
59
338
  config.agentsPath = config.agentsPath ?? []
60
339
  config.agentsPath.push(new URL('./agents', import.meta.url).pathname)
61
340
  config.skillsPaths = config.skillsPaths ?? []
@@ -74,9 +353,7 @@ const plugin: Plugin = async (input: PluginInput) => {
74
353
  candidates: activePresetCandidates(),
75
354
  })
76
355
  if (resolved) {
77
- console.log(
78
- `[Pantheon Plugin] Applied model preset: ${resolved.name} (${resolved.source})`,
79
- )
356
+ log.info(`[Pantheon Plugin] Applied model preset: ${resolved.name} (${resolved.source})`)
80
357
  }
81
358
  } catch {
82
359
  // Fully STATIC warning — the thrown error object is tainted because
@@ -89,12 +366,128 @@ const plugin: Plugin = async (input: PluginInput) => {
89
366
  // emitted by applyActivePresetToConfig's own logger before it throws.
90
367
  // The catch binding is intentionally omitted so no tainted value can
91
368
  // ever reach this log line (CodeQL alert #11).
92
- console.warn('[plugin] preset application failed (see logs for details)')
369
+ log.warn('[plugin] preset application failed (see logs for details)')
93
370
  }
94
371
  },
95
- 'chat.message': vision.chatMessage,
372
+ // Phase 2: background delegation tools (structural — matches the `tool`
373
+ // hook field shape; read_only sessions feed the Phase 4 registry).
374
+ tool: {
375
+ pantheon_delegate: delegation.pantheon_delegate,
376
+ pantheon_delegation_read: delegation.pantheon_delegation_read,
377
+ pantheon_delegation_list: delegation.pantheon_delegation_list,
378
+ hashline_edit: hashlineEdit,
379
+ // Wave 3 (PR #46): full-auto goal tools (single active goal/session).
380
+ pantheon_goal_create: goalTools.pantheon_goal_create,
381
+ pantheon_goal_update: goalTools.pantheon_goal_update,
382
+ pantheon_goal_get: goalTools.pantheon_goal_get,
383
+ // Wave 4 (PR #46): /cost — delegation cost report from opencode.db.
384
+ pantheon_cost: costCommand.pantheon_cost,
385
+ },
386
+ 'chat.message': async (hookInput, output) => {
387
+ await vision.chatMessage(hookInput, 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.
392
+ // Wave 1: a user message is activity — the todo enforcer's
393
+ // user-activity gate skips injection for userActivityQuietMs afterwards.
394
+ todoEnforcer.noteUserActivity(hookInput.sessionID)
395
+ },
96
396
  'experimental.chat.messages.transform': vision.messagesTransform,
97
- event: vision.event,
397
+ event: async ({ event: ev }) => {
398
+ // Phase 3: sessions created without a parent are roots for the depth guard.
399
+ if (ev.type === 'session.created') {
400
+ const info = ev.properties.info
401
+ if (info && info.parentID === undefined) rootSessions.add(info.id)
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
+ }
420
+ // Phase 3: observe completion on child sessions → finalizeDelegation.
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.
424
+ try {
425
+ const delegated = await handleDelegationEvent(ev, {
426
+ board,
427
+ finalize: (childSessionID, opts) => delegation.finalizeDelegation(childSessionID, opts),
428
+ })
429
+ // Wave 1/3: non-board idle → idle dispatcher. Board children are
430
+ // handled by handleDelegationEvent above; everything else (roots,
431
+ // non-board sessions) is routed by priority: an active goal owns the
432
+ // idle (goal loop), otherwise the todo enforcer re-injects. Neither
433
+ // onIdle ever throws (internal failures are logged + swallowed), so
434
+ // the session is safe.
435
+ if (!delegated && ev.type === 'session.idle') {
436
+ await idleDispatcher.onIdle(ev.properties.sessionID)
437
+ }
438
+ } catch (err) {
439
+ // The event hook must never break the session.
440
+ log.error('[Pantheon Plugin] Delegation event handling failed:', err)
441
+ }
442
+ await vision.event({ event: ev })
443
+ },
444
+ // Phase 4: deny mutating tools in read-only delegated sessions. Additive
445
+ // key — does not touch Phase 3's tools/event/onTerminal/chat.message.
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
+ },
455
+ // Wave 2 (PR #46): augment `read` output with hashline tags. Additive —
456
+ // pantheon-hooks.ts (a separate plugin instance) owns its own
457
+ // tool.execute.after, so there is no key collision. Non-read tools pass
458
+ // through untouched.
459
+ 'tool.execute.after': readEnhancer,
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.
466
+ 'experimental.session.compacting': async (_input, output) => {
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)
489
+ }
490
+ },
98
491
  }
99
492
  }
100
493
 
@@ -186,7 +186,14 @@ import { appendFile, mkdir } from 'node:fs/promises'
186
186
  import { join } from 'node:path'
187
187
  import type { Plugin, PluginInput } from '@opencode-ai/plugin'
188
188
  import type { Part } from '@opencode-ai/sdk'
189
+ import {
190
+ CHAT_REMINDER_TTL_MS,
191
+ drainFreshChatReminders,
192
+ enqueueChatReminder,
193
+ pendingChatReminders,
194
+ } from '../pantheon/chat-reminders.ts'
189
195
  import { type HookPayload, type HookResult, runHook } from './hook-runner.ts'
196
+ import { pantheonPluginOnce } from '../pantheon/plugin-once.ts'
190
197
 
191
198
  /** Tools that represent a subagent delegation (opencode `task` tool etc.). */
192
199
  const DELEGATION_TOOL_RE = /^(task|.*delegate.*|.*subagent.*)$/i
@@ -424,6 +431,15 @@ const TASK_ID_TEXT_RE = /\btask\s+id="(ses_[^"]+)"/i
424
431
  * opencode 1.18.13 drops tui.toast.show events (untagged), so every signal
425
432
  * that would fire a toast is ALSO queued here and injected into the next user
426
433
  * message by the 'chat.message' hook as a single <system-reminder> text part.
434
+ *
435
+ * The buffer itself lives in the SHARED chat-reminders.ts module: this plugin
436
+ * file must export EXACTLY ONE function-valued export (legacy loader —
437
+ * see the IMPORTANT note at the top), so the enqueue API cannot be exported
438
+ * from here. Importing the shared module instead gives src/plugin.ts (a
439
+ * separate plugin file, loaded via plain dynamic import) the SAME buffer
440
+ * instance — release-134 Phase 4 (compaction-assert.ts) enqueues
441
+ * post-compaction state re-assertions here and this hook delivers them.
442
+ *
427
443
  * Bounded: at most CHAT_REMINDER_MAX entries, each expiring
428
444
  * CHAT_REMINDER_TTL_MS after its tool event. Consumed (and cleared) by the
429
445
  * chat.message hook. Full-buffer enqueues are SKIPPED, never unbounded — the
@@ -432,20 +448,6 @@ const TASK_ID_TEXT_RE = /\btask\s+id="(ses_[^"]+)"/i
432
448
  * session.idle flushes the buffer into ONE fresh aggregated entry (see
433
449
  * flushIdleReminders) so completions survive until the next user message.
434
450
  */
435
- const CHAT_REMINDER_MAX = 10
436
- const CHAT_REMINDER_TTL_MS = 60_000
437
- const pendingChatReminders: { text: string; at: number }[] = []
438
-
439
- /** Queue one chat-reminder line, pruning expired entries first. Never throws. */
440
- function enqueueChatReminder(text: string): void {
441
- const now = Date.now()
442
- for (let i = pendingChatReminders.length - 1; i >= 0; i--) {
443
- const r = pendingChatReminders[i]
444
- if (r !== undefined && now - r.at > CHAT_REMINDER_TTL_MS) pendingChatReminders.splice(i, 1)
445
- }
446
- if (pendingChatReminders.length >= CHAT_REMINDER_MAX) return
447
- pendingChatReminders.push({ text, at: now })
448
- }
449
451
 
450
452
  /** Variants accepted by the opencode TUI showToast (TuiShowToastData.body.variant). */
451
453
  type ToastVariant = 'info' | 'success' | 'warning' | 'error'
@@ -1124,10 +1126,13 @@ function flushIdleReminders(ctx: HookContext): void {
1124
1126
 
1125
1127
  // ONE aggregated entry, fresh timestamp — chat.message delivers it next.
1126
1128
  enqueueChatReminder(lines.join('\n'))
1129
+ // 1.3.4 dedup: log a SUMMARY here (count only), never the joined body — the
1130
+ // SAME lines used to be echoed twice (idle-flush with " | ", chat-reminder
1131
+ // delivery with "\n"). The content is logged exactly once, at delivery.
1127
1132
  void reportFailure(
1128
1133
  ctx,
1129
- `[pantheon-hooks:idle-flush] ${lines.join(' | ')}`,
1130
- { script: 'idle-flush', count: fresh.length },
1134
+ `[pantheon-hooks:idle-flush] ${fresh.length} reminder(s) flushed into the chat buffer (${lines.length} line(s) after aggregation)`,
1135
+ { script: 'idle-flush', count: fresh.length, lineCount: lines.length },
1131
1136
  'info',
1132
1137
  )
1133
1138
  }
@@ -1203,6 +1208,17 @@ async function safeRun(
1203
1208
  }
1204
1209
 
1205
1210
  const plugin: Plugin = async ({ client, directory }) => {
1211
+ // release-134: runtime guard against DOUBLE registration (duplicate toasts +
1212
+ // duplicate task_ids in one process — 2026-08-12 live logs). opencode
1213
+ // merges the global config (npm-package plugin paths) with the project
1214
+ // config (repo source paths), so this module loads from TWO filesystem
1215
+ // paths in the same process and the factory would otherwise run twice —
1216
+ // every hook (tool.execute.before/after, chat.message, event) firing
1217
+ // TWICE. The second invocation becomes a no-op (empty hooks object) so
1218
+ // hooks register exactly once.
1219
+ if (pantheonPluginOnce('pantheon:hooks')) {
1220
+ return {}
1221
+ }
1206
1222
  const ctx: HookContext = { client, directory }
1207
1223
  return {
1208
1224
  'tool.execute.before': async (input, output) => {
@@ -1427,35 +1443,41 @@ const plugin: Plugin = async ({ client, directory }) => {
1427
1443
  // tui.toast.show events on opencode 1.18.13 (untagged — see header), so
1428
1444
  // queued agent-lifecycle signals (delegation start/done, hook failures,
1429
1445
  // olympians/council summaries) are injected here as ONE <system-reminder> text
1430
- // part into the next user message, then cleared. chat.message fires only
1431
- // for user-initiated messages (subagent prompts use a different path), so
1432
- // this surfaces in the main session exactly when the user is watching.
1433
- // session.idle flushes the buffer into ONE fresh aggregated entry first
1434
- // (flushIdleReminders). Same PANTHEON_TOASTS gate as toasts: the buffer
1435
- // only receives gated signals, and the empty-set check below is a
1436
- // defensive backstop.
1446
+ // part into the next user message, then cleared.
1447
+ // P0 (2026-08-11, live E2E — release blocker): this hook ALSO fires for the
1448
+ // child session's client.session.promptAsync (the old "subagent prompts use
1449
+ // a different path" claim is FALSE on 1.18.13), where input.messageID is
1450
+ // EMPTY/undefined. Injecting there with `?? ''` produced a part opencode's
1451
+ // schema rejects (SchemaError: Expected a string starting with "msg", got
1452
+ // "") → prompt_async failed → every delegation died in ~20ms. The guard
1453
+ // below skips injection ENTIRELY on that path WITHOUT draining the buffer,
1454
+ // so the reminder still lands on the parent's next real message.
1437
1455
  'chat.message': async (input, output) => {
1438
1456
  try {
1439
1457
  if (ENABLED_TOAST_CATEGORIES.size === 0) return
1440
1458
  if (pendingChatReminders.length === 0) return
1441
- const now = Date.now()
1442
- const fresh = pendingChatReminders.filter((r) => now - r.at <= CHAT_REMINDER_TTL_MS)
1443
- pendingChatReminders.length = 0
1444
- if (fresh.length === 0) return
1445
- const body = fresh.map((r) => r.text).join('\n')
1459
+ // Subagent promptAsync fires have no messageID — injecting any part
1460
+ // with an empty messageID crashes the child session (schema reject).
1461
+ // Early return BEFORE the buffer drain keeps the reminder queued for
1462
+ // the parent's next real (msg_-ID'd) message.
1463
+ if (!input.messageID) return
1464
+ const body = drainFreshChatReminders()
1465
+ if (body === undefined) return
1446
1466
  // Minimal TextPart — the core backfills messageID/sessionID for
1447
1467
  // hook-injected parts (see opencode chat.message trigger pipeline).
1468
+ // The guard above guarantees input.messageID is a non-empty string,
1469
+ // so no `?? ''` fallback is needed (an explicit "" is schema-rejected).
1448
1470
  output.parts.push({
1449
1471
  id: `prt_${crypto.randomUUID()}`,
1450
1472
  sessionID: input.sessionID,
1451
- messageID: input.messageID ?? '',
1473
+ messageID: input.messageID,
1452
1474
  type: 'text',
1453
1475
  text: `<system-reminder>\n${body}\n</system-reminder>`,
1454
1476
  } satisfies Part)
1455
1477
  await reportFailure(
1456
1478
  ctx,
1457
1479
  `[pantheon-hooks:chat-reminder] ${body}`,
1458
- { script: 'chat-reminder', count: fresh.length },
1480
+ { script: 'chat-reminder' },
1459
1481
  'info',
1460
1482
  )
1461
1483
  } catch {
@@ -0,0 +1,17 @@
1
+ //#region src/server.d.ts
2
+ /**
3
+ * No-op server stub for the opencode plugin loader.
4
+ *
5
+ * The opencode plugin host requires every plugin (TUI included) to expose a
6
+ * `server()` entry via `exports["./server"]` (or package.json `main`). When
7
+ * that entry is missing the host fails to attach the plugin's server half and
8
+ * the TUI runtime never wires the reactive channel — the initial JSX mounts
9
+ * but effects/refresh/events stay dead (the "mudo panel" symptom).
10
+ *
11
+ * This stub satisfies the loader contract; pantheon-tui is TUI-only and has
12
+ * no background server work of its own.
13
+ */
14
+ declare function server(): {};
15
+ //#endregion
16
+ export { server as default };
17
+ //# sourceMappingURL=server.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"server.d.ts","names":[],"sources":["../src/server.ts"],"mappings":";;;;;;;;;;;;;iBAYwB"}
@@ -0,0 +1,20 @@
1
+ //#region src/server.ts
2
+ /**
3
+ * No-op server stub for the opencode plugin loader.
4
+ *
5
+ * The opencode plugin host requires every plugin (TUI included) to expose a
6
+ * `server()` entry via `exports["./server"]` (or package.json `main`). When
7
+ * that entry is missing the host fails to attach the plugin's server half and
8
+ * the TUI runtime never wires the reactive channel — the initial JSX mounts
9
+ * but effects/refresh/events stay dead (the "mudo panel" symptom).
10
+ *
11
+ * This stub satisfies the loader contract; pantheon-tui is TUI-only and has
12
+ * no background server work of its own.
13
+ */
14
+ function server() {
15
+ return {};
16
+ }
17
+ //#endregion
18
+ export { server as default };
19
+
20
+ //# sourceMappingURL=server.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"server.js","names":[],"sources":["../src/server.ts"],"sourcesContent":["/**\n * No-op server stub for the opencode plugin loader.\n *\n * The opencode plugin host requires every plugin (TUI included) to expose a\n * `server()` entry via `exports[\"./server\"]` (or package.json `main`). When\n * that entry is missing the host fails to attach the plugin's server half and\n * the TUI runtime never wires the reactive channel — the initial JSX mounts\n * but effects/refresh/events stay dead (the \"mudo panel\" symptom).\n *\n * This stub satisfies the loader contract; pantheon-tui is TUI-only and has\n * no background server work of its own.\n */\nexport default function server() {\n return {}\n}\n"],"mappings":";;;;;;;;;;;;;AAYA,SAAwB,SAAS;CAC/B,OAAO,CAAC;AACV"}