pantheon-opencode 1.5.0-beta.16 → 1.5.0-beta.18

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 (41) hide show
  1. package/CHANGELOG.md +48 -0
  2. package/README.md +13 -25
  3. package/README.pt-BR.md +4 -5
  4. package/package.json +1 -1
  5. package/plugin.json +1 -1
  6. package/pyproject.toml +1 -1
  7. package/scripts/mcp_persistence_server.py +1 -1
  8. package/src/agents/apollo.md +1 -0
  9. package/src/agents/gaia.md +1 -0
  10. package/src/agents/zeus.md +8 -29
  11. package/src/mcp/mcp_persistence_server.py +1 -1
  12. package/src/pantheon/{delegation-compaction.ts → compaction-context.ts} +10 -100
  13. package/src/pantheon/cost-command.ts +1 -2
  14. package/src/pantheon/delegation-enforce.ts +59 -14
  15. package/src/pantheon/goal-loop.ts +1 -1
  16. package/src/pantheon/goal-store.ts +1 -1
  17. package/src/pantheon/idle-continuation.ts +4 -6
  18. package/src/pantheon/model-command.ts +1 -2
  19. package/src/pantheon/native-task-status.ts +2 -2
  20. package/src/pantheon/session-id.ts +30 -0
  21. package/src/pantheon/task-result-guard.ts +2 -3
  22. package/src/pantheon/todo-enforcer.ts +3 -3
  23. package/src/pantheon/tool-context.ts +23 -0
  24. package/src/pantheon/v2-bridge.ts +1 -4
  25. package/src/plugin-v2.ts +4 -15
  26. package/src/plugin.ts +61 -245
  27. package/src/plugins/tui/dist/tui.d.ts +49 -79
  28. package/src/plugins/tui/dist/tui.d.ts.map +1 -1
  29. package/src/plugins/tui/dist/tui.js +48 -189
  30. package/src/plugins/tui/package-lock.json +2 -2
  31. package/src/plugins/tui/package.json +1 -1
  32. package/src/plugins/tui/src/index.tsx +97 -295
  33. package/src/skills/context-compression/SKILL.md +1 -1
  34. package/src/pantheon/delegate-manager.ts +0 -851
  35. package/src/pantheon/delegation-classifier.ts +0 -306
  36. package/src/pantheon/delegation-finalize.ts +0 -468
  37. package/src/pantheon/delegation-notify.ts +0 -212
  38. package/src/pantheon/delegation-toast.ts +0 -73
  39. package/src/pantheon/delegation.ts +0 -1349
  40. package/src/pantheon/native-probe.ts +0 -234
  41. package/src/pantheon/step-cap.ts +0 -110
@@ -2,8 +2,8 @@
2
2
  * Native Task Status — classifies BackgroundJobRecord results from native
3
3
  * task() delegation into actionable status codes.
4
4
  *
5
- * Pure module — zero external dependencies. Consumed by native-probe.ts
6
- * and the delegation manager to decide retry/skip/escalate strategy.
5
+ * Pure module — zero external dependencies. Consumed by the tool-ceiling and
6
+ * cost layers to decide retry/skip/escalate strategy.
7
7
  *
8
8
  * @module native-task-status
9
9
  */
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Session ID safety — reject session IDs that could escape a per-session
3
+ * output directory.
4
+ *
5
+ * Shared helper for the file-backed stores that remain after the delegate
6
+ * removal (goal-store and friends) — it no longer lives alongside the
7
+ * deleted delegation module.
8
+ *
9
+ * `sessionID` is embedded in a path
10
+ * `<dir>/<sessionID>/file.json` — `path.join` does NOT strip `..`, so a
11
+ * traversing ID would write outside the sandbox. Call this BEFORE any path
12
+ * construction.
13
+ *
14
+ * @module session-id
15
+ */
16
+
17
+ /**
18
+ * Reject session IDs that could escape the target directory.
19
+ *
20
+ * @throws {Error} When the ID contains `..`, `/`, or `\`.
21
+ */
22
+ export function assertSafeParentSessionID(parentSessionID: string): void {
23
+ if (
24
+ parentSessionID.includes('..') ||
25
+ parentSessionID.includes('/') ||
26
+ parentSessionID.includes('\\')
27
+ ) {
28
+ throw new Error(`Invalid parentSessionID: ${parentSessionID}`)
29
+ }
30
+ }
@@ -58,14 +58,13 @@ export interface TaskResultGuardOptions {
58
58
 
59
59
  /**
60
60
  * The error message injected when a task() result is empty.
61
- * Matches the existing delegation-finalize.ts contract:
62
- * "Child session produced no assistant or tool output".
61
+ * Keeps the historical "no assistant or tool output" contract text.
63
62
  */
64
63
  export const EMPTY_TASK_ERROR =
65
64
  'ERROR: task() returned empty output — child session produced no assistant or tool output. ' +
66
65
  'This typically means the provider rejected the request (admission control / rate limit) ' +
67
66
  'or the child session crashed before producing any response. ' +
68
- 'Consider using pantheon_delegate (clean context) instead of task() for large payloads.'
67
+ 'Consider splitting the work into a smaller task() or retrying the child session.'
69
68
 
70
69
  // ─── Implementation ────────────────────────────────────────────────────
71
70
 
@@ -2,9 +2,9 @@
2
2
  * TODO Enforcer (Wave 1) — keeps root/non-board sessions working on their
3
3
  * todo list when the session goes idle.
4
4
  *
5
- * The plugin's `event` hook dispatches `session.idle`: board-child sessions go
6
- * to the delegation finalize path (`handleDelegationEvent`), everything else
7
- * (roots, non-board sessions) goes to `TodoEnforcer.onIdle`. The enforcer
5
+ * The plugin's `event` hook dispatches `session.idle`: board-child sessions
6
+ * are owned by the board transition path, everything else (roots, non-board
7
+ * sessions) goes to `TodoEnforcer.onIdle`. The enforcer
8
8
  * re-injects a continuation prompt when the session went idle with incomplete
9
9
  * todos, so the agent keeps working on its task list instead of stopping.
10
10
  *
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Tool Context — the structural view of the context opencode passes to a
3
+ * tool's `execute()`.
4
+ *
5
+ * Extracted from `delegation.ts` (Fase 2 of the delegate removal) so the
6
+ * surviving structural tools (cost-command, model-command, goal-loop) no
7
+ * longer depend on the delegation module. Pure type — zero runtime code.
8
+ *
9
+ * @module tool-context
10
+ */
11
+
12
+ /** Structural view of the tool context opencode passes to execute(). */
13
+ export interface ToolContextLike {
14
+ sessionID: string
15
+ directory?: string
16
+ worktree?: string
17
+ /**
18
+ * The OpenCode SDK's ToolContext declares this as required. It remains
19
+ * optional here because the structural test/embedding surface can be
20
+ * supplied by older hosts; enforcement must skip when it is absent.
21
+ */
22
+ agent?: string
23
+ }
@@ -17,7 +17,6 @@
17
17
  */
18
18
 
19
19
  import type { BackgroundJobBoard } from './background-job-board.ts'
20
- import type { DelegationClient } from './delegation-finalize.ts'
21
20
  import type { GoalStore } from './goal-store.ts'
22
21
  import type { TodoEnforcer } from './todo-enforcer.ts'
23
22
 
@@ -57,8 +56,6 @@ export interface VisionHandler {
57
56
  export interface PantheonV2Bridge {
58
57
  /** Background job board — tracks running/completed/cancelled delegated jobs. */
59
58
  board?: BackgroundJobBoard
60
- /** Structural delegation client — creates child sessions and sends prompts. */
61
- delegationClient?: DelegationClient
62
59
  /** Goal store — file-based persistence for full-auto goal loop. */
63
60
  goalStore?: GoalStore
64
61
  /** Todo enforcer — idle continuation when todos are incomplete. */
@@ -85,7 +82,7 @@ export const BRIDGE_OPTIONS_KEY = '__pantheonV1Bridge' as const
85
82
  * @example
86
83
  * ```ts
87
84
  * // In V1 plugin setup (plugin.ts):
88
- * const bridge = createV2Bridge({ board, delegationClient, goalStore, ... })
85
+ * const bridge = createV2Bridge({ board, goalStore, ... })
89
86
  * // Then pass bridge to V2 context via options
90
87
  * ```
91
88
  */
package/src/plugin-v2.ts CHANGED
@@ -2,7 +2,7 @@
2
2
  * Pantheon V2 Plugin — full orchestration plugin for OpenCode V2.
3
3
  *
4
4
  * Registers:
5
- * - 9 orchestration tools via V2 tool.transform (with V1 bridge fallback)
5
+ * - 6 orchestration tools via V2 tool.transform (with V1 bridge fallback)
6
6
  * - 4 event subscriptions (session.created, idle, error, compacted)
7
7
  * - Session hooks (prompt, context) and tool hooks (execute.before/after)
8
8
  * - Permission hooks for custom authorization
@@ -408,11 +408,11 @@ async function onSessionIdle(_event: V2SessionEvent): Promise<void> {
408
408
  }
409
409
 
410
410
  async function onSessionError(_event: V2SessionEvent): Promise<void> {
411
- // Error handling — delegation finalize as error.
411
+ // Error handling — board transition to error.
412
412
  const bridge = resolveBridge()
413
413
  if (bridge?.board != null) {
414
- // Bridge available: delegation error handling is done by V1 event hook.
415
- // V2 only logs — V1 finalizeDelegation handles the board transition.
414
+ // Bridge available: error handling is done by the V1 event hook.
415
+ // V2 only logs — V1 owns the board transition.
416
416
  }
417
417
  }
418
418
 
@@ -640,9 +640,6 @@ function createV2ToolDefinitionsFromContext(_context: PluginContext): V2ToolDef[
640
640
  'infrastructure through the V2 bridge (src/pantheon/v2-bridge.ts).'
641
641
 
642
642
  const toolNames = [
643
- 'pantheon_delegate',
644
- 'pantheon_delegation_read',
645
- 'pantheon_delegation_list',
646
643
  'hashline_edit',
647
644
  'pantheon_goal_create',
648
645
  'pantheon_goal_get',
@@ -652,14 +649,6 @@ function createV2ToolDefinitionsFromContext(_context: PluginContext): V2ToolDef[
652
649
  ]
653
650
 
654
651
  const toolDescriptions: Record<string, string> = {
655
- pantheon_delegate:
656
- 'Dispatch a background agent as a child session and register it on the job board. ' +
657
- 'Returns the readable alias (e.g. "apo-1"); read the result with pantheon_delegation_read.',
658
- pantheon_delegation_read:
659
- 'Block until a background delegation finishes (completed/error/cancelled), then return ' +
660
- 'its report markdown (with a trailing agent-activity section) and mark the job reconciled.',
661
- pantheon_delegation_list:
662
- 'List background delegations for the current session, with [unread] for finished jobs.',
663
652
  hashline_edit:
664
653
  'Edit a file anchored by hashline refs (LINE#TAG) instead of raw line numbers. ' +
665
654
  'Ops: replace, append, prepend, delete. ALL refs validated against ORIGINAL file first.',
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). The R1 retry_policy/cooldown block and
70
- // its presets.mjs loaders were removed together with their only consumer
71
- // (zeus-delegate-with-retry.ts); the plugin has no retry path (opencode cannot
72
- // intercept task completion).
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. (opencode 1.18.x cannot intercept task completion via hooks,
521
- // so empty-result handling lives in delegation-classifier.ts.)
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),