pantheon-opencode 1.3.5-beta.52.c75b04d → 1.3.5-beta.60.efa533f

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.
@@ -1,13 +1,15 @@
1
1
  ---
2
2
  name: themis
3
3
  description: "Quality & security gate — Ruff/Biome linting, dead/legacy code detection, OWASP Top 10, coverage >80%, correctness, deprecation audit. Called by implementers; escalates blockers to zeus."
4
- mode: subagent
4
+ mode: all
5
5
  reasoning_effort: high
6
6
  permission:
7
7
  read: allow
8
8
  grep: allow
9
9
  bash: allow
10
10
  edit: deny
11
+ task:
12
+ "*": deny
11
13
  temperature: 0.1
12
14
  steps: 30
13
15
  skills:
@@ -18,9 +18,9 @@
18
18
  * - `task` is blocked too, which hard-enforces depth-2: a read-only agent
19
19
  * cannot spawn its own subagents.
20
20
  *
21
- * Unknown sessions are ALLOWED by default — the guard only denies sessions
22
- * that are explicitly registered, so normal (non-delegated) agent work is
23
- * never blocked.
21
+ * Unknown sessions are allowed for ordinary tools. Delegation tools are
22
+ * fail-closed when the runtime matrix is wired: missing agent identity or
23
+ * target metadata is denied rather than inferred.
24
24
  *
25
25
  * The registry is populated by delegation.ts at delegate time: the CHILD
26
26
  * session is registered when the delegate call is read-only.
@@ -47,8 +47,100 @@ export interface ToolExecuteBeforeInput {
47
47
  callID: string
48
48
  }
49
49
 
50
+ /** The output object supplied alongside `tool.execute.before`. */
51
+ export interface ToolExecuteBeforeOutput {
52
+ args?: unknown
53
+ }
54
+
50
55
  /** The `tool.execute.before` handler shape (throw ⇒ deny). */
51
- export type ToolExecuteBeforeHandler = (input: ToolExecuteBeforeInput) => Promise<void>
56
+ export type ToolExecuteBeforeHandler = (
57
+ input: ToolExecuteBeforeInput,
58
+ output?: ToolExecuteBeforeOutput,
59
+ ) => Promise<void>
60
+
61
+ /** Runtime session hierarchy, populated only from SDK session metadata. */
62
+ export class SessionHierarchyRegistry {
63
+ private readonly roots = new Set<string>()
64
+ private readonly children = new Set<string>()
65
+ private seedState: 'unseeded' | 'pending' | 'seeded' | 'failed' = 'unseeded'
66
+
67
+ /** Mark the beginning of an authoritative session snapshot lookup. */
68
+ beginSeed(): void {
69
+ this.seedState = 'pending'
70
+ }
71
+
72
+ /** Complete the lookup and register the SDK's authoritative parentID data. */
73
+ completeSeed(sessions: ReadonlyArray<{ id: string; parentID?: string }>): void {
74
+ this.registerMany(sessions)
75
+ this.seedState = 'seeded'
76
+ }
77
+
78
+ /** Record an unavailable snapshot without classifying unknown sessions as children. */
79
+ failSeed(): void {
80
+ this.seedState = 'failed'
81
+ }
82
+
83
+ /** Register a session using its SDK parentID (or lack of one). */
84
+ register(session: { id: string; parentID?: string }): void {
85
+ if (session.parentID !== undefined) {
86
+ this.children.add(session.id)
87
+ this.roots.delete(session.id)
88
+ return
89
+ }
90
+ if (!this.children.has(session.id)) this.roots.add(session.id)
91
+ }
92
+
93
+ /** Register a snapshot returned by session.list(). */
94
+ registerMany(sessions: ReadonlyArray<{ id: string; parentID?: string }>): void {
95
+ for (const session of sessions) this.register(session)
96
+ }
97
+
98
+ /** Whether SDK metadata identified this session as a child. */
99
+ isChild(sessionID: string): boolean {
100
+ return this.children.has(sessionID)
101
+ }
102
+
103
+ /** Whether SDK metadata identified this session as a root. */
104
+ isRoot(sessionID: string): boolean {
105
+ if (this.children.has(sessionID)) return false
106
+ if (this.roots.has(sessionID)) return true
107
+ // Until the authoritative snapshot is available, an unknown session is
108
+ // deliberately unclassified. A valid root resumed after restart must not
109
+ // be denied merely because session.list() is slow or unavailable.
110
+ return this.seedState !== 'seeded'
111
+ }
112
+
113
+ /** Snapshot of known root IDs. */
114
+ rootIDs(): ReadonlySet<string> {
115
+ return new Set(this.roots)
116
+ }
117
+ }
118
+
119
+ /** Normalize an agent identity without guessing from prompts or tool names. */
120
+ export function normalizeDelegationAgent(agent: unknown): string | undefined {
121
+ if (typeof agent !== 'string') return undefined
122
+ const normalized = agent.trim().toLowerCase()
123
+ return normalized === '' ? undefined : normalized
124
+ }
125
+
126
+ /** Runtime delegation matrix from Fase B2. */
127
+ export function isDelegationAllowed(callerAgent: unknown, targetAgent: unknown): boolean {
128
+ const caller = normalizeDelegationAgent(callerAgent)
129
+ const target = normalizeDelegationAgent(targetAgent)
130
+ if (caller === undefined || target === undefined) return false
131
+ if (caller === 'zeus') return true
132
+ return (caller === 'athena' || caller === 'hermes') && target === 'apollo'
133
+ }
134
+
135
+ function targetFromTool(tool: string, args: unknown): string | undefined {
136
+ if (args === null || typeof args !== 'object') return undefined
137
+ const record = args as Record<string, unknown>
138
+ if (tool === 'pantheon_delegate') return normalizeDelegationAgent(record.agent)
139
+ if (tool === 'task') {
140
+ return normalizeDelegationAgent(record.subagent_type ?? record.subagentType)
141
+ }
142
+ return undefined
143
+ }
52
144
 
53
145
  /** Options for createEnforcementGuard(). */
54
146
  export interface EnforcementGuardOptions {
@@ -56,6 +148,14 @@ export interface EnforcementGuardOptions {
56
148
  blockedTools?: ReadonlySet<string>
57
149
  /** Prefix for the denial message. */
58
150
  messagePrefix?: string
151
+ /** Current agent by session, populated by the SDK chat.params hook. */
152
+ getSessionAgent?: (sessionID: string) => string | undefined
153
+ /** SDK-backed root check used for the delegation matrix. */
154
+ isRootSession?: (sessionID: string) => boolean
155
+ /** SDK-backed child check used to prevent native task() depth escalation. */
156
+ isChildSession?: (sessionID: string) => boolean
157
+ /** Audit sink for denied runtime delegation attempts. */
158
+ logger?: { warn?: (message: string) => void }
59
159
  }
60
160
 
61
161
  // ─── Defaults ──────────────────────────────────────────────────────────
@@ -129,7 +229,41 @@ export function createEnforcementGuard(input: {
129
229
  const blockedTools = input.options?.blockedTools ?? DEFAULT_BLOCKED_TOOLS
130
230
  const prefix = input.options?.messagePrefix ?? 'pantheon_delegate guard'
131
231
 
132
- return async (hook: ToolExecuteBeforeInput): Promise<void> => {
232
+ return async (hook: ToolExecuteBeforeInput, output?: ToolExecuteBeforeOutput): Promise<void> => {
233
+ const isDelegationTool = hook.tool === 'task' || hook.tool === 'pantheon_delegate'
234
+ if (isDelegationTool && input.options?.isChildSession?.(hook.sessionID) === true) {
235
+ throw new Error(
236
+ `${prefix}: session ${hook.sessionID} is a child session — delegation tools are denied ` +
237
+ 'to prevent nested delegation',
238
+ )
239
+ }
240
+ // pantheon_delegate has the authoritative ToolContext.agent at execution
241
+ // time, so its matrix check lives in delegation.ts. The hook only has the
242
+ // session-scoped agent learned from chat.params for native task().
243
+ if (
244
+ hook.tool === 'task' &&
245
+ input.options?.isRootSession !== undefined &&
246
+ input.options?.isChildSession?.(hook.sessionID) !== true
247
+ ) {
248
+ const caller = input.options.getSessionAgent?.(hook.sessionID)
249
+ const target = targetFromTool(hook.tool, output?.args)
250
+ if (!input.options.isRootSession(hook.sessionID)) {
251
+ input.options.logger?.warn?.(
252
+ `${prefix}: delegation denied for session ${hook.sessionID} — not an authorized root session`,
253
+ )
254
+ throw new Error(`${prefix}: session ${hook.sessionID} is not an authorized root session`)
255
+ }
256
+ if (!isDelegationAllowed(caller, target)) {
257
+ const reason =
258
+ caller === undefined ? 'caller agent is unavailable' : 'agent/target is not allowed'
259
+ input.options.logger?.warn?.(
260
+ `${prefix}: delegation denied for session ${hook.sessionID} — ${reason}`,
261
+ )
262
+ throw new Error(
263
+ `${prefix}: delegation denied — ${reason}; target must follow the runtime matrix`,
264
+ )
265
+ }
266
+ }
133
267
  if (!blockedTools.has(hook.tool)) return
134
268
  if (!input.getReadOnlySessions().has(hook.sessionID)) return
135
269
 
@@ -65,6 +65,16 @@ export interface DelegationOptions {
65
65
  rootSessions?: ReadonlySet<string>
66
66
  /** Custom depth-guard predicate — overrides rootSessions and the default. */
67
67
  isRootSession?: (sessionID: string) => boolean
68
+ /** SDK-backed child predicate; authoritative when supplied by the plugin. */
69
+ isChildSession?: (sessionID: string) => boolean
70
+ /**
71
+ * Enforce the runtime B2 delegation matrix; createDelegationTools defaults
72
+ * this to true. Set false only for an explicitly legacy host that cannot
73
+ * provide ToolContext.agent.
74
+ */
75
+ enforceRuntimeMatrix?: boolean
76
+ /** Record a child returned by the SDK session.create call. */
77
+ registerChildSession?: (sessionID: string, parentID: string) => void
68
78
  /**
69
79
  * Agents whose delegated child sessions are registered as read-only
70
80
  * (Phase 4 enforcement). Mirrors routing.yml
@@ -78,6 +88,20 @@ export interface DelegationOptions {
78
88
  * from routing.yml agent entries. Higher priority than the active preset.
79
89
  */
80
90
  agentModels?: Readonly<Record<string, string>>
91
+ /**
92
+ * Fase B3: Per-exception delegation budgets. Keys use
93
+ * `<parent-agent>-><target-agent>` (for example `athena->apollo`). The
94
+ * count is tracked per parent session for this process. It is intentionally
95
+ * not persisted across restart because this layer has no reliable store.
96
+ * An absent key has no budget limit.
97
+ */
98
+ delegationBudgets?: ReadonlyMap<string, number>
99
+ /**
100
+ * Fase B3: Wall-clock timeout (ms) for individual delegation sessions.
101
+ * The plugin resolves `PANTHEON_DELEGATION_TIMEOUT_MS` into this option;
102
+ * direct callers may provide it here instead.
103
+ */
104
+ wallClockTimeoutMs?: number
81
105
  /**
82
106
  * Env override for the active-preset resolution (see resolveActivePreset).
83
107
  * Defaults to process.env. Primarily for tests.
@@ -19,19 +19,13 @@
19
19
  * Depth guard (root-session detection): the ToolContext carries NO parentID
20
20
  * (spike), so root detection is configurable. Resolution order:
21
21
  * 1. `options.isRootSession(sessionID)` custom predicate;
22
- * 2. `options.rootSessions` explicit allowlist — with a knownChildren
23
- * fallback (compaction-134): a session is a sub-session ONLY if WE
24
- * created it as a child (`session.create` via pantheon_delegate).
25
- * Sessions in neither set are treated as root, so a resumed root can
26
- * delegate after a restart even when the allowlist is incomplete
27
- * (opencode does not replay `session.created` events for pre-existing
28
- * sessions — the plugin seeds the registry from session.list() at
29
- * startup, and this fallback covers a failed/incomplete seed);
22
+ * 2. `options.rootSessions` explicit allowlist, with `options.isChildSession`
23
+ * authoritative when supplied by the plugin. SDK session metadata is
24
+ * registered from parentID; no prompt/name inference is used.
30
25
  * 3. default (no rootSessions): any session WE created via
31
26
  * `session.create` is a sub-session; everything else is treated as
32
- * root. Phase 3 should pass the root registry derived from
33
- * `session.created` events (Session.parentID undefined ⇒ root) for
34
- * authoritative enforcement.
27
+ * root for standalone compatibility. The plugin supplies the SDK-backed
28
+ * hierarchy at runtime.
35
29
  *
36
30
  * Read-only enforcement is Phase 4 — `read_only` is exposed on the delegate
37
31
  * args and the child session is registered in the read-only registry when the
@@ -44,7 +38,11 @@
44
38
  import { z } from 'zod'
45
39
 
46
40
  import type { BackgroundJobBoard, BackgroundJobRecord } from './background-job-board.ts'
47
- import { readOnlyRegistry } from './delegation-enforce.ts'
41
+ import {
42
+ isDelegationAllowed,
43
+ normalizeDelegationAgent,
44
+ readOnlyRegistry,
45
+ } from './delegation-enforce.ts'
48
46
  import {
49
47
  DELEGATION_DEFAULTS,
50
48
  type DelegationClient,
@@ -67,6 +65,49 @@ export type {
67
65
  } from './delegation-finalize.ts'
68
66
  export { DELEGATION_DEFAULTS } from './delegation-finalize.ts'
69
67
 
68
+ // Fase B3: these are runtime controls, not routing/frontmatter fields. The
69
+ // plugin reads them from the environment, while tests and embedders can pass
70
+ // the same values through DelegationOptions.
71
+ export const B3_DEFAULT_EXCEPTION_BUDGET = 5
72
+
73
+ function readIntegerEnv(
74
+ env: Record<string, string | undefined>,
75
+ name: string,
76
+ fallback: number,
77
+ minimum: number,
78
+ ): number {
79
+ const raw = env[name]?.trim()
80
+ if (raw === undefined || raw === '') return fallback
81
+ const value = Number(raw)
82
+ return Number.isSafeInteger(value) && value >= minimum ? value : fallback
83
+ }
84
+
85
+ /** Resolve the two supported read-only delegation exception budgets. */
86
+ export function resolveDelegationBudgets(
87
+ env: Record<string, string | undefined> = process.env,
88
+ ): ReadonlyMap<string, number> {
89
+ return new Map([
90
+ [
91
+ 'athena->apollo',
92
+ readIntegerEnv(env, 'PANTHEON_ATHENA_APOLLO_BUDGET', B3_DEFAULT_EXCEPTION_BUDGET, 0),
93
+ ],
94
+ [
95
+ 'hermes->apollo',
96
+ readIntegerEnv(env, 'PANTHEON_HERMES_APOLLO_BUDGET', B3_DEFAULT_EXCEPTION_BUDGET, 0),
97
+ ],
98
+ ])
99
+ }
100
+
101
+ /** Resolve an optional positive wall-clock timeout from the environment. */
102
+ export function resolveDelegationTimeoutMs(
103
+ env: Record<string, string | undefined> = process.env,
104
+ ): number | undefined {
105
+ const raw = env.PANTHEON_DELEGATION_TIMEOUT_MS?.trim()
106
+ if (raw === undefined || raw === '') return undefined
107
+ const value = Number(raw)
108
+ return Number.isSafeInteger(value) && value > 0 ? value : undefined
109
+ }
110
+
70
111
  // ─── Types ─────────────────────────────────────────────────────────────
71
112
 
72
113
  // Silence-by-default TUI policy (pantheon-hooks L42-58): console output in a
@@ -79,6 +120,11 @@ export interface ToolContextLike {
79
120
  sessionID: string
80
121
  directory?: string
81
122
  worktree?: string
123
+ /**
124
+ * The OpenCode SDK's ToolContext declares this as required. It remains
125
+ * optional here because the structural test/embedding surface can be
126
+ * supplied by older hosts; B3 must skip enforcement when it is absent.
127
+ */
82
128
  agent?: string
83
129
  }
84
130
 
@@ -146,6 +192,9 @@ function stateLabel(state: BackgroundJobRecord['state']): string {
146
192
  }
147
193
  }
148
194
 
195
+ /** Normalize the SDK-provided caller agent without inventing an identity. */
196
+ const normalizeToolAgent = normalizeDelegationAgent
197
+
149
198
  // ─── Agent activity sampling ────────────────────────────────────────────
150
199
  //
151
200
  // Visibility for `pantheon_delegation_read` / `pantheon_delegation_list`
@@ -478,16 +527,24 @@ export function collectRootSessionIDs(
478
527
  */
479
528
  export function createDelegationTools(input: CreateDelegationToolsInput): DelegationToolset {
480
529
  const { board, client } = input
481
- const options: DelegationOptions = input.options ?? {}
530
+ // Runtime matrix enforcement is the safe default for every host. A legacy
531
+ // structural embedder must opt out explicitly with `false`; omission must
532
+ // never silently widen the delegation surface.
533
+ const options: DelegationOptions = { enforceRuntimeMatrix: true, ...(input.options ?? {}) }
482
534
  const deps: DelegationDeps = { board, client, options }
483
535
  const outputDir = options.outputDir ?? DELEGATION_DEFAULTS.outputDir
484
- const timeoutMs = options.timeoutMs ?? DELEGATION_DEFAULTS.timeoutMs
536
+ const timeoutMs = options.wallClockTimeoutMs ?? options.timeoutMs ?? DELEGATION_DEFAULTS.timeoutMs
485
537
  const readTimeoutMs = options.readTimeoutMs ?? DELEGATION_DEFAULTS.readTimeoutMs
486
538
 
487
539
  const knownChildren = new Set<string>()
488
540
  const timers = new Map<string, NodeJS.Timeout>()
541
+ // Fase B3: per-parent-session delegation budget tracking, scoped to this
542
+ // factory/process instance. Keyed by `${parentSessionID}:${parentAgent}->${targetAgent}`;
543
+ // it is not restart-persistent because the factory has no reliable store.
544
+ const budgetUsage = new Map<string, number>()
489
545
 
490
546
  function isRootSession(sessionID: string): boolean {
547
+ if (options.isChildSession?.(sessionID) === true) return false
491
548
  if (options.isRootSession !== undefined) return options.isRootSession(sessionID)
492
549
  if (options.rootSessions !== undefined) {
493
550
  // compaction-134 (fix layer 2): the allowlist alone is NOT
@@ -540,6 +597,54 @@ export function createDelegationTools(input: CreateDelegationToolsInput): Delega
540
597
  )
541
598
  }
542
599
 
600
+ // Fase B3: only the two read-only exceptions are budgeted. The official
601
+ // SDK provides the current agent on ToolContext. If a structural/older
602
+ // host omits it, do NOT infer identity from prompt, target, or process
603
+ // state: the budget is explicitly skipped and the delegation continues.
604
+ const parentAgent = normalizeToolAgent(ctx.agent)
605
+ const targetAgent = args.agent.toLowerCase()
606
+ if (options.enforceRuntimeMatrix === true && !isDelegationAllowed(parentAgent, targetAgent)) {
607
+ const reason =
608
+ parentAgent === undefined
609
+ ? 'ToolContext.agent is unavailable'
610
+ : `${parentAgent}->${targetAgent} is not allowed`
611
+ options.logger?.warn?.(
612
+ `[pantheon-delegate] runtime delegation denied for session ${ctx.sessionID}: ${reason}`,
613
+ )
614
+ throw new Error(`pantheon_delegate rejected: runtime delegation matrix denied (${reason})`)
615
+ }
616
+ if (
617
+ parentAgent === undefined &&
618
+ !options.enforceRuntimeMatrix &&
619
+ (options.delegationBudgets?.size ?? 0) > 0
620
+ ) {
621
+ const message =
622
+ `[pantheon-delegate] B3 budget skipped: current agent unavailable for session ${ctx.sessionID}; ` +
623
+ `no budget limit applied to ${targetAgent}. Runtime must provide ToolContext.agent ` +
624
+ '(the official SDK field) to enforce the configured exception budget.'
625
+ ;(options.logger?.warn ?? ((msg: string) => log.warn(msg)))(message)
626
+ }
627
+ const budgetKey =
628
+ parentAgent === undefined ? undefined : `${ctx.sessionID}:${parentAgent}->${targetAgent}`
629
+ const budget =
630
+ parentAgent === undefined
631
+ ? undefined
632
+ : options.delegationBudgets?.get(`${parentAgent}->${targetAgent}`)
633
+ let budgetReserved = false
634
+ if (budgetKey !== undefined && budget !== undefined) {
635
+ const used = budgetUsage.get(budgetKey) ?? 0
636
+ if (used >= budget) {
637
+ throw new Error(
638
+ `pantheon_delegate rejected: delegation budget exhausted for ${parentAgent}->${targetAgent} ` +
639
+ `(${used}/${budget} dispatches used in this session). ` +
640
+ 'Configure the corresponding PANTHEON_*_APOLLO_BUDGET environment variable or DelegationOptions.',
641
+ )
642
+ }
643
+ // Reserve before the first await so concurrent calls cannot overshoot.
644
+ budgetUsage.set(budgetKey, used + 1)
645
+ budgetReserved = true
646
+ }
647
+
543
648
  // session.create failure → return a clear error as TEXT (tools return
544
649
  // errors as text, not thrown) and register NO job on the board — an
545
650
  // unhandled rejection here would otherwise lose the failure entirely.
@@ -551,7 +656,11 @@ export function createDelegationTools(input: CreateDelegationToolsInput): Delega
551
656
  // opencode/deepseek-v4-flash-free; if nothing usable remains the
552
657
  // failure is returned as TEXT below — no session, no board job.
553
658
  const resolved = resolveUsableChildModel(options, args.agent, args.model)
554
- if (resolved.error !== undefined) return resolved.error
659
+ if (resolved.error !== undefined) {
660
+ if (budgetReserved && budgetKey !== undefined)
661
+ budgetUsage.set(budgetKey, (budgetUsage.get(budgetKey) ?? 1) - 1)
662
+ return resolved.error
663
+ }
555
664
  const childModel = resolved.model
556
665
  let created: { id: string }
557
666
  try {
@@ -564,10 +673,14 @@ export function createDelegationTools(input: CreateDelegationToolsInput): Delega
564
673
  })
565
674
  } catch (err: unknown) {
566
675
  const reason = err instanceof Error ? err.message : String(err)
676
+ if (budgetReserved && budgetKey !== undefined) {
677
+ budgetUsage.set(budgetKey, Math.max(0, (budgetUsage.get(budgetKey) ?? 1) - 1))
678
+ }
567
679
  return `pantheon_delegate failed to create child session: ${reason}`
568
680
  }
569
681
  const childSessionID = created.id
570
682
  knownChildren.add(childSessionID)
683
+ options.registerChildSession?.(childSessionID, ctx.sessionID)
571
684
 
572
685
  // Phase 4 read-only enforcement: register the child session in the
573
686
  // read-only registry when the delegate is read-only — explicit
@@ -7,7 +7,7 @@ import { fileURLToPath } from 'node:url'
7
7
  import type { Hooks, PluginInput } from '@opencode-ai/plugin'
8
8
  import type { FilePart, Part, TextPart, UserMessage } from '@opencode-ai/sdk'
9
9
  import type { ResolvedPreset } from './presets.mjs'
10
- import { hasVision, resolveActivePreset, visionBrokenOnGateway } from './presets.mjs'
10
+ import { CAPABILITY_TABLE, hasVision, resolveActivePreset, visionBrokenOnGateway } from './presets.mjs'
11
11
 
12
12
  // ─── Constants ────────────────────────────────────────────────────────────
13
13
 
@@ -121,9 +121,30 @@ type NativeVisionEndpoint = { modelID: string; baseURL: string }
121
121
 
122
122
  export type VisionMode = 'native' | 'tool' | 'auto'
123
123
 
124
+ /**
125
+ * Check whether a provider is text-only by examining CAPABILITY_TABLE.
126
+ * A provider is text-only if ALL provider-qualified entries (prefix starting
127
+ * with `providerID/`) have `vision: false`. If no provider-qualified entries
128
+ * exist, the provider is unknown → returns false (fail-open).
129
+ */
130
+ export function providerIsTextOnly(providerID: string): boolean {
131
+ const providerEntries = CAPABILITY_TABLE.filter((entry) =>
132
+ entry.prefix.startsWith(`${providerID}/`),
133
+ )
134
+ if (providerEntries.length === 0) return false
135
+ return providerEntries.every((entry) => !entry.vision)
136
+ }
137
+
124
138
  /** Return whether OpenCode's current model is known to accept image input. */
125
- export function modelAcceptsImages(model: ModelInfo | undefined): boolean {
126
- if (!model) return true
139
+ export function modelAcceptsImages(
140
+ model: ModelInfo | undefined,
141
+ providerID?: string,
142
+ ): boolean {
143
+ if (!model) {
144
+ // Fail-closed for known text-only providers
145
+ if (providerID && providerIsTextOnly(providerID)) return false
146
+ return true
147
+ }
127
148
  try {
128
149
  const id = `${model.providerID}/${model.modelID}`
129
150
  // Provider/gateway-aware: a model can be vision-capable per models.dev
@@ -1230,7 +1251,7 @@ export function createVisionHandler(input: PluginInput) {
1230
1251
  const message: UserMessage | undefined = output?.message
1231
1252
  if (!message || message.role !== 'user') return
1232
1253
  const model = hookInput?.model ?? message.model
1233
- if (modelAcceptsImages(model)) {
1254
+ if (modelAcceptsImages(model, model?.providerID)) {
1234
1255
  nativeVisionSessions.add(hookInput.sessionID)
1235
1256
  return
1236
1257
  }
package/src/plugin.ts CHANGED
@@ -7,9 +7,15 @@ import {
7
7
  collectRootSessionIDs,
8
8
  createDelegationTools,
9
9
  type DelegationClient,
10
+ resolveDelegationBudgets,
11
+ resolveDelegationTimeoutMs,
10
12
  } from './pantheon/delegation.ts'
11
13
  import { buildCompactionContext } from './pantheon/delegation-compaction.ts'
12
- import { createEnforcementGuard, readOnlyRegistry } from './pantheon/delegation-enforce.ts'
14
+ import {
15
+ createEnforcementGuard,
16
+ readOnlyRegistry,
17
+ SessionHierarchyRegistry,
18
+ } from './pantheon/delegation-enforce.ts'
13
19
  import { handleDelegationEvent } from './pantheon/delegation-notify.ts'
14
20
  import { FilePersistenceAdapter } from './pantheon/file-persistence.ts'
15
21
  import { GOAL_LOOP_DEFAULTS, GoalLoop, GoalStore } from './pantheon/goal-loop.ts'
@@ -94,6 +100,12 @@ setInterval(
94
100
  // from tool.execute.before; unknown sessions are allowed (safe default).
95
101
  const enforcementGuard = createEnforcementGuard({
96
102
  getReadOnlySessions: () => readOnlyRegistry.sessionIDs(),
103
+ options: {
104
+ getSessionAgent: (sessionID) => sessionAgents.get(sessionID),
105
+ isRootSession: (sessionID) => rootSessions.has(sessionID) || sessionHierarchy.isRoot(sessionID),
106
+ isChildSession: (sessionID) => sessionHierarchy.isChild(sessionID),
107
+ logger: log,
108
+ },
97
109
  })
98
110
 
99
111
  // Mirrors routing.yml background_delegation.max_compaction_items.
@@ -111,6 +123,8 @@ const COMPACTION_MAX_ITEMS = 10
111
123
  // created is a sub-session; unknown sessions are roots") so the depth guard
112
124
  // works even when this set is incomplete.
113
125
  const rootSessions = new Set<string>()
126
+ const sessionHierarchy = new SessionHierarchyRegistry()
127
+ const sessionAgents = new Map<string, string>()
114
128
 
115
129
  /**
116
130
  * Seed resumed root sessions without making OpenCode startup depend on the
@@ -127,18 +141,23 @@ function seedRootSessionsInBackground(input: PluginInput): void {
127
141
  // the seed is always safe.
128
142
  const session = input.client.session
129
143
  if (typeof session?.list !== 'function') return
144
+ sessionHierarchy.beginSeed()
130
145
  void session
131
146
  .list()
132
147
  .then((result) => {
133
148
  if (!result.error && Array.isArray(result.data)) {
149
+ sessionHierarchy.completeSeed(result.data)
134
150
  const seeded = collectRootSessionIDs(result.data)
135
151
  if (seeded.size > 0) {
136
152
  for (const id of seeded) rootSessions.add(id)
137
153
  log.info(`[Pantheon Plugin] Seeded ${seeded.size} root session(s) from session.list`)
138
154
  }
155
+ } else {
156
+ sessionHierarchy.failSeed()
139
157
  }
140
158
  })
141
159
  .catch((err) => {
160
+ sessionHierarchy.failSeed()
142
161
  log.warn('[Pantheon Plugin] Root session seed failed (fail-open):', err)
143
162
  })
144
163
  }
@@ -264,13 +283,26 @@ const plugin: Plugin = async (input: PluginInput) => {
264
283
  // Phase 2/3: background delegation toolset + the bound finalize lifecycle
265
284
  // hook. Completion is observed through the event hook below
266
285
  // (session.idle / session.error on a child) → finalizeDelegation.
286
+ // Fase B3: runtime controls for the two read-only exceptions and the real
287
+ // per-child wall-clock timer. No task_budget field is injected into the
288
+ // plugin/frontmatter; these are enforced by delegation.ts itself.
289
+ const delegationBudgets = resolveDelegationBudgets()
290
+ const wallClockTimeoutMs = resolveDelegationTimeoutMs()
291
+
267
292
  const delegation = createDelegationTools({
268
293
  board,
269
294
  client: adaptDelegationClient(input.client),
270
295
  options: {
271
296
  rootSessions,
297
+ isChildSession: (sessionID) => sessionHierarchy.isChild(sessionID),
298
+ registerChildSession: (sessionID, parentID) => {
299
+ sessionHierarchy.register({ id: sessionID, parentID })
300
+ },
301
+ enforceRuntimeMatrix: true,
272
302
  readOnlyAgents: new Set(['apollo', 'gaia']),
273
303
  agentModels: routingAgentModels,
304
+ delegationBudgets,
305
+ ...(wallClockTimeoutMs !== undefined ? { wallClockTimeoutMs } : {}),
274
306
  },
275
307
  })
276
308
 
@@ -393,12 +425,20 @@ const plugin: Plugin = async (input: PluginInput) => {
393
425
  // user-activity gate skips injection for userActivityQuietMs afterwards.
394
426
  todoEnforcer.noteUserActivity(hookInput.sessionID)
395
427
  },
428
+ 'chat.params': async (hookInput) => {
429
+ // The SDK exposes the active agent here; tool.execute.before has no
430
+ // agent field. Never infer identity from a prompt or tool name.
431
+ sessionAgents.set(hookInput.sessionID, hookInput.agent)
432
+ },
396
433
  'experimental.chat.messages.transform': vision.messagesTransform,
397
434
  event: async ({ event: ev }) => {
398
435
  // Phase 3: sessions created without a parent are roots for the depth guard.
399
436
  if (ev.type === 'session.created') {
400
437
  const info = ev.properties.info
401
- if (info && info.parentID === undefined) rootSessions.add(info.id)
438
+ if (info) {
439
+ sessionHierarchy.register(info)
440
+ if (info.parentID === undefined) rootSessions.add(info.id)
441
+ }
402
442
  }
403
443
  // release-134 Phase 3: activate the todo snapshot captured at
404
444
  // compacting time — the first todowrite within the restore window is
@@ -449,7 +489,7 @@ const plugin: Plugin = async (input: PluginInput) => {
449
489
  // first post-compaction `todowrite` with the captured todo snapshot.
450
490
  // Both are no-ops for non-matching sessions/tools; the preserver is
451
491
  // fail-open (only its intentional restore denial throws).
452
- await enforcementGuard(input)
492
+ await enforcementGuard(input, output)
453
493
  await todoPreserver.beforeTodoWrite(input, output)
454
494
  },
455
495
  // Wave 2 (PR #46): augment `read` output with hashline tags. Additive —
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "pantheon-tui",
3
3
  "type": "module",
4
- "version": "1.3.5-beta.52.c75b04d",
4
+ "version": "1.3.5-beta.60.efa533f",
5
5
  "description": "Pantheon TUI plugin — sidebar (version, preset, sessions, real-time delegations) + AI subscription usage gauges incl. OpenCode Go/Zen dollar limits (vendored, MIT)",
6
6
  "license": "MIT",
7
7
  "exports": {