pantheon-opencode 1.3.5-beta.60.efa533f → 1.3.6

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 (75) hide show
  1. package/README.md +38 -6
  2. package/package.json +4 -3
  3. package/scripts/_pantheon_paths.py +0 -1
  4. package/scripts/build-agents-md.mjs +4 -2
  5. package/scripts/code_mode_server.py +307 -21
  6. package/scripts/doctor.mjs +58 -15
  7. package/scripts/hooks/format-multi-language.sh +6 -3
  8. package/scripts/hooks/log-session-start.sh +6 -0
  9. package/scripts/hooks/on-subagent-delegation-start.sh +22 -6
  10. package/scripts/hooks/validate-post-conditions.sh +1 -1
  11. package/scripts/hooks/validate-tool-safety.sh +5 -2
  12. package/scripts/install/agents-md.mjs +3 -1
  13. package/scripts/install/cli-ui.mjs +24 -4
  14. package/scripts/install/interactive.mjs +18 -7
  15. package/scripts/install/migrate-config.mjs +13 -19
  16. package/scripts/install/migrate.mjs +8 -5
  17. package/scripts/install/model-picker.mjs +4 -1
  18. package/scripts/install/opencode.mjs +8 -2
  19. package/scripts/install/shared.mjs +8 -4
  20. package/scripts/install/state.mjs +9 -6
  21. package/scripts/mcp_persistence_server.py +23 -22
  22. package/scripts/mcp_resources_server.py +60 -1
  23. package/scripts/memory_mcp_server.py +80 -4
  24. package/scripts/postinstall.mjs +27 -25
  25. package/scripts/prune.mjs +1 -1
  26. package/scripts/redaction-gate.mjs +3 -1
  27. package/scripts/uninstall.mjs +0 -2
  28. package/scripts/validation-policy.mjs +6 -1
  29. package/scripts/version-check.mjs +9 -4
  30. package/src/agents/aphrodite.md +1 -1
  31. package/src/agents/apollo.md +3 -0
  32. package/src/agents/demeter.md +1 -1
  33. package/src/agents/gaia.md +3 -0
  34. package/src/agents/hephaestus.md +1 -1
  35. package/src/agents/hermes.md +1 -1
  36. package/src/agents/themis.md +4 -0
  37. package/src/agents/zeus.md +5 -1
  38. package/src/mcp/code_mode_server.py +304 -18
  39. package/src/mcp/eval_store.py +233 -0
  40. package/src/mcp/mcp_persistence_server.py +23 -22
  41. package/src/mcp/mcp_resources_server.py +58 -0
  42. package/src/mcp/memory_mcp_server.py +80 -3
  43. package/src/pantheon/auto-wake.ts +2 -4
  44. package/src/pantheon/background-job-board.ts +17 -4
  45. package/src/pantheon/comment-checker.ts +48 -0
  46. package/src/pantheon/delegation-classifier.ts +307 -0
  47. package/src/pantheon/delegation-enforce.ts +67 -3
  48. package/src/pantheon/delegation-finalize.ts +44 -4
  49. package/src/pantheon/delegation.ts +413 -30
  50. package/src/pantheon/dispatch-guard.ts +2 -0
  51. package/src/pantheon/file-persistence.ts +7 -3
  52. package/src/pantheon/goal-loop.ts +13 -2
  53. package/src/pantheon/permission-eval.ts +284 -0
  54. package/src/pantheon/permission-globs.ts +86 -0
  55. package/src/pantheon/presets.d.mts +41 -0
  56. package/src/pantheon/presets.mjs +135 -0
  57. package/src/pantheon/retry-policy.ts +212 -0
  58. package/src/pantheon/session-guard.ts +48 -0
  59. package/src/pantheon/step-cap.ts +110 -0
  60. package/src/pantheon/todo-enforcer.ts +31 -5
  61. package/src/pantheon/todo-preserve.ts +7 -1
  62. package/src/pantheon/vision.ts +7 -5
  63. package/src/pantheon/zeus-delegate-with-retry.ts +401 -0
  64. package/src/plugin.ts +72 -13
  65. package/src/plugins/hook-runner.ts +1 -1
  66. package/src/plugins/pantheon-hooks.ts +1 -1
  67. package/src/plugins/tui/dist/tui.d.ts +1 -1
  68. package/src/plugins/tui/dist/tui.d.ts.map +1 -1
  69. package/src/plugins/tui/dist/tui.js +36 -14
  70. package/src/plugins/tui/dist/tui.tsx +71 -16
  71. package/src/plugins/tui/package.json +1 -1
  72. package/src/plugins/tui/src/index.tsx +71 -16
  73. package/src/plugins/tui/tsdown.config.ts +1 -1
  74. package/src/routing.yml +47 -0
  75. package/src/agents/README.md +0 -26
@@ -0,0 +1,401 @@
1
+ /**
2
+ * Zeus Delegate With Retry (Wave 4, empty-mode fix) — helper for Zeus waves.
3
+ *
4
+ * Opencode 1.18.x cannot intercept `task()` completion via hooks, so the
5
+ * `dispatch-guard` lib is NOT wired in the plugin. Zeus waves MUST use this
6
+ * helper manually: it encapsulates
7
+ *
8
+ * delegate → waitForTerminal → classify via createDispatchGuard
9
+ * → maybeRetry 1x on empty-mode1/mode2 → escalate
10
+ *
11
+ * Retry cap is HARD at 1 (never 2x). The guard is stateful per helper
12
+ * instance — one retry budget total. Fail-open on missing APIs is NOT
13
+ * applicable here: a failed delegate throws/log+escalate so the wave can
14
+ * decide (a) retry with different agent, (b) simplify, (c) manual.
15
+ *
16
+ * Two usage shapes:
17
+ * 1. High-level: `zeusDelegateWithRetry({ board, client, sessionID, agent, prompt })`
18
+ * — creates child session, registers board, promptAsync, waitForTerminal,
19
+ * reads report, classifies, retries once if empty, escalates if still empty.
20
+ * 2. Low-level: `createZeusRetryHelper()` — wraps a generic
21
+ * `() => Promise<DispatchResultLike>` pair via `executeWithRetry`.
22
+ *
23
+ * The high-level helper is the one Zeus waves should import:
24
+ *
25
+ * ```ts
26
+ * import { zeusDelegateWithRetry } from './pantheon/zeus-delegate-with-retry.ts'
27
+ * const { report, alias, taskID, retried, classification } =
28
+ * await zeusDelegateWithRetry({ board, client, sessionID, agent: 'apollo', prompt })
29
+ * ```
30
+ *
31
+ * If the result is empty after one retry, the helper THROWS with an
32
+ * `EscalationError` — the wave must catch and escalate to the user (see
33
+ * `src/agents/zeus.md` "Waves with Retry").
34
+ *
35
+ * Pure TypeScript — no direct SDK import. Board/client are structural.
36
+ *
37
+ * @module zeus-delegate-with-retry
38
+ */
39
+
40
+ import type { BackgroundJobBoard } from './background-job-board.ts'
41
+ import type { DelegationClient } from './delegation-finalize.ts'
42
+ import { DELEGATION_DEFAULTS, readDelegationReport } from './delegation-finalize.ts'
43
+ import {
44
+ createDispatchGuard,
45
+ type DispatchClassification,
46
+ type DispatchGuard,
47
+ type DispatchGuardOptions,
48
+ type DispatchResultLike,
49
+ } from './dispatch-guard.ts'
50
+ import { createPantheonLogger } from './logger.ts'
51
+ import {
52
+ type ProviderCooldownTracker,
53
+ type RetryPolicy,
54
+ RetryPolicyEngine,
55
+ } from './retry-policy.ts'
56
+ import { safeSessionPath } from './session-guard.ts'
57
+
58
+ const log = createPantheonLogger({ module: 'pantheon-zeus-retry' })
59
+
60
+ // ─── Types ───────────────────────────────────────────────────────────────
61
+
62
+ export interface ZeusDelegateWithRetryOptions {
63
+ /** Board singleton (same instance the plugin uses). */
64
+ board: BackgroundJobBoard
65
+ /** Delegation client (adapted SDK client, e.g. adaptDelegationClient). */
66
+ client: DelegationClient
67
+ /** Parent session that delegates (must be a valid ses_ id). */
68
+ sessionID: string
69
+ /** Target agent (e.g. "apollo", "hermes"). */
70
+ agent: string
71
+ /** Task prompt delivered to the background agent. */
72
+ prompt: string
73
+ /** Human-readable description shown on the job board. */
74
+ description?: string
75
+ /** Explicit model for the child session (provider/model). */
76
+ model?: { id: string; providerID: string }
77
+ /** Timeout for board.waitForTerminal (ms). Default: DELEGATION_DEFAULTS.readTimeoutMs */
78
+ readTimeoutMs?: number
79
+ /** Retry on empty results. Default: true */
80
+ retryOnEmpty?: boolean
81
+ /** R1: per-error-type retry policy (routing.yml `retry_policy`). */
82
+ retryPolicy?: RetryPolicy
83
+ /** R1: provider cooldown tracker (routing.yml `cooldown`). */
84
+ cooldown?: ProviderCooldownTracker
85
+ /** R1: provider name for cooldown tracking (default "default"). */
86
+ provider?: string
87
+ /** Injectable logger. Default: file log (PANTHEON_HOOKS_LOG gate). */
88
+ logger?: { warn: (message: string) => void; info?: (message: string) => void }
89
+ /** Output dir for delegation reports. Default: DELEGATION_DEFAULTS.outputDir */
90
+ outputDir?: string
91
+ }
92
+
93
+ export interface ZeusDelegateResult {
94
+ /** Job alias (e.g. "apo-1"). */
95
+ alias: string
96
+ /** Child session id (= board task id). */
97
+ taskID: string
98
+ /** Report markdown (with Agent Activity section when sampled). */
99
+ report: string
100
+ /** Classification of the final result. */
101
+ classification: DispatchClassification
102
+ /** True when a retry was fired (cap 1). */
103
+ retried: boolean
104
+ }
105
+
106
+ /**
107
+ * Thrown when a delegate returns empty even after one retry — the wave must
108
+ * escalate (try different agent, simplify scope, or manual intervention).
109
+ */
110
+ export class ZeusEscalationError extends Error {
111
+ constructor(
112
+ public readonly agent: string,
113
+ public readonly prompt: string,
114
+ public readonly classification: DispatchClassification,
115
+ ) {
116
+ super(
117
+ `Zeus delegate escalate: agent "${agent}" returned ${classification} even after retry (prompt: "${prompt.slice(0, 80)}") — escalate to user: (a) try different agent, (b) simplify scope, (c) manual`,
118
+ )
119
+ this.name = 'ZeusEscalationError'
120
+ }
121
+ }
122
+
123
+ // ─── Low-level helper — wraps any async delegate+read pair ───────────────
124
+
125
+ /**
126
+ * Low-level retry helper. Zeus waves that already have a `delegate+read`
127
+ * pair can use this directly without the board/client machinery:
128
+ *
129
+ * ```ts
130
+ * const guard = createZeusRetryHelper({ logger })
131
+ * const out = await guard.executeWithRetry(
132
+ * () => firstRead(),
133
+ * () => secondRead(),
134
+ * )
135
+ * if (out.escalate) throw new ZeusEscalationError(...)
136
+ * ```
137
+ */
138
+ export interface ZeusRetryHelper {
139
+ guard: DispatchGuard
140
+ /**
141
+ * Classify `first` and, if empty and budget allows, run `redispatch` once.
142
+ * Returns the final result plus escalation flag (true when still empty after
143
+ * the one allowed retry).
144
+ */
145
+ executeWithRetry(
146
+ first: DispatchResultLike,
147
+ redispatch: () => Promise<DispatchResultLike>,
148
+ ): Promise<{
149
+ result: DispatchResultLike
150
+ classification: DispatchClassification
151
+ retried: boolean
152
+ escalate: boolean
153
+ }>
154
+ }
155
+
156
+ export interface ZeusRetryHelperOptions extends DispatchGuardOptions {
157
+ /**
158
+ * R1: per-error-type retry policy. When provided, error-type retries with
159
+ * exponential backoff apply (auth → 0, rate_limit → N, ...); when absent
160
+ * the legacy hard-cap-1 empty-result retry applies.
161
+ */
162
+ retryPolicy?: RetryPolicy
163
+ /** R1: provider cooldown tracker (shared across helpers for global cooldown). */
164
+ cooldown?: ProviderCooldownTracker
165
+ /** R1: provider name for cooldown tracking (default "default"). */
166
+ provider?: string
167
+ /** R1: injectable sleep for tests (default: setTimeout promise). */
168
+ sleep?: (ms: number) => Promise<void>
169
+ }
170
+
171
+ export function createZeusRetryHelper(options?: ZeusRetryHelperOptions): ZeusRetryHelper {
172
+ const guard = createDispatchGuard(options)
173
+ const engine =
174
+ options?.retryPolicy !== undefined
175
+ ? new RetryPolicyEngine(options.retryPolicy, options.cooldown)
176
+ : undefined
177
+ const provider = options?.provider ?? 'default'
178
+ const sleep =
179
+ options?.sleep ?? ((ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms)))
180
+ return {
181
+ guard,
182
+ async executeWithRetry(first, redispatch) {
183
+ if (engine === undefined) {
184
+ // Legacy path (unchanged): hard cap 1 on empty results.
185
+ const out = await guard.maybeRetry(first, redispatch)
186
+ const escalate = out.classification !== 'content'
187
+ return {
188
+ result: out.result,
189
+ classification: out.classification,
190
+ retried: out.retried,
191
+ escalate,
192
+ }
193
+ }
194
+ // R1 path: classify the failure, apply the per-error-type policy with
195
+ // exponential backoff, and track provider cooldown. A content result
196
+ // records success (resets the provider's failure counter); an
197
+ // exhausted budget records failure (feeds the cooldown tracker).
198
+ let result = first
199
+ let attemptsUsed = 0
200
+ let retried = false
201
+ let classification = guard.classifyResult(result)
202
+ let lastError: unknown =
203
+ result.error ??
204
+ (classification === 'content' ? undefined : new Error('empty dispatch result'))
205
+ for (;;) {
206
+ if (classification === 'content') {
207
+ engine.recordSuccess(provider)
208
+ return { result, classification, retried, escalate: false }
209
+ }
210
+ lastError = result.error ?? lastError
211
+ const decision = engine.decide(provider, lastError, attemptsUsed)
212
+ if (!decision.shouldRetry) {
213
+ engine.recordFailure(provider, lastError)
214
+ return { result, classification, retried, escalate: true }
215
+ }
216
+ retried = true
217
+ attemptsUsed += 1
218
+ await sleep(decision.delayMs)
219
+ try {
220
+ result = await redispatch()
221
+ lastError = undefined
222
+ } catch (err: unknown) {
223
+ lastError = err
224
+ }
225
+ classification = guard.classifyResult(result)
226
+ }
227
+ },
228
+ }
229
+ }
230
+
231
+ // ─── High-level helper — full delegate → wait → read → retry → escalate ───
232
+
233
+ /**
234
+ * High-level helper Zeus waves MUST use (see `src/agents/zeus.md`).
235
+ * Delegates a background agent as a child session, waits for terminal,
236
+ * classifies via `createDispatchGuard`, retries ONCE on empty-mode1/mode2,
237
+ * and escalates (throws `ZeusEscalationError`) if still empty.
238
+ *
239
+ * Not wired automatically in the plugin — opencode 1.18.x cannot intercept
240
+ * task completion via hooks, so wiring is manual-orchestration only.
241
+ */
242
+ export async function zeusDelegateWithRetry(
243
+ opts: ZeusDelegateWithRetryOptions,
244
+ ): Promise<ZeusDelegateResult> {
245
+ const logger = opts.logger ?? log
246
+ const warn = logger.warn.bind(logger)
247
+ const board = opts.board
248
+ const client = opts.client
249
+ const sessionID = opts.sessionID
250
+
251
+ const sessionPath = safeSessionPath(sessionID)
252
+ if (!sessionPath) {
253
+ warn(`zeusDelegateWithRetry: invalid sessionID "${sessionID}" — aborting`)
254
+ throw new Error(`invalid sessionID: ${sessionID}`)
255
+ }
256
+
257
+ const readTimeoutMs = opts.readTimeoutMs ?? DELEGATION_DEFAULTS.readTimeoutMs
258
+ const outputDir = opts.outputDir ?? DELEGATION_DEFAULTS.outputDir
259
+ const retryHelper = createZeusRetryHelper({
260
+ retryOnEmpty: opts.retryOnEmpty ?? true,
261
+ logger,
262
+ ...(opts.retryPolicy !== undefined ? { retryPolicy: opts.retryPolicy } : {}),
263
+ ...(opts.cooldown !== undefined ? { cooldown: opts.cooldown } : {}),
264
+ ...(opts.provider !== undefined ? { provider: opts.provider } : {}),
265
+ })
266
+
267
+ // One delegate+read attempt. Factored so maybeRetry can invoke a second
268
+ // attempt with a fresh child session + board job.
269
+ async function runOnce(): Promise<{
270
+ alias: string
271
+ taskID: string
272
+ report: string
273
+ dispatchResult: DispatchResultLike
274
+ }> {
275
+ // 1. Create child session (parentID = caller). No model forwarding here —
276
+ // delegation.ts resolves models via routing.yml; this helper uses the
277
+ // bare SDK default unless opts.model is supplied (explicit caller intent).
278
+ let childID: string
279
+ try {
280
+ const created = await client.session.create({
281
+ body: {
282
+ parentID: sessionID,
283
+ title: opts.description ?? opts.prompt.slice(0, 80),
284
+ ...(opts.model !== undefined ? { model: opts.model } : {}),
285
+ },
286
+ })
287
+ childID = created.id
288
+ } catch (err: unknown) {
289
+ const reason = err instanceof Error ? err.message : String(err)
290
+ warn(`zeusDelegateWithRetry: session.create failed: ${reason}`)
291
+ throw new Error(`delegate session.create failed: ${reason}`)
292
+ }
293
+
294
+ const childPath = safeSessionPath(childID)
295
+ if (!childPath) {
296
+ warn(`zeusDelegateWithRetry: child sessionID invalid "${childID}"`)
297
+ throw new Error(`invalid child sessionID: ${childID}`)
298
+ }
299
+
300
+ // 2. Register on board (taskID == child session id)
301
+ const job = await board.registerLaunch({
302
+ taskID: childID,
303
+ parentSessionID: sessionID,
304
+ agent: opts.agent,
305
+ description: opts.description ?? opts.prompt,
306
+ objective: opts.prompt,
307
+ })
308
+
309
+ // 3. Fire-and-forget promptAsync on the child. Completion is observed via
310
+ // board.waitForTerminal, not via noReply — the spike refuted noReply.
311
+ // A promptAsync failure is logged but does not abort the wait — the board
312
+ // timeout will finalize the job.
313
+ void client.session
314
+ .promptAsync({
315
+ path: childPath.path,
316
+ body: { agent: opts.agent, parts: [{ type: 'text', text: opts.prompt }] },
317
+ })
318
+ .catch((err: unknown) => {
319
+ const reason = err instanceof Error ? err.message : String(err)
320
+ warn(`zeusDelegateWithRetry: promptAsync failed for ${childID}: ${reason}`)
321
+ })
322
+
323
+ // 4. Wait for terminal state
324
+ let terminal: Awaited<ReturnType<BackgroundJobBoard['waitForTerminal']>>
325
+ try {
326
+ terminal = await board.waitForTerminal(childID, readTimeoutMs)
327
+ } catch {
328
+ // Timed out waiting — treat as empty so the guard retries once
329
+ return {
330
+ alias: job.alias,
331
+ taskID: childID,
332
+ report: '',
333
+ dispatchResult: { content: '' },
334
+ }
335
+ }
336
+
337
+ // 5. Read report markdown
338
+ const report = (await readDelegationReport(outputDir, terminal)) ?? ''
339
+
340
+ // DispatchResultLike for classification: content = report markdown.
341
+ // Tokens are unavailable from the board/report path → mode1 when empty.
342
+ const dispatchResult: DispatchResultLike = { content: report }
343
+
344
+ return { alias: job.alias, taskID: childID, report, dispatchResult }
345
+ }
346
+
347
+ // First attempt
348
+ const first = await runOnce()
349
+
350
+ // Classify + maybe retry once — capture second run's metadata via closure
351
+ let secondRun: typeof first | undefined
352
+ const out = await retryHelper.executeWithRetry(first.dispatchResult, async () => {
353
+ const second = await runOnce()
354
+ secondRun = second
355
+ return second.dispatchResult
356
+ })
357
+
358
+ // When retried, return second run's report or escalate
359
+ if (out.retried && secondRun !== undefined) {
360
+ if (out.classification === 'content') {
361
+ return {
362
+ alias: secondRun.alias,
363
+ taskID: secondRun.taskID,
364
+ report: secondRun.report,
365
+ classification: out.classification,
366
+ retried: true,
367
+ }
368
+ }
369
+ // Still empty after retry → escalate
370
+ throw new ZeusEscalationError(opts.agent, opts.prompt, out.classification)
371
+ }
372
+
373
+ // No retry, or first was content
374
+ if (out.classification !== 'content') {
375
+ // Empty and retry budget exhausted (or retryOnEmpty:false) → escalate
376
+ // But if we already retried and failed we threw above; this path is for
377
+ // the case where maybeRetry did NOT retry (budget exhausted or disabled)
378
+ // and the first result was empty. Per spec, one retry is allowed — if
379
+ // maybeRetry didn't retry, we should NOT auto-escalate here unless the
380
+ // guard's budget was used. Instead, treat as final empty that still
381
+ // requires escalation when retried==false but classification empty and
382
+ // retryOnEmpty was true but guard had no budget left? The guard already
383
+ // enforces cap 1, so after one retry escalate is required. For the
384
+ // first-attempt-empty with no retry (retryOnEmpty false), we still escalate
385
+ // so the wave knows to handle it.
386
+ if (out.retried) {
387
+ throw new ZeusEscalationError(opts.agent, opts.prompt, out.classification)
388
+ }
389
+ // No retry happened — still empty. Escalate so the wave doesn't silently
390
+ // use an empty report.
391
+ throw new ZeusEscalationError(opts.agent, opts.prompt, out.classification)
392
+ }
393
+
394
+ return {
395
+ alias: first.alias,
396
+ taskID: first.taskID,
397
+ report: first.report,
398
+ classification: out.classification,
399
+ retried: out.retried,
400
+ }
401
+ }
package/src/plugin.ts CHANGED
@@ -15,6 +15,7 @@ import {
15
15
  createEnforcementGuard,
16
16
  readOnlyRegistry,
17
17
  SessionHierarchyRegistry,
18
+ zeusReadGuard,
18
19
  } from './pantheon/delegation-enforce.ts'
19
20
  import { handleDelegationEvent } from './pantheon/delegation-notify.ts'
20
21
  import { FilePersistenceAdapter } from './pantheon/file-persistence.ts'
@@ -24,7 +25,14 @@ import { createHashlineEditTool } from './pantheon/hashline/tool.ts'
24
25
  import { createIdleDispatcher } from './pantheon/idle-continuation.ts'
25
26
  import { createPantheonLogger } from './pantheon/logger.ts'
26
27
  import { pantheonPluginOnce } from './pantheon/plugin-once.ts'
27
- import { applyActivePresetToConfig, loadRoutingAgentModels } from './pantheon/presets.mjs'
28
+ import {
29
+ applyActivePresetToConfig,
30
+ loadRoutingAgentModels,
31
+ loadRoutingMaxSteps,
32
+ loadRoutingPermissionTask,
33
+ } from './pantheon/presets.mjs'
34
+ import { safeSessionPath } from './pantheon/session-guard.ts'
35
+ import { StepCapTracker } from './pantheon/step-cap.ts'
28
36
  import {
29
37
  TODO_ENFORCER_DEFAULTS,
30
38
  TodoEnforcer,
@@ -53,6 +61,17 @@ const log = createPantheonLogger({ module: 'pantheon-plugin' })
53
61
  // plugin never throws at startup.
54
62
  const routingAgentModels = loadRoutingAgentModels({ logger: log })
55
63
 
64
+ // R4: per-agent step caps from routing.yml `agents.<name>.max_steps`
65
+ // (fail-open → {} = no agent capped). R1 retry_policy/cooldown are NOT
66
+ // wired here — the plugin has no retry path (opencode cannot intercept task
67
+ // completion); zeus waves load them via presets.mjs loaders and pass them
68
+ // to zeusDelegateWithRetry explicitly.
69
+ const stepCapTracker = new StepCapTracker(loadRoutingMaxSteps({ logger: log }))
70
+
71
+ // O5: permission.task glob rules from routing.yml `permission.task`
72
+ // (fail-open → undefined = existing runtime matrix only).
73
+ const routingPermissionTask = loadRoutingPermissionTask({ logger: log })
74
+
56
75
  const board = new BackgroundJobBoard({
57
76
  maxConcurrentPerAgent: 3,
58
77
  signalDir: '.pantheon/deepwork/board-signals',
@@ -193,15 +212,31 @@ function adaptDelegationClient(client: PluginInput['client']): DelegationClient
193
212
  return { id: result.data.id }
194
213
  },
195
214
  promptAsync: async (input) => {
215
+ const path = safeSessionPath(input.path.id)
216
+ if (!path) {
217
+ log.warn(`[Pantheon Plugin] promptAsync skipped: invalid sessionID "${input.path.id}"`)
218
+ throw new Error(`invalid sessionID: ${input.path.id}`)
219
+ }
196
220
  const result = await client.session.promptAsync({
197
- path: input.path,
198
- body: { agent: input.body.agent, parts: input.body.parts },
221
+ path: path.path,
222
+ body: {
223
+ agent: input.body.agent,
224
+ ...(input.body.model !== undefined
225
+ ? { model: { providerID: input.body.model.providerID, modelID: input.body.model.id } }
226
+ : {}),
227
+ parts: input.body.parts,
228
+ },
199
229
  })
200
230
  if (result.error) throw new Error(sdkErrorMessage(result.error))
201
231
  return result.data
202
232
  },
203
233
  messages: async (input) => {
204
- const result = await client.session.messages({ path: input.path })
234
+ const path = safeSessionPath(input.path.id)
235
+ if (!path) {
236
+ log.warn(`[Pantheon Plugin] messages skipped: invalid sessionID "${input.path.id}"`)
237
+ return []
238
+ }
239
+ const result = await client.session.messages({ path: path.path })
205
240
  if (result.error) throw new Error(sdkErrorMessage(result.error))
206
241
  return result.data
207
242
  },
@@ -219,22 +254,42 @@ function adaptTodoEnforcerClient(client: PluginInput['client']): TodoEnforcerCli
219
254
  return {
220
255
  session: {
221
256
  todo: async (input) => {
222
- const result = await client.session.todo({ path: input.path })
257
+ const path = safeSessionPath(input.path.id)
258
+ if (!path) {
259
+ log.warn(`[Pantheon Plugin] todo skipped: invalid sessionID "${input.path.id}"`)
260
+ return []
261
+ }
262
+ const result = await client.session.todo({ path: path.path })
223
263
  if (result.error) throw new Error(sdkErrorMessage(result.error))
224
264
  return result.data
225
265
  },
226
266
  messages: async (input) => {
227
- const result = await client.session.messages({ path: input.path })
267
+ const path = safeSessionPath(input.path.id)
268
+ if (!path) {
269
+ log.warn(`[Pantheon Plugin] messages skipped: invalid sessionID "${input.path.id}"`)
270
+ return []
271
+ }
272
+ const result = await client.session.messages({ path: path.path })
228
273
  if (result.error) throw new Error(sdkErrorMessage(result.error))
229
274
  return result.data
230
275
  },
231
276
  children: async (input) => {
232
- const result = await client.session.children({ path: input.path })
277
+ const path = safeSessionPath(input.path.id)
278
+ if (!path) {
279
+ log.warn(`[Pantheon Plugin] children skipped: invalid sessionID "${input.path.id}"`)
280
+ return []
281
+ }
282
+ const result = await client.session.children({ path: path.path })
233
283
  if (result.error) throw new Error(sdkErrorMessage(result.error))
234
284
  return result.data
235
285
  },
236
286
  promptAsync: async (input) => {
237
- const result = await client.session.promptAsync({ path: input.path, body: input.body })
287
+ const path = safeSessionPath(input.path.id)
288
+ if (!path) {
289
+ log.warn(`[Pantheon Plugin] promptAsync skipped: invalid sessionID "${input.path.id}"`)
290
+ throw new Error(`invalid sessionID: ${input.path.id}`)
291
+ }
292
+ const result = await client.session.promptAsync({ path: path.path, body: input.body })
238
293
  if (result.error) throw new Error(sdkErrorMessage(result.error))
239
294
  return result.data
240
295
  },
@@ -302,6 +357,8 @@ const plugin: Plugin = async (input: PluginInput) => {
302
357
  readOnlyAgents: new Set(['apollo', 'gaia']),
303
358
  agentModels: routingAgentModels,
304
359
  delegationBudgets,
360
+ stepCapTracker,
361
+ ...(routingPermissionTask != null ? { permissionTask: routingPermissionTask } : {}),
305
362
  ...(wallClockTimeoutMs !== undefined ? { wallClockTimeoutMs } : {}),
306
363
  },
307
364
  })
@@ -484,11 +541,13 @@ const plugin: Plugin = async (input: PluginInput) => {
484
541
  // Phase 4: deny mutating tools in read-only delegated sessions. Additive
485
542
  // key — does not touch Phase 3's tools/event/onTerminal/chat.message.
486
543
  'tool.execute.before': async (input, output) => {
487
- // Chain: the read-only enforcement guard runs first (denies mutating
488
- // tools in read-only sessions), then release-134 Phase 3 rewrites the
489
- // first post-compaction `todowrite` with the captured todo snapshot.
490
- // Both are no-ops for non-matching sessions/tools; the preserver is
491
- // fail-open (only its intentional restore denial throws).
544
+ // Chain: Zeus read guard runs first (denies read/glob/grep of source
545
+ // code when the active agent is Zeus — delegate to @apollo), then the
546
+ // read-only enforcement guard (denies mutating tools in read-only
547
+ // sessions), then release-134 Phase 3 rewrites the first
548
+ // post-compaction `todowrite` with the captured todo snapshot. All are
549
+ // no-ops for non-matching sessions/tools.
550
+ zeusReadGuard(input.tool, output?.args, sessionAgents.get(input.sessionID))
492
551
  await enforcementGuard(input, output)
493
552
  await todoPreserver.beforeTodoWrite(input, output)
494
553
  },
@@ -22,7 +22,7 @@
22
22
  * is returned as a structured HookResult so the opencode session can never be
23
23
  * taken down by the security layer.
24
24
  */
25
- import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process'
25
+ import { type ChildProcessWithoutNullStreams, spawn } from 'node:child_process'
26
26
  import path from 'node:path'
27
27
  import type { Readable } from 'node:stream'
28
28
  import { fileURLToPath } from 'node:url'
@@ -192,8 +192,8 @@ import {
192
192
  enqueueChatReminder,
193
193
  pendingChatReminders,
194
194
  } from '../pantheon/chat-reminders.ts'
195
- import { type HookPayload, type HookResult, runHook } from './hook-runner.ts'
196
195
  import { pantheonPluginOnce } from '../pantheon/plugin-once.ts'
196
+ import { type HookPayload, type HookResult, runHook } from './hook-runner.ts'
197
197
 
198
198
  /** Tools that represent a subagent delegation (opencode `task` tool etc.). */
199
199
  const DELEGATION_TOOL_RE = /^(task|.*delegate.*|.*subagent.*)$/i
@@ -11,7 +11,7 @@ type DelegationEntry = {
11
11
  taskID?: string;
12
12
  /** Agent name, e.g. "apollo". */
13
13
  agent: string;
14
- state: 'running' | 'completed' | 'error' | 'cancelled';
14
+ state: 'running' | 'completed' | 'error' | 'startup_failed' | 'startup_unknown' | 'cancelled';
15
15
  /** Epoch ms of the `Started` header. */
16
16
  startedAt: number;
17
17
  /** Epoch ms of the `Finalized` header — null while still running. */
@@ -1 +1 @@
1
- {"version":3,"file":"tui.d.ts","names":[],"sources":["../src/index.tsx"],"mappings":";;KAm5BY;;EAEV;;EAEA;;;;EAIA;;EAEA;EACA;;EAEA;;EAEA;EACA;EACA;;EAEA;;;;EAIA;;;;;;;;;;;iBAsBc,wBACd,aACA,oBACA,qBACC;;;;;;iBA8GmB,sBAAsB,cAAc,QAAQ;;;;;;;;iBAwC5C,yBAAyB,eAAe,QAAQ;;;;;;;;iBAWtD,sBACd;EAAS;EAAoB;eAC7B;;;;;;iBAYc,YAAY;;;iBAMZ,WAAW;;;;iBAOX,yBAAyB,GAAG,iBAAiB,GAAG;;;;;iBAWhD,sBACd,cAAc,mBACd,uBACC;;;iBAQa,WAAW;;;iBAcX,kBAAkB,OAAO,iBAAiB;;;KAO9C;iBAQI,mBAAmB,OAAO,kBAAkB;iBAS5C,wBAAwB,OAAO;;iBAoB/B,uBAAuB;;;;;;;iBAWvB,4BACd,mBAAmB,mBACnB,eAAe,wBACd;;KA2DS;EACV;EACA;EACA;EACA;EACA;EACA;IACE;IACA,QAAQ;IACR;IACA;IACA;MAAS;MAAgB;;;;;KAKjB;;EAEV;;EAEA;;EAEA;EACA;;EAEA;EACA;;EAEA;;EAEA;EACA;EACA;EACA;;EAEA;;;KAIU;EACV;EACA;EACA;EACA;;EAEA;EACA;EACA;EACA;EACA;EACA;EACA;;;;;iBAac,wBACd,MAAM,oBACN,eACC;;;iBA+Ea,yBACd,KAAK,YAAY,sBACjB,MAAM,oBACN;;;iBA+Fc,sBACd,KAAK,YAAY,sBACjB;;;;;;iBAiBc,2BACd;EAAqB;EAAa;iBAClC,YAAY,uDACX;;;;;iBA2Ba,sBACd,KAAK,YAAY,sBACjB,gBAAgB,sBAChB;;;iBAWc,kBAAkB,MAAM,sBAAsB;;;;;;iBAqB9C,uBACd,eAAe,uBACf,aAAa,oBACZ;;;;;;;;;iBA8Da,iBAAiB,cAAc;;;;KAOnC;;EAEV;EACA;;IAEE;MAAU;;;IAEV;MAAU;QAAY;QAAe,SAAS;;;;;;;;;;iBASlC,wBAAwB,SAAS;;;;;;;;iBAmBjC,gBAAgB;EAAgB;IAAQ;;;;;;;iBASxC,kBAAkB;EAAkC;IAAQ;;;;KAKhE;;EAEV;;EAEA;;;EAGA;;;EAGA;EACA;IACE;IACA;;;;;;;iBAQY,mBAAmB;;;;;iBASnB,cAAc,OAAO;;;;;;;;;;;;;;iBAiBrB,4BACd,mBAAmB,mCACnB,aAAa,mBACb,eACC;;;;;;iBAwCa,4BACd;EAAS,YAAY,cAAc,SAAS;eAC5C;;;;;KA8KU;EACV,KAAK,YAAY;;EAEjB;;EAEA;;cAicI,QAAQ;EAAoB"}
1
+ {"version":3,"file":"tui.d.ts","names":[],"sources":["../src/index.tsx"],"mappings":";;KAq6BY;;EAEV;;EAEA;;;;EAIA;;EAEA;EACA;;EAEA;;EAEA;EACA;EACA;;EAEA;;;;EAIA;;;;;;;;;;;iBAsBc,wBACd,aACA,oBACA,qBACC;;;;;;iBAgHmB,sBAAsB,cAAc,QAAQ;;;;;;;;iBAwC5C,yBAAyB,eAAe,QAAQ;;;;;;;;iBAWtD,sBACd;EAAS;EAAoB;eAC7B;;;;;;iBAYc,YAAY;;;iBAMZ,WAAW;;;;iBAOX,yBAAyB,GAAG,iBAAiB,GAAG;;;;;iBAWhD,sBACd,cAAc,mBACd,uBACC;;;iBAQa,WAAW;;;iBAcX,kBAAkB,OAAO,iBAAiB;;;KAO9C;iBAQI,mBAAmB,OAAO,kBAAkB;iBAc5C,wBAAwB,OAAO;;iBAoB/B,uBAAuB;;;;;;;iBAWvB,4BACd,mBAAmB,mBACnB,eAAe,wBACd;;KAmFS;EACV;EACA;EACA;EACA;EACA;EACA;IACE;IACA,QAAQ;IACR;IACA;IACA;MAAS;MAAgB;;;;;KAKjB;;EAEV;;EAEA;;EAEA;EACA;;EAEA;EACA;;EAEA;;EAEA;EACA;EACA;EACA;;EAEA;;;KAIU;EACV;EACA;EACA;EACA;;EAEA;EACA;EACA;EACA;EACA;EACA;EACA;;;;;iBAac,wBACd,MAAM,oBACN,eACC;;;iBA+Ea,yBACd,KAAK,YAAY,sBACjB,MAAM,oBACN;;;iBA+Fc,sBACd,KAAK,YAAY,sBACjB;;;;;;iBAiBc,2BACd;EAAqB;EAAa;iBAClC,YAAY,uDACX;;;;;iBA2Ba,sBACd,KAAK,YAAY,sBACjB,gBAAgB,sBAChB;;;iBAWc,kBAAkB,MAAM,sBAAsB;;;;;;iBAqB9C,uBACd,eAAe,uBACf,aAAa,oBACZ;;;;;;;;;iBA8Da,iBAAiB,cAAc;;;;KAOnC;;EAEV;EACA;;IAEE;MAAU;;;IAEV;MAAU;QAAY;QAAe,SAAS;;;;;;;;;;iBASlC,wBAAwB,SAAS;;;;;;;;iBAmBjC,gBAAgB;EAAgB;IAAQ;;;;;;;iBASxC,kBAAkB;EAAkC;IAAQ;;;;KAKhE;;EAEV;;EAEA;;;EAGA;;;EAGA;EACA;IACE;IACA;;;;;;;iBAQY,mBAAmB;;;;;iBASnB,cAAc,OAAO;;;;;;;;;;;;;;iBAiBrB,4BACd,mBAAmB,mCACnB,aAAa,mBACb,eACC;;;;;;iBAwCa,4BACd;EAAS,YAAY,cAAc,SAAS;eAC5C;;;;;KA8KU;EACV,KAAK,YAAY;;EAEjB;;EAEA;;cAucI,QAAQ;EAAoB"}