pantheon-opencode 1.4.1 → 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.
@@ -43,6 +43,7 @@ import {
43
43
  syncDir,
44
44
  writeIfChanged,
45
45
  } from './shared.mjs'
46
+
46
47
  import { setupVenv, venvPythonPath } from './venv.mjs'
47
48
 
48
49
  const COMPONENT_NAMES = [
@@ -59,6 +60,16 @@ function deepClone(value) {
59
60
  return JSON.parse(JSON.stringify(value))
60
61
  }
61
62
 
63
+ function readJsonConfig(filePath) {
64
+ if (!existsSync(filePath)) return {}
65
+
66
+ try {
67
+ return JSON.parse(readFileSync(filePath, 'utf8'))
68
+ } catch (cause) {
69
+ throw new Error(`Invalid JSON in ${filePath}`, { cause })
70
+ }
71
+ }
72
+
62
73
  function mergeMissing(target, source) {
63
74
  if (!source || typeof source !== 'object' || Array.isArray(source)) return target
64
75
  if (!target || typeof target !== 'object' || Array.isArray(target)) return deepClone(source)
@@ -274,6 +285,11 @@ export async function installOpenCode(
274
285
  }
275
286
  }
276
287
 
288
+ const pantheonConfigPath = join(ROOT, 'opencode.json')
289
+ const targetConfigPath = join(target, 'opencode.json')
290
+ const config = readJsonConfig(targetConfigPath)
291
+ const pantheonConfig = readJsonConfig(pantheonConfigPath)
292
+
277
293
  // Determine layout based on install scope.
278
294
  // Global config dir (~/.opencode or ~/.config/opencode) uses a flat layout:
279
295
  // agents/ skills/ commands/ plugins/
@@ -533,27 +549,6 @@ export async function installOpenCode(
533
549
  // Reads TARGET's existing config first, then merges Pantheon settings
534
550
  // on top. Preserves user's MCP, provider, plugin, compaction, theme.
535
551
  // -----------------------------------------------------------------------
536
- const pantheonConfigPath = join(ROOT, 'opencode.json')
537
- const targetConfigPath = join(target, 'opencode.json')
538
-
539
- let config = {}
540
- if (existsSync(targetConfigPath)) {
541
- try {
542
- config = JSON.parse(readFileSync(targetConfigPath, 'utf8'))
543
- } catch {
544
- config = {}
545
- }
546
- }
547
-
548
- let pantheonConfig = {}
549
- if (existsSync(pantheonConfigPath)) {
550
- try {
551
- pantheonConfig = JSON.parse(readFileSync(pantheonConfigPath, 'utf8'))
552
- } catch {
553
- pantheonConfig = {}
554
- }
555
- }
556
-
557
552
  // --------------------------------------------------------------------
558
553
  // A. Parse canonical agent config from agents/*.agent.md frontmatter
559
554
  // and merge into opencode.json config.
@@ -622,61 +617,64 @@ export async function installOpenCode(
622
617
  'disable_model_invocation',
623
618
  ]
624
619
 
625
- if (!config.agent) config.agent = {}
626
-
627
- for (const [agentName, agentCfg] of Object.entries(canonicalAgentConfig)) {
628
- if (!agentCfg || typeof agentCfg !== 'object') continue
620
+ if (config.agent === undefined) config.agent = {}
621
+
622
+ if (config.agent && typeof config.agent === 'object' && !Array.isArray(config.agent)) {
623
+ for (const [agentName, agentCfg] of Object.entries(canonicalAgentConfig)) {
624
+ if (!agentCfg || typeof agentCfg !== 'object') continue
625
+
626
+ if (Object.hasOwn(config.agent, agentName)) {
627
+ // ── Agent exists in target config ──
628
+ // Update framework-managed fields from canonical source
629
+ // Preserve user-customized fields (model, provider, mcp, etc.)
630
+ const existing = config.agent[agentName]
631
+ if (!existing || typeof existing !== 'object' || Array.isArray(existing)) continue
632
+ if (agentSources[agentName]) existing.source = agentSources[agentName]
633
+ for (const field of MANAGED_FIELDS) {
634
+ if (field in agentCfg) {
635
+ existing[field] = JSON.parse(JSON.stringify(agentCfg[field]))
636
+ }
637
+ }
638
+ // Remove stale fields that are no longer in canonical config
639
+ delete existing.model
640
+ delete existing.small_model
641
+ } else {
642
+ // ── New agent ──
643
+ const newAgent = {}
644
+ if (agentSources[agentName]) newAgent.source = agentSources[agentName]
645
+ if (agentCfg.description) newAgent.description = agentCfg.description
646
+
647
+ // Copy all framework-managed fields from canonical config
648
+ for (const field of MANAGED_FIELDS) {
649
+ if (field in agentCfg) {
650
+ newAgent[field] = JSON.parse(JSON.stringify(agentCfg[field]))
651
+ }
652
+ }
629
653
 
630
- if (config.agent[agentName]) {
631
- // ── Agent exists in target config ──
632
- // Update framework-managed fields from canonical source
633
- // Preserve user-customized fields (model, provider, mcp, etc.)
634
- const existing = config.agent[agentName]
635
- if (agentSources[agentName]) existing.source = agentSources[agentName]
636
- for (const field of MANAGED_FIELDS) {
637
- if (field in agentCfg) {
638
- existing[field] = JSON.parse(JSON.stringify(agentCfg[field]))
654
+ // Ensure bash permission is set from canonical (default to deny if missing)
655
+ if (newAgent.permission === undefined) {
656
+ newAgent.permission = {}
639
657
  }
640
- }
641
- // Remove stale fields that are no longer in canonical config
642
- delete existing.model
643
- delete existing.small_model
644
- } else {
645
- // ── New agent ──
646
- const newAgent = {}
647
- if (agentSources[agentName]) newAgent.source = agentSources[agentName]
648
- if (agentCfg.description) newAgent.description = agentCfg.description
649
-
650
- // Copy all framework-managed fields from canonical config
651
- for (const field of MANAGED_FIELDS) {
652
- if (field in agentCfg) {
653
- newAgent[field] = JSON.parse(JSON.stringify(agentCfg[field]))
658
+ if (newAgent.permission.bash === undefined && agentCfg.permission?.bash) {
659
+ newAgent.permission.bash = JSON.parse(JSON.stringify(agentCfg.permission.bash))
654
660
  }
655
- }
656
661
 
657
- // Ensure bash permission is set from canonical (default to deny if missing)
658
- if (!newAgent.permission) {
659
- newAgent.permission = {}
660
- }
661
- if (!newAgent.permission.bash && agentCfg.permission?.bash) {
662
- newAgent.permission.bash = JSON.parse(JSON.stringify(agentCfg.permission.bash))
662
+ config.agent[agentName] = newAgent
663
663
  }
664
-
665
- config.agent[agentName] = newAgent
666
664
  }
667
- }
668
665
 
669
- // Remove stale agents (exist in target config but not in canonical source)
670
- // Only removes agents whose source is managed (starts with our prefix)
671
- // so user-defined agents with different source paths are preserved.
672
- const canonicalNames = new Set(Object.keys(canonicalAgentConfig))
673
- for (const [agentName, agentCfg] of Object.entries(config.agent)) {
674
- const source = agentCfg?.source || ''
675
- if (source.startsWith(agentPrefix) && !canonicalNames.has(agentName)) {
676
- delete config.agent[agentName]
666
+ // Remove stale agents (exist in target config but not in canonical source)
667
+ // Only removes agents whose source is managed (starts with our prefix)
668
+ // so user-defined agents with different source paths are preserved.
669
+ const canonicalNames = new Set(Object.keys(canonicalAgentConfig))
670
+ for (const [agentName, agentCfg] of Object.entries(config.agent)) {
671
+ const source = agentCfg?.source || ''
672
+ if (source.startsWith(agentPrefix) && !canonicalNames.has(agentName)) {
673
+ delete config.agent[agentName]
674
+ }
677
675
  }
676
+ if (Object.keys(config.agent).length === 0) delete config.agent
678
677
  }
679
- if (Object.keys(config.agent).length === 0) delete config.agent
680
678
 
681
679
  // --------------------------------------------------------------------
682
680
  // B. Commands from .md frontmatter (commands.json removed)
@@ -687,19 +685,16 @@ export async function installOpenCode(
687
685
  // --------------------------------------------------------------------
688
686
  // B.5 Ensure critical top-level OpenCode config sections
689
687
  // --------------------------------------------------------------------
690
- if (!config.default_agent && pantheonConfig.default_agent) {
688
+ if (config.default_agent === undefined && pantheonConfig.default_agent !== undefined) {
691
689
  config.default_agent = pantheonConfig.default_agent
692
690
  }
693
- // Merge top-level model/small_model from the repo config when the target
694
- // config has no top-level `model` (fresh sandbox installs have none). The
695
- // repo's opencode.json carries the default pair
696
- // (opencode-go/deepseek-v4-flash); a user-set model is never overwritten.
697
- if (!config.model && pantheonConfig.model) {
698
- config.model = pantheonConfig.model
699
- if (!config.small_model && pantheonConfig.small_model) {
700
- config.small_model = pantheonConfig.small_model
701
- }
702
- }
691
+ // Apply only explicit top-level model overrides. Existing values remain
692
+ // untouched and an install without either flag leaves both fields absent.
693
+ const modelFlag = typeof opts.model === 'string' && opts.model !== '' ? opts.model : undefined
694
+ const smallModelFlag =
695
+ typeof opts.smallModel === 'string' && opts.smallModel !== '' ? opts.smallModel : undefined
696
+ if (modelFlag !== undefined) config.model = modelFlag
697
+ if (smallModelFlag !== undefined) config.small_model = smallModelFlag
703
698
  // Merge subagent_depth HERE (the config merge always runs, including on a
704
699
  // fresh install) instead of a file-existence-gated block, so run #1 already
705
700
  // produces the final config — run #2 must be a byte-identical no-op (#19).
@@ -710,16 +705,24 @@ export async function installOpenCode(
710
705
  // experimental (preserving the user's choice) and default fresh installs to
711
706
  // experimental.subagent_depth = 2. V1 reads experimental.subagent_depth
712
707
  // too, so the V1-shape config stays valid under both versions.
713
- if (config.experimental === undefined || typeof config.experimental !== 'object') {
708
+ if (config.experimental === undefined) {
714
709
  config.experimental = {}
715
710
  }
716
- if (config.subagent_depth !== undefined) {
717
- if (config.experimental.subagent_depth === undefined) {
718
- config.experimental.subagent_depth = config.subagent_depth
711
+ if (
712
+ typeof config.experimental === 'object' &&
713
+ config.experimental !== null &&
714
+ !Array.isArray(config.experimental)
715
+ ) {
716
+ if (config.subagent_depth !== undefined) {
717
+ if (config.experimental.subagent_depth === undefined) {
718
+ config.experimental.subagent_depth = config.subagent_depth
719
+ }
720
+ delete config.subagent_depth
721
+ } else if (config.experimental.subagent_depth === undefined) {
722
+ config.experimental.subagent_depth = 2
719
723
  }
724
+ } else if (config.subagent_depth !== undefined) {
720
725
  delete config.subagent_depth
721
- } else if (config.experimental.subagent_depth === undefined) {
722
- config.experimental.subagent_depth = 2
723
726
  }
724
727
 
725
728
  // --------------------------------------------------------------------
@@ -735,74 +738,82 @@ export async function installOpenCode(
735
738
  // The source opencode.json keeps its dev path for local development; the
736
739
  // transform happens at install/sync time only.
737
740
 
738
- if (!Array.isArray(config.plugin)) {
741
+ if (config.plugin === undefined) {
739
742
  config.plugin = []
740
743
  }
741
- if (Array.isArray(pantheonConfig.plugin)) {
742
- for (const plugin of pantheonConfig.plugin) {
743
- const resolved = resolveInstalledPlugin(plugin)
744
+ if (Array.isArray(config.plugin)) {
745
+ if (Array.isArray(pantheonConfig.plugin)) {
746
+ for (const plugin of pantheonConfig.plugin) {
747
+ const resolved = resolveInstalledPlugin(plugin)
748
+ const file = basename(resolved)
749
+ // Replace any pre-existing entry for the same plugin file (e.g. a stale
750
+ // dev-machine path from an earlier install) so upgrades stay hermetic.
751
+ config.plugin = config.plugin.filter((p) => basename(p) !== file)
752
+ config.plugin.push(resolved)
753
+ }
754
+ }
755
+
756
+ // The two Pantheon plugins MUST always be registered, on fresh installs AND
757
+ // upgrades. The packaged opencode.json carries no `plugin` key (dev-machine
758
+ // paths removed in 3e552cc), so config.plugin would otherwise only contain
759
+ // whatever the user had — a fresh install would register NOTHING and lose
760
+ // delegation tools, GoalLoop, cost command, hashline and vision. Register
761
+ // both unconditionally, replacing any stale entry for the same file
762
+ // (basename-dedup) so installs and upgrades are idempotent and hermetic
763
+ // while preserving any user plugins.
764
+ const ensurePantheonPlugin = (ref) => {
765
+ const resolved = resolveInstalledPlugin(ref)
744
766
  const file = basename(resolved)
745
- // Replace any pre-existing entry for the same plugin file (e.g. a stale
746
- // dev-machine path from an earlier install) so upgrades stay hermetic.
747
767
  config.plugin = config.plugin.filter((p) => basename(p) !== file)
748
768
  config.plugin.push(resolved)
749
769
  }
770
+ // Root-level delegation plugin (PR #45): tools, GoalLoop, cost, hashline.
771
+ ensurePantheonPlugin('src/plugin.ts')
772
+ // Runtime hooks plugin: chat hooks, hook-runner, TUI wiring.
773
+ ensurePantheonPlugin('src/plugins/pantheon-hooks.ts')
750
774
  }
751
775
 
752
- // The two Pantheon plugins MUST always be registered, on fresh installs AND
753
- // upgrades. The packaged opencode.json carries no `plugin` key (dev-machine
754
- // paths removed in 3e552cc), so config.plugin would otherwise only contain
755
- // whatever the user had — a fresh install would register NOTHING and lose
756
- // delegation tools, GoalLoop, cost command, hashline and vision. Register
757
- // both unconditionally, replacing any stale entry for the same file
758
- // (basename-dedup) so installs and upgrades are idempotent and hermetic
759
- // while preserving any user plugins.
760
- const ensurePantheonPlugin = (ref) => {
761
- const resolved = resolveInstalledPlugin(ref)
762
- const file = basename(resolved)
763
- config.plugin = config.plugin.filter((p) => basename(p) !== file)
764
- config.plugin.push(resolved)
765
- }
766
- // Root-level delegation plugin (PR #45): tools, GoalLoop, cost, hashline.
767
- ensurePantheonPlugin('src/plugin.ts')
768
- // Runtime hooks plugin: chat hooks, hook-runner, TUI wiring.
769
- ensurePantheonPlugin('src/plugins/pantheon-hooks.ts')
770
-
771
- if (!config.provider && pantheonConfig.provider) {
776
+ if (config.provider === undefined && pantheonConfig.provider !== undefined) {
772
777
  config.provider = deepClone(pantheonConfig.provider)
773
- } else if (config.provider && pantheonConfig.provider) {
778
+ } else if (config.provider !== undefined && pantheonConfig.provider !== undefined) {
774
779
  mergeMissing(config.provider, pantheonConfig.provider)
775
780
  }
776
781
 
777
- if (!config.compaction && pantheonConfig.compaction) {
782
+ if (config.compaction === undefined && pantheonConfig.compaction !== undefined) {
778
783
  config.compaction = deepClone(pantheonConfig.compaction)
779
- } else if (config.compaction && pantheonConfig.compaction) {
784
+ } else if (config.compaction !== undefined && pantheonConfig.compaction !== undefined) {
780
785
  mergeMissing(config.compaction, pantheonConfig.compaction)
781
786
  }
782
787
 
783
788
  // --------------------------------------------------------------------
784
789
  // C. Merge permissions
785
790
  // --------------------------------------------------------------------
786
- if (!config.permission) config.permission = {}
787
- if (pantheonConfig.permission) {
791
+ if (config.permission === undefined) config.permission = {}
792
+ if (pantheonConfig.permission !== undefined) {
788
793
  mergeMissing(config.permission, pantheonConfig.permission)
789
794
  }
790
- if (componentSet.has('skills')) {
791
- config.permission.skill = { '*': 'allow' }
792
- }
793
- if (!config.permission.bash) {
794
- config.permission.bash = {
795
- 'git *': 'allow',
796
- 'npm *': 'allow',
797
- 'npx *': 'allow',
798
- 'pytest *': 'allow',
799
- 'ruff *': 'allow',
800
- 'black *': 'allow',
801
- 'pip *': 'allow',
802
- 'docker *': 'allow',
803
- 'curl *': 'allow',
804
- 'gh *': 'allow',
805
- 'make *': 'allow',
795
+ if (
796
+ typeof config.permission === 'object' &&
797
+ config.permission !== null &&
798
+ !Array.isArray(config.permission)
799
+ ) {
800
+ if (componentSet.has('skills')) {
801
+ config.permission.skill = { '*': 'allow' }
802
+ }
803
+ if (config.permission.bash === undefined) {
804
+ config.permission.bash = {
805
+ 'git *': 'allow',
806
+ 'npm *': 'allow',
807
+ 'npx *': 'allow',
808
+ 'pytest *': 'allow',
809
+ 'ruff *': 'allow',
810
+ 'black *': 'allow',
811
+ 'pip *': 'allow',
812
+ 'docker *': 'allow',
813
+ 'curl *': 'allow',
814
+ 'gh *': 'allow',
815
+ 'make *': 'allow',
816
+ }
806
817
  }
807
818
  }
808
819
 
@@ -816,9 +827,9 @@ export async function installOpenCode(
816
827
  // glob is no longer needed — keeping it would duplicate content under V1
817
828
  // and is ignored under V2 anyway. Only ensure AGENTS.md is present.
818
829
  const pantheonInstructions = ['AGENTS.md']
819
- if (!config.instructions) {
830
+ if (config.instructions === undefined) {
820
831
  config.instructions = [...pantheonInstructions]
821
- } else {
832
+ } else if (Array.isArray(config.instructions)) {
822
833
  // Strip stale per-file instruction globs from PREVIOUS installs
823
834
  // (e.g. `instructions/*.instructions.md` / `src/instructions/*.md`).
824
835
  // The merge is additive-only otherwise, so without this filter existing
@@ -838,7 +849,7 @@ export async function installOpenCode(
838
849
  // --------------------------------------------------------------------
839
850
  // E. Ensure $schema
840
851
  // --------------------------------------------------------------------
841
- if (!config.$schema) {
852
+ if (config.$schema === undefined) {
842
853
  config.$schema = 'https://opencode.ai/config.json'
843
854
  }
844
855
 
@@ -1009,12 +1020,14 @@ export async function installOpenCode(
1009
1020
  }
1010
1021
 
1011
1022
  // -----------------------------------------------------------------------
1012
- // 5. Model preset selection (--components agents). Interactive picker runs
1023
+ // 5. Model preset selection (--components agents). Interactive wizard runs
1013
1024
  // unless autoYes (use defaults) or an explicit --preset was given.
1025
+ // Q1 default "herdar do chat" (inherit) writes no active-preset.json —
1026
+ // delegates inherit the parent chat model (native inheritance).
1014
1027
  // -----------------------------------------------------------------------
1015
1028
  if (interactive && !dryRun && !autoYes && !opts.preset) {
1016
- const { runModelPicker } = await import('./model-picker.mjs')
1017
- await runModelPicker({ presetDir: target, logger: console })
1029
+ const { runInitWizard } = await import('./model-picker.mjs')
1030
+ await runInitWizard({ presetDir: target, logger: console })
1018
1031
  }
1019
1032
 
1020
1033
  if (opts.preset && !dryRun) {
@@ -590,46 +590,57 @@ export interface ChildSessionModelRef {
590
590
  providerID: string
591
591
  }
592
592
 
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
+ }
602
+
593
603
  /**
594
604
  * Split a `provider/model` model ID (e.g. `opencode/deepseek-v4-flash-free`)
595
605
  * into the `{ id, providerID }` ref the session.create body expects.
596
- * Returns undefined for malformed IDs (no slash, empty segments).
606
+ * Returns undefined for malformed or unsafe IDs.
597
607
  */
598
- 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
599
610
  const slash = model.indexOf('/')
600
- if (slash <= 0 || slash === model.length - 1) return undefined
601
611
  return { providerID: model.slice(0, slash), id: model.slice(slash + 1) }
602
612
  }
603
613
 
604
614
  /**
605
- * Resolve only an explicitly supplied model. A routing profile's `model` is
606
- * passed through as `options.agentModels`; profiles without one, and agents
607
- * without a profile, intentionally resolve to undefined so the OpenCode
608
- * parent session can provide inheritance.
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.
609
618
  */
610
619
  function resolveChildModel(
611
620
  options: DelegationOptions,
612
621
  agent: string,
613
622
  explicitModel?: string,
614
- ): ChildSessionModelRef | undefined {
623
+ ): { model: ChildSessionModelRef | undefined; error: string | undefined } {
615
624
  // (a) explicit model option on the delegate tool call
616
- if (explicitModel !== undefined && explicitModel !== '') {
625
+ if (explicitModel !== undefined) {
617
626
  const ref = splitModelRef(explicitModel)
618
- if (ref !== undefined) return ref
627
+ if (ref !== undefined) return { model: ref, error: undefined }
628
+ return { model: undefined, error: invalidModelOverride('explicit') }
619
629
  }
620
630
  const key = agent.toLowerCase()
621
- // (b) routing.yml agent entry wired through options.agentModels
631
+ // (b) agent-specific model override (routing.yml agent entry via agentModels)
622
632
  const mapped = options.agentModels?.[key]
623
- if (mapped !== undefined && mapped !== '') {
633
+ if (mapped !== undefined) {
624
634
  const ref = splitModelRef(mapped)
625
- if (ref !== undefined) return ref
635
+ if (ref !== undefined) return { model: ref, error: undefined }
636
+ return { model: undefined, error: invalidModelOverride('agentModels') }
626
637
  }
627
- return undefined
638
+ return { model: undefined, error: undefined }
628
639
  }
629
640
 
630
641
  /**
631
642
  * Resolve a child model without selecting a provider or model on the caller's
632
- * behalf. OpenCode owns model inheritance when this returns no model; if the
643
+ * behalf. OpenCode owns native inheritance when this returns no model; if the
633
644
  * host requires one, its session.create/promptAsync error is returned with
634
645
  * the normal diagnostic rather than silently choosing a fallback.
635
646
  *
@@ -641,15 +652,13 @@ function resolveUsableChildModel(
641
652
  agent: string,
642
653
  explicitModel: string | undefined,
643
654
  ): { model: ChildSessionModelRef | undefined; error: string | undefined } {
644
- const model = resolveChildModel(options, agent, explicitModel)
645
- if (model === undefined) {
646
- options.logger?.warn?.(
647
- `[pantheon-delegate] no model resolved for agent "${agent}" — ` +
648
- 'omitting model so OpenCode can inherit it from the parent session',
649
- )
650
- return { model: undefined, error: undefined }
651
- }
652
- return { model, error: undefined }
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
653
662
  }
654
663
 
655
664
  // ─── Factory ───────────────────────────────────────────────────────────
@@ -819,8 +828,9 @@ export function createDelegationTools(input: CreateDelegationToolsInput): Delega
819
828
  // session.create failure → return a clear error as TEXT (tools return
820
829
  // errors as text, not thrown) and register NO job on the board — an
821
830
  // unhandled rejection here would otherwise lose the failure entirely.
822
- // Only an explicit delegate model or routing profile model is sent.
823
- // Otherwise omit it so OpenCode inherits the parent model.
831
+ // Resolution order: explicit delegate model > active-preset agent model
832
+ // > omit (native inheritance). small_model is never used for delegated
833
+ // children.
824
834
  const resolved = resolveUsableChildModel(options, args.agent, args.model)
825
835
  if (resolved.error !== undefined) {
826
836
  if (budgetReserved && budgetKey !== undefined)