From 8a8b78071dfa9d652d3911bb758ea1ca16e0f8fa Mon Sep 17 00:00:00 2001 From: Arjun <6592213+arkml@users.noreply.github.com> Date: Tue, 2 Jun 2026 22:25:05 +0530 Subject: [PATCH 1/8] more recents in side bar --- .../renderer/src/components/sidebar-content.tsx | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/apps/x/apps/renderer/src/components/sidebar-content.tsx b/apps/x/apps/renderer/src/components/sidebar-content.tsx index 0368b1da..f5870ec4 100644 --- a/apps/x/apps/renderer/src/components/sidebar-content.tsx +++ b/apps/x/apps/renderer/src/components/sidebar-content.tsx @@ -512,7 +512,7 @@ export function SidebarContentPanel({ const out: TreeNode[] = [] const walk = (nodes: TreeNode[]) => { for (const n of nodes) { - if (n.path === 'knowledge/Meetings' || n.path === 'knowledge/Workspace') continue + if (n.path === 'knowledge/Meetings' || n.path === 'knowledge/Workspace' || n.path === 'knowledge/Agent Notes') continue if (n.kind === 'file') out.push(n) else if (n.children?.length) walk(n.children) } @@ -521,11 +521,11 @@ export function SidebarContentPanel({ return out .filter((n) => n.stat?.mtimeMs) .sort((a, b) => (b.stat?.mtimeMs ?? 0) - (a.stat?.mtimeMs ?? 0)) - .slice(0, 5) + .slice(0, 10) }, [tree]) // Recents: most recently touched notes / agents / chats, interleaved by - // recency. Capped per type (3 notes, 2 agents, 1 chat) and 5 overall. + // recency. Capped per type (4 notes, 4 agents, 4 chats) and 12 overall. type QuickAccessItem = { key: string label: string @@ -536,7 +536,7 @@ export function SidebarContentPanel({ const quickAccessItems = React.useMemo(() => { const items: QuickAccessItem[] = [] - for (const note of recentNotes.slice(0, 3)) { + for (const note of recentNotes.slice(0, 4)) { items.push({ key: `note:${note.path}`, label: displayNoteName(note), @@ -551,7 +551,7 @@ export function SidebarContentPanel({ const ms = ts ? new Date(ts).getTime() : 0 return Number.isFinite(ms) ? ms : 0 } - for (const t of [...bgTaskSummaries].sort((a, b) => agentRecency(b) - agentRecency(a)).slice(0, 2)) { + for (const t of [...bgTaskSummaries].sort((a, b) => agentRecency(b) - agentRecency(a)).slice(0, 4)) { items.push({ key: `agent:${t.slug}`, label: t.name, @@ -565,7 +565,7 @@ export function SidebarContentPanel({ const ms = new Date(r.createdAt).getTime() return Number.isFinite(ms) ? ms : 0 } - for (const r of [...recentRuns].sort((a, b) => chatRecency(b) - chatRecency(a)).slice(0, 1)) { + for (const r of [...recentRuns].sort((a, b) => chatRecency(b) - chatRecency(a)).slice(0, 4)) { items.push({ key: `chat:${r.id}`, label: r.title || '(Untitled chat)', @@ -575,7 +575,7 @@ export function SidebarContentPanel({ }) } - return items.sort((a, b) => b.recency - a.recency).slice(0, 5) + return items.sort((a, b) => b.recency - a.recency).slice(0, 12) }, [recentNotes, bgTaskSummaries, recentRuns, onSelectFile, onOpenAgent, onOpenRun]) // Workspace count for the Workspaces sublabel — top-level dir children of From d47cab6a0f6ecf1e26a438abf0dadfa0f89e1d80 Mon Sep 17 00:00:00 2001 From: Ramnique Singh <30795890+ramnique@users.noreply.github.com> Date: Wed, 3 Jun 2026 07:57:50 +0530 Subject: [PATCH 2/8] 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 --- apps/x/apps/renderer/src/App.tsx | 119 +++++++++++++----- .../ai-elements/auto-permission-decision.tsx | 100 +++++++++++++++ .../src/components/ai-elements/tool.tsx | 60 +++++++-- .../components/chat-input-with-mentions.tsx | 44 ++++++- .../renderer/src/components/chat-sidebar.tsx | 63 +++++++--- .../src/components/settings-dialog.tsx | 69 ++++++++-- .../renderer/src/lib/chat-conversation.ts | 4 +- apps/x/packages/core/src/agents/runtime.ts | 117 +++++++++++++++-- apps/x/packages/core/src/models/defaults.ts | 12 ++ apps/x/packages/core/src/models/repo.ts | 1 + apps/x/packages/core/src/runs/repo.ts | 6 +- apps/x/packages/core/src/runs/runs.ts | 1 + .../security/auto-permission-classifier.ts | 112 +++++++++++++++++ apps/x/packages/shared/src/models.ts | 5 + apps/x/packages/shared/src/runs.ts | 13 ++ 15 files changed, 641 insertions(+), 85 deletions(-) create mode 100644 apps/x/apps/renderer/src/components/ai-elements/auto-permission-decision.tsx create mode 100644 apps/x/packages/core/src/security/auto-permission-classifier.ts diff --git a/apps/x/apps/renderer/src/App.tsx b/apps/x/apps/renderer/src/App.tsx index 57a03727..56445821 100644 --- a/apps/x/apps/renderer/src/App.tsx +++ b/apps/x/apps/renderer/src/App.tsx @@ -11,7 +11,7 @@ import { MarkdownEditor, type MarkdownEditorHandle } from './components/markdown import { ChatSidebar } from './components/chat-sidebar'; import { ChatHeader } from './components/chat-header'; 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 { GraphView, type GraphEdge, type GraphNode } from '@/components/graph-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 { ComposioConnectCard } from '@/components/ai-elements/composio-connect-card'; import { PermissionRequest } from '@/components/ai-elements/permission-request'; +import { AutoPermissionDecision } from '@/components/ai-elements/auto-permission-decision'; import { TerminalOutput } from '@/components/terminal-output'; 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 { SidebarInset, SidebarProvider, @@ -961,7 +962,7 @@ function App() { voice.start() }, [voice]) - const handlePromptSubmitRef = useRef<((message: PromptInputMessage, mentions?: FileMention[], stagedAttachments?: StagedAttachment[], searchEnabled?: boolean, codeMode?: 'claude' | 'codex') => Promise) | null>(null) + const handlePromptSubmitRef = useRef<((message: PromptInputMessage, mentions?: FileMention[], stagedAttachments?: StagedAttachment[], searchEnabled?: boolean, codeMode?: 'claude' | 'codex', permissionMode?: PermissionMode) => Promise) | null>(null) const pendingVoiceInputRef = useRef(false) // 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>>(new Map()) // Track permission responses (toolCallId -> response) const [permissionResponses, setPermissionResponses] = useState>(new Map()) + const [autoPermissionDecisions, setAutoPermissionDecisions] = useState>>(new Map()) useEffect(() => { chatViewStateByTabRef.current = chatViewStateByTab @@ -1193,6 +1195,7 @@ function App() { pendingAskHumanRequests: new Map(pendingAskHumanRequests), allPermissionRequests: new Map(allPermissionRequests), permissionResponses: new Map(permissionResponses), + autoPermissionDecisions: new Map(autoPermissionDecisions), } setChatViewStateByTab((prev) => ({ ...prev, [activeChatTabId]: snapshot })) }, [ @@ -1203,6 +1206,7 @@ function App() { pendingAskHumanRequests, allPermissionRequests, permissionResponses, + autoPermissionDecisions, ]) useEffect(() => { @@ -2026,6 +2030,7 @@ function App() { // Track permission requests and responses from history const allPermissionRequests = new Map>() const permResponseMap = new Map() + const autoPermissionDecisions = new Map>() const askHumanRequests = new Map>() const respondedAskHumanIds = new Set() @@ -2034,6 +2039,8 @@ function App() { allPermissionRequests.set(event.toolCall.toolCallId, event) } else if (event.type === 'tool-permission-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') { askHumanRequests.set(event.toolCallId, event) } else if (event.type === 'ask-human-response') { @@ -2066,6 +2073,7 @@ function App() { setPendingAskHumanRequests(pendingAsks) setAllPermissionRequests(allPermissionRequests) setPermissionResponses(permResponseMap) + setAutoPermissionDecisions(autoPermissionDecisions) // Restore the run's per-chat work directory into the tab it was loaded into. const tabId = activeChatTabIdRef.current @@ -2375,6 +2383,16 @@ function App() { 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': { if (!isActiveRun) return const key = event.toolCallId @@ -2491,6 +2509,7 @@ function App() { stagedAttachments: StagedAttachment[] = [], searchEnabled?: boolean, codeMode?: 'claude' | 'codex', + permissionMode?: PermissionMode, ) => { if (isProcessing) return @@ -2530,6 +2549,7 @@ function App() { const run = await window.ipc.invoke('runs:create', { agentId, ...(selected ? { model: selected.model, provider: selected.provider } : {}), + permissionMode: permissionMode ?? 'manual', }) currentRunId = run.id newRunCreatedAt = run.createdAt @@ -2734,6 +2754,7 @@ function App() { setPendingAskHumanRequests(new Map()) setAllPermissionRequests(new Map()) setPermissionResponses(new Map()) + setAutoPermissionDecisions(new Map()) setSelectedBackgroundTask(null) setChatViewportAnchor(activeChatTabIdRef.current, null) setChatViewStateByTab(prev => ({ @@ -2760,6 +2781,7 @@ function App() { setPendingAskHumanRequests(new Map()) setAllPermissionRequests(new Map()) setPermissionResponses(new Map()) + setAutoPermissionDecisions(new Map()) setChatViewportAnchor(tab.id, null) } }, [loadRun, setChatViewportAnchor]) @@ -2785,6 +2807,7 @@ function App() { setPendingAskHumanRequests(new Map(cached.pendingAskHumanRequests)) setAllPermissionRequests(new Map(cached.allPermissionRequests)) setPermissionResponses(new Map(cached.permissionResponses)) + setAutoPermissionDecisions(new Map(cached.autoPermissionDecisions)) setIsProcessing(Boolean(resolvedRunId && processingRunIdsRef.current.has(resolvedRunId))) return true }, []) @@ -5057,7 +5080,11 @@ function App() { } }, [isGraphOpen, knowledgeFilePaths]) - const renderConversationItem = (item: ConversationItem, tabId: string) => { + const renderConversationItem = ( + item: ConversationItem, + tabId: string, + options?: { autoPermissionDetail?: { decision: 'allow'; reason: string } }, + ) => { if (isChatMessage(item)) { if (item.role === 'user') { if (item.attachments && item.attachments.length > 0) { @@ -5155,6 +5182,7 @@ function App() { key={item.id} open={isToolOpenForTab(tabId, item.id)} onOpenChange={(open) => setToolOpenForTab(tabId, item.id, open)} + autoPermissionDetail={options?.autoPermissionDetail} > (() => createEmptyChatTabViewState(), []) const getChatTabStateForRender = useCallback((tabId: string): ChatTabViewState => { @@ -5790,7 +5820,7 @@ function App() { <> {groupConversationItems( tabState.conversation, - (id) => !!tabState.allPermissionRequests.get(id) + (id) => !!tabState.allPermissionRequests.get(id) || !!tabState.autoPermissionDecisions.get(id) ).map(item => { if (isToolGroup(item)) { 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)) { + const deniedAutoDecision = autoDecision?.decision === 'deny' ? autoDecision : null const permRequest = tabState.allPermissionRequests.get(item.id) - if (permRequest) { + if (deniedAutoDecision || permRequest) { const response = tabState.permissionResponses.get(item.id) || null return ( - handlePermissionResponse(permRequest.toolCall.toolCallId, permRequest.subflow, 'approve')} - onApproveSession={() => handlePermissionResponse(permRequest.toolCall.toolCallId, permRequest.subflow, 'approve', 'session')} - onApproveAlways={() => handlePermissionResponse(permRequest.toolCall.toolCallId, permRequest.subflow, 'approve', 'always')} - onDeny={() => handlePermissionResponse(permRequest.toolCall.toolCallId, permRequest.subflow, 'deny')} - onSwitchAgent={async (newAgent) => { - const runIdForSwitch = tab.runId - await handlePermissionResponse(permRequest.toolCall.toolCallId, permRequest.subflow, 'deny') - window.dispatchEvent(new CustomEvent('code-mode-detected', { - detail: { runId: runIdForSwitch, agent: newAgent }, - })) - 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) + {deniedAutoDecision && ( + + )} + {permRequest && ( + handlePermissionResponse(permRequest.toolCall.toolCallId, permRequest.subflow, 'approve')} + onApproveSession={() => handlePermissionResponse(permRequest.toolCall.toolCallId, permRequest.subflow, 'approve', 'session')} + onApproveAlways={() => handlePermissionResponse(permRequest.toolCall.toolCallId, permRequest.subflow, 'approve', 'always')} + onDeny={() => handlePermissionResponse(permRequest.toolCall.toolCallId, permRequest.subflow, 'deny')} + onSwitchAgent={async (newAgent) => { + const runIdForSwitch = tab.runId + await handlePermissionResponse(permRequest.toolCall.toolCallId, permRequest.subflow, 'deny') + window.dispatchEvent(new CustomEvent('code-mode-detected', { + detail: { runId: runIdForSwitch, agent: newAgent }, + })) + 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} - response={response} - /> + }} + isProcessing={isActive && isProcessing} + response={response} + /> + )} {rendered} ) @@ -5989,6 +6039,7 @@ function App() { pendingAskHumanRequests={pendingAskHumanRequests} allPermissionRequests={allPermissionRequests} permissionResponses={permissionResponses} + autoPermissionDecisions={autoPermissionDecisions} onPermissionResponse={handlePermissionResponse} onAskHumanResponse={handleAskHumanResponse} isToolOpenForTab={isToolOpenForTab} diff --git a/apps/x/apps/renderer/src/components/ai-elements/auto-permission-decision.tsx b/apps/x/apps/renderer/src/components/ai-elements/auto-permission-decision.tsx new file mode 100644 index 00000000..3c34aaec --- /dev/null +++ b/apps/x/apps/renderer/src/components/ai-elements/auto-permission-decision.tsx @@ -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; + decision: "allow" | "deny"; + reason: string; + permission?: z.infer; +}; + +const fileActionLabels: Record = { + 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 ( +
+
+
+ {allowed ? ( + + ) : ( + + )} +
+
+

+ {allowed ? "Auto Allowed" : "Auto Denied"} +

+ + + {toolCall.toolName} + +
+

{reason}

+
+
+ {command && ( +
+

Command

+
{command}
+
+ )} + {filePermission && ( +
+
+

Action

+

+ {fileActionLabels[filePermission.operation] ?? filePermission.operation} +

+
+
+

+ Path{filePermission.paths.length === 1 ? "" : "s"} +

+
+                {filePermission.paths.join("\n")}
+              
+
+
+ )} +
+
+ ); +} diff --git a/apps/x/apps/renderer/src/components/ai-elements/tool.tsx b/apps/x/apps/renderer/src/components/ai-elements/tool.tsx index 61ba6fbd..9635b244 100644 --- a/apps/x/apps/renderer/src/components/ai-elements/tool.tsx +++ b/apps/x/apps/renderer/src/components/ai-elements/tool.tsx @@ -5,12 +5,18 @@ import { CollapsibleContent, CollapsibleTrigger, } from "@/components/ui/collapsible"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/ui/tooltip"; import { cn } from "@/lib/utils"; import type { ToolUIPart } from "ai"; import { ChevronDownIcon, CircleCheck, LoaderIcon, + ShieldCheckIcon, XCircleIcon, } from "lucide-react"; import { type ComponentProps, type ReactNode, isValidElement, useState } from "react"; @@ -45,17 +51,51 @@ const ToolCode = ({ ); -export type ToolProps = ComponentProps; +export type ToolAutoPermissionDetail = { + decision: "allow"; + reason: string; +}; -export const Tool = ({ className, ...props }: ToolProps) => ( - -); +export type ToolProps = ComponentProps & { + autoPermissionDetail?: ToolAutoPermissionDetail; +}; + +export const Tool = ({ className, children, autoPermissionDetail, ...props }: ToolProps) => { + const toolCard = ( + + {children} + + ); + + if (!autoPermissionDetail) return toolCard; + + return ( +
+ {toolCard} +
+ + + + + Auto-approved + + + + {autoPermissionDetail.reason} + + +
+
+ ); +}; export type ToolHeaderProps = { title?: string; diff --git a/apps/x/apps/renderer/src/components/chat-input-with-mentions.tsx b/apps/x/apps/renderer/src/components/chat-input-with-mentions.tsx index 360a8657..8c62054c 100644 --- a/apps/x/apps/renderer/src/components/chat-input-with-mentions.tsx +++ b/apps/x/apps/renderer/src/components/chat-input-with-mentions.tsx @@ -17,6 +17,7 @@ import { LoaderIcon, Mic, Plus, + ShieldCheck, Square, Terminal, X, @@ -85,6 +86,8 @@ export interface SelectedModel { model: string } +export type PermissionMode = 'manual' | 'auto' + function getSelectedModelDisplayName(model: string) { return model.split('/').pop() || model } @@ -109,7 +112,7 @@ function getAttachmentIcon(kind: AttachmentIconKind) { } 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 isProcessing: boolean isStopping?: boolean @@ -182,11 +185,13 @@ function ChatInputInner({ const [codingAgent, setCodingAgent] = useState<'claude' | 'codex'>('claude') const [codeModeEnabled, setCodeModeEnabled] = useState(false) const [codeModeFeatureEnabled, setCodeModeFeatureEnabled] = useState(false) + const [permissionMode, setPermissionMode] = useState('auto') // When a run exists, freeze the dropdown to the run's resolved model+provider. useEffect(() => { if (!runId) { setLockedModel(null) + setPermissionMode('auto') return } let cancelled = false @@ -195,6 +200,7 @@ function ChatInputInner({ if (run.provider && run.model) { setLockedModel({ provider: run.provider, model: run.model }) } + setPermissionMode(run.permissionMode ?? 'manual') }).catch(() => { /* legacy run or fetch failure — leave unlocked */ }) return () => { cancelled = true } }, [runId]) @@ -482,13 +488,13 @@ function ChatInputInner({ if (!canSubmit) return // codeMode is sticky per conversation — don't reset after send. 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.mentions.clearMentions() setAttachments([]) // Web search toggle stays on for the rest of the chat session; the user // 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) => { if (e.key === 'Enter' && !e.shiftKey) { @@ -709,6 +715,36 @@ function ChatInputInner({ )} + + + + + + {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'} + + {codeModeFeatureEnabled && (codeModeEnabled ? (
@@ -915,7 +951,7 @@ export interface ChatInputWithMentionsProps { knowledgeFiles: string[] recentFiles: 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 isProcessing: boolean isStopping?: boolean diff --git a/apps/x/apps/renderer/src/components/chat-sidebar.tsx b/apps/x/apps/renderer/src/components/chat-sidebar.tsx index 7987e6dd..f8923e4f 100644 --- a/apps/x/apps/renderer/src/components/chat-sidebar.tsx +++ b/apps/x/apps/renderer/src/components/chat-sidebar.tsx @@ -28,6 +28,7 @@ import { Tool, ToolContent, ToolGroupComponent, ToolHeader, ToolTabbedContent } import { WebSearchResult } from '@/components/ai-elements/web-search-result' import { ComposioConnectCard } from '@/components/ai-elements/composio-connect-card' import { PermissionRequest } from '@/components/ai-elements/permission-request' +import { AutoPermissionDecision } from '@/components/ai-elements/auto-permission-decision' import { TerminalOutput } from '@/components/terminal-output' import { AskHumanRequest } from '@/components/ai-elements/ask-human-request' 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 remarkBreaks from 'remark-breaks' 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 { useSidebar } from '@/components/ui/sidebar' import { wikiLabel } from '@/lib/wiki-links' @@ -139,7 +140,7 @@ interface ChatSidebarProps { isProcessing: boolean isStopping?: boolean 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[] recentFiles?: string[] visibleFiles?: string[] @@ -154,6 +155,7 @@ interface ChatSidebarProps { pendingAskHumanRequests?: ChatTabViewState['pendingAskHumanRequests'] allPermissionRequests?: ChatTabViewState['allPermissionRequests'] permissionResponses?: ChatTabViewState['permissionResponses'] + autoPermissionDecisions?: ChatTabViewState['autoPermissionDecisions'] onPermissionResponse?: (toolCallId: string, subflow: string[], response: PermissionResponse, scope?: 'once' | 'session' | 'always') => void onAskHumanResponse?: (toolCallId: string, subflow: string[], response: string) => void isToolOpenForTab?: (tabId: string, toolId: string) => boolean @@ -211,6 +213,7 @@ export function ChatSidebar({ pendingAskHumanRequests = new Map(), allPermissionRequests = new Map(), permissionResponses = new Map(), + autoPermissionDecisions = new Map(), onPermissionResponse, onAskHumanResponse, isToolOpenForTab, @@ -325,6 +328,7 @@ export function ChatSidebar({ pendingAskHumanRequests, allPermissionRequests, permissionResponses, + autoPermissionDecisions, }), [ runId, conversation, @@ -332,6 +336,7 @@ export function ChatSidebar({ pendingAskHumanRequests, allPermissionRequests, permissionResponses, + autoPermissionDecisions, ]) const emptyTabState = useMemo(() => createEmptyChatTabViewState(), []) const getTabState = useCallback((tabId: string): ChatTabViewState => { @@ -358,7 +363,11 @@ export function ChatSidebar({ } }, [activeRunId]) - const renderConversationItem = (item: ConversationItem, tabId: string) => { + const renderConversationItem = ( + item: ConversationItem, + tabId: string, + options?: { autoPermissionDetail?: { decision: 'allow'; reason: string } }, + ) => { if (isChatMessage(item)) { if (item.role === 'user') { if (item.attachments && item.attachments.length > 0) { @@ -451,6 +460,7 @@ export function ChatSidebar({ key={item.id} open={isToolOpenForTab?.(tabId, item.id) ?? false} onOpenChange={(open) => onToolOpenChangeForTab?.(tabId, item.id, open)} + autoPermissionDetail={options?.autoPermissionDetail} > @@ -626,7 +636,7 @@ export function ChatSidebar({ <> {groupConversationItems( tabState.conversation, - (id) => !!tabState.allPermissionRequests.get(id) + (id) => !!tabState.allPermissionRequests.get(id) || !!tabState.autoPermissionDecisions.get(id) ).map((item) => { if (isToolGroup(item)) { return ( @@ -638,22 +648,43 @@ export function ChatSidebar({ /> ) } - const rendered = renderConversationItem(item, tab.id) - if (isToolCall(item) && onPermissionResponse) { + 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)) { + const deniedAutoDecision = autoDecision?.decision === 'deny' ? autoDecision : null const permRequest = tabState.allPermissionRequests.get(item.id) - if (permRequest) { + if (deniedAutoDecision || (permRequest && onPermissionResponse)) { const response = tabState.permissionResponses.get(item.id) || null return ( - 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} - /> + {deniedAutoDecision && ( + + )} + {permRequest && onPermissionResponse && ( + 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} ) diff --git a/apps/x/apps/renderer/src/components/settings-dialog.tsx b/apps/x/apps/renderer/src/components/settings-dialog.tsx index 37e0a930..74082664 100644 --- a/apps/x/apps/renderer/src/components/settings-dialog.tsx +++ b/apps/x/apps/renderer/src/components/settings-dialog.tsx @@ -277,17 +277,27 @@ const defaultBaseURLs: Partial> = { "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 }) { const [provider, setProvider] = useState("openai") const [defaultProvider, setDefaultProvider] = useState(null) - const [providerConfigs, setProviderConfigs] = useState>({ - openai: { apiKey: "", baseURL: "", models: [""], knowledgeGraphModel: "", meetingNotesModel: "", liveNoteAgentModel: "" }, - anthropic: { apiKey: "", baseURL: "", models: [""], knowledgeGraphModel: "", meetingNotesModel: "", liveNoteAgentModel: "" }, - google: { apiKey: "", baseURL: "", models: [""], knowledgeGraphModel: "", meetingNotesModel: "", liveNoteAgentModel: "" }, - openrouter: { apiKey: "", baseURL: "", models: [""], knowledgeGraphModel: "", meetingNotesModel: "", liveNoteAgentModel: "" }, - aigateway: { apiKey: "", baseURL: "", models: [""], knowledgeGraphModel: "", meetingNotesModel: "", liveNoteAgentModel: "" }, - ollama: { apiKey: "", baseURL: "http://localhost:11434", models: [""], knowledgeGraphModel: "", meetingNotesModel: "", liveNoteAgentModel: "" }, - "openai-compatible": { apiKey: "", baseURL: "http://localhost:1234/v1", models: [""], knowledgeGraphModel: "", meetingNotesModel: "", liveNoteAgentModel: "" }, + const [providerConfigs, setProviderConfigs] = useState>({ + openai: { apiKey: "", baseURL: "", models: [""], knowledgeGraphModel: "", meetingNotesModel: "", liveNoteAgentModel: "", autoPermissionDecisionModel: "" }, + anthropic: { apiKey: "", baseURL: "", models: [""], knowledgeGraphModel: "", meetingNotesModel: "", liveNoteAgentModel: "", autoPermissionDecisionModel: "" }, + google: { apiKey: "", baseURL: "", models: [""], knowledgeGraphModel: "", meetingNotesModel: "", liveNoteAgentModel: "", autoPermissionDecisionModel: "" }, + openrouter: { apiKey: "", baseURL: "", models: [""], knowledgeGraphModel: "", meetingNotesModel: "", liveNoteAgentModel: "", autoPermissionDecisionModel: "" }, + aigateway: { apiKey: "", baseURL: "", models: [""], knowledgeGraphModel: "", meetingNotesModel: "", liveNoteAgentModel: "", autoPermissionDecisionModel: "" }, + ollama: { apiKey: "", baseURL: "http://localhost:11434", models: [""], knowledgeGraphModel: "", meetingNotesModel: "", liveNoteAgentModel: "", autoPermissionDecisionModel: "" }, + "openai-compatible": { apiKey: "", baseURL: "http://localhost:1234/v1", models: [""], knowledgeGraphModel: "", meetingNotesModel: "", liveNoteAgentModel: "", autoPermissionDecisionModel: "" }, }) const [modelsCatalog, setModelsCatalog] = useState>({}) const [modelsLoading, setModelsLoading] = useState(false) @@ -313,7 +323,7 @@ function ModelSettings({ dialogOpen }: { dialogOpen: boolean }) { (!requiresBaseURL || activeConfig.baseURL.trim().length > 0) const updateConfig = useCallback( - (prov: LlmProviderFlavor, updates: Partial<{ apiKey: string; baseURL: string; models: string[]; knowledgeGraphModel: string; meetingNotesModel: string; liveNoteAgentModel: string }>) => { + (prov: LlmProviderFlavor, updates: Partial) => { setProviderConfigs(prev => ({ ...prev, [prov]: { ...prev[prov], ...updates }, @@ -388,6 +398,7 @@ function ModelSettings({ dialogOpen }: { dialogOpen: boolean }) { knowledgeGraphModel: e.knowledgeGraphModel || "", meetingNotesModel: e.meetingNotesModel || "", liveNoteAgentModel: e.liveNoteAgentModel || "", + autoPermissionDecisionModel: e.autoPermissionDecisionModel || "", }; } } @@ -406,6 +417,7 @@ function ModelSettings({ dialogOpen }: { dialogOpen: boolean }) { knowledgeGraphModel: parsed.knowledgeGraphModel || "", meetingNotesModel: parsed.meetingNotesModel || "", liveNoteAgentModel: parsed.liveNoteAgentModel || "", + autoPermissionDecisionModel: parsed.autoPermissionDecisionModel || "", }; } return next; @@ -481,6 +493,7 @@ function ModelSettings({ dialogOpen }: { dialogOpen: boolean }) { knowledgeGraphModel: activeConfig.knowledgeGraphModel.trim() || undefined, meetingNotesModel: activeConfig.meetingNotesModel.trim() || undefined, liveNoteAgentModel: activeConfig.liveNoteAgentModel.trim() || undefined, + autoPermissionDecisionModel: activeConfig.autoPermissionDecisionModel.trim() || undefined, } const result = await window.ipc.invoke("models:test", providerConfig) if (result.success) { @@ -515,6 +528,7 @@ function ModelSettings({ dialogOpen }: { dialogOpen: boolean }) { knowledgeGraphModel: config.knowledgeGraphModel.trim() || undefined, meetingNotesModel: config.meetingNotesModel.trim() || undefined, liveNoteAgentModel: config.liveNoteAgentModel.trim() || undefined, + autoPermissionDecisionModel: config.autoPermissionDecisionModel.trim() || undefined, }) setDefaultProvider(prov) window.dispatchEvent(new Event('models-config-changed')) @@ -546,6 +560,7 @@ function ModelSettings({ dialogOpen }: { dialogOpen: boolean }) { parsed.knowledgeGraphModel = defConfig.knowledgeGraphModel.trim() || undefined parsed.meetingNotesModel = defConfig.meetingNotesModel.trim() || undefined parsed.liveNoteAgentModel = defConfig.liveNoteAgentModel.trim() || undefined + parsed.autoPermissionDecisionModel = defConfig.autoPermissionDecisionModel.trim() || undefined } await window.ipc.invoke("workspace:writeFile", { path: "config/models.json", @@ -553,7 +568,7 @@ function ModelSettings({ dialogOpen }: { dialogOpen: boolean }) { }) setProviderConfigs(prev => ({ ...prev, - [prov]: { apiKey: "", baseURL: defaultBaseURLs[prov] || "", models: [""], knowledgeGraphModel: "", meetingNotesModel: "", liveNoteAgentModel: "" }, + [prov]: { apiKey: "", baseURL: defaultBaseURLs[prov] || "", models: [""], knowledgeGraphModel: "", meetingNotesModel: "", liveNoteAgentModel: "", autoPermissionDecisionModel: "" }, })) setTestState({ status: "idle" }) window.dispatchEvent(new Event('models-config-changed')) @@ -811,6 +826,40 @@ function ModelSettings({ dialogOpen }: { dialogOpen: boolean }) { )}
+ + {/* Auto-permission model */} +
+ Auto-permission model + {modelsLoading ? ( +
+ + Loading... +
+ ) : showModelInput ? ( + updateConfig(provider, { autoPermissionDecisionModel: e.target.value })} + placeholder={primaryModel || "Enter model"} + /> + ) : ( + + )} +
{/* API Key */} diff --git a/apps/x/apps/renderer/src/lib/chat-conversation.ts b/apps/x/apps/renderer/src/lib/chat-conversation.ts index bbf1cde2..7b0c15c6 100644 --- a/apps/x/apps/renderer/src/lib/chat-conversation.ts +++ b/apps/x/apps/renderer/src/lib/chat-conversation.ts @@ -1,6 +1,6 @@ import type { ToolUIPart } from 'ai' 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' export interface MessageAttachment { @@ -46,6 +46,7 @@ export type ChatTabViewState = { pendingAskHumanRequests: Map> allPermissionRequests: Map> permissionResponses: Map + autoPermissionDecisions: Map> } export type ChatViewportAnchorState = { @@ -60,6 +61,7 @@ export const createEmptyChatTabViewState = (): ChatTabViewState => ({ pendingAskHumanRequests: new Map(), allPermissionRequests: new Map(), permissionResponses: new Map(), + autoPermissionDecisions: new Map(), }) export type ToolState = 'input-streaming' | 'input-available' | 'output-available' | 'output-error' diff --git a/apps/x/packages/core/src/agents/runtime.ts b/apps/x/packages/core/src/agents/runtime.ts index 84aa4092..f42fad72 100644 --- a/apps/x/packages/core/src/agents/runtime.ts +++ b/apps/x/packages/core/src/agents/runtime.ts @@ -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 getInlineTaskAgentRaw } from "../knowledge/inline_task_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'); @@ -901,6 +902,7 @@ export class AgentState { agentName: string | null = null; runModel: string | null = null; runProvider: string | null = null; + permissionMode: "manual" | "auto" = "manual"; runUseCase: UseCase | null = null; runSubUseCase: string | null = null; messages: z.infer = []; @@ -912,6 +914,8 @@ export class AgentState { pendingAskHumanRequests: Record> = {}; allowedToolCallIds: Record = {}; deniedToolCallIds: Record = {}; + autoAllowedToolCalls: Record = {}; + autoDeniedToolCalls: Record = {}; sessionAllowedCommands: Set = new Set(); sessionAllowedFileAccess: FileAccessGrant[] = []; @@ -1019,6 +1023,7 @@ export class AgentState { this.agentName = event.agentName; this.runModel = event.model; this.runProvider = event.provider; + this.permissionMode = event.permissionMode ?? "manual"; this.runUseCase = event.useCase ?? null; this.runSubUseCase = event.subUseCase ?? null; break; @@ -1031,6 +1036,7 @@ export class AgentState { this.subflowStates[event.toolCallId].agentName = event.agentName; this.subflowStates[event.toolCallId].runModel = this.runModel; 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].runSubUseCase = this.runSubUseCase; break; @@ -1081,10 +1087,22 @@ export class AgentState { break; case "deny": this.deniedToolCallIds[event.toolCallId] = true; + delete this.autoDeniedToolCalls[event.toolCallId]; break; } delete this.pendingToolPermissionRequests[event.toolCallId]; 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": this.pendingAskHumanRequests[event.toolCallId] = event; break; @@ -1190,13 +1208,19 @@ export async function* streamAgent({ // if tool has been denied, deny if (state.deniedToolCallIds[toolCallId]) { _logger.log('returning denied tool message, reason: tool has been denied'); + const autoDenied = state.autoDeniedToolCalls[toolCallId]; yield* processEvent({ runId, messageId: await idGenerator.next(), type: "message", message: { 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, 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 (message.content instanceof Array) { + const permissionCandidates: AutoPermissionCandidate[] = []; for (const part of message.content) { if (part.type === "tool-call") { 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, ); if (permission) { - loopLogger.log('emitting tool-permission-request, toolCallId:', part.toolCallId); - yield* processEvent({ - runId, - type: "tool-permission-request", - toolCall: part, - permission, - subflow: [], - }); + permissionCandidates.push({ toolCall: part, permission }); } if (underlyingTool.type === "agent" && underlyingTool.name) { 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(); + 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: [], + }); + } + } + } } } } diff --git a/apps/x/packages/core/src/models/defaults.ts b/apps/x/packages/core/src/models/defaults.ts index 3163438c..6d05e393 100644 --- a/apps/x/packages/core/src/models/defaults.ts +++ b/apps/x/packages/core/src/models/defaults.ts @@ -8,6 +8,7 @@ const SIGNED_IN_DEFAULT_MODEL = "gpt-5.4"; const SIGNED_IN_DEFAULT_PROVIDER = "rowboat"; 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_AUTO_PERMISSION_DECISION_MODEL = "google/gemini-3.1-flash-lite"; /** * The single source of truth for "what model+provider should we use when @@ -76,6 +77,17 @@ export async function getLiveNoteAgentModel(): Promise { 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 { + if (await isSignedIn()) return SIGNED_IN_AUTO_PERMISSION_DECISION_MODEL; + const cfg = await container.resolve("modelConfigRepo").getConfig(); + return cfg.autoPermissionDecisionModel ?? cfg.model; +} + /** * Model used by the meeting-notes summarizer. No special signed-in default — * historically meetings used the assistant model. BYOK: user override diff --git a/apps/x/packages/core/src/models/repo.ts b/apps/x/packages/core/src/models/repo.ts index 3f21e675..29febf4b 100644 --- a/apps/x/packages/core/src/models/repo.ts +++ b/apps/x/packages/core/src/models/repo.ts @@ -53,6 +53,7 @@ export class FSModelConfigRepo implements IModelConfigRepo { knowledgeGraphModel: config.knowledgeGraphModel, meetingNotesModel: config.meetingNotesModel, liveNoteAgentModel: config.liveNoteAgentModel, + autoPermissionDecisionModel: config.autoPermissionDecisionModel, }; const toWrite = { ...config, providers: existingProviders }; diff --git a/apps/x/packages/core/src/runs/repo.ts b/apps/x/packages/core/src/runs/repo.ts index addb4f35..8a1d3e85 100644 --- a/apps/x/packages/core/src/runs/repo.ts +++ b/apps/x/packages/core/src/runs/repo.ts @@ -35,6 +35,7 @@ export type CreateRunRepoOptions = { agentId: string; model: string; provider: string; + permissionMode: "manual" | "auto"; useCase: z.infer; subUseCase?: string; }; @@ -204,6 +205,7 @@ export class FSRunsRepo implements IRunsRepo { agentName: options.agentId, model: options.model, provider: options.provider, + permissionMode: options.permissionMode, useCase: options.useCase, ...(options.subUseCase ? { subUseCase: options.subUseCase } : {}), subflow: [], @@ -216,6 +218,7 @@ export class FSRunsRepo implements IRunsRepo { agentId: options.agentId, model: options.model, provider: options.provider, + permissionMode: options.permissionMode, useCase: options.useCase, ...(options.subUseCase ? { subUseCase: options.subUseCase } : {}), log: [start], @@ -251,6 +254,7 @@ export class FSRunsRepo implements IRunsRepo { agentId: start.agentName, model: start.model, provider: start.provider, + permissionMode: start.permissionMode ?? "manual", ...(start.useCase ? { useCase: start.useCase } : {}), ...(start.subUseCase ? { subUseCase: start.subUseCase } : {}), log: events, @@ -320,4 +324,4 @@ export class FSRunsRepo implements IRunsRepo { async delete(id: string): Promise { await fsp.unlink(runLogPath(id)); } -} \ No newline at end of file +} diff --git a/apps/x/packages/core/src/runs/runs.ts b/apps/x/packages/core/src/runs/runs.ts index c316cd3b..f832c00d 100644 --- a/apps/x/packages/core/src/runs/runs.ts +++ b/apps/x/packages/core/src/runs/runs.ts @@ -32,6 +32,7 @@ export async function createRun(opts: z.infer): Promise agentId: opts.agentId, model, provider, + permissionMode: opts.permissionMode ?? "manual", useCase, ...(opts.subUseCase ? { subUseCase: opts.subUseCase } : {}), }); diff --git a/apps/x/packages/core/src/security/auto-permission-classifier.ts b/apps/x/packages/core/src/security/auto-permission-classifier.ts new file mode 100644 index 00000000..352512be --- /dev/null +++ b/apps/x/packages/core/src/security/auto-permission-classifier.ts @@ -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; + permission: z.infer; +}; + +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...`; +} + +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 { + 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)); +} diff --git a/apps/x/packages/shared/src/models.ts b/apps/x/packages/shared/src/models.ts index 3a24c217..4571de2c 100644 --- a/apps/x/packages/shared/src/models.ts +++ b/apps/x/packages/shared/src/models.ts @@ -17,10 +17,15 @@ export const LlmModelConfig = z.object({ headers: z.record(z.string(), z.string()).optional(), model: 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(), // Per-category model overrides (BYOK only — signed-in users always get // the curated gateway defaults). Read by helpers in core/models/defaults.ts. knowledgeGraphModel: z.string().optional(), meetingNotesModel: z.string().optional(), liveNoteAgentModel: z.string().optional(), + autoPermissionDecisionModel: z.string().optional(), }); diff --git a/apps/x/packages/shared/src/runs.ts b/apps/x/packages/shared/src/runs.ts index a977db0b..a4deea9a 100644 --- a/apps/x/packages/shared/src/runs.ts +++ b/apps/x/packages/shared/src/runs.ts @@ -21,6 +21,7 @@ export const StartEvent = BaseRunEvent.extend({ agentName: z.string(), model: z.string(), provider: z.string(), + permissionMode: z.enum(["manual", "auto"]).optional(), // useCase/subUseCase tag the run for analytics. Optional on read so legacy // run files written before these fields existed still parse cleanly. useCase: z.enum([ @@ -110,6 +111,15 @@ export const ToolPermissionResponseEvent = BaseRunEvent.extend({ 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({ type: z.literal("error"), error: z.string(), @@ -134,6 +144,7 @@ export const RunEvent = z.union([ AskHumanResponseEvent, ToolPermissionRequestEvent, ToolPermissionResponseEvent, + ToolPermissionAutoDecisionEvent, RunErrorEvent, RunStoppedEvent, ]); @@ -166,6 +177,7 @@ export const Run = z.object({ agentId: z.string(), model: z.string(), provider: z.string(), + permissionMode: z.enum(["manual", "auto"]).optional(), useCase: UseCase.optional(), subUseCase: z.string().optional(), log: z.array(RunEvent), @@ -185,6 +197,7 @@ export const CreateRunOptions = z.object({ agentId: z.string(), model: z.string().optional(), provider: z.string().optional(), + permissionMode: z.enum(["manual", "auto"]).optional(), useCase: UseCase.optional(), subUseCase: z.string().optional(), }); From 547a22ae1a2da290d73ef1296bf74a618f27cbed Mon Sep 17 00:00:00 2001 From: hrsvrn Date: Wed, 3 Jun 2026 11:26:17 +0530 Subject: [PATCH 3/8] added icon and rowboat url scheme handler to linux packages --- apps/x/apps/main/forge.config.cjs | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/apps/x/apps/main/forge.config.cjs b/apps/x/apps/main/forge.config.cjs index ad639a86..8cd34acd 100644 --- a/apps/x/apps/main/forge.config.cjs +++ b/apps/x/apps/main/forge.config.cjs @@ -66,7 +66,9 @@ module.exports = { bin: "rowboat", description: 'AI coworker with memory', maintainer: 'rowboatlabs', - homepage: 'https://rowboatlabs.com' + homepage: 'https://rowboatlabs.com', + icon: path.join(__dirname, 'icons/icon.png'), + mimeType: ['x-scheme-handler/rowboat'], } }) }, @@ -77,7 +79,9 @@ module.exports = { name: `Rowboat-linux`, bin: "rowboat", description: 'AI coworker with memory', - homepage: 'https://rowboatlabs.com' + homepage: 'https://rowboatlabs.com', + icon: path.join(__dirname, 'icons/icon.png'), + mimeType: ['x-scheme-handler/rowboat'], } } }, From 81cc4e10b727400af4dd67a45d9b7f66cafff955 Mon Sep 17 00:00:00 2001 From: gagan Date: Thu, 4 Jun 2026 14:01:10 +0530 Subject: [PATCH 4/8] fix: set rowboat icon for windows taskbar and installer (#595) Co-authored-by: arkml <6592213+arkml@users.noreply.github.com> --- apps/x/apps/main/forge.config.cjs | 1 + apps/x/apps/main/icons/icon.ico | Bin 0 -> 4286 bytes apps/x/apps/main/src/main.ts | 1 + 3 files changed, 2 insertions(+) create mode 100644 apps/x/apps/main/icons/icon.ico diff --git a/apps/x/apps/main/forge.config.cjs b/apps/x/apps/main/forge.config.cjs index 8cd34acd..7806f6cd 100644 --- a/apps/x/apps/main/forge.config.cjs +++ b/apps/x/apps/main/forge.config.cjs @@ -56,6 +56,7 @@ module.exports = { description: 'AI coworker with memory', name: `Rowboat-win32-${arch}`, setupExe: `Rowboat-win32-${arch}-${pkg.version}-setup.exe`, + setupIcon: path.join(__dirname, 'icons/icon.ico'), }) }, { diff --git a/apps/x/apps/main/icons/icon.ico b/apps/x/apps/main/icons/icon.ico new file mode 100644 index 0000000000000000000000000000000000000000..0e5ac8701ad6d4d73a4578cf7066b20668d6f327 GIT binary patch literal 4286 zcmc(iJ&!3-7{|x^OF}LY6eJ{sAav@j6+)?&=zIj*C?u;v;w#8))Hc~dmO_C}f|`U5 z#f`$tbLT&sbK_<0wXB(ubI!~eGr#|N&htEH4#&y(pUcIe=6?U;I4j3-e!lQs+!J5W zFC6^6KTjX!qeG|-eErOqIbTSm0v4Ezy%vdkI6g3;VI~2a=>U5#A^$G6LZKk7R?EgB zdaF{YD3wZ)*XtFX&E_l~k5i-3Aiv))i3X$5NX=%G+U+*E-EK)VWV2bqQLop@Y&MIo zAruPHY&H`#6bc1WDwU#Y(CKva`uZyHSF05zlSxtea=DypwVJ?3)$8@h;c$qi!Rd6; zcs%~c$4Ne)Cz(tpiiUJLP227Eh(DQ3C=!W0oNu$)sMF~jvDrnrTqev7%N5tO_`h1M zzUmo`Mnf+zFSOt98SAg%Qb2lG45Ch?Gm@1WupyN{PpNgnf zt3SeLw!pVvAP^wLH)8C6dEo8s?P1@Fh;PIg{2W@A%jKD$42MIq+wDJU17-`>V!bn& zOoFdrzF~8Wdo&u+U@#z`&-YM1({b1$Z(`hF>-GB2_;>9A4p}#xgDv&`>zUUe<^GfH Nfu-7i^TU_!_zUc72ZsOv literal 0 HcmV?d00001 diff --git a/apps/x/apps/main/src/main.ts b/apps/x/apps/main/src/main.ts index 81d43553..780d78cd 100644 --- a/apps/x/apps/main/src/main.ts +++ b/apps/x/apps/main/src/main.ts @@ -220,6 +220,7 @@ function createWindow() { backgroundColor: "#252525", // Prevent white flash (matches dark mode) titleBarStyle: "hiddenInset", trafficLightPosition: { x: 12, y: 12 }, + icon: process.platform !== "darwin" ? path.join(__dirname, "../../icons/icon.png") : undefined, webPreferences: { // IMPORTANT: keep Node out of renderer nodeIntegration: false, From 05a93c98ae7c3352ccf3222a38eb2cc3bb5d1cc2 Mon Sep 17 00:00:00 2001 From: Arjun <6592213+arkml@users.noreply.github.com> Date: Fri, 5 Jun 2026 00:04:21 +0530 Subject: [PATCH 5/8] show last working directories --- .../components/chat-input-with-mentions.tsx | 236 +++++++++++++++++- 1 file changed, 224 insertions(+), 12 deletions(-) diff --git a/apps/x/apps/renderer/src/components/chat-input-with-mentions.tsx b/apps/x/apps/renderer/src/components/chat-input-with-mentions.tsx index 8c62054c..624b0e7c 100644 --- a/apps/x/apps/renderer/src/components/chat-input-with-mentions.tsx +++ b/apps/x/apps/renderer/src/components/chat-input-with-mentions.tsx @@ -10,7 +10,10 @@ import { FileSpreadsheet, FileText, FileVideo, + FolderCheck, + FolderClock, FolderCog, + FolderOpen, Globe, Headphones, ImagePlus, @@ -30,6 +33,9 @@ import { DropdownMenuItem, DropdownMenuRadioGroup, DropdownMenuRadioItem, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, DropdownMenuTrigger, } from '@/components/ui/dropdown-menu' import { @@ -61,6 +67,12 @@ export type StagedAttachment = { } const MAX_ATTACHMENT_SIZE = 10 * 1024 * 1024 // 10MB +const MAX_VISIBLE_RECENT_WORK_DIRS = 3 +const MAX_STORED_RECENT_WORK_DIRS = 8 +// Stored in the workspace (~/.rowboat/config) so it travels with the workspace and +// stays consistent with the other config/*.json files (e.g. coding-agents.json). +const RECENT_WORK_DIRS_CONFIG_PATH = 'config/recent-work-dirs.json' +const RECENT_WORK_DIRS_CHANGED_EVENT = 'rowboat-chat-recent-work-dirs-changed' const providerDisplayNames: Record = { @@ -81,6 +93,11 @@ interface ConfiguredModel { model: string } +type RecentWorkDir = { + path: string + lastUsedAt: number +} + export interface SelectedModel { provider: string model: string @@ -111,6 +128,84 @@ function getAttachmentIcon(kind: AttachmentIconKind) { } } +function normalizeRecentWorkDir(value: unknown): RecentWorkDir | null { + if (typeof value === 'string') { + const path = value.trim() + return path ? { path, lastUsedAt: 0 } : null + } + if (!value || typeof value !== 'object') return null + const entry = value as Record + const path = typeof entry.path === 'string' ? entry.path.trim() : '' + const lastUsedAt = typeof entry.lastUsedAt === 'number' && Number.isFinite(entry.lastUsedAt) + ? entry.lastUsedAt + : 0 + return path ? { path, lastUsedAt } : null +} + +async function readRecentWorkDirs(): Promise { + try { + const result = await window.ipc.invoke('workspace:readFile', { path: RECENT_WORK_DIRS_CONFIG_PATH }) + const parsed = JSON.parse(result.data) + if (!Array.isArray(parsed)) return [] + const seen = new Set() + const dirs: RecentWorkDir[] = [] + for (const value of parsed) { + const entry = normalizeRecentWorkDir(value) + if (!entry || seen.has(entry.path)) continue + seen.add(entry.path) + dirs.push(entry) + if (dirs.length >= MAX_STORED_RECENT_WORK_DIRS) break + } + return dirs + } catch { + // File missing or invalid — no recents yet. + return [] + } +} + +async function writeRecentWorkDirs(dirs: RecentWorkDir[]) { + try { + await window.ipc.invoke('workspace:writeFile', { + path: RECENT_WORK_DIRS_CONFIG_PATH, + data: JSON.stringify(dirs.slice(0, MAX_STORED_RECENT_WORK_DIRS), null, 2), + }) + } catch (err) { + console.error('Failed to persist recent work directories', err) + } + // Notify other mounted chat inputs in this window to re-read. + window.dispatchEvent(new CustomEvent(RECENT_WORK_DIRS_CHANGED_EVENT)) +} + +function formatRecentWorkDirTime(lastUsedAt: number) { + if (!lastUsedAt) return '' + const now = Date.now() + const diffMs = Math.max(0, now - lastUsedAt) + const minute = 60 * 1000 + const hour = 60 * minute + const day = 24 * hour + if (diffMs < minute) return 'now' + if (diffMs < hour) return `${Math.max(1, Math.floor(diffMs / minute))}m ago` + if (diffMs < day) return `${Math.floor(diffMs / hour)}h ago` + + const used = new Date(lastUsedAt) + const yesterday = new Date(now - day) + if ( + used.getFullYear() === yesterday.getFullYear() && + used.getMonth() === yesterday.getMonth() && + used.getDate() === yesterday.getDate() + ) { + return 'Yesterday' + } + if (diffMs < 7 * day) { + return used.toLocaleDateString(undefined, { weekday: 'short' }) + } + return used.toLocaleDateString(undefined, { month: 'short', day: 'numeric' }) +} + +function compactWorkDirPath(path: string) { + return path.replace(/^\/Users\/[^/]+/, '~') +} + interface ChatInputInnerProps { onSubmit: (message: PromptInputMessage, mentions?: FileMention[], attachments?: StagedAttachment[], searchEnabled?: boolean, codeMode?: 'claude' | 'codex', permissionMode?: PermissionMode) => void onStop?: () => void @@ -186,6 +281,7 @@ function ChatInputInner({ const [codeModeEnabled, setCodeModeEnabled] = useState(false) const [codeModeFeatureEnabled, setCodeModeFeatureEnabled] = useState(false) const [permissionMode, setPermissionMode] = useState('auto') + const [recentWorkDirs, setRecentWorkDirs] = useState([]) // When a run exists, freeze the dropdown to the run's resolved model+provider. useEffect(() => { @@ -205,6 +301,15 @@ function ChatInputInner({ return () => { cancelled = true } }, [runId]) + useEffect(() => { + const syncRecentWorkDirs = () => { void readRecentWorkDirs().then(setRecentWorkDirs) } + syncRecentWorkDirs() + window.addEventListener(RECENT_WORK_DIRS_CHANGED_EVENT, syncRecentWorkDirs) + return () => { + window.removeEventListener(RECENT_WORK_DIRS_CHANGED_EVENT, syncRecentWorkDirs) + } + }, []) + // Check Rowboat sign-in state useEffect(() => { window.ipc.invoke('oauth:getState', null).then((result) => { @@ -311,6 +416,17 @@ function ChatInputInner({ return idx >= 0 ? trimmed.slice(idx + 1) : trimmed }, []) + const rememberWorkDir = useCallback(async (dir: string) => { + const trimmed = dir.trim() + if (!trimmed) return + const next = [ + { path: trimmed, lastUsedAt: Date.now() }, + ...(await readRecentWorkDirs()).filter((item) => item.path !== trimmed), + ].slice(0, MAX_STORED_RECENT_WORK_DIRS) + setRecentWorkDirs(next) + await writeRecentWorkDirs(next) + }, []) + // Load coding-agent preference for a given workdir. // Storage: config/coding-agents.json — { [workDirPath]: 'claude' | 'codex' } const loadCodingAgentFor = useCallback(async (dir: string | null): Promise<'claude' | 'codex'> => { @@ -327,7 +443,7 @@ function ChatInputInner({ }, []) const persistCodingAgent = useCallback(async (dir: string, agent: 'claude' | 'codex') => { - let existing: Record = {} + const existing: Record = {} try { const result = await window.ipc.invoke('workspace:readFile', { path: 'config/coding-agents.json' }) const parsed = JSON.parse(result.data) as Record @@ -353,6 +469,10 @@ function ChatInputInner({ return () => { cancelled = true } }, [workDir, loadCodingAgentFor]) + useEffect(() => { + if (isActive && workDir) void rememberWorkDir(workDir) + }, [isActive, workDir, rememberWorkDir]) + const handleSetWorkDir = useCallback(async () => { try { let defaultPath: string | undefined = workDir ?? undefined @@ -373,13 +493,21 @@ function ChatInputInner({ }) if (!chosen) return onWorkDirChange?.(chosen) + await rememberWorkDir(chosen) setCodingAgent(await loadCodingAgentFor(chosen)) toast.success(`Work directory set: ${chosen}`) } catch (err) { console.error('Failed to set work directory', err) toast.error('Failed to set work directory') } - }, [workDir, onWorkDirChange, loadCodingAgentFor]) + }, [workDir, onWorkDirChange, rememberWorkDir, loadCodingAgentFor]) + + const handleSelectRecentWorkDir = useCallback(async (dir: string) => { + onWorkDirChange?.(dir) + await rememberWorkDir(dir) + setCodingAgent(await loadCodingAgentFor(dir)) + toast.success(`Work directory set: ${dir}`) + }, [onWorkDirChange, rememberWorkDir, loadCodingAgentFor]) const handleClearWorkDir = useCallback(() => { onWorkDirChange?.(null) @@ -533,6 +661,12 @@ function ChatInputInner({ } }, [addFiles, isActive]) + const visibleRecentWorkDirs = recentWorkDirs + .filter((entry) => entry.path !== workDir) + .slice(0, MAX_VISIBLE_RECENT_WORK_DIRS) + const currentWorkDirLabel = workDir ? basename(workDir) || workDir : 'Not set' + const currentWorkDirPath = workDir ? compactWorkDirPath(workDir) : '' + return (
{attachments.length > 0 && ( @@ -651,17 +785,95 @@ function ChatInputInner({ - Add files or set work directory + + {workDir ? 'Add files or change work directory' : 'Add files or set work directory'} + - - fileInputRef.current?.click()}> - - Add files or photos - - { void handleSetWorkDir() }}> - - {workDir ? 'Change work directory' : 'Set work directory'} - + +
+ fileInputRef.current?.click()} className="h-9 rounded-[9px] px-2.5"> + + Add files or photos + + + {/* Working directory lives behind a submenu so the main menu stays to two + items. One hover/click away for power users; out of the way otherwise. */} + + + + + Set working directory + + {currentWorkDirLabel} + + + + + {/* Current selection — shown for context only when one is set. */} + {workDir && ( +
+ + + {currentWorkDirLabel} + + {currentWorkDirPath} + + +
+ )} + + {/* Primary action: choose when unset, change when set. Always on top. */} + { void handleSetWorkDir() }} + className="h-9 rounded-[9px] px-2.5" + > + + {workDir ? 'Change folder…' : 'Choose a folder…'} + + + {visibleRecentWorkDirs.length > 0 && ( + <> +
+ Recent +
+ {visibleRecentWorkDirs.map((entry) => { + const name = basename(entry.path) || entry.path + const when = formatRecentWorkDirTime(entry.lastUsedAt) + return ( + { void handleSelectRecentWorkDir(entry.path) }} + className="h-8 rounded-[9px] px-2.5" + > + + {name} + {when && {when}} + + ) + })} + + )} + + {/* Clear — only meaningful once a directory is set. Kept at the bottom. */} + {workDir && ( + <> +
+ + + Clear folder + + + )} + + +
{workDir && ( From 97c8f9d7876bbe2a86527fd94a582acf414c9896 Mon Sep 17 00:00:00 2001 From: Arjun <6592213+arkml@users.noreply.github.com> Date: Fri, 5 Jun 2026 00:43:43 +0530 Subject: [PATCH 6/8] hide background task details if there is output already --- .../renderer/src/components/bg-tasks-view.tsx | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/apps/x/apps/renderer/src/components/bg-tasks-view.tsx b/apps/x/apps/renderer/src/components/bg-tasks-view.tsx index b4574b58..4ba7479f 100644 --- a/apps/x/apps/renderer/src/components/bg-tasks-view.tsx +++ b/apps/x/apps/renderer/src/components/bg-tasks-view.tsx @@ -1237,6 +1237,8 @@ function TaskDetail({ const [confirmingDelete, setConfirmingDelete] = useState(false) const [sidebarOpen, setSidebarOpen] = useState(true) const [outputRefreshKey, setOutputRefreshKey] = useState(0) + // Whether we've already chosen the initial sidebar state for this task. + const sidebarInitialized = useRef(false) const agentStatus = useBackgroundTaskAgentStatus() const liveStatus = agentStatus.get(slug) @@ -1252,6 +1254,23 @@ function TaskDetail({ if (result.success && result.task) { setTask(result.task) setDraft(result.task) + // On first open, collapse the details sidebar when the agent + // already has output — let the user read it without chrome. + // Resolved before `loading` clears so the sidebar never flashes. + if (!sidebarInitialized.current) { + sidebarInitialized.current = true + try { + const out = await window.ipc.invoke('workspace:readFile', { + path: `bg-tasks/${slug}/index.md`, + }) + const body = (out.data ?? '').trim() + if (body && body !== `# ${result.task.name}`) { + setSidebarOpen(false) + } + } catch { + // No output file yet — keep the sidebar open. + } + } } } finally { setLoading(false) From 7f3c16cc332569f0e7fbbd94c5701bc4072e88c2 Mon Sep 17 00:00:00 2001 From: arkml <6592213+arkml@users.noreply.github.com> Date: Fri, 5 Jun 2026 00:49:49 +0530 Subject: [PATCH 7/8] Pane placement (#598) * allow user to change pane placement * allow user to change starting pane size --- apps/x/apps/renderer/src/App.tsx | 36 +++++++++++--- .../renderer/src/components/chat-sidebar.tsx | 35 ++++++++++---- .../src/components/settings-dialog.tsx | 48 ++++++++++++++++++- .../renderer/src/contexts/theme-context.tsx | 42 +++++++++++++++- 4 files changed, 144 insertions(+), 17 deletions(-) diff --git a/apps/x/apps/renderer/src/App.tsx b/apps/x/apps/renderer/src/App.tsx index 56445821..df85e06b 100644 --- a/apps/x/apps/renderer/src/App.tsx +++ b/apps/x/apps/renderer/src/App.tsx @@ -5,7 +5,7 @@ import { RunEvent, ListRunsResponse } from '@x/shared/src/runs.js'; import type { LanguageModelUsage, ToolUIPart } from 'ai'; import './App.css' import z from 'zod'; -import { CheckIcon, LoaderIcon, PanelLeftIcon, ArrowRight, MessageSquare, ChevronLeftIcon, ChevronRightIcon, Plus, HistoryIcon } from 'lucide-react'; +import { CheckIcon, LoaderIcon, PanelLeftIcon, ArrowLeft, ArrowRight, MessageSquare, ChevronLeftIcon, ChevronRightIcon, Plus, HistoryIcon } from 'lucide-react'; import { cn } from '@/lib/utils'; import { MarkdownEditor, type MarkdownEditorHandle } from './components/markdown-editor'; import { ChatSidebar } from './components/chat-sidebar'; @@ -117,6 +117,7 @@ import { useVoiceTTS } from '@/hooks/useVoiceTTS' import { useMeetingTranscription, type CalendarEventMeta } from '@/hooks/useMeetingTranscription' import { useAnalyticsIdentity } from '@/hooks/useAnalyticsIdentity' import * as analytics from '@/lib/analytics' +import { useTheme } from '@/contexts/theme-context' type DirEntry = z.infer type RunEventType = z.infer @@ -165,6 +166,7 @@ function AutoScrollPre({ className, children }: { className?: string; children: } const DEFAULT_SIDEBAR_WIDTH = 256 +const DEFAULT_CHAT_PANE_WIDTH = 460 const wikiLinkRegex = /\[\[([^[\]]+)\]\]/g const graphPalette = [ { hue: 210, sat: 72, light: 52 }, @@ -736,6 +738,9 @@ function ContentHeader({ } function App() { + const { chatPanePlacement, chatPaneSize } = useTheme() + const isChatPaneInMiddle = chatPanePlacement === 'middle' + type ShortcutPane = 'left' | 'right' type MarkdownHistoryHandlers = { undo: () => boolean; redo: () => boolean } @@ -765,7 +770,7 @@ function App() { // Lives in ViewState so folder drill-down participates in back/forward history. const [knowledgeViewFolderPath, setKnowledgeViewFolderPath] = useState(null) const [isChatHistoryOpen, setIsChatHistoryOpen] = useState(false) - // Default landing view: Home in the middle with the chat docked on the right. + // Default landing view: Home with the chat docked according to appearance settings. const [isHomeOpen, setIsHomeOpen] = useState(true) const [emailInitialThreadId, setEmailInitialThreadId] = useState(null) const [emailThreadIdVersion, setEmailThreadIdVersion] = useState(0) @@ -5246,6 +5251,17 @@ function App() { const isRightPaneContext = Boolean(selectedPath || isGraphOpen || isSuggestedTopicsOpen || isMeetingsOpen || isLiveNotesOpen || isBgTasksOpen || isEmailOpen || isWorkspaceOpen || isKnowledgeViewOpen || isChatHistoryOpen || isHomeOpen || isBrowserOpen) const isRightPaneOnlyMode = isRightPaneContext && isChatSidebarOpen && isRightPaneMaximized const shouldCollapseLeftPane = isRightPaneOnlyMode + const nonChatPaneStyle = React.useMemo(() => { + const style: React.CSSProperties = { maxWidth: insetMaxWidth } + if (!isRightPaneContext || !isChatSidebarOpen || isRightPaneMaximized) return style + if (chatPaneSize === 'chat-equal') { + return { ...style, width: 0, flex: '1 1 0' } + } + if (chatPaneSize === 'chat-bigger') { + return { ...style, width: DEFAULT_CHAT_PANE_WIDTH, flex: '0 0 auto' } + } + return style + }, [chatPaneSize, insetMaxWidth, isChatSidebarOpen, isRightPaneContext, isRightPaneMaximized]) // Collapsing: pin max-width to the snapshot px (no transition) for one frame so it's // binding immediately (no flex jump), then animate to 0. Expanding goes back to 100% // — its non-binding range lands at the end of the range, where it isn't visible. @@ -5323,10 +5339,11 @@ function App() { setActiveShortcutPane('left')} onFocusCapture={() => setActiveShortcutPane('left')} @@ -5438,7 +5455,11 @@ function App() { : (viewOpen && !isChatSidebarOpen) ? { onClick: openChatSidePane, icon: , label: 'Open chat' } : (viewOpen && isChatSidebarOpen && !isRightPaneMaximized) - ? { onClick: () => setIsChatSidebarOpen(false), icon: , label: 'Expand pane' } + ? { + onClick: () => setIsChatSidebarOpen(false), + icon: isChatPaneInMiddle ? : , + label: 'Expand pane' + } : null return ( @@ -5989,10 +6010,13 @@ function App() { )} - {/* Chat sidebar - shown when viewing files/graph */} + {/* Chat pane - shown when viewing files/graph */} {isRightPaneContext && ( string @@ -183,6 +187,9 @@ export function ChatSidebar({ defaultWidth = DEFAULT_WIDTH, isOpen = true, isMaximized = false, + placement = 'right', + paneSize = 'chat-smaller', + className, chatTabs, activeChatTabId, getChatTabTitle, @@ -246,6 +253,8 @@ export function ChatSidebar({ const startWidthRef = useRef(0) const prevIsMaximizedRef = useRef(isMaximized) const justToggledMaximize = prevIsMaximizedRef.current !== isMaximized + const isMiddlePlacement = placement === 'middle' + const isResizable = paneSize === 'chat-smaller' const getMaxAllowedWidth = useCallback(() => { if (typeof window === 'undefined') return MAX_WIDTH @@ -306,7 +315,9 @@ export function ChatSidebar({ setIsResizing(true) const handleMouseMove = (event: MouseEvent) => { - const delta = startXRef.current - event.clientX + const delta = isMiddlePlacement + ? event.clientX - startXRef.current + : startXRef.current - event.clientX const maxAllowedWidth = getMaxAllowedWidth() setWidth(clampPaneWidth(startWidthRef.current + delta, maxAllowedWidth)) } @@ -319,7 +330,7 @@ export function ChatSidebar({ document.addEventListener('mousemove', handleMouseMove) document.addEventListener('mouseup', handleMouseUp) - }, [width, getMaxAllowedWidth]) + }, [width, getMaxAllowedWidth, isMiddlePlacement]) const activeTabState = useMemo(() => ({ runId: runId ?? null, @@ -501,8 +512,11 @@ export function ChatSidebar({ // not add extra width to the right and overflow the app viewport. return { width: 0, flex: '1 1 auto' } } + if (paneSize === 'chat-equal' || paneSize === 'chat-bigger') { + return { width: 0, flex: '1 1 0' } + } return { width, flex: '0 0 auto' } - }, [isOpen, isMaximized, width]) + }, [isOpen, isMaximized, paneSize, width]) return (
- {!isMaximized && ( + {!isMaximized && isResizable && (
- {isMaximized ? : } + {isMaximized + ? (isMiddlePlacement ? : ) + : (isMiddlePlacement ? : )} {isMaximized ? 'Dock to side pane' : 'Expand chat'} diff --git a/apps/x/apps/renderer/src/components/settings-dialog.tsx b/apps/x/apps/renderer/src/components/settings-dialog.tsx index 74082664..bf85d99b 100644 --- a/apps/x/apps/renderer/src/components/settings-dialog.tsx +++ b/apps/x/apps/renderer/src/components/settings-dialog.tsx @@ -2,7 +2,7 @@ import * as React from "react" import { useState, useEffect, useCallback, useMemo } from "react" -import { Server, Key, Shield, Palette, Monitor, Sun, Moon, Loader2, CheckCircle2, Plus, X, Wrench, Search, ChevronRight, Link2, Tags, Mail, BookOpen, User, Plug, HelpCircle, MessageCircle, Bug, Terminal, AlertTriangle, RefreshCw } from "lucide-react" +import { Server, Key, Shield, Palette, Monitor, Sun, Moon, Loader2, CheckCircle2, Plus, X, Wrench, Search, ChevronRight, Link2, Tags, Mail, BookOpen, User, Plug, HelpCircle, MessageCircle, Bug, Terminal, AlertTriangle, RefreshCw, PanelRight } from "lucide-react" import { Dialog, @@ -210,7 +210,7 @@ function ThemeOption({ } function AppearanceSettings() { - const { theme, setTheme } = useTheme() + const { theme, setTheme, chatPanePlacement, setChatPanePlacement, chatPaneSize, setChatPaneSize } = useTheme() return (
@@ -240,6 +240,50 @@ function AppearanceSettings() { />
+
+

Chat

+

+ Choose where chat sits when another pane is open +

+
+ setChatPanePlacement("right")} + /> + setChatPanePlacement("middle")} + /> +
+

Chat size

+

+ Choose how much width chat gets when another pane is open +

+
+ setChatPaneSize("chat-smaller")} + /> + setChatPaneSize("chat-equal")} + /> + setChatPaneSize("chat-bigger")} + /> +
+
) } diff --git a/apps/x/apps/renderer/src/contexts/theme-context.tsx b/apps/x/apps/renderer/src/contexts/theme-context.tsx index 1149cb42..04df59e7 100644 --- a/apps/x/apps/renderer/src/contexts/theme-context.tsx +++ b/apps/x/apps/renderer/src/contexts/theme-context.tsx @@ -3,16 +3,32 @@ import * as React from "react" export type Theme = "light" | "dark" | "system" +export type ChatPanePlacement = "right" | "middle" +export type ChatPaneSize = "chat-smaller" | "chat-equal" | "chat-bigger" type ThemeContextProps = { theme: Theme resolvedTheme: "light" | "dark" setTheme: (theme: Theme) => void + chatPanePlacement: ChatPanePlacement + setChatPanePlacement: (placement: ChatPanePlacement) => void + chatPaneSize: ChatPaneSize + setChatPaneSize: (size: ChatPaneSize) => void } const ThemeContext = React.createContext(null) const STORAGE_KEY = "rowboat-theme" +const CHAT_PANE_PLACEMENT_STORAGE_KEY = "rowboat-chat-pane-placement" +const CHAT_PANE_SIZE_STORAGE_KEY = "rowboat-chat-pane-size" + +function isChatPanePlacement(value: string | null): value is ChatPanePlacement { + return value === "right" || value === "middle" +} + +function isChatPaneSize(value: string | null): value is ChatPaneSize { + return value === "chat-smaller" || value === "chat-equal" || value === "chat-bigger" +} function getSystemTheme(): "light" | "dark" { if (typeof window === "undefined") return "light" @@ -39,6 +55,16 @@ export function ThemeProvider({ const stored = localStorage.getItem(STORAGE_KEY) as Theme | null return stored || defaultTheme }) + const [chatPanePlacement, setChatPanePlacementState] = React.useState(() => { + if (typeof window === "undefined") return "right" + const stored = localStorage.getItem(CHAT_PANE_PLACEMENT_STORAGE_KEY) + return isChatPanePlacement(stored) ? stored : "right" + }) + const [chatPaneSize, setChatPaneSizeState] = React.useState(() => { + if (typeof window === "undefined") return "chat-smaller" + const stored = localStorage.getItem(CHAT_PANE_SIZE_STORAGE_KEY) + return isChatPaneSize(stored) ? stored : "chat-smaller" + }) const [resolvedTheme, setResolvedTheme] = React.useState<"light" | "dark">(() => { if (theme === "system") return getSystemTheme() @@ -76,13 +102,27 @@ export function ThemeProvider({ setThemeState(newTheme) }, []) + const setChatPanePlacement = React.useCallback((placement: ChatPanePlacement) => { + localStorage.setItem(CHAT_PANE_PLACEMENT_STORAGE_KEY, placement) + setChatPanePlacementState(placement) + }, []) + + const setChatPaneSize = React.useCallback((size: ChatPaneSize) => { + localStorage.setItem(CHAT_PANE_SIZE_STORAGE_KEY, size) + setChatPaneSizeState(size) + }, []) + const contextValue = React.useMemo( () => ({ theme, resolvedTheme, setTheme, + chatPanePlacement, + setChatPanePlacement, + chatPaneSize, + setChatPaneSize, }), - [theme, resolvedTheme, setTheme] + [theme, resolvedTheme, setTheme, chatPanePlacement, setChatPanePlacement, chatPaneSize, setChatPaneSize] ) return ( From 372309eb1898a6eb52a457ea0ee1b2e4a72aa495 Mon Sep 17 00:00:00 2001 From: gagan Date: Fri, 5 Jun 2026 14:45:08 +0530 Subject: [PATCH 8/8] feat: run code mode on an in-app ACP client with live approvals (#593) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(code-mode): add ACP client engine (Layer 2 core) Own the Agent Client Protocol client instead of shelling out to `acpx`, so code mode can stream structured events (tool calls, diffs, plan) and surface live permission requests. Headless acpx can't do live approvals (it only supports --approve-all), which is why we drive the agent adapters ourselves. - code-mode/acp/{agents,client,permission-broker,session-store,manager,types}.ts: headless engine driving the Claude/Codex ACP adapters; one warm session per chat with create-or-resume via session/load; approval policy (ask | auto-approve-reads | yolo) in the broker. - claude-exec.ts: cross-platform claude resolver (Windows .cmd EINVAL fix + macOS/Linux GUI-PATH safety net) shared with the legacy acpx path in builtin-tools.ts. - add @agentclientprotocol/sdk + claude/codex adapters to core. * feat(code-mode): route code mode through code_agent_run tool + live approvals Replace the acpx shell-out with a structured code_agent_run tool that drives the ACP engine directly, streaming the agent's tool calls / diffs / plan into the chat and surfacing permission requests inline. - shared: code-mode.ts zod schemas; add code-run-event + code-run-permission-request RunEvent variants (stream to the renderer over the existing runs:events channel); codeRun:resolvePermission IPC channel. - core: CodePermissionRegistry (promise-based mid-run approvals — the LLM tool-loop's pre-call gate can't model a mid-execution wait); register codeModeManager + codePermissionRegistry in awilix. - core: code_agent_run builtin tool (streams via ctx.publish, asks via the registry, cancels on ctx.signal, returns the agent summary). CodeModeConfig.approvalPolicy (ask | auto-approve-reads | yolo; default ask). Exclude the tool from the headless background-task / live-note / inline-task agents so they can't block on an approval. - main: codeRun:resolvePermission handler -> registry.resolve. - rewrite the code-with-agents skill and the runtime "Code Mode (Active)" block to call code_agent_run instead of emitting npx acpx commands. * feat(code-mode): render coding runs inline (live timeline + permission card) Render a code_agent_run tool call as a live CodingRun block instead of generic tool output: the agent's text, tool-call rows (kind icon + status + changed-file names from diffs), a plan checklist, and resolved-permission lines — plus an inline Allow / Always-allow / Deny card wired to codeRun:resolvePermission. - chat-conversation.ts: ToolCall carries codeRunEvents + pendingCodePermission; code_agent_run is excluded from tool-grouping so it renders standalone. - App.tsx: handle code-run-event / code-run-permission-request, clear the pending card on tool-result, handleCodePermissionResponse, render via CodingRunBlock. * fix(code-mode): run the ACP adapter as Node under Electron + resolve it from main Two runtime failures that only surfaced inside the packaged/bundled Electron app (the headless harness used real node, so neither showed there): - "ACP connection closed": the main process spawns the adapter via process.execPath, which inside Electron is the Electron binary, not node — so the child never ran as Node and its ACP stdio stream closed immediately. Set ELECTRON_RUN_AS_NODE=1 on the adapter env (a no-op under real node). - "Cannot find module '@agentclientprotocol/claude-agent-acp'": the adapters were transitive (core) deps, unreachable from the esbuild-bundled main.cjs. Add them as direct deps of the main app so require.resolve finds them at runtime (and so they ship when packaged). Also capture the adapter's stderr + exit code and enrich connection errors, so a future failure reports the real cause instead of the opaque "ACP connection closed". * chore(code-mode): remove dead acpx code paths and stale copy Code mode now runs through the code_agent_run tool (owning the ACP client), so the legacy acpx shell-out paths are dead. Remove them: - core: envForCommand (acpx-only CLAUDE_CODE_EXECUTABLE injection) from executeCommand; getCodeModeCommandLabel (acpx run-status label). - renderer: the acpx-detection "switch agent / auto-flip the code-mode chip" flow — App.tsx executeCommand detection, the permission-request onSwitchAgent button + badge, and the composer's code-mode-detected listener. - copy: Settings -> Code Mode and the code-with-agents skill summary no longer mention acpx; tidy stale comments (claude-exec, command-executor). No behavior change for code mode; the general executeCommand tool is unaffected. * feat(code-mode): approval-policy selector in Settings Surface the approval policy (Ask every time / Auto-approve reads / YOLO) in Settings -> Code Mode, instead of being config-file only. The broker already reads CodeModeConfig.approvalPolicy; this plumbs it through the codeMode:getConfig / setConfig IPC + main handlers and adds the picker UI (with a one-line explanation of each level). Defaults to "ask". * fix(code-mode): harden ACP engine — turn-scoped connections, chip-authoritative agent, reliable stop Three robustness fixes that co-modify manager.runPrompt and the code_agent_run tool, so they land together: - Lifecycle: scope each ACP adapter connection to the agent turn. Dispose it a short grace (60s) after the turn ends instead of holding it for the app's life; the next turn resumes via session/load (both agents support it). Wire disposeAll() on app quit (was dead code). Fixes the unbounded per-chat leak of booted agent processes. - Agent selection: make the composer chip the source of truth. Thread codeMode into ToolContext; code_agent_run uses it instead of the model's guessed `agent` arg, which anchored on the thread's earlier agent and ignored a chip change. Prompts updated to match; the run is labelled by the agent that actually ran. - Stop/abort: guarantee a stopped turn unwinds. On abort the manager sends ACP session/cancel, then force-kills the adapter after a 2s grace and resolves the turn as cancelled — a wedged adapter can no longer hang the run and lock the chat. code_agent_run returns a clean cancelled result. * fix(code-mode): hide Codex's native console window on Windows Codex's engine ships as a native console-subsystem binary (codex.exe). Launched from our console-less Electron process tree, Windows allocated a fresh *visible* console window for it; closing that window wedged the run in a pending state. (Claude Code is a Node CLI, so it never triggers this.) The window is created by @openai/codex's launcher (bin/codex.js), which spawns codex.exe with no windowsHide. Patch it via pnpm to pass windowsHide: true (CREATE_NO_WINDOW) so the console stays hidden — no window, nothing to close. * refactor(code-mode): move ACP session files out of WorkDir/config Per-run ACP session state is runtime state that accumulates one file per chat run, not user/app config. Relocate it from WorkDir/config to a dedicated WorkDir/code-mode/sessions/ so it can be listed, cleaned up, and managed on its own without crowding config. Drop the now-redundant codesession- filename prefix (the directory conveys it). --- apps/x/apps/main/package.json | 2 + apps/x/apps/main/src/ipc.ts | 10 +- apps/x/apps/main/src/main.ts | 9 +- apps/x/apps/renderer/src/App.tsx | 96 +++-- .../ai-elements/permission-request.tsx | 37 +- .../components/chat-input-with-mentions.tsx | 14 - .../renderer/src/components/coding-run.tsx | 253 ++++++++++++ .../src/components/settings-dialog.tsx | 60 ++- .../renderer/src/lib/chat-conversation.ts | 37 +- apps/x/packages/core/package.json | 3 + apps/x/packages/core/src/agents/runtime.ts | 46 +-- .../skills/code-with-agents/skill.ts | 86 +--- .../src/application/assistant/skills/index.ts | 2 +- .../core/src/application/lib/builtin-tools.ts | 171 ++++---- .../src/application/lib/command-executor.ts | 2 +- .../core/src/application/lib/exec-tool.ts | 4 + .../core/src/background-tasks/agent.ts | 4 +- .../packages/core/src/code-mode/acp/agents.ts | 60 +++ .../core/src/code-mode/acp/claude-exec.ts | 91 +++++ .../packages/core/src/code-mode/acp/client.ts | 219 ++++++++++ .../core/src/code-mode/acp/manager.ts | 186 +++++++++ .../src/code-mode/acp/permission-broker.ts | 91 +++++ .../src/code-mode/acp/permission-registry.ts | 43 ++ .../core/src/code-mode/acp/session-store.ts | 48 +++ .../packages/core/src/code-mode/acp/types.ts | 11 + apps/x/packages/core/src/code-mode/status.ts | 2 +- apps/x/packages/core/src/code-mode/types.ts | 4 + apps/x/packages/core/src/di/container.ts | 8 + .../core/src/knowledge/inline_task_agent.ts | 3 + .../core/src/knowledge/live-note/agent.ts | 4 +- apps/x/packages/shared/src/code-mode.ts | 70 ++++ apps/x/packages/shared/src/ipc.ts | 13 + apps/x/packages/shared/src/runs.ts | 20 + apps/x/patches/@openai__codex@0.128.0.patch | 15 + apps/x/pnpm-lock.yaml | 377 ++++++++++++++++++ apps/x/pnpm-workspace.yaml | 2 + 36 files changed, 1809 insertions(+), 294 deletions(-) create mode 100644 apps/x/apps/renderer/src/components/coding-run.tsx create mode 100644 apps/x/packages/core/src/code-mode/acp/agents.ts create mode 100644 apps/x/packages/core/src/code-mode/acp/claude-exec.ts create mode 100644 apps/x/packages/core/src/code-mode/acp/client.ts create mode 100644 apps/x/packages/core/src/code-mode/acp/manager.ts create mode 100644 apps/x/packages/core/src/code-mode/acp/permission-broker.ts create mode 100644 apps/x/packages/core/src/code-mode/acp/permission-registry.ts create mode 100644 apps/x/packages/core/src/code-mode/acp/session-store.ts create mode 100644 apps/x/packages/core/src/code-mode/acp/types.ts create mode 100644 apps/x/packages/shared/src/code-mode.ts create mode 100644 apps/x/patches/@openai__codex@0.128.0.patch diff --git a/apps/x/apps/main/package.json b/apps/x/apps/main/package.json index 74cb1598..3330c3c0 100644 --- a/apps/x/apps/main/package.json +++ b/apps/x/apps/main/package.json @@ -13,6 +13,8 @@ "make": "electron-forge make" }, "dependencies": { + "@agentclientprotocol/claude-agent-acp": "^0.39.0", + "@agentclientprotocol/codex-acp": "^0.0.44", "@x/core": "workspace:*", "@x/shared": "workspace:*", "chokidar": "^4.0.3", diff --git a/apps/x/apps/main/src/ipc.ts b/apps/x/apps/main/src/ipc.ts index ec2803aa..e5d407f8 100644 --- a/apps/x/apps/main/src/ipc.ts +++ b/apps/x/apps/main/src/ipc.ts @@ -32,6 +32,7 @@ import type { IModelConfigRepo } from '@x/core/dist/models/repo.js'; import type { IOAuthRepo } from '@x/core/dist/auth/repo.js'; import { IGranolaConfigRepo } from '@x/core/dist/knowledge/granola/repo.js'; import { ICodeModeConfigRepo } from '@x/core/dist/code-mode/repo.js'; +import { CodePermissionRegistry } from '@x/core/dist/code-mode/acp/permission-registry.js'; import { checkCodeModeAgentStatus } from '@x/core/dist/code-mode/status.js'; import { invalidateCopilotInstructionsCache } from '@x/core/dist/application/assistant/instructions.js'; import { triggerSync as triggerGranolaSync } from '@x/core/dist/knowledge/granola/sync.js'; @@ -536,6 +537,11 @@ export function setupIpcHandlers() { await runsCore.authorizePermission(args.runId, args.authorization); return { success: true }; }, + 'codeRun:resolvePermission': async (_event, args) => { + const registry = container.resolve('codePermissionRegistry'); + registry.resolve(args.requestId, args.decision); + return { success: true }; + }, 'runs:provideHumanInput': async (_event, args) => { await runsCore.replyToHumanInputRequest(args.runId, args.reply); return { success: true }; @@ -637,11 +643,11 @@ export function setupIpcHandlers() { 'codeMode:getConfig': async () => { const repo = container.resolve('codeModeConfigRepo'); const config = await repo.getConfig(); - return { enabled: config.enabled }; + return { enabled: config.enabled, approvalPolicy: config.approvalPolicy }; }, 'codeMode:setConfig': async (_event, args) => { const repo = container.resolve('codeModeConfigRepo'); - await repo.setConfig({ enabled: args.enabled }); + await repo.setConfig({ enabled: args.enabled, approvalPolicy: args.approvalPolicy }); invalidateCopilotInstructionsCache(); return { success: true }; }, diff --git a/apps/x/apps/main/src/main.ts b/apps/x/apps/main/src/main.ts index 780d78cd..f4415b5d 100644 --- a/apps/x/apps/main/src/main.ts +++ b/apps/x/apps/main/src/main.ts @@ -40,7 +40,8 @@ import started from "electron-squirrel-startup"; import { execSync, exec, execFileSync } from "node:child_process"; import { promisify } from "node:util"; import { init as initChromeSync } from "@x/core/dist/knowledge/chrome-extension/server/server.js"; -import { registerBrowserControlService, registerNotificationService } from "@x/core/dist/di/container.js"; +import container, { registerBrowserControlService, registerNotificationService } from "@x/core/dist/di/container.js"; +import type { CodeModeManager } from "@x/core/dist/code-mode/acp/manager.js"; import { browserViewManager, BROWSER_PARTITION } from "./browser/view.js"; import { setupBrowserEventForwarding } from "./browser/ipc.js"; import { ElectronBrowserControlService } from "./browser/control-service.js"; @@ -417,6 +418,12 @@ app.on("before-quit", () => { stopWorkspaceWatcher(); stopRunsWatcher(); stopServicesWatcher(); + // Tear down any live ACP coding-agent adapter processes so they don't outlive the app. + try { + container.resolve('codeModeManager').disposeAll(); + } catch { + // nothing live to dispose + } shutdownLocalSites().catch((error) => { console.error('[LocalSites] Failed to shut down cleanly:', error); }); diff --git a/apps/x/apps/renderer/src/App.tsx b/apps/x/apps/renderer/src/App.tsx index df85e06b..b850b57f 100644 --- a/apps/x/apps/renderer/src/App.tsx +++ b/apps/x/apps/renderer/src/App.tsx @@ -29,6 +29,7 @@ import { LiveNotesView } from '@/components/live-notes-view'; import { BgTasksView } from '@/components/bg-tasks-view'; import { EmailView } from '@/components/email-view'; import { WorkspaceView } from '@/components/workspace-view'; +import { CodingRunBlock } from '@/components/coding-run'; import { KnowledgeView } from '@/components/knowledge-view'; import { ChatHistoryView } from '@/components/chat-history-view'; import { HomeView } from '@/components/home-view'; @@ -2198,19 +2199,6 @@ function App() { status: 'running', timestamp: Date.now(), }]) - // Detect acpx-driven coding-agent runs so the composer can retroactively - // flip code mode on with the right agent (when the user reached the skill - // via plain prompt rather than the explicit toggle). - if (llmEvent.toolName === 'executeCommand') { - const input = llmEvent.input as { command?: unknown } | undefined - const cmd = typeof input?.command === 'string' ? input.command : '' - const match = cmd.match(/\bacpx\b[\s\S]*?\b(claude|codex)\b/) - if (match) { - window.dispatchEvent(new CustomEvent('code-mode-detected', { - detail: { runId: event.runId, agent: match[1] as 'claude' | 'codex' }, - })) - } - } } else if (llmEvent.type === 'finish-step') { const nextUsage = normalizeUsage(llmEvent.usage) if (nextUsage) { @@ -2308,6 +2296,8 @@ function App() { ...item, result: event.result as ToolUIPart['output'], status: 'completed' as const, + // a code_agent_run finished — drop any lingering permission card + pendingCodePermission: null, } } return item @@ -2388,6 +2378,33 @@ function App() { break } + case 'code-run-event': { + if (!isActiveRun) return + setConversation(prev => prev.map(item => { + if (isToolCall(item) && item.id === event.toolCallId) { + const existing = item.codeRunEvents ?? [] + if (existing.length === 0) { + setToolOpenForTab(activeChatTabIdRef.current, item.id, true) + } + return { ...item, codeRunEvents: [...existing, event.event] } + } + return item + })) + break + } + + case 'code-run-permission-request': { + if (!isActiveRun) return + setConversation(prev => prev.map(item => { + if (isToolCall(item) && item.id === event.toolCallId) { + setToolOpenForTab(activeChatTabIdRef.current, item.id, true) + return { ...item, pendingCodePermission: { requestId: event.requestId, ask: event.ask } } + } + return item + })) + break + } + case 'tool-permission-auto-decision': { if (!isActiveRun) return setAutoPermissionDecisions(prev => { @@ -2730,6 +2747,26 @@ function App() { } }, [runId]) + // Answer a mid-run permission request from a code_agent_run coding turn. The + // pending ask lives on the tool call itself, so we optimistically clear it and + // tell main which decision the user picked (keyed by the request id). + const handleCodePermissionResponse = useCallback(async ( + toolCallId: string, + requestId: string, + decision: 'allow_once' | 'allow_always' | 'reject', + ) => { + setConversation(prev => prev.map(item => + isToolCall(item) && item.id === toolCallId + ? { ...item, pendingCodePermission: null } + : item + )) + try { + await window.ipc.invoke('codeRun:resolvePermission', { requestId, decision }) + } catch (error) { + console.error('Failed to resolve code permission:', error) + } + }, []) + const handleAskHumanResponse = useCallback(async (toolCallId: string, subflow: string[], response: string) => { if (!runId) return try { @@ -5147,6 +5184,21 @@ function App() { } if (isToolCall(item)) { + if (item.name === 'code_agent_run') { + return ( + setToolOpenForTab(tabId, item.id, open)} + onPermissionDecision={(decision) => { + if (item.pendingCodePermission) { + handleCodePermissionResponse(item.id, item.pendingCodePermission.requestId, decision) + } + }} + /> + ) + } const appActionData = getAppActionCardData(item) if (appActionData) { return @@ -5886,24 +5938,6 @@ function App() { onApproveSession={() => handlePermissionResponse(permRequest.toolCall.toolCallId, permRequest.subflow, 'approve', 'session')} onApproveAlways={() => handlePermissionResponse(permRequest.toolCall.toolCallId, permRequest.subflow, 'approve', 'always')} onDeny={() => handlePermissionResponse(permRequest.toolCall.toolCallId, permRequest.subflow, 'deny')} - onSwitchAgent={async (newAgent) => { - const runIdForSwitch = tab.runId - await handlePermissionResponse(permRequest.toolCall.toolCallId, permRequest.subflow, 'deny') - window.dispatchEvent(new CustomEvent('code-mode-detected', { - detail: { runId: runIdForSwitch, agent: newAgent }, - })) - 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} response={response} /> diff --git a/apps/x/apps/renderer/src/components/ai-elements/permission-request.tsx b/apps/x/apps/renderer/src/components/ai-elements/permission-request.tsx index d99d2e8b..cdcafc27 100644 --- a/apps/x/apps/renderer/src/components/ai-elements/permission-request.tsx +++ b/apps/x/apps/renderer/src/components/ai-elements/permission-request.tsx @@ -1,6 +1,5 @@ "use client"; -import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { DropdownMenu, @@ -9,7 +8,7 @@ import { DropdownMenuItem, } from "@/components/ui/dropdown-menu"; import { cn } from "@/lib/utils"; -import { AlertTriangleIcon, CheckIcon, ChevronDownIcon, RefreshCwIcon, Terminal, XIcon } from "lucide-react"; +import { AlertTriangleIcon, CheckIcon, ChevronDownIcon, XIcon } from "lucide-react"; import { useState, type ComponentProps } from "react"; import { ToolCallPart } from "@x/shared/dist/message.js"; import { ToolPermissionMetadata } from "@x/shared/dist/runs.js"; @@ -21,7 +20,6 @@ export type PermissionRequestProps = ComponentProps<"div"> & { onApproveSession?: () => void; onApproveAlways?: () => void; onDeny?: () => void; - onSwitchAgent?: (newAgent: 'claude' | 'codex') => void; isProcessing?: boolean; response?: 'approve' | 'deny' | null; permission?: z.infer; @@ -42,7 +40,6 @@ export const PermissionRequest = ({ onApproveSession, onApproveAlways, onDeny, - onSwitchAgent, isProcessing = false, response = null, permission, @@ -56,17 +53,6 @@ export const PermissionRequest = ({ : null; const filePermission = permission?.kind === "file" ? permission : null; - // Detect acpx coding-agent invocations so we can show the agent identity and - // offer a one-click swap-and-retry. - const acpxAgent: 'claude' | 'codex' | null = (() => { - if (!command) return null; - const match = command.match(/\bacpx\b[\s\S]*?\b(claude|codex)\b\s+exec\b/); - return match ? (match[1] as 'claude' | 'codex') : null; - })(); - const otherAgent: 'claude' | 'codex' | null = acpxAgent === 'claude' ? 'codex' : acpxAgent === 'codex' ? 'claude' : null; - const agentDisplay = acpxAgent === 'claude' ? 'Claude Code' : acpxAgent === 'codex' ? 'Codex' : null; - const otherDisplay = otherAgent === 'claude' ? 'Claude Code' : otherAgent === 'codex' ? 'Codex' : null; - const isResponded = response !== null; const isApproved = response === 'approve'; @@ -104,15 +90,6 @@ export const PermissionRequest = ({

{isResponded ? "Requested:" : "The agent wants to execute:"} {toolCall.toolName} - {agentDisplay && ( - - - {agentDisplay} - - )}

{isResponded && ( @@ -220,18 +197,6 @@ export const PermissionRequest = ({ Deny - {otherAgent && otherDisplay && onSwitchAgent && ( - - )}
)} diff --git a/apps/x/apps/renderer/src/components/chat-input-with-mentions.tsx b/apps/x/apps/renderer/src/components/chat-input-with-mentions.tsx index 624b0e7c..a7d548ea 100644 --- a/apps/x/apps/renderer/src/components/chat-input-with-mentions.tsx +++ b/apps/x/apps/renderer/src/components/chat-input-with-mentions.tsx @@ -394,20 +394,6 @@ function ChatInputInner({ } }, [codeModeFeatureEnabled, codeModeEnabled]) - // Listen for coding-agent runs that were triggered without the explicit code-mode - // toggle. App.tsx dispatches this when it sees an acpx executeCommand fire. We - // flip the pill on with the detected agent so the UI reflects what's happening. - useEffect(() => { - const handler = (ev: Event) => { - const detail = (ev as CustomEvent<{ runId?: string; agent?: 'claude' | 'codex' }>).detail - if (!detail || !detail.agent) return - if (runId && detail.runId && detail.runId !== runId) return - setCodeModeEnabled(true) - setCodingAgent(detail.agent) - } - window.addEventListener('code-mode-detected', handler) - return () => window.removeEventListener('code-mode-detected', handler) - }, [runId]) // Cross-platform basename — handles both / and \ separators. const basename = useCallback((p: string): string => { diff --git a/apps/x/apps/renderer/src/components/coding-run.tsx b/apps/x/apps/renderer/src/components/coding-run.tsx new file mode 100644 index 00000000..4d5dd33b --- /dev/null +++ b/apps/x/apps/renderer/src/components/coding-run.tsx @@ -0,0 +1,253 @@ +import { useMemo, useState } from 'react' +import { + CheckCircle2, + Circle, + CircleDot, + Eye, + FileText, + Loader, + Pencil, + Search, + ShieldQuestion, + Terminal, + Trash2, + Wrench, +} from 'lucide-react' +import type { CodeRunEvent, PermissionAsk, PermissionDecision } from '@x/shared/src/code-mode.js' +import { cn } from '@/lib/utils' +import { Tool, ToolContent, ToolHeader } from '@/components/ai-elements/tool' +import { toToolState, type ToolCall } from '@/lib/chat-conversation' + +// ── Timeline reduction ────────────────────────────────────────────── +// The raw ACP stream is a flat list of events; collapse it into ordered rows, +// folding tool_call + tool_call_update (by id) and the latest plan in place. + +type TextRow = { kind: 'text'; id: string; text: string } +type ToolRow = { kind: 'tool'; id: string; title?: string; toolKind?: string; status?: string; diffs: string[] } +type PlanRow = { kind: 'plan'; id: string; entries: { content: string; status?: string }[] } +type PermRow = { kind: 'perm'; id: string; title: string; decision: string } +type Row = TextRow | ToolRow | PlanRow | PermRow + +function reduceEvents(events: CodeRunEvent[]): Row[] { + const rows: Row[] = [] + const toolIdx = new Map() + let planIdx = -1 + + events.forEach((e, i) => { + switch (e.type) { + case 'message': { + if (e.role !== 'agent' || !e.text) return + const last = rows[rows.length - 1] + if (last && last.kind === 'text') last.text += e.text + else rows.push({ kind: 'text', id: `t${i}`, text: e.text }) + break + } + case 'tool_call': { + const id = e.id ?? `tc${i}` + const at = toolIdx.get(id) + if (at != null) { + const r = rows[at] as ToolRow + r.title = e.title ?? r.title + r.toolKind = e.kind ?? r.toolKind + r.status = e.status ?? r.status + } else { + toolIdx.set(id, rows.length) + rows.push({ kind: 'tool', id, title: e.title, toolKind: e.kind, status: e.status, diffs: [] }) + } + break + } + case 'tool_call_update': { + const id = e.id ?? `tu${i}` + let at = toolIdx.get(id) + if (at == null) { + at = rows.length + toolIdx.set(id, at) + rows.push({ kind: 'tool', id, diffs: [] }) + } + const r = rows[at] as ToolRow + if (e.status) r.status = e.status + for (const d of e.diffs) if (!r.diffs.includes(d)) r.diffs.push(d) + break + } + case 'plan': { + if (planIdx >= 0) (rows[planIdx] as PlanRow).entries = e.entries + else { + planIdx = rows.length + rows.push({ kind: 'plan', id: 'plan', entries: e.entries }) + } + break + } + case 'permission': + rows.push({ kind: 'perm', id: `p${i}`, title: e.ask.title, decision: e.decision }) + break + default: + break + } + }) + return rows +} + +function toolKindIcon(kind?: string) { + switch (kind) { + case 'read': return + case 'edit': return + case 'delete': return + case 'search': return + case 'execute': return + case 'fetch': return + default: return + } +} + +function planMarker(status?: string) { + if (status === 'completed') return + if (status === 'in_progress') return + return +} + +const basename = (p: string) => p.split(/[\\/]/).pop() || p + +function CodingRunTimeline({ events }: { events: CodeRunEvent[] }) { + const rows = useMemo(() => reduceEvents(events), [events]) + if (rows.length === 0) { + return
Starting the agent…
+ } + return ( +
+ {rows.map((row) => { + if (row.kind === 'text') { + return ( +

+ {row.text} +

+ ) + } + if (row.kind === 'tool') { + const running = row.status !== 'completed' && row.status !== 'failed' + return ( +
+
+ {running + ? + : } + {toolKindIcon(row.toolKind)} + {row.title ?? row.toolKind ?? 'Tool call'} +
+ {row.diffs.length > 0 && ( +
+ {row.diffs.map((d) => ( + + {basename(d)} + + ))} +
+ )} +
+ ) + } + if (row.kind === 'plan') { + return ( +
+ {row.entries.map((entry, idx) => ( +
+ {planMarker(entry.status)} + + {entry.content} + +
+ ))} +
+ ) + } + // resolved permission + const denied = row.decision === 'reject' || row.decision === 'cancelled' + return ( +
+ {denied ? '✕' : '✓'} + {denied ? 'Denied' : 'Allowed'}: {row.title} +
+ ) + })} +
+ ) +} + +// ── In-run permission card ────────────────────────────────────────── + +export function CodeRunPermissionRequest({ + ask, + onDecide, +}: { + ask: PermissionAsk + onDecide: (decision: PermissionDecision) => void +}) { + const [busy, setBusy] = useState(false) + const decide = (d: PermissionDecision) => { + if (busy) return + setBusy(true) + onDecide(d) + } + const btn = 'rounded-full px-3 py-1.5 text-xs font-medium transition-colors disabled:opacity-50' + return ( +
+
+ + Permission needed +
+

+ The agent wants to: {ask.title} +

+
+ + + +
+
+ ) +} + +// ── Block wrapper (rendered in the chat for a code_agent_run tool call) ── + +const AGENT_LABEL: Record = { claude: 'Claude Code', codex: 'Codex' } + +export function CodingRunBlock({ + item, + open, + onOpenChange, + onPermissionDecision, +}: { + item: ToolCall + open: boolean + onOpenChange: (open: boolean) => void + onPermissionDecision: (decision: PermissionDecision) => void +}) { + // Prefer the agent the backend actually ran (the chip) once the run returns; fall + // back to the requested input agent while it's still in flight. Never trust only the + // model's input — it can pass a stale agent the backend overrode with the chip. + const agent = + (item.result as { agent?: string } | undefined)?.agent ?? + (item.input as { agent?: string } | undefined)?.agent + const title = AGENT_LABEL[agent ?? ''] ?? 'Coding agent' + return ( + <> + + + + + + + {item.pendingCodePermission && ( + + )} + + ) +} diff --git a/apps/x/apps/renderer/src/components/settings-dialog.tsx b/apps/x/apps/renderer/src/components/settings-dialog.tsx index bf85d99b..c45ed64e 100644 --- a/apps/x/apps/renderer/src/components/settings-dialog.tsx +++ b/apps/x/apps/renderer/src/components/settings-dialog.tsx @@ -25,6 +25,7 @@ import { useTheme } from "@/contexts/theme-context" import { toast } from "sonner" import { AccountSettings } from "@/components/settings/account-settings" import { ConnectedAccountsSettings } from "@/components/settings/connected-accounts-settings" +import type { ApprovalPolicy } from "@x/shared/src/code-mode.js" type ConfigTab = "account" | "connections" | "models" | "mcp" | "security" | "code-mode" | "appearance" | "note-tagging" | "help" @@ -1805,6 +1806,7 @@ function AgentStatusRow({ function CodeModeSettings({ dialogOpen }: { dialogOpen: boolean }) { const [enabled, setEnabled] = useState(false) + const [approvalPolicy, setApprovalPolicy] = useState('ask') const [loading, setLoading] = useState(true) const [saving, setSaving] = useState(false) const [status, setStatus] = useState(null) @@ -1829,7 +1831,10 @@ function CodeModeSettings({ dialogOpen }: { dialogOpen: boolean }) { setLoading(true) try { const result = await window.ipc.invoke("codeMode:getConfig", null) - if (!cancelled) setEnabled(result.enabled) + if (!cancelled) { + setEnabled(result.enabled) + setApprovalPolicy(result.approvalPolicy ?? 'ask') + } } catch { if (!cancelled) setEnabled(false) } finally { @@ -1845,7 +1850,7 @@ function CodeModeSettings({ dialogOpen }: { dialogOpen: boolean }) { setSaving(true) setEnabled(next) try { - await window.ipc.invoke("codeMode:setConfig", { enabled: next }) + await window.ipc.invoke("codeMode:setConfig", { enabled: next, approvalPolicy }) window.dispatchEvent(new Event("code-mode-config-changed")) toast.success(next ? "Code mode enabled" : "Code mode disabled") } catch { @@ -1854,7 +1859,22 @@ function CodeModeSettings({ dialogOpen }: { dialogOpen: boolean }) { } finally { setSaving(false) } - }, []) + }, [approvalPolicy]) + + const handlePolicyChange = useCallback(async (next: ApprovalPolicy) => { + const prev = approvalPolicy + setSaving(true) + setApprovalPolicy(next) + try { + await window.ipc.invoke("codeMode:setConfig", { enabled, approvalPolicy: next }) + window.dispatchEvent(new Event("code-mode-config-changed")) + } catch { + setApprovalPolicy(prev) + toast.error("Failed to update approval policy") + } finally { + setSaving(false) + } + }, [enabled, approvalPolicy]) const anyReady = status?.claude.installed && status?.claude.signedIn || status?.codex.installed && status?.codex.signedIn @@ -1874,9 +1894,8 @@ function CodeModeSettings({ dialogOpen }: { dialogOpen: boolean }) {

Code mode lets the assistant delegate coding tasks to Claude Code or Codex running - on your machine. Pick the agent inline from the composer; the assistant calls it via - acpx - and streams results back into chat. + on your machine. Pick the agent inline from the composer; the assistant runs it on-device + and streams its work — tool calls, file diffs, and approvals — back into chat.

Requires an active Claude Code subscription or @@ -1926,6 +1945,35 @@ function CodeModeSettings({ dialogOpen }: { dialogOpen: boolean }) { /> + {enabled && ( +

+
Approvals
+
+ How the coding agent checks in before changing files or running commands. You always see + everything it does in the timeline — this only controls the prompts. +
+ +
+ {approvalPolicy === 'ask' && 'You approve every file change and command the agent wants to run.'} + {approvalPolicy === 'auto-approve-reads' && 'Reading and searching run automatically; you still approve writes, edits, and commands.'} + {approvalPolicy === 'yolo' && 'The agent runs everything — writes, edits, and commands — without asking. Use only in folders you trust.'} +
+
+ )} + {enabled && status && !anyReady && (
diff --git a/apps/x/apps/renderer/src/lib/chat-conversation.ts b/apps/x/apps/renderer/src/lib/chat-conversation.ts index 7b0c15c6..5fb28574 100644 --- a/apps/x/apps/renderer/src/lib/chat-conversation.ts +++ b/apps/x/apps/renderer/src/lib/chat-conversation.ts @@ -2,6 +2,7 @@ import type { ToolUIPart } from 'ai' import z from 'zod' import { AskHumanRequestEvent, ToolPermissionAutoDecisionEvent, ToolPermissionRequestEvent } from '@x/shared/src/runs.js' import { COMPOSIO_DISPLAY_NAMES } from '@x/shared/src/composio.js' +import type { CodeRunEvent, PermissionAsk } from '@x/shared/src/code-mode.js' export interface MessageAttachment { path: string @@ -27,6 +28,9 @@ export interface ToolCall { streamingOutput?: string status: 'pending' | 'running' | 'completed' | 'error' timestamp: number + // code_agent_run only: structured ACP stream items + the in-flight permission ask. + codeRunEvents?: CodeRunEvent[] + pendingCodePermission?: { requestId: string; ask: PermissionAsk } | null } export interface ErrorMessage { @@ -519,41 +523,9 @@ const TOOL_DISPLAY_NAMES: Record = { * For builtin tools, returns a static friendly name (e.g., "Reading file"). * Falls back to the raw tool name if no mapping exists. */ -// Phrases shown while a code-mode task is running. They advance over time (5s -// each) to read as progress, then hold on the last one until the task finishes. -const CODE_MODE_RUNNING_LABELS = [ - 'Working on the task…', - 'Inspecting the project…', - 'Digging into the code…', - 'Figuring it out…', - 'Making the changes…', - 'Wiring things up…', - 'Putting it together…', -] -const CODE_MODE_LABEL_INTERVAL_MS = 5000 - -// Detect acpx coding-agent invocations (code mode) and produce a status-aware -// label, e.g. "Working on the task…" → "Completed the task". -export const getCodeModeCommandLabel = (tool: ToolCall): string | null => { - if (tool.name !== 'executeCommand') return null - const input = normalizeToolInput(tool.input) as Record | undefined - const command = typeof input?.command === 'string' ? input.command : '' - const match = command.match(/\bacpx\b[\s\S]*?\b(claude|codex)\b\s+exec\b/) - if (!match) return null - if (tool.status === 'error') return `Couldn't complete the task` - if (tool.status === 'completed') return `Completed the task` - // Advance through the phrases from the tool's start, holding on the last. - const elapsed = Math.max(0, Date.now() - tool.timestamp) - const step = Math.floor(elapsed / CODE_MODE_LABEL_INTERVAL_MS) - const idx = Math.min(step, CODE_MODE_RUNNING_LABELS.length - 1) - return CODE_MODE_RUNNING_LABELS[idx] -} - export const getToolDisplayName = (tool: ToolCall): string => { const browserLabel = getBrowserControlLabel(tool) if (browserLabel) return browserLabel - const codeModeLabel = getCodeModeCommandLabel(tool) - if (codeModeLabel) return codeModeLabel const composioData = getComposioActionCardData(tool) if (composioData) return composioData.label return TOOL_DISPLAY_NAMES[tool.name] || tool.name @@ -634,6 +606,7 @@ export const isToolGroup = (item: GroupedConversationItem): item is ToolGroup => const isPlainToolCall = (item: ConversationItem): item is ToolCall => { if (!isToolCall(item)) return false + if (item.name === 'code_agent_run') return false // rich standalone block, never grouped if (getWebSearchCardData(item)) return false if (getComposioConnectCardData(item)) return false if (getAppActionCardData(item)) return false diff --git a/apps/x/packages/core/package.json b/apps/x/packages/core/package.json index b552eab7..08c2644d 100644 --- a/apps/x/packages/core/package.json +++ b/apps/x/packages/core/package.json @@ -11,6 +11,9 @@ "test:watch": "vitest" }, "dependencies": { + "@agentclientprotocol/claude-agent-acp": "^0.39.0", + "@agentclientprotocol/codex-acp": "^0.0.44", + "@agentclientprotocol/sdk": "^0.22.1", "@ai-sdk/anthropic": "^2.0.63", "@ai-sdk/google": "^2.0.53", "@ai-sdk/openai": "^2.0.91", diff --git a/apps/x/packages/core/src/agents/runtime.ts b/apps/x/packages/core/src/agents/runtime.ts index f42fad72..6f563a07 100644 --- a/apps/x/packages/core/src/agents/runtime.ts +++ b/apps/x/packages/core/src/agents/runtime.ts @@ -1279,6 +1279,7 @@ export async function* streamAgent({ signal, abortRegistry, publish: (event) => bus.publish(event), + codeMode, }); } } catch (error) { @@ -1426,44 +1427,19 @@ Do not announce the work directory unless it's relevant. Just use it.`; if (codeMode) { loopLogger.log('code mode enabled, injecting coding-agent context', codeMode); const agentDisplay = codeMode === 'claude' ? 'Claude Code' : 'Codex'; - const otherAgent = codeMode === 'claude' ? 'codex' : 'claude'; - const otherDisplay = codeMode === 'claude' ? 'Codex' : 'Claude Code'; - // Deterministic, per-chat session name so the coding agent keeps - // context across the user's requests within this chat. Reusing the - // same -s resumes the session; the first call creates it. - const sessionName = `rowboat-${runId}`; - instructionsWithDateTime += `\n\n# Code Mode (Active) — Default agent: ${agentDisplay} -The user has turned on **code mode** and the composer chip is set to **${agentDisplay}** (\`${codeMode}\`). Use this as the **default** agent for coding tasks in this turn. + instructionsWithDateTime += `\n\n# Code Mode (Active) — Agent: ${agentDisplay} +The user has turned on **code mode** and the composer chip is set to **${agentDisplay}** (\`${codeMode}\`). For EVERY coding task this turn, use **${agentDisplay}**, and narrate that agent ("Using ${agentDisplay} to …"). -**The user can override the agent at any time, two ways:** -1. By toggling the chip in the composer (preferred). -2. By asking you directly in chat ("use codex", "switch to claude", "do this with ${otherDisplay}", etc.). When the user explicitly asks to use a different agent in the current message, honor that — use \`${otherAgent}\` instead of \`${codeMode}\` for this turn, and briefly mention they can also toggle it via the chip for stickiness. +The chip is the single source of truth for which agent runs: +- Do NOT carry over a different agent from earlier in this thread — even if a previous run used the other agent, use **${agentDisplay}** now. +- Do NOT switch agents based on an in-chat text request ("use codex", "switch to claude"). The agent only changes when the user toggles the chip; if they ask in chat, tell them to toggle the chip. -**Persistent session for this chat — session name: \`${sessionName}\`.** This chat uses one named agent session so the agent keeps context across your requests. The session must exist before it can be prompted (\`-s\` only resumes; it does not create). +**How to run coding work — call the \`code_agent_run\` tool** with: +- \`agent\`: \`${codeMode}\` (always — match the chip). +- \`cwd\`: the absolute project/working directory (resolve it per the code-with-agents skill — a path the user named, the "# User Work Directory" block, or ask once). +- \`prompt\`: a clear, self-contained coding instruction. -**1. First coding action in this chat — ensure the session exists:** - -\`\`\` -npx acpx@latest --approve-all --cwd sessions ensure --name ${sessionName} -\`\`\` - -(\`ensure\` creates the session if missing and reuses it if it already exists — safe to call when reopening this chat later.) - -**2. Then run the prompt:** - -\`\`\` -npx acpx@latest --approve-all --timeout 600 --cwd -s ${sessionName} "" -\`\`\` - -**3. Every follow-up coding request in this chat — reuse the same session (do NOT create again):** - -\`\`\` -npx acpx@latest --approve-all --timeout 600 --cwd -s ${sessionName} "" -\`\`\` - -Run these as **separate, sequential** \`executeCommand\` calls — issue the \`sessions ensure\` call first and WAIT for it to finish, then issue the prompt call. Do NOT fire both in the same turn / batch. - -Where \`\` is either \`claude\` or \`codex\` — pick based on (in priority order): an explicit in-chat override → the chip setting (\`${codeMode}\`). Use \`${sessionName}\` exactly — do NOT invent a different name, and do NOT use \`exec\` (it is one-shot and forgets). +The tool runs the agent on-device and streams its tool calls, file diffs, and plan into the chat; any action needing approval surfaces as an inline permission card, so you do NOT pre-confirm with an in-chat "reply yes". This chat keeps ONE persistent agent session, so follow-up coding requests automatically resume with full context — just call \`code_agent_run\` again. Do NOT shell out to \`acpx\` or \`executeCommand\` for coding, and do NOT fall back to your own file tools. If the user's message is clearly NOT a coding request (small talk, an unrelated question), answer directly without invoking the coding agent. Code mode signals readiness, not that every message must route through the agent.`; } diff --git a/apps/x/packages/core/src/application/assistant/skills/code-with-agents/skill.ts b/apps/x/packages/core/src/application/assistant/skills/code-with-agents/skill.ts index d8e81a58..d9f15dd8 100644 --- a/apps/x/packages/core/src/application/assistant/skills/code-with-agents/skill.ts +++ b/apps/x/packages/core/src/application/assistant/skills/code-with-agents/skill.ts @@ -5,6 +5,8 @@ Use this skill whenever the user asks you to write code, build a project, create Coding agents operate on **arbitrary file paths** (including paths outside the Rowboat workspace root, like \`G:/4th sem/CN\` or \`~/projects/foo\`). Do NOT raise "outside workspace" concerns, and do NOT fall back to your own \`executeCommand\` (PowerShell / bash) or workspace file tools to do code work yourself. +All coding work runs through the **\`code_agent_run\`** tool. It launches the selected on-device coding agent (Claude Code / Codex), streams its tool calls, file diffs, and plan into the chat, and surfaces any action needing approval as an inline permission card. One persistent session is kept per chat, so follow-up requests resume with full context automatically. + --- ## STEP 1 — MANDATORY FIRST ACTION @@ -39,96 +41,52 @@ This is non-negotiable. The user gets clickable buttons. Free-text "which agent? --- -## STEP 2 — Resolve workdir, confirm, execute +## STEP 2 — Resolve workdir, then run **Resolve the workdir** (in this priority order): 1. A path the user named in their original message (e.g. \`G:/4th sem/CN\`). 2. The path from a "# User Work Directory" block in your context. 3. Ask once in plain text: "Which folder should I work in?" -**State your intent in one line, then execute immediately — do NOT wait for a "yes".** The \`executeCommand\` call surfaces a permission card that is itself the user's confirmation, so an extra in-chat "reply yes to proceed" is redundant friction. Say something like: +**Pick the agent** (\`claude\` or \`codex\`): use the agent from the "# Code Mode (Active)" block (the composer chip) / the Step 1 choice. The chip is authoritative — do NOT carry over a different agent from earlier in this thread, and do NOT switch on an in-chat text request ("use codex"); tell the user to toggle the chip instead. + +**State your intent in one line, then call the tool immediately — do NOT wait for a "yes".** The tool's own permission cards are the user's confirmation, so an extra in-chat "reply yes to proceed" is redundant friction. Say something like: > Using [Claude Code / Codex] to [task description] in \`[folder]\`. -…and then immediately make the \`executeCommand\` call in the same turn. - -**Execute** with the chosen agent using a **persistent named session** so follow-up coding requests in this chat resume the same agent and keep context. - -Pick \`\` (\`claude\` or \`codex\`) by, in priority order: -- An explicit in-chat override from the user this turn ("use codex", "switch to claude") — honor it. -- The agent chosen in Step 1 / the "# Code Mode (Active)" block. - -Pick \`\` — **stable for this whole chat**: -- If the "# Code Mode (Active)" block gives a session name (e.g. \`rowboat-\`), use that exact name. -- Otherwise pick one short, kebab-case name and **reuse it for every coding call this turn and in follow-ups** — never a new name each time. - -**\`-s\` resumes an existing session; it does NOT create one.** So ensure the session exists once at the start, then prompt: - -**1. First coding action in this chat — ensure the session exists:** +…and then immediately call: \`\`\` -npx acpx@latest --approve-all --cwd sessions ensure --name +code_agent_run({ + agent: "", + cwd: "", + prompt: "" +}) \`\`\` -(\`ensure\` creates the session if missing and reuses it if it already exists — so reopening this chat later just resumes the same session instead of erroring.) - -**2. Then run the prompt:** - -\`\`\` -npx acpx@latest --approve-all --timeout 600 --cwd -s "" -\`\`\` - -**3. Every follow-up coding request in this chat — reuse the same session (do NOT create again):** - -\`\`\` -npx acpx@latest --approve-all --timeout 600 --cwd -s "" -\`\`\` - -**Run steps 1 and 2 as separate, sequential \`executeCommand\` calls.** Issue the \`sessions ensure\` call FIRST, wait for it to finish, and only THEN issue the prompt call. Do NOT fire both in the same turn / batch — each must surface and complete its own permission + command block before the next begins. - -Do NOT use \`exec\` — it is one-shot and forgets everything. - -Concrete example: - -\`\`\` -# First coding message in the chat — ensure the session, then prompt: -npx acpx@latest --approve-all --cwd "G:\\Blogging\\myblog" claude sessions ensure --name diskspace-check -npx acpx@latest --approve-all --timeout 600 --cwd "G:\\Blogging\\myblog" claude -s diskspace-check "Check the system disk space and report total, used, and free space." - -# Follow-up in the same chat — reuse the session, no create: -npx acpx@latest --approve-all --timeout 600 --cwd "G:\\Blogging\\myblog" claude -s diskspace-check "Summarize what we did and the final findings." -\`\`\` - -### Critical: flag order - -\`--approve-all\`, \`--timeout\`, and \`--cwd\` are GLOBAL flags and MUST appear BEFORE the agent name. \`sessions ensure --name \` and \`-s \` come AFTER the agent name: - -- ✓ Correct: \`npx acpx@latest --approve-all --timeout 600 --cwd -s ""\` -- ✗ Wrong: \`npx acpx@latest --approve-all -s "..."\` (will fail) - -### Writing good prompts for the agent - +**Writing good prompts for the agent:** - Be specific: file names, function signatures, expected behavior. - Mention constraints (language, framework, style). -- Expand short user requests into clear, actionable prompts. +- Expand short user requests into clear, actionable instructions. + +**Follow-ups:** for every later coding request in this chat, just call \`code_agent_run\` again with the same \`cwd\` and the chip's current agent. The session resumes automatically — do NOT start over or re-explain prior context. --- ## STEP 3 — Report results -After the command finishes: -- Pass through the coding agent's summary as-is. Do not rewrite. +After \`code_agent_run\` returns: +- Pass through the agent's \`summary\` as-is. Do not rewrite it. - Refer to file paths as plain text. Do NOT use \`\`\`file:path\`\`\` reference blocks. (This overrides the global "always wrap paths in filepath blocks" rule — for code-mode output, plain text.) -- Only add your own explanation if the command failed (non-zero exit): - - Exit code 5 — permissions were denied (shouldn't happen with \`--approve-all\`; flag it). - - Exit code 4 / "No acpx session found" — the \`-s \` session doesn't exist yet. Create it once with \`npx acpx@latest --approve-all --cwd sessions ensure --name \`, then retry the prompt. (\`-s\` only resumes; it never creates.) - - "command not found" / agent not installed, or an auth/sign-in error — the agent isn't set up. Tell the user to install or sign in to the agent via **Settings → Code Mode**, where Rowboat shows the install and sign-in status. +- Only add your own explanation if it failed: + - \`success: false\` with a message — surface the message. If it mentions the agent isn't installed or signed in, tell the user to install or sign in via **Settings → Code Mode**. + - \`stopReason: "cancelled"\` — the run was stopped; acknowledge briefly and ask if they want to continue. --- ## Once delegating: delegate fully -After Step 2 fires, delegate ALL related coding tasks for this turn to the coding agent — writing, editing, reading, debugging, exploring structure, running tests. You are the coordinator; the agent does the work. +After Step 2 fires, delegate ALL related coding tasks for this turn to \`code_agent_run\` — writing, editing, reading, debugging, exploring structure, running tests. You are the coordinator; the agent does the work. ## Prerequisites (informational) diff --git a/apps/x/packages/core/src/application/assistant/skills/index.ts b/apps/x/packages/core/src/application/assistant/skills/index.ts index 30ceea95..a06c153b 100644 --- a/apps/x/packages/core/src/application/assistant/skills/index.ts +++ b/apps/x/packages/core/src/application/assistant/skills/index.ts @@ -99,7 +99,7 @@ const definitions: SkillDefinition[] = [ { id: "code-with-agents", title: "Code with Agents", - summary: "Write code, build projects, create scripts, or fix bugs by delegating to Claude Code or Codex via acpx.", + summary: "Write code, build projects, create scripts, or fix bugs by delegating to Claude Code or Codex.", content: codeWithAgentsSkill, }, { diff --git a/apps/x/packages/core/src/application/lib/builtin-tools.ts b/apps/x/packages/core/src/application/lib/builtin-tools.ts index 9bfb4250..08e8334f 100644 --- a/apps/x/packages/core/src/application/lib/builtin-tools.ts +++ b/apps/x/packages/core/src/application/lib/builtin-tools.ts @@ -1,7 +1,6 @@ import { z, ZodType } from "zod"; import * as path from "path"; import * as fs from "fs/promises"; -import { existsSync, readFileSync } from "fs"; import { executeCommand, executeCommandAbortable } from "./command-executor.js"; import { resolveSkill, availableSkills } from "../assistant/skills/index.js"; import { executeTool, listServers, listTools } from "../../mcp/mcp.js"; @@ -16,6 +15,10 @@ import { executeAction as executeComposioAction, isConfigured as isComposioConfi import { CURATED_TOOLKITS, CURATED_TOOLKIT_SLUGS } from "@x/shared/dist/composio.js"; import { BrowserControlInputSchema, type BrowserControlInput } from "@x/shared/dist/browser-control.js"; import { BackgroundTaskSchema, TriggersSchema } from "@x/shared/dist/background-task.js"; +import type { CodeModeManager } from "../../code-mode/acp/manager.js"; +import type { CodePermissionRegistry } from "../../code-mode/acp/permission-registry.js"; +import { ICodeModeConfigRepo } from "../../code-mode/repo.js"; +import type { ApprovalPolicy } from "@x/shared/dist/code-mode.js"; // Inputs for the bg-task builtin tools. Reuse the canonical schema field // descriptions; only `triggers` gets a tighter contextual override (the @@ -90,69 +93,6 @@ const LLMPARSE_MIME_TYPES: Record = { '.tiff': 'image/tiff', }; -// Windows-only workaround: the Claude ACP bridge spawns CLAUDE_CODE_EXECUTABLE -// without `shell: true`, and Node refuses to spawn .cmd files that way (EINVAL). -// When the LLM invokes acpx via executeCommand, pre-resolve claude's real .exe -// from the npm-shim layout and inject it via env so the bridge can spawn it. -function resolveClaudeExeOnWindows(): string | undefined { - // Candidate dirs = everything on PATH, plus well-known npm/pnpm/volta global - // bin dirs. Electron's runtime PATH can omit these even when the user's shell - // includes them, which would otherwise leave us unable to find claude.exe and - // force a fallback to claude.cmd (which Node refuses to spawn — EINVAL). - const home = process.env.USERPROFILE ?? ''; - const appData = process.env.APPDATA || (home && path.join(home, 'AppData', 'Roaming')); - const localAppData = process.env.LOCALAPPDATA || (home && path.join(home, 'AppData', 'Local')); - const programFiles = process.env.ProgramFiles || 'C:\\Program Files'; - const knownDirs = [ - appData && path.join(appData, 'npm'), - localAppData && path.join(localAppData, 'npm'), - appData && path.join(appData, 'pnpm'), - localAppData && path.join(localAppData, 'pnpm'), - home && path.join(home, '.volta', 'bin'), - path.join(programFiles, 'nodejs'), - ].filter(Boolean) as string[]; - - const pathDirs = (process.env.PATH ?? '').split(';').map((d) => d.trim()).filter(Boolean); - const seen = new Set(); - const candidates = [...pathDirs, ...knownDirs].filter((d) => { - const key = d.toLowerCase(); - if (seen.has(key)) return false; - seen.add(key); - return true; - }); - - for (const dir of candidates) { - // Direct npm-shim layout: \node_modules\@anthropic-ai\claude-code\bin\claude.exe - const exeFromLayout = path.join(dir, 'node_modules', '@anthropic-ai', 'claude-code', 'bin', 'claude.exe'); - if (existsSync(exeFromLayout)) return exeFromLayout; - - // Otherwise parse the claude.cmd shim for the real exe path. - const cmdPath = path.join(dir, 'claude.cmd'); - if (!existsSync(cmdPath)) continue; - try { - const content = readFileSync(cmdPath, 'utf-8'); - const absMatch = content.match(/[A-Z]:[\\/][^\s"]*claude\.exe/i); - if (absMatch && existsSync(absMatch[0])) return absMatch[0]; - const relMatch = content.match(/%~dp0[\\/]?([^\s"%]+claude\.exe)/i); - if (relMatch) { - const resolved = path.join(dir, relMatch[1]); - if (existsSync(resolved)) return resolved; - } - } catch { - // ignore shim parse failures - } - } - return undefined; -} - -function envForCommand(command: string): NodeJS.ProcessEnv | undefined { - if (process.platform !== 'win32') return undefined; - if (!/\bacpx\b/.test(command)) return undefined; - if (process.env.CLAUDE_CODE_EXECUTABLE) return undefined; - const exe = resolveClaudeExeOnWindows(); - if (!exe) return undefined; - return { ...process.env, CLAUDE_CODE_EXECUTABLE: exe }; -} export const BuiltinTools: z.infer = { loadSkill: { @@ -814,14 +754,11 @@ export const BuiltinTools: z.infer = { // }; // } - const envOverride = envForCommand(command); - // Use abortable version when we have a signal if (ctx?.signal) { const { promise, process: proc } = executeCommandAbortable(command, { cwd: workingDir, signal: ctx.signal, - env: envOverride, onData: (chunk: string) => { ctx.publish({ runId: ctx.runId, @@ -851,7 +788,7 @@ export const BuiltinTools: z.infer = { } // Fallback to original for backward compatibility - const result = await executeCommand(command, { cwd: workingDir, env: envOverride }); + const result = await executeCommand(command, { cwd: workingDir }); return { success: result.exitCode === 0, @@ -871,6 +808,104 @@ export const BuiltinTools: z.infer = { }, }, + code_agent_run: { + description: 'Run a coding/software task with the selected on-device coding agent (Claude Code or Codex) inside a project folder. Streams the agent\'s tool calls, file diffs, and plan into the chat and surfaces permission requests inline. Use this for ALL code-mode work (writing/editing/reading code, running tests, debugging, exploring a repo). Reuses one persistent session per chat, so follow-up requests keep context.', + inputSchema: z.object({ + agent: z.enum(['claude', 'codex']).describe('Which coding agent to use: "claude" (Claude Code) or "codex". Set this to the active code-mode chip agent. Note: when the chip is set, the backend uses the chip agent regardless of this value — this only takes effect in the ask-human flow where no chip is set.'), + cwd: z.string().describe('Absolute path to the working directory / project folder the agent should operate in.'), + prompt: z.string().describe('The full, self-contained coding instruction for the agent (file names, expected behavior, constraints).'), + }), + execute: async ({ agent, cwd, prompt }: { agent: 'claude' | 'codex', cwd: string, prompt: string }, ctx?: ToolContext) => { + if (!ctx) { + return { success: false, message: 'code_agent_run requires run context (runId / streaming).' }; + } + // The composer chip is the source of truth for the agent. The model's `agent` + // argument is only a fallback for the ask-human flow (code mode not active, no + // chip set) — otherwise it can anchor on the thread's earlier agent and ignore a + // chip change. Honor the chip so switching it deterministically switches agents. + const effectiveAgent = ctx.codeMode ?? agent; + const manager = container.resolve('codeModeManager'); + const registry = container.resolve('codePermissionRegistry'); + + // Approval policy from settings; default to asking the user. + let policy: ApprovalPolicy = 'ask'; + try { + const cfg = await container.resolve('codeModeConfigRepo').getConfig(); + if (cfg.approvalPolicy) policy = cfg.approvalPolicy; + } catch { + // fall back to 'ask' + } + + // On stop, unblock any pending approval card so the broker stops waiting for + // an answer that will never come. The ACP cancel + force-kill backstop that + // actually ends the turn is handled inside manager.runPrompt via the signal + // we pass below. + const onAbort = () => registry.cancelRun(ctx.runId); + if (ctx.signal.aborted) onAbort(); + else ctx.signal.addEventListener('abort', onAbort, { once: true }); + + let finalText = ''; + const changedFiles = new Set(); + try { + const result = await manager.runPrompt({ + runId: ctx.runId, + agent: effectiveAgent, + cwd, + prompt, + policy, + signal: ctx.signal, + onEvent: (event) => { + if (event.type === 'message' && event.role === 'agent') finalText += event.text; + if (event.type === 'tool_call_update') for (const f of event.diffs) changedFiles.add(f); + void ctx.publish({ + runId: ctx.runId, + type: 'code-run-event', + toolCallId: ctx.toolCallId, + event, + subflow: [], + }); + }, + ask: (permAsk) => registry.request(ctx.runId, (requestId) => { + void ctx.publish({ + runId: ctx.runId, + type: 'code-run-permission-request', + toolCallId: ctx.toolCallId, + requestId, + ask: permAsk, + subflow: [], + }); + }), + }); + return { + success: result.stopReason === 'end_turn', + stopReason: result.stopReason, + // The agent that actually ran (the chip), so the UI can label the run + // authoritatively rather than trusting the model's `agent` argument. + agent: effectiveAgent, + summary: finalText.trim(), + changedFiles: [...changedFiles], + }; + } catch (error) { + // A stop mid-run isn't a failure — report it as a clean cancellation. + if (ctx.signal.aborted) { + return { + success: false, + stopReason: 'cancelled', + agent: effectiveAgent, + summary: finalText.trim(), + changedFiles: [...changedFiles], + }; + } + return { + success: false, + message: `Coding agent failed: ${error instanceof Error ? error.message : String(error)}`, + }; + } finally { + ctx.signal.removeEventListener('abort', onAbort); + } + }, + }, + // ============================================================================ // Browser Skills (browser-use/browser-harness domain-skills cache) // ============================================================================ diff --git a/apps/x/packages/core/src/application/lib/command-executor.ts b/apps/x/packages/core/src/application/lib/command-executor.ts index 6be3c0e6..357ce1a8 100644 --- a/apps/x/packages/core/src/application/lib/command-executor.ts +++ b/apps/x/packages/core/src/application/lib/command-executor.ts @@ -80,7 +80,7 @@ export async function executeCommand( cwd?: string; timeout?: number; // timeout in milliseconds maxBuffer?: number; // max buffer size in bytes - env?: NodeJS.ProcessEnv; // override environment (e.g. CLAUDE_CODE_EXECUTABLE for acpx) + env?: NodeJS.ProcessEnv; // override environment } ): Promise { try { diff --git a/apps/x/packages/core/src/application/lib/exec-tool.ts b/apps/x/packages/core/src/application/lib/exec-tool.ts index 92e87fa6..b34f6ed4 100644 --- a/apps/x/packages/core/src/application/lib/exec-tool.ts +++ b/apps/x/packages/core/src/application/lib/exec-tool.ts @@ -14,6 +14,10 @@ export interface ToolContext { signal: AbortSignal; abortRegistry: IAbortRegistry; publish: (event: z.infer) => Promise; + // The composer code-mode chip for the message that triggered this turn. When set, + // it is the authoritative coding agent — code_agent_run uses it rather than the + // agent the model guessed, so switching the chip deterministically switches agents. + codeMode?: 'claude' | 'codex' | null; } async function execMcpTool(agentTool: z.infer & { type: "mcp" }, input: Record): Promise { diff --git a/apps/x/packages/core/src/background-tasks/agent.ts b/apps/x/packages/core/src/background-tasks/agent.ts index 3f3a2d47..853c1ef0 100644 --- a/apps/x/packages/core/src/background-tasks/agent.ts +++ b/apps/x/packages/core/src/background-tasks/agent.ts @@ -71,7 +71,9 @@ The workspace lives at \`${WorkDir}\`. export function buildBackgroundTaskAgent(): z.infer { const tools: Record> = {}; for (const name of Object.keys(BuiltinTools)) { - if (name === 'executeCommand') continue; + // code_agent_run requires an interactive UI for permission approvals — skip it + // here (headless) so it can't hang on an approval no one can answer. + if (name === 'executeCommand' || name === 'code_agent_run') continue; tools[name] = { type: 'builtin', name }; } diff --git a/apps/x/packages/core/src/code-mode/acp/agents.ts b/apps/x/packages/core/src/code-mode/acp/agents.ts new file mode 100644 index 00000000..da06d8ea --- /dev/null +++ b/apps/x/packages/core/src/code-mode/acp/agents.ts @@ -0,0 +1,60 @@ +import { createRequire } from 'module'; +import * as path from 'path'; +import type { CodingAgent } from './types.js'; +import { resolveClaudeExecutable } from './claude-exec.js'; + +const require = createRequire(import.meta.url); + +// The ACP adapter npm package that exposes each coding agent as an ACP server. +const ADAPTER_PACKAGE: Record = { + claude: '@agentclientprotocol/claude-agent-acp', + codex: '@agentclientprotocol/codex-acp', +}; + +export interface AgentLaunchSpec { + /** Executable to spawn — always `node` so we never hit the Windows .cmd EINVAL. */ + command: string; + /** Args = [adapter entry script]. */ + args: string[]; + /** Extra env merged over process.env (e.g. CLAUDE_CODE_EXECUTABLE on Windows). */ + env: NodeJS.ProcessEnv; +} + +// Resolve the adapter's executable ENTRY (its `bin`, not its library `main`) to an +// absolute path so we can spawn it directly with `node `. createRequire lets +// us resolve workspace/pnpm-installed packages from this module's location. +function resolveAdapterEntry(pkg: string): string { + const pkgJsonPath = require.resolve(`${pkg}/package.json`); + const pkgDir = path.dirname(pkgJsonPath); + const pkgJson = require(`${pkg}/package.json`) as { bin?: string | Record }; + const bin = pkgJson.bin; + const rel = typeof bin === 'string' ? bin : bin ? Object.values(bin)[0] : undefined; + if (!rel) { + throw new Error(`ACP adapter ${pkg} has no bin entry to spawn`); + } + return path.join(pkgDir, rel); +} + +export function getAgentLaunchSpec(agent: CodingAgent): AgentLaunchSpec { + const entry = resolveAdapterEntry(ADAPTER_PACKAGE[agent]); + const env: NodeJS.ProcessEnv = { ...process.env }; + + // Point the Claude adapter at the real claude executable. On Windows this is + // mandatory (Node can't spawn the .cmd shim — EINVAL); on macOS/Linux it's a + // PATH safety net for GUI launches. Resolver is a no-op when claude isn't found, + // leaving the adapter to do its own lookup. (Codex relies on PATH for now — wire + // an equivalent when we add Codex support.) + if (agent === 'claude' && !env.CLAUDE_CODE_EXECUTABLE) { + const exe = resolveClaudeExecutable(); + if (exe) env.CLAUDE_CODE_EXECUTABLE = exe; + } + + // We spawn the adapter with process.execPath. Inside Electron's main process + // that is the Electron binary, NOT node — so set ELECTRON_RUN_AS_NODE=1 to make + // it behave as a plain Node runtime. (Harmless under a real node process, which + // ignores the var.) Without this the child never runs as node and the ACP stdio + // stream closes immediately ("ACP connection closed"). + env.ELECTRON_RUN_AS_NODE = '1'; + + return { command: process.execPath, args: [entry], env }; +} diff --git a/apps/x/packages/core/src/code-mode/acp/claude-exec.ts b/apps/x/packages/core/src/code-mode/acp/claude-exec.ts new file mode 100644 index 00000000..ae6c9c51 --- /dev/null +++ b/apps/x/packages/core/src/code-mode/acp/claude-exec.ts @@ -0,0 +1,91 @@ +import { execSync } from 'child_process'; +import * as path from 'path'; +import { existsSync, readFileSync } from 'fs'; +import { commonInstallPaths } from '../status.js'; + +// Windows-only: Node refuses to spawn `.cmd` files without `shell: true` (EINVAL), +// and the Claude ACP adapter spawns its executable directly. So we pre-resolve +// claude's real `.exe` from the npm-shim layout. Used by resolveClaudeExecutable below. +export function resolveClaudeExeOnWindows(): string | undefined { + // Candidate dirs = everything on PATH, plus well-known npm/pnpm/volta global + // bin dirs. Electron's runtime PATH can omit these even when the user's shell + // includes them, which would otherwise leave us unable to find claude.exe and + // force a fallback to claude.cmd (which Node refuses to spawn — EINVAL). + const home = process.env.USERPROFILE ?? ''; + const appData = process.env.APPDATA || (home && path.join(home, 'AppData', 'Roaming')); + const localAppData = process.env.LOCALAPPDATA || (home && path.join(home, 'AppData', 'Local')); + const programFiles = process.env.ProgramFiles || 'C:\\Program Files'; + const knownDirs = [ + appData && path.join(appData, 'npm'), + localAppData && path.join(localAppData, 'npm'), + appData && path.join(appData, 'pnpm'), + localAppData && path.join(localAppData, 'pnpm'), + home && path.join(home, '.volta', 'bin'), + path.join(programFiles, 'nodejs'), + ].filter(Boolean) as string[]; + + const pathDirs = (process.env.PATH ?? '').split(';').map((d) => d.trim()).filter(Boolean); + const seen = new Set(); + const candidates = [...pathDirs, ...knownDirs].filter((d) => { + const key = d.toLowerCase(); + if (seen.has(key)) return false; + seen.add(key); + return true; + }); + + for (const dir of candidates) { + // Direct npm-shim layout: \node_modules\@anthropic-ai\claude-code\bin\claude.exe + const exeFromLayout = path.join(dir, 'node_modules', '@anthropic-ai', 'claude-code', 'bin', 'claude.exe'); + if (existsSync(exeFromLayout)) return exeFromLayout; + + // Otherwise parse the claude.cmd shim for the real exe path. + const cmdPath = path.join(dir, 'claude.cmd'); + if (!existsSync(cmdPath)) continue; + try { + const content = readFileSync(cmdPath, 'utf-8'); + const absMatch = content.match(/[A-Z]:[\\/][^\s"]*claude\.exe/i); + if (absMatch && existsSync(absMatch[0])) return absMatch[0]; + const relMatch = content.match(/%~dp0[\\/]?([^\s"%]+claude\.exe)/i); + if (relMatch) { + const resolved = path.join(dir, relMatch[1]); + if (existsSync(resolved)) return resolved; + } + } catch { + // ignore shim parse failures + } + } + return undefined; +} + +// macOS/Linux: find the real `claude` binary. Unlike Windows this isn't a spawn +// requirement (no .cmd problem) — it's a PATH safety net. Electron apps launched +// from the GUI (Dock/Finder) often don't inherit the login shell's PATH, so the +// spawned adapter may fail to find `claude`. We resolve the path here so the adapter +// can be pointed straight at it. +function resolveClaudeBinaryUnix(): string | undefined { + // Primary: a login shell sees the user's full PATH (~/.zprofile, nvm, homebrew, …). + try { + const out = execSync("/bin/sh -lc 'command -v claude'", { timeout: 5000, encoding: 'utf-8' }).trim(); + if (out && existsSync(out)) return out; + } catch { + // not found on the login-shell PATH + } + // Fallback: scan well-known install locations directly. + for (const candidate of commonInstallPaths('claude')) { + if (existsSync(candidate)) return candidate; + } + return undefined; +} + +let cached: string | undefined; + +// Cross-platform: the real `claude` executable to hand the ACP adapter via +// CLAUDE_CODE_EXECUTABLE (the adapter prefers this env var on every OS). Returns +// undefined if it can't be found — callers then fall back to the adapter's own lookup. +// Cached on first success so we don't re-probe the shell on every cold start. +export function resolveClaudeExecutable(): string | undefined { + if (cached) return cached; + const resolved = process.platform === 'win32' ? resolveClaudeExeOnWindows() : resolveClaudeBinaryUnix(); + if (resolved) cached = resolved; + return resolved; +} diff --git a/apps/x/packages/core/src/code-mode/acp/client.ts b/apps/x/packages/core/src/code-mode/acp/client.ts new file mode 100644 index 00000000..5c2bd1ba --- /dev/null +++ b/apps/x/packages/core/src/code-mode/acp/client.ts @@ -0,0 +1,219 @@ +import { spawn, type ChildProcess } from 'child_process'; +import { Writable, Readable } from 'node:stream'; +import fs from 'fs/promises'; +import { + ClientSideConnection, + ndJsonStream, + PROTOCOL_VERSION, + type Client, + type RequestPermissionRequest, + type RequestPermissionResponse, + type SessionNotification, + type SessionUpdate, + type PromptResponse, + type ReadTextFileRequest, + type ReadTextFileResponse, + type WriteTextFileRequest, + type WriteTextFileResponse, +} from '@agentclientprotocol/sdk'; +import type { CodingAgent, CodeRunEvent } from './types.js'; +import type { PermissionBroker } from './permission-broker.js'; +import { getAgentLaunchSpec } from './agents.js'; + +export interface AcpClientOptions { + agent: CodingAgent; + cwd: string; + broker: PermissionBroker; + onEvent: (event: CodeRunEvent) => void; +} + +// Map a raw ACP session/update notification onto our small CodeRunEvent union. +function toEvent(update: SessionUpdate): CodeRunEvent { + switch (update.sessionUpdate) { + case 'agent_message_chunk': + case 'user_message_chunk': { + const c = update.content; + const role = update.sessionUpdate === 'user_message_chunk' ? 'user' : 'agent'; + return { type: 'message', role, text: c.type === 'text' ? c.text : `[${c.type}]` }; + } + case 'agent_thought_chunk': + return { type: 'thought' }; + case 'tool_call': + return { + type: 'tool_call', + id: update.toolCallId, + title: update.title, + kind: update.kind ?? undefined, + status: update.status ?? undefined, + }; + case 'tool_call_update': { + const diffs = (update.content ?? []) + .filter((c): c is Extract => c.type === 'diff') + .map((c) => c.path); + return { type: 'tool_call_update', id: update.toolCallId, status: update.status ?? undefined, diffs }; + } + case 'plan': + return { + type: 'plan', + entries: (update.entries ?? []).map((e) => ({ + content: e.content, + status: e.status ?? undefined, + priority: e.priority ?? undefined, + })), + }; + default: + return { type: 'other', sessionUpdate: update.sessionUpdate }; + } +} + +// Owns one spawned adapter process + ACP connection. Stateless about sessions — +// the manager decides whether to newSession or loadSession. +// +// The connection is long-lived and reused across follow-up prompts, but each prompt +// may stream to a different message's UI, so broker + onEvent are swappable via +// setHandlers() rather than fixed at construction. +export class AcpClient { + readonly agent: CodingAgent; + readonly cwd: string; + private broker: PermissionBroker; + private onEvent: (event: CodeRunEvent) => void; + private child?: ChildProcess; + private connection?: ClientSideConnection; + private loadSession_ = false; + // Diagnostics: the adapter's stderr/exit are captured so a dropped connection + // reports WHY (e.g. a crash) instead of the SDK's bare "ACP connection closed". + private stderrTail = ''; + private exitInfo: string | null = null; + + constructor(opts: AcpClientOptions) { + this.agent = opts.agent; + this.cwd = opts.cwd; + this.broker = opts.broker; + this.onEvent = opts.onEvent; + } + + get loadSupported(): boolean { + return this.loadSession_; + } + + // Re-point the live connection at a new prompt's broker / event sink. + setHandlers(broker: PermissionBroker, onEvent: (event: CodeRunEvent) => void): void { + this.broker = broker; + this.onEvent = onEvent; + } + + // Spawn the adapter and negotiate the protocol. Returns once initialized. + async start(): Promise { + const spec = getAgentLaunchSpec(this.agent); + const child = spawn(spec.command, spec.args, { + cwd: this.cwd, + env: spec.env, + // Capture stderr (not inherit) so we can attribute a dropped connection. + stdio: ['pipe', 'pipe', 'pipe'], + }); + this.child = child; + child.stderr?.on('data', (d: Buffer) => { + this.stderrTail = (this.stderrTail + d.toString()).slice(-4000); + }); + child.on('exit', (code, signal) => { + this.exitInfo = `adapter exited (code ${code}${signal ? `, signal ${signal}` : ''})`; + }); + child.on('error', (err) => { + this.stderrTail = (this.stderrTail + `\nspawn error: ${err.message}`).slice(-4000); + }); + + const stream = ndJsonStream( + Writable.toWeb(child.stdin!) as WritableStream, + Readable.toWeb(child.stdout!) as ReadableStream, + ); + const client = this.buildClient(); + this.connection = new ClientSideConnection(() => client, stream); + + try { + const init = await this.connection.initialize({ + protocolVersion: PROTOCOL_VERSION, + clientCapabilities: { fs: { readTextFile: true, writeTextFile: true } }, + }); + this.loadSession_ = init.agentCapabilities?.loadSession === true; + } catch (e) { + throw this.enrich(e, 'initialize'); + } + } + + async newSession(): Promise { + try { + const res = await this.conn().newSession({ cwd: this.cwd, mcpServers: [] }); + return res.sessionId; + } catch (e) { + throw this.enrich(e, 'newSession'); + } + } + + async loadSession(sessionId: string): Promise { + try { + await this.conn().loadSession({ sessionId, cwd: this.cwd, mcpServers: [] }); + } catch (e) { + throw this.enrich(e, 'loadSession'); + } + } + + async prompt(sessionId: string, text: string): Promise { + try { + return await this.conn().prompt({ sessionId, prompt: [{ type: 'text', text }] }); + } catch (e) { + throw this.enrich(e, 'prompt'); + } + } + + // Wrap a connection error with the adapter's exit/stderr so failures are + // self-explanatory rather than the SDK's opaque "ACP connection closed". + private enrich(err: unknown, phase: string): Error { + const base = err instanceof Error ? err.message : String(err); + const parts = [ + this.exitInfo, + this.stderrTail.trim() ? `adapter output: ${this.stderrTail.trim().slice(-1200)}` : '', + ].filter(Boolean); + return new Error(parts.length ? `${base} — ${parts.join(' | ')} [during ${phase}]` : `${base} [during ${phase}]`); + } + + async cancel(sessionId: string): Promise { + await this.conn().cancel({ sessionId }); + } + + dispose(): void { + try { + this.child?.kill(); + } catch { + // already gone + } + this.child = undefined; + this.connection = undefined; + } + + private conn(): ClientSideConnection { + if (!this.connection) throw new Error('AcpClient not started'); + return this.connection; + } + + // The client side of ACP: the agent calls these on us. These read the CURRENT + // handlers off `self` so follow-up prompts can swap them via setHandlers(). + private buildClient(): Client { + const self = this; + return { + async requestPermission(params: RequestPermissionRequest): Promise { + return self.broker.resolve(params); + }, + async sessionUpdate(params: SessionNotification): Promise { + self.onEvent(toEvent(params.update)); + }, + async readTextFile(params: ReadTextFileRequest): Promise { + const content = await fs.readFile(params.path, 'utf8'); + return { content }; + }, + async writeTextFile(params: WriteTextFileRequest): Promise { + await fs.writeFile(params.path, params.content); + return {}; + }, + }; + } +} diff --git a/apps/x/packages/core/src/code-mode/acp/manager.ts b/apps/x/packages/core/src/code-mode/acp/manager.ts new file mode 100644 index 00000000..04ccdebd --- /dev/null +++ b/apps/x/packages/core/src/code-mode/acp/manager.ts @@ -0,0 +1,186 @@ +import type { ApprovalPolicy, CodeRunEvent, CodingAgent, PermissionAsk, PermissionDecision, RunPromptResult } from './types.js'; +import { AcpClient } from './client.js'; +import { PermissionBroker } from './permission-broker.js'; +import { readStoredSession, writeStoredSession, clearStoredSession } from './session-store.js'; + +export interface RunPromptArgs { + runId: string; + agent: CodingAgent; + cwd: string; + prompt: string; + policy: ApprovalPolicy; + /** Called when the policy needs the user to decide (the "ask" path). */ + ask: (ask: PermissionAsk) => Promise; + /** Stream sink for this prompt's run. */ + onEvent: (event: CodeRunEvent) => void; + /** Aborts the turn on stop; the manager cancels then force-kills the adapter. */ + signal?: AbortSignal; +} + +interface ActiveRun { + client: AcpClient; + sessionId: string; + agent: CodingAgent; + cwd: string; + // Prompts currently streaming on this connection. Disposal is deferred while + // this is > 0 so we never tear down a connection mid-turn. + inflight: number; + // Pending grace-window teardown, cleared if the run is reused before it fires. + disposeTimer?: ReturnType; +} + +// How long a connection stays warm after its last turn ends before we tear it down. +// A coding "turn" is one code_agent_run tool call; we keep the adapter briefly so +// back-to-back calls within one copilot turn (edit -> test -> fix) and quick user +// follow-ups reuse the warm connection instead of cold-starting. Set to 0 for strict +// per-turn teardown. Context is never lost either way: the next turn resumes the +// persisted session via session/load. +const DISPOSE_GRACE_MS = 60_000; + +// On stop, how long to let the adapter cancel gracefully (ACP session/cancel) before +// we force-kill it. The kill guarantees the turn unwinds even if the adapter ignores +// cancel or is blocked — otherwise a hung prompt would lock the chat indefinitely. +const CANCEL_GRACE_MS = 2_000; + +// Drives ACP coding sessions. A connection's lifetime is scoped to the agent turn +// (one code_agent_run): it is torn down a short grace window after the turn ends, so +// idle chats hold no adapter processes. Turns that land within the grace window reuse +// the warm connection; anything colder (grace elapsed, or after an app restart) +// resumes the persisted session via session/load. +export class CodeModeManager { + private readonly runs = new Map(); + + async runPrompt(args: RunPromptArgs): Promise { + const { runId, agent, cwd, prompt, policy, ask, onEvent, signal } = args; + + const broker = new PermissionBroker({ + policy, + ask, + onResolved: (a, decision, auto) => onEvent({ type: 'permission', ask: a, decision, auto }), + }); + + const run = await this.ensureRun(runId, agent, cwd, broker, onEvent); + run.inflight++; + + let graceTimer: ReturnType | undefined; + let onAbort: (() => void) | undefined; + try { + const promptP = run.client.prompt(run.sessionId, prompt); + // We may stop awaiting this prompt below (force-kill on stop rejects it); + // attach a no-op catch so the orphaned rejection isn't flagged. + promptP.catch(() => {}); + + // Stop handling: on abort, ask the adapter to cancel; if it hasn't unwound + // within the grace, force-kill it and resolve as cancelled. This guarantees + // the turn ends even if the adapter ignores cancel or is wedged — a hung + // prompt would otherwise lock the chat (no run-stopped, composer disabled). + const cancelledP = new Promise<{ stopReason: string }>((resolve) => { + if (!signal) return; + onAbort = () => { + run.client.cancel(run.sessionId).catch(() => {}); + graceTimer = setTimeout(() => { + this.dispose(runId); + resolve({ stopReason: 'cancelled' }); + }, CANCEL_GRACE_MS); + graceTimer.unref?.(); + }; + if (signal.aborted) onAbort(); + else signal.addEventListener('abort', onAbort, { once: true }); + }); + + const res = await Promise.race([promptP, cancelledP]); + return { stopReason: res.stopReason, sessionId: run.sessionId }; + } catch (e) { + // A kill-induced "connection closed" during a stop is an expected cancel. + if (signal?.aborted) return { stopReason: 'cancelled', sessionId: run.sessionId }; + throw e; + } finally { + if (signal && onAbort) signal.removeEventListener('abort', onAbort); + if (graceTimer) clearTimeout(graceTimer); + run.inflight--; + this.scheduleDispose(runId); + } + } + + dispose(runId: string): void { + const run = this.runs.get(runId); + if (!run) return; + this.cancelDispose(run); + run.client.dispose(); + this.runs.delete(runId); + } + + // Tear down the connection a grace window after its last turn ends. Skipped while a + // prompt is still streaming, and re-armed when each turn ends so the window measures + // idle-since-last-activity. With grace 0 we dispose immediately (strict per-turn). + private scheduleDispose(runId: string): void { + const run = this.runs.get(runId); + if (!run || run.inflight > 0) return; + this.cancelDispose(run); + if (DISPOSE_GRACE_MS <= 0) { + this.dispose(runId); + return; + } + run.disposeTimer = setTimeout(() => { + const r = this.runs.get(runId); + if (r && r.inflight === 0) this.dispose(runId); + }, DISPOSE_GRACE_MS); + // A pending teardown timer must not keep the process alive at quit. + run.disposeTimer.unref?.(); + } + + private cancelDispose(run: ActiveRun): void { + if (run.disposeTimer) { + clearTimeout(run.disposeTimer); + run.disposeTimer = undefined; + } + } + + disposeAll(): void { + for (const runId of [...this.runs.keys()]) this.dispose(runId); + } + + // Reuse the warm connection if it matches; otherwise (cold start, or the user + // switched agent/cwd for this chat) build a fresh one and create-or-resume its session. + private async ensureRun( + runId: string, + agent: CodingAgent, + cwd: string, + broker: PermissionBroker, + onEvent: (event: CodeRunEvent) => void, + ): Promise { + const existing = this.runs.get(runId); + if (existing && existing.agent === agent && existing.cwd === cwd) { + this.cancelDispose(existing); // reused before its grace window elapsed + existing.client.setHandlers(broker, onEvent); + return existing; + } + if (existing) this.dispose(runId); // agent/cwd changed — start over + + const client = new AcpClient({ agent, cwd, broker, onEvent }); + await client.start(); + + const sessionId = await this.openSession(runId, agent, cwd, client); + const run: ActiveRun = { client, sessionId, agent, cwd, inflight: 0 }; + this.runs.set(runId, run); + return run; + } + + // Resume the persisted session for this chat when possible; else start a new one + // and persist its id so a later restart can resume it. + private async openSession(runId: string, agent: CodingAgent, cwd: string, client: AcpClient): Promise { + const stored = await readStoredSession(runId); + if (stored && stored.agent === agent && stored.cwd === cwd && client.loadSupported) { + try { + await client.loadSession(stored.sessionId); + return stored.sessionId; + } catch { + // Stored session is stale/unloadable — fall through to a fresh one. + await clearStoredSession(runId); + } + } + const sessionId = await client.newSession(); + await writeStoredSession({ runId, agent, cwd, sessionId }); + return sessionId; + } +} diff --git a/apps/x/packages/core/src/code-mode/acp/permission-broker.ts b/apps/x/packages/core/src/code-mode/acp/permission-broker.ts new file mode 100644 index 00000000..9699dec4 --- /dev/null +++ b/apps/x/packages/core/src/code-mode/acp/permission-broker.ts @@ -0,0 +1,91 @@ +import type { + RequestPermissionRequest, + RequestPermissionResponse, + PermissionOption, + PermissionOptionKind, +} from '@agentclientprotocol/sdk'; +import type { ApprovalPolicy, PermissionDecision, PermissionAsk } from './types.js'; + +// Tool kinds that don't mutate anything — eligible for `auto-approve-reads`. +const READ_KINDS = new Set(['read', 'search', 'fetch', 'think']); + +function toAsk(request: RequestPermissionRequest): PermissionAsk { + const tc = request.toolCall; + const kind = tc.kind ?? undefined; + const title = tc.title ?? kind ?? 'Tool call'; + return { + toolCallId: tc.toolCallId ?? undefined, + title, + kind, + isRead: kind ? READ_KINDS.has(kind) : false, + }; +} + +// Map a desired decision to one of the options the agent actually offered. +// Agents may offer only a subset (e.g. allow_once + reject_once, no allow_always), +// so we fall back within the same allow/reject family before giving up. +function pickOption(options: PermissionOption[], decision: PermissionDecision): PermissionOption | undefined { + const order: Record = { + allow_always: ['allow_always', 'allow_once'], + allow_once: ['allow_once', 'allow_always'], + reject: ['reject_once', 'reject_always'], + }; + for (const kind of order[decision]) { + const found = options.find((o) => o.kind === kind); + if (found) return found; + } + return undefined; +} + +function selected(optionId: string): RequestPermissionResponse { + return { outcome: { outcome: 'selected', optionId } }; +} + +// A request's identity for "always allow" memory: prefer tool kind, else title. +function memoryKey(ask: PermissionAsk): string { + return ask.kind ? `kind:${ask.kind}` : `title:${ask.title}`; +} + +export interface PermissionBrokerOptions { + policy: ApprovalPolicy; + // Called only when the policy can't decide on its own (the "ask" path). + ask: (ask: PermissionAsk) => Promise; + // Notified of every resolved request so the engine can emit a stream event. + onResolved?: (ask: PermissionAsk, decision: PermissionDecision, auto: boolean) => void; +} + +// Decides how to answer the agent's requestPermission calls. Holds per-session +// "always allow" memory so a one-time approval sticks for the rest of the run. +export class PermissionBroker { + private readonly opts: PermissionBrokerOptions; + private readonly alwaysAllow = new Set(); + + constructor(opts: PermissionBrokerOptions) { + this.opts = opts; + } + + async resolve(request: RequestPermissionRequest): Promise { + const ask = toAsk(request); + const key = memoryKey(ask); + + const finish = (decision: PermissionDecision, auto: boolean): RequestPermissionResponse => { + if (decision === 'allow_always') this.alwaysAllow.add(key); + this.opts.onResolved?.(ask, decision, auto); + const opt = pickOption(request.options, decision); + // If the agent offered no matching option we fall back to its first one + // (don't deadlock the turn); decision precedence above keeps this rare. + return selected(opt?.optionId ?? request.options[0]?.optionId ?? ''); + }; + + // 1. Sticky "always allow" from earlier this session. + if (this.alwaysAllow.has(key)) return finish('allow_always', true); + + // 2. Policy-level auto decisions. + if (this.opts.policy === 'yolo') return finish('allow_always', true); + if (this.opts.policy === 'auto-approve-reads' && ask.isRead) return finish('allow_once', true); + + // 3. Ask the user. + const decision = await this.opts.ask(ask); + return finish(decision, false); + } +} diff --git a/apps/x/packages/core/src/code-mode/acp/permission-registry.ts b/apps/x/packages/core/src/code-mode/acp/permission-registry.ts new file mode 100644 index 00000000..862f2de4 --- /dev/null +++ b/apps/x/packages/core/src/code-mode/acp/permission-registry.ts @@ -0,0 +1,43 @@ +import type { PermissionDecision } from './types.js'; + +interface Pending { + runId: string; + resolve: (decision: PermissionDecision) => void; +} + +// Holds in-flight mid-run permission asks. The agent (via the broker) calls +// request() which BLOCKS the coding turn until the user answers; the renderer's +// answer arrives over IPC and calls resolve(). This is separate from the LLM +// tool-loop's pre-call permission gate, which can't model a mid-execution wait. +export class CodePermissionRegistry { + private readonly pending = new Map(); + private counter = 0; + + // Register a pending ask, hand the generated requestId to `emit` (so the caller + // can publish the UI event), and resolve once the user answers. + request(runId: string, emit: (requestId: string) => void): Promise { + const requestId = `cpr-${runId}-${++this.counter}`; + return new Promise((resolve) => { + this.pending.set(requestId, { runId, resolve }); + emit(requestId); + }); + } + + // Called from the IPC handler when the user answers a card. + resolve(requestId: string, decision: PermissionDecision): void { + const entry = this.pending.get(requestId); + if (!entry) return; + this.pending.delete(requestId); + entry.resolve(decision); + } + + // On run stop/cancel: reject anything still waiting so the turn can unwind. + cancelRun(runId: string): void { + for (const [id, entry] of [...this.pending]) { + if (entry.runId === runId) { + this.pending.delete(id); + entry.resolve('reject'); + } + } + } +} diff --git a/apps/x/packages/core/src/code-mode/acp/session-store.ts b/apps/x/packages/core/src/code-mode/acp/session-store.ts new file mode 100644 index 00000000..e5e45666 --- /dev/null +++ b/apps/x/packages/core/src/code-mode/acp/session-store.ts @@ -0,0 +1,48 @@ +import fs from 'fs/promises'; +import path from 'path'; +import { WorkDir } from '../../config/config.js'; +import type { CodingAgent } from './types.js'; + +// One ACP session is pinned per chat run. We persist its sessionId (plus the agent +// and cwd it belongs to) so reopening the chat after an app restart can resume the +// same agent context via session/load instead of starting over. +export interface StoredSession { + runId: string; + agent: CodingAgent; + cwd: string; + sessionId: string; +} + +// Per-run ACP session state lives in its own directory (not WorkDir/config): it's +// runtime state that accumulates one file per chat run, so it's kept separate from +// user/app config to be listed and cleaned up on its own. +const SESSIONS_DIR = path.join(WorkDir, 'code-mode', 'sessions'); + +function sessionFile(runId: string): string { + return path.join(SESSIONS_DIR, `${runId}.json`); +} + +export async function readStoredSession(runId: string): Promise { + try { + const raw = await fs.readFile(sessionFile(runId), 'utf8'); + const parsed = JSON.parse(raw) as StoredSession; + if (parsed && parsed.sessionId && parsed.agent && parsed.cwd) return parsed; + return null; + } catch { + return null; + } +} + +export async function writeStoredSession(session: StoredSession): Promise { + const file = sessionFile(session.runId); + await fs.mkdir(path.dirname(file), { recursive: true }); + await fs.writeFile(file, JSON.stringify(session, null, 2)); +} + +export async function clearStoredSession(runId: string): Promise { + try { + await fs.rm(sessionFile(runId), { force: true }); + } catch { + // best effort + } +} diff --git a/apps/x/packages/core/src/code-mode/acp/types.ts b/apps/x/packages/core/src/code-mode/acp/types.ts new file mode 100644 index 00000000..6fafd438 --- /dev/null +++ b/apps/x/packages/core/src/code-mode/acp/types.ts @@ -0,0 +1,11 @@ +// Rowboat-facing types for the ACP code-mode engine. The schemas live in +// @x/shared (so the IPC/renderer layers share them); we re-export the inferred +// types here so the engine modules import from one local barrel. +export type { + CodingAgent, + ApprovalPolicy, + PermissionDecision, + PermissionAsk, + CodeRunEvent, + RunPromptResult, +} from '@x/shared/dist/code-mode.js'; diff --git a/apps/x/packages/core/src/code-mode/status.ts b/apps/x/packages/core/src/code-mode/status.ts index 3858708b..a78b23f4 100644 --- a/apps/x/packages/core/src/code-mode/status.ts +++ b/apps/x/packages/core/src/code-mode/status.ts @@ -12,7 +12,7 @@ const execAsync = promisify(exec); // We scan these directly because Electron's spawned shell sometimes doesn't // inherit the user's full PATH (especially on macOS GUI launches, and even on // Windows when global npm prefix isn't propagated to system PATH). -function commonInstallPaths(binary: string): string[] { +export function commonInstallPaths(binary: string): string[] { const home = os.homedir(); if (process.platform === 'win32') { const appData = process.env.APPDATA || path.join(home, 'AppData', 'Roaming'); diff --git a/apps/x/packages/core/src/code-mode/types.ts b/apps/x/packages/core/src/code-mode/types.ts index 57a3158f..f52ae813 100644 --- a/apps/x/packages/core/src/code-mode/types.ts +++ b/apps/x/packages/core/src/code-mode/types.ts @@ -1,7 +1,11 @@ import z from "zod"; +import { ApprovalPolicy } from "@x/shared/dist/code-mode.js"; export const CodeModeConfig = z.object({ enabled: z.boolean(), + // How the ACP engine answers the coding agent's permission requests. + // Optional for back-compat; the tool defaults to "ask" when unset. + approvalPolicy: ApprovalPolicy.optional(), }); export type CodeModeConfig = z.infer; diff --git a/apps/x/packages/core/src/di/container.ts b/apps/x/packages/core/src/di/container.ts index f452105a..d7b17ce7 100644 --- a/apps/x/packages/core/src/di/container.ts +++ b/apps/x/packages/core/src/di/container.ts @@ -16,6 +16,8 @@ import { IAbortRegistry, InMemoryAbortRegistry } from "../runs/abort-registry.js import { FSAgentScheduleRepo, IAgentScheduleRepo } from "../agent-schedule/repo.js"; import { FSAgentScheduleStateRepo, IAgentScheduleStateRepo } from "../agent-schedule/state-repo.js"; import { FSSlackConfigRepo, ISlackConfigRepo } from "../slack/repo.js"; +import { CodeModeManager } from "../code-mode/acp/manager.js"; +import { CodePermissionRegistry } from "../code-mode/acp/permission-registry.js"; import type { IBrowserControlService } from "../application/browser-control/service.js"; import type { INotificationService } from "../application/notification/service.js"; @@ -43,6 +45,12 @@ container.register({ agentScheduleRepo: asClass(FSAgentScheduleRepo).singleton(), agentScheduleStateRepo: asClass(FSAgentScheduleStateRepo).singleton(), slackConfigRepo: asClass(FSSlackConfigRepo).singleton(), + + // ACP code-mode engine: the manager holds a live agent connection per chat only + // around an active turn (torn down after a short idle grace; resumed via + // session/load); the registry brokers mid-run approvals. + codeModeManager: asClass(CodeModeManager).singleton(), + codePermissionRegistry: asClass(CodePermissionRegistry).singleton(), }); export default container; diff --git a/apps/x/packages/core/src/knowledge/inline_task_agent.ts b/apps/x/packages/core/src/knowledge/inline_task_agent.ts index 1a5c2582..db81198d 100644 --- a/apps/x/packages/core/src/knowledge/inline_task_agent.ts +++ b/apps/x/packages/core/src/knowledge/inline_task_agent.ts @@ -1,7 +1,10 @@ import { BuiltinTools } from '../application/lib/builtin-tools.js'; export function getRaw(): string { + // code_agent_run needs an interactive UI to answer its permission asks; exclude it + // from this headless agent so it can't hang waiting on an approval no one can give. const toolEntries = Object.keys(BuiltinTools) + .filter(name => name !== 'code_agent_run') .map(name => ` ${name}:\n type: builtin\n name: ${name}`) .join('\n'); diff --git a/apps/x/packages/core/src/knowledge/live-note/agent.ts b/apps/x/packages/core/src/knowledge/live-note/agent.ts index 8bba90bc..7638384e 100644 --- a/apps/x/packages/core/src/knowledge/live-note/agent.ts +++ b/apps/x/packages/core/src/knowledge/live-note/agent.ts @@ -152,7 +152,9 @@ Avoid: "I updated the note.", "Done!", "Here is the update:". The summary is a d export function buildLiveNoteAgent(): z.infer { const tools: Record> = {}; for (const name of Object.keys(BuiltinTools)) { - if (name === 'executeCommand') continue; + // code_agent_run requires an interactive UI for permission approvals — skip it + // here (headless) so it can't hang on an approval no one can answer. + if (name === 'executeCommand' || name === 'code_agent_run') continue; tools[name] = { type: 'builtin', name }; } diff --git a/apps/x/packages/shared/src/code-mode.ts b/apps/x/packages/shared/src/code-mode.ts new file mode 100644 index 00000000..a3bd46a7 --- /dev/null +++ b/apps/x/packages/shared/src/code-mode.ts @@ -0,0 +1,70 @@ +import z from "zod"; + +// Shared zod schemas for the ACP code-mode engine. Single source of truth: the +// core engine re-exports the inferred TS types, and runs.ts builds the RunEvent +// variants that carry these to the renderer. + +export const CodingAgent = z.enum(["claude", "codex"]); +export type CodingAgent = z.infer; + +// How the permission broker answers the agent's requests before any per-tool +// "always allow" memory is applied. `yolo` is the safe, scoped equivalent of +// `claude --dangerously-skip-permissions` (our toggle, not a CLI flag). +export const ApprovalPolicy = z.enum(["ask", "auto-approve-reads", "yolo"]); +export type ApprovalPolicy = z.infer; + +export const PermissionDecision = z.enum(["allow_once", "allow_always", "reject"]); +export type PermissionDecision = z.infer; + +// What the UI needs to render a permission card. +export const PermissionAsk = z.object({ + toolCallId: z.string().optional(), + title: z.string(), + kind: z.string().optional(), // tool kind, e.g. "edit" | "execute" | "read" + isRead: z.boolean(), +}); +export type PermissionAsk = z.infer; + +// Normalized per-run stream items. The engine maps raw ACP session/update +// notifications onto this union; the renderer renders them. +export const CodeRunEvent = z.discriminatedUnion("type", [ + // role distinguishes the agent's own output from replayed user turns + // (loadSession streams the whole prior conversation back on resume). + z.object({ type: z.literal("message"), role: z.enum(["agent", "user"]), text: z.string() }), + z.object({ type: z.literal("thought") }), + z.object({ + type: z.literal("tool_call"), + id: z.string().optional(), + title: z.string().optional(), + kind: z.string().optional(), + status: z.string().optional(), + }), + z.object({ + type: z.literal("tool_call_update"), + id: z.string().optional(), + status: z.string().optional(), + diffs: z.array(z.string()), + }), + z.object({ + type: z.literal("plan"), + entries: z.array(z.object({ + content: z.string(), + status: z.string().optional(), + priority: z.string().optional(), + })), + }), + z.object({ + type: z.literal("permission"), + ask: PermissionAsk, + decision: z.union([PermissionDecision, z.literal("cancelled")]), + auto: z.boolean(), + }), + z.object({ type: z.literal("other"), sessionUpdate: z.string() }), +]); +export type CodeRunEvent = z.infer; + +export const RunPromptResult = z.object({ + stopReason: z.string(), + sessionId: z.string(), +}); +export type RunPromptResult = z.infer; diff --git a/apps/x/packages/shared/src/ipc.ts b/apps/x/packages/shared/src/ipc.ts index 092a4b29..f3acffa1 100644 --- a/apps/x/packages/shared/src/ipc.ts +++ b/apps/x/packages/shared/src/ipc.ts @@ -19,6 +19,7 @@ import { ZListToolkitsResponse } from './composio.js'; import { BrowserStateSchema } from './browser-control.js'; import { BillingInfoSchema } from './billing.js'; import { EmailBlockSchema, GmailThreadSchema } from './blocks.js'; +import { PermissionDecision, ApprovalPolicy } from './code-mode.js'; // ============================================================================ // Runtime Validation Schemas (Single Source of Truth) @@ -430,11 +431,23 @@ const ipcSchemas = { req: z.null(), res: z.object({ enabled: z.boolean(), + approvalPolicy: ApprovalPolicy.optional(), }), }, 'codeMode:setConfig': { req: z.object({ enabled: z.boolean(), + approvalPolicy: ApprovalPolicy.optional(), + }), + res: z.object({ + success: z.literal(true), + }), + }, + // Answer a mid-run permission request from a code_agent_run coding turn. + 'codeRun:resolvePermission': { + req: z.object({ + requestId: z.string(), + decision: PermissionDecision, }), res: z.object({ success: z.literal(true), diff --git a/apps/x/packages/shared/src/runs.ts b/apps/x/packages/shared/src/runs.ts index a4deea9a..a5043cde 100644 --- a/apps/x/packages/shared/src/runs.ts +++ b/apps/x/packages/shared/src/runs.ts @@ -1,5 +1,6 @@ import { LlmStepStreamEvent } from "./llm-step-events.js"; import { Message, ToolCallPart } from "./message.js"; +import { CodeRunEvent as CodeRunEventSchema, PermissionAsk } from "./code-mode.js"; import z from "zod"; const BaseRunEvent = z.object({ @@ -111,6 +112,23 @@ export const ToolPermissionResponseEvent = BaseRunEvent.extend({ scope: z.enum(["once", "session", "always"]).optional(), }); +// A structured item from a code_agent_run coding turn (tool call, diff, plan, +// message chunk, resolved permission). Fire-and-forget — rendered live. +export const CodeRunStreamEvent = BaseRunEvent.extend({ + type: z.literal("code-run-event"), + toolCallId: z.string(), + event: CodeRunEventSchema, +}); + +// The coding agent is asking for permission mid-turn and the run is BLOCKED until +// the user answers via `codeRun:resolvePermission` (keyed by requestId). +export const CodeRunPermissionRequestEvent = BaseRunEvent.extend({ + type: z.literal("code-run-permission-request"), + toolCallId: z.string(), + requestId: z.string(), + ask: PermissionAsk, +}); + export const ToolPermissionAutoDecisionEvent = BaseRunEvent.extend({ type: z.literal("tool-permission-auto-decision"), toolCallId: z.string(), @@ -144,6 +162,8 @@ export const RunEvent = z.union([ AskHumanResponseEvent, ToolPermissionRequestEvent, ToolPermissionResponseEvent, + CodeRunStreamEvent, + CodeRunPermissionRequestEvent, ToolPermissionAutoDecisionEvent, RunErrorEvent, RunStoppedEvent, diff --git a/apps/x/patches/@openai__codex@0.128.0.patch b/apps/x/patches/@openai__codex@0.128.0.patch new file mode 100644 index 00000000..73b2e0c3 --- /dev/null +++ b/apps/x/patches/@openai__codex@0.128.0.patch @@ -0,0 +1,15 @@ +diff --git a/bin/codex.js b/bin/codex.js +index 67ab3e2d95dfac1c91882578b5403916c3121484..f8030b6e1459e05161af99e152b2e7f65ea6c41d 100644 +--- a/bin/codex.js ++++ b/bin/codex.js +@@ -175,6 +175,10 @@ env[packageManagerEnvVar] = "1"; + const child = spawn(binaryPath, process.argv.slice(2), { + stdio: "inherit", + env, ++ // Native console-subsystem binary: without this Windows pops a visible console ++ // window when launched from a console-less (Electron GUI) parent. Closing that ++ // window wedges the agent. CREATE_NO_WINDOW keeps the console hidden. ++ windowsHide: true, + }); + + child.on("error", (err) => { diff --git a/apps/x/pnpm-lock.yaml b/apps/x/pnpm-lock.yaml index 6c78cdce..c4e5a8d5 100644 --- a/apps/x/pnpm-lock.yaml +++ b/apps/x/pnpm-lock.yaml @@ -10,6 +10,11 @@ catalogs: specifier: 4.1.7 version: 4.1.7 +patchedDependencies: + '@openai/codex@0.128.0': + hash: 9edd926108a95aaa788aa93870fd6b16d70eeccdf5740b503af5d34cc9f25e86 + path: patches/@openai__codex@0.128.0.patch + importers: .: @@ -47,6 +52,12 @@ importers: apps/main: dependencies: + '@agentclientprotocol/claude-agent-acp': + specifier: ^0.39.0 + version: 0.39.0(@anthropic-ai/sdk@0.100.1(zod@4.2.1))(@modelcontextprotocol/sdk@1.25.1(hono@4.11.3)(zod@4.2.1)) + '@agentclientprotocol/codex-acp': + specifier: ^0.0.44 + version: 0.0.44(zod@4.2.1) '@x/core': specifier: workspace:* version: link:../../packages/core @@ -362,6 +373,15 @@ importers: packages/core: dependencies: + '@agentclientprotocol/claude-agent-acp': + specifier: ^0.39.0 + version: 0.39.0(@anthropic-ai/sdk@0.100.1(zod@4.2.1))(@modelcontextprotocol/sdk@1.25.1(hono@4.11.3)(zod@4.2.1)) + '@agentclientprotocol/codex-acp': + specifier: ^0.0.44 + version: 0.0.44(zod@4.2.1) + '@agentclientprotocol/sdk': + specifier: ^0.22.1 + version: 0.22.1(zod@4.2.1) '@ai-sdk/anthropic': specifier: ^2.0.63 version: 2.0.70(zod@4.2.1) @@ -489,6 +509,24 @@ importers: packages: + '@agentclientprotocol/claude-agent-acp@0.39.0': + resolution: {integrity: sha512-+tCm5v32L0R3zE4qjZQowfO1L/zqvQ5FapmsMSIf4gawXfTf26CG5hgz99wARdo0zn20/1eP80gzx7PbZlSX9A==} + hasBin: true + + '@agentclientprotocol/codex-acp@0.0.44': + resolution: {integrity: sha512-iHzFWKzJ0Z8I6yJCkuLZ+nb9mF2WYmfTcHFFvc7sU/awBsQmVBmpSOXOpZ+IK2Dy9cR3iRoML/B2/Wq2/zKBCA==} + hasBin: true + + '@agentclientprotocol/sdk@0.21.1': + resolution: {integrity: sha512-ZTLH+o9QxcZDLX/9ww+W7C2iExnXFM+vD/uGFVSlR61Kzj9FaxUqBC6Rv/kwgA7qVWYUEI9c5ZNqCuO9PM4rKg==} + peerDependencies: + zod: ^3.25.0 || ^4.0.0 + + '@agentclientprotocol/sdk@0.22.1': + resolution: {integrity: sha512-DfqXtl/8gO9NImq094MTaCXEU2vkhh6v7q/kT+9UjZxUqj8hYaya2OjLVIqn16MzNHcXEpShTR2RIauLSYeDQQ==} + peerDependencies: + zod: ^3.25.0 || ^4.0.0 + '@ai-sdk/anthropic@2.0.70': resolution: {integrity: sha512-W3WjQlb0Ho+CVAQUvb8Rtk3hGS3Jlgy79ihY2H0yj2k4yU8XuxpQw0Oz+7JQsB47j+jlHhk7nUXtxhAeRg3S3Q==} engines: {node: '>=18'} @@ -544,6 +582,67 @@ packages: '@antfu/install-pkg@1.1.0': resolution: {integrity: sha512-MGQsmw10ZyI+EJo45CdSER4zEb+p31LpDAFp2Z3gkSd1yqVZGi0Ebx++YTEMonJy4oChEMLsxZ64j8FH6sSqtQ==} + '@anthropic-ai/claude-agent-sdk-darwin-arm64@0.3.156': + resolution: {integrity: sha512-IkjcS9dqAUlD4Nb62L9AZtmAXCa+FV4ul8lIlyXXUprh3nlecbKsWOXVd/GORrzAhMmynJaX4+iV1JiutFKXUA==} + cpu: [arm64] + os: [darwin] + + '@anthropic-ai/claude-agent-sdk-darwin-x64@0.3.156': + resolution: {integrity: sha512-6PKi5fPmGRuzXu+Em/iwLmPG3mqg0hl92wcTU8fmChqyNtxhxsjCw7LTbdFqp/05o5NeZVVV4k3p7YUv5IFD6g==} + cpu: [x64] + os: [darwin] + + '@anthropic-ai/claude-agent-sdk-linux-arm64-musl@0.3.156': + resolution: {integrity: sha512-R7KEVjxkR4rYgIQoHGBzwPdUJYxRTO8I4vHjRbMLH1eW4FS7BJvVs7ogfKR/NnHFBvMVqtC+l6jHLQv8bobUiw==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@anthropic-ai/claude-agent-sdk-linux-arm64@0.3.156': + resolution: {integrity: sha512-H0Nfd41iw5isto9uQI1FlVSZ0eaDttr8rBpJMR25oK/mj3egMO5EmZ6aAxeeUYSLn2mSU50HA5VNxlGUE118TQ==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@anthropic-ai/claude-agent-sdk-linux-x64-musl@0.3.156': + resolution: {integrity: sha512-/Q6WUizI6a+hqZZ6ElwRU0PEuFhOoN4v6CuU35HHbiZ/7uaocGht4A8ZIgK1Fw6wOGtZzGLbc00CA1OU1Zg8EA==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@anthropic-ai/claude-agent-sdk-linux-x64@0.3.156': + resolution: {integrity: sha512-ymhrdlbWoYvTACUdaGdhrEv+ZMfwXLsf0BRLkr/IvY5aqybP7URzWmmZGOtDQpqkT/8xu/UCGqUYH3woJwUxfg==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@anthropic-ai/claude-agent-sdk-win32-arm64@0.3.156': + resolution: {integrity: sha512-5sAeNObQQrMy4NF9HwxewrMnU7mVxZDHh+/MfJVQSz0GSTvXQ6gOuRH8helMlfspoU6VOdekPxVLRooX/3foEw==} + cpu: [arm64] + os: [win32] + + '@anthropic-ai/claude-agent-sdk-win32-x64@0.3.156': + resolution: {integrity: sha512-/PofeTWoiKgnWNSNk0wG4SsRn22GGLmnLhg2R94WcNhCRFOyOTmiZcYH2DBlWZBIRVTZDsSfa/Pl1DyPvYCGKw==} + cpu: [x64] + os: [win32] + + '@anthropic-ai/claude-agent-sdk@0.3.156': + resolution: {integrity: sha512-6nM/Dj+VMds52UXJ2YaV4IKhYamlUqN0HtdDrFzYz5lvPMpDS935qD8YZDAUpy+ltdoD6PJMd1V/CKFY3/oWCQ==} + engines: {node: '>=18.0.0'} + peerDependencies: + '@anthropic-ai/sdk': '>=0.93.0' + '@modelcontextprotocol/sdk': ^1.29.0 + zod: ^4.0.0 + + '@anthropic-ai/sdk@0.100.1': + resolution: {integrity: sha512-RANcEe7LpiLczkKGOwoXOTuFdPhuubS0i4xaAKOMpcqc55YO0mukgxppV7eygx3DXNjxWT6RYOLPyOy0aIAmwg==} + hasBin: true + peerDependencies: + zod: ^3.25.0 || ^4.0.0 + peerDependenciesMeta: + zod: + optional: true + '@aws-crypto/crc32@5.2.0': resolution: {integrity: sha512-nLbCWqQNgUiwwtFsen1AdzAtvuLRsQS8rYgMuxCrdKf9kOssamGLuPwyTY9wyYblNr9+1XM8v6zoDTPPSIeANg==} engines: {node: '>=16.0.0'} @@ -1743,6 +1842,47 @@ packages: resolution: {integrity: sha512-6gH/bLQJSJEg7OEpkH4wGQdA8KXHRbzL1YkGyUO12YNAgV3jxKy4K9kvfXj4+9T0OLug5k58cnPCKSSIKzp7pg==} engines: {node: '>=8.0'} + '@openai/codex@0.128.0': + resolution: {integrity: sha512-+xp6ODmFfBNnexIWRHApEaPXot2j6gyM8A5we/5IS/uY4eYHj4arETct4hQ5M4eO+MK7JY3ZU4xhuobhlysr0A==} + engines: {node: '>=16'} + hasBin: true + + '@openai/codex@0.128.0-darwin-arm64': + resolution: {integrity: sha512-w+6zohfHx/kHBdles/CyFKaY57u9I3nK8QI9+NrdwMliKA0b7xn13yblRNkMpe09j6vL1oAWoxYsMOQ/vjBGug==} + engines: {node: '>=16'} + cpu: [arm64] + os: [darwin] + + '@openai/codex@0.128.0-darwin-x64': + resolution: {integrity: sha512-SDbn6fO22Puy8xmMIbZi4f2znMrUEPwABApke4mo+4ihaauwuVjeqzXvW5SPJz5ty/bG11/mSupQgReT7T8BBw==} + engines: {node: '>=16'} + cpu: [x64] + os: [darwin] + + '@openai/codex@0.128.0-linux-arm64': + resolution: {integrity: sha512-+SvH73H60qvCXFuQGP/EsmR//s1hHMBR22PvJkXvM/hdnTIGucx+JqRUjAWdmmQ1IU6j3kgwVvdLW/6ICB+M6w==} + engines: {node: '>=16'} + cpu: [arm64] + os: [linux] + + '@openai/codex@0.128.0-linux-x64': + resolution: {integrity: sha512-2lnSPA05CRRuKAzFW8BCmmNCSieDcToLwfC2ALLbBYilGLgzhRibjlDglK9F1BkEzfohSSWJu4PBbRu/aG60lQ==} + engines: {node: '>=16'} + cpu: [x64] + os: [linux] + + '@openai/codex@0.128.0-win32-arm64': + resolution: {integrity: sha512-ECJvsqmYFdA9pn42xxK3Odp/G16AjmBW0BglX8L0PwPjqbstbmlew9bfHf7xvL+SNfNl4NmyotW0+RNo1phgaA==} + engines: {node: '>=16'} + cpu: [arm64] + os: [win32] + + '@openai/codex@0.128.0-win32-x64': + resolution: {integrity: sha512-k3jmUAFrzkUtvjGTXvSKjQqJLLlzjxp/VoHJDYedgmXUn6j70HxK38IwapzmnYfiBiTuzETvGwjXHzZgzKjhoQ==} + engines: {node: '>=16'} + cpu: [x64] + os: [win32] + '@openrouter/ai-sdk-provider@1.5.4': resolution: {integrity: sha512-xrSQPUIH8n9zuyYZR0XK7Ba0h2KsjJcMkxnwaYfmv13pKs3sDkjPzVPPhlhzqBGddHb5cFEwJ9VFuFeDcxCDSw==} engines: {node: '>=18'} @@ -3057,6 +3197,9 @@ packages: resolution: {integrity: sha512-4aUIteuyxtBUhVdiQqcDhKFitwfd9hqoSDYY2KRXiWtgoWJ9Bmise+KfEPDiVHWeJepvF8xJO9/9+WDIciMFFw==} engines: {node: '>=18.0.0'} + '@stablelib/base64@1.0.1': + resolution: {integrity: sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==} + '@standard-schema/spec@1.1.0': resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} @@ -4060,6 +4203,10 @@ packages: buffer@6.0.3: resolution: {integrity: sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==} + bundle-name@4.1.0: + resolution: {integrity: sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==} + engines: {node: '>=18'} + bytes@3.1.2: resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==} engines: {node: '>= 0.8'} @@ -4540,6 +4687,14 @@ packages: deep-is@0.1.4: resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} + default-browser-id@5.0.1: + resolution: {integrity: sha512-x1VCxdX4t+8wVfd1so/9w+vQ4vx7lKd2Qp5tDRutErwmR85OgmfX7RlLRMWafRMY7hbEiXIbudNrjOAPa/hL8Q==} + engines: {node: '>=18'} + + default-browser@5.5.0: + resolution: {integrity: sha512-H9LMLr5zwIbSxrmvikGuI/5KGhZ8E2zH3stkMgM5LpOWDutGM2JZaj460Udnf1a+946zc7YBgrqEWwbk7zHvGw==} + engines: {node: '>=18'} + defaults@1.0.4: resolution: {integrity: sha512-eFuaLoy/Rxalv2kr+lqMlUnrDWV+3j4pljOIJgLIhI058IQfWJ7vXhyEIHu+HtC738klGALYxOKDO0bQP3tg8A==} @@ -4551,6 +4706,10 @@ packages: resolution: {integrity: sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==} engines: {node: '>= 0.4'} + define-lazy-prop@3.0.0: + resolution: {integrity: sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg==} + engines: {node: '>=12'} + define-properties@1.2.1: resolution: {integrity: sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==} engines: {node: '>= 0.4'} @@ -4592,6 +4751,10 @@ packages: diff3@0.0.3: resolution: {integrity: sha512-iSq8ngPOt0K53A6eVr4d5Kn6GNrM2nQZtC740pzIriHtn4pOQ2lyzEXQMBeVcWERN0ye7fhBsk9PbLLQOnUx/g==} + diff@8.0.4: + resolution: {integrity: sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw==} + engines: {node: '>=0.3.1'} + dingbat-to-unicode@1.0.1: resolution: {integrity: sha512-98l0sW87ZT58pU4i61wa2OHwxbiYSbuxsCBozaVnYX2iCnr3bLM3fIes1/ej7h1YdOKuKt/MLs706TVnALA65w==} @@ -4942,6 +5105,9 @@ packages: fast-levenshtein@2.0.6: resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} + fast-sha256@1.3.0: + resolution: {integrity: sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==} + fast-uri@3.1.0: resolution: {integrity: sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==} @@ -5541,6 +5707,11 @@ packages: engines: {node: '>=8'} hasBin: true + is-docker@3.0.0: + resolution: {integrity: sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + hasBin: true + is-extglob@2.1.1: resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} engines: {node: '>=0.10.0'} @@ -5560,6 +5731,15 @@ packages: is-hexadecimal@2.0.1: resolution: {integrity: sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg==} + is-in-ssh@1.0.0: + resolution: {integrity: sha512-jYa6Q9rH90kR1vKB6NM7qqd1mge3Fx4Dhw5TVlK1MUBqhEOuCagrEHMevNuCcbECmXZ0ThXkRm+Ymr51HwEPAw==} + engines: {node: '>=20'} + + is-inside-container@1.0.0: + resolution: {integrity: sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==} + engines: {node: '>=14.16'} + hasBin: true + is-interactive@1.0.0: resolution: {integrity: sha512-2HvIEKRoqS62guEC+qBjpvRubdX910WCMuJTZ+I9yvqKU2/12eSL549HMwtabb4oupdj2sMP50k+XJfB/8JE6w==} engines: {node: '>=8'} @@ -5617,6 +5797,10 @@ packages: resolution: {integrity: sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==} engines: {node: '>=8'} + is-wsl@3.1.1: + resolution: {integrity: sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw==} + engines: {node: '>=16'} + isarray@1.0.0: resolution: {integrity: sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==} @@ -5677,6 +5861,10 @@ packages: json-parse-even-better-errors@2.3.1: resolution: {integrity: sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==} + json-schema-to-ts@3.1.1: + resolution: {integrity: sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g==} + engines: {node: '>=16'} + json-schema-traverse@0.4.1: resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} @@ -6431,6 +6619,10 @@ packages: oniguruma-to-es@4.3.4: resolution: {integrity: sha512-3VhUGN3w2eYxnTzHn+ikMI+fp/96KoRSVK9/kMTcFqj1NRDh2IhQCKvYxDnWePKRXY/AqH+Fuiyb7VHSzBjHfA==} + open@11.0.0: + resolution: {integrity: sha512-smsWv2LzFjP03xmvFoJ331ss6h+jixfA4UUV/Bsiyuu4YJPfN+FIQGOIiv4w9/+MoHkfkJ22UIaQWRVFRfH6Vw==} + engines: {node: '>=20'} + open@7.4.2: resolution: {integrity: sha512-MVHddDVweXZF3awtlAS+6pgKLlm/JgxZ90+/NBurBoQctVOOB/zDdVjcyPzQ+0laDGbsWgrRkflI65sQeOgT9Q==} engines: {node: '>=8'} @@ -6679,6 +6871,10 @@ packages: engines: {node: '>=14.0.0'} hasBin: true + powershell-utils@0.1.0: + resolution: {integrity: sha512-dM0jVuXJPsDN6DvRpea484tCUaMiXWjuCn++HGTqUWzGDjv5tZkEZldAJ/UMlqRYGFrD/etByo4/xOuC/snX2A==} + engines: {node: '>=20'} + preact@10.28.2: resolution: {integrity: sha512-lbteaWGzGHdlIuiJ0l2Jq454m6kcpI1zNje6d8MlGAFlYvP2GO4ibnat7P74Esfz4sPTdM6UxtTwh/d3pwM9JA==} @@ -7097,6 +7293,10 @@ packages: resolution: {integrity: sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==} engines: {node: '>= 18'} + run-applescript@7.1.0: + resolution: {integrity: sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q==} + engines: {node: '>=18'} + run-parallel@1.2.0: resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} @@ -7305,6 +7505,9 @@ packages: stackback@0.0.2: resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + standardwebhooks@1.0.0: + resolution: {integrity: sha512-BbHGOQK9olHPMvQNHWul6MYlrRTAOKn03rOe4A8O3CLWhNf4YHBqq2HJKKC+sfqpxiBY52pNeesD6jIiLDz8jg==} + statuses@2.0.2: resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} engines: {node: '>= 0.8'} @@ -7523,6 +7726,9 @@ packages: trough@2.2.0: resolution: {integrity: sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw==} + ts-algebra@2.0.0: + resolution: {integrity: sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==} + ts-api-utils@2.1.0: resolution: {integrity: sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ==} engines: {node: '>=18.12'} @@ -7827,6 +8033,10 @@ packages: resolution: {integrity: sha512-C+r0eKJUIfiDIfwJhria30+TYWPtuHJXHtI7J0YlOmKAo7ogxP20T0zxB7HZQIFhIyvoBPwWskjxrvAtfjyZfA==} engines: {node: '>=14.0.0'} + vscode-jsonrpc@8.2.1: + resolution: {integrity: sha512-kdjOSJ2lLIn7r1rtrMbbNCHjyMPfRnowdKjBQ+mGq6NAW5QY2bEZC/khaC5OR8svbbjvLEaIXkOq45e2X9BIbQ==} + engines: {node: '>=14.0.0'} + vscode-languageserver-protocol@3.17.5: resolution: {integrity: sha512-mb1bvRJN8SVznADSGWM9u/b07H7Ecg0I3OgXDuLdn307rl/J3A9YD6/eYOssqhecL27hK1IPZAsaqh00i/Jljg==} @@ -7933,6 +8143,10 @@ packages: wrappy@1.0.2: resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + wsl-utils@0.3.1: + resolution: {integrity: sha512-g/eziiSUNBSsdDJtCLB8bdYEUMj4jR7AGeUo96p/3dTafgjHhpF4RiCFPiRILwjQoDXx5MqkBr4fwWtR3Ky4Wg==} + engines: {node: '>=20'} + x-is-array@0.1.0: resolution: {integrity: sha512-goHPif61oNrr0jJgsXRfc8oqtYzvfiMJpTqwE7Z4y9uH+T3UozkGqQ4d2nX9mB9khvA8U2o/UbPOFjgC7hLWIA==} @@ -8028,6 +8242,33 @@ packages: snapshots: + '@agentclientprotocol/claude-agent-acp@0.39.0(@anthropic-ai/sdk@0.100.1(zod@4.2.1))(@modelcontextprotocol/sdk@1.25.1(hono@4.11.3)(zod@4.2.1))': + dependencies: + '@agentclientprotocol/sdk': 0.22.1(zod@4.2.1) + '@anthropic-ai/claude-agent-sdk': 0.3.156(@anthropic-ai/sdk@0.100.1(zod@4.2.1))(@modelcontextprotocol/sdk@1.25.1(hono@4.11.3)(zod@4.2.1))(zod@4.2.1) + zod: 4.2.1 + transitivePeerDependencies: + - '@anthropic-ai/sdk' + - '@modelcontextprotocol/sdk' + + '@agentclientprotocol/codex-acp@0.0.44(zod@4.2.1)': + dependencies: + '@agentclientprotocol/sdk': 0.21.1(zod@4.2.1) + '@openai/codex': 0.128.0(patch_hash=9edd926108a95aaa788aa93870fd6b16d70eeccdf5740b503af5d34cc9f25e86) + diff: 8.0.4 + open: 11.0.0 + vscode-jsonrpc: 8.2.1 + transitivePeerDependencies: + - zod + + '@agentclientprotocol/sdk@0.21.1(zod@4.2.1)': + dependencies: + zod: 4.2.1 + + '@agentclientprotocol/sdk@0.22.1(zod@4.2.1)': + dependencies: + zod: 4.2.1 + '@ai-sdk/anthropic@2.0.70(zod@4.2.1)': dependencies: '@ai-sdk/provider': 2.0.1 @@ -8089,6 +8330,52 @@ snapshots: package-manager-detector: 1.6.0 tinyexec: 1.0.2 + '@anthropic-ai/claude-agent-sdk-darwin-arm64@0.3.156': + optional: true + + '@anthropic-ai/claude-agent-sdk-darwin-x64@0.3.156': + optional: true + + '@anthropic-ai/claude-agent-sdk-linux-arm64-musl@0.3.156': + optional: true + + '@anthropic-ai/claude-agent-sdk-linux-arm64@0.3.156': + optional: true + + '@anthropic-ai/claude-agent-sdk-linux-x64-musl@0.3.156': + optional: true + + '@anthropic-ai/claude-agent-sdk-linux-x64@0.3.156': + optional: true + + '@anthropic-ai/claude-agent-sdk-win32-arm64@0.3.156': + optional: true + + '@anthropic-ai/claude-agent-sdk-win32-x64@0.3.156': + optional: true + + '@anthropic-ai/claude-agent-sdk@0.3.156(@anthropic-ai/sdk@0.100.1(zod@4.2.1))(@modelcontextprotocol/sdk@1.25.1(hono@4.11.3)(zod@4.2.1))(zod@4.2.1)': + dependencies: + '@anthropic-ai/sdk': 0.100.1(zod@4.2.1) + '@modelcontextprotocol/sdk': 1.25.1(hono@4.11.3)(zod@4.2.1) + zod: 4.2.1 + optionalDependencies: + '@anthropic-ai/claude-agent-sdk-darwin-arm64': 0.3.156 + '@anthropic-ai/claude-agent-sdk-darwin-x64': 0.3.156 + '@anthropic-ai/claude-agent-sdk-linux-arm64': 0.3.156 + '@anthropic-ai/claude-agent-sdk-linux-arm64-musl': 0.3.156 + '@anthropic-ai/claude-agent-sdk-linux-x64': 0.3.156 + '@anthropic-ai/claude-agent-sdk-linux-x64-musl': 0.3.156 + '@anthropic-ai/claude-agent-sdk-win32-arm64': 0.3.156 + '@anthropic-ai/claude-agent-sdk-win32-x64': 0.3.156 + + '@anthropic-ai/sdk@0.100.1(zod@4.2.1)': + dependencies: + json-schema-to-ts: 3.1.1 + standardwebhooks: 1.0.0 + optionalDependencies: + zod: 4.2.1 + '@aws-crypto/crc32@5.2.0': dependencies: '@aws-crypto/util': 5.2.0 @@ -9819,6 +10106,33 @@ snapshots: '@oozcitak/util@8.3.4': {} + '@openai/codex@0.128.0(patch_hash=9edd926108a95aaa788aa93870fd6b16d70eeccdf5740b503af5d34cc9f25e86)': + optionalDependencies: + '@openai/codex-darwin-arm64': '@openai/codex@0.128.0-darwin-arm64' + '@openai/codex-darwin-x64': '@openai/codex@0.128.0-darwin-x64' + '@openai/codex-linux-arm64': '@openai/codex@0.128.0-linux-arm64' + '@openai/codex-linux-x64': '@openai/codex@0.128.0-linux-x64' + '@openai/codex-win32-arm64': '@openai/codex@0.128.0-win32-arm64' + '@openai/codex-win32-x64': '@openai/codex@0.128.0-win32-x64' + + '@openai/codex@0.128.0-darwin-arm64': + optional: true + + '@openai/codex@0.128.0-darwin-x64': + optional: true + + '@openai/codex@0.128.0-linux-arm64': + optional: true + + '@openai/codex@0.128.0-linux-x64': + optional: true + + '@openai/codex@0.128.0-win32-arm64': + optional: true + + '@openai/codex@0.128.0-win32-x64': + optional: true + '@openrouter/ai-sdk-provider@1.5.4(ai@5.0.151(zod@4.2.1))(zod@4.2.1)': dependencies: '@openrouter/sdk': 0.1.27 @@ -11301,6 +11615,8 @@ snapshots: dependencies: tslib: 2.8.1 + '@stablelib/base64@1.0.1': {} + '@standard-schema/spec@1.1.0': {} '@standard-schema/utils@0.3.0': {} @@ -12431,6 +12747,10 @@ snapshots: base64-js: 1.5.1 ieee754: 1.2.1 + bundle-name@4.1.0: + dependencies: + run-applescript: 7.1.0 + bytes@3.1.2: {} cacache@16.1.3: @@ -12925,6 +13245,13 @@ snapshots: deep-is@0.1.4: {} + default-browser-id@5.0.1: {} + + default-browser@5.5.0: + dependencies: + bundle-name: 4.1.0 + default-browser-id: 5.0.1 + defaults@1.0.4: dependencies: clone: 1.0.4 @@ -12937,6 +13264,8 @@ snapshots: es-errors: 1.3.0 gopd: 1.2.0 + define-lazy-prop@3.0.0: {} + define-properties@1.2.1: dependencies: define-data-property: 1.1.4 @@ -12971,6 +13300,8 @@ snapshots: diff3@0.0.3: {} + diff@8.0.4: {} + dingbat-to-unicode@1.0.1: {} dir-compare@4.2.0: @@ -13473,6 +13804,8 @@ snapshots: fast-levenshtein@2.0.6: {} + fast-sha256@1.3.0: {} + fast-uri@3.1.0: {} fast-xml-parser@5.2.5: @@ -14248,6 +14581,8 @@ snapshots: is-docker@2.2.1: {} + is-docker@3.0.0: {} + is-extglob@2.1.1: {} is-fullwidth-code-point@3.0.0: {} @@ -14260,6 +14595,12 @@ snapshots: is-hexadecimal@2.0.1: {} + is-in-ssh@1.0.0: {} + + is-inside-container@1.0.0: + dependencies: + is-docker: 3.0.0 + is-interactive@1.0.0: {} is-lambda@1.0.1: {} @@ -14310,6 +14651,10 @@ snapshots: dependencies: is-docker: 2.2.1 + is-wsl@3.1.1: + dependencies: + is-inside-container: 1.0.0 + isarray@1.0.0: {} isarray@2.0.5: {} @@ -14378,6 +14723,11 @@ snapshots: json-parse-even-better-errors@2.3.1: {} + json-schema-to-ts@3.1.1: + dependencies: + '@babel/runtime': 7.28.6 + ts-algebra: 2.0.0 + json-schema-traverse@0.4.1: {} json-schema-traverse@1.0.0: {} @@ -15367,6 +15717,15 @@ snapshots: regex: 6.1.0 regex-recursion: 6.0.2 + open@11.0.0: + dependencies: + default-browser: 5.5.0 + define-lazy-prop: 3.0.0 + is-in-ssh: 1.0.0 + is-inside-container: 1.0.0 + powershell-utils: 0.1.0 + wsl-utils: 0.3.1 + open@7.4.2: dependencies: is-docker: 2.2.1 @@ -15606,6 +15965,8 @@ snapshots: dependencies: commander: 9.5.0 + powershell-utils@0.1.0: {} + preact@10.28.2: {} prelude-ls@1.2.1: {} @@ -16189,6 +16550,8 @@ snapshots: transitivePeerDependencies: - supports-color + run-applescript@7.1.0: {} + run-parallel@1.2.0: dependencies: queue-microtask: 1.2.3 @@ -16424,6 +16787,11 @@ snapshots: stackback@0.0.2: {} + standardwebhooks@1.0.0: + dependencies: + '@stablelib/base64': 1.0.1 + fast-sha256: 1.3.0 + statuses@2.0.2: {} std-env@4.1.0: {} @@ -16664,6 +17032,8 @@ snapshots: trough@2.2.0: {} + ts-algebra@2.0.0: {} + ts-api-utils@2.1.0(typescript@5.9.3): dependencies: typescript: 5.9.3 @@ -16965,6 +17335,8 @@ snapshots: vscode-jsonrpc@8.2.0: {} + vscode-jsonrpc@8.2.1: {} + vscode-languageserver-protocol@3.17.5: dependencies: vscode-jsonrpc: 8.2.0 @@ -17097,6 +17469,11 @@ snapshots: wrappy@1.0.2: {} + wsl-utils@0.3.1: + dependencies: + is-wsl: 3.1.1 + powershell-utils: 0.1.0 + x-is-array@0.1.0: {} x-is-string@0.1.0: {} diff --git a/apps/x/pnpm-workspace.yaml b/apps/x/pnpm-workspace.yaml index 19bafad8..f5cdd141 100644 --- a/apps/x/pnpm-workspace.yaml +++ b/apps/x/pnpm-workspace.yaml @@ -13,3 +13,5 @@ onlyBuiltDependencies: - fs-xattr - macos-alias - protobufjs +patchedDependencies: + '@openai/codex@0.128.0': patches/@openai__codex@0.128.0.patch