pantheon-opencode 1.6.0-beta.10 → 1.6.0-beta.11
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/AGENTS.global.md +42 -18
- package/AGENTS.md +1 -1
- package/CHANGELOG.md +24 -0
- package/README.md +66 -21
- package/bin/pantheon-init.mjs +44 -1
- package/package.json +4 -4
- package/plugin.json +1 -1
- package/pyproject.toml +1 -1
- package/scripts/build-agents-md.mjs +60 -6
- package/scripts/install/backup.mjs +475 -0
- package/scripts/install/cli-ui.mjs +6 -1
- package/scripts/install/exit-codes.mjs +26 -0
- package/scripts/install/interactive.mjs +15 -1
- package/scripts/install/migrate.mjs +41 -22
- package/scripts/install/opencode.mjs +562 -50
- package/scripts/install/plan.mjs +287 -0
- package/scripts/install/plugin.mjs +22 -8
- package/scripts/install/shared.mjs +89 -55
- package/scripts/install/strings.mjs +20 -0
- package/scripts/install/venv.mjs +13 -3
- package/scripts/install/vision-prompt.mjs +11 -1
- package/scripts/mcp_persistence.py +50 -15
- package/scripts/prune.mjs +9 -3
- package/scripts/restore.mjs +222 -0
- package/skills-lock.json +1 -1
- package/src/agents/iris.md +6 -0
- package/src/agents/zeus.md +19 -62
- package/src/instructions/agent-return-format.instructions.md +17 -15
- package/src/instructions/memory-protocol.instructions.md +3 -2
- package/src/instructions/test-economy.instructions.md +21 -0
- package/src/instructions/zeus-anti-stall.instructions.md +6 -6
- package/src/instructions/zeus-council-synthesis.instructions.md +10 -11
- package/src/instructions/zeus-memory-operations.instructions.md +10 -6
- package/src/instructions/zeus-timeout-retry.instructions.md +28 -9
- package/src/mcp/mcp_persistence.py +50 -15
- package/src/pantheon/cost-command.ts +5 -2
- package/src/pantheon/model-agents.ts +19 -0
- package/src/pantheon/model-command.ts +218 -56
- package/src/pantheon/v2-model-overrides.ts +275 -0
- package/src/pantheon/v2-tools.ts +18 -29
- package/src/plugin-v2.ts +13 -0
- package/src/plugins/tui/dist/tui-v2.js +2 -2
- package/src/plugins/tui/dist/tui.js +1 -1
- package/src/plugins/tui/package-lock.json +2 -2
- package/src/plugins/tui/package.json +1 -1
- package/src/skills/auto-continue/SKILL.md +14 -6
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
* - set --agent X --model Y --effort Z --scope project|global: validates
|
|
9
9
|
* via CAPABILITY_TABLE/hasVision and persists atomically with .bak + lock.
|
|
10
10
|
* - reset --agent X [--scope]: removes override.
|
|
11
|
-
* - wizard (no args): askQuestions flow agent → model → effort → scope.
|
|
11
|
+
* - legacy V1 wizard (no args): askQuestions flow agent → model → effort → scope.
|
|
12
12
|
*
|
|
13
13
|
* Atomic writes use sibling .bak and temporary-file rename with O_NOFOLLOW
|
|
14
14
|
* and per-path in-memory lock.
|
|
@@ -23,6 +23,7 @@ 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
|
+
import { KNOWN_AGENTS } from './model-agents.ts'
|
|
26
27
|
import {
|
|
27
28
|
capabilityEntry,
|
|
28
29
|
hasVision,
|
|
@@ -31,6 +32,10 @@ import {
|
|
|
31
32
|
resolveActivePreset,
|
|
32
33
|
} from './presets.mjs'
|
|
33
34
|
import type { ToolContextLike } from './tool-context.ts'
|
|
35
|
+
import { readV2ModelConfiguration } from './v2-model-overrides.ts'
|
|
36
|
+
|
|
37
|
+
export type { KnownAgent } from './model-agents.ts'
|
|
38
|
+
export { KNOWN_AGENTS } from './model-agents.ts'
|
|
34
39
|
|
|
35
40
|
export type ModelScope = 'project' | 'global'
|
|
36
41
|
export type ModelAction = 'status' | 'show' | 'set' | 'reset'
|
|
@@ -38,24 +43,6 @@ export type ModelAction = 'status' | 'show' | 'set' | 'reset'
|
|
|
38
43
|
const MODEL_REF_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]*\/[A-Za-z0-9][A-Za-z0-9._:+-]*$/
|
|
39
44
|
const SAFE_PRESET_NAME = /^[A-Za-z0-9][A-Za-z0-9._-]*$/
|
|
40
45
|
|
|
41
|
-
/** 14 canonical agents — must stay in sync with src/routing.yml agents. */
|
|
42
|
-
export const KNOWN_AGENTS = [
|
|
43
|
-
'zeus',
|
|
44
|
-
'athena',
|
|
45
|
-
'apollo',
|
|
46
|
-
'hermes',
|
|
47
|
-
'aphrodite',
|
|
48
|
-
'demeter',
|
|
49
|
-
'themis',
|
|
50
|
-
'prometheus',
|
|
51
|
-
'hephaestus',
|
|
52
|
-
'nyx',
|
|
53
|
-
'gaia',
|
|
54
|
-
'iris',
|
|
55
|
-
'mnemosyne',
|
|
56
|
-
'talos',
|
|
57
|
-
] as const
|
|
58
|
-
export type KnownAgent = (typeof KNOWN_AGENTS)[number]
|
|
59
46
|
const KNOWN_AGENTS_SET = new Set<string>(KNOWN_AGENTS)
|
|
60
47
|
|
|
61
48
|
const modelArgs = {
|
|
@@ -107,6 +94,10 @@ export interface ModelCommandOptions {
|
|
|
107
94
|
ask?: (questions: unknown[]) => Promise<Record<string, unknown>>
|
|
108
95
|
/** Optional readline injection for wizard fallback. */
|
|
109
96
|
rl?: { question: (q: string) => Promise<string>; close?: () => void }
|
|
97
|
+
/** V2 ignores static presets and reports/applies only explicit per-agent overrides. */
|
|
98
|
+
v2Mode?: boolean
|
|
99
|
+
/** Filesystem seam for secure V2 preset reads, including post-write verification. */
|
|
100
|
+
v2PresetReadFileSystem?: V2PresetReadFileSystem
|
|
110
101
|
}
|
|
111
102
|
|
|
112
103
|
export interface ModelTool {
|
|
@@ -127,6 +118,22 @@ interface ReadFileResult {
|
|
|
127
118
|
data: unknown
|
|
128
119
|
}
|
|
129
120
|
|
|
121
|
+
interface V2PresetReadStats {
|
|
122
|
+
isFile(): boolean
|
|
123
|
+
mode: number
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
interface V2PresetReadHandle {
|
|
127
|
+
stat(): Promise<V2PresetReadStats>
|
|
128
|
+
readFile(encoding: 'utf8'): Promise<string>
|
|
129
|
+
close(): Promise<void>
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** Filesystem operations injectable for focused V2 race-safety tests. */
|
|
133
|
+
export interface V2PresetReadFileSystem {
|
|
134
|
+
open(path: string, flags: number): Promise<V2PresetReadHandle>
|
|
135
|
+
}
|
|
136
|
+
|
|
130
137
|
interface AgentStatus {
|
|
131
138
|
agent: string
|
|
132
139
|
model: string | undefined
|
|
@@ -375,6 +382,75 @@ async function readActivePresetRaw(path: string, label: string): Promise<ReadFil
|
|
|
375
382
|
}
|
|
376
383
|
}
|
|
377
384
|
|
|
385
|
+
/** Read V2 mutation state from one nonblocking, no-follow file descriptor. */
|
|
386
|
+
export async function readV2ActivePresetRaw(
|
|
387
|
+
path: string,
|
|
388
|
+
label: string,
|
|
389
|
+
fileSystem: V2PresetReadFileSystem = {
|
|
390
|
+
open: async (filePath, flags) => open(filePath, flags),
|
|
391
|
+
},
|
|
392
|
+
): Promise<ReadFileResult> {
|
|
393
|
+
const noFollowFlag = constants.O_NOFOLLOW
|
|
394
|
+
const nonBlockFlag = constants.O_NONBLOCK
|
|
395
|
+
if (
|
|
396
|
+
noFollowFlag === undefined ||
|
|
397
|
+
noFollowFlag === 0 ||
|
|
398
|
+
nonBlockFlag === undefined ||
|
|
399
|
+
nonBlockFlag === 0
|
|
400
|
+
) {
|
|
401
|
+
throw new ModelCommandError(
|
|
402
|
+
`${label} cannot be read securely on this platform; configuration was not changed`,
|
|
403
|
+
)
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
let file: V2PresetReadHandle | undefined
|
|
407
|
+
try {
|
|
408
|
+
file = await fileSystem.open(path, constants.O_RDONLY | noFollowFlag | nonBlockFlag)
|
|
409
|
+
const openedStats = await file.stat()
|
|
410
|
+
if (!openedStats.isFile()) {
|
|
411
|
+
throw new ModelCommandError(
|
|
412
|
+
`${label} must be a regular file and must not be a symlink; configuration was not changed`,
|
|
413
|
+
)
|
|
414
|
+
}
|
|
415
|
+
const content = await file.readFile('utf8')
|
|
416
|
+
try {
|
|
417
|
+
const parsed: unknown = JSON.parse(content)
|
|
418
|
+
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
419
|
+
return {
|
|
420
|
+
exists: true,
|
|
421
|
+
valid: false,
|
|
422
|
+
data: null,
|
|
423
|
+
content,
|
|
424
|
+
mode: openedStats.mode & PERMISSION_MASK,
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
return {
|
|
428
|
+
exists: true,
|
|
429
|
+
valid: true,
|
|
430
|
+
data: parsed as Record<string, unknown>,
|
|
431
|
+
content,
|
|
432
|
+
mode: openedStats.mode & PERMISSION_MASK,
|
|
433
|
+
}
|
|
434
|
+
} catch {
|
|
435
|
+
return {
|
|
436
|
+
exists: true,
|
|
437
|
+
valid: false,
|
|
438
|
+
data: null,
|
|
439
|
+
content,
|
|
440
|
+
mode: openedStats.mode & PERMISSION_MASK,
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
} catch (error: unknown) {
|
|
444
|
+
if (errorCode(error) === 'ENOENT') return { exists: false, valid: true, data: null }
|
|
445
|
+
if (errorCode(error) === 'ELOOP') {
|
|
446
|
+
throw new ModelCommandError(`${label} must not be a symlink; configuration was not changed`)
|
|
447
|
+
}
|
|
448
|
+
throw error
|
|
449
|
+
} finally {
|
|
450
|
+
await file?.close().catch(() => undefined)
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
|
|
378
454
|
function normalizeAgentName(value: string): string {
|
|
379
455
|
return value.trim().toLowerCase()
|
|
380
456
|
}
|
|
@@ -464,11 +540,14 @@ async function writeActivePresetAtomically(
|
|
|
464
540
|
|
|
465
541
|
function ensureActivePresetShape(
|
|
466
542
|
raw: Record<string, unknown> | null,
|
|
467
|
-
fallbackPreset
|
|
543
|
+
fallbackPreset?: string,
|
|
468
544
|
): Record<string, unknown> {
|
|
469
545
|
const out: Record<string, unknown> = raw && typeof raw === 'object' ? { ...raw } : {}
|
|
470
546
|
if (typeof out.version !== 'number') out.version = 1
|
|
471
|
-
if (
|
|
547
|
+
if (
|
|
548
|
+
fallbackPreset !== undefined &&
|
|
549
|
+
(typeof out.preset !== 'string' || (out.preset as string).trim() === '')
|
|
550
|
+
) {
|
|
472
551
|
out.preset = fallbackPreset
|
|
473
552
|
}
|
|
474
553
|
if (typeof out.source !== 'string') out.source = 'cli'
|
|
@@ -516,34 +595,39 @@ async function updateActivePreset(
|
|
|
516
595
|
|
|
517
596
|
const path = presetPathForScope(options, scope)
|
|
518
597
|
return withPathLock(path, async () => {
|
|
519
|
-
const current =
|
|
598
|
+
const current = options.v2Mode
|
|
599
|
+
? await readV2ActivePresetRaw(path, 'configuration file', options.v2PresetReadFileSystem)
|
|
600
|
+
: await readActivePresetRaw(path, 'configuration file')
|
|
520
601
|
if (current.exists && !current.valid) {
|
|
521
602
|
throw new ModelCommandError(`${scope} preset file contains invalid JSON; file preserved`)
|
|
522
603
|
}
|
|
523
604
|
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
if (
|
|
542
|
-
typeof existing === 'string' &&
|
|
543
|
-
existing.trim() !== '' &&
|
|
544
|
-
SAFE_PRESET_NAME.test(existing.trim())
|
|
605
|
+
let fallbackPreset: string | undefined
|
|
606
|
+
if (!options.v2Mode) {
|
|
607
|
+
const env = envValue(options)
|
|
608
|
+
fallbackPreset = defaultFallbackPreset()
|
|
609
|
+
// Prefer env preset if set and known (V1 legacy behavior only).
|
|
610
|
+
const envPreset = env.PANTHEON_MODEL_PRESET?.trim()
|
|
611
|
+
if (envPreset && envPreset !== '' && envPreset !== 'none') {
|
|
612
|
+
try {
|
|
613
|
+
const defs = loadPresetDefs()
|
|
614
|
+
if (Object.hasOwn(defs, envPreset)) fallbackPreset = envPreset
|
|
615
|
+
} catch {
|
|
616
|
+
// ignore
|
|
617
|
+
}
|
|
618
|
+
} else if (
|
|
619
|
+
current.data &&
|
|
620
|
+
typeof current.data === 'object' &&
|
|
621
|
+
(current.data as Record<string, unknown>).preset
|
|
545
622
|
) {
|
|
546
|
-
|
|
623
|
+
const existing = (current.data as Record<string, unknown>).preset as string
|
|
624
|
+
if (
|
|
625
|
+
typeof existing === 'string' &&
|
|
626
|
+
existing.trim() !== '' &&
|
|
627
|
+
SAFE_PRESET_NAME.test(existing.trim())
|
|
628
|
+
) {
|
|
629
|
+
fallbackPreset = existing.trim()
|
|
630
|
+
}
|
|
547
631
|
}
|
|
548
632
|
}
|
|
549
633
|
|
|
@@ -603,13 +687,25 @@ async function updateActivePreset(
|
|
|
603
687
|
|
|
604
688
|
// health-check: verify file parses and preset resolves
|
|
605
689
|
try {
|
|
606
|
-
|
|
607
|
-
|
|
690
|
+
if (options.v2Mode) {
|
|
691
|
+
const written = await readV2ActivePresetRaw(
|
|
692
|
+
path,
|
|
693
|
+
'configuration file',
|
|
694
|
+
options.v2PresetReadFileSystem,
|
|
695
|
+
)
|
|
696
|
+
if (written.exists && written.content !== undefined) JSON.parse(written.content)
|
|
697
|
+
} else {
|
|
698
|
+
const written = await readRegularFile(path, 'configuration file')
|
|
699
|
+
if (written) JSON.parse(written.content)
|
|
700
|
+
}
|
|
608
701
|
} catch {
|
|
609
702
|
options.logger?.warn?.('health-check: active-preset.json verification failed')
|
|
610
703
|
}
|
|
611
704
|
|
|
612
|
-
const
|
|
705
|
+
const activation = options.v2Mode
|
|
706
|
+
? 'V2 reads this override during the next agent-config transform on a subsequent OpenCode configuration load; the current session is unchanged.'
|
|
707
|
+
: 'Restart OpenCode for the change to take effect.'
|
|
708
|
+
const base = `Model override set (${scope}): ${agentKey} → ${model} (effort: ${variantInfo.variant ?? 'none'}). ${activation}`
|
|
613
709
|
return visionWarning ? `${base}\n${visionWarning}` : base
|
|
614
710
|
} else {
|
|
615
711
|
// reset
|
|
@@ -620,7 +716,10 @@ async function updateActivePreset(
|
|
|
620
716
|
// Clean up empty overrides.agents to keep file tidy, but keep overrides object
|
|
621
717
|
// Remove empty overrides if no agents and no providers etc? Keep minimal.
|
|
622
718
|
await writeActivePresetAtomically(path, shaped, current)
|
|
623
|
-
|
|
719
|
+
const activation = options.v2Mode
|
|
720
|
+
? 'V2 reads overrides during the next agent-config transform on a subsequent OpenCode configuration load; the current session is unchanged.'
|
|
721
|
+
: 'Restart OpenCode for the change to take effect.'
|
|
722
|
+
return `Model override removed (${scope}): ${agentKey}. ${activation}`
|
|
624
723
|
}
|
|
625
724
|
})
|
|
626
725
|
}
|
|
@@ -630,6 +729,7 @@ async function updateActivePreset(
|
|
|
630
729
|
async function resolvePerAgentStatus(options: ModelCommandOptions): Promise<{
|
|
631
730
|
presetName: string | null
|
|
632
731
|
presetSource: string | null
|
|
732
|
+
legacyPresetMarker: { source: 'env' | 'file' } | null
|
|
633
733
|
agents: AgentStatus[]
|
|
634
734
|
warnings: string[]
|
|
635
735
|
}> {
|
|
@@ -637,6 +737,36 @@ async function resolvePerAgentStatus(options: ModelCommandOptions): Promise<{
|
|
|
637
737
|
const candidates = activePresetCandidatesList(options)
|
|
638
738
|
const warnings: string[] = []
|
|
639
739
|
|
|
740
|
+
if (options.v2Mode) {
|
|
741
|
+
const configuration = readV2ModelConfiguration({ candidates, env })
|
|
742
|
+
warnings.push(...configuration.warnings)
|
|
743
|
+
const agents = KNOWN_AGENTS.map((agent): AgentStatus => {
|
|
744
|
+
const override = configuration.overrides[agent]
|
|
745
|
+
let vision: boolean | undefined
|
|
746
|
+
if (override?.model) {
|
|
747
|
+
try {
|
|
748
|
+
vision = hasVision(override.model)
|
|
749
|
+
} catch {
|
|
750
|
+
vision = undefined
|
|
751
|
+
}
|
|
752
|
+
}
|
|
753
|
+
return {
|
|
754
|
+
agent,
|
|
755
|
+
model: override?.model,
|
|
756
|
+
effort: override?.variant,
|
|
757
|
+
origin: override ? 'override' : 'none',
|
|
758
|
+
vision,
|
|
759
|
+
}
|
|
760
|
+
})
|
|
761
|
+
return {
|
|
762
|
+
presetName: null,
|
|
763
|
+
presetSource: null,
|
|
764
|
+
legacyPresetMarker: configuration.legacyPresetMarker,
|
|
765
|
+
agents,
|
|
766
|
+
warnings,
|
|
767
|
+
}
|
|
768
|
+
}
|
|
769
|
+
|
|
640
770
|
// Use presets.mjs resolver (sync) for canonical resolution, but capture warnings
|
|
641
771
|
const logger: ModelCommandLogger = {
|
|
642
772
|
warn: (m) => warnings.push(m),
|
|
@@ -702,16 +832,37 @@ async function resolvePerAgentStatus(options: ModelCommandOptions): Promise<{
|
|
|
702
832
|
return {
|
|
703
833
|
presetName: (resolved as { name?: string } | null)?.name ?? null,
|
|
704
834
|
presetSource: (resolved as { source?: string } | null)?.source ?? null,
|
|
835
|
+
legacyPresetMarker: null,
|
|
705
836
|
agents,
|
|
706
837
|
warnings,
|
|
707
838
|
}
|
|
708
839
|
}
|
|
709
840
|
|
|
710
841
|
async function renderStatus(options: ModelCommandOptions): Promise<string> {
|
|
711
|
-
const { presetName, presetSource, agents, warnings } =
|
|
842
|
+
const { presetName, presetSource, legacyPresetMarker, agents, warnings } =
|
|
843
|
+
await resolvePerAgentStatus(options)
|
|
712
844
|
const lines: string[] = []
|
|
713
|
-
lines.push(
|
|
714
|
-
|
|
845
|
+
lines.push(
|
|
846
|
+
options.v2Mode
|
|
847
|
+
? 'Pantheon model configuration per-agent (configured values only; not a readout of the model serving a completion):'
|
|
848
|
+
: 'Pantheon model configuration per-agent (restart OpenCode after changes):',
|
|
849
|
+
)
|
|
850
|
+
if (options.v2Mode) {
|
|
851
|
+
lines.push('V2 manual override scope: project only; global overrides are not read or applied.')
|
|
852
|
+
if (legacyPresetMarker) {
|
|
853
|
+
const markerSource =
|
|
854
|
+
legacyPresetMarker.source === 'env' ? 'PANTHEON_MODEL_PRESET' : 'active-preset.json'
|
|
855
|
+
lines.push(
|
|
856
|
+
`Legacy preset marker present in ${markerSource} — ignored in V2; static presets are retired.`,
|
|
857
|
+
)
|
|
858
|
+
} else {
|
|
859
|
+
lines.push('Legacy preset marker: none (static presets are retired and ignored in V2).')
|
|
860
|
+
}
|
|
861
|
+
lines.push('Host default remains unchanged when no manual override is set.')
|
|
862
|
+
lines.push(
|
|
863
|
+
'Manual project-scoped per-agent overrides are read by the V2 agent transform on the next OpenCode configuration load; the current session is unchanged.',
|
|
864
|
+
)
|
|
865
|
+
} else if (presetName) {
|
|
715
866
|
const originLabel =
|
|
716
867
|
presetSource === 'env' ? 'env' : presetSource === 'file' ? 'preset' : presetSource
|
|
717
868
|
lines.push(`active preset: ${presetName} (${presetSource ?? 'unknown'})`)
|
|
@@ -736,7 +887,7 @@ async function renderStatus(options: ModelCommandOptions): Promise<string> {
|
|
|
736
887
|
return lines.join('\n')
|
|
737
888
|
}
|
|
738
889
|
|
|
739
|
-
// ───
|
|
890
|
+
// ─── Legacy V1 wizard (askQuestions) ────────────────────────────────────────
|
|
740
891
|
|
|
741
892
|
export function buildWizardQuestions(): unknown[] {
|
|
742
893
|
return [
|
|
@@ -875,13 +1026,23 @@ async function runWizard(options: ModelCommandOptions): Promise<string> {
|
|
|
875
1026
|
}
|
|
876
1027
|
}
|
|
877
1028
|
|
|
878
|
-
/** Create the
|
|
1029
|
+
/** Create the `pantheon_model` tool; the interactive wizard is V1-only. */
|
|
879
1030
|
export function createModelCommand(options: ModelCommandOptions = {}): ModelToolset {
|
|
1031
|
+
const args = options.v2Mode
|
|
1032
|
+
? {
|
|
1033
|
+
...modelArgs,
|
|
1034
|
+
action: modelArgs.action.describe(
|
|
1035
|
+
'Operation: status/show, set, or reset. V1 no-args invocation runs the legacy wizard.',
|
|
1036
|
+
),
|
|
1037
|
+
}
|
|
1038
|
+
: modelArgs
|
|
1039
|
+
|
|
880
1040
|
return {
|
|
881
1041
|
pantheon_model: {
|
|
882
|
-
description:
|
|
883
|
-
'Show, set, or reset per-agent model overrides in active-preset.json
|
|
884
|
-
|
|
1042
|
+
description: options.v2Mode
|
|
1043
|
+
? 'Show, set, or reset explicit per-agent project-scoped model overrides in active-preset.json. Global overrides are not read or written in V2; future support requires separate explicit authorization. V2 ignores static preset names and PANTHEON_MODEL_PRESET; no wizard or implicit model selection. Status reports project-scoped configured values only and never writes .env or top-level model/small_model.'
|
|
1044
|
+
: 'Show, set, or reset per-agent model overrides in active-preset.json (project or global). Overrides win over preset; env preset wins over file. Never writes .env or top-level model/small_model.',
|
|
1045
|
+
args,
|
|
885
1046
|
execute: async (args, _ctx) => {
|
|
886
1047
|
try {
|
|
887
1048
|
const hasAction = args.action !== undefined
|
|
@@ -890,6 +1051,7 @@ export function createModelCommand(options: ModelCommandOptions = {}): ModelTool
|
|
|
890
1051
|
|
|
891
1052
|
// Wizard when invoked with no args at all
|
|
892
1053
|
if (!hasAction && !hasAgentModelEffort) {
|
|
1054
|
+
if (options.v2Mode) return await renderStatus(options)
|
|
893
1055
|
return await runWizard(options)
|
|
894
1056
|
}
|
|
895
1057
|
|
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
import { closeSync, constants, fstatSync, openSync, readFileSync } from 'node:fs'
|
|
2
|
+
import { join } from 'node:path'
|
|
3
|
+
import { KNOWN_AGENTS } from './model-agents.ts'
|
|
4
|
+
import { capabilityEntry, normalizeCapability } from './presets.mjs'
|
|
5
|
+
|
|
6
|
+
export interface V2ModelOverride {
|
|
7
|
+
model: string
|
|
8
|
+
variant?: 'low' | 'medium' | 'high'
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface V2ModelConfiguration {
|
|
12
|
+
overrides: Record<string, V2ModelOverride>
|
|
13
|
+
legacyPresetMarker: { source: 'env' | 'file' } | null
|
|
14
|
+
warnings: string[]
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface V2AgentDraftLike {
|
|
18
|
+
list(): readonly { id: string }[]
|
|
19
|
+
update(id: string, update: (agent: { id: string; model?: unknown }) => void): void
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
interface AgentModelLike {
|
|
23
|
+
id: string
|
|
24
|
+
model?: unknown
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const MODEL_REF_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]*\/[A-Za-z0-9][A-Za-z0-9._:+-]*$/
|
|
28
|
+
const VARIANTS = new Set(['low', 'medium', 'high'])
|
|
29
|
+
const KNOWN_AGENTS_SET = new Set<string>(KNOWN_AGENTS)
|
|
30
|
+
|
|
31
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
32
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function isVariant(value: unknown): value is NonNullable<V2ModelOverride['variant']> {
|
|
36
|
+
return typeof value === 'string' && VARIANTS.has(value)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function isSupportedModelChoice(model: unknown, variant: unknown): model is string {
|
|
40
|
+
if (typeof model !== 'string' || !MODEL_REF_PATTERN.test(model)) return false
|
|
41
|
+
if (variant !== undefined && !isVariant(variant)) return false
|
|
42
|
+
try {
|
|
43
|
+
capabilityEntry(model)
|
|
44
|
+
if (variant === undefined) return true
|
|
45
|
+
const normalized = normalizeCapability(model, variant)
|
|
46
|
+
return !normalized.clamped && normalized.variant === variant
|
|
47
|
+
} catch {
|
|
48
|
+
return false
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
interface PresetFileSystem {
|
|
53
|
+
openSync(path: string, flags: number): number
|
|
54
|
+
fstatSync(fileDescriptor: number): { isFile(): boolean }
|
|
55
|
+
readFileSync(fileDescriptor: number, encoding: 'utf8'): string
|
|
56
|
+
closeSync(fileDescriptor: number): void
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
class SecurePresetReadUnavailableError extends Error {
|
|
60
|
+
constructor() {
|
|
61
|
+
super('Secure preset-file opening is unavailable')
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
class NonRegularPresetFileError extends Error {
|
|
66
|
+
constructor() {
|
|
67
|
+
super('Preset file is not a regular file')
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Open and read a regular preset file without following links or blocking on special files. */
|
|
72
|
+
export function readV2PresetFile(
|
|
73
|
+
candidate: string,
|
|
74
|
+
fileSystem: PresetFileSystem = { openSync, fstatSync, readFileSync, closeSync },
|
|
75
|
+
): string {
|
|
76
|
+
const noFollowFlag = constants.O_NOFOLLOW
|
|
77
|
+
const nonBlockFlag = constants.O_NONBLOCK
|
|
78
|
+
if (
|
|
79
|
+
noFollowFlag === undefined ||
|
|
80
|
+
noFollowFlag === 0 ||
|
|
81
|
+
nonBlockFlag === undefined ||
|
|
82
|
+
nonBlockFlag === 0
|
|
83
|
+
) {
|
|
84
|
+
throw new SecurePresetReadUnavailableError()
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
let fileDescriptor: number | undefined
|
|
88
|
+
try {
|
|
89
|
+
fileDescriptor = fileSystem.openSync(
|
|
90
|
+
candidate,
|
|
91
|
+
constants.O_RDONLY | noFollowFlag | nonBlockFlag,
|
|
92
|
+
)
|
|
93
|
+
if (!fileSystem.fstatSync(fileDescriptor).isFile()) {
|
|
94
|
+
throw new NonRegularPresetFileError()
|
|
95
|
+
}
|
|
96
|
+
return fileSystem.readFileSync(fileDescriptor, 'utf8')
|
|
97
|
+
} finally {
|
|
98
|
+
if (fileDescriptor !== undefined) fileSystem.closeSync(fileDescriptor)
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function parseOverrides(
|
|
103
|
+
value: Record<string, unknown>,
|
|
104
|
+
warnings: string[],
|
|
105
|
+
): Record<string, V2ModelOverride> {
|
|
106
|
+
const parsed: Record<string, V2ModelOverride> = {}
|
|
107
|
+
for (const [agent, raw] of Object.entries(value)) {
|
|
108
|
+
if (!KNOWN_AGENTS_SET.has(agent)) {
|
|
109
|
+
warnings.push(
|
|
110
|
+
'Override entry ignored for an unsupported agent; use one of the canonical Pantheon agent IDs.',
|
|
111
|
+
)
|
|
112
|
+
continue
|
|
113
|
+
}
|
|
114
|
+
const entry = isRecord(raw) ? raw : undefined
|
|
115
|
+
const hasVariant = entry !== undefined && Object.hasOwn(entry, 'variant')
|
|
116
|
+
const variant = hasVariant ? entry?.variant : entry?.reasoning_effort
|
|
117
|
+
const model = entry?.model
|
|
118
|
+
if (!entry || (hasVariant && !isVariant(variant)) || !isSupportedModelChoice(model, variant)) {
|
|
119
|
+
warnings.push(
|
|
120
|
+
'Override entry ignored because its model or variant is unsupported; choose a supported model and reasoning variant.',
|
|
121
|
+
)
|
|
122
|
+
continue
|
|
123
|
+
}
|
|
124
|
+
const override: V2ModelOverride = { model }
|
|
125
|
+
if (isVariant(variant)) override.variant = variant
|
|
126
|
+
parsed[agent] = override
|
|
127
|
+
}
|
|
128
|
+
return parsed
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function defaultCandidates(): string[] {
|
|
132
|
+
return [join(process.cwd(), '.pantheon', 'active-preset.json')]
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** Read explicit per-agent project overrides only; global files are ignored in V2. */
|
|
136
|
+
export function readV2ModelConfiguration(
|
|
137
|
+
options: { candidates?: string[]; env?: NodeJS.ProcessEnv } = {},
|
|
138
|
+
): V2ModelConfiguration {
|
|
139
|
+
const env = options.env ?? process.env
|
|
140
|
+
const envName = env.PANTHEON_MODEL_PRESET?.trim()
|
|
141
|
+
let fileMarkerPresent = false
|
|
142
|
+
const configurations: Array<Record<string, V2ModelOverride>> = []
|
|
143
|
+
const warnings: string[] = []
|
|
144
|
+
let readFailure = false
|
|
145
|
+
|
|
146
|
+
// `model-command` passes project before global candidates. Keep this reader
|
|
147
|
+
// project-only even if a caller supplies the legacy candidate list.
|
|
148
|
+
const candidates = options.candidates ?? defaultCandidates()
|
|
149
|
+
for (const candidate of candidates.slice(0, 1)) {
|
|
150
|
+
let rawContent: string
|
|
151
|
+
try {
|
|
152
|
+
rawContent = readV2PresetFile(candidate)
|
|
153
|
+
} catch (error: unknown) {
|
|
154
|
+
if (error instanceof Error && 'code' in error && error.code === 'ENOENT') continue
|
|
155
|
+
if (error instanceof SecurePresetReadUnavailableError) {
|
|
156
|
+
warnings.push(
|
|
157
|
+
'V2 project model configuration cannot be read securely on this platform; manual overrides were not applied.',
|
|
158
|
+
)
|
|
159
|
+
readFailure = true
|
|
160
|
+
break
|
|
161
|
+
}
|
|
162
|
+
if (
|
|
163
|
+
error instanceof NonRegularPresetFileError ||
|
|
164
|
+
(error instanceof Error && 'code' in error && error.code === 'ELOOP')
|
|
165
|
+
) {
|
|
166
|
+
warnings.push(
|
|
167
|
+
'V2 project model configuration is not a regular file or is a symlink; check active-preset.json. Manual overrides were not applied.',
|
|
168
|
+
)
|
|
169
|
+
readFailure = true
|
|
170
|
+
break
|
|
171
|
+
}
|
|
172
|
+
warnings.push(
|
|
173
|
+
'V2 project model configuration could not be read; check active-preset.json file access. Manual overrides were not applied.',
|
|
174
|
+
)
|
|
175
|
+
readFailure = true
|
|
176
|
+
break
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
let raw: unknown
|
|
180
|
+
try {
|
|
181
|
+
raw = JSON.parse(rawContent)
|
|
182
|
+
} catch {
|
|
183
|
+
warnings.push(
|
|
184
|
+
'V2 project model configuration contains invalid JSON; correct active-preset.json. Manual overrides were not applied.',
|
|
185
|
+
)
|
|
186
|
+
readFailure = true
|
|
187
|
+
break
|
|
188
|
+
}
|
|
189
|
+
if (!isRecord(raw)) {
|
|
190
|
+
warnings.push(
|
|
191
|
+
'V2 project model configuration has an invalid shape; correct active-preset.json. Manual overrides were not applied.',
|
|
192
|
+
)
|
|
193
|
+
readFailure = true
|
|
194
|
+
break
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
if (Object.hasOwn(raw, 'preset')) fileMarkerPresent = true
|
|
198
|
+
let parsedOverrides: Record<string, V2ModelOverride> = {}
|
|
199
|
+
if (Object.hasOwn(raw, 'overrides')) {
|
|
200
|
+
if (!isRecord(raw.overrides)) {
|
|
201
|
+
warnings.push(
|
|
202
|
+
'V2 project model configuration has an invalid `overrides` object; expected an object in active-preset.json. The project file contributed no manual overrides.',
|
|
203
|
+
)
|
|
204
|
+
} else if (Object.hasOwn(raw.overrides, 'agents')) {
|
|
205
|
+
if (!isRecord(raw.overrides.agents)) {
|
|
206
|
+
warnings.push(
|
|
207
|
+
'V2 project model configuration has invalid `overrides.agents`; expected an object in active-preset.json. The project file contributed no manual overrides.',
|
|
208
|
+
)
|
|
209
|
+
} else {
|
|
210
|
+
parsedOverrides = parseOverrides(raw.overrides.agents, warnings)
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
configurations.push(parsedOverrides)
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
const envMarkerPresent = typeof envName === 'string' && envName.length > 0
|
|
218
|
+
const markerSource: 'env' | 'file' | null = envMarkerPresent
|
|
219
|
+
? 'env'
|
|
220
|
+
: fileMarkerPresent
|
|
221
|
+
? 'file'
|
|
222
|
+
: null
|
|
223
|
+
|
|
224
|
+
// A project-file read/parse failure fails closed. Malformed entries warn but
|
|
225
|
+
// leave other valid project entries available.
|
|
226
|
+
const overrides: Record<string, V2ModelOverride> = {}
|
|
227
|
+
if (!readFailure) {
|
|
228
|
+
Object.assign(overrides, configurations[0] ?? {})
|
|
229
|
+
}
|
|
230
|
+
return {
|
|
231
|
+
overrides,
|
|
232
|
+
legacyPresetMarker: markerSource === null ? null : { source: markerSource },
|
|
233
|
+
warnings,
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/** Apply one explicit model override while leaving all other agent settings intact. */
|
|
238
|
+
export function applyV2ModelOverride(
|
|
239
|
+
agent: AgentModelLike,
|
|
240
|
+
override: V2ModelOverride | undefined,
|
|
241
|
+
): void {
|
|
242
|
+
if (
|
|
243
|
+
!KNOWN_AGENTS_SET.has(agent.id) ||
|
|
244
|
+
!override ||
|
|
245
|
+
!isSupportedModelChoice(override.model, override.variant)
|
|
246
|
+
) {
|
|
247
|
+
return
|
|
248
|
+
}
|
|
249
|
+
const separator = override.model.indexOf('/')
|
|
250
|
+
const previous = isRecord(agent.model) ? agent.model : {}
|
|
251
|
+
const model: Record<string, unknown> = {
|
|
252
|
+
...previous,
|
|
253
|
+
providerID: override.model.slice(0, separator),
|
|
254
|
+
id: override.model.slice(separator + 1),
|
|
255
|
+
}
|
|
256
|
+
if (override.variant !== undefined) {
|
|
257
|
+
model.variant = override.variant
|
|
258
|
+
} else {
|
|
259
|
+
delete model.variant
|
|
260
|
+
}
|
|
261
|
+
agent.model = model
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/** Apply explicit overrides to matching agents and leave unmatched host defaults untouched. */
|
|
265
|
+
export function applyV2ModelOverrides(
|
|
266
|
+
draft: V2AgentDraftLike,
|
|
267
|
+
overrides: Record<string, V2ModelOverride>,
|
|
268
|
+
): void {
|
|
269
|
+
for (const agent of draft.list()) {
|
|
270
|
+
if (!KNOWN_AGENTS_SET.has(agent.id)) continue
|
|
271
|
+
const override = overrides[agent.id]
|
|
272
|
+
if (!override) continue
|
|
273
|
+
draft.update(agent.id, (current) => applyV2ModelOverride(current, override))
|
|
274
|
+
}
|
|
275
|
+
}
|