mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-06-06 19:35:44 +02:00
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:
parent
7f3c16cc33
commit
372309eb18
36 changed files with 1809 additions and 294 deletions
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 => {
|
||||
|
|
|
|||
253
apps/x/apps/renderer/src/components/coding-run.tsx
Normal file
253
apps/x/apps/renderer/src/components/coding-run.tsx
Normal 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} />
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
@ -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" />
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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 <name> 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 <workdir> <agent> 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 <workdir> <agent> -s ${sessionName} "<prompt>"
|
||||
\`\`\`
|
||||
|
||||
**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 <workdir> <agent> -s ${sessionName} "<prompt>"
|
||||
\`\`\`
|
||||
|
||||
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 \`<agent>\` 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.`;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 \`<agent>\` (\`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 \`<session-name>\` — **stable for this whole chat**:
|
||||
- If the "# Code Mode (Active)" block gives a session name (e.g. \`rowboat-<runId>\`), 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 <folder> <agent> sessions ensure --name <session-name>
|
||||
code_agent_run({
|
||||
agent: "<claude|codex>",
|
||||
cwd: "<resolved absolute folder>",
|
||||
prompt: "<clear, self-contained coding instruction>"
|
||||
})
|
||||
\`\`\`
|
||||
|
||||
(\`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 <folder> <agent> -s <session-name> "<prompt>"
|
||||
\`\`\`
|
||||
|
||||
**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 <folder> <agent> -s <session-name> "<prompt>"
|
||||
\`\`\`
|
||||
|
||||
**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 <name>\` and \`-s <session-name>\` come AFTER the agent name:
|
||||
|
||||
- ✓ Correct: \`npx acpx@latest --approve-all --timeout 600 --cwd <folder> <agent> -s <session-name> "<prompt>"\`
|
||||
- ✗ Wrong: \`npx acpx@latest <agent> --approve-all -s <name> "..."\` (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-name>\` session doesn't exist yet. Create it once with \`npx acpx@latest --approve-all --cwd <folder> <agent> sessions ensure --name <session-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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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<string, string> = {
|
|||
'.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<string>();
|
||||
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: <dir>\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<typeof BuiltinToolsSchema> = {
|
||||
loadSkill: {
|
||||
|
|
@ -814,14 +754,11 @@ export const BuiltinTools: z.infer<typeof BuiltinToolsSchema> = {
|
|||
// };
|
||||
// }
|
||||
|
||||
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<typeof BuiltinToolsSchema> = {
|
|||
}
|
||||
|
||||
// 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<typeof BuiltinToolsSchema> = {
|
|||
},
|
||||
},
|
||||
|
||||
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>('codeModeManager');
|
||||
const registry = container.resolve<CodePermissionRegistry>('codePermissionRegistry');
|
||||
|
||||
// Approval policy from settings; default to asking the user.
|
||||
let policy: ApprovalPolicy = 'ask';
|
||||
try {
|
||||
const cfg = await container.resolve<ICodeModeConfigRepo>('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<string>();
|
||||
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)
|
||||
// ============================================================================
|
||||
|
|
|
|||
|
|
@ -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<CommandResult> {
|
||||
try {
|
||||
|
|
|
|||
|
|
@ -14,6 +14,10 @@ export interface ToolContext {
|
|||
signal: AbortSignal;
|
||||
abortRegistry: IAbortRegistry;
|
||||
publish: (event: z.infer<typeof RunEvent>) => Promise<void>;
|
||||
// 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<typeof ToolAttachment> & { type: "mcp" }, input: Record<string, unknown>): Promise<unknown> {
|
||||
|
|
|
|||
|
|
@ -71,7 +71,9 @@ The workspace lives at \`${WorkDir}\`.
|
|||
export function buildBackgroundTaskAgent(): z.infer<typeof Agent> {
|
||||
const tools: Record<string, z.infer<typeof ToolAttachment>> = {};
|
||||
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 };
|
||||
}
|
||||
|
||||
|
|
|
|||
60
apps/x/packages/core/src/code-mode/acp/agents.ts
Normal file
60
apps/x/packages/core/src/code-mode/acp/agents.ts
Normal file
|
|
@ -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<CodingAgent, string> = {
|
||||
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 <entry>`. 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<string, string> };
|
||||
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 };
|
||||
}
|
||||
91
apps/x/packages/core/src/code-mode/acp/claude-exec.ts
Normal file
91
apps/x/packages/core/src/code-mode/acp/claude-exec.ts
Normal file
|
|
@ -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<string>();
|
||||
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: <dir>\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;
|
||||
}
|
||||
219
apps/x/packages/core/src/code-mode/acp/client.ts
Normal file
219
apps/x/packages/core/src/code-mode/acp/client.ts
Normal file
|
|
@ -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<typeof c, { type: 'diff' }> => 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<void> {
|
||||
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<Uint8Array>,
|
||||
Readable.toWeb(child.stdout!) as ReadableStream<Uint8Array>,
|
||||
);
|
||||
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<string> {
|
||||
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<void> {
|
||||
try {
|
||||
await this.conn().loadSession({ sessionId, cwd: this.cwd, mcpServers: [] });
|
||||
} catch (e) {
|
||||
throw this.enrich(e, 'loadSession');
|
||||
}
|
||||
}
|
||||
|
||||
async prompt(sessionId: string, text: string): Promise<PromptResponse> {
|
||||
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<void> {
|
||||
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<RequestPermissionResponse> {
|
||||
return self.broker.resolve(params);
|
||||
},
|
||||
async sessionUpdate(params: SessionNotification): Promise<void> {
|
||||
self.onEvent(toEvent(params.update));
|
||||
},
|
||||
async readTextFile(params: ReadTextFileRequest): Promise<ReadTextFileResponse> {
|
||||
const content = await fs.readFile(params.path, 'utf8');
|
||||
return { content };
|
||||
},
|
||||
async writeTextFile(params: WriteTextFileRequest): Promise<WriteTextFileResponse> {
|
||||
await fs.writeFile(params.path, params.content);
|
||||
return {};
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
186
apps/x/packages/core/src/code-mode/acp/manager.ts
Normal file
186
apps/x/packages/core/src/code-mode/acp/manager.ts
Normal file
|
|
@ -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<PermissionDecision>;
|
||||
/** 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<typeof setTimeout>;
|
||||
}
|
||||
|
||||
// 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<string, ActiveRun>();
|
||||
|
||||
async runPrompt(args: RunPromptArgs): Promise<RunPromptResult> {
|
||||
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<typeof setTimeout> | 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<ActiveRun> {
|
||||
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<string> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
91
apps/x/packages/core/src/code-mode/acp/permission-broker.ts
Normal file
91
apps/x/packages/core/src/code-mode/acp/permission-broker.ts
Normal file
|
|
@ -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<PermissionDecision, PermissionOptionKind[]> = {
|
||||
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<PermissionDecision>;
|
||||
// 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<string>();
|
||||
|
||||
constructor(opts: PermissionBrokerOptions) {
|
||||
this.opts = opts;
|
||||
}
|
||||
|
||||
async resolve(request: RequestPermissionRequest): Promise<RequestPermissionResponse> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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<string, Pending>();
|
||||
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<PermissionDecision> {
|
||||
const requestId = `cpr-${runId}-${++this.counter}`;
|
||||
return new Promise<PermissionDecision>((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');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
48
apps/x/packages/core/src/code-mode/acp/session-store.ts
Normal file
48
apps/x/packages/core/src/code-mode/acp/session-store.ts
Normal file
|
|
@ -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<StoredSession | null> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
try {
|
||||
await fs.rm(sessionFile(runId), { force: true });
|
||||
} catch {
|
||||
// best effort
|
||||
}
|
||||
}
|
||||
11
apps/x/packages/core/src/code-mode/acp/types.ts
Normal file
11
apps/x/packages/core/src/code-mode/acp/types.ts
Normal file
|
|
@ -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';
|
||||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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<typeof CodeModeConfig>;
|
||||
|
||||
|
|
|
|||
|
|
@ -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<IAgentScheduleRepo>(FSAgentScheduleRepo).singleton(),
|
||||
agentScheduleStateRepo: asClass<IAgentScheduleStateRepo>(FSAgentScheduleStateRepo).singleton(),
|
||||
slackConfigRepo: asClass<ISlackConfigRepo>(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;
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
||||
|
|
|
|||
|
|
@ -152,7 +152,9 @@ Avoid: "I updated the note.", "Done!", "Here is the update:". The summary is a d
|
|||
export function buildLiveNoteAgent(): z.infer<typeof Agent> {
|
||||
const tools: Record<string, z.infer<typeof ToolAttachment>> = {};
|
||||
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 };
|
||||
}
|
||||
|
||||
|
|
|
|||
70
apps/x/packages/shared/src/code-mode.ts
Normal file
70
apps/x/packages/shared/src/code-mode.ts
Normal file
|
|
@ -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<typeof CodingAgent>;
|
||||
|
||||
// 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<typeof ApprovalPolicy>;
|
||||
|
||||
export const PermissionDecision = z.enum(["allow_once", "allow_always", "reject"]);
|
||||
export type PermissionDecision = z.infer<typeof PermissionDecision>;
|
||||
|
||||
// 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<typeof PermissionAsk>;
|
||||
|
||||
// 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<typeof CodeRunEvent>;
|
||||
|
||||
export const RunPromptResult = z.object({
|
||||
stopReason: z.string(),
|
||||
sessionId: z.string(),
|
||||
});
|
||||
export type RunPromptResult = z.infer<typeof RunPromptResult>;
|
||||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
15
apps/x/patches/@openai__codex@0.128.0.patch
Normal file
15
apps/x/patches/@openai__codex@0.128.0.patch
Normal file
|
|
@ -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) => {
|
||||
377
apps/x/pnpm-lock.yaml
generated
377
apps/x/pnpm-lock.yaml
generated
|
|
@ -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: {}
|
||||
|
|
|
|||
|
|
@ -13,3 +13,5 @@ onlyBuiltDependencies:
|
|||
- fs-xattr
|
||||
- macos-alias
|
||||
- protobufjs
|
||||
patchedDependencies:
|
||||
'@openai/codex@0.128.0': patches/@openai__codex@0.128.0.patch
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue