Add run-level auto permission mode

- add LLM-based auto permission classifier for permission-gated tool calls
- store run-level permission mode and auto permission decision events
- auto-approve low-risk calls, and bubble auto-denied calls to manual approval
- show auto-denied reasons in chat and auto-approved labels below tool cards
- add BYOK setting for the auto-permission decision model
This commit is contained in:
Ramnique Singh 2026-06-03 07:57:50 +05:30
parent 8a8b78071d
commit d47cab6a0f
15 changed files with 641 additions and 85 deletions

View file

@ -11,7 +11,7 @@ import { MarkdownEditor, type MarkdownEditorHandle } from './components/markdown
import { ChatSidebar } from './components/chat-sidebar'; import { ChatSidebar } from './components/chat-sidebar';
import { ChatHeader } from './components/chat-header'; import { ChatHeader } from './components/chat-header';
import { ChatEmptyState } from './components/chat-empty-state'; import { ChatEmptyState } from './components/chat-empty-state';
import { ChatInputWithMentions, type StagedAttachment } from './components/chat-input-with-mentions'; import { ChatInputWithMentions, type PermissionMode, type StagedAttachment } from './components/chat-input-with-mentions';
import { ChatMessageAttachments } from '@/components/chat-message-attachments' import { ChatMessageAttachments } from '@/components/chat-message-attachments'
import { GraphView, type GraphEdge, type GraphNode } from '@/components/graph-view'; import { GraphView, type GraphEdge, type GraphNode } from '@/components/graph-view';
import { BasesView, type BaseConfig, DEFAULT_BASE_CONFIG } from '@/components/bases-view'; import { BasesView, type BaseConfig, DEFAULT_BASE_CONFIG } from '@/components/bases-view';
@ -56,9 +56,10 @@ import { WebSearchResult } from '@/components/ai-elements/web-search-result';
import { AppActionCard } from '@/components/ai-elements/app-action-card'; import { AppActionCard } from '@/components/ai-elements/app-action-card';
import { ComposioConnectCard } from '@/components/ai-elements/composio-connect-card'; import { ComposioConnectCard } from '@/components/ai-elements/composio-connect-card';
import { PermissionRequest } from '@/components/ai-elements/permission-request'; import { PermissionRequest } from '@/components/ai-elements/permission-request';
import { AutoPermissionDecision } from '@/components/ai-elements/auto-permission-decision';
import { TerminalOutput } from '@/components/terminal-output'; import { TerminalOutput } from '@/components/terminal-output';
import { AskHumanRequest } from '@/components/ai-elements/ask-human-request'; import { AskHumanRequest } from '@/components/ai-elements/ask-human-request';
import { ToolPermissionRequestEvent, AskHumanRequestEvent } from '@x/shared/src/runs.js'; import { ToolPermissionAutoDecisionEvent, ToolPermissionRequestEvent, AskHumanRequestEvent } from '@x/shared/src/runs.js';
import { import {
SidebarInset, SidebarInset,
SidebarProvider, SidebarProvider,
@ -961,7 +962,7 @@ function App() {
voice.start() voice.start()
}, [voice]) }, [voice])
const handlePromptSubmitRef = useRef<((message: PromptInputMessage, mentions?: FileMention[], stagedAttachments?: StagedAttachment[], searchEnabled?: boolean, codeMode?: 'claude' | 'codex') => Promise<void>) | null>(null) const handlePromptSubmitRef = useRef<((message: PromptInputMessage, mentions?: FileMention[], stagedAttachments?: StagedAttachment[], searchEnabled?: boolean, codeMode?: 'claude' | 'codex', permissionMode?: PermissionMode) => Promise<void>) | null>(null)
const pendingVoiceInputRef = useRef(false) const pendingVoiceInputRef = useRef(false)
// Palette: per-tab editor handles for capturing cursor context on Cmd+K, and pending payload // Palette: per-tab editor handles for capturing cursor context on Cmd+K, and pending payload
@ -1180,6 +1181,7 @@ function App() {
const [allPermissionRequests, setAllPermissionRequests] = useState<Map<string, z.infer<typeof ToolPermissionRequestEvent>>>(new Map()) const [allPermissionRequests, setAllPermissionRequests] = useState<Map<string, z.infer<typeof ToolPermissionRequestEvent>>>(new Map())
// Track permission responses (toolCallId -> response) // Track permission responses (toolCallId -> response)
const [permissionResponses, setPermissionResponses] = useState<Map<string, 'approve' | 'deny'>>(new Map()) const [permissionResponses, setPermissionResponses] = useState<Map<string, 'approve' | 'deny'>>(new Map())
const [autoPermissionDecisions, setAutoPermissionDecisions] = useState<Map<string, z.infer<typeof ToolPermissionAutoDecisionEvent>>>(new Map())
useEffect(() => { useEffect(() => {
chatViewStateByTabRef.current = chatViewStateByTab chatViewStateByTabRef.current = chatViewStateByTab
@ -1193,6 +1195,7 @@ function App() {
pendingAskHumanRequests: new Map(pendingAskHumanRequests), pendingAskHumanRequests: new Map(pendingAskHumanRequests),
allPermissionRequests: new Map(allPermissionRequests), allPermissionRequests: new Map(allPermissionRequests),
permissionResponses: new Map(permissionResponses), permissionResponses: new Map(permissionResponses),
autoPermissionDecisions: new Map(autoPermissionDecisions),
} }
setChatViewStateByTab((prev) => ({ ...prev, [activeChatTabId]: snapshot })) setChatViewStateByTab((prev) => ({ ...prev, [activeChatTabId]: snapshot }))
}, [ }, [
@ -1203,6 +1206,7 @@ function App() {
pendingAskHumanRequests, pendingAskHumanRequests,
allPermissionRequests, allPermissionRequests,
permissionResponses, permissionResponses,
autoPermissionDecisions,
]) ])
useEffect(() => { useEffect(() => {
@ -2026,6 +2030,7 @@ function App() {
// Track permission requests and responses from history // Track permission requests and responses from history
const allPermissionRequests = new Map<string, z.infer<typeof ToolPermissionRequestEvent>>() const allPermissionRequests = new Map<string, z.infer<typeof ToolPermissionRequestEvent>>()
const permResponseMap = new Map<string, 'approve' | 'deny'>() const permResponseMap = new Map<string, 'approve' | 'deny'>()
const autoPermissionDecisions = new Map<string, z.infer<typeof ToolPermissionAutoDecisionEvent>>()
const askHumanRequests = new Map<string, z.infer<typeof AskHumanRequestEvent>>() const askHumanRequests = new Map<string, z.infer<typeof AskHumanRequestEvent>>()
const respondedAskHumanIds = new Set<string>() const respondedAskHumanIds = new Set<string>()
@ -2034,6 +2039,8 @@ function App() {
allPermissionRequests.set(event.toolCall.toolCallId, event) allPermissionRequests.set(event.toolCall.toolCallId, event)
} else if (event.type === 'tool-permission-response') { } else if (event.type === 'tool-permission-response') {
permResponseMap.set(event.toolCallId, event.response) permResponseMap.set(event.toolCallId, event.response)
} else if (event.type === 'tool-permission-auto-decision') {
autoPermissionDecisions.set(event.toolCallId, event)
} else if (event.type === 'ask-human-request') { } else if (event.type === 'ask-human-request') {
askHumanRequests.set(event.toolCallId, event) askHumanRequests.set(event.toolCallId, event)
} else if (event.type === 'ask-human-response') { } else if (event.type === 'ask-human-response') {
@ -2066,6 +2073,7 @@ function App() {
setPendingAskHumanRequests(pendingAsks) setPendingAskHumanRequests(pendingAsks)
setAllPermissionRequests(allPermissionRequests) setAllPermissionRequests(allPermissionRequests)
setPermissionResponses(permResponseMap) setPermissionResponses(permResponseMap)
setAutoPermissionDecisions(autoPermissionDecisions)
// Restore the run's per-chat work directory into the tab it was loaded into. // Restore the run's per-chat work directory into the tab it was loaded into.
const tabId = activeChatTabIdRef.current const tabId = activeChatTabIdRef.current
@ -2375,6 +2383,16 @@ function App() {
break break
} }
case 'tool-permission-auto-decision': {
if (!isActiveRun) return
setAutoPermissionDecisions(prev => {
const next = new Map(prev)
next.set(event.toolCallId, event)
return next
})
break
}
case 'ask-human-request': { case 'ask-human-request': {
if (!isActiveRun) return if (!isActiveRun) return
const key = event.toolCallId const key = event.toolCallId
@ -2491,6 +2509,7 @@ function App() {
stagedAttachments: StagedAttachment[] = [], stagedAttachments: StagedAttachment[] = [],
searchEnabled?: boolean, searchEnabled?: boolean,
codeMode?: 'claude' | 'codex', codeMode?: 'claude' | 'codex',
permissionMode?: PermissionMode,
) => { ) => {
if (isProcessing) return if (isProcessing) return
@ -2530,6 +2549,7 @@ function App() {
const run = await window.ipc.invoke('runs:create', { const run = await window.ipc.invoke('runs:create', {
agentId, agentId,
...(selected ? { model: selected.model, provider: selected.provider } : {}), ...(selected ? { model: selected.model, provider: selected.provider } : {}),
permissionMode: permissionMode ?? 'manual',
}) })
currentRunId = run.id currentRunId = run.id
newRunCreatedAt = run.createdAt newRunCreatedAt = run.createdAt
@ -2734,6 +2754,7 @@ function App() {
setPendingAskHumanRequests(new Map()) setPendingAskHumanRequests(new Map())
setAllPermissionRequests(new Map()) setAllPermissionRequests(new Map())
setPermissionResponses(new Map()) setPermissionResponses(new Map())
setAutoPermissionDecisions(new Map())
setSelectedBackgroundTask(null) setSelectedBackgroundTask(null)
setChatViewportAnchor(activeChatTabIdRef.current, null) setChatViewportAnchor(activeChatTabIdRef.current, null)
setChatViewStateByTab(prev => ({ setChatViewStateByTab(prev => ({
@ -2760,6 +2781,7 @@ function App() {
setPendingAskHumanRequests(new Map()) setPendingAskHumanRequests(new Map())
setAllPermissionRequests(new Map()) setAllPermissionRequests(new Map())
setPermissionResponses(new Map()) setPermissionResponses(new Map())
setAutoPermissionDecisions(new Map())
setChatViewportAnchor(tab.id, null) setChatViewportAnchor(tab.id, null)
} }
}, [loadRun, setChatViewportAnchor]) }, [loadRun, setChatViewportAnchor])
@ -2785,6 +2807,7 @@ function App() {
setPendingAskHumanRequests(new Map(cached.pendingAskHumanRequests)) setPendingAskHumanRequests(new Map(cached.pendingAskHumanRequests))
setAllPermissionRequests(new Map(cached.allPermissionRequests)) setAllPermissionRequests(new Map(cached.allPermissionRequests))
setPermissionResponses(new Map(cached.permissionResponses)) setPermissionResponses(new Map(cached.permissionResponses))
setAutoPermissionDecisions(new Map(cached.autoPermissionDecisions))
setIsProcessing(Boolean(resolvedRunId && processingRunIdsRef.current.has(resolvedRunId))) setIsProcessing(Boolean(resolvedRunId && processingRunIdsRef.current.has(resolvedRunId)))
return true return true
}, []) }, [])
@ -5057,7 +5080,11 @@ function App() {
} }
}, [isGraphOpen, knowledgeFilePaths]) }, [isGraphOpen, knowledgeFilePaths])
const renderConversationItem = (item: ConversationItem, tabId: string) => { const renderConversationItem = (
item: ConversationItem,
tabId: string,
options?: { autoPermissionDetail?: { decision: 'allow'; reason: string } },
) => {
if (isChatMessage(item)) { if (isChatMessage(item)) {
if (item.role === 'user') { if (item.role === 'user') {
if (item.attachments && item.attachments.length > 0) { if (item.attachments && item.attachments.length > 0) {
@ -5155,6 +5182,7 @@ function App() {
key={item.id} key={item.id}
open={isToolOpenForTab(tabId, item.id)} open={isToolOpenForTab(tabId, item.id)}
onOpenChange={(open) => setToolOpenForTab(tabId, item.id, open)} onOpenChange={(open) => setToolOpenForTab(tabId, item.id, open)}
autoPermissionDetail={options?.autoPermissionDetail}
> >
<ToolHeader <ToolHeader
title={toolTitle} title={toolTitle}
@ -5197,6 +5225,7 @@ function App() {
pendingAskHumanRequests, pendingAskHumanRequests,
allPermissionRequests, allPermissionRequests,
permissionResponses, permissionResponses,
autoPermissionDecisions,
}), [ }), [
runId, runId,
conversation, conversation,
@ -5204,6 +5233,7 @@ function App() {
pendingAskHumanRequests, pendingAskHumanRequests,
allPermissionRequests, allPermissionRequests,
permissionResponses, permissionResponses,
autoPermissionDecisions,
]) ])
const emptyChatTabState = React.useMemo<ChatTabViewState>(() => createEmptyChatTabViewState(), []) const emptyChatTabState = React.useMemo<ChatTabViewState>(() => createEmptyChatTabViewState(), [])
const getChatTabStateForRender = useCallback((tabId: string): ChatTabViewState => { const getChatTabStateForRender = useCallback((tabId: string): ChatTabViewState => {
@ -5790,7 +5820,7 @@ function App() {
<> <>
{groupConversationItems( {groupConversationItems(
tabState.conversation, tabState.conversation,
(id) => !!tabState.allPermissionRequests.get(id) (id) => !!tabState.allPermissionRequests.get(id) || !!tabState.autoPermissionDecisions.get(id)
).map(item => { ).map(item => {
if (isToolGroup(item)) { if (isToolGroup(item)) {
return ( return (
@ -5802,41 +5832,61 @@ function App() {
/> />
) )
} }
const rendered = renderConversationItem(item, tab.id) const autoDecision = isToolCall(item)
? tabState.autoPermissionDecisions.get(item.id)
: undefined
const rendered = renderConversationItem(
item,
tab.id,
autoDecision?.decision === 'allow'
? { autoPermissionDetail: { decision: 'allow', reason: autoDecision.reason } }
: undefined,
)
if (isToolCall(item)) { if (isToolCall(item)) {
const deniedAutoDecision = autoDecision?.decision === 'deny' ? autoDecision : null
const permRequest = tabState.allPermissionRequests.get(item.id) const permRequest = tabState.allPermissionRequests.get(item.id)
if (permRequest) { if (deniedAutoDecision || permRequest) {
const response = tabState.permissionResponses.get(item.id) || null const response = tabState.permissionResponses.get(item.id) || null
return ( return (
<React.Fragment key={item.id}> <React.Fragment key={item.id}>
<PermissionRequest {deniedAutoDecision && (
toolCall={permRequest.toolCall} <AutoPermissionDecision
permission={permRequest.permission} toolCall={deniedAutoDecision.toolCall}
onApprove={() => handlePermissionResponse(permRequest.toolCall.toolCallId, permRequest.subflow, 'approve')} permission={deniedAutoDecision.permission}
onApproveSession={() => handlePermissionResponse(permRequest.toolCall.toolCallId, permRequest.subflow, 'approve', 'session')} decision={deniedAutoDecision.decision}
onApproveAlways={() => handlePermissionResponse(permRequest.toolCall.toolCallId, permRequest.subflow, 'approve', 'always')} reason={deniedAutoDecision.reason}
onDeny={() => handlePermissionResponse(permRequest.toolCall.toolCallId, permRequest.subflow, 'deny')} />
onSwitchAgent={async (newAgent) => { )}
const runIdForSwitch = tab.runId {permRequest && (
await handlePermissionResponse(permRequest.toolCall.toolCallId, permRequest.subflow, 'deny') <PermissionRequest
window.dispatchEvent(new CustomEvent('code-mode-detected', { toolCall={permRequest.toolCall}
detail: { runId: runIdForSwitch, agent: newAgent }, permission={permRequest.permission}
})) onApprove={() => handlePermissionResponse(permRequest.toolCall.toolCallId, permRequest.subflow, 'approve')}
if (runIdForSwitch) { onApproveSession={() => handlePermissionResponse(permRequest.toolCall.toolCallId, permRequest.subflow, 'approve', 'session')}
try { onApproveAlways={() => handlePermissionResponse(permRequest.toolCall.toolCallId, permRequest.subflow, 'approve', 'always')}
await window.ipc.invoke('runs:createMessage', { onDeny={() => handlePermissionResponse(permRequest.toolCall.toolCallId, permRequest.subflow, 'deny')}
runId: runIdForSwitch, onSwitchAgent={async (newAgent) => {
message: `Use ${newAgent === 'claude' ? 'Claude Code' : 'Codex'} instead — rerun the same task with the same prompt, just swap the agent binary to \`${newAgent}\`.`, const runIdForSwitch = tab.runId
codeMode: newAgent, await handlePermissionResponse(permRequest.toolCall.toolCallId, permRequest.subflow, 'deny')
}) window.dispatchEvent(new CustomEvent('code-mode-detected', {
} catch (err) { detail: { runId: runIdForSwitch, agent: newAgent },
console.error('Failed to send swap-agent follow-up', err) }))
if (runIdForSwitch) {
try {
await window.ipc.invoke('runs:createMessage', {
runId: runIdForSwitch,
message: `Use ${newAgent === 'claude' ? 'Claude Code' : 'Codex'} instead — rerun the same task with the same prompt, just swap the agent binary to \`${newAgent}\`.`,
codeMode: newAgent,
})
} catch (err) {
console.error('Failed to send swap-agent follow-up', err)
}
} }
} }}
}} isProcessing={isActive && isProcessing}
isProcessing={isActive && isProcessing} response={response}
response={response} />
/> )}
{rendered} {rendered}
</React.Fragment> </React.Fragment>
) )
@ -5989,6 +6039,7 @@ function App() {
pendingAskHumanRequests={pendingAskHumanRequests} pendingAskHumanRequests={pendingAskHumanRequests}
allPermissionRequests={allPermissionRequests} allPermissionRequests={allPermissionRequests}
permissionResponses={permissionResponses} permissionResponses={permissionResponses}
autoPermissionDecisions={autoPermissionDecisions}
onPermissionResponse={handlePermissionResponse} onPermissionResponse={handlePermissionResponse}
onAskHumanResponse={handleAskHumanResponse} onAskHumanResponse={handleAskHumanResponse}
isToolOpenForTab={isToolOpenForTab} isToolOpenForTab={isToolOpenForTab}

View file

@ -0,0 +1,100 @@
"use client";
import { Badge } from "@/components/ui/badge";
import { cn } from "@/lib/utils";
import { CheckCircle2Icon, ShieldAlertIcon, Terminal } from "lucide-react";
import type { ComponentProps } from "react";
import { ToolCallPart } from "@x/shared/dist/message.js";
import { ToolPermissionMetadata } from "@x/shared/dist/runs.js";
import z from "zod";
export type AutoPermissionDecisionProps = ComponentProps<"div"> & {
toolCall: z.infer<typeof ToolCallPart>;
decision: "allow" | "deny";
reason: string;
permission?: z.infer<typeof ToolPermissionMetadata>;
};
const fileActionLabels: Record<string, string> = {
read: "Read file",
list: "List folder",
search: "Search files",
write: "Write files",
delete: "Delete path",
};
export function AutoPermissionDecision({
className,
toolCall,
decision,
reason,
permission,
...props
}: AutoPermissionDecisionProps) {
const command = permission?.kind === "command" || toolCall.toolName === "executeCommand"
? (typeof toolCall.arguments === "object" && toolCall.arguments !== null && "command" in toolCall.arguments
? String(toolCall.arguments.command)
: JSON.stringify(toolCall.arguments))
: null;
const filePermission = permission?.kind === "file" ? permission : null;
const allowed = decision === "allow";
return (
<div
className={cn(
"not-prose mb-4 w-full rounded-md border",
allowed
? "border-green-500/50 bg-green-50/80 dark:border-green-500/35 dark:bg-green-950/30"
: "border-[#fa2525]/60 bg-[#fa2525]/15 dark:border-[#fa2525]/50 dark:bg-[#fa2525]/20",
className,
)}
{...props}
>
<div className="space-y-3 p-4">
<div className="flex items-start gap-3">
{allowed ? (
<CheckCircle2Icon className="mt-0.5 size-5 shrink-0 text-green-600 dark:text-green-400" />
) : (
<ShieldAlertIcon className="mt-0.5 size-5 shrink-0 text-destructive" />
)}
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<h3 className="text-sm font-semibold text-foreground">
{allowed ? "Auto Allowed" : "Auto Denied"}
</h3>
<Badge variant="secondary" className="bg-secondary text-foreground">
<Terminal className="mr-1 size-3" />
{toolCall.toolName}
</Badge>
</div>
<p className="mt-1 text-sm text-muted-foreground">{reason}</p>
</div>
</div>
{command && (
<div className="rounded-md border bg-background/50 p-3">
<p className="mb-1.5 text-xs font-medium uppercase tracking-wide text-muted-foreground">Command</p>
<pre className="whitespace-pre-wrap break-all font-mono text-xs text-foreground">{command}</pre>
</div>
)}
{filePermission && (
<div className="space-y-3 rounded-md border bg-background/50 p-3">
<div>
<p className="mb-1.5 text-xs font-medium uppercase tracking-wide text-muted-foreground">Action</p>
<p className="text-xs font-medium text-foreground">
{fileActionLabels[filePermission.operation] ?? filePermission.operation}
</p>
</div>
<div>
<p className="mb-1.5 text-xs font-medium uppercase tracking-wide text-muted-foreground">
Path{filePermission.paths.length === 1 ? "" : "s"}
</p>
<pre className="whitespace-pre-wrap break-all font-mono text-xs text-foreground">
{filePermission.paths.join("\n")}
</pre>
</div>
</div>
)}
</div>
</div>
);
}

View file

@ -5,12 +5,18 @@ import {
CollapsibleContent, CollapsibleContent,
CollapsibleTrigger, CollapsibleTrigger,
} from "@/components/ui/collapsible"; } from "@/components/ui/collapsible";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import type { ToolUIPart } from "ai"; import type { ToolUIPart } from "ai";
import { import {
ChevronDownIcon, ChevronDownIcon,
CircleCheck, CircleCheck,
LoaderIcon, LoaderIcon,
ShieldCheckIcon,
XCircleIcon, XCircleIcon,
} from "lucide-react"; } from "lucide-react";
import { type ComponentProps, type ReactNode, isValidElement, useState } from "react"; import { type ComponentProps, type ReactNode, isValidElement, useState } from "react";
@ -45,17 +51,51 @@ const ToolCode = ({
</pre> </pre>
); );
export type ToolProps = ComponentProps<typeof Collapsible>; export type ToolAutoPermissionDetail = {
decision: "allow";
reason: string;
};
export const Tool = ({ className, ...props }: ToolProps) => ( export type ToolProps = ComponentProps<typeof Collapsible> & {
<Collapsible autoPermissionDetail?: ToolAutoPermissionDetail;
className={cn( };
"not-prose mb-4 w-full rounded-[28px] border bg-[var(--card-surface)] transition-colors duration-150 ease-out hover:border-foreground/30",
className export const Tool = ({ className, children, autoPermissionDetail, ...props }: ToolProps) => {
)} const toolCard = (
{...props} <Collapsible
/> className={cn(
); autoPermissionDetail
? "w-full rounded-[28px] border bg-[var(--card-surface)] transition-colors duration-150 ease-out hover:border-foreground/30"
: "not-prose mb-4 w-full rounded-[28px] border bg-[var(--card-surface)] transition-colors duration-150 ease-out hover:border-foreground/30",
className
)}
{...props}
>
{children}
</Collapsible>
);
if (!autoPermissionDetail) return toolCard;
return (
<div className="not-prose mb-4 w-full">
{toolCard}
<div className="mt-1 flex justify-end px-3">
<Tooltip>
<TooltipTrigger asChild>
<span className="inline-flex cursor-help items-center gap-1 text-[11px] text-muted-foreground/70">
<ShieldCheckIcon className="size-3 text-muted-foreground/70" />
Auto-approved
</span>
</TooltipTrigger>
<TooltipContent side="bottom" align="end" className="max-w-sm">
{autoPermissionDetail.reason}
</TooltipContent>
</Tooltip>
</div>
</div>
);
};
export type ToolHeaderProps = { export type ToolHeaderProps = {
title?: string; title?: string;

View file

@ -17,6 +17,7 @@ import {
LoaderIcon, LoaderIcon,
Mic, Mic,
Plus, Plus,
ShieldCheck,
Square, Square,
Terminal, Terminal,
X, X,
@ -85,6 +86,8 @@ export interface SelectedModel {
model: string model: string
} }
export type PermissionMode = 'manual' | 'auto'
function getSelectedModelDisplayName(model: string) { function getSelectedModelDisplayName(model: string) {
return model.split('/').pop() || model return model.split('/').pop() || model
} }
@ -109,7 +112,7 @@ function getAttachmentIcon(kind: AttachmentIconKind) {
} }
interface ChatInputInnerProps { interface ChatInputInnerProps {
onSubmit: (message: PromptInputMessage, mentions?: FileMention[], attachments?: StagedAttachment[], searchEnabled?: boolean, codeMode?: 'claude' | 'codex') => void onSubmit: (message: PromptInputMessage, mentions?: FileMention[], attachments?: StagedAttachment[], searchEnabled?: boolean, codeMode?: 'claude' | 'codex', permissionMode?: PermissionMode) => void
onStop?: () => void onStop?: () => void
isProcessing: boolean isProcessing: boolean
isStopping?: boolean isStopping?: boolean
@ -182,11 +185,13 @@ function ChatInputInner({
const [codingAgent, setCodingAgent] = useState<'claude' | 'codex'>('claude') const [codingAgent, setCodingAgent] = useState<'claude' | 'codex'>('claude')
const [codeModeEnabled, setCodeModeEnabled] = useState(false) const [codeModeEnabled, setCodeModeEnabled] = useState(false)
const [codeModeFeatureEnabled, setCodeModeFeatureEnabled] = useState(false) const [codeModeFeatureEnabled, setCodeModeFeatureEnabled] = useState(false)
const [permissionMode, setPermissionMode] = useState<PermissionMode>('auto')
// When a run exists, freeze the dropdown to the run's resolved model+provider. // When a run exists, freeze the dropdown to the run's resolved model+provider.
useEffect(() => { useEffect(() => {
if (!runId) { if (!runId) {
setLockedModel(null) setLockedModel(null)
setPermissionMode('auto')
return return
} }
let cancelled = false let cancelled = false
@ -195,6 +200,7 @@ function ChatInputInner({
if (run.provider && run.model) { if (run.provider && run.model) {
setLockedModel({ provider: run.provider, model: run.model }) setLockedModel({ provider: run.provider, model: run.model })
} }
setPermissionMode(run.permissionMode ?? 'manual')
}).catch(() => { /* legacy run or fetch failure — leave unlocked */ }) }).catch(() => { /* legacy run or fetch failure — leave unlocked */ })
return () => { cancelled = true } return () => { cancelled = true }
}, [runId]) }, [runId])
@ -482,13 +488,13 @@ function ChatInputInner({
if (!canSubmit) return if (!canSubmit) return
// codeMode is sticky per conversation — don't reset after send. // codeMode is sticky per conversation — don't reset after send.
const effectiveCodeMode = codeModeEnabled ? codingAgent : undefined const effectiveCodeMode = codeModeEnabled ? codingAgent : undefined
onSubmit({ text: message.trim(), files: [] }, controller.mentions.mentions, attachments, searchEnabled || undefined, effectiveCodeMode) onSubmit({ text: message.trim(), files: [] }, controller.mentions.mentions, attachments, searchEnabled || undefined, effectiveCodeMode, permissionMode)
controller.textInput.clear() controller.textInput.clear()
controller.mentions.clearMentions() controller.mentions.clearMentions()
setAttachments([]) setAttachments([])
// Web search toggle stays on for the rest of the chat session; the user // Web search toggle stays on for the rest of the chat session; the user
// turns it off explicitly. (Not persisted across app restarts.) // turns it off explicitly. (Not persisted across app restarts.)
}, [attachments, canSubmit, controller, message, onSubmit, searchEnabled, codeModeEnabled, codingAgent, workDir]) }, [attachments, canSubmit, controller, message, onSubmit, searchEnabled, codeModeEnabled, codingAgent, permissionMode, workDir])
const handleKeyDown = useCallback((e: React.KeyboardEvent) => { const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
if (e.key === 'Enter' && !e.shiftKey) { if (e.key === 'Enter' && !e.shiftKey) {
@ -709,6 +715,36 @@ function ChatInputInner({
</span> </span>
</button> </button>
)} )}
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
onClick={() => {
if (runId) return
setPermissionMode((mode) => mode === 'auto' ? 'manual' : 'auto')
}}
disabled={Boolean(runId)}
className={cn(
"flex h-7 shrink-0 items-center gap-1.5 rounded-full px-2.5 text-xs font-medium transition-colors",
permissionMode === 'auto'
? "bg-secondary text-foreground hover:bg-secondary/70"
: "text-muted-foreground hover:bg-muted hover:text-foreground",
runId && "cursor-not-allowed opacity-70 hover:bg-secondary"
)}
aria-label="Permission mode"
>
<ShieldCheck className="h-3.5 w-3.5" />
<span>{permissionMode === 'auto' ? 'Auto' : 'Manual'}</span>
</button>
</TooltipTrigger>
<TooltipContent side="top">
{runId
? `Permission mode is fixed for this run: ${permissionMode === 'auto' ? 'Auto' : 'Manual'}`
: permissionMode === 'auto'
? 'Auto-permission on — click for manual approval prompts'
: 'Manual approval prompts — click for auto-permission'}
</TooltipContent>
</Tooltip>
{codeModeFeatureEnabled && (codeModeEnabled ? ( {codeModeFeatureEnabled && (codeModeEnabled ? (
<div className="flex h-7 shrink-0 items-center rounded-full bg-secondary text-xs font-medium text-foreground"> <div className="flex h-7 shrink-0 items-center rounded-full bg-secondary text-xs font-medium text-foreground">
<Tooltip> <Tooltip>
@ -915,7 +951,7 @@ export interface ChatInputWithMentionsProps {
knowledgeFiles: string[] knowledgeFiles: string[]
recentFiles: string[] recentFiles: string[]
visibleFiles: string[] visibleFiles: string[]
onSubmit: (message: PromptInputMessage, mentions?: FileMention[], attachments?: StagedAttachment[], searchEnabled?: boolean, codeMode?: 'claude' | 'codex') => void onSubmit: (message: PromptInputMessage, mentions?: FileMention[], attachments?: StagedAttachment[], searchEnabled?: boolean, codeMode?: 'claude' | 'codex', permissionMode?: PermissionMode) => void
onStop?: () => void onStop?: () => void
isProcessing: boolean isProcessing: boolean
isStopping?: boolean isStopping?: boolean

View file

@ -28,6 +28,7 @@ import { Tool, ToolContent, ToolGroupComponent, ToolHeader, ToolTabbedContent }
import { WebSearchResult } from '@/components/ai-elements/web-search-result' import { WebSearchResult } from '@/components/ai-elements/web-search-result'
import { ComposioConnectCard } from '@/components/ai-elements/composio-connect-card' import { ComposioConnectCard } from '@/components/ai-elements/composio-connect-card'
import { PermissionRequest } from '@/components/ai-elements/permission-request' import { PermissionRequest } from '@/components/ai-elements/permission-request'
import { AutoPermissionDecision } from '@/components/ai-elements/auto-permission-decision'
import { TerminalOutput } from '@/components/terminal-output' import { TerminalOutput } from '@/components/terminal-output'
import { AskHumanRequest } from '@/components/ai-elements/ask-human-request' import { AskHumanRequest } from '@/components/ai-elements/ask-human-request'
import { type PromptInputMessage, type FileMention } from '@/components/ai-elements/prompt-input' import { type PromptInputMessage, type FileMention } from '@/components/ai-elements/prompt-input'
@ -36,7 +37,7 @@ import { MarkdownPreOverride } from '@/components/ai-elements/markdown-code-over
import { defaultRemarkPlugins } from 'streamdown' import { defaultRemarkPlugins } from 'streamdown'
import remarkBreaks from 'remark-breaks' import remarkBreaks from 'remark-breaks'
import { type ChatTab } from '@/components/tab-bar' import { type ChatTab } from '@/components/tab-bar'
import { ChatInputWithMentions, type StagedAttachment, type SelectedModel } from '@/components/chat-input-with-mentions' import { ChatInputWithMentions, type PermissionMode, type StagedAttachment, type SelectedModel } from '@/components/chat-input-with-mentions'
import { ChatMessageAttachments } from '@/components/chat-message-attachments' import { ChatMessageAttachments } from '@/components/chat-message-attachments'
import { useSidebar } from '@/components/ui/sidebar' import { useSidebar } from '@/components/ui/sidebar'
import { wikiLabel } from '@/lib/wiki-links' import { wikiLabel } from '@/lib/wiki-links'
@ -139,7 +140,7 @@ interface ChatSidebarProps {
isProcessing: boolean isProcessing: boolean
isStopping?: boolean isStopping?: boolean
onStop?: () => void onStop?: () => void
onSubmit: (message: PromptInputMessage, mentions?: FileMention[], attachments?: StagedAttachment[]) => void onSubmit: (message: PromptInputMessage, mentions?: FileMention[], attachments?: StagedAttachment[], searchEnabled?: boolean, codeMode?: 'claude' | 'codex', permissionMode?: PermissionMode) => void
knowledgeFiles?: string[] knowledgeFiles?: string[]
recentFiles?: string[] recentFiles?: string[]
visibleFiles?: string[] visibleFiles?: string[]
@ -154,6 +155,7 @@ interface ChatSidebarProps {
pendingAskHumanRequests?: ChatTabViewState['pendingAskHumanRequests'] pendingAskHumanRequests?: ChatTabViewState['pendingAskHumanRequests']
allPermissionRequests?: ChatTabViewState['allPermissionRequests'] allPermissionRequests?: ChatTabViewState['allPermissionRequests']
permissionResponses?: ChatTabViewState['permissionResponses'] permissionResponses?: ChatTabViewState['permissionResponses']
autoPermissionDecisions?: ChatTabViewState['autoPermissionDecisions']
onPermissionResponse?: (toolCallId: string, subflow: string[], response: PermissionResponse, scope?: 'once' | 'session' | 'always') => void onPermissionResponse?: (toolCallId: string, subflow: string[], response: PermissionResponse, scope?: 'once' | 'session' | 'always') => void
onAskHumanResponse?: (toolCallId: string, subflow: string[], response: string) => void onAskHumanResponse?: (toolCallId: string, subflow: string[], response: string) => void
isToolOpenForTab?: (tabId: string, toolId: string) => boolean isToolOpenForTab?: (tabId: string, toolId: string) => boolean
@ -211,6 +213,7 @@ export function ChatSidebar({
pendingAskHumanRequests = new Map(), pendingAskHumanRequests = new Map(),
allPermissionRequests = new Map(), allPermissionRequests = new Map(),
permissionResponses = new Map(), permissionResponses = new Map(),
autoPermissionDecisions = new Map(),
onPermissionResponse, onPermissionResponse,
onAskHumanResponse, onAskHumanResponse,
isToolOpenForTab, isToolOpenForTab,
@ -325,6 +328,7 @@ export function ChatSidebar({
pendingAskHumanRequests, pendingAskHumanRequests,
allPermissionRequests, allPermissionRequests,
permissionResponses, permissionResponses,
autoPermissionDecisions,
}), [ }), [
runId, runId,
conversation, conversation,
@ -332,6 +336,7 @@ export function ChatSidebar({
pendingAskHumanRequests, pendingAskHumanRequests,
allPermissionRequests, allPermissionRequests,
permissionResponses, permissionResponses,
autoPermissionDecisions,
]) ])
const emptyTabState = useMemo<ChatTabViewState>(() => createEmptyChatTabViewState(), []) const emptyTabState = useMemo<ChatTabViewState>(() => createEmptyChatTabViewState(), [])
const getTabState = useCallback((tabId: string): ChatTabViewState => { const getTabState = useCallback((tabId: string): ChatTabViewState => {
@ -358,7 +363,11 @@ export function ChatSidebar({
} }
}, [activeRunId]) }, [activeRunId])
const renderConversationItem = (item: ConversationItem, tabId: string) => { const renderConversationItem = (
item: ConversationItem,
tabId: string,
options?: { autoPermissionDetail?: { decision: 'allow'; reason: string } },
) => {
if (isChatMessage(item)) { if (isChatMessage(item)) {
if (item.role === 'user') { if (item.role === 'user') {
if (item.attachments && item.attachments.length > 0) { if (item.attachments && item.attachments.length > 0) {
@ -451,6 +460,7 @@ export function ChatSidebar({
key={item.id} key={item.id}
open={isToolOpenForTab?.(tabId, item.id) ?? false} open={isToolOpenForTab?.(tabId, item.id) ?? false}
onOpenChange={(open) => onToolOpenChangeForTab?.(tabId, item.id, open)} onOpenChange={(open) => onToolOpenChangeForTab?.(tabId, item.id, open)}
autoPermissionDetail={options?.autoPermissionDetail}
> >
<ToolHeader title={toolTitle} type={`tool-${item.name}`} state={toToolState(item.status)} /> <ToolHeader title={toolTitle} type={`tool-${item.name}`} state={toToolState(item.status)} />
<ToolContent> <ToolContent>
@ -626,7 +636,7 @@ export function ChatSidebar({
<> <>
{groupConversationItems( {groupConversationItems(
tabState.conversation, tabState.conversation,
(id) => !!tabState.allPermissionRequests.get(id) (id) => !!tabState.allPermissionRequests.get(id) || !!tabState.autoPermissionDecisions.get(id)
).map((item) => { ).map((item) => {
if (isToolGroup(item)) { if (isToolGroup(item)) {
return ( return (
@ -638,22 +648,43 @@ export function ChatSidebar({
/> />
) )
} }
const rendered = renderConversationItem(item, tab.id) const autoDecision = isToolCall(item)
if (isToolCall(item) && onPermissionResponse) { ? tabState.autoPermissionDecisions.get(item.id)
: undefined
const rendered = renderConversationItem(
item,
tab.id,
autoDecision?.decision === 'allow'
? { autoPermissionDetail: { decision: 'allow', reason: autoDecision.reason } }
: undefined,
)
if (isToolCall(item)) {
const deniedAutoDecision = autoDecision?.decision === 'deny' ? autoDecision : null
const permRequest = tabState.allPermissionRequests.get(item.id) const permRequest = tabState.allPermissionRequests.get(item.id)
if (permRequest) { if (deniedAutoDecision || (permRequest && onPermissionResponse)) {
const response = tabState.permissionResponses.get(item.id) || null const response = tabState.permissionResponses.get(item.id) || null
return ( return (
<React.Fragment key={item.id}> <React.Fragment key={item.id}>
<PermissionRequest {deniedAutoDecision && (
toolCall={permRequest.toolCall} <AutoPermissionDecision
onApprove={() => onPermissionResponse(permRequest.toolCall.toolCallId, permRequest.subflow, 'approve')} toolCall={deniedAutoDecision.toolCall}
onApproveSession={() => onPermissionResponse(permRequest.toolCall.toolCallId, permRequest.subflow, 'approve', 'session')} permission={deniedAutoDecision.permission}
onApproveAlways={() => onPermissionResponse(permRequest.toolCall.toolCallId, permRequest.subflow, 'approve', 'always')} decision={deniedAutoDecision.decision}
onDeny={() => onPermissionResponse(permRequest.toolCall.toolCallId, permRequest.subflow, 'deny')} reason={deniedAutoDecision.reason}
isProcessing={isActive && isProcessing} />
response={response} )}
/> {permRequest && onPermissionResponse && (
<PermissionRequest
toolCall={permRequest.toolCall}
permission={permRequest.permission}
onApprove={() => onPermissionResponse(permRequest.toolCall.toolCallId, permRequest.subflow, 'approve')}
onApproveSession={() => onPermissionResponse(permRequest.toolCall.toolCallId, permRequest.subflow, 'approve', 'session')}
onApproveAlways={() => onPermissionResponse(permRequest.toolCall.toolCallId, permRequest.subflow, 'approve', 'always')}
onDeny={() => onPermissionResponse(permRequest.toolCall.toolCallId, permRequest.subflow, 'deny')}
isProcessing={isActive && isProcessing}
response={response}
/>
)}
{rendered} {rendered}
</React.Fragment> </React.Fragment>
) )

View file

@ -277,17 +277,27 @@ const defaultBaseURLs: Partial<Record<LlmProviderFlavor, string>> = {
"openai-compatible": "http://localhost:1234/v1", "openai-compatible": "http://localhost:1234/v1",
} }
type ProviderModelConfig = {
apiKey: string
baseURL: string
models: string[]
knowledgeGraphModel: string
meetingNotesModel: string
liveNoteAgentModel: string
autoPermissionDecisionModel: string
}
function ModelSettings({ dialogOpen }: { dialogOpen: boolean }) { function ModelSettings({ dialogOpen }: { dialogOpen: boolean }) {
const [provider, setProvider] = useState<LlmProviderFlavor>("openai") const [provider, setProvider] = useState<LlmProviderFlavor>("openai")
const [defaultProvider, setDefaultProvider] = useState<LlmProviderFlavor | null>(null) const [defaultProvider, setDefaultProvider] = useState<LlmProviderFlavor | null>(null)
const [providerConfigs, setProviderConfigs] = useState<Record<LlmProviderFlavor, { apiKey: string; baseURL: string; models: string[]; knowledgeGraphModel: string; meetingNotesModel: string; liveNoteAgentModel: string }>>({ const [providerConfigs, setProviderConfigs] = useState<Record<LlmProviderFlavor, ProviderModelConfig>>({
openai: { apiKey: "", baseURL: "", models: [""], knowledgeGraphModel: "", meetingNotesModel: "", liveNoteAgentModel: "" }, openai: { apiKey: "", baseURL: "", models: [""], knowledgeGraphModel: "", meetingNotesModel: "", liveNoteAgentModel: "", autoPermissionDecisionModel: "" },
anthropic: { apiKey: "", baseURL: "", models: [""], knowledgeGraphModel: "", meetingNotesModel: "", liveNoteAgentModel: "" }, anthropic: { apiKey: "", baseURL: "", models: [""], knowledgeGraphModel: "", meetingNotesModel: "", liveNoteAgentModel: "", autoPermissionDecisionModel: "" },
google: { apiKey: "", baseURL: "", models: [""], knowledgeGraphModel: "", meetingNotesModel: "", liveNoteAgentModel: "" }, google: { apiKey: "", baseURL: "", models: [""], knowledgeGraphModel: "", meetingNotesModel: "", liveNoteAgentModel: "", autoPermissionDecisionModel: "" },
openrouter: { apiKey: "", baseURL: "", models: [""], knowledgeGraphModel: "", meetingNotesModel: "", liveNoteAgentModel: "" }, openrouter: { apiKey: "", baseURL: "", models: [""], knowledgeGraphModel: "", meetingNotesModel: "", liveNoteAgentModel: "", autoPermissionDecisionModel: "" },
aigateway: { apiKey: "", baseURL: "", models: [""], knowledgeGraphModel: "", meetingNotesModel: "", liveNoteAgentModel: "" }, aigateway: { apiKey: "", baseURL: "", models: [""], knowledgeGraphModel: "", meetingNotesModel: "", liveNoteAgentModel: "", autoPermissionDecisionModel: "" },
ollama: { apiKey: "", baseURL: "http://localhost:11434", models: [""], knowledgeGraphModel: "", meetingNotesModel: "", liveNoteAgentModel: "" }, ollama: { apiKey: "", baseURL: "http://localhost:11434", models: [""], knowledgeGraphModel: "", meetingNotesModel: "", liveNoteAgentModel: "", autoPermissionDecisionModel: "" },
"openai-compatible": { apiKey: "", baseURL: "http://localhost:1234/v1", models: [""], knowledgeGraphModel: "", meetingNotesModel: "", liveNoteAgentModel: "" }, "openai-compatible": { apiKey: "", baseURL: "http://localhost:1234/v1", models: [""], knowledgeGraphModel: "", meetingNotesModel: "", liveNoteAgentModel: "", autoPermissionDecisionModel: "" },
}) })
const [modelsCatalog, setModelsCatalog] = useState<Record<string, LlmModelOption[]>>({}) const [modelsCatalog, setModelsCatalog] = useState<Record<string, LlmModelOption[]>>({})
const [modelsLoading, setModelsLoading] = useState(false) const [modelsLoading, setModelsLoading] = useState(false)
@ -313,7 +323,7 @@ function ModelSettings({ dialogOpen }: { dialogOpen: boolean }) {
(!requiresBaseURL || activeConfig.baseURL.trim().length > 0) (!requiresBaseURL || activeConfig.baseURL.trim().length > 0)
const updateConfig = useCallback( const updateConfig = useCallback(
(prov: LlmProviderFlavor, updates: Partial<{ apiKey: string; baseURL: string; models: string[]; knowledgeGraphModel: string; meetingNotesModel: string; liveNoteAgentModel: string }>) => { (prov: LlmProviderFlavor, updates: Partial<ProviderModelConfig>) => {
setProviderConfigs(prev => ({ setProviderConfigs(prev => ({
...prev, ...prev,
[prov]: { ...prev[prov], ...updates }, [prov]: { ...prev[prov], ...updates },
@ -388,6 +398,7 @@ function ModelSettings({ dialogOpen }: { dialogOpen: boolean }) {
knowledgeGraphModel: e.knowledgeGraphModel || "", knowledgeGraphModel: e.knowledgeGraphModel || "",
meetingNotesModel: e.meetingNotesModel || "", meetingNotesModel: e.meetingNotesModel || "",
liveNoteAgentModel: e.liveNoteAgentModel || "", liveNoteAgentModel: e.liveNoteAgentModel || "",
autoPermissionDecisionModel: e.autoPermissionDecisionModel || "",
}; };
} }
} }
@ -406,6 +417,7 @@ function ModelSettings({ dialogOpen }: { dialogOpen: boolean }) {
knowledgeGraphModel: parsed.knowledgeGraphModel || "", knowledgeGraphModel: parsed.knowledgeGraphModel || "",
meetingNotesModel: parsed.meetingNotesModel || "", meetingNotesModel: parsed.meetingNotesModel || "",
liveNoteAgentModel: parsed.liveNoteAgentModel || "", liveNoteAgentModel: parsed.liveNoteAgentModel || "",
autoPermissionDecisionModel: parsed.autoPermissionDecisionModel || "",
}; };
} }
return next; return next;
@ -481,6 +493,7 @@ function ModelSettings({ dialogOpen }: { dialogOpen: boolean }) {
knowledgeGraphModel: activeConfig.knowledgeGraphModel.trim() || undefined, knowledgeGraphModel: activeConfig.knowledgeGraphModel.trim() || undefined,
meetingNotesModel: activeConfig.meetingNotesModel.trim() || undefined, meetingNotesModel: activeConfig.meetingNotesModel.trim() || undefined,
liveNoteAgentModel: activeConfig.liveNoteAgentModel.trim() || undefined, liveNoteAgentModel: activeConfig.liveNoteAgentModel.trim() || undefined,
autoPermissionDecisionModel: activeConfig.autoPermissionDecisionModel.trim() || undefined,
} }
const result = await window.ipc.invoke("models:test", providerConfig) const result = await window.ipc.invoke("models:test", providerConfig)
if (result.success) { if (result.success) {
@ -515,6 +528,7 @@ function ModelSettings({ dialogOpen }: { dialogOpen: boolean }) {
knowledgeGraphModel: config.knowledgeGraphModel.trim() || undefined, knowledgeGraphModel: config.knowledgeGraphModel.trim() || undefined,
meetingNotesModel: config.meetingNotesModel.trim() || undefined, meetingNotesModel: config.meetingNotesModel.trim() || undefined,
liveNoteAgentModel: config.liveNoteAgentModel.trim() || undefined, liveNoteAgentModel: config.liveNoteAgentModel.trim() || undefined,
autoPermissionDecisionModel: config.autoPermissionDecisionModel.trim() || undefined,
}) })
setDefaultProvider(prov) setDefaultProvider(prov)
window.dispatchEvent(new Event('models-config-changed')) window.dispatchEvent(new Event('models-config-changed'))
@ -546,6 +560,7 @@ function ModelSettings({ dialogOpen }: { dialogOpen: boolean }) {
parsed.knowledgeGraphModel = defConfig.knowledgeGraphModel.trim() || undefined parsed.knowledgeGraphModel = defConfig.knowledgeGraphModel.trim() || undefined
parsed.meetingNotesModel = defConfig.meetingNotesModel.trim() || undefined parsed.meetingNotesModel = defConfig.meetingNotesModel.trim() || undefined
parsed.liveNoteAgentModel = defConfig.liveNoteAgentModel.trim() || undefined parsed.liveNoteAgentModel = defConfig.liveNoteAgentModel.trim() || undefined
parsed.autoPermissionDecisionModel = defConfig.autoPermissionDecisionModel.trim() || undefined
} }
await window.ipc.invoke("workspace:writeFile", { await window.ipc.invoke("workspace:writeFile", {
path: "config/models.json", path: "config/models.json",
@ -553,7 +568,7 @@ function ModelSettings({ dialogOpen }: { dialogOpen: boolean }) {
}) })
setProviderConfigs(prev => ({ setProviderConfigs(prev => ({
...prev, ...prev,
[prov]: { apiKey: "", baseURL: defaultBaseURLs[prov] || "", models: [""], knowledgeGraphModel: "", meetingNotesModel: "", liveNoteAgentModel: "" }, [prov]: { apiKey: "", baseURL: defaultBaseURLs[prov] || "", models: [""], knowledgeGraphModel: "", meetingNotesModel: "", liveNoteAgentModel: "", autoPermissionDecisionModel: "" },
})) }))
setTestState({ status: "idle" }) setTestState({ status: "idle" })
window.dispatchEvent(new Event('models-config-changed')) window.dispatchEvent(new Event('models-config-changed'))
@ -811,6 +826,40 @@ function ModelSettings({ dialogOpen }: { dialogOpen: boolean }) {
</Select> </Select>
)} )}
</div> </div>
{/* Auto-permission model */}
<div className="space-y-2">
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Auto-permission model</span>
{modelsLoading ? (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 className="size-4 animate-spin" />
Loading...
</div>
) : showModelInput ? (
<Input
value={activeConfig.autoPermissionDecisionModel}
onChange={(e) => updateConfig(provider, { autoPermissionDecisionModel: e.target.value })}
placeholder={primaryModel || "Enter model"}
/>
) : (
<Select
value={activeConfig.autoPermissionDecisionModel || "__same__"}
onValueChange={(value) => updateConfig(provider, { autoPermissionDecisionModel: value === "__same__" ? "" : value })}
>
<SelectTrigger>
<SelectValue placeholder="Select a model" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__same__">Same as assistant</SelectItem>
{modelsForProvider.map((m) => (
<SelectItem key={m.id} value={m.id}>
{m.name || m.id}
</SelectItem>
))}
</SelectContent>
</Select>
)}
</div>
</div> </div>
{/* API Key */} {/* API Key */}

View file

@ -1,6 +1,6 @@
import type { ToolUIPart } from 'ai' import type { ToolUIPart } from 'ai'
import z from 'zod' import z from 'zod'
import { AskHumanRequestEvent, ToolPermissionRequestEvent } from '@x/shared/src/runs.js' import { AskHumanRequestEvent, ToolPermissionAutoDecisionEvent, ToolPermissionRequestEvent } from '@x/shared/src/runs.js'
import { COMPOSIO_DISPLAY_NAMES } from '@x/shared/src/composio.js' import { COMPOSIO_DISPLAY_NAMES } from '@x/shared/src/composio.js'
export interface MessageAttachment { export interface MessageAttachment {
@ -46,6 +46,7 @@ export type ChatTabViewState = {
pendingAskHumanRequests: Map<string, z.infer<typeof AskHumanRequestEvent>> pendingAskHumanRequests: Map<string, z.infer<typeof AskHumanRequestEvent>>
allPermissionRequests: Map<string, z.infer<typeof ToolPermissionRequestEvent>> allPermissionRequests: Map<string, z.infer<typeof ToolPermissionRequestEvent>>
permissionResponses: Map<string, PermissionResponse> permissionResponses: Map<string, PermissionResponse>
autoPermissionDecisions: Map<string, z.infer<typeof ToolPermissionAutoDecisionEvent>>
} }
export type ChatViewportAnchorState = { export type ChatViewportAnchorState = {
@ -60,6 +61,7 @@ export const createEmptyChatTabViewState = (): ChatTabViewState => ({
pendingAskHumanRequests: new Map(), pendingAskHumanRequests: new Map(),
allPermissionRequests: new Map(), allPermissionRequests: new Map(),
permissionResponses: new Map(), permissionResponses: new Map(),
autoPermissionDecisions: new Map(),
}) })
export type ToolState = 'input-streaming' | 'input-available' | 'output-available' | 'output-error' export type ToolState = 'input-streaming' | 'input-available' | 'output-available' | 'output-error'

View file

@ -36,6 +36,7 @@ import { getRaw as getLabelingAgentRaw } from "../knowledge/labeling_agent.js";
import { getRaw as getNoteTaggingAgentRaw } from "../knowledge/note_tagging_agent.js"; import { getRaw as getNoteTaggingAgentRaw } from "../knowledge/note_tagging_agent.js";
import { getRaw as getInlineTaskAgentRaw } from "../knowledge/inline_task_agent.js"; import { getRaw as getInlineTaskAgentRaw } from "../knowledge/inline_task_agent.js";
import { getRaw as getAgentNotesAgentRaw } from "../knowledge/agent_notes_agent.js"; import { getRaw as getAgentNotesAgentRaw } from "../knowledge/agent_notes_agent.js";
import { classifyToolPermissions, type AutoPermissionCandidate } from "../security/auto-permission-classifier.js";
const AGENT_NOTES_DIR = path.join(WorkDir, 'knowledge', 'Agent Notes'); const AGENT_NOTES_DIR = path.join(WorkDir, 'knowledge', 'Agent Notes');
@ -901,6 +902,7 @@ export class AgentState {
agentName: string | null = null; agentName: string | null = null;
runModel: string | null = null; runModel: string | null = null;
runProvider: string | null = null; runProvider: string | null = null;
permissionMode: "manual" | "auto" = "manual";
runUseCase: UseCase | null = null; runUseCase: UseCase | null = null;
runSubUseCase: string | null = null; runSubUseCase: string | null = null;
messages: z.infer<typeof MessageList> = []; messages: z.infer<typeof MessageList> = [];
@ -912,6 +914,8 @@ export class AgentState {
pendingAskHumanRequests: Record<string, z.infer<typeof AskHumanRequestEvent>> = {}; pendingAskHumanRequests: Record<string, z.infer<typeof AskHumanRequestEvent>> = {};
allowedToolCallIds: Record<string, true> = {}; allowedToolCallIds: Record<string, true> = {};
deniedToolCallIds: Record<string, true> = {}; deniedToolCallIds: Record<string, true> = {};
autoAllowedToolCalls: Record<string, { reason: string }> = {};
autoDeniedToolCalls: Record<string, { reason: string }> = {};
sessionAllowedCommands: Set<string> = new Set(); sessionAllowedCommands: Set<string> = new Set();
sessionAllowedFileAccess: FileAccessGrant[] = []; sessionAllowedFileAccess: FileAccessGrant[] = [];
@ -1019,6 +1023,7 @@ export class AgentState {
this.agentName = event.agentName; this.agentName = event.agentName;
this.runModel = event.model; this.runModel = event.model;
this.runProvider = event.provider; this.runProvider = event.provider;
this.permissionMode = event.permissionMode ?? "manual";
this.runUseCase = event.useCase ?? null; this.runUseCase = event.useCase ?? null;
this.runSubUseCase = event.subUseCase ?? null; this.runSubUseCase = event.subUseCase ?? null;
break; break;
@ -1031,6 +1036,7 @@ export class AgentState {
this.subflowStates[event.toolCallId].agentName = event.agentName; this.subflowStates[event.toolCallId].agentName = event.agentName;
this.subflowStates[event.toolCallId].runModel = this.runModel; this.subflowStates[event.toolCallId].runModel = this.runModel;
this.subflowStates[event.toolCallId].runProvider = this.runProvider; this.subflowStates[event.toolCallId].runProvider = this.runProvider;
this.subflowStates[event.toolCallId].permissionMode = this.permissionMode;
this.subflowStates[event.toolCallId].runUseCase = this.runUseCase; this.subflowStates[event.toolCallId].runUseCase = this.runUseCase;
this.subflowStates[event.toolCallId].runSubUseCase = this.runSubUseCase; this.subflowStates[event.toolCallId].runSubUseCase = this.runSubUseCase;
break; break;
@ -1081,10 +1087,22 @@ export class AgentState {
break; break;
case "deny": case "deny":
this.deniedToolCallIds[event.toolCallId] = true; this.deniedToolCallIds[event.toolCallId] = true;
delete this.autoDeniedToolCalls[event.toolCallId];
break; break;
} }
delete this.pendingToolPermissionRequests[event.toolCallId]; delete this.pendingToolPermissionRequests[event.toolCallId];
break; break;
case "tool-permission-auto-decision":
switch (event.decision) {
case "allow":
this.allowedToolCallIds[event.toolCallId] = true;
this.autoAllowedToolCalls[event.toolCallId] = { reason: event.reason };
break;
case "deny":
this.autoDeniedToolCalls[event.toolCallId] = { reason: event.reason };
break;
}
break;
case "ask-human-request": case "ask-human-request":
this.pendingAskHumanRequests[event.toolCallId] = event; this.pendingAskHumanRequests[event.toolCallId] = event;
break; break;
@ -1190,13 +1208,19 @@ export async function* streamAgent({
// if tool has been denied, deny // if tool has been denied, deny
if (state.deniedToolCallIds[toolCallId]) { if (state.deniedToolCallIds[toolCallId]) {
_logger.log('returning denied tool message, reason: tool has been denied'); _logger.log('returning denied tool message, reason: tool has been denied');
const autoDenied = state.autoDeniedToolCalls[toolCallId];
yield* processEvent({ yield* processEvent({
runId, runId,
messageId: await idGenerator.next(), messageId: await idGenerator.next(),
type: "message", type: "message",
message: { message: {
role: "tool", role: "tool",
content: "Unable to execute this tool: Permission was denied.", content: autoDenied
? JSON.stringify({
success: false,
error: `Auto-permission denied: ${autoDenied.reason}`,
})
: "Unable to execute this tool: Permission was denied.",
toolCallId: toolCallId, toolCallId: toolCallId,
toolName: toolCall.toolName, toolName: toolCall.toolName,
}, },
@ -1493,6 +1517,7 @@ If the user's message is clearly NOT a coding request (small talk, an unrelated
// if there were any ask-human calls, emit those events // if there were any ask-human calls, emit those events
if (message.content instanceof Array) { if (message.content instanceof Array) {
const permissionCandidates: AutoPermissionCandidate[] = [];
for (const part of message.content) { for (const part of message.content) {
if (part.type === "tool-call") { if (part.type === "tool-call") {
const underlyingTool = agent.tools![part.toolName]; const underlyingTool = agent.tools![part.toolName];
@ -1518,14 +1543,7 @@ If the user's message is clearly NOT a coding request (small talk, an unrelated
state.sessionAllowedFileAccess, state.sessionAllowedFileAccess,
); );
if (permission) { if (permission) {
loopLogger.log('emitting tool-permission-request, toolCallId:', part.toolCallId); permissionCandidates.push({ toolCall: part, permission });
yield* processEvent({
runId,
type: "tool-permission-request",
toolCall: part,
permission,
subflow: [],
});
} }
if (underlyingTool.type === "agent" && underlyingTool.name) { if (underlyingTool.type === "agent" && underlyingTool.name) {
loopLogger.log('emitting spawn-subflow, toolCallId:', part.toolCallId); loopLogger.log('emitting spawn-subflow, toolCallId:', part.toolCallId);
@ -1549,6 +1567,87 @@ If the user's message is clearly NOT a coding request (small talk, an unrelated
} }
} }
} }
if (permissionCandidates.length > 0) {
if (state.permissionMode === "auto") {
let decisionsByToolCallId = new Map<string, { decision: "allow" | "deny"; reason: string }>();
try {
const decisions = await classifyToolPermissions({
runId,
agentName: state.agentName,
messages: convertFromMessages(state.messages),
candidates: permissionCandidates,
useCase: state.runUseCase ?? "copilot_chat",
subUseCase: state.runSubUseCase,
});
decisionsByToolCallId = new Map(decisions.map((decision) => [
decision.toolCallId,
{ decision: decision.decision, reason: decision.reason },
]));
} catch (error) {
loopLogger.log(
'auto-permission classifier failed:',
error instanceof Error ? error.message : String(error),
);
}
for (const candidate of permissionCandidates) {
const decision = decisionsByToolCallId.get(candidate.toolCall.toolCallId);
if (!decision) {
loopLogger.log('auto-permission missing decision, falling back to prompt:', candidate.toolCall.toolCallId);
yield* processEvent({
runId,
type: "tool-permission-request",
toolCall: candidate.toolCall,
permission: candidate.permission,
subflow: [],
});
continue;
}
loopLogger.log(
'emitting tool-permission-auto-decision, toolCallId:',
candidate.toolCall.toolCallId,
'decision:',
decision.decision,
);
yield* processEvent({
runId,
type: "tool-permission-auto-decision",
toolCallId: candidate.toolCall.toolCallId,
toolCall: candidate.toolCall,
permission: candidate.permission,
decision: decision.decision,
reason: decision.reason,
subflow: [],
});
if (decision.decision === "deny") {
loopLogger.log(
'auto-permission denied, falling back to prompt:',
candidate.toolCall.toolCallId,
);
yield* processEvent({
runId,
type: "tool-permission-request",
toolCall: candidate.toolCall,
permission: candidate.permission,
subflow: [],
});
}
}
} else {
for (const candidate of permissionCandidates) {
loopLogger.log('emitting tool-permission-request, toolCallId:', candidate.toolCall.toolCallId);
yield* processEvent({
runId,
type: "tool-permission-request",
toolCall: candidate.toolCall,
permission: candidate.permission,
subflow: [],
});
}
}
}
} }
} }
} }

View file

@ -8,6 +8,7 @@ const SIGNED_IN_DEFAULT_MODEL = "gpt-5.4";
const SIGNED_IN_DEFAULT_PROVIDER = "rowboat"; const SIGNED_IN_DEFAULT_PROVIDER = "rowboat";
const SIGNED_IN_KG_MODEL = "google/gemini-3.1-flash-lite"; const SIGNED_IN_KG_MODEL = "google/gemini-3.1-flash-lite";
const SIGNED_IN_LIVE_NOTE_AGENT_MODEL = "google/gemini-3.1-flash-lite"; const SIGNED_IN_LIVE_NOTE_AGENT_MODEL = "google/gemini-3.1-flash-lite";
const SIGNED_IN_AUTO_PERMISSION_DECISION_MODEL = "google/gemini-3.1-flash-lite";
/** /**
* The single source of truth for "what model+provider should we use when * The single source of truth for "what model+provider should we use when
@ -76,6 +77,17 @@ export async function getLiveNoteAgentModel(): Promise<string> {
return cfg.liveNoteAgentModel ?? cfg.model; return cfg.liveNoteAgentModel ?? cfg.model;
} }
/**
* Model used by the auto-permission classifier.
* Signed-in: curated default. BYOK: user override
* (`autoPermissionDecisionModel`) or assistant model.
*/
export async function getAutoPermissionDecisionModel(): Promise<string> {
if (await isSignedIn()) return SIGNED_IN_AUTO_PERMISSION_DECISION_MODEL;
const cfg = await container.resolve<IModelConfigRepo>("modelConfigRepo").getConfig();
return cfg.autoPermissionDecisionModel ?? cfg.model;
}
/** /**
* Model used by the meeting-notes summarizer. No special signed-in default * Model used by the meeting-notes summarizer. No special signed-in default
* historically meetings used the assistant model. BYOK: user override * historically meetings used the assistant model. BYOK: user override

View file

@ -53,6 +53,7 @@ export class FSModelConfigRepo implements IModelConfigRepo {
knowledgeGraphModel: config.knowledgeGraphModel, knowledgeGraphModel: config.knowledgeGraphModel,
meetingNotesModel: config.meetingNotesModel, meetingNotesModel: config.meetingNotesModel,
liveNoteAgentModel: config.liveNoteAgentModel, liveNoteAgentModel: config.liveNoteAgentModel,
autoPermissionDecisionModel: config.autoPermissionDecisionModel,
}; };
const toWrite = { ...config, providers: existingProviders }; const toWrite = { ...config, providers: existingProviders };

View file

@ -35,6 +35,7 @@ export type CreateRunRepoOptions = {
agentId: string; agentId: string;
model: string; model: string;
provider: string; provider: string;
permissionMode: "manual" | "auto";
useCase: z.infer<typeof UseCase>; useCase: z.infer<typeof UseCase>;
subUseCase?: string; subUseCase?: string;
}; };
@ -204,6 +205,7 @@ export class FSRunsRepo implements IRunsRepo {
agentName: options.agentId, agentName: options.agentId,
model: options.model, model: options.model,
provider: options.provider, provider: options.provider,
permissionMode: options.permissionMode,
useCase: options.useCase, useCase: options.useCase,
...(options.subUseCase ? { subUseCase: options.subUseCase } : {}), ...(options.subUseCase ? { subUseCase: options.subUseCase } : {}),
subflow: [], subflow: [],
@ -216,6 +218,7 @@ export class FSRunsRepo implements IRunsRepo {
agentId: options.agentId, agentId: options.agentId,
model: options.model, model: options.model,
provider: options.provider, provider: options.provider,
permissionMode: options.permissionMode,
useCase: options.useCase, useCase: options.useCase,
...(options.subUseCase ? { subUseCase: options.subUseCase } : {}), ...(options.subUseCase ? { subUseCase: options.subUseCase } : {}),
log: [start], log: [start],
@ -251,6 +254,7 @@ export class FSRunsRepo implements IRunsRepo {
agentId: start.agentName, agentId: start.agentName,
model: start.model, model: start.model,
provider: start.provider, provider: start.provider,
permissionMode: start.permissionMode ?? "manual",
...(start.useCase ? { useCase: start.useCase } : {}), ...(start.useCase ? { useCase: start.useCase } : {}),
...(start.subUseCase ? { subUseCase: start.subUseCase } : {}), ...(start.subUseCase ? { subUseCase: start.subUseCase } : {}),
log: events, log: events,

View file

@ -32,6 +32,7 @@ export async function createRun(opts: z.infer<typeof CreateRunOptions>): Promise
agentId: opts.agentId, agentId: opts.agentId,
model, model,
provider, provider,
permissionMode: opts.permissionMode ?? "manual",
useCase, useCase,
...(opts.subUseCase ? { subUseCase: opts.subUseCase } : {}), ...(opts.subUseCase ? { subUseCase: opts.subUseCase } : {}),
}); });

View file

@ -0,0 +1,112 @@
import { generateObject, type ModelMessage } from "ai";
import z from "zod";
import { ToolPermissionMetadata } from "@x/shared/dist/runs.js";
import { ToolCallPart } from "@x/shared/dist/message.js";
import { captureLlmUsage } from "../analytics/usage.js";
import { withUseCase, type UseCase } from "../analytics/use_case.js";
import { getAutoPermissionDecisionModel, getDefaultModelAndProvider, resolveProviderConfig } from "../models/defaults.js";
import { createProvider } from "../models/models.js";
const DecisionSchema = z.object({
decisions: z.array(z.object({
toolCallId: z.string(),
decision: z.enum(["allow", "deny"]),
reason: z.string().min(1),
})),
});
export type AutoPermissionCandidate = {
toolCall: z.infer<typeof ToolCallPart>;
permission: z.infer<typeof ToolPermissionMetadata>;
};
export type AutoPermissionDecision = {
toolCallId: string;
decision: "allow" | "deny";
reason: string;
};
const SYSTEM_PROMPT = `You decide whether a personal productivity app may run tool calls without interrupting the user.
You only receive tool calls that already require permission under deterministic rules.
Allow a tool call only when it is clearly consistent with the user's request and low risk.
Deny tool calls that are destructive, credential-sensitive, privacy-sensitive, broad in scope, likely irreversible, or not clearly requested.
Command examples to deny unless explicitly requested: deleting data, force pushing, deploying, running migrations, changing permissions, reading secrets, exfiltrating tokens, or modifying files outside the user's workspace.
File examples to deny unless explicitly requested: deleting paths, writing outside the workspace, reading secrets or credentials, or broad access to private directories.
Return one decision for every toolCallId. Use the exact toolCallId values provided.`;
function compact(value: unknown, max = 8_000): string {
const text = typeof value === "string" ? value : JSON.stringify(value, null, 2);
if (text.length <= max) return text;
return `${text.slice(0, max)}\n...<truncated>`;
}
function recentContext(messages: ModelMessage[]): unknown[] {
return messages.slice(-8).map((message) => {
if (typeof message.content === "string") {
return { role: message.role, content: compact(message.content, 2_000) };
}
return { role: message.role, content: compact(message.content, 3_000) };
});
}
function buildPrompt(input: {
agentName: string | null;
messages: ModelMessage[];
candidates: AutoPermissionCandidate[];
}) {
return compact({
agentName: input.agentName,
recentConversation: recentContext(input.messages),
toolCalls: input.candidates.map(({ toolCall, permission }) => ({
toolCallId: toolCall.toolCallId,
toolName: toolCall.toolName,
arguments: toolCall.arguments,
permission,
})),
}, 24_000);
}
export async function classifyToolPermissions(input: {
runId: string;
agentName: string | null;
messages: ModelMessage[];
candidates: AutoPermissionCandidate[];
useCase: UseCase;
subUseCase?: string | null;
}): Promise<AutoPermissionDecision[]> {
if (input.candidates.length === 0) return [];
const modelId = await getAutoPermissionDecisionModel();
const { provider: providerName } = await getDefaultModelAndProvider();
const providerConfig = await resolveProviderConfig(providerName);
const model = createProvider(providerConfig).languageModel(modelId);
const result = await withUseCase(
{
useCase: input.useCase,
subUseCase: "auto_permission_classifier",
...(input.agentName ? { agentName: input.agentName } : {}),
},
() => generateObject({
model,
system: SYSTEM_PROMPT,
prompt: buildPrompt(input),
schema: DecisionSchema,
}),
);
captureLlmUsage({
useCase: input.useCase,
subUseCase: "auto_permission_classifier",
model: modelId,
provider: providerName,
usage: result.usage,
});
const allowedIds = new Set(input.candidates.map((candidate) => candidate.toolCall.toolCallId));
return result.object.decisions.filter((decision) => allowedIds.has(decision.toolCallId));
}

View file

@ -17,10 +17,15 @@ export const LlmModelConfig = z.object({
headers: z.record(z.string(), z.string()).optional(), headers: z.record(z.string(), z.string()).optional(),
model: z.string().optional(), model: z.string().optional(),
models: z.array(z.string()).optional(), models: z.array(z.string()).optional(),
knowledgeGraphModel: z.string().optional(),
meetingNotesModel: z.string().optional(),
liveNoteAgentModel: z.string().optional(),
autoPermissionDecisionModel: z.string().optional(),
})).optional(), })).optional(),
// Per-category model overrides (BYOK only — signed-in users always get // Per-category model overrides (BYOK only — signed-in users always get
// the curated gateway defaults). Read by helpers in core/models/defaults.ts. // the curated gateway defaults). Read by helpers in core/models/defaults.ts.
knowledgeGraphModel: z.string().optional(), knowledgeGraphModel: z.string().optional(),
meetingNotesModel: z.string().optional(), meetingNotesModel: z.string().optional(),
liveNoteAgentModel: z.string().optional(), liveNoteAgentModel: z.string().optional(),
autoPermissionDecisionModel: z.string().optional(),
}); });

View file

@ -21,6 +21,7 @@ export const StartEvent = BaseRunEvent.extend({
agentName: z.string(), agentName: z.string(),
model: z.string(), model: z.string(),
provider: z.string(), provider: z.string(),
permissionMode: z.enum(["manual", "auto"]).optional(),
// useCase/subUseCase tag the run for analytics. Optional on read so legacy // useCase/subUseCase tag the run for analytics. Optional on read so legacy
// run files written before these fields existed still parse cleanly. // run files written before these fields existed still parse cleanly.
useCase: z.enum([ useCase: z.enum([
@ -110,6 +111,15 @@ export const ToolPermissionResponseEvent = BaseRunEvent.extend({
scope: z.enum(["once", "session", "always"]).optional(), scope: z.enum(["once", "session", "always"]).optional(),
}); });
export const ToolPermissionAutoDecisionEvent = BaseRunEvent.extend({
type: z.literal("tool-permission-auto-decision"),
toolCallId: z.string(),
toolCall: ToolCallPart,
permission: ToolPermissionMetadata.optional(),
decision: z.enum(["allow", "deny"]),
reason: z.string(),
});
export const RunErrorEvent = BaseRunEvent.extend({ export const RunErrorEvent = BaseRunEvent.extend({
type: z.literal("error"), type: z.literal("error"),
error: z.string(), error: z.string(),
@ -134,6 +144,7 @@ export const RunEvent = z.union([
AskHumanResponseEvent, AskHumanResponseEvent,
ToolPermissionRequestEvent, ToolPermissionRequestEvent,
ToolPermissionResponseEvent, ToolPermissionResponseEvent,
ToolPermissionAutoDecisionEvent,
RunErrorEvent, RunErrorEvent,
RunStoppedEvent, RunStoppedEvent,
]); ]);
@ -166,6 +177,7 @@ export const Run = z.object({
agentId: z.string(), agentId: z.string(),
model: z.string(), model: z.string(),
provider: z.string(), provider: z.string(),
permissionMode: z.enum(["manual", "auto"]).optional(),
useCase: UseCase.optional(), useCase: UseCase.optional(),
subUseCase: z.string().optional(), subUseCase: z.string().optional(),
log: z.array(RunEvent), log: z.array(RunEvent),
@ -185,6 +197,7 @@ export const CreateRunOptions = z.object({
agentId: z.string(), agentId: z.string(),
model: z.string().optional(), model: z.string().optional(),
provider: z.string().optional(), provider: z.string().optional(),
permissionMode: z.enum(["manual", "auto"]).optional(),
useCase: UseCase.optional(), useCase: UseCase.optional(),
subUseCase: z.string().optional(), subUseCase: z.string().optional(),
}); });