pantheon-opencode 1.4.2 → 1.4.3

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.
@@ -23,8 +23,6 @@ import { lstat, mkdir, open, rename, unlink } from 'node:fs/promises'
23
23
  import { homedir } from 'node:os'
24
24
  import { basename, dirname, join, resolve } from 'node:path'
25
25
  import { z } from 'zod'
26
-
27
- import type { ToolContextLike } from './delegation.ts'
28
26
  import {
29
27
  capabilityEntry,
30
28
  hasVision,
@@ -32,6 +30,7 @@ import {
32
30
  normalizeCapability,
33
31
  resolveActivePreset,
34
32
  } from './presets.mjs'
33
+ import type { ToolContextLike } from './tool-context.ts'
35
34
 
36
35
  export type ModelScope = 'project' | 'global'
37
36
  export type ModelAction = 'status' | 'show' | 'set' | 'reset'
@@ -65,10 +64,7 @@ const modelArgs = {
65
64
  .optional()
66
65
  .describe('Operation: status/show, set, or reset. No args runs wizard.'),
67
66
  agent: z.string().optional().describe('Agent name (one of 14 canonical agents).'),
68
- model: z
69
- .string()
70
- .optional()
71
- .describe('Model in provider/model-id format; used by set.'),
67
+ model: z.string().optional().describe('Model in provider/model-id format; used by set.'),
72
68
  effort: z
73
69
  .enum(['low', 'medium', 'high'])
74
70
  .optional()
@@ -357,10 +353,7 @@ function presetPathForScope(options: ModelCommandOptions, scope: ModelScope): st
357
353
  return globalCandidates[0] ?? candidates[0]!
358
354
  }
359
355
 
360
- async function readActivePresetRaw(
361
- path: string,
362
- label: string,
363
- ): Promise<ReadFileResult> {
356
+ async function readActivePresetRaw(path: string, label: string): Promise<ReadFileResult> {
364
357
  const file = await readRegularFile(path, label)
365
358
  if (file === undefined) return { exists: false, valid: true, data: null }
366
359
  try {
@@ -368,7 +361,13 @@ async function readActivePresetRaw(
368
361
  if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
369
362
  return { exists: true, valid: false, data: null, content: file.content, mode: file.mode }
370
363
  }
371
- return { exists: true, valid: true, data: parsed as Record<string, unknown>, content: file.content, mode: file.mode }
364
+ return {
365
+ exists: true,
366
+ valid: true,
367
+ data: parsed as Record<string, unknown>,
368
+ content: file.content,
369
+ mode: file.mode,
370
+ }
372
371
  } catch {
373
372
  return { exists: true, valid: false, data: null, content: file.content, mode: file.mode }
374
373
  }
@@ -444,9 +443,21 @@ async function writeActivePresetAtomically(
444
443
  if (current.content === undefined) {
445
444
  throw new ModelCommandError('configuration content could not be read; file preserved')
446
445
  }
447
- await atomicReplace(backupPath, current.content, backupMode, 'configuration backup', backup !== undefined)
446
+ await atomicReplace(
447
+ backupPath,
448
+ current.content,
449
+ backupMode,
450
+ 'configuration backup',
451
+ backup !== undefined,
452
+ )
448
453
  }
449
- await atomicReplace(path, `${JSON.stringify(data, null, 2)}\n`, originalMode, 'configuration file', current.exists)
454
+ await atomicReplace(
455
+ path,
456
+ `${JSON.stringify(data, null, 2)}\n`,
457
+ originalMode,
458
+ 'configuration file',
459
+ current.exists,
460
+ )
450
461
  }
451
462
 
452
463
  function ensureActivePresetShape(
@@ -460,11 +471,21 @@ function ensureActivePresetShape(
460
471
  }
461
472
  if (typeof out.source !== 'string') out.source = 'cli'
462
473
  out.updated_at = new Date().toISOString()
463
- if (out.overrides === undefined || out.overrides === null || typeof out.overrides !== 'object' || Array.isArray(out.overrides)) {
474
+ if (
475
+ out.overrides === undefined ||
476
+ out.overrides === null ||
477
+ typeof out.overrides !== 'object' ||
478
+ Array.isArray(out.overrides)
479
+ ) {
464
480
  out.overrides = {}
465
481
  }
466
482
  const overrides = out.overrides as Record<string, unknown>
467
- if (overrides.agents === undefined || overrides.agents === null || typeof overrides.agents !== 'object' || Array.isArray(overrides.agents)) {
483
+ if (
484
+ overrides.agents === undefined ||
485
+ overrides.agents === null ||
486
+ typeof overrides.agents !== 'object' ||
487
+ Array.isArray(overrides.agents)
488
+ ) {
468
489
  overrides.agents = {}
469
490
  }
470
491
  return out
@@ -509,14 +530,25 @@ async function updateActivePreset(
509
530
  } catch {
510
531
  // ignore
511
532
  }
512
- } else if (current.data && typeof current.data === 'object' && (current.data as Record<string, unknown>).preset) {
533
+ } else if (
534
+ current.data &&
535
+ typeof current.data === 'object' &&
536
+ (current.data as Record<string, unknown>).preset
537
+ ) {
513
538
  const existing = (current.data as Record<string, unknown>).preset as string
514
- if (typeof existing === 'string' && existing.trim() !== '' && SAFE_PRESET_NAME.test(existing.trim())) {
539
+ if (
540
+ typeof existing === 'string' &&
541
+ existing.trim() !== '' &&
542
+ SAFE_PRESET_NAME.test(existing.trim())
543
+ ) {
515
544
  fallbackPreset = existing.trim()
516
545
  }
517
546
  }
518
547
 
519
- const shaped = ensureActivePresetShape(current.data as Record<string, unknown> | null, fallbackPreset)
548
+ const shaped = ensureActivePresetShape(
549
+ current.data as Record<string, unknown> | null,
550
+ fallbackPreset,
551
+ )
520
552
  const overrides = shaped.overrides as Record<string, unknown>
521
553
  const agents = overrides.agents as Record<string, unknown>
522
554
 
@@ -551,7 +583,10 @@ async function updateActivePreset(
551
583
 
552
584
  // If clamped, append warning
553
585
  if (variantInfo.clamped) {
554
- visionWarning = [visionWarning, `effort clamped to ${variantInfo.variant} for model ${model}`]
586
+ visionWarning = [
587
+ visionWarning,
588
+ `effort clamped to ${variantInfo.variant} for model ${model}`,
589
+ ]
555
590
  .filter(Boolean)
556
591
  .join('; ')
557
592
  }
@@ -584,10 +619,6 @@ async function updateActivePreset(
584
619
 
585
620
  // ─── Status ─────────────────────────────────────────────────────────────────
586
621
 
587
- function silentLogger(): ModelCommandLogger {
588
- return { warn: () => {}, log: () => {} }
589
- }
590
-
591
622
  async function resolvePerAgentStatus(options: ModelCommandOptions): Promise<{
592
623
  presetName: string | null
593
624
  presetSource: string | null
@@ -624,12 +655,20 @@ async function resolvePerAgentStatus(options: ModelCommandOptions): Promise<{
624
655
  let vision: boolean | undefined
625
656
 
626
657
  if (resolved) {
627
- const merged = (resolved.agents as Record<string, { model?: string; reasoning_effort?: string; variant?: string }>)?.[key]
658
+ const merged = (
659
+ resolved.agents as Record<
660
+ string,
661
+ { model?: string; reasoning_effort?: string; variant?: string }
662
+ >
663
+ )?.[key]
628
664
  if (merged?.model) {
629
665
  model = merged.model
630
- effort = (merged as { reasoning_effort?: string }).reasoning_effort ?? (merged as { variant?: string }).variant
666
+ effort =
667
+ (merged as { reasoning_effort?: string }).reasoning_effort ??
668
+ (merged as { variant?: string }).variant
631
669
  // Determine origin: file overrides > file preset > env
632
- const fileOverrides = (resolved.overrides as { agents?: Record<string, unknown> } | null)?.agents
670
+ const fileOverrides = (resolved.overrides as { agents?: Record<string, unknown> } | null)
671
+ ?.agents
633
672
  if (resolved.source === 'file' && fileOverrides && Object.hasOwn(fileOverrides, key)) {
634
673
  origin = 'override'
635
674
  } else if (resolved.source === 'env') {
@@ -665,7 +704,8 @@ async function renderStatus(options: ModelCommandOptions): Promise<string> {
665
704
  const lines: string[] = []
666
705
  lines.push('Pantheon model configuration per-agent (restart OpenCode after changes):')
667
706
  if (presetName) {
668
- const originLabel = presetSource === 'env' ? 'env' : presetSource === 'file' ? 'preset' : presetSource
707
+ const originLabel =
708
+ presetSource === 'env' ? 'env' : presetSource === 'file' ? 'preset' : presetSource
669
709
  lines.push(`active preset: ${presetName} (${presetSource ?? 'unknown'})`)
670
710
  // For test compatibility, also show origin mapping explicitly
671
711
  void originLabel
@@ -700,7 +740,8 @@ export function buildWizardQuestions(): unknown[] {
700
740
  },
701
741
  {
702
742
  header: 'Modelo',
703
- question: 'Informe o modelo em provider/model-id (ex: openai/gpt-5.6 ou opencode-go/kimi-k2.7-code)',
743
+ question:
744
+ 'Informe o modelo em provider/model-id (ex: openai/gpt-5.6 ou opencode-go/kimi-k2.7-code)',
704
745
  kind: 'text',
705
746
  },
706
747
  {
@@ -717,8 +758,14 @@ export function buildWizardQuestions(): unknown[] {
717
758
  header: 'Escopo',
718
759
  question: 'Onde salvar o override?',
719
760
  options: [
720
- { label: 'project', description: './.pantheon/active-preset.json (escopo do projeto, seguro)' },
721
- { label: 'global', description: '~/.config/opencode/.pantheon/active-preset.json (global)' },
761
+ {
762
+ label: 'project',
763
+ description: './.pantheon/active-preset.json (escopo do projeto, seguro)',
764
+ },
765
+ {
766
+ label: 'global',
767
+ description: '~/.config/opencode/.pantheon/active-preset.json (global)',
768
+ },
722
769
  ],
723
770
  multiSelect: false,
724
771
  },
@@ -741,11 +788,15 @@ async function promptViaReadline(
741
788
  answers.model = modelAns
742
789
 
743
790
  const effortQ = questions[2]!
744
- const effortAns = (await rl.question(`${effortQ.question} [low/medium/high] : `)).trim().toLowerCase()
791
+ const effortAns = (await rl.question(`${effortQ.question} [low/medium/high] : `))
792
+ .trim()
793
+ .toLowerCase()
745
794
  answers.effort = effortAns || 'medium'
746
795
 
747
796
  const scopeQ = questions[3]!
748
- const scopeAns = (await rl.question(`${scopeQ.question} [project/global] : `)).trim().toLowerCase()
797
+ const scopeAns = (await rl.question(`${scopeQ.question} [project/global] : `))
798
+ .trim()
799
+ .toLowerCase()
749
800
  answers.scope = scopeAns || 'project'
750
801
 
751
802
  return answers
@@ -779,7 +830,8 @@ async function runWizard(options: ModelCommandOptions): Promise<string> {
779
830
  const agent = typeof answers.agent === 'string' ? answers.agent.trim() : ''
780
831
  const model = typeof answers.model === 'string' ? answers.model.trim() : ''
781
832
  const effort = typeof answers.effort === 'string' ? answers.effort.trim().toLowerCase() : ''
782
- const scopeRaw = typeof answers.scope === 'string' ? answers.scope.trim().toLowerCase() : 'project'
833
+ const scopeRaw =
834
+ typeof answers.scope === 'string' ? answers.scope.trim().toLowerCase() : 'project'
783
835
  const scope: ModelScope = scopeRaw === 'global' ? 'global' : 'project'
784
836
 
785
837
  if (!agent) return 'pantheon_model wizard failed: agent is required'
@@ -830,7 +882,8 @@ export function createModelCommand(options: ModelCommandOptions = {}): ModelTool
830
882
  return await runWizard(options)
831
883
  }
832
884
 
833
- const action: ModelAction = (args.action ?? (hasAgentModelEffort ? 'set' : 'status')) as ModelAction
885
+ const action: ModelAction = (args.action ??
886
+ (hasAgentModelEffort ? 'set' : 'status')) as ModelAction
834
887
 
835
888
  if (action === 'status' || action === 'show') return await renderStatus(options)
836
889
 
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Shared tool-context types and path guards used by the preserved tool
3
+ * modules (cost-command, goal-loop, model-command, goal-store).
4
+ *
5
+ * These were previously exported from the removed delegation modules; they
6
+ * are hoisted here so the surviving subsystems keep a single source of truth
7
+ * without depending on the deleted delegate machinery.
8
+ *
9
+ * @module tool-context
10
+ */
11
+
12
+ /**
13
+ * The structural tool-execution context a tool's `execute` receives. Mirrors
14
+ * the OpenCode SDK's ToolContext but keeps every field optional so the
15
+ * structural test/embedding surface can be supplied by older hosts.
16
+ */
17
+ export interface ToolContextLike {
18
+ sessionID: string
19
+ directory?: string
20
+ worktree?: string
21
+ /**
22
+ * The OpenCode SDK's ToolContext declares this as required. It remains
23
+ * optional here because the structural test/embedding surface can be
24
+ * supplied by older hosts; enforcement must skip when it is absent.
25
+ */
26
+ agent?: string
27
+ }
28
+
29
+ /**
30
+ * Path-traversal guard for session IDs embedded in file paths. Throws on
31
+ * `..`, `/`, `\`. Used by goal-store to keep goal files inside their dir.
32
+ */
33
+ export function assertSafeParentSessionID(parentSessionID: string): void {
34
+ if (
35
+ parentSessionID.includes('..') ||
36
+ parentSessionID.includes('/') ||
37
+ parentSessionID.includes('\\')
38
+ ) {
39
+ throw new Error(`Invalid parentSessionID: ${parentSessionID}`)
40
+ }
41
+ }
package/src/plugin.ts CHANGED
@@ -1,5 +1,3 @@
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 { BackgroundJobBoard } from './pantheon/background-job-board.ts'
@@ -12,14 +10,6 @@ import {
12
10
  DEFAULT_LIMITS as SANDBOX_DEFAULT_LIMITS,
13
11
  } from './pantheon/context-sandbox.ts'
14
12
  import { createCostCommand } from './pantheon/cost-command.ts'
15
- import {
16
- collectRootSessionIDs,
17
- createDelegationTools,
18
- type DelegationClient,
19
- resolveDelegationBudgets,
20
- resolveDelegationTimeoutMs,
21
- startIdleChildScan,
22
- } from './pantheon/delegation.ts'
23
13
  import { buildCompactionContext } from './pantheon/delegation-compaction.ts'
24
14
  import {
25
15
  createEnforcementGuard,
@@ -27,8 +17,6 @@ import {
27
17
  SessionHierarchyRegistry,
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 { FilePersistenceAdapter } from './pantheon/file-persistence.ts'
33
21
  import { GOAL_LOOP_DEFAULTS, GoalLoop, GoalStore } from './pantheon/goal-loop.ts'
34
22
  import { createReadEnhancer } from './pantheon/hashline/read-enhancer.ts'
@@ -37,14 +25,8 @@ import { createIdleDispatcher } from './pantheon/idle-continuation.ts'
37
25
  import { createPantheonLogger } from './pantheon/logger.ts'
38
26
  import { createModelCommand } from './pantheon/model-command.ts'
39
27
  import { pantheonPluginOnce } from './pantheon/plugin-once.ts'
40
- import {
41
- applyActivePresetToConfig,
42
- loadRoutingAgentModels,
43
- loadRoutingMaxSteps,
44
- loadRoutingPermissionTask,
45
- } from './pantheon/presets.mjs'
28
+ import { applyActivePresetToConfig } from './pantheon/presets.mjs'
46
29
  import { safeSessionPath } from './pantheon/session-guard.ts'
47
- import { StepCapTracker } from './pantheon/step-cap.ts'
48
30
  import {
49
31
  TODO_ENFORCER_DEFAULTS,
50
32
  TodoEnforcer,
@@ -63,24 +45,12 @@ import { activePresetCandidates, createVisionHandler } from './pantheon/vision.t
63
45
  // .pantheon/logs/hooks.log; the console echo is opt-in via PANTHEON_HOOKS_LOG=1.
64
46
  const log = createPantheonLogger({ module: 'pantheon-plugin' })
65
47
 
66
- // R4: per-agent step caps from routing.yml `agents.<name>.max_steps`
67
- // (fail-open → {} = no agent capped). R1 retry_policy/cooldown are NOT
68
- // wired here — the plugin has no retry path (opencode cannot intercept task
69
- // completion); zeus waves load them via presets.mjs loaders and pass them
70
- // to zeusDelegateWithRetry explicitly.
71
- const stepCapTracker = new StepCapTracker(loadRoutingMaxSteps({ logger: log }))
72
-
73
- // O5: permission.task glob rules from routing.yml `permission.task`
74
- // (fail-open → undefined = existing runtime matrix only).
75
- const routingPermissionTask = loadRoutingPermissionTask({ logger: log })
76
-
77
48
  const board = new BackgroundJobBoard({
78
49
  maxConcurrentPerAgent: 3,
79
50
  signalDir: '.pantheon/deepwork/board-signals',
80
51
  })
81
52
  const persistence = new FilePersistenceAdapter('.pantheon/board/state.json')
82
53
  board.setPersistence(persistence)
83
- let terminalToastClient: ToastClient = {}
84
54
  board
85
55
  .recoverRunningJobs()
86
56
  .catch((err) => log.error('[Pantheon Plugin] Failed to recover running jobs:', err))
@@ -90,17 +60,13 @@ board
90
60
  // file-only log line (console echo opt-in via PANTHEON_HOOKS_LOG). There is
91
61
  // deliberately NO chat delivery — the user policy is zero delegation
92
62
  // notifications in the transcript. Completion visibility lives in the board
93
- // `[unread]` marker (pantheon_delegation_list), pantheon_delegation_read,
94
- // TUI toasts (pantheon-hooks, PANTHEON_TOASTS gate) and compaction
95
- // carry-forward.
63
+ // `[unread]` marker and compaction carry-forward.
96
64
  board.onTerminal((taskID: string) => {
97
65
  const job = board.get(taskID)
98
66
  if (!job) return
99
67
  log.info(
100
68
  `[Pantheon Plugin] Board terminal: [${job.alias}] ${job.description} → ${job.state}${job.resultSummary ? ` — ${job.resultSummary}` : ''}`,
101
69
  )
102
- // Deliberately direct-to-TUI: onTerminal must not inject chat/transcript text.
103
- void showDelegationTerminalToast(terminalToastClient, job)
104
70
  })
105
71
 
106
72
  // Periodically prune terminal/reconciled jobs (24h TTL, every 30 min).
@@ -118,10 +84,10 @@ setInterval(
118
84
 
119
85
  // ─── Phase 4: Read-Only Enforcement + Compaction Context ───────────────
120
86
 
121
- // The read-only registry is populated by createDelegationTools (delegation.ts)
122
- // when a delegate is read-only (explicit flag or agent ∈ readOnlyAgents).
123
- // The guard denies edit/write/bash/task inside those sessions by throwing
124
- // from tool.execute.before; unknown sessions are allowed (safe default).
87
+ // The read-only registry is populated when a child session is launched
88
+ // read-only (explicit flag or agent ∈ readOnlyAgents). The guard denies
89
+ // edit/write/bash/task inside those sessions by throwing from
90
+ // tool.execute.before; unknown sessions are allowed (safe default).
125
91
  const enforcementGuard = createEnforcementGuard({
126
92
  getReadOnlySessions: () => readOnlyRegistry.sessionIDs(),
127
93
  options: {
@@ -142,7 +108,7 @@ const commandNormalizer = createCommandNormalizer({ logger: log })
142
108
  // Mirrors routing.yml background_delegation.max_compaction_items.
143
109
  const COMPACTION_MAX_ITEMS = 10
144
110
 
145
- // Root-session registry for the delegation depth guard: sessions created
111
+ // Root-session registry for the child-session depth guard: sessions created
146
112
  // without a parentID are roots. Populated from TWO sources:
147
113
  // 1. the `session.created` event hook (live roots — fires for sessions
148
114
  // created while the plugin runs);
@@ -150,9 +116,9 @@ const COMPACTION_MAX_ITEMS = 10
150
116
  // hook (compaction-134) — opencode does NOT replay session.created
151
117
  // events for sessions that exist before plugin load, so without the
152
118
  // seed a resumed root would never enter this set after a restart.
153
- // delegation.ts additionally falls back to knownChildren ("any session WE
154
- // created is a sub-session; unknown sessions are roots") so the depth guard
155
- // works even when this set is incomplete.
119
+ // The hierarchy registry additionally falls back to known children ("any
120
+ // session WE created is a sub-session; unknown sessions are roots") so the
121
+ // depth guard works even when this set is incomplete.
156
122
  const rootSessions = new Set<string>()
157
123
  const sessionHierarchy = new SessionHierarchyRegistry()
158
124
  const sessionAgents = new Map<string, string>()
@@ -161,14 +127,14 @@ const sessionAgents = new Map<string, string>()
161
127
  * Seed resumed root sessions without making OpenCode startup depend on the
162
128
  * session API responding. Some OpenCode environments do not have the API
163
129
  * ready while plugin config hooks run; awaiting session.list() there leaves
164
- * the TUI stuck before it can open. The delegation layer already treats an
130
+ * the TUI stuck before it can open. The hierarchy registry already treats an
165
131
  * unknown session as a root, so this enrichment is deliberately best-effort.
166
132
  */
167
133
  function seedRootSessionsInBackground(input: PluginInput): void {
168
134
  // Fail-open: environments without the session API (client.session absent or
169
135
  // not yet wired while config hooks run) must NOT break startup. `session`
170
136
  // is captured and the method invoked on it so `this` stays bound; the
171
- // delegation layer already treats unknown sessions as roots, so skipping
137
+ // hierarchy registry already treats unknown sessions as roots, so skipping
172
138
  // the seed is always safe.
173
139
  const session = input.client.session
174
140
  if (typeof session?.list !== 'function') return
@@ -178,10 +144,15 @@ function seedRootSessionsInBackground(input: PluginInput): void {
178
144
  .then((result) => {
179
145
  if (!result.error && Array.isArray(result.data)) {
180
146
  sessionHierarchy.completeSeed(result.data)
181
- const seeded = collectRootSessionIDs(result.data)
182
- if (seeded.size > 0) {
183
- for (const id of seeded) rootSessions.add(id)
184
- log.info(`[Pantheon Plugin] Seeded ${seeded.size} root session(s) from session.list`)
147
+ let seeded = 0
148
+ for (const info of result.data) {
149
+ if (info.parentID === undefined) {
150
+ rootSessions.add(info.id)
151
+ seeded += 1
152
+ }
153
+ }
154
+ if (seeded > 0) {
155
+ log.info(`[Pantheon Plugin] Seeded ${seeded} root session(s) from session.list`)
185
156
  }
186
157
  } else {
187
158
  sessionHierarchy.failSeed()
@@ -199,62 +170,6 @@ function sdkErrorMessage(error: { data?: { message?: string } } | null | undefin
199
170
  return typeof message === 'string' && message.trim() !== '' ? message : 'request failed'
200
171
  }
201
172
 
202
- /**
203
- * Adapt the opencode SDK client to the structural DelegationClient the
204
- * delegation toolset expects: the SDK wraps every call in a
205
- * `{data, error, request, response}` result while delegation.ts uses direct
206
- * returns. Errors surface as throws so the toolset's own error handling
207
- * (tool errors returned as TEXT) keeps working.
208
- */
209
- function adaptDelegationClient(client: PluginInput['client']): DelegationClient {
210
- return {
211
- session: {
212
- create: async (input) => {
213
- const body: {
214
- parentID?: string
215
- title?: string
216
- model?: { id: string; providerID: string }
217
- } = { parentID: input.body.parentID }
218
- if (input.body.title !== undefined) body.title = input.body.title
219
- // Forward a model only when the active routing profile supplied one.
220
- if (input.body.model !== undefined) body.model = input.body.model
221
- const result = await client.session.create({ body })
222
- if (result.error) throw new Error(sdkErrorMessage(result.error))
223
- return { id: result.data.id }
224
- },
225
- promptAsync: async (input) => {
226
- const path = safeSessionPath(input.path.id)
227
- if (!path) {
228
- log.warn(`[Pantheon Plugin] promptAsync skipped: invalid sessionID "${input.path.id}"`)
229
- throw new Error(`invalid sessionID: ${input.path.id}`)
230
- }
231
- const result = await client.session.promptAsync({
232
- path: path.path,
233
- body: {
234
- agent: input.body.agent,
235
- ...(input.body.model !== undefined
236
- ? { model: { providerID: input.body.model.providerID, modelID: input.body.model.id } }
237
- : {}),
238
- parts: input.body.parts,
239
- },
240
- })
241
- if (result.error) throw new Error(sdkErrorMessage(result.error))
242
- return result.data
243
- },
244
- messages: async (input) => {
245
- const path = safeSessionPath(input.path.id)
246
- if (!path) {
247
- log.warn(`[Pantheon Plugin] messages skipped: invalid sessionID "${input.path.id}"`)
248
- return []
249
- }
250
- const result = await client.session.messages({ path: path.path })
251
- if (result.error) throw new Error(sdkErrorMessage(result.error))
252
- return result.data
253
- },
254
- },
255
- }
256
- }
257
-
258
173
  /**
259
174
  * Adapt the opencode SDK client to the structural TodoEnforcerClient the
260
175
  * todo enforcer expects: unwraps the SDK `{data, error, request, response}`
@@ -335,7 +250,6 @@ function adaptTodoEnforcerClient(client: PluginInput['client']): TodoEnforcerCli
335
250
  * Helpers live in src/pantheon/vision.ts and are imported from there directly.
336
251
  */
337
252
  const plugin: Plugin = async (input: PluginInput) => {
338
- terminalToastClient = input.client as unknown as ToastClient
339
253
  // release-134: runtime guard against DOUBLE registration (duplicate toasts +
340
254
  // duplicate task_ids in one process — 2026-08-12 live logs). opencode
341
255
  // merges the global config (npm-package plugin paths) with the project
@@ -350,67 +264,12 @@ const plugin: Plugin = async (input: PluginInput) => {
350
264
  // is older than the installed package. Runs once, fail-open, no auto-sync.
351
265
  checkTuiVersionStaleness((msg) => log.warn(msg))
352
266
  const vision = createVisionHandler(input)
353
- // Phase 2/3: background delegation toolset + the bound finalize lifecycle
354
- // hook. Completion is observed through the event hook below
355
- // (session.idle / session.error on a child) → finalizeDelegation.
356
- // Fase B3: runtime controls for the two read-only exceptions and the real
357
- // per-child wall-clock timer. No task_budget field is injected into the
358
- // plugin/frontmatter; these are enforced by delegation.ts itself.
359
- const delegationBudgets = resolveDelegationBudgets()
360
- const wallClockTimeoutMs = resolveDelegationTimeoutMs()
361
-
362
- const delegation = createDelegationTools({
363
- board,
364
- client: adaptDelegationClient(input.client),
365
- options: {
366
- rootSessions,
367
- isChildSession: (sessionID) => sessionHierarchy.isChild(sessionID),
368
- registerChildSession: (sessionID, parentID) => {
369
- sessionHierarchy.register({ id: sessionID, parentID })
370
- },
371
- enforceRuntimeMatrix: true,
372
- readOnlyAgents: new Set(['apollo', 'gaia']),
373
- // Only an explicitly active routing profile may provide child models.
374
- // An empty map deliberately lets OpenCode inherit the parent model.
375
- agentModels: loadRoutingAgentModels({
376
- candidates: activePresetCandidates(),
377
- logger: log,
378
- }),
379
- delegationBudgets,
380
- stepCapTracker,
381
- ...(routingPermissionTask != null ? { permissionTask: routingPermissionTask } : {}),
382
- ...(wallClockTimeoutMs !== undefined ? { wallClockTimeoutMs } : {}),
383
- },
384
- })
385
-
386
- // Proactive idle-child scan (Fix 2): finalize children that are idle on the
387
- // board but have no MD report on disk — eliminates the phantom "running"
388
- // window. Runs every 30s (configurable via PANTHEON_IDLE_SCAN_INTERVAL_MS),
389
- // unref'd so it never holds the process open. Fail-open by design.
390
- const idleScanIntervalMs = Number(process.env.PANTHEON_IDLE_SCAN_INTERVAL_MS) || undefined
391
- startIdleChildScan(
392
- {
393
- board,
394
- finalize: (childSessionID, opts) => delegation.finalizeDelegation(childSessionID, opts),
395
- hasReport: (job) => {
396
- try {
397
- const filePath = join('.pantheon/delegations', job.parentSessionID, `${job.alias}.md`)
398
- return existsSync(filePath)
399
- } catch {
400
- return false
401
- }
402
- },
403
- logger: log,
404
- },
405
- idleScanIntervalMs,
406
- )
407
-
408
267
  // Wave 1 (PR #46): TODO continuation enforcer for root/non-board sessions.
409
268
  // Mirrors routing.yml `todo_enforcer` (plugin scope has no routing.yml
410
- // access — COMPACTION_MAX_ITEMS pattern). The event hook routes non-board
411
- // session.idle events here; board-child idles go to finalizeDelegation.
412
- // Runtime kill-switch: PANTHEON_TODO_ENFORCER=off (routing.yml is a doc
413
- // mirror only — the env var is the real switch, PANTHEON_TOASTS pattern).
269
+ // access — COMPACTION_MAX_ITEMS pattern). The event hook routes all
270
+ // session.idle events here. Runtime kill-switch: PANTHEON_TODO_ENFORCER=off
271
+ // (routing.yml is a doc mirror only — the env var is the real switch,
272
+ // PANTHEON_TOASTS pattern).
414
273
  const todoEnforcer = new TodoEnforcer({
415
274
  client: adaptTodoEnforcerClient(input.client),
416
275
  board,
@@ -467,7 +326,7 @@ const plugin: Plugin = async (input: PluginInput) => {
467
326
  await readEnhancer(input, output)
468
327
  }
469
328
 
470
- // Wave 4 (PR #46): /cost — delegation cost + token visibility. Reads
329
+ // Wave 4 (PR #46): /cost — cost + token visibility by agent. Reads
471
330
  // opencode.db read-only (node:sqlite, falls back to scripts/cost.mjs).
472
331
  // Fully wired (unlike dispatch-guard, which is manual-orchestration-only
473
332
  // because opencode 1.18.x cannot intercept task completion via hooks).
@@ -479,11 +338,11 @@ const plugin: Plugin = async (input: PluginInput) => {
479
338
  // compaction-134 seed: opencode does NOT replay `session.created`
480
339
  // events for sessions that exist before plugin load, so after a
481
340
  // restart the resumed ROOT session never enters rootSessions and the
482
- // depth guard would reject its pantheon_delegate calls. Seed the
483
- // registry from session.list() in the background — every session
484
- // WITHOUT a parentID is a root. Fail-open: a failed/throttled list
485
- // leaves the set untouched (delegation.ts treats unknown sessions as
486
- // roots), and config setup must never wait for the session API.
341
+ // depth guard would treat it as a child. Seed the registry from
342
+ // session.list() in the background — every session WITHOUT a parentID
343
+ // is a root. Fail-open: a failed/throttled list leaves the set
344
+ // untouched (the hierarchy registry treats unknown sessions as roots),
345
+ // and config setup must never wait for the session API.
487
346
  // Context sandbox config — opencode.json → context_sandbox: {enabled, limits}
488
347
  try {
489
348
  const raw = (config as unknown as Record<string, unknown>).context_sandbox
@@ -528,18 +387,16 @@ const plugin: Plugin = async (input: PluginInput) => {
528
387
  log.warn('[plugin] preset application failed (see logs for details)')
529
388
  }
530
389
  },
531
- // Phase 2: background delegation tools (structural — matches the `tool`
532
- // hook field shape; read_only sessions feed the Phase 4 registry).
390
+ // Phase 2: structural tool registrations (matches the `tool` hook field
391
+ // shape). Native task() owns orchestration; the plugin registers the
392
+ // deterministic /pantheon-model, /cost and full-auto goal tools.
533
393
  tool: {
534
- pantheon_delegate: delegation.pantheon_delegate,
535
- pantheon_delegation_read: delegation.pantheon_delegation_read,
536
- pantheon_delegation_list: delegation.pantheon_delegation_list,
537
394
  hashline_edit: hashlineEdit,
538
395
  // Wave 3 (PR #46): full-auto goal tools (single active goal/session).
539
396
  pantheon_goal_create: goalTools.pantheon_goal_create,
540
397
  pantheon_goal_update: goalTools.pantheon_goal_update,
541
398
  pantheon_goal_get: goalTools.pantheon_goal_get,
542
- // Wave 4 (PR #46): /cost — delegation cost report from opencode.db.
399
+ // Wave 4 (PR #46): /cost — cost report by agent from opencode.db.
543
400
  pantheon_cost: costCommand.pantheon_cost,
544
401
  // Deterministic /pantheon-model management of top-level model fields.
545
402
  pantheon_model: modelCommand.pantheon_model,
@@ -573,22 +430,13 @@ const plugin: Plugin = async (input: PluginInput) => {
573
430
  if (ev.type === 'session.compacted') {
574
431
  await todoPreserver.onCompacted(ev.properties.sessionID)
575
432
  }
576
- // Phase 3: observe completion on child sessions → finalizeDelegation.
577
- // The board transition fires onTerminal → the file-only audit log (no
578
- // chat delivery — see the onTerminal listener above). Unknown sessions
579
- // are a no-op.
433
+ // Native task() children are owned by OpenCode. Idle routing for
434
+ // generic root/non-board sessions remains priority ordered: an active
435
+ // goal owns the idle (goal loop), otherwise the todo enforcer
436
+ // re-injects. Neither onIdle ever throws (internal failures are logged
437
+ // + swallowed), so the session is safe.
580
438
  try {
581
- const delegated = await handleDelegationEvent(ev, {
582
- board,
583
- finalize: (childSessionID, opts) => delegation.finalizeDelegation(childSessionID, opts),
584
- })
585
- // Wave 1/3: non-board idle → idle dispatcher. Board children are
586
- // handled by handleDelegationEvent above; everything else (roots,
587
- // non-board sessions) is routed by priority: an active goal owns the
588
- // idle (goal loop), otherwise the todo enforcer re-injects. Neither
589
- // onIdle ever throws (internal failures are logged + swallowed), so
590
- // the session is safe.
591
- if (!delegated && ev.type === 'session.idle') {
439
+ if (ev.type === 'session.idle') {
592
440
  await idleDispatcher.onIdle(ev.properties.sessionID)
593
441
  }
594
442
  } catch (err) {