pantheon-opencode 1.4.0 → 1.4.2

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.
@@ -172,24 +172,48 @@ export const DELEGATION_DEFAULTS = {
172
172
 
173
173
  // ─── Output pulling ────────────────────────────────────────────────────
174
174
 
175
- /** Concatenate every non-empty text part across the child's messages. */
176
- async function pullOutput(client: DelegationClient, childSessionID: string): Promise<string> {
175
+ interface PulledOutput {
176
+ text: string
177
+ /** True only when the child produced an assistant message or tool activity. */
178
+ hasWork: boolean
179
+ }
180
+
181
+ /** Pull visible text and structurally identify real child work. */
182
+ async function pullOutput(client: DelegationClient, childSessionID: string): Promise<PulledOutput> {
177
183
  if (typeof client.session.messages !== 'function')
178
- return '(child session messages API unavailable)'
184
+ return { text: '(child session messages API unavailable)', hasWork: false }
179
185
  try {
180
186
  const bundles = await client.session.messages({ path: { id: childSessionID } })
181
187
  const lines: string[] = []
188
+ let hasWork = false
182
189
  for (const bundle of bundles) {
190
+ const hasToolPart = (bundle.parts ?? []).some((part) => part.type === 'tool')
191
+ const hasTextPart = (bundle.parts ?? []).some(
192
+ (part) => part.type === 'text' && typeof part.text === 'string' && part.text.trim() !== '',
193
+ )
194
+ if (
195
+ bundle.info?.role === 'assistant' &&
196
+ bundle.info.error === undefined &&
197
+ (hasToolPart || hasTextPart)
198
+ ) {
199
+ hasWork = true
200
+ }
183
201
  for (const part of bundle.parts ?? []) {
202
+ if (
203
+ part.type === 'tool' &&
204
+ bundle.info?.role === 'assistant' &&
205
+ bundle.info.error === undefined
206
+ )
207
+ hasWork = true
184
208
  if (part.type === 'text' && typeof part.text === 'string' && part.text.trim() !== '') {
185
209
  lines.push(part.text)
186
210
  }
187
211
  }
188
212
  }
189
- return lines.join('\n\n')
213
+ return { text: lines.join('\n\n'), hasWork }
190
214
  } catch (err: unknown) {
191
215
  const reason = err instanceof Error ? err.message : String(err)
192
- return `(failed to pull child session output: ${reason})`
216
+ return { text: `(failed to pull child session output: ${reason})`, hasWork: false }
193
217
  }
194
218
  }
195
219
 
@@ -311,9 +335,18 @@ export async function finalizeDelegation(
311
335
  const job = deps.board.get(childSessionID)
312
336
  if (!job) return undefined
313
337
 
314
- const output = await pullOutput(deps.client, childSessionID)
338
+ const pulled = await pullOutput(deps.client, childSessionID)
339
+ const output = pulled.text
340
+ const effectiveOpts: FinalizeInput =
341
+ opts.state === 'completed' && !pulled.hasWork
342
+ ? {
343
+ state: 'error',
344
+ error: opts.error ?? 'Child session produced no assistant or tool output',
345
+ ...(opts.timedOut !== undefined ? { timedOut: opts.timedOut } : {}),
346
+ }
347
+ : opts
315
348
  const outputDir = deps.options.outputDir ?? DELEGATION_DEFAULTS.outputDir
316
- await writeDelegationReport(outputDir, job, renderDelegationMarkdown(job, output, opts))
349
+ await writeDelegationReport(outputDir, job, renderDelegationMarkdown(job, output, effectiveOpts))
317
350
 
318
351
  const status: {
319
352
  taskID: string
@@ -323,15 +356,15 @@ export async function finalizeDelegation(
323
356
  resultSummary?: string
324
357
  } = {
325
358
  taskID: childSessionID,
326
- state: opts.state,
359
+ state: effectiveOpts.state,
327
360
  resultSummary: summarizeOutput(output),
328
361
  }
329
- if (opts.error !== undefined) status.error = opts.error
330
- if (opts.timedOut !== undefined) status.timedOut = opts.timedOut
362
+ if (effectiveOpts.error !== undefined) status.error = effectiveOpts.error
363
+ if (effectiveOpts.timedOut !== undefined) status.timedOut = effectiveOpts.timedOut
331
364
 
332
365
  const current = deps.board.get(childSessionID)
333
366
  if (current !== undefined) {
334
- if (current.state === 'running' || current.state === opts.state) {
367
+ if (current.state === 'running' || current.state === effectiveOpts.state) {
335
368
  await deps.board.updateStatus(status)
336
369
  }
337
370
  // Terminal in a DIFFERENT state (e.g. timeout already recorded) — the
@@ -0,0 +1,73 @@
1
+ import type { BackgroundJobRecord } from './background-job-board.ts'
2
+
3
+ type ToastCategory = 'errors' | 'delegations' | 'council'
4
+
5
+ const toastMode = (process.env.PANTHEON_TOASTS ?? '').trim().toLowerCase()
6
+ const enabledToastCategories: ReadonlySet<ToastCategory> = (() => {
7
+ switch (toastMode) {
8
+ case 'off':
9
+ return new Set()
10
+ case 'errors':
11
+ return new Set(['errors'])
12
+ case 'council':
13
+ return new Set(['errors', 'council'])
14
+ case 'all':
15
+ return new Set(['errors', 'delegations', 'council'])
16
+ default:
17
+ return new Set(['errors', 'delegations', 'council'])
18
+ }
19
+ })()
20
+ const terminalToastsShown = new Set<string>()
21
+ const terminalStates = new Set(['completed', 'error', 'cancelled'])
22
+
23
+ /** Match the lifecycle toast gate used by pantheon-hooks. */
24
+ export function delegationToastEnabled(): boolean {
25
+ return enabledToastCategories.has('delegations')
26
+ }
27
+
28
+ export type ToastClient = {
29
+ tui?: {
30
+ showToast?: (input: {
31
+ body: {
32
+ title?: string
33
+ message: string
34
+ variant: 'info' | 'success' | 'warning' | 'error'
35
+ duration: number
36
+ }
37
+ }) => Promise<unknown>
38
+ }
39
+ }
40
+
41
+ /** Best-effort visual completion signal; never affects board finalization. */
42
+ export async function showDelegationTerminalToast(
43
+ client: ToastClient,
44
+ job: Pick<BackgroundJobRecord, 'taskID' | 'alias' | 'agent' | 'state' | 'timedOut'>,
45
+ ): Promise<void> {
46
+ try {
47
+ if (!delegationToastEnabled()) return
48
+ const isTerminal = job.timedOut || terminalStates.has(job.state)
49
+ if (isTerminal) {
50
+ if (terminalToastsShown.has(job.taskID)) return
51
+ terminalToastsShown.add(job.taskID)
52
+ }
53
+ const showToast = client.tui?.showToast
54
+ if (typeof showToast !== 'function') return
55
+ const state = job.timedOut ? 'timeout' : job.state
56
+ const variant =
57
+ state === 'completed'
58
+ ? 'success'
59
+ : state === 'error' || state === 'timeout'
60
+ ? 'error'
61
+ : 'warning'
62
+ await showToast({
63
+ body: {
64
+ title: 'Delegação concluída',
65
+ message: `${job.alias} (${job.agent}) — ${state}`,
66
+ variant,
67
+ duration: 4000,
68
+ },
69
+ })
70
+ } catch {
71
+ // TUI is optional (and may disappear during shutdown); completion is durable.
72
+ }
73
+ }
@@ -58,14 +58,9 @@ import {
58
58
  finalizeDelegation as finalizeDelegationReport,
59
59
  readDelegationReport,
60
60
  } from './delegation-finalize.ts'
61
- import {
62
- type IdleChildScanDeps,
63
- finalizeIdleChildrenWithoutMd,
64
- startIdleChildScan,
65
- } from './delegation-notify.ts'
61
+ import { finalizeIdleChildrenWithoutMd } from './delegation-notify.ts'
66
62
  import { createPantheonLogger } from './logger.ts'
67
63
  import { buildAgentListDescription } from './permission-globs.ts'
68
- import { missingProviderKeyEnv, resolveActivePreset } from './presets.mjs'
69
64
  import { buildStopInstruction, cappedSummary, DEFAULT_MAX_STEPS } from './step-cap.ts'
70
65
 
71
66
  export type {
@@ -217,8 +212,10 @@ async function bootstrapChild(
217
212
  const remainingMs = timeoutMs - (now() - startedAt)
218
213
  if (hasMessagesAPI) {
219
214
  try {
215
+ const messagesAPI = client.session.messages
216
+ if (messagesAPI === undefined) continue
220
217
  const messages = await boundedProbe(
221
- client.session.messages!({ path: { id: childSessionID } }),
218
+ messagesAPI({ path: { id: childSessionID } }),
222
219
  remainingMs,
223
220
  )
224
221
  if (messages !== undefined) {
@@ -231,8 +228,10 @@ async function bootstrapChild(
231
228
  }
232
229
  if (hasStatusAPI) {
233
230
  try {
231
+ const statusAPI = client.session.get
232
+ if (statusAPI === undefined) continue
234
233
  const status = await boundedProbe(
235
- client.session.get!({ path: { id: childSessionID } }),
234
+ statusAPI({ path: { id: childSessionID } }),
236
235
  timeoutMs - (now() - startedAt),
237
236
  )
238
237
  if (status !== undefined) {
@@ -591,93 +590,59 @@ export interface ChildSessionModelRef {
591
590
  providerID: string
592
591
  }
593
592
 
594
- /**
595
- * Known-good fallback model (P1, 2026-08-11 — release 1.3.4, updated
596
- * 2026-08-21 for sandbox without PANTHEON_OPENCODE_API_KEY): when an
597
- * AUTO-RESOLVED model (routing.yml agent entry / active preset) points at a
598
- * provider whose API key is not configured, the child is dispatched on the
599
- * `opencode-go` provider instead — `opencode-go/deepseek-v4-flash`
600
- * via https://opencode.ai/zen/go/v1 works without an external API key
601
- * (Go subscription / opencode auth, sandbox default). Validated with the
602
- * same providerKeyConfigured gate as the resolved model, with an
603
- * opencode-go exception for the sandbox; an explicit caller-supplied
604
- * `model` always wins over this fallback.
605
- */
606
- const FALLBACK_MODEL = 'opencode-go/deepseek-v4-flash'
593
+ const MODEL_REF_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]*\/[A-Za-z0-9][A-Za-z0-9._:+-]*$/
594
+ const MODEL_REF_HINT =
595
+ 'Expected a non-empty "provider/model-id" reference with exactly one slash, no whitespace/control characters, backslashes, or path traversal. Remove the override or use that format.'
596
+
597
+ type ModelOverrideSource = 'explicit' | 'agentModels'
598
+
599
+ function invalidModelOverride(source: ModelOverrideSource): string {
600
+ return `pantheon_delegate rejected: invalid ${source} model override. ${MODEL_REF_HINT}`
601
+ }
607
602
 
608
603
  /**
609
604
  * Split a `provider/model` model ID (e.g. `opencode/deepseek-v4-flash-free`)
610
605
  * into the `{ id, providerID }` ref the session.create body expects.
611
- * Returns undefined for malformed IDs (no slash, empty segments).
606
+ * Returns undefined for malformed or unsafe IDs.
612
607
  */
613
- function splitModelRef(model: string): ChildSessionModelRef | undefined {
608
+ function splitModelRef(model: unknown): ChildSessionModelRef | undefined {
609
+ if (typeof model !== 'string' || !MODEL_REF_PATTERN.test(model)) return undefined
614
610
  const slash = model.indexOf('/')
615
- if (slash <= 0 || slash === model.length - 1) return undefined
616
611
  return { providerID: model.slice(0, slash), id: model.slice(slash + 1) }
617
612
  }
618
613
 
619
614
  /**
620
- * Resolve the child session model with priority:
621
- * 1. explicit `model` option passed to the delegate tool;
622
- * 2. `options.agentModels[agent]` (routing.yml agent entry, case-insensitive);
623
- * 3. the active preset's agent entry via resolveActivePreset();
624
- * 4. fallback: undefined — the child is created without a model (the
625
- * caller warns) so opencode's default applies.
615
+ * Resolve the child session model from explicit or agent-specific overrides.
616
+ * When neither override is present, return undefined so OpenCode can apply
617
+ * native model inheritance.
626
618
  */
627
619
  function resolveChildModel(
628
620
  options: DelegationOptions,
629
621
  agent: string,
630
622
  explicitModel?: string,
631
- ): ChildSessionModelRef | undefined {
623
+ ): { model: ChildSessionModelRef | undefined; error: string | undefined } {
632
624
  // (a) explicit model option on the delegate tool call
633
- if (explicitModel !== undefined && explicitModel !== '') {
625
+ if (explicitModel !== undefined) {
634
626
  const ref = splitModelRef(explicitModel)
635
- if (ref !== undefined) return ref
627
+ if (ref !== undefined) return { model: ref, error: undefined }
628
+ return { model: undefined, error: invalidModelOverride('explicit') }
636
629
  }
637
630
  const key = agent.toLowerCase()
638
- // (b) routing.yml agent entry wired through options.agentModels
631
+ // (b) agent-specific model override (routing.yml agent entry via agentModels)
639
632
  const mapped = options.agentModels?.[key]
640
- if (mapped !== undefined && mapped !== '') {
633
+ if (mapped !== undefined) {
641
634
  const ref = splitModelRef(mapped)
642
- if (ref !== undefined) return ref
643
- }
644
- // (c) active preset agent entry (resolveActivePreset reads routing.yml
645
- // presets + .pantheon/active-preset.json). Guarded — a broken routing.yml
646
- // must never kill the delegate.
647
- try {
648
- const presetModel = resolveActivePreset({
649
- ...(options.presetEnv !== undefined ? { env: options.presetEnv } : {}),
650
- ...(options.logger !== undefined ? { logger: options.logger } : {}),
651
- })?.agents?.[key]?.model
652
- if (presetModel !== undefined && presetModel !== '') {
653
- const ref = splitModelRef(presetModel)
654
- if (ref !== undefined) return ref
655
- }
656
- } catch (err: unknown) {
657
- const reason = err instanceof Error ? err.message : String(err)
658
- options.logger?.warn?.(`[pantheon-delegate] active preset resolution failed: ${reason}`)
635
+ if (ref !== undefined) return { model: ref, error: undefined }
636
+ return { model: undefined, error: invalidModelOverride('agentModels') }
659
637
  }
660
- return undefined
638
+ return { model: undefined, error: undefined }
661
639
  }
662
640
 
663
641
  /**
664
- * Resolve a USABLE child model for a delegate dispatch, gating the resolved
665
- * provider's API key (P1, 2026-08-11 — release 1.3.4):
666
- *
667
- * 1. resolve the model with the existing precedence (explicit `model` >
668
- * options.agentModels > active preset) — see resolveChildModel.
669
- * 2. provider key gate: the resolved provider requires its apiKeyEnv env
670
- * var (routing.yml preset defs — same source applyPreset enforces at
671
- * startup). No gate (native providers) or key present → usable as-is.
672
- * 3. key MISSING + EXPLICIT caller model → respected anyway (user intent),
673
- * warned.
674
- * 4. key MISSING + AUTO-resolved model → fall back to FALLBACK_MODEL
675
- * (validated the same way). Fallback also unusable → returns an `error`
676
- * TEXT (the caller returns it from the tool — never throws) and the
677
- * caller registers NO job on the board.
678
- *
679
- * The no-model case (nothing resolved) keeps the pre-existing behavior:
680
- * warn and let the child use opencode's default.
642
+ * Resolve a child model without selecting a provider or model on the caller's
643
+ * behalf. OpenCode owns native inheritance when this returns no model; if the
644
+ * host requires one, its session.create/promptAsync error is returned with
645
+ * the normal diagnostic rather than silently choosing a fallback.
681
646
  *
682
647
  * @returns `{ model, error }` — at most one of `model` / `error` is set;
683
648
  * both may be unset (no model resolved, warn emitted).
@@ -687,50 +652,13 @@ function resolveUsableChildModel(
687
652
  agent: string,
688
653
  explicitModel: string | undefined,
689
654
  ): { model: ChildSessionModelRef | undefined; error: string | undefined } {
690
- const env = options.presetEnv ?? process.env
691
- const explicit = explicitModel !== undefined && explicitModel !== ''
692
- const warn = (msg: string) => options.logger?.warn?.(msg)
693
-
694
- const model = resolveChildModel(options, agent, explicitModel)
695
- if (model === undefined) {
696
- warn(
697
- `[pantheon-delegate] no model resolved for agent "${agent}" — ` +
698
- "child session will use opencode's default model (may require API keys)",
699
- )
700
- return { model: undefined, error: undefined }
701
- }
702
-
703
- const missingVar = missingProviderKeyEnv(model.providerID, { env })
704
- if (missingVar === undefined) return { model, error: undefined }
705
-
706
- if (explicit) {
707
- // Rule of precedence: the caller's explicit model is respected — warn only.
708
- warn(
709
- `[pantheon-delegate] model "${explicitModel}" provider "${model.providerID}" requires ` +
710
- `API key ${missingVar} (unset) — respecting the explicit model anyway`,
711
- )
712
- return { model, error: undefined }
713
- }
714
-
715
- // Auto-resolved (agentModels / preset) → try the known-good fallback.
716
- const fallback = splitModelRef(FALLBACK_MODEL)
717
- if (
718
- fallback !== undefined &&
719
- (fallback.providerID === 'opencode-go' ||
720
- missingProviderKeyEnv(fallback.providerID, { env }) === undefined)
721
- ) {
722
- warn(
723
- `[pantheon-delegate] provider "${model.providerID}" requires API key ${missingVar} ` +
724
- `(unset) — falling back to ${FALLBACK_MODEL}`,
725
- )
726
- return { model: fallback, error: undefined }
727
- }
728
-
729
- const message =
730
- `pantheon_delegate: no usable model for agent "${agent}" — provider "${model.providerID}" ` +
731
- `requires API key (set ${missingVar} or PANTHEON_MODEL_PRESET)`
732
- warn(`[pantheon-delegate] ${message}`)
733
- return { model: undefined, error: message }
655
+ const resolved = resolveChildModel(options, agent, explicitModel)
656
+ if (resolved.error !== undefined || resolved.model !== undefined) return resolved
657
+ options.logger?.warn?.(
658
+ `[pantheon-delegate] no model resolved for agent "${agent}" — ` +
659
+ 'omitting model so OpenCode can inherit it from the parent session',
660
+ )
661
+ return resolved
734
662
  }
735
663
 
736
664
  // ─── Factory ───────────────────────────────────────────────────────────
@@ -900,13 +828,9 @@ export function createDelegationTools(input: CreateDelegationToolsInput): Delega
900
828
  // session.create failure → return a clear error as TEXT (tools return
901
829
  // errors as text, not thrown) and register NO job on the board — an
902
830
  // unhandled rejection here would otherwise lose the failure entirely.
903
- // The child model is resolved with a provider API-key gate (P1): the
904
- // resolved model (explicit `model` > options.agentModels > active
905
- // preset) is used when its provider key is configured; an explicit
906
- // caller model is always respected (warned); an auto-resolved model
907
- // whose provider key is missing falls back to the known-good
908
- // opencode-go/deepseek-v4-flash; if nothing usable remains the
909
- // failure is returned as TEXT below — no session, no board job.
831
+ // Resolution order: explicit delegate model > active-preset agent model
832
+ // > omit (native inheritance). small_model is never used for delegated
833
+ // children.
910
834
  const resolved = resolveUsableChildModel(options, args.agent, args.model)
911
835
  if (resolved.error !== undefined) {
912
836
  if (budgetReserved && budgetKey !== undefined)