diff --git a/packages/adapter-utils/src/types.ts b/packages/adapter-utils/src/types.ts index ce89e0e80b..345583e4c2 100644 --- a/packages/adapter-utils/src/types.ts +++ b/packages/adapter-utils/src/types.ts @@ -271,7 +271,7 @@ export interface ServerAdapterModule { sessionManagement?: import("./session-compaction.js").AdapterSessionManagement; supportsLocalAgentJwt?: boolean; models?: AdapterModel[]; - listModels?: () => Promise; + listModels?: (opts?: { command?: string }) => Promise; agentConfigurationDoc?: string; /** * Optional lifecycle hook when an agent is approved/hired (join-request or hire_agent approval). diff --git a/packages/adapters/opencode-local/src/server/models.test.ts b/packages/adapters/opencode-local/src/server/models.test.ts index cd49e4a274..6d12fe9629 100644 --- a/packages/adapters/opencode-local/src/server/models.test.ts +++ b/packages/adapters/opencode-local/src/server/models.test.ts @@ -16,6 +16,12 @@ describe("openCode models", () => { await expect(listOpenCodeModels()).resolves.toEqual([]); }); + it("uses command option when provided", async () => { + await expect( + listOpenCodeModels({ command: "__paperclip_missing_opencode_command__" }), + ).resolves.toEqual([]); + }); + it("rejects when model is missing", async () => { await expect( ensureOpenCodeModelConfiguredAndAvailable({ model: "" }), diff --git a/packages/adapters/opencode-local/src/server/models.ts b/packages/adapters/opencode-local/src/server/models.ts index 95cb1fc943..fc64393875 100644 --- a/packages/adapters/opencode-local/src/server/models.ts +++ b/packages/adapters/opencode-local/src/server/models.ts @@ -197,9 +197,9 @@ export async function ensureOpenCodeModelConfiguredAndAvailable(input: { return models; } -export async function listOpenCodeModels(): Promise { +export async function listOpenCodeModels(opts?: { command?: string }): Promise { try { - return await discoverOpenCodeModelsCached(); + return await discoverOpenCodeModelsCached({ command: opts?.command }); } catch { return []; } diff --git a/server/src/adapters/registry.ts b/server/src/adapters/registry.ts index 67a8e95ba2..041ab860f8 100644 --- a/server/src/adapters/registry.ts +++ b/server/src/adapters/registry.ts @@ -205,11 +205,11 @@ export function getServerAdapter(type: string): ServerAdapterModule { return adapter; } -export async function listAdapterModels(type: string): Promise<{ id: string; label: string }[]> { +export async function listAdapterModels(type: string, opts?: { command?: string }): Promise<{ id: string; label: string }[]> { const adapter = adaptersByType.get(type); if (!adapter) return []; if (adapter.listModels) { - const discovered = await adapter.listModels(); + const discovered = await adapter.listModels(opts); if (discovered.length > 0) return discovered; } return adapter.models ?? []; diff --git a/server/src/routes/agents.ts b/server/src/routes/agents.ts index f642eb10f5..5b5d926782 100644 --- a/server/src/routes/agents.ts +++ b/server/src/routes/agents.ts @@ -667,7 +667,14 @@ export function agentRoutes(db: Db) { const companyId = req.params.companyId as string; assertCompanyAccess(req, companyId); const type = req.params.type as string; - const models = await listAdapterModels(type); + const command = typeof req.query.command === "string" ? req.query.command : undefined; + if (command !== undefined) { + // Reject shell metacharacters and empty/whitespace-only values + if (!command.trim() || /[;&|`$(){}[\]!#~]/.test(command)) { + return res.status(400).json({ error: "Invalid command" }); + } + } + const models = await listAdapterModels(type, command ? { command } : undefined); res.json(models); }); diff --git a/ui/src/api/agents.ts b/ui/src/api/agents.ts index ccaf15c0cc..dd0eb6d4fc 100644 --- a/ui/src/api/agents.ts +++ b/ui/src/api/agents.ts @@ -155,10 +155,13 @@ export const agentsApi = { api.get(agentPath(id, companyId, "/task-sessions")), resetSession: (id: string, taskKey?: string | null, companyId?: string) => api.post(agentPath(id, companyId, "/runtime-state/reset-session"), { taskKey: taskKey ?? null }), - adapterModels: (companyId: string, type: string) => - api.get( - `/companies/${encodeURIComponent(companyId)}/adapters/${encodeURIComponent(type)}/models`, - ), + adapterModels: (companyId: string, type: string, opts?: { command?: string }) => { + const base = `/companies/${encodeURIComponent(companyId)}/adapters/${encodeURIComponent(type)}/models`; + const params = new URLSearchParams(); + if (opts?.command) params.set("command", opts.command); + const qs = params.toString(); + return api.get(qs ? `${base}?${qs}` : base); + }, testEnvironment: ( companyId: string, type: string, diff --git a/ui/src/components/AgentConfigForm.tsx b/ui/src/components/AgentConfigForm.tsx index 1810e9a84e..46d3016c8a 100644 --- a/ui/src/components/AgentConfigForm.tsx +++ b/ui/src/components/AgentConfigForm.tsx @@ -303,16 +303,31 @@ export function AgentConfigForm(props: AgentConfigFormProps) { isLocal && shouldShowLegacyWorkingDirectoryField({ isCreate, adapterConfig: config }); const uiAdapter = useMemo(() => getUIAdapter(adapterType), [adapterType]); - // Fetch adapter models for the effective adapter type + // Resolve the current command value from form state (create or edit overlay) + const currentCommand = isCreate + ? props.values.command + : eff("adapterConfig", "command", String(config.command ?? "")); + + // Debounce command so every keystroke doesn't trigger a new fetch (server-side child process) + const [debouncedCommand, setDebouncedCommand] = useState(currentCommand); + useEffect(() => { + const timer = setTimeout(() => setDebouncedCommand(currentCommand), 800); + return () => clearTimeout(timer); + }, [currentCommand]); + + // Fetch adapter models for the effective adapter type, passing command so + // adapters like opencode can discover models from the configured binary. const { data: fetchedModels, error: fetchedModelsError, } = useQuery({ queryKey: selectedCompanyId - ? queryKeys.agents.adapterModels(selectedCompanyId, adapterType) - : ["agents", "none", "adapter-models", adapterType], - queryFn: () => agentsApi.adapterModels(selectedCompanyId!, adapterType), + ? queryKeys.agents.adapterModels(selectedCompanyId, adapterType, debouncedCommand || undefined) + : ["agents", "none", "adapter-models", adapterType, debouncedCommand || ""], + queryFn: () => + agentsApi.adapterModels(selectedCompanyId!, adapterType, debouncedCommand ? { command: debouncedCommand } : undefined), enabled: Boolean(selectedCompanyId), + staleTime: 60_000, }); const models = fetchedModels ?? externalModels ?? []; diff --git a/ui/src/components/NewIssueDialog.tsx b/ui/src/components/NewIssueDialog.tsx index 8db828ecd7..647ee7cc39 100644 --- a/ui/src/components/NewIssueDialog.tsx +++ b/ui/src/components/NewIssueDialog.tsx @@ -354,7 +354,9 @@ export function NewIssueDialog() { const selectedAssigneeAgentId = selectedAssignee.assigneeAgentId; const selectedAssigneeUserId = selectedAssignee.assigneeUserId; - const assigneeAdapterType = (agents ?? []).find((agent) => agent.id === selectedAssigneeAgentId)?.adapterType ?? null; + const assigneeAgent = (agents ?? []).find((agent) => agent.id === selectedAssigneeAgentId); + const assigneeAdapterType = assigneeAgent?.adapterType ?? null; + const assigneeCommand = typeof assigneeAgent?.adapterConfig?.command === "string" ? assigneeAgent.adapterConfig.command : undefined; const supportsAssigneeOverrides = Boolean( assigneeAdapterType && ISSUE_OVERRIDE_ADAPTER_TYPES.has(assigneeAdapterType), ); @@ -387,10 +389,12 @@ export function NewIssueDialog() { const { data: assigneeAdapterModels } = useQuery({ queryKey: effectiveCompanyId && assigneeAdapterType - ? queryKeys.agents.adapterModels(effectiveCompanyId, assigneeAdapterType) - : ["agents", "none", "adapter-models", assigneeAdapterType ?? "none"], - queryFn: () => agentsApi.adapterModels(effectiveCompanyId!, assigneeAdapterType!), + ? queryKeys.agents.adapterModels(effectiveCompanyId, assigneeAdapterType, assigneeCommand) + : ["agents", "none", "adapter-models", assigneeAdapterType ?? "none", assigneeCommand ?? ""], + queryFn: () => + agentsApi.adapterModels(effectiveCompanyId!, assigneeAdapterType!, assigneeCommand ? { command: assigneeCommand } : undefined), enabled: Boolean(effectiveCompanyId) && newIssueOpen && supportsAssigneeOverrides, + staleTime: 60_000, }); const createIssue = useMutation({ diff --git a/ui/src/components/OnboardingWizard.tsx b/ui/src/components/OnboardingWizard.tsx index cd28af9fde..940f896922 100644 --- a/ui/src/components/OnboardingWizard.tsx +++ b/ui/src/components/OnboardingWizard.tsx @@ -199,10 +199,12 @@ export function OnboardingWizard() { isFetching: adapterModelsFetching } = useQuery({ queryKey: createdCompanyId - ? queryKeys.agents.adapterModels(createdCompanyId, adapterType) - : ["agents", "none", "adapter-models", adapterType], - queryFn: () => agentsApi.adapterModels(createdCompanyId!, adapterType), - enabled: Boolean(createdCompanyId) && effectiveOnboardingOpen && step === 2 + ? queryKeys.agents.adapterModels(createdCompanyId, adapterType, command || undefined) + : ["agents", "none", "adapter-models", adapterType, command || ""], + queryFn: () => + agentsApi.adapterModels(createdCompanyId!, adapterType, command ? { command } : undefined), + enabled: Boolean(createdCompanyId) && effectiveOnboardingOpen && step === 2, + staleTime: 60_000, }); const isLocalAdapter = adapterType === "claude_local" || diff --git a/ui/src/lib/queryKeys.ts b/ui/src/lib/queryKeys.ts index ecee1a2026..e1026a8a23 100644 --- a/ui/src/lib/queryKeys.ts +++ b/ui/src/lib/queryKeys.ts @@ -23,8 +23,8 @@ export const queryKeys = { ["agents", "instructions-bundle", id, "file", relativePath] as const, keys: (agentId: string) => ["agents", "keys", agentId] as const, configRevisions: (agentId: string) => ["agents", "config-revisions", agentId] as const, - adapterModels: (companyId: string, adapterType: string) => - ["agents", companyId, "adapter-models", adapterType] as const, + adapterModels: (companyId: string, adapterType: string, command?: string) => + ["agents", companyId, "adapter-models", adapterType, command ?? ""] as const, }, issues: { list: (companyId: string) => ["issues", companyId] as const, diff --git a/ui/src/pages/AgentDetail.tsx b/ui/src/pages/AgentDetail.tsx index c0bed88686..2c08dc7d42 100644 --- a/ui/src/pages/AgentDetail.tsx +++ b/ui/src/pages/AgentDetail.tsx @@ -1428,13 +1428,16 @@ function ConfigurationTab({ const [awaitingRefreshAfterSave, setAwaitingRefreshAfterSave] = useState(false); const lastAgentRef = useRef(agent); + const savedCommand = typeof agent.adapterConfig?.command === "string" ? agent.adapterConfig.command : undefined; const { data: adapterModels } = useQuery({ queryKey: companyId - ? queryKeys.agents.adapterModels(companyId, agent.adapterType) - : ["agents", "none", "adapter-models", agent.adapterType], - queryFn: () => agentsApi.adapterModels(companyId!, agent.adapterType), + ? queryKeys.agents.adapterModels(companyId, agent.adapterType, savedCommand) + : ["agents", "none", "adapter-models", agent.adapterType, savedCommand ?? ""], + queryFn: () => + agentsApi.adapterModels(companyId!, agent.adapterType, savedCommand ? { command: savedCommand } : undefined), enabled: Boolean(companyId), + staleTime: 60_000, }); const updateAgent = useMutation({ diff --git a/ui/src/pages/NewAgent.tsx b/ui/src/pages/NewAgent.tsx index b8787be2fb..ef5b48159c 100644 --- a/ui/src/pages/NewAgent.tsx +++ b/ui/src/pages/NewAgent.tsx @@ -87,10 +87,12 @@ export function NewAgent() { isFetching: adapterModelsFetching, } = useQuery({ queryKey: selectedCompanyId - ? queryKeys.agents.adapterModels(selectedCompanyId, configValues.adapterType) - : ["agents", "none", "adapter-models", configValues.adapterType], - queryFn: () => agentsApi.adapterModels(selectedCompanyId!, configValues.adapterType), + ? queryKeys.agents.adapterModels(selectedCompanyId, configValues.adapterType, configValues.command || undefined) + : ["agents", "none", "adapter-models", configValues.adapterType, configValues.command || ""], + queryFn: () => + agentsApi.adapterModels(selectedCompanyId!, configValues.adapterType, configValues.command ? { command: configValues.command } : undefined), enabled: Boolean(selectedCompanyId), + staleTime: 60_000, }); const { data: companySkills } = useQuery({