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.
- package/README.md +166 -50
- package/bin/pantheon-init.mjs +7 -0
- package/commands/pantheon-model.md +34 -0
- package/package.json +2 -2
- package/scripts/generate-preset-docs.mjs +239 -0
- package/scripts/install/model-picker.mjs +367 -2
- package/scripts/install/opencode.mjs +150 -137
- package/src/pantheon/delegation.ts +36 -26
- package/src/pantheon/model-command.ts +858 -0
- package/src/pantheon/presets.mjs +30 -33
- package/src/pantheon/zeus-delegate-with-retry.ts +9 -4
- package/src/plugin.ts +4 -0
- package/src/plugins/tui/dist/tui.d.ts.map +1 -1
- package/src/plugins/tui/dist/tui.js +1 -1
- package/src/plugins/tui/dist/tui.tsx +13 -2
- package/src/plugins/tui/package.json +1 -1
- package/src/plugins/tui/src/index.tsx +13 -2
- package/src/plugins/tui/tsdown.config.ts +11 -0
- package/src/routing.yml +71 -123
|
@@ -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 (
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
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
|
-
|
|
631
|
-
|
|
632
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
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 (
|
|
688
|
+
if (config.default_agent === undefined && pantheonConfig.default_agent !== undefined) {
|
|
691
689
|
config.default_agent = pantheonConfig.default_agent
|
|
692
690
|
}
|
|
693
|
-
//
|
|
694
|
-
//
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
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
|
|
708
|
+
if (config.experimental === undefined) {
|
|
714
709
|
config.experimental = {}
|
|
715
710
|
}
|
|
716
|
-
if (
|
|
717
|
-
|
|
718
|
-
|
|
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 (
|
|
741
|
+
if (config.plugin === undefined) {
|
|
739
742
|
config.plugin = []
|
|
740
743
|
}
|
|
741
|
-
if (Array.isArray(
|
|
742
|
-
|
|
743
|
-
const
|
|
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
|
-
|
|
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 (
|
|
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 (
|
|
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 (
|
|
791
|
-
config.permission
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
'
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
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 (
|
|
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 (
|
|
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
|
|
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 {
|
|
1017
|
-
await
|
|
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
|
|
606
|
+
* Returns undefined for malformed or unsafe IDs.
|
|
597
607
|
*/
|
|
598
|
-
function splitModelRef(model:
|
|
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
|
|
606
|
-
*
|
|
607
|
-
*
|
|
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
|
|
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
|
|
631
|
+
// (b) agent-specific model override (routing.yml agent entry via agentModels)
|
|
622
632
|
const mapped = options.agentModels?.[key]
|
|
623
|
-
if (mapped !== undefined
|
|
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
|
|
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
|
|
645
|
-
if (model
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
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
|
-
//
|
|
823
|
-
//
|
|
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)
|