mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-07-06 20:42:15 +02:00
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.
This commit is contained in:
parent
caea83aecf
commit
99ef643c8e
11 changed files with 973 additions and 57 deletions
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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,7 @@ 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 { resolveClaudeExeOnWindows } from "../../code-mode/acp/claude-exec.js";
|
||||
|
||||
// Inputs for the bg-task builtin tools. Reuse the canonical schema field
|
||||
// descriptions; only `triggers` gets a tighter contextual override (the
|
||||
|
|
@ -90,61 +90,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;
|
||||
|
|
|
|||
53
apps/x/packages/core/src/code-mode/acp/agents.ts
Normal file
53
apps/x/packages/core/src/code-mode/acp/agents.ts
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
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;
|
||||
}
|
||||
|
||||
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. Also used by the legacy acpx path.
|
||||
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;
|
||||
}
|
||||
178
apps/x/packages/core/src/code-mode/acp/client.ts
Normal file
178
apps/x/packages/core/src/code-mode/acp/client.ts
Normal file
|
|
@ -0,0 +1,178 @@
|
|||
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;
|
||||
|
||||
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,
|
||||
stdio: ['pipe', 'pipe', 'inherit'],
|
||||
});
|
||||
this.child = child;
|
||||
|
||||
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);
|
||||
|
||||
const init = await this.connection.initialize({
|
||||
protocolVersion: PROTOCOL_VERSION,
|
||||
clientCapabilities: { fs: { readTextFile: true, writeTextFile: true } },
|
||||
});
|
||||
this.loadSession_ = init.agentCapabilities?.loadSession === true;
|
||||
}
|
||||
|
||||
async newSession(): Promise<string> {
|
||||
const res = await this.conn().newSession({ cwd: this.cwd, mcpServers: [] });
|
||||
return res.sessionId;
|
||||
}
|
||||
|
||||
async loadSession(sessionId: string): Promise<void> {
|
||||
await this.conn().loadSession({ sessionId, cwd: this.cwd, mcpServers: [] });
|
||||
}
|
||||
|
||||
async prompt(sessionId: string, text: string): Promise<PromptResponse> {
|
||||
return this.conn().prompt({ sessionId, prompt: [{ type: 'text', text }] });
|
||||
}
|
||||
|
||||
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 {};
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
103
apps/x/packages/core/src/code-mode/acp/manager.ts
Normal file
103
apps/x/packages/core/src/code-mode/acp/manager.ts
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
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;
|
||||
}
|
||||
|
||||
interface ActiveRun {
|
||||
client: AcpClient;
|
||||
sessionId: string;
|
||||
agent: CodingAgent;
|
||||
cwd: string;
|
||||
}
|
||||
|
||||
// Drives ACP coding sessions, one live connection per chat run. Reuses a warm
|
||||
// connection for follow-up prompts in the same chat; resumes a persisted session
|
||||
// (via session/load) on the first prompt after an app restart.
|
||||
export class CodeModeManager {
|
||||
private readonly runs = new Map<string, ActiveRun>();
|
||||
|
||||
async runPrompt(args: RunPromptArgs): Promise<RunPromptResult> {
|
||||
const { runId, agent, cwd, prompt, policy, ask, onEvent } = 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);
|
||||
const res = await run.client.prompt(run.sessionId, prompt);
|
||||
return { stopReason: res.stopReason, sessionId: run.sessionId };
|
||||
}
|
||||
|
||||
async cancel(runId: string): Promise<void> {
|
||||
const run = this.runs.get(runId);
|
||||
if (run) await run.client.cancel(run.sessionId);
|
||||
}
|
||||
|
||||
dispose(runId: string): void {
|
||||
const run = this.runs.get(runId);
|
||||
if (!run) return;
|
||||
run.client.dispose();
|
||||
this.runs.delete(runId);
|
||||
}
|
||||
|
||||
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) {
|
||||
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 };
|
||||
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);
|
||||
}
|
||||
}
|
||||
43
apps/x/packages/core/src/code-mode/acp/session-store.ts
Normal file
43
apps/x/packages/core/src/code-mode/acp/session-store.ts
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
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;
|
||||
}
|
||||
|
||||
function sessionFile(runId: string): string {
|
||||
return path.join(WorkDir, 'config', `codesession-${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
|
||||
}
|
||||
}
|
||||
43
apps/x/packages/core/src/code-mode/acp/types.ts
Normal file
43
apps/x/packages/core/src/code-mode/acp/types.ts
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
// Rowboat-facing types for the ACP code-mode engine. These are intentionally
|
||||
// decoupled from the raw @agentclientprotocol/sdk schema so the IPC layer (Phase 2)
|
||||
// and renderer (Phase 3) consume a small, stable surface instead of the full protocol.
|
||||
|
||||
export type CodingAgent = 'claude' | 'codex';
|
||||
|
||||
// How the permission broker answers an agent's requestPermission, before any
|
||||
// per-tool "allow for this session" memory is applied.
|
||||
// ask -> surface every gated action to the user
|
||||
// auto-approve-reads -> silently allow read-only tool calls, ask for the rest
|
||||
// yolo -> auto-approve everything (the safe, scoped equivalent of
|
||||
// `claude --dangerously-skip-permissions` — our toggle, not a flag)
|
||||
export type ApprovalPolicy = 'ask' | 'auto-approve-reads' | 'yolo';
|
||||
|
||||
// A user's decision for a single permission request.
|
||||
export type PermissionDecision = 'allow_once' | 'allow_always' | 'reject';
|
||||
|
||||
// What we hand to the UI (Phase 3) when the agent asks for permission.
|
||||
export interface PermissionAsk {
|
||||
toolCallId?: string;
|
||||
title: string;
|
||||
kind?: string; // tool kind, e.g. "edit" | "execute" | "read"
|
||||
/** Whether this looks like a read-only action (used by auto-approve-reads). */
|
||||
isRead: boolean;
|
||||
}
|
||||
|
||||
// Normalized stream events emitted for a coding run. The renderer renders these;
|
||||
// the engine maps raw ACP session/update notifications onto this union.
|
||||
export type CodeRunEvent =
|
||||
// role distinguishes the agent's own output from replayed user turns
|
||||
// (loadSession streams the whole prior conversation back on resume).
|
||||
| { type: 'message'; role: 'agent' | 'user'; text: string }
|
||||
| { type: 'thought' }
|
||||
| { type: 'tool_call'; id?: string; title?: string; kind?: string; status?: string }
|
||||
| { type: 'tool_call_update'; id?: string; status?: string; diffs: string[] }
|
||||
| { type: 'plan'; entries: { content: string; status?: string; priority?: string }[] }
|
||||
| { type: 'permission'; ask: PermissionAsk; decision: PermissionDecision | 'cancelled'; auto: boolean }
|
||||
| { type: 'other'; sessionUpdate: string };
|
||||
|
||||
export interface RunPromptResult {
|
||||
stopReason: string;
|
||||
sessionId: string;
|
||||
}
|
||||
|
|
@ -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');
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue