v2.2-paseo-providers: Paseo provider stack + v2.2.1 pane-scoped chat fixes
Ship Paseo-equivalent provider snapshot, AgentComposerBar, ACP dispatch rewrite with streaming/persist, permission prompts, and agent commands. Follow-up: pane-scoped chat resolution, CoderMessageList tool timeline, WS user-delta replace, and inference orphan tool_call stripping. Archive openspec v2-2; update CHANGELOG and CURRENT. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -1,22 +1,12 @@
|
||||
/**
|
||||
* ACP dispatch — runs ACP-capable agents (opencode, goose) directly on the host.
|
||||
* ACP dispatch — runs ACP-capable agents directly on the host.
|
||||
*
|
||||
* v2.1.1: BooCoder runs on the host now — agents are spawned directly,
|
||||
* no SSH needed. Uses @agentclientprotocol/sdk for structured JSON-RPC.
|
||||
*
|
||||
* Flow:
|
||||
* 1. Spawn `opencode acp` (or `goose acp`) in the worktree
|
||||
* 2. Wrap child's stdin/stdout into NDJSON streams
|
||||
* 3. Create a ClientSideConnection from the SDK
|
||||
* 4. Initialize → newSession → prompt(task)
|
||||
* 5. Collect session updates (tool calls, text output)
|
||||
* 6. On prompt completion → return collected output
|
||||
* v2.3: Paseo-aligned tool lifecycle — stable toolCallId, merge on
|
||||
* tool_call_update, reasoning stream, worktree FS client, persist-ready snapshots.
|
||||
*/
|
||||
import type { FastifyBaseLogger } from 'fastify';
|
||||
import { Readable, Writable } from 'node:stream';
|
||||
import {
|
||||
ClientSideConnection,
|
||||
ndJsonStream,
|
||||
type Client,
|
||||
type SessionNotification,
|
||||
type RequestPermissionRequest,
|
||||
@@ -27,13 +17,30 @@ import {
|
||||
type WriteTextFileResponse,
|
||||
type CreateTerminalRequest,
|
||||
type CreateTerminalResponse,
|
||||
type SessionConfigOption,
|
||||
type ClientSideConnection as ConnectionType,
|
||||
} from '@agentclientprotocol/sdk';
|
||||
import type { Broker } from '@boocode/server/broker';
|
||||
import type { WsFrame } from '@boocode/server/ws-frames';
|
||||
import { spawn } from 'node:child_process';
|
||||
import { findThoughtLevelConfigId } from './acp-derive.js';
|
||||
import { resolveAcpSpawnArgs } from './acp-spawn.js';
|
||||
import { createAcpNdJsonStream } from './acp-stream.js';
|
||||
import { waitForPermissionResponse, cancelPendingPermission } from './permission-waiter.js';
|
||||
import { mergeTaskCommands, getTaskCommands } from './agent-commands-cache.js';
|
||||
import { readWorktreeTextFile, writeWorktreeTextFile } from './acp-client-fs.js';
|
||||
import {
|
||||
type AcpToolSnapshot,
|
||||
mergeToolSnapshot,
|
||||
snapshotToWireToolCall,
|
||||
synthesizeCanceledSnapshots,
|
||||
} from './acp-tool-snapshot.js';
|
||||
|
||||
export interface AcpDispatchResult {
|
||||
exitCode: number;
|
||||
output: string;
|
||||
toolCalls: Array<{ title: string; input: unknown; output?: unknown }>;
|
||||
toolSnapshots: AcpToolSnapshot[];
|
||||
reasoningText: string;
|
||||
stopReason: string;
|
||||
}
|
||||
|
||||
@@ -42,212 +49,316 @@ export interface AcpDispatchOpts {
|
||||
task: string;
|
||||
worktreePath: string;
|
||||
model?: string;
|
||||
modeId?: string;
|
||||
thinkingOptionId?: string;
|
||||
taskId?: string;
|
||||
sessionId?: string;
|
||||
chatId?: string;
|
||||
messageId?: string;
|
||||
broker?: Broker;
|
||||
installPath?: string;
|
||||
signal?: AbortSignal;
|
||||
log: FastifyBaseLogger;
|
||||
}
|
||||
|
||||
function acpArgs(agent: string): string[] | null {
|
||||
switch (agent) {
|
||||
case 'opencode':
|
||||
return ['acp'];
|
||||
case 'goose':
|
||||
return ['acp'];
|
||||
default:
|
||||
return null;
|
||||
async function applySessionOverrides(
|
||||
connection: ConnectionType,
|
||||
acpSessionId: string,
|
||||
configOptions: SessionConfigOption[] | null | undefined,
|
||||
opts: Pick<AcpDispatchOpts, 'model' | 'modeId' | 'thinkingOptionId' | 'log'>,
|
||||
): Promise<void> {
|
||||
const { model, modeId, thinkingOptionId, log } = opts;
|
||||
|
||||
if (modeId) {
|
||||
try {
|
||||
await connection.setSessionMode({ sessionId: acpSessionId, modeId });
|
||||
} catch (err) {
|
||||
log.warn({ modeId, err: err instanceof Error ? err.message : String(err) }, 'acp-dispatch: setSessionMode failed');
|
||||
}
|
||||
}
|
||||
|
||||
if (model) {
|
||||
try {
|
||||
await connection.unstable_setSessionModel({ sessionId: acpSessionId, modelId: model });
|
||||
} catch (err) {
|
||||
log.warn({ model, err: err instanceof Error ? err.message : String(err) }, 'acp-dispatch: setSessionModel failed');
|
||||
}
|
||||
}
|
||||
|
||||
if (thinkingOptionId) {
|
||||
const configId = findThoughtLevelConfigId(configOptions);
|
||||
if (configId) {
|
||||
try {
|
||||
await connection.setSessionConfigOption({
|
||||
sessionId: acpSessionId,
|
||||
configId,
|
||||
value: thinkingOptionId,
|
||||
});
|
||||
} catch (err) {
|
||||
log.warn(
|
||||
{ thinkingOptionId, err: err instanceof Error ? err.message : String(err) },
|
||||
'acp-dispatch: setSessionConfigOption failed',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a Node.js Readable stream to a web ReadableStream<Uint8Array>.
|
||||
*/
|
||||
function nodeReadableToWeb(nodeStream: NodeJS.ReadableStream): ReadableStream<Uint8Array> {
|
||||
return new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
nodeStream.on('data', (chunk: Buffer) => {
|
||||
controller.enqueue(new Uint8Array(chunk));
|
||||
});
|
||||
nodeStream.on('end', () => {
|
||||
controller.close();
|
||||
});
|
||||
nodeStream.on('error', (err) => {
|
||||
controller.error(err);
|
||||
});
|
||||
},
|
||||
cancel() {
|
||||
if ('destroy' in nodeStream && typeof (nodeStream as Readable).destroy === 'function') {
|
||||
(nodeStream as Readable).destroy();
|
||||
class AcpStreamContext {
|
||||
readonly textChunks: string[] = [];
|
||||
readonly reasoningChunks: string[] = [];
|
||||
readonly toolSnapshots = new Map<string, AcpToolSnapshot>();
|
||||
private aborted = false;
|
||||
|
||||
constructor(
|
||||
private readonly opts: Pick<
|
||||
AcpDispatchOpts,
|
||||
'broker' | 'sessionId' | 'chatId' | 'messageId' | 'taskId'
|
||||
>,
|
||||
private readonly worktreePath: string,
|
||||
) {}
|
||||
|
||||
get reasoningText(): string {
|
||||
return this.reasoningChunks.join('');
|
||||
}
|
||||
|
||||
get output(): string {
|
||||
return this.textChunks.join('');
|
||||
}
|
||||
|
||||
get snapshots(): AcpToolSnapshot[] {
|
||||
return [...this.toolSnapshots.values()];
|
||||
}
|
||||
|
||||
markAborted(): void {
|
||||
this.aborted = true;
|
||||
for (const snap of synthesizeCanceledSnapshots(this.toolSnapshots.values())) {
|
||||
this.toolSnapshots.set(snap.toolCallId, snap);
|
||||
this.publishToolSnapshot(snap);
|
||||
}
|
||||
}
|
||||
|
||||
private canStream(): boolean {
|
||||
return !!(this.opts.broker && this.opts.sessionId && this.opts.chatId && this.opts.messageId);
|
||||
}
|
||||
|
||||
private publishToolSnapshot(snapshot: AcpToolSnapshot): void {
|
||||
if (!this.canStream()) return;
|
||||
const wire = snapshotToWireToolCall(snapshot);
|
||||
this.opts.broker!.publishFrame(this.opts.sessionId!, {
|
||||
type: 'tool_call',
|
||||
message_id: this.opts.messageId!,
|
||||
chat_id: this.opts.chatId!,
|
||||
tool_call: wire,
|
||||
} as WsFrame);
|
||||
}
|
||||
|
||||
handleToolUpdate(toolCallId: string, update: Parameters<typeof mergeToolSnapshot>[1]): void {
|
||||
const previous = this.toolSnapshots.get(toolCallId);
|
||||
const snapshot = mergeToolSnapshot(toolCallId, update, previous);
|
||||
this.toolSnapshots.set(toolCallId, snapshot);
|
||||
this.publishToolSnapshot(snapshot);
|
||||
}
|
||||
|
||||
async handleSessionUpdate(params: SessionNotification): Promise<void> {
|
||||
const update = params.update;
|
||||
switch (update.sessionUpdate) {
|
||||
case 'agent_message_chunk': {
|
||||
const content = update.content;
|
||||
if (content.type === 'text' && 'text' in content) {
|
||||
const text = (content as { text: string }).text;
|
||||
this.textChunks.push(text);
|
||||
if (this.canStream()) {
|
||||
this.opts.broker!.publishFrame(this.opts.sessionId!, {
|
||||
type: 'delta',
|
||||
message_id: this.opts.messageId!,
|
||||
chat_id: this.opts.chatId!,
|
||||
content: text,
|
||||
} as WsFrame);
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
},
|
||||
});
|
||||
case 'agent_thought_chunk': {
|
||||
const content = update.content;
|
||||
if (content.type === 'text' && 'text' in content) {
|
||||
const text = (content as { text: string }).text;
|
||||
this.reasoningChunks.push(text);
|
||||
if (this.canStream()) {
|
||||
this.opts.broker!.publishFrame(this.opts.sessionId!, {
|
||||
type: 'reasoning_delta',
|
||||
message_id: this.opts.messageId!,
|
||||
chat_id: this.opts.chatId!,
|
||||
content: text,
|
||||
} as WsFrame);
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'tool_call':
|
||||
this.handleToolUpdate(update.toolCallId, update);
|
||||
break;
|
||||
case 'tool_call_update':
|
||||
this.handleToolUpdate(update.toolCallId, update);
|
||||
break;
|
||||
case 'available_commands_update': {
|
||||
const commands = update.availableCommands.map((cmd) => ({
|
||||
name: cmd.name,
|
||||
description: cmd.description ?? undefined,
|
||||
}));
|
||||
if (this.opts.taskId && commands.length > 0) {
|
||||
mergeTaskCommands(this.opts.taskId, commands);
|
||||
if (this.canStream() && this.opts.sessionId) {
|
||||
const all = getTaskCommands(this.opts.taskId) ?? commands;
|
||||
this.opts.broker!.publishFrame(this.opts.sessionId, {
|
||||
type: 'agent_commands',
|
||||
task_id: this.opts.taskId,
|
||||
session_id: this.opts.sessionId,
|
||||
commands: all,
|
||||
} as WsFrame);
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
buildClient(agent: string, modeId: string | undefined, taskId: string | undefined, sessionId: string | undefined): Client {
|
||||
return {
|
||||
sessionUpdate: (params) => this.handleSessionUpdate(params),
|
||||
requestPermission: async (params: RequestPermissionRequest): Promise<RequestPermissionResponse> => {
|
||||
if (taskId && sessionId) {
|
||||
return waitForPermissionResponse(taskId, sessionId, agent, modeId, params);
|
||||
}
|
||||
const firstOption = params.options[0];
|
||||
if (firstOption) {
|
||||
return { outcome: { outcome: 'selected', optionId: firstOption.optionId } };
|
||||
}
|
||||
return { outcome: { outcome: 'cancelled' } };
|
||||
},
|
||||
readTextFile: async (params: ReadTextFileRequest): Promise<ReadTextFileResponse> => {
|
||||
const content = await readWorktreeTextFile(
|
||||
this.worktreePath,
|
||||
params.path,
|
||||
params.line,
|
||||
params.limit,
|
||||
);
|
||||
return { content };
|
||||
},
|
||||
writeTextFile: async (params: WriteTextFileRequest): Promise<WriteTextFileResponse> => {
|
||||
await writeWorktreeTextFile(this.worktreePath, params.path, params.content);
|
||||
return {};
|
||||
},
|
||||
createTerminal: async (_params: CreateTerminalRequest): Promise<CreateTerminalResponse> => {
|
||||
return { terminalId: 'noop' };
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a Node.js Writable stream to a web WritableStream<Uint8Array>.
|
||||
*/
|
||||
function nodeWritableToWeb(nodeStream: NodeJS.WritableStream): WritableStream<Uint8Array> {
|
||||
return new WritableStream<Uint8Array>({
|
||||
write(chunk) {
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
const ok = (nodeStream as Writable).write(chunk, (err) => {
|
||||
if (err) reject(err);
|
||||
});
|
||||
if (ok) resolve();
|
||||
else (nodeStream as Writable).once('drain', resolve);
|
||||
});
|
||||
},
|
||||
close() {
|
||||
return new Promise<void>((resolve) => {
|
||||
(nodeStream as Writable).end(resolve);
|
||||
});
|
||||
},
|
||||
abort() {
|
||||
(nodeStream as Writable).destroy();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispatch a task to an ACP-capable agent via SSH.
|
||||
*
|
||||
* Opens a structured ACP session, sends the task as a prompt, and collects
|
||||
* all session updates. Returns the collected output and tool calls.
|
||||
*/
|
||||
export async function dispatchViaAcp(opts: AcpDispatchOpts): Promise<AcpDispatchResult> {
|
||||
const { agent, task, worktreePath, installPath, signal, log } = opts;
|
||||
const {
|
||||
agent,
|
||||
task,
|
||||
worktreePath,
|
||||
installPath,
|
||||
signal,
|
||||
log,
|
||||
taskId,
|
||||
modeId,
|
||||
sessionId,
|
||||
chatId,
|
||||
messageId,
|
||||
broker,
|
||||
} = opts;
|
||||
|
||||
const args = acpArgs(agent);
|
||||
const args = resolveAcpSpawnArgs(agent);
|
||||
if (!args) {
|
||||
return {
|
||||
exitCode: 1,
|
||||
output: `Agent '${agent}' does not support ACP.`,
|
||||
toolCalls: [],
|
||||
toolSnapshots: [],
|
||||
reasoningText: '',
|
||||
stopReason: 'error',
|
||||
};
|
||||
}
|
||||
|
||||
const binary = installPath ?? agent;
|
||||
log.info({ agent, binary, worktreePath }, 'acp-dispatch: spawning');
|
||||
log.info({ agent, binary, worktreePath, modeId, model: opts.model }, 'acp-dispatch: spawning');
|
||||
const child = spawn(binary, args, {
|
||||
cwd: worktreePath,
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
env: { ...process.env },
|
||||
});
|
||||
|
||||
// Wire up abort
|
||||
const streamCtx = new AcpStreamContext(
|
||||
{ broker, sessionId, chatId, messageId, taskId },
|
||||
worktreePath,
|
||||
);
|
||||
|
||||
let killed = false;
|
||||
const cleanup = () => {
|
||||
if (!killed) {
|
||||
killed = true;
|
||||
streamCtx.markAborted();
|
||||
child.kill('SIGTERM');
|
||||
setTimeout(() => child.kill('SIGKILL'), 5_000);
|
||||
}
|
||||
if (taskId) cancelPendingPermission(taskId);
|
||||
};
|
||||
|
||||
if (signal) {
|
||||
if (signal.aborted) {
|
||||
cleanup();
|
||||
return { exitCode: 130, output: 'Aborted before start', toolCalls: [], stopReason: 'cancelled' };
|
||||
return {
|
||||
exitCode: 130,
|
||||
output: 'Aborted before start',
|
||||
toolSnapshots: streamCtx.snapshots,
|
||||
reasoningText: '',
|
||||
stopReason: 'cancelled',
|
||||
};
|
||||
}
|
||||
signal.addEventListener('abort', cleanup, { once: true });
|
||||
}
|
||||
|
||||
try {
|
||||
// Create web streams from the child process stdio
|
||||
const inputStream = nodeReadableToWeb(child.stdout!);
|
||||
const outputStream = nodeWritableToWeb(child.stdin!);
|
||||
|
||||
// Create the NDJSON ACP stream
|
||||
const stream = ndJsonStream(outputStream, inputStream);
|
||||
|
||||
// Collected session updates
|
||||
const textChunks: string[] = [];
|
||||
const toolCalls: Array<{ title: string; input: unknown; output?: unknown }> = [];
|
||||
|
||||
// Create client-side connection — we are the "client" (editor), the agent is remote
|
||||
const stream = createAcpNdJsonStream(child);
|
||||
const connection = new ClientSideConnection(
|
||||
(_agentInterface): Client => ({
|
||||
// Handle session updates from the agent
|
||||
async sessionUpdate(params: SessionNotification): Promise<void> {
|
||||
const update = params.update;
|
||||
if (update.sessionUpdate === 'agent_message_chunk') {
|
||||
// ContentChunk with content: ContentBlock
|
||||
const content = update.content;
|
||||
if (content.type === 'text' && 'text' in content) {
|
||||
textChunks.push((content as { text: string }).text);
|
||||
}
|
||||
} else if (update.sessionUpdate === 'tool_call') {
|
||||
toolCalls.push({
|
||||
title: update.title,
|
||||
input: update.rawInput,
|
||||
});
|
||||
} else if (update.sessionUpdate === 'tool_call_update') {
|
||||
const last = toolCalls[toolCalls.length - 1];
|
||||
if (last && update.rawOutput !== undefined) {
|
||||
last.output = update.rawOutput;
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// Permission requests — auto-approve by selecting the first option (worktree is isolated)
|
||||
async requestPermission(params: RequestPermissionRequest): Promise<RequestPermissionResponse> {
|
||||
// Select the first available option to auto-approve
|
||||
const firstOption = params.options[0];
|
||||
if (firstOption) {
|
||||
return {
|
||||
outcome: { outcome: 'selected', optionId: firstOption.optionId },
|
||||
};
|
||||
}
|
||||
// No options available — cancel
|
||||
return { outcome: { outcome: 'cancelled' } };
|
||||
},
|
||||
|
||||
// File system operations — let the agent handle them directly in the worktree
|
||||
async readTextFile(_params: ReadTextFileRequest): Promise<ReadTextFileResponse> {
|
||||
return { content: '' };
|
||||
},
|
||||
async writeTextFile(_params: WriteTextFileRequest): Promise<WriteTextFileResponse> {
|
||||
return {};
|
||||
},
|
||||
async createTerminal(_params: CreateTerminalRequest): Promise<CreateTerminalResponse> {
|
||||
return { terminalId: 'noop' };
|
||||
},
|
||||
}),
|
||||
() => streamCtx.buildClient(agent, modeId, taskId, sessionId),
|
||||
stream,
|
||||
);
|
||||
|
||||
// Initialize the connection
|
||||
// ProtocolVersion is a number in this SDK version
|
||||
const initResult = await connection.initialize({
|
||||
await connection.initialize({
|
||||
protocolVersion: 1,
|
||||
clientInfo: { name: 'boocoder', version: '2.0.1' },
|
||||
clientInfo: { name: 'boocoder', version: '2.3.0' },
|
||||
clientCapabilities: {},
|
||||
});
|
||||
log.info({ agentInfo: initResult.agentInfo }, 'acp-dispatch: initialized');
|
||||
|
||||
// Create a new session
|
||||
const session = await connection.newSession({
|
||||
cwd: worktreePath,
|
||||
mcpServers: [],
|
||||
});
|
||||
log.info({ sessionId: session.sessionId }, 'acp-dispatch: session created');
|
||||
const acpSession = await connection.newSession({ cwd: worktreePath, mcpServers: [] });
|
||||
log.info({ sessionId: acpSession.sessionId }, 'acp-dispatch: session created');
|
||||
|
||||
await applySessionOverrides(connection, acpSession.sessionId, acpSession.configOptions, opts);
|
||||
|
||||
// Send the prompt
|
||||
const promptResult = await connection.prompt({
|
||||
sessionId: session.sessionId,
|
||||
sessionId: acpSession.sessionId,
|
||||
prompt: [{ type: 'text', text: task }],
|
||||
});
|
||||
|
||||
const stopReason = promptResult.stopReason ?? 'end_turn';
|
||||
log.info({ agent, stopReason, toolCallCount: toolCalls.length }, 'acp-dispatch: prompt completed');
|
||||
log.info(
|
||||
{ agent, stopReason, toolCallCount: streamCtx.snapshots.length, reasoningChars: streamCtx.reasoningText.length },
|
||||
'acp-dispatch: prompt completed',
|
||||
);
|
||||
|
||||
// Clean shutdown
|
||||
await connection.closeSession({ sessionId: session.sessionId }).catch(() => {});
|
||||
await connection.closeSession({ sessionId: acpSession.sessionId }).catch(() => {});
|
||||
|
||||
return {
|
||||
exitCode: 0,
|
||||
output: textChunks.join(''),
|
||||
toolCalls,
|
||||
output: streamCtx.output,
|
||||
toolSnapshots: streamCtx.snapshots,
|
||||
reasoningText: streamCtx.reasoningText,
|
||||
stopReason,
|
||||
};
|
||||
} catch (err) {
|
||||
@@ -256,14 +367,13 @@ export async function dispatchViaAcp(opts: AcpDispatchOpts): Promise<AcpDispatch
|
||||
return {
|
||||
exitCode: 1,
|
||||
output: message,
|
||||
toolCalls: [],
|
||||
toolSnapshots: streamCtx.snapshots,
|
||||
reasoningText: streamCtx.reasoningText,
|
||||
stopReason: 'error',
|
||||
};
|
||||
} finally {
|
||||
if (signal) signal.removeEventListener('abort', cleanup);
|
||||
cleanup();
|
||||
|
||||
// Wait for child to exit
|
||||
await new Promise<void>((resolve) => {
|
||||
child.on('close', resolve);
|
||||
setTimeout(resolve, 3_000);
|
||||
|
||||
Reference in New Issue
Block a user