pantheon-opencode 1.5.0-beta.8 → 1.5.0

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 (82) hide show
  1. package/AGENTS.md +14 -63
  2. package/CHANGELOG.md +320 -0
  3. package/README.md +14 -27
  4. package/README.pt-BR.md +5 -6
  5. package/commands/pantheon.md +1 -35
  6. package/package.json +1 -2
  7. package/plugin.json +1 -1
  8. package/pyproject.toml +2 -5
  9. package/scripts/doctor.mjs +32 -3
  10. package/scripts/install/plugin.mjs +9 -12
  11. package/scripts/mcp_persistence_server.py +1 -1
  12. package/scripts/sync-tui.mjs +8 -11
  13. package/src/agents/apollo.md +1 -0
  14. package/src/agents/gaia.md +1 -0
  15. package/src/agents/zeus.md +9 -52
  16. package/src/instructions/memory-protocol.instructions.md +6 -3
  17. package/src/instructions/zeus-anti-stall.instructions.md +2 -4
  18. package/src/instructions/zeus-council-synthesis.instructions.md +3 -28
  19. package/src/instructions/zeus-timeout-retry.instructions.md +3 -28
  20. package/src/mcp/mcp_persistence_server.py +1 -1
  21. package/src/mcp/toon_codec.py +2 -4
  22. package/src/pantheon/background-job-board.ts +4 -4
  23. package/src/pantheon/{delegation-compaction.ts → compaction-context.ts} +10 -100
  24. package/src/pantheon/cost-command.ts +1 -2
  25. package/src/pantheon/delegation-enforce.ts +59 -14
  26. package/src/pantheon/goal-loop.ts +1 -1
  27. package/src/pantheon/goal-store.ts +1 -1
  28. package/src/pantheon/idle-continuation.ts +4 -6
  29. package/src/pantheon/model-command.ts +1 -2
  30. package/src/pantheon/native-task-status.ts +2 -2
  31. package/src/pantheon/presets.d.mts +0 -20
  32. package/src/pantheon/presets.mjs +0 -64
  33. package/src/pantheon/session-guard.ts +2 -2
  34. package/src/pantheon/session-id.ts +30 -0
  35. package/src/pantheon/task-result-guard.ts +2 -3
  36. package/src/pantheon/todo-enforcer.ts +3 -3
  37. package/src/pantheon/tool-context.ts +23 -0
  38. package/src/pantheon/v2-bridge.ts +1 -4
  39. package/src/plugin-v2.ts +8 -21
  40. package/src/plugin.ts +61 -245
  41. package/src/plugins/pantheon-hooks.ts +1 -1
  42. package/src/plugins/tui/dist/tui.d.ts +229 -77
  43. package/src/plugins/tui/dist/tui.d.ts.map +1 -1
  44. package/src/plugins/tui/dist/tui.js +509 -259
  45. package/src/plugins/tui/package-lock.json +2 -2
  46. package/src/plugins/tui/package.json +2 -2
  47. package/src/plugins/tui/src/index.tsx +753 -279
  48. package/src/routing.yml +0 -16
  49. package/src/skills/context-compression/SKILL.md +1 -1
  50. package/scripts/generate-preset-docs.mjs +0 -300
  51. package/scripts/generate-prompts.sh +0 -222
  52. package/scripts/hash_verify.py +0 -192
  53. package/scripts/install/component.mjs +0 -24
  54. package/scripts/install/migrate-config.mjs +0 -302
  55. package/scripts/test-context-compression.sh +0 -169
  56. package/src/pantheon/auto-wake.ts +0 -85
  57. package/src/pantheon/chat-reminders.ts +0 -9
  58. package/src/pantheon/comment-checker.ts +0 -48
  59. package/src/pantheon/compaction-assert.ts +0 -160
  60. package/src/pantheon/compaction-handoff.ts +0 -304
  61. package/src/pantheon/cost-tracker.ts +0 -108
  62. package/src/pantheon/delegate-manager.ts +0 -766
  63. package/src/pantheon/delegation-classifier.ts +0 -307
  64. package/src/pantheon/delegation-finalize.ts +0 -406
  65. package/src/pantheon/delegation-notify.ts +0 -212
  66. package/src/pantheon/delegation-toast.ts +0 -73
  67. package/src/pantheon/delegation.ts +0 -1337
  68. package/src/pantheon/dispatch-guard.ts +0 -109
  69. package/src/pantheon/native-probe.ts +0 -234
  70. package/src/pantheon/permission-eval.ts +0 -284
  71. package/src/pantheon/preemptive-compact.ts +0 -76
  72. package/src/pantheon/provider-capabilities.ts +0 -221
  73. package/src/pantheon/provider-stream-tracker.ts +0 -182
  74. package/src/pantheon/retry-policy.ts +0 -212
  75. package/src/pantheon/step-cap.ts +0 -110
  76. package/src/pantheon/token-opt.ts +0 -689
  77. package/src/pantheon/toon-codec.ts +0 -481
  78. package/src/pantheon/v2-events.ts +0 -148
  79. package/src/pantheon/v2-hooks.ts +0 -287
  80. package/src/pantheon/v2-tool-definitions.ts +0 -331
  81. package/src/pantheon/zeus-delegate-with-retry.ts +0 -406
  82. package/src/plugins/tui/dist/tui.tsx +0 -2777
package/src/plugin.ts CHANGED
@@ -1,8 +1,7 @@
1
- import { existsSync } from 'node:fs'
2
- import { join } from 'node:path'
3
1
  import type { Plugin, PluginInput } from '@opencode-ai/plugin'
4
2
  import type { PluginConfig } from 'opencode'
5
3
  import { createCommandNormalizer } from './pantheon/command-normalizer.ts'
4
+ import { buildCompactionContext } from './pantheon/compaction-context.ts'
6
5
  import {
7
6
  type ContextSandboxConfig,
8
7
  createContextSandbox,
@@ -11,24 +10,13 @@ import {
11
10
  DEFAULT_LIMITS as SANDBOX_DEFAULT_LIMITS,
12
11
  } from './pantheon/context-sandbox.ts'
13
12
  import { createCostCommand } from './pantheon/cost-command.ts'
14
- import { buildPluginNativeDelegation, resolveDelegateMode } from './pantheon/delegate-manager.ts'
15
- import {
16
- collectRootSessionIDs,
17
- createDelegationTools,
18
- type DelegationClient,
19
- resolveDelegationBudgets,
20
- resolveDelegationTimeoutMs,
21
- startIdleChildScan,
22
- } from './pantheon/delegation.ts'
23
- import { buildCompactionContext } from './pantheon/delegation-compaction.ts'
24
13
  import {
25
14
  createEnforcementGuard,
26
15
  readOnlyRegistry,
27
16
  SessionHierarchyRegistry,
17
+ syncReadOnlySession,
28
18
  zeusReadGuard,
29
19
  } from './pantheon/delegation-enforce.ts'
30
- import { handleDelegationEvent } from './pantheon/delegation-notify.ts'
31
- import { showDelegationTerminalToast, type ToastClient } from './pantheon/delegation-toast.ts'
32
20
  import { GOAL_LOOP_DEFAULTS, GoalLoop, GoalStore } from './pantheon/goal-loop.ts'
33
21
  import { createReadEnhancer } from './pantheon/hashline/read-enhancer.ts'
34
22
  import { createHashlineEditTool } from './pantheon/hashline/tool.ts'
@@ -36,15 +24,9 @@ import { createIdleDispatcher } from './pantheon/idle-continuation.ts'
36
24
  import { createPantheonLogger } from './pantheon/logger.ts'
37
25
  import { createModelCommand } from './pantheon/model-command.ts'
38
26
  import { pantheonPluginOnce } from './pantheon/plugin-once.ts'
39
- import {
40
- applyActivePresetToConfig,
41
- loadRoutingAgentModels,
42
- loadRoutingMaxSteps,
43
- loadRoutingPermissionTask,
44
- } from './pantheon/presets.mjs'
27
+ import { applyActivePresetToConfig } from './pantheon/presets.mjs'
45
28
  import { safeSessionPath } from './pantheon/session-guard.ts'
46
29
  import { getSharedBoard } from './pantheon/shared-board.ts'
47
- import { StepCapTracker } from './pantheon/step-cap.ts'
48
30
  import { createTaskResultGuard } from './pantheon/task-result-guard.ts'
49
31
  import {
50
32
  TODO_ENFORCER_DEFAULTS,
@@ -65,23 +47,10 @@ import { activePresetCandidates, createVisionHandler } from './pantheon/vision.t
65
47
  // .pantheon/logs/hooks.log; the console echo is opt-in via PANTHEON_HOOKS_LOG=1.
66
48
  const log = createPantheonLogger({ module: 'pantheon-plugin' })
67
49
 
68
- // R4: per-agent step caps from routing.yml `agents.<name>.max_steps`
69
- // (fail-open → {} = no agent capped). R1 retry_policy/cooldown are NOT
70
- // wired here — the plugin has no retry path (opencode cannot intercept task
71
- // completion); zeus waves load them via presets.mjs loaders and pass them
72
- // to zeusDelegateWithRetry explicitly.
73
- const stepCapTracker = new StepCapTracker(loadRoutingMaxSteps({ logger: log }))
74
-
75
- // O5: permission.task glob rules from routing.yml `permission.task`
76
- // (fail-open → undefined = existing runtime matrix only).
77
- const routingPermissionTask = loadRoutingPermissionTask({ logger: log })
78
-
79
50
  // beta.5: the board is a PROCESS-wide singleton (getSharedBoard) shared with
80
51
  // src/plugins/pantheon-hooks.ts, whose native-task mirror registers children
81
- // here; the finalize path below (session.idle + idle scan) must observe the
82
- // SAME instance. globalThis anchoring dedupes the npm+repo double load.
52
+ // here. globalThis anchoring dedupes the npm+repo double load.
83
53
  const board = getSharedBoard()
84
- let terminalToastClient: ToastClient = {}
85
54
  board
86
55
  .recoverRunningJobs()
87
56
  .catch((err) => log.error('[Pantheon Plugin] Failed to recover running jobs:', err))
@@ -91,17 +60,14 @@ board
91
60
  // file-only log line (console echo opt-in via PANTHEON_HOOKS_LOG). There is
92
61
  // deliberately NO chat delivery — the user policy is zero delegation
93
62
  // notifications in the transcript. Completion visibility lives in the board
94
- // `[unread]` marker (pantheon_delegation_list), pantheon_delegation_read,
95
- // TUI toasts (pantheon-hooks, PANTHEON_TOASTS gate) and compaction
96
- // carry-forward.
63
+ // `[unread]` marker and the TUI toasts owned by pantheon-hooks
64
+ // (PANTHEON_TOASTS gate).
97
65
  board.onTerminal((taskID: string) => {
98
66
  const job = board.get(taskID)
99
67
  if (!job) return
100
68
  log.info(
101
69
  `[Pantheon Plugin] Board terminal: [${job.alias}] ${job.description} → ${job.state}${job.resultSummary ? ` — ${job.resultSummary}` : ''}`,
102
70
  )
103
- // Deliberately direct-to-TUI: onTerminal must not inject chat/transcript text.
104
- void showDelegationTerminalToast(terminalToastClient, job)
105
71
  })
106
72
 
107
73
  // Periodically prune terminal/reconciled jobs (24h TTL, every 30 min).
@@ -127,10 +93,11 @@ function isRootSession(sessionID: string): boolean {
127
93
  return rootSessions.has(sessionID) || sessionHierarchy.isRoot(sessionID)
128
94
  }
129
95
 
130
- // The read-only registry is populated by createDelegationTools (delegation.ts)
131
- // when a delegate is read-only (explicit flag or agent ∈ readOnlyAgents).
132
- // The guard denies edit/write/bash/task inside those sessions by throwing
133
- // from tool.execute.before; unknown sessions are allowed (safe default).
96
+ // The read-only registry is populated by the chat.params hook below, when a
97
+ // session's active agent is read-only (apollo/gaia — READ_ONLY_AGENTS /
98
+ // syncReadOnlySession). The guard denies edit/write/bash/task/hashline_edit
99
+ // inside those sessions by throwing from tool.execute.before; unknown sessions
100
+ // are allowed (safe default).
134
101
  const enforcementGuard = createEnforcementGuard({
135
102
  getReadOnlySessions: () => readOnlyRegistry.sessionIDs(),
136
103
  options: {
@@ -148,10 +115,7 @@ const enforcementGuard = createEnforcementGuard({
148
115
  // to the shared hooks.log for @nyx frequency measurement.
149
116
  const commandNormalizer = createCommandNormalizer({ logger: log })
150
117
 
151
- // Mirrors routing.yml background_delegation.max_compaction_items.
152
- const COMPACTION_MAX_ITEMS = 10
153
-
154
- // Root-session registry for the delegation depth guard: sessions created
118
+ // Root-session registry for the read-only enforcement guard: sessions created
155
119
  // without a parentID are roots. Populated from TWO sources:
156
120
  // 1. the `session.created` event hook (live roots — fires for sessions
157
121
  // created while the plugin runs);
@@ -159,20 +123,30 @@ const COMPACTION_MAX_ITEMS = 10
159
123
  // hook (compaction-134) — opencode does NOT replay session.created
160
124
  // events for sessions that exist before plugin load, so without the
161
125
  // seed a resumed root would never enter this set after a restart.
162
- // delegation.ts additionally falls back to knownChildren ("any session WE
163
- // created is a sub-session; unknown sessions are roots") so the depth guard
164
- // works even when this set is incomplete.
126
+ // The guard treats unknown sessions as roots, so this set is an enrichment
127
+ // that improves accuracy but is never required for correctness.
165
128
  const rootSessions = new Set<string>()
166
129
  const sessionHierarchy = new SessionHierarchyRegistry()
167
130
  const sessionAgents = new Map<string, string>()
168
131
  const sessionToolCeilings = new Map<string, ToolCeilingResult>()
169
132
 
133
+ /** Pure helper: collect the IDs of root sessions (parentID === undefined). */
134
+ function collectRootSessionIDs(
135
+ sessions: ReadonlyArray<{ id: string; parentID?: string }>,
136
+ ): Set<string> {
137
+ const roots = new Set<string>()
138
+ for (const session of sessions) {
139
+ if (session.parentID === undefined) roots.add(session.id)
140
+ }
141
+ return roots
142
+ }
143
+
170
144
  /**
171
145
  * Seed resumed root sessions without making OpenCode startup depend on the
172
146
  * session API responding. Some OpenCode environments do not have the API
173
147
  * ready while plugin config hooks run; awaiting session.list() there leaves
174
- * the TUI stuck before it can open. The delegation layer already treats an
175
- * unknown session as a root, so this enrichment is deliberately best-effort.
148
+ * the TUI stuck before it can open. The enforcement guard treats an unknown
149
+ * session as a root, so this enrichment is deliberately best-effort.
176
150
  */
177
151
  function seedRootSessionsInBackground(input: PluginInput): void {
178
152
  // Fail-open: environments without the session API (client.session absent or
@@ -209,62 +183,6 @@ function sdkErrorMessage(error: { data?: { message?: string } } | null | undefin
209
183
  return typeof message === 'string' && message.trim() !== '' ? message : 'request failed'
210
184
  }
211
185
 
212
- /**
213
- * Adapt the opencode SDK client to the structural DelegationClient the
214
- * delegation toolset expects: the SDK wraps every call in a
215
- * `{data, error, request, response}` result while delegation.ts uses direct
216
- * returns. Errors surface as throws so the toolset's own error handling
217
- * (tool errors returned as TEXT) keeps working.
218
- */
219
- function adaptDelegationClient(client: PluginInput['client']): DelegationClient {
220
- return {
221
- session: {
222
- create: async (input) => {
223
- const body: {
224
- parentID?: string
225
- title?: string
226
- model?: { id: string; providerID: string }
227
- } = { parentID: input.body.parentID }
228
- if (input.body.title !== undefined) body.title = input.body.title
229
- // Forward a model only when the active routing profile supplied one.
230
- if (input.body.model !== undefined) body.model = input.body.model
231
- const result = await client.session.create({ body })
232
- if (result.error) throw new Error(sdkErrorMessage(result.error))
233
- return { id: result.data.id }
234
- },
235
- promptAsync: async (input) => {
236
- const path = safeSessionPath(input.path.id)
237
- if (!path) {
238
- log.warn(`[Pantheon Plugin] promptAsync skipped: invalid sessionID "${input.path.id}"`)
239
- throw new Error(`invalid sessionID: ${input.path.id}`)
240
- }
241
- const result = await client.session.promptAsync({
242
- path: path.path,
243
- body: {
244
- agent: input.body.agent,
245
- ...(input.body.model !== undefined
246
- ? { model: { providerID: input.body.model.providerID, modelID: input.body.model.id } }
247
- : {}),
248
- parts: input.body.parts,
249
- },
250
- })
251
- if (result.error) throw new Error(sdkErrorMessage(result.error))
252
- return result.data
253
- },
254
- messages: async (input) => {
255
- const path = safeSessionPath(input.path.id)
256
- if (!path) {
257
- log.warn(`[Pantheon Plugin] messages skipped: invalid sessionID "${input.path.id}"`)
258
- return []
259
- }
260
- const result = await client.session.messages({ path: path.path })
261
- if (result.error) throw new Error(sdkErrorMessage(result.error))
262
- return result.data
263
- },
264
- },
265
- }
266
- }
267
-
268
186
  /**
269
187
  * Adapt the opencode SDK client to the structural TodoEnforcerClient the
270
188
  * todo enforcer expects: unwraps the SDK `{data, error, request, response}`
@@ -345,7 +263,6 @@ function adaptTodoEnforcerClient(client: PluginInput['client']): TodoEnforcerCli
345
263
  * Helpers live in src/pantheon/vision.ts and are imported from there directly.
346
264
  */
347
265
  const plugin: Plugin = async (input: PluginInput) => {
348
- terminalToastClient = input.client as unknown as ToastClient
349
266
  // release-134: runtime guard against DOUBLE registration (duplicate toasts +
350
267
  // duplicate task_ids in one process — 2026-08-12 live logs). opencode
351
268
  // merges the global config (npm-package plugin paths) with the project
@@ -360,100 +277,11 @@ const plugin: Plugin = async (input: PluginInput) => {
360
277
  // is older than the installed package. Runs once, fail-open, no auto-sync.
361
278
  checkTuiVersionStaleness((msg) => log.warn(msg))
362
279
  const vision = createVisionHandler(input)
363
- // Phase 2/3: background delegation toolset + the bound finalize lifecycle
364
- // hook. Completion is observed through the event hook below
365
- // (session.idle / session.error on a child) → finalizeDelegation.
366
- // Fase B3: runtime controls for the two read-only exceptions and the real
367
- // per-child wall-clock timer. No task_budget field is injected into the
368
- // plugin/frontmatter; these are enforced by delegation.ts itself.
369
- const delegationBudgets = resolveDelegationBudgets()
370
- const wallClockTimeoutMs = resolveDelegationTimeoutMs()
371
-
372
- // WS1 (PR #94): delegation backend gate. Default = legacy (the V1
373
- // fire-and-forget toolset, intact); PANTHEON_DELEGATE_MODE=native selects
374
- // the thin manager over native task() semantics. Kill-switch
375
- // PANTHEON_DELEGATION=off covers the native manager; finalizeDelegation stays on
376
- // the legacy observer: it works on the shared board in both modes and must
377
- // never be kill-switched, so children never orphan. The idle hook below is
378
- // untouched.
379
- const delegationClient = adaptDelegationClient(input.client)
380
- // Only an explicitly active routing profile may provide child models.
381
- // An empty map deliberately lets OpenCode inherit the parent model.
382
- const routingAgentModels = loadRoutingAgentModels({
383
- candidates: activePresetCandidates(),
384
- logger: log,
385
- })
386
- const readOnlyAgents = new Set(['apollo', 'gaia'])
387
- const legacyDelegation = createDelegationTools({
388
- board,
389
- client: delegationClient,
390
- options: {
391
- rootSessions,
392
- isChildSession: (sessionID) => sessionHierarchy.isChild(sessionID),
393
- registerChildSession: (sessionID, parentID) => {
394
- sessionHierarchy.register({ id: sessionID, parentID })
395
- },
396
- enforceRuntimeMatrix: true,
397
- readOnlyAgents,
398
- agentModels: routingAgentModels,
399
- delegationBudgets,
400
- stepCapTracker,
401
- ...(routingPermissionTask != null ? { permissionTask: routingPermissionTask } : {}),
402
- ...(wallClockTimeoutMs !== undefined ? { wallClockTimeoutMs } : {}),
403
- },
404
- })
405
- const delegateMode = resolveDelegateMode()
406
- if (delegateMode === 'native') {
407
- log.info('[Pantheon Plugin] Delegation mode: native (PANTHEON_DELEGATE_MODE=native)')
408
- }
409
- const delegation =
410
- delegateMode === 'native'
411
- ? buildPluginNativeDelegation({
412
- board,
413
- client: delegationClient,
414
- isRootSession,
415
- registerChildSession: (sessionID, parentID) => {
416
- sessionHierarchy.register({ id: sessionID, parentID })
417
- },
418
- registerReadOnlySession: (sessionID, info) => {
419
- readOnlyRegistry.register(sessionID, info)
420
- },
421
- readOnlyAgents,
422
- agentModels: routingAgentModels,
423
- stepCap: stepCapTracker,
424
- ...(wallClockTimeoutMs !== undefined ? { wallClockTimeoutMs } : {}),
425
- finalizeDelegation: legacyDelegation.finalizeDelegation,
426
- })
427
- : legacyDelegation
428
-
429
- // Proactive idle-child scan (Fix 2): finalize children that are idle on the
430
- // board but have no MD report on disk — eliminates the phantom "running"
431
- // window. Runs every 30s (configurable via PANTHEON_IDLE_SCAN_INTERVAL_MS),
432
- // unref'd so it never holds the process open. Fail-open by design.
433
- const idleScanIntervalMs = Number(process.env.PANTHEON_IDLE_SCAN_INTERVAL_MS) || undefined
434
- startIdleChildScan(
435
- {
436
- board,
437
- finalize: (childSessionID, opts) => delegation.finalizeDelegation(childSessionID, opts),
438
- hasReport: (job) => {
439
- try {
440
- const filePath = join('.pantheon/delegations', job.parentSessionID, `${job.alias}.md`)
441
- return existsSync(filePath)
442
- } catch {
443
- return false
444
- }
445
- },
446
- logger: log,
447
- },
448
- idleScanIntervalMs,
449
- )
450
-
451
- // Wave 1 (PR #46): TODO continuation enforcer for root/non-board sessions.
452
- // Mirrors routing.yml `todo_enforcer` (plugin scope has no routing.yml
453
- // access — COMPACTION_MAX_ITEMS pattern). The event hook routes non-board
454
- // session.idle events here; board-child idles go to finalizeDelegation.
455
- // Runtime kill-switch: PANTHEON_TODO_ENFORCER=off (routing.yml is a doc
456
- // mirror only — the env var is the real switch, PANTHEON_TOASTS pattern).
280
+ // Wave 1 (PR #46): TODO continuation enforcer for root sessions. The event
281
+ // hook routes `session.idle` events here; an active goal owns the idle
282
+ // (goal loop), otherwise the todo enforcer re-injects. Runtime kill-switch:
283
+ // PANTHEON_TODO_ENFORCER=off (routing.yml is a doc mirror only — the env var
284
+ // is the real switch, PANTHEON_TOASTS pattern).
457
285
  const todoEnforcer = new TodoEnforcer({
458
286
  client: adaptTodoEnforcerClient(input.client),
459
287
  board,
@@ -516,9 +344,7 @@ const plugin: Plugin = async (input: PluginInput) => {
516
344
  }
517
345
 
518
346
  // Wave 4 (PR #46): /cost — delegation cost + token visibility. Reads
519
- // opencode.db read-only via the single node:sqlite backend.
520
- // Fully wired (unlike dispatch-guard, which is manual-orchestration-only
521
- // because opencode 1.18.x cannot intercept task completion via hooks).
347
+ // opencode.db read-only via the single node:sqlite backend. Fully wired.
522
348
  const costCommand = createCostCommand()
523
349
  const modelCommand = createModelCommand()
524
350
 
@@ -527,11 +353,11 @@ const plugin: Plugin = async (input: PluginInput) => {
527
353
  // compaction-134 seed: opencode does NOT replay `session.created`
528
354
  // events for sessions that exist before plugin load, so after a
529
355
  // restart the resumed ROOT session never enters rootSessions and the
530
- // depth guard would reject its pantheon_delegate calls. Seed the
531
- // registry from session.list() in the background — every session
532
- // WITHOUT a parentID is a root. Fail-open: a failed/throttled list
533
- // leaves the set untouched (delegation.ts treats unknown sessions as
534
- // roots), and config setup must never wait for the session API.
356
+ // read-only enforcement guard would misclassify it. Seed the registry
357
+ // from session.list() in the background — every session WITHOUT a
358
+ // parentID is a root. Fail-open: a failed/throttled list leaves the set
359
+ // untouched (the guard treats unknown sessions as roots), and config
360
+ // setup must never wait for the session API.
535
361
  // Context sandbox config — opencode.json → context_sandbox: {enabled, limits}
536
362
  try {
537
363
  const raw = (config as unknown as Record<string, unknown>).context_sandbox
@@ -576,12 +402,9 @@ const plugin: Plugin = async (input: PluginInput) => {
576
402
  log.warn('[plugin] preset application failed (see logs for details)')
577
403
  }
578
404
  },
579
- // Phase 2: background delegation tools (structural — matches the `tool`
580
- // hook field shape; read_only sessions feed the Phase 4 registry).
405
+ // Plugin tool surface: tag-anchored edits (hashline_edit), the full-auto
406
+ // goal loop, /cost and /pantheon-model management.
581
407
  tool: {
582
- pantheon_delegate: delegation.pantheon_delegate,
583
- pantheon_delegation_read: delegation.pantheon_delegation_read,
584
- pantheon_delegation_list: delegation.pantheon_delegation_list,
585
408
  hashline_edit: hashlineEdit,
586
409
  // Wave 3 (PR #46): full-auto goal tools (single active goal/session).
587
410
  pantheon_goal_create: goalTools.pantheon_goal_create,
@@ -604,6 +427,10 @@ const plugin: Plugin = async (input: PluginInput) => {
604
427
  // The SDK exposes the active agent here; tool.execute.before has no
605
428
  // agent field. Never infer identity from a prompt or tool name.
606
429
  sessionAgents.set(hookInput.sessionID, hookInput.agent)
430
+ // Phase 4: chat.params is the ONLY hook with the active agent, so this
431
+ // is where the read-only registry is populated — apollo/gaia sessions
432
+ // must be denied edit/write/bash/task/hashline_edit by the guard.
433
+ syncReadOnlySession(readOnlyRegistry, hookInput.sessionID, hookInput.agent)
607
434
  },
608
435
  'experimental.chat.messages.transform': vision.messagesTransform,
609
436
  event: async ({ event: ev }) => {
@@ -615,6 +442,12 @@ const plugin: Plugin = async (input: PluginInput) => {
615
442
  if (info.parentID === undefined) rootSessions.add(info.id)
616
443
  }
617
444
  }
445
+ // Phase 4: a deleted session must not leave a read-only registration
446
+ // (or cached agent identity) behind.
447
+ if (ev.type === 'session.deleted') {
448
+ readOnlyRegistry.unregister(ev.properties.info.id)
449
+ sessionAgents.delete(ev.properties.info.id)
450
+ }
618
451
  // release-134 Phase 3: activate the todo snapshot captured at
619
452
  // compacting time — the first todowrite within the restore window is
620
453
  // rewritten with the exact list (see todo-preserve.ts). Fail-open.
@@ -624,27 +457,12 @@ const plugin: Plugin = async (input: PluginInput) => {
624
457
  if (ev.type === 'message.updated') {
625
458
  await handleToolCeilingEvent(input.client, ev, sessionToolCeilings)
626
459
  }
627
- // Phase 3: observe completion on child sessions → finalizeDelegation.
628
- // The board transition fires onTerminal → the file-only audit log (no
629
- // chat delivery — see the onTerminal listener above). Unknown sessions
630
- // are a no-op.
631
- try {
632
- const delegated = await handleDelegationEvent(ev, {
633
- board,
634
- finalize: (childSessionID, opts) => delegation.finalizeDelegation(childSessionID, opts),
635
- })
636
- // Wave 1/3: non-board idle → idle dispatcher. Board children are
637
- // handled by handleDelegationEvent above; everything else (roots,
638
- // non-board sessions) is routed by priority: an active goal owns the
639
- // idle (goal loop), otherwise the todo enforcer re-injects. Neither
640
- // onIdle ever throws (internal failures are logged + swallowed), so
641
- // the session is safe.
642
- if (!delegated && ev.type === 'session.idle') {
643
- await idleDispatcher.onIdle(ev.properties.sessionID)
644
- }
645
- } catch (err) {
646
- // The event hook must never break the session.
647
- log.error('[Pantheon Plugin] Delegation event handling failed:', err)
460
+ // Wave 1/3: `session.idle` → idle dispatcher. An active goal owns the
461
+ // idle (goal loop); otherwise the todo enforcer re-injects. onIdle never
462
+ // throws (internal failures are logged + swallowed), so the session is
463
+ // safe.
464
+ if (ev.type === 'session.idle') {
465
+ await idleDispatcher.onIdle(ev.properties.sessionID)
648
466
  }
649
467
  await vision.event({ event: ev })
650
468
  },
@@ -670,16 +488,14 @@ const plugin: Plugin = async (input: PluginInput) => {
670
488
  // through untouched.
671
489
  'tool.execute.after': combinedAfter,
672
490
  // Phase 4 + release-134 Phase 2: keep the session's working state across
673
- // compaction — preservation directive, active goals (<mission_context>),
674
- // pending todos (<todo_context>), and in-flight background delegations
675
- // (running + unread terminal ≤ max_compaction_items). Guarded: only
676
- // pushes when there is something to preserve; a build failure must never
677
- // break the experimental compaction hook.
491
+ // compaction — preservation directive, active goals (<mission_context>)
492
+ // and pending todos (<todo_context>). Guarded: only pushes when there is
493
+ // something to preserve; a build failure must never break the experimental
494
+ // compaction hook.
678
495
  'experimental.session.compacting': async (_input, output) => {
679
496
  try {
680
- const blocks = await buildCompactionContext(board, {
497
+ const blocks = await buildCompactionContext({
681
498
  sessionID: _input.sessionID,
682
- maxItems: COMPACTION_MAX_ITEMS,
683
499
  goals: {
684
500
  enabled: GOAL_LOOP_DEFAULTS.enabled,
685
501
  list: (sessionID: string) => goalStore.list(sessionID),
@@ -783,7 +783,7 @@ function delegationAgent(tool: string, args?: unknown): string {
783
783
  * panel tracks the child end-to-end. Fire-and-forget and silent: a mirror
784
784
  * failure must never surface into the tool call or the transcript.
785
785
  * registerLaunchIfAbsent keeps it idempotent against any other dispatcher
786
- * (v2-events, plugin double-load, repeated after-hook firings).
786
+ * (plugin double-load, repeated after-hook firings).
787
787
  */
788
788
  function mirrorNativeBackgroundDispatch(
789
789
  input: { tool: string; sessionID: string },