From ac162fd84026097a90035db93969460d01323a66 Mon Sep 17 00:00:00 2001 From: Jason Kneen Date: Mon, 15 Jun 2026 14:12:38 +0100 Subject: [PATCH 1/4] feat(chat): skill-defined model lock (P1b-2, precedence layer 1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A Persona can LINK skills (Persona.skills). If a linked skill declares a requiredModel (model:/provider: frontmatter, or authored in the editor), selecting that persona PINS the composer model/provider and DISABLES the picker — precedence layer 1, above the soft default (layer 2) and user pick (layer 3). - types: SkillDefinition.requiredModel/requiredProvider; Persona.skills; PersonaModelSeed.locked/reason (fills the reserved P1b-2 seam). - resolveSkillModelLock() runs above resolvePersonaModelSeed; first linked skill (by id OR name) with a requiredModel wins. - ChatTile: computes modelLock from active persona + workspace skills, short-circuits the layer-2 seed in onSelectAgent, pins live state via a lock-only effect, threads modelLocked/lockReason to the composer pills. - SkillEditor: Required Model/Provider fields; AgentEditor: linked-skills picker (stores names; flags skills that lock a model). - Discovery parses model:/provider: frontmatter into requiredModel/Provider. Model is NOT a security boundary: renderer model-resolution + composer disablement only. resolveAuthoritativeAgentMode and the daemon tools/ permission path are untouched. Built-ins carry NO skills, so the shared<->daemon DEFAULT_PERSONAS drift guard stays green. --- src/renderer/src/components/ChatTile.tsx | 48 ++++- .../src/components/CustomisationTile.tsx | 51 ++++- .../src/components/chat/ChatTileComposer.tsx | 11 +- src/renderer/src/hooks/personaModelBinding.ts | 50 ++++- .../src/hooks/useChatTileWorkspaceSkills.ts | 9 + src/shared/types.ts | 24 +++ test/daemon/persona-model-binding.test.mjs | 186 +++++++++++++++++- 7 files changed, 363 insertions(+), 16 deletions(-) diff --git a/src/renderer/src/components/ChatTile.tsx b/src/renderer/src/components/ChatTile.tsx index 0f48cfda..0abaf154 100644 --- a/src/renderer/src/components/ChatTile.tsx +++ b/src/renderer/src/components/ChatTile.tsx @@ -1,7 +1,7 @@ import React, { useState, useEffect, useRef, useCallback, useMemo, Suspense } from 'react' import type { AppSettings, Persona } from '../../../shared/types' import { loadPersonas, getAgentIcon, DEFAULT_PERSONAS } from '../config/agentModes' -import { resolvePersonaModelSeed } from '../hooks/personaModelBinding' +import { resolvePersonaModelSeed, resolveSkillModelLock } from '../hooks/personaModelBinding' import { MONO_DEFAULT } from '../FontContext' const LazyTerminalTile = React.lazy(() => import('./TerminalTile').then(m => ({ default: m.TerminalTile }))) @@ -235,6 +235,24 @@ export function ChatTile({ tileId, workspaceId, workspaceDir: _workspaceDir, wid () => agentModes.find(a => a.id === agentId) ?? null, [agentModes, agentId], ) + // Precedence LAYER 1 (P1b-2): a linked skill's `requiredModel` HARD-locks the + // composer. Computed from the active persona + discovered workspace skills; when + // non-null the model/provider pills are disabled and the live state is pinned. + const modelLock = useMemo( + () => resolveSkillModelLock(resolvedAgentMode, workspaceSkills), + [resolvedAgentMode, workspaceSkills], + ) + // Keep the live composer state pinned while a lock is active. This closes the + // gap onSelectAgent can't: an async skill load or a restored `agentId` (where + // the click handler never fires) would otherwise leave a stale model behind a + // disabled pill. Early-return when unlocked so layer 3 (user pick) is untouched. + // This effect only forces the HARD lock — the soft seed (layer 2) still happens + // exclusively in the onSelectAgent click handler, never in an effect. + useEffect(() => { + if (!modelLock) return + if (modelLock.provider) setProvider(modelLock.provider) + if (modelLock.model) setModel(modelLock.model) + }, [modelLock?.provider, modelLock?.model]) const { chatSurfaceMenu, @@ -943,6 +961,8 @@ export function ChatTile({ tileId, workspaceId, workspaceDir: _workspaceDir, wid showModelMenu={showModelMenu} currentProviderEntry={currentProviderEntry} currentModelLabel={currentModel.label} + modelLocked={Boolean(modelLock)} + lockReason={modelLock?.reason} model={model} modelFilter={modelFilter} onModelFilterChange={setModelFilter} @@ -1014,14 +1034,24 @@ export function ChatTile({ tileId, workspaceId, workspaceDir: _workspaceDir, wid agentModes={agentModes} onSelectAgent={nextAgentId => { setAgentId(nextAgentId) - // Precedence layer 2: a selected persona's SOFT defaultBinding seeds the - // composer's provider/model. Seed once here (NOT in an effect — an effect - // keyed on agentId would re-clobber the user's pick on restore/re-render). - // The user can freely change it afterward; the live composer state flows - // to req.model/provider, so the user pick (layer 3) always wins. - const modelSeed = resolvePersonaModelSeed(agentModes.find(a => a.id === nextAgentId) ?? null) - if (modelSeed?.provider) setProvider(modelSeed.provider) - if (modelSeed?.model) setModel(modelSeed.model) + const nextPersona = agentModes.find(a => a.id === nextAgentId) ?? null + // Precedence LAYER 1 (P1b-2): if a linked skill imposes a HARD model lock, + // it PINS provider/model and SHORT-CIRCUITS the soft seed below. The picker + // is disabled in the composer; the live-state effect keeps the pin honoured. + const skillLock = resolveSkillModelLock(nextPersona, workspaceSkills) + if (skillLock) { + if (skillLock.provider) setProvider(skillLock.provider) + if (skillLock.model) setModel(skillLock.model) + } else { + // Precedence layer 2: a selected persona's SOFT defaultBinding seeds the + // composer's provider/model. Seed once here (NOT in an effect — an effect + // keyed on agentId would re-clobber the user's pick on restore/re-render). + // The user can freely change it afterward; the live composer state flows + // to req.model/provider, so the user pick (layer 3) always wins. + const modelSeed = resolvePersonaModelSeed(nextPersona) + if (modelSeed?.provider) setProvider(modelSeed.provider) + if (modelSeed?.model) setModel(modelSeed.model) + } setShowAgentMenu(false) }} planTodos={planTodos} diff --git a/src/renderer/src/components/CustomisationTile.tsx b/src/renderer/src/components/CustomisationTile.tsx index 800183f9..c982c79c 100644 --- a/src/renderer/src/components/CustomisationTile.tsx +++ b/src/renderer/src/components/CustomisationTile.tsx @@ -4,6 +4,7 @@ import { useAppFonts } from '../FontContext' import type { PromptTemplate, PromptField, SkillDefinition, Persona } from '../../../shared/types' import { ChatMarkdown } from './shared/streamdown-utils' import { DEFAULT_PERSONAS as DEFAULT_MODES, AGENT_COLORS, AGENT_ICONS } from '../config/agentModes' +import { useChatTileWorkspaceSkills } from '../hooks/useChatTileWorkspaceSkills' type Tab = 'prompts' | 'skills' | 'tools' | 'agents' @@ -794,6 +795,12 @@ function SkillEditor({ item, onSave, onCancel }: { item: SkillDefinition; onSave up({ name: v })} placeholder="Skill name" /> up({ description: v })} placeholder="What does this skill do?" /> up({ command: v })} placeholder="e.g. my-skill" /> + + up({ requiredModel: v.trim() || undefined })} placeholder="e.g. claude-opus-4-8 — pins the composer model when a persona links this skill" /> + + + up({ requiredProvider: v.trim() || undefined })} placeholder="optional — e.g. claude, codex (pins alongside the model)" /> + up({ content: v })} placeholder="# Skill content..." multiline mono rows={12} />
@@ -1203,7 +1210,7 @@ export function AgentsSection({ workspacePath, hideHeaderText = false }: { works scanDirs() }, [workspacePath, locationText]) - if (editing) return setEditing(null)} /> + if (editing) return setEditing(null)} /> if (locationsOpen) return ( void; onCancel: () => void }): JSX.Element { +function AgentEditor({ item, modes, workspacePath, onSave, onCancel }: { item: Persona; modes: Persona[]; workspacePath: string; onSave: (m: Persona) => void; onCancel: () => void }): JSX.Element { const theme = useTheme() const fonts = useAppFonts() const [draft, setDraft] = useState(item) const up = (patch: Partial) => setDraft(prev => ({ ...prev, ...patch })) + // Discovered + authored skills for the link picker. Linked skills are stored by + // NAME (stable across machines; discovered ids are path-based). A skill carrying a + // `requiredModel` HARD-locks the composer model for any persona that links it. + const { workspaceSkills } = useChatTileWorkspaceSkills(workspacePath) + const linkedSkills = draft.skills ?? [] + const toggleSkill = (name: string) => up({ + skills: linkedSkills.includes(name) + ? linkedSkills.filter(s => s !== name) + : [...linkedSkills, name], + }) // tools semantics: null/undefined (unset) = unrestricted → checkbox off; // [] = explicit deny-all and [names] = restricted → checkbox on. Loose `!=` // so an absent (undefined) tools field reads as unrestricted, not restricted. @@ -1302,6 +1319,36 @@ function AgentEditor({ item, modes, onSave, onCancel }: { item: Persona; modes: )} + + {workspaceSkills.length === 0 ? ( +
+ No workspace skills found. Skills that declare a Required Model will pin this persona’s model when linked. +
+ ) : ( +
+ {workspaceSkills.map(skill => { + const checked = linkedSkills.includes(skill.name) + return ( + + ) + })} +
+ )} + {linkedSkills.some(name => !workspaceSkills.find(s => s.name === name)) && ( +
+ Linked (not in current workspace): {linkedSkills.filter(name => !workspaceSkills.find(s => s.name === name)).join(', ')} +
+ )} +
+