feat: run code mode on an in-app ACP client with live approvals (#593)

* 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).
This commit is contained in:
gagan 2026-06-05 14:45:08 +05:30 committed by GitHub
parent 7f3c16cc33
commit 372309eb18
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
36 changed files with 1809 additions and 294 deletions

View file

@ -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",

View file

@ -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>('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<ICodeModeConfigRepo>('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<ICodeModeConfigRepo>('codeModeConfigRepo');
await repo.setConfig({ enabled: args.enabled });
await repo.setConfig({ enabled: args.enabled, approvalPolicy: args.approvalPolicy });
invalidateCopilotInstructionsCache();
return { success: true };
},

View file

@ -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>('codeModeManager').disposeAll();
} catch {
// nothing live to dispose
}
shutdownLocalSites().catch((error) => {
console.error('[LocalSites] Failed to shut down cleanly:', error);
});

View file

@ -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 (
<CodingRunBlock
key={item.id}
item={item}
open={isToolOpenForTab(tabId, item.id)}
onOpenChange={(open) => setToolOpenForTab(tabId, item.id, open)}
onPermissionDecision={(decision) => {
if (item.pendingCodePermission) {
handleCodePermissionResponse(item.id, item.pendingCodePermission.requestId, decision)
}
}}
/>
)
}
const appActionData = getAppActionCardData(item)
if (appActionData) {
return <AppActionCard key={item.id} data={appActionData} status={item.status} />
@ -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}
/>

View file

@ -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<typeof ToolPermissionMetadata>;
@ -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 = ({
</h3>
<p className="text-sm text-muted-foreground mt-1">
{isResponded ? "Requested:" : "The agent wants to execute:"} <span className="font-mono font-medium">{toolCall.toolName}</span>
{agentDisplay && (
<Badge
variant="secondary"
className="ml-2 align-middle bg-secondary text-foreground"
>
<Terminal className="size-3 mr-1" />
{agentDisplay}
</Badge>
)}
</p>
</div>
{isResponded && (
@ -220,18 +197,6 @@ export const PermissionRequest = ({
<XIcon className="size-4" />
Deny
</Button>
{otherAgent && otherDisplay && onSwitchAgent && (
<Button
variant="secondary"
size="sm"
onClick={() => onSwitchAgent(otherAgent)}
disabled={isProcessing}
className="flex-1"
>
<RefreshCwIcon className="size-4" />
Use {otherDisplay} instead
</Button>
)}
</div>
)}
</div>

View file

@ -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 => {

View file

@ -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<string, number>()
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 <Eye className="size-3.5 shrink-0 text-muted-foreground" />
case 'edit': return <Pencil className="size-3.5 shrink-0 text-muted-foreground" />
case 'delete': return <Trash2 className="size-3.5 shrink-0 text-muted-foreground" />
case 'search': return <Search className="size-3.5 shrink-0 text-muted-foreground" />
case 'execute': return <Terminal className="size-3.5 shrink-0 text-muted-foreground" />
case 'fetch': return <FileText className="size-3.5 shrink-0 text-muted-foreground" />
default: return <Wrench className="size-3.5 shrink-0 text-muted-foreground" />
}
}
function planMarker(status?: string) {
if (status === 'completed') return <CheckCircle2 className="size-3.5 shrink-0 text-green-600" />
if (status === 'in_progress') return <CircleDot className="size-3.5 shrink-0 text-blue-500" />
return <Circle className="size-3.5 shrink-0 text-muted-foreground" />
}
const basename = (p: string) => p.split(/[\\/]/).pop() || p
function CodingRunTimeline({ events }: { events: CodeRunEvent[] }) {
const rows = useMemo(() => reduceEvents(events), [events])
if (rows.length === 0) {
return <div className="px-4 py-3 text-xs text-muted-foreground">Starting the agent</div>
}
return (
<div className="flex flex-col gap-2 px-4 py-3">
{rows.map((row) => {
if (row.kind === 'text') {
return (
<p key={row.id} className="whitespace-pre-wrap text-sm leading-relaxed text-foreground/90">
{row.text}
</p>
)
}
if (row.kind === 'tool') {
const running = row.status !== 'completed' && row.status !== 'failed'
return (
<div key={row.id} className="flex flex-col gap-1">
<div className="flex items-center gap-2 text-sm">
{running
? <Loader className="size-3.5 shrink-0 animate-spin text-muted-foreground" />
: <CheckCircle2 className="size-3.5 shrink-0 text-green-600" />}
{toolKindIcon(row.toolKind)}
<span className="truncate text-foreground/90">{row.title ?? row.toolKind ?? 'Tool call'}</span>
</div>
{row.diffs.length > 0 && (
<div className="ml-7 flex flex-col gap-0.5">
{row.diffs.map((d) => (
<span key={d} className="truncate font-mono text-xs text-muted-foreground" title={d}>
{basename(d)}
</span>
))}
</div>
)}
</div>
)
}
if (row.kind === 'plan') {
return (
<div key={row.id} className="flex flex-col gap-1 rounded-lg border bg-muted/30 p-2">
{row.entries.map((entry, idx) => (
<div key={idx} className="flex items-center gap-2 text-sm text-foreground/90">
{planMarker(entry.status)}
<span className={cn('truncate', entry.status === 'completed' && 'text-muted-foreground line-through')}>
{entry.content}
</span>
</div>
))}
</div>
)
}
// resolved permission
const denied = row.decision === 'reject' || row.decision === 'cancelled'
return (
<div key={row.id} className={cn('flex items-center gap-2 text-xs', denied ? 'text-red-600' : 'text-green-600')}>
{denied ? '✕' : '✓'}
<span className="truncate">{denied ? 'Denied' : 'Allowed'}: {row.title}</span>
</div>
)
})}
</div>
)
}
// ── 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 (
<div className="mb-4 rounded-[20px] border border-amber-500/40 bg-amber-500/5 p-4">
<div className="flex items-center gap-2 text-sm font-medium text-foreground">
<ShieldQuestion className="size-4 shrink-0 text-amber-600" />
Permission needed
</div>
<p className="mt-1 text-sm text-muted-foreground">
The agent wants to: <span className="font-medium text-foreground">{ask.title}</span>
</p>
<div className="mt-3 flex flex-wrap gap-2">
<button type="button" disabled={busy} onClick={() => decide('allow_once')}
className={cn(btn, 'bg-foreground text-background hover:bg-foreground/90')}>
Allow
</button>
<button type="button" disabled={busy} onClick={() => decide('allow_always')}
className={cn(btn, 'border hover:bg-muted')}>
Always allow{ask.kind ? ` (${ask.kind})` : ''}
</button>
<button type="button" disabled={busy} onClick={() => decide('reject')}
className={cn(btn, 'border border-red-500/40 text-red-600 hover:bg-red-500/10')}>
Deny
</button>
</div>
</div>
)
}
// ── Block wrapper (rendered in the chat for a code_agent_run tool call) ──
const AGENT_LABEL: Record<string, string> = { 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 (
<>
<Tool open={open} onOpenChange={onOpenChange}>
<ToolHeader title={title} type="tool-code_agent_run" state={toToolState(item.status)} />
<ToolContent>
<CodingRunTimeline events={item.codeRunEvents ?? []} />
</ToolContent>
</Tool>
{item.pendingCodePermission && (
<CodeRunPermissionRequest ask={item.pendingCodePermission.ask} onDecide={onPermissionDecision} />
)}
</>
)
}

View file

@ -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<ApprovalPolicy>('ask')
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [status, setStatus] = useState<CodeModeAgentStatus | null>(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 }) {
<p>
<strong className="text-foreground">Code mode</strong> lets the assistant delegate coding tasks
to <strong className="text-foreground">Claude Code</strong> or <strong className="text-foreground">Codex</strong> running
on your machine. Pick the agent inline from the composer; the assistant calls it via
<code className="mx-1 rounded bg-muted px-1 py-0.5 text-[11px]">acpx</code>
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.
</p>
<p>
Requires an active <strong className="text-foreground">Claude Code</strong> subscription or
@ -1926,6 +1945,35 @@ function CodeModeSettings({ dialogOpen }: { dialogOpen: boolean }) {
/>
</div>
{enabled && (
<div className="rounded-md border px-3 py-3 space-y-2">
<div className="text-sm font-medium">Approvals</div>
<div className="text-xs text-muted-foreground">
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.
</div>
<Select
value={approvalPolicy}
onValueChange={(v) => handlePolicyChange(v as ApprovalPolicy)}
disabled={saving}
>
<SelectTrigger className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="ask">Ask every time</SelectItem>
<SelectItem value="auto-approve-reads">Auto-approve reads</SelectItem>
<SelectItem value="yolo">Auto-approve everything (YOLO)</SelectItem>
</SelectContent>
</Select>
<div className="text-xs text-muted-foreground">
{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.'}
</div>
</div>
)}
{enabled && status && !anyReady && (
<div className="rounded-md border border-amber-500/40 bg-amber-50/60 dark:bg-amber-950/20 px-3 py-2.5 flex items-start gap-2 text-xs">
<AlertTriangle className="size-4 text-amber-600 dark:text-amber-500 shrink-0 mt-0.5" />

View file

@ -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<string, string> = {
* 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<string, unknown> | 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