pantheon-opencode 1.4.3 → 1.5.0-beta.10

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 (118) hide show
  1. package/.pantheon/code-mode/README.md +93 -0
  2. package/.pantheon/code-mode/checkpoint-session.py +7 -0
  3. package/.pantheon/code-mode/checkpoint-session.sh +11 -0
  4. package/.pantheon/code-mode/checkpoint_session.py +481 -0
  5. package/.pantheon/code-mode/compress-inline.py +450 -0
  6. package/.pantheon/code-mode/eval-llm-judge.py +255 -0
  7. package/.pantheon/code-mode/eval-monte-carlo.py +368 -0
  8. package/.pantheon/code-mode/eval-run.py +197 -0
  9. package/.pantheon/code-mode/eval-static.py +321 -0
  10. package/.pantheon/code-mode/example-sync.sh +5 -0
  11. package/.pantheon/code-mode/manifest.json +21 -0
  12. package/.pantheon/code-mode/session-end-save.py +186 -0
  13. package/.pantheon/code-mode/session-end-save.sh +18 -0
  14. package/.pantheon/code-mode/test-allowed.sh +5 -0
  15. package/.pantheon/code-mode/test-auto-continue-checkpoint.sh +179 -0
  16. package/.pantheon/code-mode/test-env.sh +7 -0
  17. package/.pantheon/code-mode/test-prlimit.sh +9 -0
  18. package/.pantheon/code-mode/test-timeout.sh +6 -0
  19. package/CHANGELOG.md +1026 -0
  20. package/README.md +292 -1080
  21. package/README.pt-BR.md +308 -0
  22. package/bin/pantheon-init.mjs +207 -31
  23. package/opencode.json +1 -0
  24. package/package.json +34 -14
  25. package/plugin.json +15 -0
  26. package/pyproject.toml +65 -0
  27. package/scripts/_pantheon_paths.py +48 -7
  28. package/scripts/ci-validate-yaml.py +7 -2
  29. package/scripts/code_mode_server.py +451 -44
  30. package/scripts/cost.mjs +30 -14
  31. package/scripts/doctor.mjs +306 -129
  32. package/scripts/generate-preset-docs.mjs +127 -66
  33. package/scripts/generate-prompts.sh +2 -2
  34. package/scripts/install/cli-ui.mjs +23 -10
  35. package/scripts/install/config-migration.mjs +399 -0
  36. package/scripts/install/health-check.mjs +3 -3
  37. package/scripts/install/model-picker.mjs +72 -23
  38. package/scripts/install/opencode-version.mjs +47 -0
  39. package/scripts/install/opencode.mjs +396 -121
  40. package/scripts/install/plugin.mjs +89 -49
  41. package/scripts/install/shared.mjs +55 -159
  42. package/scripts/install/strings.mjs +129 -0
  43. package/scripts/install-mcp.mjs +45 -232
  44. package/scripts/manifest-inventory.mjs +410 -0
  45. package/scripts/mcp_persistence_server.py +798 -60
  46. package/scripts/mcp_resources_server.py +144 -40
  47. package/scripts/memory_mcp_server.py +2 -2
  48. package/scripts/package-evidence.mjs +242 -0
  49. package/scripts/probe-context-rehydrate.mjs +240 -0
  50. package/scripts/probe-pantheon-cost.mjs +313 -0
  51. package/scripts/recover-zenodo-deposition.mjs +146 -0
  52. package/scripts/release-beta-version.mjs +56 -60
  53. package/scripts/sync-artifacts.mjs +169 -0
  54. package/scripts/sync-tui.mjs +135 -42
  55. package/scripts/test-opencode-v1-v2-sandbox.sh +862 -0
  56. package/scripts/uninstall.mjs +298 -954
  57. package/scripts/validate-zenodo-release.mjs +154 -0
  58. package/scripts/validation-policy.mjs +7 -7
  59. package/scripts/version-check.mjs +113 -108
  60. package/scripts/versioning.mjs +230 -86
  61. package/scripts/zenodo-artifact.mjs +21 -0
  62. package/scripts/zenodo-release-state.mjs +53 -0
  63. package/src/agents/mnemosyne.md +1 -1
  64. package/src/agents/zeus.md +27 -16
  65. package/src/mcp/_pantheon_paths.py +48 -7
  66. package/src/mcp/code_mode_server.py +451 -44
  67. package/src/mcp/eval_store.py +1 -1
  68. package/src/mcp/mcp_persistence_server.py +798 -60
  69. package/src/mcp/mcp_resources_server.py +144 -40
  70. package/src/mcp/pantheon_vision_server.py +1 -1
  71. package/src/mcp/toon_codec.py +491 -0
  72. package/src/pantheon/auto-wake.ts +1 -5
  73. package/src/pantheon/background-job-board.ts +110 -2
  74. package/src/pantheon/compaction-handoff.ts +304 -0
  75. package/src/pantheon/cost-command.ts +356 -69
  76. package/src/pantheon/delegate-manager.ts +766 -0
  77. package/src/pantheon/delegation-classifier.ts +307 -0
  78. package/src/pantheon/delegation-enforce.ts +13 -12
  79. package/src/pantheon/delegation-finalize.ts +406 -0
  80. package/src/pantheon/delegation-notify.ts +212 -0
  81. package/src/pantheon/delegation-toast.ts +73 -0
  82. package/src/pantheon/delegation.ts +1337 -0
  83. package/src/pantheon/file-persistence-salvage.ts +64 -0
  84. package/src/pantheon/file-persistence.ts +194 -45
  85. package/src/pantheon/goal-loop.ts +2 -2
  86. package/src/pantheon/goal-store.ts +1 -1
  87. package/src/pantheon/hashline/tool.ts +2 -2
  88. package/src/pantheon/model-command.ts +2 -1
  89. package/src/pantheon/native-probe.ts +234 -0
  90. package/src/pantheon/native-task-status.ts +139 -0
  91. package/src/pantheon/permission-eval.ts +284 -0
  92. package/src/pantheon/provider-capabilities.ts +221 -0
  93. package/src/pantheon/provider-stream-tracker.ts +182 -0
  94. package/src/pantheon/shared-board.ts +37 -0
  95. package/src/pantheon/step-cap.ts +110 -0
  96. package/src/pantheon/task-result-guard.ts +107 -0
  97. package/src/pantheon/token-opt.ts +689 -0
  98. package/src/pantheon/tool-ceiling.ts +159 -0
  99. package/src/pantheon/toon-codec.ts +481 -0
  100. package/src/pantheon/tui-version-check.ts +1 -3
  101. package/src/pantheon/v2-bridge.ts +129 -0
  102. package/src/pantheon/v2-events.ts +148 -0
  103. package/src/pantheon/v2-hooks.ts +287 -0
  104. package/src/pantheon/v2-tool-definitions.ts +331 -0
  105. package/src/pantheon/zeus-delegate-with-retry.ts +406 -0
  106. package/src/plugin-v2/index.ts +21 -0
  107. package/src/plugin-v2.ts +824 -0
  108. package/src/plugin.ts +261 -51
  109. package/src/plugins/pantheon-hooks.ts +51 -0
  110. package/src/plugins/tui/dist/tui.d.ts +116 -24
  111. package/src/plugins/tui/dist/tui.d.ts.map +1 -1
  112. package/src/plugins/tui/dist/tui.js +416 -149
  113. package/src/plugins/tui/dist/tui.tsx +456 -87
  114. package/src/plugins/tui/package-lock.json +51 -51
  115. package/src/plugins/tui/package.json +7 -7
  116. package/src/plugins/tui/src/index.tsx +462 -95
  117. package/src/skills/README.md +0 -2
  118. package/src/pantheon/tool-context.ts +0 -41
package/src/plugin.ts CHANGED
@@ -1,6 +1,7 @@
1
+ import { existsSync } from 'node:fs'
2
+ import { join } from 'node:path'
1
3
  import type { Plugin, PluginInput } from '@opencode-ai/plugin'
2
4
  import type { PluginConfig } from 'opencode'
3
- import { BackgroundJobBoard } from './pantheon/background-job-board.ts'
4
5
  import { createCommandNormalizer } from './pantheon/command-normalizer.ts'
5
6
  import {
6
7
  type ContextSandboxConfig,
@@ -10,6 +11,15 @@ import {
10
11
  DEFAULT_LIMITS as SANDBOX_DEFAULT_LIMITS,
11
12
  } from './pantheon/context-sandbox.ts'
12
13
  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'
13
23
  import { buildCompactionContext } from './pantheon/delegation-compaction.ts'
14
24
  import {
15
25
  createEnforcementGuard,
@@ -17,7 +27,8 @@ import {
17
27
  SessionHierarchyRegistry,
18
28
  zeusReadGuard,
19
29
  } from './pantheon/delegation-enforce.ts'
20
- import { FilePersistenceAdapter } from './pantheon/file-persistence.ts'
30
+ import { handleDelegationEvent } from './pantheon/delegation-notify.ts'
31
+ import { showDelegationTerminalToast, type ToastClient } from './pantheon/delegation-toast.ts'
21
32
  import { GOAL_LOOP_DEFAULTS, GoalLoop, GoalStore } from './pantheon/goal-loop.ts'
22
33
  import { createReadEnhancer } from './pantheon/hashline/read-enhancer.ts'
23
34
  import { createHashlineEditTool } from './pantheon/hashline/tool.ts'
@@ -25,8 +36,16 @@ import { createIdleDispatcher } from './pantheon/idle-continuation.ts'
25
36
  import { createPantheonLogger } from './pantheon/logger.ts'
26
37
  import { createModelCommand } from './pantheon/model-command.ts'
27
38
  import { pantheonPluginOnce } from './pantheon/plugin-once.ts'
28
- import { applyActivePresetToConfig } from './pantheon/presets.mjs'
39
+ import {
40
+ applyActivePresetToConfig,
41
+ loadRoutingAgentModels,
42
+ loadRoutingMaxSteps,
43
+ loadRoutingPermissionTask,
44
+ } from './pantheon/presets.mjs'
29
45
  import { safeSessionPath } from './pantheon/session-guard.ts'
46
+ import { getSharedBoard } from './pantheon/shared-board.ts'
47
+ import { StepCapTracker } from './pantheon/step-cap.ts'
48
+ import { createTaskResultGuard } from './pantheon/task-result-guard.ts'
30
49
  import {
31
50
  TODO_ENFORCER_DEFAULTS,
32
51
  TodoEnforcer,
@@ -34,6 +53,7 @@ import {
34
53
  todoEnforcerEnabledFromEnv,
35
54
  } from './pantheon/todo-enforcer.ts'
36
55
  import { TodoPreserver } from './pantheon/todo-preserve.ts'
56
+ import { handleToolCeilingEvent, type ToolCeilingResult } from './pantheon/tool-ceiling.ts'
37
57
  import { checkTuiVersionStaleness } from './pantheon/tui-version-check.ts'
38
58
  import { activePresetCandidates, createVisionHandler } from './pantheon/vision.ts'
39
59
 
@@ -45,12 +65,23 @@ import { activePresetCandidates, createVisionHandler } from './pantheon/vision.t
45
65
  // .pantheon/logs/hooks.log; the console echo is opt-in via PANTHEON_HOOKS_LOG=1.
46
66
  const log = createPantheonLogger({ module: 'pantheon-plugin' })
47
67
 
48
- const board = new BackgroundJobBoard({
49
- maxConcurrentPerAgent: 3,
50
- signalDir: '.pantheon/deepwork/board-signals',
51
- })
52
- const persistence = new FilePersistenceAdapter('.pantheon/board/state.json')
53
- board.setPersistence(persistence)
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
+ // beta.5: the board is a PROCESS-wide singleton (getSharedBoard) shared with
80
+ // 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.
83
+ const board = getSharedBoard()
84
+ let terminalToastClient: ToastClient = {}
54
85
  board
55
86
  .recoverRunningJobs()
56
87
  .catch((err) => log.error('[Pantheon Plugin] Failed to recover running jobs:', err))
@@ -60,13 +91,17 @@ board
60
91
  // file-only log line (console echo opt-in via PANTHEON_HOOKS_LOG). There is
61
92
  // deliberately NO chat delivery — the user policy is zero delegation
62
93
  // notifications in the transcript. Completion visibility lives in the board
63
- // `[unread]` marker and compaction carry-forward.
94
+ // `[unread]` marker (pantheon_delegation_list), pantheon_delegation_read,
95
+ // TUI toasts (pantheon-hooks, PANTHEON_TOASTS gate) and compaction
96
+ // carry-forward.
64
97
  board.onTerminal((taskID: string) => {
65
98
  const job = board.get(taskID)
66
99
  if (!job) return
67
100
  log.info(
68
101
  `[Pantheon Plugin] Board terminal: [${job.alias}] ${job.description} → ${job.state}${job.resultSummary ? ` — ${job.resultSummary}` : ''}`,
69
102
  )
103
+ // Deliberately direct-to-TUI: onTerminal must not inject chat/transcript text.
104
+ void showDelegationTerminalToast(terminalToastClient, job)
70
105
  })
71
106
 
72
107
  // Periodically prune terminal/reconciled jobs (24h TTL, every 30 min).
@@ -84,15 +119,23 @@ setInterval(
84
119
 
85
120
  // ─── Phase 4: Read-Only Enforcement + Compaction Context ───────────────
86
121
 
87
- // The read-only registry is populated when a child session is launched
88
- // read-only (explicit flag or agent ∈ readOnlyAgents). The guard denies
89
- // edit/write/bash/task inside those sessions by throwing from
90
- // tool.execute.before; unknown sessions are allowed (safe default).
122
+ // WS1 (PR #94): root-session predicate shared by the Phase 4 enforcement
123
+ // guard and the native delegate toolset — both must agree on what a root is.
124
+ // (Hoisted function: `rootSessions`/`sessionHierarchy` below are resolved at
125
+ // call time, after module init.)
126
+ function isRootSession(sessionID: string): boolean {
127
+ return rootSessions.has(sessionID) || sessionHierarchy.isRoot(sessionID)
128
+ }
129
+
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).
91
134
  const enforcementGuard = createEnforcementGuard({
92
135
  getReadOnlySessions: () => readOnlyRegistry.sessionIDs(),
93
136
  options: {
94
137
  getSessionAgent: (sessionID) => sessionAgents.get(sessionID),
95
- isRootSession: (sessionID) => rootSessions.has(sessionID) || sessionHierarchy.isRoot(sessionID),
138
+ isRootSession,
96
139
  isChildSession: (sessionID) => sessionHierarchy.isChild(sessionID),
97
140
  logger: log,
98
141
  },
@@ -108,7 +151,7 @@ const commandNormalizer = createCommandNormalizer({ logger: log })
108
151
  // Mirrors routing.yml background_delegation.max_compaction_items.
109
152
  const COMPACTION_MAX_ITEMS = 10
110
153
 
111
- // Root-session registry for the child-session depth guard: sessions created
154
+ // Root-session registry for the delegation depth guard: sessions created
112
155
  // without a parentID are roots. Populated from TWO sources:
113
156
  // 1. the `session.created` event hook (live roots — fires for sessions
114
157
  // created while the plugin runs);
@@ -116,25 +159,26 @@ const COMPACTION_MAX_ITEMS = 10
116
159
  // hook (compaction-134) — opencode does NOT replay session.created
117
160
  // events for sessions that exist before plugin load, so without the
118
161
  // seed a resumed root would never enter this set after a restart.
119
- // The hierarchy registry additionally falls back to known children ("any
120
- // session WE created is a sub-session; unknown sessions are roots") so the
121
- // depth guard works even when this set is incomplete.
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.
122
165
  const rootSessions = new Set<string>()
123
166
  const sessionHierarchy = new SessionHierarchyRegistry()
124
167
  const sessionAgents = new Map<string, string>()
168
+ const sessionToolCeilings = new Map<string, ToolCeilingResult>()
125
169
 
126
170
  /**
127
171
  * Seed resumed root sessions without making OpenCode startup depend on the
128
172
  * session API responding. Some OpenCode environments do not have the API
129
173
  * ready while plugin config hooks run; awaiting session.list() there leaves
130
- * the TUI stuck before it can open. The hierarchy registry already treats an
174
+ * the TUI stuck before it can open. The delegation layer already treats an
131
175
  * unknown session as a root, so this enrichment is deliberately best-effort.
132
176
  */
133
177
  function seedRootSessionsInBackground(input: PluginInput): void {
134
178
  // Fail-open: environments without the session API (client.session absent or
135
179
  // not yet wired while config hooks run) must NOT break startup. `session`
136
180
  // is captured and the method invoked on it so `this` stays bound; the
137
- // hierarchy registry already treats unknown sessions as roots, so skipping
181
+ // delegation layer already treats unknown sessions as roots, so skipping
138
182
  // the seed is always safe.
139
183
  const session = input.client.session
140
184
  if (typeof session?.list !== 'function') return
@@ -144,15 +188,10 @@ function seedRootSessionsInBackground(input: PluginInput): void {
144
188
  .then((result) => {
145
189
  if (!result.error && Array.isArray(result.data)) {
146
190
  sessionHierarchy.completeSeed(result.data)
147
- let seeded = 0
148
- for (const info of result.data) {
149
- if (info.parentID === undefined) {
150
- rootSessions.add(info.id)
151
- seeded += 1
152
- }
153
- }
154
- if (seeded > 0) {
155
- log.info(`[Pantheon Plugin] Seeded ${seeded} root session(s) from session.list`)
191
+ const seeded = collectRootSessionIDs(result.data)
192
+ if (seeded.size > 0) {
193
+ for (const id of seeded) rootSessions.add(id)
194
+ log.info(`[Pantheon Plugin] Seeded ${seeded.size} root session(s) from session.list`)
156
195
  }
157
196
  } else {
158
197
  sessionHierarchy.failSeed()
@@ -170,6 +209,62 @@ function sdkErrorMessage(error: { data?: { message?: string } } | null | undefin
170
209
  return typeof message === 'string' && message.trim() !== '' ? message : 'request failed'
171
210
  }
172
211
 
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
+
173
268
  /**
174
269
  * Adapt the opencode SDK client to the structural TodoEnforcerClient the
175
270
  * todo enforcer expects: unwraps the SDK `{data, error, request, response}`
@@ -250,6 +345,7 @@ function adaptTodoEnforcerClient(client: PluginInput['client']): TodoEnforcerCli
250
345
  * Helpers live in src/pantheon/vision.ts and are imported from there directly.
251
346
  */
252
347
  const plugin: Plugin = async (input: PluginInput) => {
348
+ terminalToastClient = input.client as unknown as ToastClient
253
349
  // release-134: runtime guard against DOUBLE registration (duplicate toasts +
254
350
  // duplicate task_ids in one process — 2026-08-12 live logs). opencode
255
351
  // merges the global config (npm-package plugin paths) with the project
@@ -264,12 +360,100 @@ const plugin: Plugin = async (input: PluginInput) => {
264
360
  // is older than the installed package. Runs once, fail-open, no auto-sync.
265
361
  checkTuiVersionStaleness((msg) => log.warn(msg))
266
362
  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
+
267
451
  // Wave 1 (PR #46): TODO continuation enforcer for root/non-board sessions.
268
452
  // Mirrors routing.yml `todo_enforcer` (plugin scope has no routing.yml
269
- // access — COMPACTION_MAX_ITEMS pattern). The event hook routes all
270
- // session.idle events here. Runtime kill-switch: PANTHEON_TODO_ENFORCER=off
271
- // (routing.yml is a doc mirror only — the env var is the real switch,
272
- // PANTHEON_TOASTS pattern).
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).
273
457
  const todoEnforcer = new TodoEnforcer({
274
458
  client: adaptTodoEnforcerClient(input.client),
275
459
  board,
@@ -321,13 +505,18 @@ const plugin: Plugin = async (input: PluginInput) => {
321
505
  },
322
506
  }
323
507
  const sandboxHandler = createContextSandbox(sandboxConfig)
508
+ // Task Result Guard (2026-09-10): detect empty task() results from native
509
+ // opencode subagent calls and convert to explicit errors. Runs FIRST in the
510
+ // chain so the error message is visible even if later handlers skip.
511
+ const taskResultGuard = createTaskResultGuard()
324
512
  const combinedAfter: typeof readEnhancer = async (input, output) => {
513
+ await taskResultGuard(input, output)
325
514
  await sandboxHandler(input, output)
326
515
  await readEnhancer(input, output)
327
516
  }
328
517
 
329
- // Wave 4 (PR #46): /cost — cost + token visibility by agent. Reads
330
- // opencode.db read-only (node:sqlite, falls back to scripts/cost.mjs).
518
+ // Wave 4 (PR #46): /cost — delegation cost + token visibility. Reads
519
+ // opencode.db read-only via the single node:sqlite backend.
331
520
  // Fully wired (unlike dispatch-guard, which is manual-orchestration-only
332
521
  // because opencode 1.18.x cannot intercept task completion via hooks).
333
522
  const costCommand = createCostCommand()
@@ -338,11 +527,11 @@ const plugin: Plugin = async (input: PluginInput) => {
338
527
  // compaction-134 seed: opencode does NOT replay `session.created`
339
528
  // events for sessions that exist before plugin load, so after a
340
529
  // restart the resumed ROOT session never enters rootSessions and the
341
- // depth guard would treat it as a child. Seed the registry from
342
- // session.list() in the background — every session WITHOUT a parentID
343
- // is a root. Fail-open: a failed/throttled list leaves the set
344
- // untouched (the hierarchy registry treats unknown sessions as roots),
345
- // and config setup must never wait for the session API.
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.
346
535
  // Context sandbox config — opencode.json → context_sandbox: {enabled, limits}
347
536
  try {
348
537
  const raw = (config as unknown as Record<string, unknown>).context_sandbox
@@ -387,16 +576,18 @@ const plugin: Plugin = async (input: PluginInput) => {
387
576
  log.warn('[plugin] preset application failed (see logs for details)')
388
577
  }
389
578
  },
390
- // Phase 2: structural tool registrations (matches the `tool` hook field
391
- // shape). Native task() owns orchestration; the plugin registers the
392
- // deterministic /pantheon-model, /cost and full-auto goal tools.
579
+ // Phase 2: background delegation tools (structural — matches the `tool`
580
+ // hook field shape; read_only sessions feed the Phase 4 registry).
393
581
  tool: {
582
+ pantheon_delegate: delegation.pantheon_delegate,
583
+ pantheon_delegation_read: delegation.pantheon_delegation_read,
584
+ pantheon_delegation_list: delegation.pantheon_delegation_list,
394
585
  hashline_edit: hashlineEdit,
395
586
  // Wave 3 (PR #46): full-auto goal tools (single active goal/session).
396
587
  pantheon_goal_create: goalTools.pantheon_goal_create,
397
588
  pantheon_goal_update: goalTools.pantheon_goal_update,
398
589
  pantheon_goal_get: goalTools.pantheon_goal_get,
399
- // Wave 4 (PR #46): /cost — cost report by agent from opencode.db.
590
+ // Wave 4 (PR #46): /cost — delegation cost report from opencode.db.
400
591
  pantheon_cost: costCommand.pantheon_cost,
401
592
  // Deterministic /pantheon-model management of top-level model fields.
402
593
  pantheon_model: modelCommand.pantheon_model,
@@ -430,13 +621,25 @@ const plugin: Plugin = async (input: PluginInput) => {
430
621
  if (ev.type === 'session.compacted') {
431
622
  await todoPreserver.onCompacted(ev.properties.sessionID)
432
623
  }
433
- // Native task() children are owned by OpenCode. Idle routing for
434
- // generic root/non-board sessions remains priority ordered: an active
435
- // goal owns the idle (goal loop), otherwise the todo enforcer
436
- // re-injects. Neither onIdle ever throws (internal failures are logged
437
- // + swallowed), so the session is safe.
624
+ if (ev.type === 'message.updated') {
625
+ await handleToolCeilingEvent(input.client, ev, sessionToolCeilings)
626
+ }
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.
438
631
  try {
439
- if (ev.type === 'session.idle') {
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') {
440
643
  await idleDispatcher.onIdle(ev.properties.sessionID)
441
644
  }
442
645
  } catch (err) {
@@ -486,6 +689,13 @@ const plugin: Plugin = async (input: PluginInput) => {
486
689
  list: (sessionID: string) => todoEnforcer.listPendingTodos(sessionID),
487
690
  },
488
691
  })
692
+ const ceiling = sessionToolCeilings.get(_input.sessionID)
693
+ if (ceiling?.status === 'OK') {
694
+ output.context.push(
695
+ `<tool_ceiling remaining_context_tokens="${ceiling.ceiling}">` +
696
+ 'Use the remaining host-reported context budget when selecting tools.</tool_ceiling>',
697
+ )
698
+ }
489
699
  if (blocks.length > 0) {
490
700
  output.context.push(...blocks)
491
701
  }
@@ -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
+ * (v2-events, 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)