pantheon-opencode 1.5.0-beta.2 → 1.5.0-beta.20

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 (107) hide show
  1. package/.pantheon/code-mode/manifest.json +21 -0
  2. package/AGENTS.md +14 -63
  3. package/CHANGELOG.md +1260 -0
  4. package/README.md +162 -23
  5. package/README.pt-BR.md +104 -19
  6. package/bin/pantheon-init.mjs +178 -21
  7. package/commands/pantheon.md +1 -35
  8. package/package.json +23 -15
  9. package/plugin.json +1 -1
  10. package/pyproject.toml +62 -0
  11. package/scripts/code_mode_server.py +431 -55
  12. package/scripts/doctor.mjs +207 -9
  13. package/scripts/install/cli-ui.mjs +17 -11
  14. package/scripts/install/config-migration.mjs +4 -6
  15. package/scripts/install/opencode.mjs +137 -35
  16. package/scripts/install/plugin.mjs +12 -45
  17. package/scripts/install/shared.mjs +43 -2
  18. package/scripts/install/strings.mjs +129 -0
  19. package/scripts/install-mcp.mjs +1 -0
  20. package/scripts/manifest-inventory.mjs +410 -0
  21. package/scripts/mcp_persistence_server.py +536 -164
  22. package/scripts/package-evidence.mjs +242 -0
  23. package/scripts/probe-pantheon-cost.mjs +10 -1
  24. package/scripts/release-beta-version.mjs +56 -60
  25. package/scripts/sync-artifacts.mjs +169 -0
  26. package/scripts/sync-tui.mjs +129 -64
  27. package/scripts/test-opencode-v1-v2-sandbox.sh +79 -161
  28. package/scripts/validation-policy.mjs +7 -7
  29. package/scripts/version-check.mjs +113 -108
  30. package/scripts/versioning.mjs +230 -86
  31. package/src/agents/apollo.md +1 -0
  32. package/src/agents/gaia.md +1 -0
  33. package/src/agents/mnemosyne.md +1 -1
  34. package/src/agents/zeus.md +11 -54
  35. package/src/instructions/memory-protocol.instructions.md +6 -3
  36. package/src/instructions/zeus-anti-stall.instructions.md +2 -4
  37. package/src/instructions/zeus-council-synthesis.instructions.md +3 -28
  38. package/src/instructions/zeus-timeout-retry.instructions.md +3 -28
  39. package/src/mcp/code_mode_server.py +431 -55
  40. package/src/mcp/mcp_persistence_server.py +536 -164
  41. package/src/mcp/toon_codec.py +2 -4
  42. package/src/pantheon/background-job-board.ts +47 -5
  43. package/src/pantheon/{delegation-compaction.ts → compaction-context.ts} +10 -100
  44. package/src/pantheon/cost-command.ts +309 -44
  45. package/src/pantheon/delegation-enforce.ts +59 -14
  46. package/src/pantheon/file-persistence-salvage.ts +64 -0
  47. package/src/pantheon/file-persistence.ts +194 -45
  48. package/src/pantheon/goal-loop.ts +1 -1
  49. package/src/pantheon/goal-store.ts +1 -1
  50. package/src/pantheon/idle-continuation.ts +4 -6
  51. package/src/pantheon/model-command.ts +1 -2
  52. package/src/pantheon/native-task-status.ts +139 -0
  53. package/src/pantheon/presets.d.mts +0 -20
  54. package/src/pantheon/presets.mjs +0 -64
  55. package/src/pantheon/session-guard.ts +2 -2
  56. package/src/pantheon/session-id.ts +30 -0
  57. package/src/pantheon/shared-board.ts +37 -0
  58. package/src/pantheon/task-result-guard.ts +106 -0
  59. package/src/pantheon/todo-enforcer.ts +3 -3
  60. package/src/pantheon/tool-ceiling.ts +159 -0
  61. package/src/pantheon/tool-context.ts +23 -0
  62. package/src/pantheon/v2-bridge.ts +1 -4
  63. package/src/plugin-v2.ts +8 -21
  64. package/src/plugin.ts +83 -251
  65. package/src/plugins/pantheon-hooks.ts +51 -0
  66. package/src/plugins/tui/dist/server.d.ts +1 -1
  67. package/src/plugins/tui/dist/server.d.ts.map +1 -1
  68. package/src/plugins/tui/dist/server.js +1 -1
  69. package/src/plugins/tui/dist/server.js.map +1 -1
  70. package/src/plugins/tui/dist/tui.d.ts +261 -77
  71. package/src/plugins/tui/dist/tui.d.ts.map +1 -1
  72. package/src/plugins/tui/dist/tui.js +640 -234
  73. package/src/plugins/tui/dist/tui.js.map +1 -1
  74. package/src/plugins/tui/package-lock.json +2 -2
  75. package/src/plugins/tui/package.json +2 -2
  76. package/src/plugins/tui/src/index.tsx +872 -226
  77. package/src/routing.yml +0 -16
  78. package/src/skills/context-compression/SKILL.md +1 -1
  79. package/scripts/generate-preset-docs.mjs +0 -300
  80. package/scripts/generate-prompts.sh +0 -222
  81. package/scripts/hash_verify.py +0 -192
  82. package/scripts/install/component.mjs +0 -24
  83. package/scripts/install/migrate-config.mjs +0 -302
  84. package/scripts/test-context-compression.sh +0 -169
  85. package/src/pantheon/auto-wake.ts +0 -89
  86. package/src/pantheon/chat-reminders.ts +0 -9
  87. package/src/pantheon/comment-checker.ts +0 -48
  88. package/src/pantheon/compaction-assert.ts +0 -160
  89. package/src/pantheon/cost-tracker.ts +0 -108
  90. package/src/pantheon/delegate-manager.ts +0 -669
  91. package/src/pantheon/delegation-classifier.ts +0 -307
  92. package/src/pantheon/delegation-finalize.ts +0 -374
  93. package/src/pantheon/delegation-notify.ts +0 -212
  94. package/src/pantheon/delegation-toast.ts +0 -73
  95. package/src/pantheon/delegation.ts +0 -1120
  96. package/src/pantheon/dispatch-guard.ts +0 -109
  97. package/src/pantheon/permission-eval.ts +0 -284
  98. package/src/pantheon/preemptive-compact.ts +0 -76
  99. package/src/pantheon/retry-policy.ts +0 -212
  100. package/src/pantheon/step-cap.ts +0 -110
  101. package/src/pantheon/token-opt.ts +0 -781
  102. package/src/pantheon/toon-codec.ts +0 -481
  103. package/src/pantheon/v2-events.ts +0 -131
  104. package/src/pantheon/v2-hooks.ts +0 -287
  105. package/src/pantheon/v2-tool-definitions.ts +0 -331
  106. package/src/pantheon/zeus-delegate-with-retry.ts +0 -406
  107. package/src/plugins/tui/dist/tui.tsx +0 -2605
package/src/plugin.ts CHANGED
@@ -1,9 +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
- import { BackgroundJobBoard } from './pantheon/background-job-board.ts'
6
3
  import { createCommandNormalizer } from './pantheon/command-normalizer.ts'
4
+ import { buildCompactionContext } from './pantheon/compaction-context.ts'
7
5
  import {
8
6
  type ContextSandboxConfig,
9
7
  createContextSandbox,
@@ -12,25 +10,13 @@ import {
12
10
  DEFAULT_LIMITS as SANDBOX_DEFAULT_LIMITS,
13
11
  } from './pantheon/context-sandbox.ts'
14
12
  import { createCostCommand } from './pantheon/cost-command.ts'
15
- import { buildPluginNativeDelegation, resolveDelegateMode } from './pantheon/delegate-manager.ts'
16
- import {
17
- collectRootSessionIDs,
18
- createDelegationTools,
19
- type DelegationClient,
20
- resolveDelegationBudgets,
21
- resolveDelegationTimeoutMs,
22
- startIdleChildScan,
23
- } from './pantheon/delegation.ts'
24
- import { buildCompactionContext } from './pantheon/delegation-compaction.ts'
25
13
  import {
26
14
  createEnforcementGuard,
27
15
  readOnlyRegistry,
28
16
  SessionHierarchyRegistry,
17
+ syncReadOnlySession,
29
18
  zeusReadGuard,
30
19
  } from './pantheon/delegation-enforce.ts'
31
- import { handleDelegationEvent } from './pantheon/delegation-notify.ts'
32
- import { showDelegationTerminalToast, type ToastClient } from './pantheon/delegation-toast.ts'
33
- import { FilePersistenceAdapter } from './pantheon/file-persistence.ts'
34
20
  import { GOAL_LOOP_DEFAULTS, GoalLoop, GoalStore } from './pantheon/goal-loop.ts'
35
21
  import { createReadEnhancer } from './pantheon/hashline/read-enhancer.ts'
36
22
  import { createHashlineEditTool } from './pantheon/hashline/tool.ts'
@@ -38,14 +24,10 @@ import { createIdleDispatcher } from './pantheon/idle-continuation.ts'
38
24
  import { createPantheonLogger } from './pantheon/logger.ts'
39
25
  import { createModelCommand } from './pantheon/model-command.ts'
40
26
  import { pantheonPluginOnce } from './pantheon/plugin-once.ts'
41
- import {
42
- applyActivePresetToConfig,
43
- loadRoutingAgentModels,
44
- loadRoutingMaxSteps,
45
- loadRoutingPermissionTask,
46
- } from './pantheon/presets.mjs'
27
+ import { applyActivePresetToConfig } from './pantheon/presets.mjs'
47
28
  import { safeSessionPath } from './pantheon/session-guard.ts'
48
- import { StepCapTracker } from './pantheon/step-cap.ts'
29
+ import { getSharedBoard } from './pantheon/shared-board.ts'
30
+ import { createTaskResultGuard } from './pantheon/task-result-guard.ts'
49
31
  import {
50
32
  TODO_ENFORCER_DEFAULTS,
51
33
  TodoEnforcer,
@@ -53,6 +35,7 @@ import {
53
35
  todoEnforcerEnabledFromEnv,
54
36
  } from './pantheon/todo-enforcer.ts'
55
37
  import { TodoPreserver } from './pantheon/todo-preserve.ts'
38
+ import { handleToolCeilingEvent, type ToolCeilingResult } from './pantheon/tool-ceiling.ts'
56
39
  import { checkTuiVersionStaleness } from './pantheon/tui-version-check.ts'
57
40
  import { activePresetCandidates, createVisionHandler } from './pantheon/vision.ts'
58
41
 
@@ -64,24 +47,10 @@ import { activePresetCandidates, createVisionHandler } from './pantheon/vision.t
64
47
  // .pantheon/logs/hooks.log; the console echo is opt-in via PANTHEON_HOOKS_LOG=1.
65
48
  const log = createPantheonLogger({ module: 'pantheon-plugin' })
66
49
 
67
- // R4: per-agent step caps from routing.yml `agents.<name>.max_steps`
68
- // (fail-open → {} = no agent capped). R1 retry_policy/cooldown are NOT
69
- // wired here — the plugin has no retry path (opencode cannot intercept task
70
- // completion); zeus waves load them via presets.mjs loaders and pass them
71
- // to zeusDelegateWithRetry explicitly.
72
- const stepCapTracker = new StepCapTracker(loadRoutingMaxSteps({ logger: log }))
73
-
74
- // O5: permission.task glob rules from routing.yml `permission.task`
75
- // (fail-open → undefined = existing runtime matrix only).
76
- const routingPermissionTask = loadRoutingPermissionTask({ logger: log })
77
-
78
- const board = new BackgroundJobBoard({
79
- maxConcurrentPerAgent: 3,
80
- signalDir: '.pantheon/deepwork/board-signals',
81
- })
82
- const persistence = new FilePersistenceAdapter('.pantheon/board/state.json')
83
- board.setPersistence(persistence)
84
- let terminalToastClient: ToastClient = {}
50
+ // beta.5: the board is a PROCESS-wide singleton (getSharedBoard) shared with
51
+ // src/plugins/pantheon-hooks.ts, whose native-task mirror registers children
52
+ // here. globalThis anchoring dedupes the npm+repo double load.
53
+ const board = getSharedBoard()
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,19 +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>()
131
+ const sessionToolCeilings = new Map<string, ToolCeilingResult>()
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
+ }
168
143
 
169
144
  /**
170
145
  * Seed resumed root sessions without making OpenCode startup depend on the
171
146
  * session API responding. Some OpenCode environments do not have the API
172
147
  * ready while plugin config hooks run; awaiting session.list() there leaves
173
- * the TUI stuck before it can open. The delegation layer already treats an
174
- * 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.
175
150
  */
176
151
  function seedRootSessionsInBackground(input: PluginInput): void {
177
152
  // Fail-open: environments without the session API (client.session absent or
@@ -208,62 +183,6 @@ function sdkErrorMessage(error: { data?: { message?: string } } | null | undefin
208
183
  return typeof message === 'string' && message.trim() !== '' ? message : 'request failed'
209
184
  }
210
185
 
211
- /**
212
- * Adapt the opencode SDK client to the structural DelegationClient the
213
- * delegation toolset expects: the SDK wraps every call in a
214
- * `{data, error, request, response}` result while delegation.ts uses direct
215
- * returns. Errors surface as throws so the toolset's own error handling
216
- * (tool errors returned as TEXT) keeps working.
217
- */
218
- function adaptDelegationClient(client: PluginInput['client']): DelegationClient {
219
- return {
220
- session: {
221
- create: async (input) => {
222
- const body: {
223
- parentID?: string
224
- title?: string
225
- model?: { id: string; providerID: string }
226
- } = { parentID: input.body.parentID }
227
- if (input.body.title !== undefined) body.title = input.body.title
228
- // Forward a model only when the active routing profile supplied one.
229
- if (input.body.model !== undefined) body.model = input.body.model
230
- const result = await client.session.create({ body })
231
- if (result.error) throw new Error(sdkErrorMessage(result.error))
232
- return { id: result.data.id }
233
- },
234
- promptAsync: async (input) => {
235
- const path = safeSessionPath(input.path.id)
236
- if (!path) {
237
- log.warn(`[Pantheon Plugin] promptAsync skipped: invalid sessionID "${input.path.id}"`)
238
- throw new Error(`invalid sessionID: ${input.path.id}`)
239
- }
240
- const result = await client.session.promptAsync({
241
- path: path.path,
242
- body: {
243
- agent: input.body.agent,
244
- ...(input.body.model !== undefined
245
- ? { model: { providerID: input.body.model.providerID, modelID: input.body.model.id } }
246
- : {}),
247
- parts: input.body.parts,
248
- },
249
- })
250
- if (result.error) throw new Error(sdkErrorMessage(result.error))
251
- return result.data
252
- },
253
- messages: async (input) => {
254
- const path = safeSessionPath(input.path.id)
255
- if (!path) {
256
- log.warn(`[Pantheon Plugin] messages skipped: invalid sessionID "${input.path.id}"`)
257
- return []
258
- }
259
- const result = await client.session.messages({ path: path.path })
260
- if (result.error) throw new Error(sdkErrorMessage(result.error))
261
- return result.data
262
- },
263
- },
264
- }
265
- }
266
-
267
186
  /**
268
187
  * Adapt the opencode SDK client to the structural TodoEnforcerClient the
269
188
  * todo enforcer expects: unwraps the SDK `{data, error, request, response}`
@@ -344,7 +263,6 @@ function adaptTodoEnforcerClient(client: PluginInput['client']): TodoEnforcerCli
344
263
  * Helpers live in src/pantheon/vision.ts and are imported from there directly.
345
264
  */
346
265
  const plugin: Plugin = async (input: PluginInput) => {
347
- terminalToastClient = input.client as unknown as ToastClient
348
266
  // release-134: runtime guard against DOUBLE registration (duplicate toasts +
349
267
  // duplicate task_ids in one process — 2026-08-12 live logs). opencode
350
268
  // merges the global config (npm-package plugin paths) with the project
@@ -359,100 +277,11 @@ const plugin: Plugin = async (input: PluginInput) => {
359
277
  // is older than the installed package. Runs once, fail-open, no auto-sync.
360
278
  checkTuiVersionStaleness((msg) => log.warn(msg))
361
279
  const vision = createVisionHandler(input)
362
- // Phase 2/3: background delegation toolset + the bound finalize lifecycle
363
- // hook. Completion is observed through the event hook below
364
- // (session.idle / session.error on a child) → finalizeDelegation.
365
- // Fase B3: runtime controls for the two read-only exceptions and the real
366
- // per-child wall-clock timer. No task_budget field is injected into the
367
- // plugin/frontmatter; these are enforced by delegation.ts itself.
368
- const delegationBudgets = resolveDelegationBudgets()
369
- const wallClockTimeoutMs = resolveDelegationTimeoutMs()
370
-
371
- // WS1 (PR #94): delegation backend gate. Default = legacy (the V1
372
- // fire-and-forget toolset, intact); PANTHEON_DELEGATE_MODE=native selects
373
- // the thin manager over native task() semantics. Kill-switch
374
- // PANTHEON_DELEGATION=off covers the native manager; finalizeDelegation stays on
375
- // the legacy observer: it works on the shared board in both modes and must
376
- // never be kill-switched, so children never orphan. The idle hook below is
377
- // untouched.
378
- const delegationClient = adaptDelegationClient(input.client)
379
- // Only an explicitly active routing profile may provide child models.
380
- // An empty map deliberately lets OpenCode inherit the parent model.
381
- const routingAgentModels = loadRoutingAgentModels({
382
- candidates: activePresetCandidates(),
383
- logger: log,
384
- })
385
- const readOnlyAgents = new Set(['apollo', 'gaia'])
386
- const legacyDelegation = createDelegationTools({
387
- board,
388
- client: delegationClient,
389
- options: {
390
- rootSessions,
391
- isChildSession: (sessionID) => sessionHierarchy.isChild(sessionID),
392
- registerChildSession: (sessionID, parentID) => {
393
- sessionHierarchy.register({ id: sessionID, parentID })
394
- },
395
- enforceRuntimeMatrix: true,
396
- readOnlyAgents,
397
- agentModels: routingAgentModels,
398
- delegationBudgets,
399
- stepCapTracker,
400
- ...(routingPermissionTask != null ? { permissionTask: routingPermissionTask } : {}),
401
- ...(wallClockTimeoutMs !== undefined ? { wallClockTimeoutMs } : {}),
402
- },
403
- })
404
- const delegateMode = resolveDelegateMode()
405
- if (delegateMode === 'native') {
406
- log.info('[Pantheon Plugin] Delegation mode: native (PANTHEON_DELEGATE_MODE=native)')
407
- }
408
- const delegation =
409
- delegateMode === 'native'
410
- ? buildPluginNativeDelegation({
411
- board,
412
- client: delegationClient,
413
- isRootSession,
414
- registerChildSession: (sessionID, parentID) => {
415
- sessionHierarchy.register({ id: sessionID, parentID })
416
- },
417
- registerReadOnlySession: (sessionID, info) => {
418
- readOnlyRegistry.register(sessionID, info)
419
- },
420
- readOnlyAgents,
421
- agentModels: routingAgentModels,
422
- stepCap: stepCapTracker,
423
- ...(wallClockTimeoutMs !== undefined ? { wallClockTimeoutMs } : {}),
424
- finalizeDelegation: legacyDelegation.finalizeDelegation,
425
- })
426
- : legacyDelegation
427
-
428
- // Proactive idle-child scan (Fix 2): finalize children that are idle on the
429
- // board but have no MD report on disk — eliminates the phantom "running"
430
- // window. Runs every 30s (configurable via PANTHEON_IDLE_SCAN_INTERVAL_MS),
431
- // unref'd so it never holds the process open. Fail-open by design.
432
- const idleScanIntervalMs = Number(process.env.PANTHEON_IDLE_SCAN_INTERVAL_MS) || undefined
433
- startIdleChildScan(
434
- {
435
- board,
436
- finalize: (childSessionID, opts) => delegation.finalizeDelegation(childSessionID, opts),
437
- hasReport: (job) => {
438
- try {
439
- const filePath = join('.pantheon/delegations', job.parentSessionID, `${job.alias}.md`)
440
- return existsSync(filePath)
441
- } catch {
442
- return false
443
- }
444
- },
445
- logger: log,
446
- },
447
- idleScanIntervalMs,
448
- )
449
-
450
- // Wave 1 (PR #46): TODO continuation enforcer for root/non-board sessions.
451
- // Mirrors routing.yml `todo_enforcer` (plugin scope has no routing.yml
452
- // access — COMPACTION_MAX_ITEMS pattern). The event hook routes non-board
453
- // session.idle events here; board-child idles go to finalizeDelegation.
454
- // Runtime kill-switch: PANTHEON_TODO_ENFORCER=off (routing.yml is a doc
455
- // 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).
456
285
  const todoEnforcer = new TodoEnforcer({
457
286
  client: adaptTodoEnforcerClient(input.client),
458
287
  board,
@@ -504,15 +333,18 @@ const plugin: Plugin = async (input: PluginInput) => {
504
333
  },
505
334
  }
506
335
  const sandboxHandler = createContextSandbox(sandboxConfig)
336
+ // Task Result Guard (2026-09-10): detect empty task() results from native
337
+ // opencode subagent calls and convert to explicit errors. Runs FIRST in the
338
+ // chain so the error message is visible even if later handlers skip.
339
+ const taskResultGuard = createTaskResultGuard()
507
340
  const combinedAfter: typeof readEnhancer = async (input, output) => {
341
+ await taskResultGuard(input, output)
508
342
  await sandboxHandler(input, output)
509
343
  await readEnhancer(input, output)
510
344
  }
511
345
 
512
346
  // Wave 4 (PR #46): /cost — delegation cost + token visibility. Reads
513
- // opencode.db read-only (node:sqlite, falls back to scripts/cost.mjs).
514
- // Fully wired (unlike dispatch-guard, which is manual-orchestration-only
515
- // because opencode 1.18.x cannot intercept task completion via hooks).
347
+ // opencode.db read-only via the single node:sqlite backend. Fully wired.
516
348
  const costCommand = createCostCommand()
517
349
  const modelCommand = createModelCommand()
518
350
 
@@ -521,11 +353,11 @@ const plugin: Plugin = async (input: PluginInput) => {
521
353
  // compaction-134 seed: opencode does NOT replay `session.created`
522
354
  // events for sessions that exist before plugin load, so after a
523
355
  // restart the resumed ROOT session never enters rootSessions and the
524
- // depth guard would reject its pantheon_delegate calls. Seed the
525
- // registry from session.list() in the background — every session
526
- // WITHOUT a parentID is a root. Fail-open: a failed/throttled list
527
- // leaves the set untouched (delegation.ts treats unknown sessions as
528
- // 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.
529
361
  // Context sandbox config — opencode.json → context_sandbox: {enabled, limits}
530
362
  try {
531
363
  const raw = (config as unknown as Record<string, unknown>).context_sandbox
@@ -570,12 +402,9 @@ const plugin: Plugin = async (input: PluginInput) => {
570
402
  log.warn('[plugin] preset application failed (see logs for details)')
571
403
  }
572
404
  },
573
- // Phase 2: background delegation tools (structural — matches the `tool`
574
- // 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.
575
407
  tool: {
576
- pantheon_delegate: delegation.pantheon_delegate,
577
- pantheon_delegation_read: delegation.pantheon_delegation_read,
578
- pantheon_delegation_list: delegation.pantheon_delegation_list,
579
408
  hashline_edit: hashlineEdit,
580
409
  // Wave 3 (PR #46): full-auto goal tools (single active goal/session).
581
410
  pantheon_goal_create: goalTools.pantheon_goal_create,
@@ -598,6 +427,10 @@ const plugin: Plugin = async (input: PluginInput) => {
598
427
  // The SDK exposes the active agent here; tool.execute.before has no
599
428
  // agent field. Never infer identity from a prompt or tool name.
600
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)
601
434
  },
602
435
  'experimental.chat.messages.transform': vision.messagesTransform,
603
436
  event: async ({ event: ev }) => {
@@ -609,33 +442,27 @@ const plugin: Plugin = async (input: PluginInput) => {
609
442
  if (info.parentID === undefined) rootSessions.add(info.id)
610
443
  }
611
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
+ }
612
451
  // release-134 Phase 3: activate the todo snapshot captured at
613
452
  // compacting time — the first todowrite within the restore window is
614
453
  // rewritten with the exact list (see todo-preserve.ts). Fail-open.
615
454
  if (ev.type === 'session.compacted') {
616
455
  await todoPreserver.onCompacted(ev.properties.sessionID)
617
456
  }
618
- // Phase 3: observe completion on child sessions → finalizeDelegation.
619
- // The board transition fires onTerminal → the file-only audit log (no
620
- // chat delivery — see the onTerminal listener above). Unknown sessions
621
- // are a no-op.
622
- try {
623
- const delegated = await handleDelegationEvent(ev, {
624
- board,
625
- finalize: (childSessionID, opts) => delegation.finalizeDelegation(childSessionID, opts),
626
- })
627
- // Wave 1/3: non-board idle → idle dispatcher. Board children are
628
- // handled by handleDelegationEvent above; everything else (roots,
629
- // non-board sessions) is routed by priority: an active goal owns the
630
- // idle (goal loop), otherwise the todo enforcer re-injects. Neither
631
- // onIdle ever throws (internal failures are logged + swallowed), so
632
- // the session is safe.
633
- if (!delegated && ev.type === 'session.idle') {
634
- await idleDispatcher.onIdle(ev.properties.sessionID)
635
- }
636
- } catch (err) {
637
- // The event hook must never break the session.
638
- log.error('[Pantheon Plugin] Delegation event handling failed:', err)
457
+ if (ev.type === 'message.updated') {
458
+ await handleToolCeilingEvent(input.client, ev, sessionToolCeilings)
459
+ }
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)
639
466
  }
640
467
  await vision.event({ event: ev })
641
468
  },
@@ -661,16 +488,14 @@ const plugin: Plugin = async (input: PluginInput) => {
661
488
  // through untouched.
662
489
  'tool.execute.after': combinedAfter,
663
490
  // Phase 4 + release-134 Phase 2: keep the session's working state across
664
- // compaction — preservation directive, active goals (<mission_context>),
665
- // pending todos (<todo_context>), and in-flight background delegations
666
- // (running + unread terminal ≤ max_compaction_items). Guarded: only
667
- // pushes when there is something to preserve; a build failure must never
668
- // 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.
669
495
  'experimental.session.compacting': async (_input, output) => {
670
496
  try {
671
- const blocks = await buildCompactionContext(board, {
497
+ const blocks = await buildCompactionContext({
672
498
  sessionID: _input.sessionID,
673
- maxItems: COMPACTION_MAX_ITEMS,
674
499
  goals: {
675
500
  enabled: GOAL_LOOP_DEFAULTS.enabled,
676
501
  list: (sessionID: string) => goalStore.list(sessionID),
@@ -680,6 +505,13 @@ const plugin: Plugin = async (input: PluginInput) => {
680
505
  list: (sessionID: string) => todoEnforcer.listPendingTodos(sessionID),
681
506
  },
682
507
  })
508
+ const ceiling = sessionToolCeilings.get(_input.sessionID)
509
+ if (ceiling?.status === 'OK') {
510
+ output.context.push(
511
+ `<tool_ceiling remaining_context_tokens="${ceiling.ceiling}">` +
512
+ 'Use the remaining host-reported context budget when selecting tools.</tool_ceiling>',
513
+ )
514
+ }
683
515
  if (blocks.length > 0) {
684
516
  output.context.push(...blocks)
685
517
  }
@@ -177,6 +177,7 @@ import { appendFile, mkdir } from 'node:fs/promises'
177
177
  import { join } from 'node:path'
178
178
  import type { Plugin, PluginInput } from '@opencode-ai/plugin'
179
179
  import { pantheonPluginOnce } from '../pantheon/plugin-once.ts'
180
+ import { getSharedBoard } from '../pantheon/shared-board.ts'
180
181
  import { type HookPayload, type HookResult, runHook } from './hook-runner.ts'
181
182
 
182
183
  /** Tools that represent a subagent delegation (opencode `task` tool etc.). */
@@ -775,6 +776,51 @@ function delegationAgent(tool: string, args?: unknown): string {
775
776
  return tool
776
777
  }
777
778
 
779
+ /**
780
+ * beta.5: mirror a native background task dispatch onto the SHARED board
781
+ * (getSharedBoard) so the existing finalize path in plugin.ts (session.idle
782
+ * event + 30s idle scan) writes the terminal report and the TUI delegation
783
+ * panel tracks the child end-to-end. Fire-and-forget and silent: a mirror
784
+ * failure must never surface into the tool call or the transcript.
785
+ * registerLaunchIfAbsent keeps it idempotent against any other dispatcher
786
+ * (plugin double-load, repeated after-hook firings).
787
+ */
788
+ function mirrorNativeBackgroundDispatch(
789
+ input: { tool: string; sessionID: string },
790
+ args: unknown,
791
+ result: ToolExecuteAfterResult | null,
792
+ ): void {
793
+ const taskID = extractTaskId(result)
794
+ if (taskID === '' || typeof input.sessionID !== 'string' || input.sessionID === '') return
795
+ const description = trim(delegationDescription(args) ?? `native ${input.tool} dispatch`, 200)
796
+ try {
797
+ getSharedBoard()
798
+ .registerLaunchIfAbsent({
799
+ taskID,
800
+ parentSessionID: input.sessionID,
801
+ agent: delegationAgent(input.tool, args),
802
+ description,
803
+ })
804
+ .catch(() => {
805
+ // Mirror is best-effort — never surface into the tool call.
806
+ })
807
+ } catch {
808
+ // Never let the mirror take down the tool call.
809
+ }
810
+ }
811
+
812
+ /** Task description from task-tool args (description ?? prompt); undefined when absent. */
813
+ function delegationDescription(args: unknown): string | undefined {
814
+ try {
815
+ const a = (args ?? {}) as Record<string, unknown>
816
+ const raw = a.description ?? a.prompt
817
+ if (typeof raw === 'string' && raw.trim() !== '') return raw
818
+ } catch {
819
+ // Malformed args — fall through to the generic description.
820
+ }
821
+ return undefined
822
+ }
823
+
778
824
  /** 🚀 delegation-started toast (variant 'info', 2500ms), deduped per agent. */
779
825
  async function delegationStartedToast(ctx: HookContext, agent: string): Promise<void> {
780
826
  await throttledToast(ctx, {
@@ -1311,6 +1357,11 @@ const plugin: Plugin = async ({ client, directory }) => {
1311
1357
  // NOT a completion: do not mark the group done and do not fire ✅
1312
1358
  // — that would corrupt olympians aggregation. Real completion of
1313
1359
  // background tasks is unobservable on 1.18.13 (see header).
1360
+ // beta.5: mirror the native background child onto the SHARED
1361
+ // board so the existing finalize path (session.idle + idle scan
1362
+ // in plugin.ts) writes the terminal report and the TUI
1363
+ // delegation panel tracks the child end-to-end. Fire-and-forget.
1364
+ mirrorNativeBackgroundDispatch(input, args, result)
1314
1365
  } else {
1315
1366
  const handledByOlympians = handleOlympiansCompletion(ctx, agent)
1316
1367
  if (!handledByOlympians) void delegationDoneToast(ctx, agent)
@@ -14,4 +14,4 @@
14
14
  declare function server(): {};
15
15
  //#endregion
16
16
  export { server as default };
17
- //# sourceMappingURL=server.d.ts.map
17
+ //# sourceMappingURL=server.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"server.d.ts","names":[],"sources":["../src/server.ts"],"mappings":";;;;;;;;;;;;;iBAYwB"}
1
+ {"version":3,"file":"server.d.ts","names":[],"sources":["../src/server.ts"],"mappings":";;;;;;;;;;;;;iBAYwB"}
@@ -17,4 +17,4 @@ function server() {
17
17
  //#endregion
18
18
  export { server as default };
19
19
 
20
- //# sourceMappingURL=server.js.map
20
+ //# sourceMappingURL=server.js.map
@@ -1 +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"}
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"}