From 3533db64a5afcebb88e9238528c10e1430429225 Mon Sep 17 00:00:00 2001 From: James LePage <36246732+Jameswlepage@users.noreply.github.com> Date: Fri, 30 Jan 2026 15:36:41 -0800 Subject: [PATCH 01/20] Fix ACP agent detection and communication issues - Filter available agents to show only wpcom, anthropic-builtin, claude-code-acp, opencode, and codex-acp - Add --yes --quiet flags to npx commands to suppress install prompts - Add npx fallback for codex-acp when binary not installed - Combine stdout/stderr for JSON-RPC stream (codex-acp writes to stderr) - Filter echoed requests from stream to prevent SDK confusion - Remove TTY requirement for codex-acp (ACP wrapper uses stdio, not interactive mode) --- src/hooks/use-agent-chat.ts | 466 ++++++++++++ src/modules/acp/lib/acp-process-manager.ts | 796 +++++++++++++++++++++ src/modules/acp/lib/acp-registry.ts | 291 ++++++++ src/modules/acp/lib/agent-detection.ts | 347 +++++++++ src/modules/acp/lib/ipc-handlers.ts | 374 ++++++++++ 5 files changed, 2274 insertions(+) create mode 100644 src/hooks/use-agent-chat.ts create mode 100644 src/modules/acp/lib/acp-process-manager.ts create mode 100644 src/modules/acp/lib/acp-registry.ts create mode 100644 src/modules/acp/lib/agent-detection.ts create mode 100644 src/modules/acp/lib/ipc-handlers.ts diff --git a/src/hooks/use-agent-chat.ts b/src/hooks/use-agent-chat.ts new file mode 100644 index 0000000000..5ed3203a7f --- /dev/null +++ b/src/hooks/use-agent-chat.ts @@ -0,0 +1,466 @@ +import { useCallback, useEffect, useRef } from 'react'; +import { getIpcApi } from 'src/lib/get-ipc-api'; +import { useAppDispatch, useRootSelector } from 'src/stores'; +import { agentChatActions, agentChatSelectors, AgentMessage } from 'src/stores/agent-chat-slice'; + +// Stable handler refs to avoid StrictMode double-subscription issues +interface AcpSessionUpdateData { + sessionId: string; + type: string; + text?: string; + tool_use?: { id: string; name: string; input: Record< string, unknown > }; + tool_result?: { tool_use_id: string; output?: string; error?: string }; + thinking?: string; + progress?: { message: string; percentage?: number }; + approval_request?: { id: string; message: string; options: string[] }; + error?: { code: string; message: string }; +} + +interface ServerEvent { + type: string; + data: unknown; +} + +interface UseAgentChatOptions { + siteId: string; +} + +interface UseAgentChatResult { + messages: readonly AgentMessage[]; + isLoading: boolean; + isStreaming: boolean; + sendMessage: ( message: string ) => Promise< void >; + clearConversation: () => void; + instanceId: string; +} + +/** + * Hook for managing agent chat interactions. + * + * Supports two modes: + * - Built-in Anthropic agent: Uses HTTP/SSE streaming + * - ACP agents: Uses IPC with ACP process manager + * + * Handles: + * - Sending messages to the appropriate backend + * - Parsing responses and dispatching Redux actions + * - Error handling + */ +export function useAgentChat( { siteId }: UseAgentChatOptions ): UseAgentChatResult { + const dispatch = useAppDispatch(); + const instanceId = `agent_${ siteId }`; + + // Track ACP session ID for this chat + const acpSessionIdRef = useRef< string | null >( null ); + + const messages = useRootSelector( ( state ) => + agentChatSelectors.selectMessages( state, instanceId ) + ) as AgentMessage[]; + + const isLoading = useRootSelector( ( state ) => + agentChatSelectors.selectIsLoading( state, instanceId ) + ); + + const isStreaming = useRootSelector( ( state ) => + agentChatSelectors.selectIsStreaming( state, instanceId ) + ); + + const serverPort = useRootSelector( agentChatSelectors.selectAgentServerPort ); + + // Get the selected agent to determine which backend to use + const selectedAgent = useRootSelector( agentChatSelectors.selectSelectedAgent ); + + const handleServerEvent = useCallback( + ( event: ServerEvent ) => { + switch ( event.type ) { + case 'content_block_start': { + const data = event.data as { + index: number; + blockType: 'text' | 'tool_use'; + id?: string; + name?: string; + }; + dispatch( + agentChatActions.startContentBlock( { + instanceId, + index: data.index, + blockType: data.blockType, + id: data.id, + name: data.name, + } ) + ); + break; + } + case 'content_delta': { + const data = event.data as { index: number; text: string }; + dispatch( + agentChatActions.appendToAssistantMessage( { + instanceId, + text: data.text, + index: data.index, + } ) + ); + break; + } + case 'content_block_stop': { + const data = event.data as { + index: number; + blockType: 'text' | 'tool_use'; + id?: string; + name?: string; + input?: Record< string, unknown >; + }; + dispatch( + agentChatActions.finishContentBlock( { + instanceId, + index: data.index, + blockType: data.blockType, + id: data.id, + name: data.name, + input: data.input, + } ) + ); + break; + } + case 'tool_use_start': { + // Legacy event - kept for backwards compatibility + const data = event.data as { + id: string; + name: string; + input: Record< string, unknown >; + }; + dispatch( + agentChatActions.addToolCall( { + instanceId, + toolCall: data, + } ) + ); + break; + } + case 'tool_result': { + const data = event.data as { + toolCallId: string; + result: { success: boolean; output?: string; error?: string }; + }; + dispatch( + agentChatActions.addToolResult( { + instanceId, + toolCallId: data.toolCallId, + result: data.result, + } ) + ); + break; + } + case 'message_done': { + dispatch( agentChatActions.finishAssistantMessage( { instanceId } ) ); + break; + } + case 'error': { + const data = event.data as { message: string }; + dispatch( + agentChatActions.setMessageError( { + instanceId, + error: data.message, + } ) + ); + break; + } + } + }, + [ dispatch, instanceId ] + ); + + /** + * Handle ACP session update events from the main process. + * This function is stored in a ref to avoid useEffect re-running on changes. + */ + const handleAcpSessionUpdate = useCallback( + ( data: AcpSessionUpdateData ) => { + // Only handle events for our session + if ( data.sessionId !== acpSessionIdRef.current ) { + return; + } + + switch ( data.type ) { + case 'text': + if ( data.text ) { + dispatch( + agentChatActions.appendToAssistantMessage( { + instanceId, + text: data.text, + } ) + ); + } + break; + + case 'tool_use': + if ( data.tool_use ) { + dispatch( + agentChatActions.addToolCall( { + instanceId, + toolCall: { + id: data.tool_use.id, + name: data.tool_use.name, + input: data.tool_use.input, + }, + } ) + ); + } + break; + + case 'tool_result': + if ( data.tool_result ) { + const hasError = !! data.tool_result.error; + dispatch( + agentChatActions.addToolResult( { + instanceId, + toolCallId: data.tool_result.tool_use_id, + result: { + success: ! hasError, + output: data.tool_result.output, + error: data.tool_result.error, + }, + } ) + ); + } + break; + + case 'end': + dispatch( agentChatActions.finishAssistantMessage( { instanceId } ) ); + break; + + case 'error': + if ( data.error ) { + dispatch( + agentChatActions.setMessageError( { + instanceId, + error: data.error.message, + } ) + ); + } + break; + } + }, + [ dispatch, instanceId ] + ); + + // Store the handler in a ref to avoid re-subscribing on every render + const handleAcpSessionUpdateRef = useRef( handleAcpSessionUpdate ); + handleAcpSessionUpdateRef.current = handleAcpSessionUpdate; + + // Set up ACP event listeners + // Use refs for handlers to avoid StrictMode double-subscription issues + useEffect( () => { + const unsubscribeUpdate = window.ipcListener.subscribe( + 'acp-session-update', + ( _event, data ) => { + // Call through ref to always get the latest handler + handleAcpSessionUpdateRef.current( data ); + } + ); + + const unsubscribeError = window.ipcListener.subscribe( + 'acp-session-error', + ( _event, data ) => { + if ( data.sessionId === acpSessionIdRef.current ) { + dispatch( + agentChatActions.setMessageError( { + instanceId, + error: data.error, + } ) + ); + } + } + ); + + const unsubscribeClosed = window.ipcListener.subscribe( + 'acp-session-closed', + ( _event, data ) => { + if ( data.sessionId === acpSessionIdRef.current ) { + acpSessionIdRef.current = null; + // Finish any pending message + dispatch( agentChatActions.finishAssistantMessage( { instanceId } ) ); + } + } + ); + + return () => { + unsubscribeUpdate(); + unsubscribeError(); + unsubscribeClosed(); + }; + // Note: handleAcpSessionUpdate is accessed via ref, so it's not needed in deps + }, [ dispatch, instanceId ] ); + + /** + * Send message via built-in Anthropic HTTP server. + */ + const sendMessageViaHttp = useCallback( + async ( message: string ) => { + if ( ! serverPort ) { + dispatch( + agentChatActions.setMessageError( { + instanceId, + error: 'Agent server not running', + } ) + ); + return; + } + + try { + const response = await fetch( `http://127.0.0.1:${ serverPort }/chat`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify( { + message, + siteId, + instanceId, + history: messages.map( ( m ) => ( { + role: m.role, + content: m.content, + } ) ), + } ), + } ); + + if ( ! response.ok ) { + const error = await response.json(); + throw new Error( error.error || 'Failed to send message' ); + } + + // Process SSE stream + const reader = response.body?.getReader(); + if ( ! reader ) { + throw new Error( 'No response body' ); + } + + const decoder = new TextDecoder(); + let buffer = ''; + + while ( true ) { + const { done, value } = await reader.read(); + if ( done ) { + break; + } + + buffer += decoder.decode( value, { stream: true } ); + const lines = buffer.split( '\n' ); + buffer = lines.pop() || ''; + + for ( const line of lines ) { + if ( line.startsWith( 'data: ' ) ) { + try { + const event = JSON.parse( line.slice( 6 ) ) as ServerEvent; + handleServerEvent( event ); + } catch ( e ) { + console.error( 'Failed to parse SSE event:', e ); + } + } + } + } + } catch ( error ) { + const errorMessage = error instanceof Error ? error.message : String( error ); + dispatch( + agentChatActions.setMessageError( { + instanceId, + error: errorMessage, + } ) + ); + } + }, + [ dispatch, handleServerEvent, instanceId, messages, serverPort, siteId ] + ); + + /** + * Send message via ACP process manager. + */ + const sendMessageViaAcp = useCallback( + async ( message: string, agentId: string ) => { + try { + // Create ACP session if we don't have one + if ( ! acpSessionIdRef.current ) { + const session = await getIpcApi().createAcpSession( agentId, siteId ); + acpSessionIdRef.current = session.id; + + // Store session in Redux + dispatch( agentChatActions.setAcpSession( session ) ); + } + + // Send prompt to ACP agent + const result = await getIpcApi().sendAcpPrompt( acpSessionIdRef.current, message ); + + // Finish the message when prompt completes + if ( result.stopReason ) { + dispatch( agentChatActions.finishAssistantMessage( { instanceId } ) ); + } + } catch ( error ) { + const errorMessage = error instanceof Error ? error.message : String( error ); + dispatch( + agentChatActions.setMessageError( { + instanceId, + error: errorMessage, + } ) + ); + } + }, + [ dispatch, instanceId, siteId ] + ); + + const sendMessage = useCallback( + async ( message: string ) => { + if ( ! message.trim() || isLoading || isStreaming ) { + return; + } + + // Add user message + dispatch( agentChatActions.addUserMessage( { instanceId, message } ) ); + + // Start assistant message placeholder + dispatch( agentChatActions.startAssistantMessage( { instanceId } ) ); + + // Route to appropriate backend based on agent provider + if ( selectedAgent?.provider === 'acp' ) { + await sendMessageViaAcp( message, selectedAgent.id ); + } else { + // Default to built-in HTTP server (anthropic-builtin) + await sendMessageViaHttp( message ); + } + }, + [ + dispatch, + instanceId, + isLoading, + isStreaming, + selectedAgent, + sendMessageViaAcp, + sendMessageViaHttp, + ] + ); + + const clearConversation = useCallback( () => { + dispatch( agentChatActions.clearMessages( { instanceId } ) ); + + // Close ACP session if one exists + if ( acpSessionIdRef.current ) { + getIpcApi().closeAcpSession( acpSessionIdRef.current ).catch( console.error ); + acpSessionIdRef.current = null; + } + }, [ dispatch, instanceId ] ); + + // Clean up ACP session on unmount + useEffect( () => { + return () => { + if ( acpSessionIdRef.current ) { + getIpcApi().closeAcpSession( acpSessionIdRef.current ).catch( console.error ); + } + }; + }, [] ); + + return { + messages, + isLoading, + isStreaming, + sendMessage, + clearConversation, + instanceId, + }; +} diff --git a/src/modules/acp/lib/acp-process-manager.ts b/src/modules/acp/lib/acp-process-manager.ts new file mode 100644 index 0000000000..2a0b188a49 --- /dev/null +++ b/src/modules/acp/lib/acp-process-manager.ts @@ -0,0 +1,796 @@ +/** + * ACP Process Manager + * + * Manages spawning and lifecycle of ACP agent subprocesses using the official ACP SDK. + */ + +import { spawn, ChildProcess } from 'child_process'; +import type * as pty from 'node-pty'; +import { Readable, Writable } from 'stream'; +import { EventEmitter } from 'events'; +import { + ClientSideConnection, + ndJsonStream, + type Client, + type SessionNotification, + type RequestPermissionRequest, + type RequestPermissionResponse, + type ReadTextFileRequest, + type ReadTextFileResponse, + type WriteTextFileRequest, + type WriteTextFileResponse, + type CreateTerminalRequest, + type CreateTerminalResponse, + type TerminalOutputRequest, + type TerminalOutputResponse, + type ReleaseTerminalRequest, + type ReleaseTerminalResponse, + type WaitForTerminalExitRequest, + type WaitForTerminalExitResponse, + type KillTerminalCommandRequest, + type KillTerminalCommandResponse, + type NewSessionResponse, + type PromptResponse, +} from '@agentclientprotocol/sdk'; +import { detectAgentById, getAugmentedPath } from './agent-detection'; +import type { AgentConfig, AcpSession } from '../types'; + +function loadPty(): typeof import( 'node-pty' ) { + try { + return require( 'node-pty' ); + } catch ( error ) { + throw new Error( + 'node-pty is required to run this agent. Please install dependencies and try again.' + ); + } +} + +/** + * Callback handler for file system operations. + */ +export interface AcpCallbackHandler { + readTextFile?: ( path: string ) => Promise< string >; + writeTextFile?: ( path: string, content: string ) => Promise< void >; + requestPermission?: ( + toolCall: unknown, + options: Array< { optionId: string; name: string; kind: string } > + ) => Promise< { outcome: 'selected' | 'cancelled'; optionId?: string } >; +} + +/** + * Terminal instance for ACP terminal capability. + */ +interface AcpTerminal { + id: string; + process: ChildProcess; + output: string; + outputByteLimit: number; + exitCode: number | null; + exitSignal: string | null; + exited: boolean; + exitPromise: Promise< void >; + exitResolve: () => void; +} + +/** + * Running ACP process state. + */ +interface RunningProcess { + agentId: string; + siteId: string; + process: ChildProcess | pty.IPty; + connection: ClientSideConnection; + session: AcpSession; + callbackHandler?: AcpCallbackHandler; + terminals: Map< string, AcpTerminal >; + terminalCounter: number; +} + +/** + * ACP Process Manager. + * + * Manages the lifecycle of ACP agent subprocesses using the official ACP SDK. + */ +export class AcpProcessManager extends EventEmitter { + private processes: Map< string, RunningProcess > = new Map(); + private sessionCounter = 0; + + constructor() { + super(); + } + + /** + * Generate a unique session ID. + */ + private nextSessionId(): string { + return `acp-session-${ Date.now() }-${ ++this.sessionCounter }`; + } + + private isPtyProcess( proc: ChildProcess | pty.IPty ): proc is pty.IPty { + return typeof ( proc as pty.IPty ).onData === 'function'; + } + + private getProcessStreams( proc: ChildProcess | pty.IPty ): { + input: WritableStream< Uint8Array >; + output: ReadableStream< Uint8Array >; + } { + if ( this.isPtyProcess( proc ) ) { + const encoder = new TextEncoder(); + const decoder = new TextDecoder(); + + const input = new WritableStream< Uint8Array >( { + write( chunk ) { + proc.write( decoder.decode( chunk ) ); + }, + } ); + + const output = new ReadableStream< Uint8Array >( { + start( controller ) { + proc.onData( ( data ) => { + controller.enqueue( encoder.encode( data ) ); + } ); + proc.onExit( () => controller.close() ); + }, + } ); + + return { input, output }; + } + + const input = Writable.toWeb( proc.stdin! ) as WritableStream< Uint8Array >; + + // Some agents (like codex-acp) write JSON-RPC to stderr instead of stdout, + // and may also echo requests back. We need to: + // 1. Combine stdout and stderr + // 2. Filter out echoed requests (messages with 'method' field that we sent) + // 3. Only pass through responses and valid incoming requests from the agent + const encoder = new TextEncoder(); + let buffer = ''; + + const output = new ReadableStream< Uint8Array >( { + start( controller ) { + const processData = ( data: Buffer ) => { + buffer += data.toString(); + const lines = buffer.split( '\n' ); + buffer = lines.pop() || ''; + + for ( const line of lines ) { + const trimmed = line.trim(); + if ( ! trimmed ) { + continue; + } + + // Try to parse as JSON to filter echoed requests + try { + const msg = JSON.parse( trimmed ); + + // Skip echoed requests - these are requests WE sent that the agent echoes back + // They have 'method' field AND 'id' field (requests we send always have IDs) + // Valid incoming requests from agent (callbacks) also have 'method' but we should handle those + // The key is: our outgoing requests use methods like 'initialize', 'newSession', 'prompt' + // Agent callbacks use methods like 'readTextFile', 'writeTextFile', etc. + const echoMethods = [ 'initialize', 'newSession', 'prompt', 'cancel' ]; + if ( msg.method && msg.id !== undefined && echoMethods.includes( msg.method ) ) { + // This looks like an echoed request, skip it + console.log( `ACP: Skipping echoed request: ${ msg.method }` ); + continue; + } + + // Pass through responses (have result or error) and valid callbacks + controller.enqueue( encoder.encode( trimmed + '\n' ) ); + } catch { + // Not valid JSON, skip (log messages, spinners, etc.) + } + } + }; + + proc.stdout?.on( 'data', processData ); + proc.stderr?.on( 'data', processData ); + proc.on( 'exit', () => controller.close() ); + proc.on( 'error', () => controller.close() ); + }, + } ); + + return { input, output }; + } + + /** + * Create a new ACP session with an agent. + */ + async createSession( + agentId: string, + siteId: string, + workingDirectory: string, + callbackHandler?: AcpCallbackHandler + ): Promise< AcpSession > { + const agentConfig = await detectAgentById( agentId ); + + if ( ! agentConfig ) { + throw new Error( `Unknown agent: ${ agentId }` ); + } + + if ( agentConfig.provider !== 'acp' ) { + throw new Error( `Agent ${ agentId } is not an ACP agent` ); + } + + if ( agentConfig.status === 'unavailable' ) { + throw new Error( `Agent ${ agentId } is not available` ); + } + + if ( ! agentConfig.command ) { + throw new Error( `Agent ${ agentId } has no command defined` ); + } + + const sessionId = this.nextSessionId(); + + // Create session object + const session: AcpSession = { + id: sessionId, + agentId, + siteId, + state: 'starting', + createdAt: Date.now(), + }; + + try { + // Spawn the agent process + console.log( `ACP [${ sessionId }] Spawning agent: ${ agentConfig.command } ${ ( agentConfig.args ?? [] ).join( ' ' ) }` ); + const proc = this.spawnAgent( agentConfig, workingDirectory ); + console.log( `ACP [${ sessionId }] Process spawned with PID: ${ proc.pid }` ); + + // Convert Node streams to Web streams for the SDK + const io = this.getProcessStreams( proc ); + + // Create the ACP stream + const stream = ndJsonStream( io.input, io.output ); + console.log( `ACP [${ sessionId }] Stream created` ); + + // Create the client handler that will receive agent callbacks + const clientHandler = this.createClientHandler( sessionId, callbackHandler ); + + // Create the connection using the official SDK + const connection = new ClientSideConnection( () => clientHandler, stream ); + console.log( `ACP [${ sessionId }] Connection created` ); + + // Store the running process + const runningProcess: RunningProcess = { + agentId, + siteId, + process: proc, + connection, + session, + callbackHandler, + terminals: new Map(), + terminalCounter: 0, + }; + + this.processes.set( sessionId, runningProcess ); + + // Set up process event handlers + this.setupProcessHandlers( sessionId, proc ); + + // Initialize the connection and create session + console.log( `ACP [${ sessionId }] Starting initialization...` ); + const newSessionResult = await this.initializeSession( sessionId, workingDirectory ); + console.log( `ACP [${ sessionId }] Initialization complete:`, newSessionResult ); + + // Update session state + session.pid = proc.pid; + session.state = 'ready'; + session.acpSessionId = newSessionResult.sessionId; + + this.emit( 'session_ready', sessionId, newSessionResult ); + + return session; + } catch ( error ) { + session.state = 'error'; + session.error = error instanceof Error ? error.message : String( error ); + this.processes.delete( sessionId ); + throw error; + } + } + + /** + * Create a Client handler for the ACP connection. + */ + private createClientHandler( sessionId: string, callbackHandler?: AcpCallbackHandler ): Client { + const self = this; + + const getRunningProcess = () => { + const proc = self.processes.get( sessionId ); + if ( ! proc ) { + throw new Error( `Session not found: ${ sessionId }` ); + } + return proc; + }; + + return { + // Handle session updates (streaming content from agent) + async sessionUpdate( params: SessionNotification ): Promise< void > { + console.log( `ACP [${ sessionId }] sessionUpdate received:`, JSON.stringify( params ).slice( 0, 500 ) ); + self.emit( 'session_update', sessionId, params ); + }, + + // Handle permission requests from agent + async requestPermission( + params: RequestPermissionRequest + ): Promise< RequestPermissionResponse > { + if ( callbackHandler?.requestPermission ) { + const result = await callbackHandler.requestPermission( + params.toolCall, + params.options.map( ( opt ) => ( { + optionId: opt.optionId, + name: opt.name, + kind: opt.kind, + } ) ) + ); + + if ( result.outcome === 'cancelled' ) { + return { outcome: { outcome: 'cancelled' } }; + } + + return { + outcome: { + outcome: 'selected', + optionId: result.optionId!, + }, + }; + } + + // Default: auto-approve with first option + if ( params.options.length > 0 ) { + return { + outcome: { + outcome: 'selected', + optionId: params.options[ 0 ].optionId, + }, + }; + } + + return { outcome: { outcome: 'cancelled' } }; + }, + + // Handle file read requests from agent + async readTextFile( params: ReadTextFileRequest ): Promise< ReadTextFileResponse > { + if ( callbackHandler?.readTextFile ) { + const content = await callbackHandler.readTextFile( params.path ); + return { content }; + } + throw new Error( 'File read not supported' ); + }, + + // Handle file write requests from agent + async writeTextFile( params: WriteTextFileRequest ): Promise< WriteTextFileResponse > { + if ( callbackHandler?.writeTextFile ) { + await callbackHandler.writeTextFile( params.path, params.content ); + return {}; + } + throw new Error( 'File write not supported' ); + }, + + // Create a new terminal and execute a command + async createTerminal( params: CreateTerminalRequest ): Promise< CreateTerminalResponse > { + const runningProcess = getRunningProcess(); + const terminalId = `terminal-${ ++runningProcess.terminalCounter }`; + const outputByteLimit = Number( params.outputByteLimit ?? 1024 * 1024 ); // Default 1MB + + console.log( `ACP [${ sessionId }] createTerminal: ${ params.command } ${ ( params.args ?? [] ).join( ' ' ) }` ); + + // Build environment + const env: Record< string, string > = { ...process.env } as Record< string, string >; + env.PATH = getAugmentedPath(); + if ( params.env ) { + for ( const { name, value } of params.env ) { + env[ name ] = value; + } + } + + let exitResolve: () => void = () => {}; + const exitPromise = new Promise< void >( ( resolve ) => { + exitResolve = resolve; + } ); + + const terminal: AcpTerminal = { + id: terminalId, + process: null as unknown as ChildProcess, + output: '', + outputByteLimit, + exitCode: null, + exitSignal: null, + exited: false, + exitPromise, + exitResolve, + }; + + // Spawn the process + const proc = spawn( params.command, params.args ?? [], { + cwd: params.cwd ?? process.cwd(), + env, + stdio: [ 'pipe', 'pipe', 'pipe' ], + shell: true, + } ); + + terminal.process = proc; + + // Collect output + const appendOutput = ( data: Buffer ) => { + terminal.output += data.toString(); + // Truncate from beginning if exceeds limit + if ( terminal.output.length > outputByteLimit ) { + terminal.output = terminal.output.slice( -outputByteLimit ); + } + }; + + proc.stdout?.on( 'data', appendOutput ); + proc.stderr?.on( 'data', appendOutput ); + + proc.on( 'exit', ( code, signal ) => { + terminal.exitCode = code; + terminal.exitSignal = signal ?? null; + terminal.exited = true; + terminal.exitResolve(); + } ); + + proc.on( 'error', ( error ) => { + terminal.output += `\nError: ${ error.message }`; + terminal.exited = true; + terminal.exitCode = 1; + terminal.exitResolve(); + } ); + + runningProcess.terminals.set( terminalId, terminal ); + + return { terminalId }; + }, + + // Get current terminal output + async terminalOutput( params: TerminalOutputRequest ): Promise< TerminalOutputResponse > { + const runningProcess = getRunningProcess(); + const terminal = runningProcess.terminals.get( params.terminalId ); + + if ( ! terminal ) { + throw new Error( `Terminal not found: ${ params.terminalId }` ); + } + + const response: TerminalOutputResponse = { + output: terminal.output, + truncated: terminal.output.length >= terminal.outputByteLimit, + }; + + if ( terminal.exited ) { + response.exitStatus = { + exitCode: terminal.exitCode, + signal: terminal.exitSignal, + }; + } + + return response; + }, + + // Wait for terminal to exit + async waitForTerminalExit( params: WaitForTerminalExitRequest ): Promise< WaitForTerminalExitResponse > { + const runningProcess = getRunningProcess(); + const terminal = runningProcess.terminals.get( params.terminalId ); + + if ( ! terminal ) { + throw new Error( `Terminal not found: ${ params.terminalId }` ); + } + + await terminal.exitPromise; + + return { + exitCode: terminal.exitCode, + signal: terminal.exitSignal, + }; + }, + + // Kill terminal command without releasing + async killTerminal( params: KillTerminalCommandRequest ): Promise< KillTerminalCommandResponse > { + const runningProcess = getRunningProcess(); + const terminal = runningProcess.terminals.get( params.terminalId ); + + if ( ! terminal ) { + throw new Error( `Terminal not found: ${ params.terminalId }` ); + } + + if ( ! terminal.exited ) { + terminal.process.kill( 'SIGTERM' ); + // Force kill after timeout + setTimeout( () => { + if ( ! terminal.exited ) { + terminal.process.kill( 'SIGKILL' ); + } + }, 1000 ); + } + + return {}; + }, + + // Release terminal and free resources + async releaseTerminal( params: ReleaseTerminalRequest ): Promise< ReleaseTerminalResponse > { + const runningProcess = getRunningProcess(); + const terminal = runningProcess.terminals.get( params.terminalId ); + + if ( ! terminal ) { + return {}; + } + + // Kill if still running + if ( ! terminal.exited ) { + terminal.process.kill( 'SIGKILL' ); + } + + runningProcess.terminals.delete( params.terminalId ); + + return {}; + }, + }; + } + + /** + * Spawn an ACP agent process. + */ + private spawnAgent( agentConfig: AgentConfig, workingDirectory: string ): ChildProcess { + const command = agentConfig.command!; + const args = agentConfig.args ?? []; + + // Prepare environment with augmented PATH + const env: Record< string, string | undefined > = { + ...process.env, + PATH: getAugmentedPath(), + }; + + // Merge agent-specific environment variables + if ( agentConfig.env ) { + Object.assign( env, agentConfig.env ); + } + + if ( agentConfig.requiresTty ) { + const ptyModule = loadPty(); + const ptyProc = ptyModule.spawn( command, args, { + name: 'xterm-256color', + cols: 120, + rows: 40, + cwd: workingDirectory, + env: env as Record< string, string >, + } ); + return ptyProc as unknown as ChildProcess; + } + + const proc = spawn( command, args, { + cwd: workingDirectory, + env, + stdio: [ 'pipe', 'pipe', 'pipe' ], + } ); + + return proc; + } + + /** + * Set up event handlers for a process. + */ + private setupProcessHandlers( sessionId: string, proc: ChildProcess ): void { + const runningProcess = this.processes.get( sessionId ); + if ( ! runningProcess ) { + return; + } + + // Handle stderr for PTY processes only (for debugging/errors) + // Note: For non-PTY processes, stderr is combined with stdout for JSON-RPC + // (some agents like codex-acp write JSON-RPC to stderr) + if ( this.isPtyProcess( proc ) ) { + proc.onData( ( data ) => { + console.warn( `ACP [${ sessionId }] data:`, data ); + this.emit( 'stderr', sessionId, data ); + } ); + } + + // Handle process exit + if ( this.isPtyProcess( proc ) ) { + proc.onExit( ( event ) => { + console.log( `ACP [${ sessionId }] exited with code ${ event.exitCode }, signal ${ event.signal }` ); + + runningProcess.session.state = 'closed'; + this.processes.delete( sessionId ); + + this.emit( 'session_closed', sessionId, event.exitCode, event.signal ); + } ); + } else { + proc.on( 'exit', ( code, signal ) => { + console.log( `ACP [${ sessionId }] exited with code ${ code }, signal ${ signal }` ); + + runningProcess.session.state = 'closed'; + this.processes.delete( sessionId ); + + this.emit( 'session_closed', sessionId, code, signal ); + } ); + } + + // Handle process errors + if ( ! this.isPtyProcess( proc ) ) { + proc.on( 'error', ( error ) => { + console.error( `ACP [${ sessionId }] error:`, error ); + + runningProcess.session.state = 'error'; + runningProcess.session.error = error.message; + + this.emit( 'session_error', sessionId, error ); + } ); + } + } + + /** + * Initialize the ACP connection and create a session. + */ + private async initializeSession( + sessionId: string, + workingDirectory: string + ): Promise< NewSessionResponse > { + const runningProcess = this.processes.get( sessionId ); + if ( ! runningProcess ) { + throw new Error( `Session not found: ${ sessionId }` ); + } + + const { connection } = runningProcess; + + // Step 1: Initialize the connection + console.log( `ACP [${ sessionId }] Calling initialize()...` ); + const initResult = await connection.initialize( { + protocolVersion: 1, + clientInfo: { + name: 'WordPress Studio', + version: '1.0.0', + }, + clientCapabilities: { + fs: { + readTextFile: true, + writeTextFile: true, + }, + terminal: true, + }, + } ); + console.log( `ACP [${ sessionId }] initialize() complete:`, initResult ); + + // Step 2: Create the session + console.log( `ACP [${ sessionId }] Calling newSession() with cwd: ${ workingDirectory }` ); + const result = await connection.newSession( { + cwd: workingDirectory, + mcpServers: [], + } ); + console.log( `ACP [${ sessionId }] newSession() complete:`, result ); + + return result; + } + + /** + * Send a prompt to an active session. + */ + async sendPrompt( sessionId: string, prompt: string ): Promise< PromptResponse > { + console.log( `ACP [${ sessionId }] sendPrompt called with: "${ prompt.slice( 0, 100 ) }..."` ); + + const runningProcess = this.processes.get( sessionId ); + if ( ! runningProcess ) { + throw new Error( `Session not found: ${ sessionId }` ); + } + + if ( runningProcess.session.state !== 'ready' ) { + throw new Error( `Session not ready: ${ runningProcess.session.state }` ); + } + + const { connection, session } = runningProcess; + + console.log( `ACP [${ sessionId }] Calling connection.prompt() with acpSessionId: ${ session.acpSessionId }` ); + + // Send the prompt using the SDK + const result = await connection.prompt( { + sessionId: session.acpSessionId!, + prompt: [ + { + type: 'text', + text: prompt, + }, + ], + } ); + + console.log( `ACP [${ sessionId }] prompt() complete:`, result ); + + return result; + } + + /** + * Cancel an ongoing prompt. + */ + async cancelPrompt( sessionId: string ): Promise< void > { + const runningProcess = this.processes.get( sessionId ); + if ( ! runningProcess ) { + throw new Error( `Session not found: ${ sessionId }` ); + } + + const { connection, session } = runningProcess; + + await connection.cancel( { + sessionId: session.acpSessionId!, + } ); + } + + /** + * Close a session. + */ + async closeSession( sessionId: string ): Promise< void > { + const runningProcess = this.processes.get( sessionId ); + if ( ! runningProcess ) { + return; + } + + const { process: proc } = runningProcess; + + if ( this.isPtyProcess( proc ) ) { + proc.kill(); + } else { + // Kill the process + proc.kill( 'SIGTERM' ); + + // Give it a moment, then force kill if needed + setTimeout( () => { + if ( ! proc.killed ) { + proc.kill( 'SIGKILL' ); + } + }, 3000 ); + } + + runningProcess.session.state = 'closed'; + this.processes.delete( sessionId ); + } + + /** + * Close all sessions. + */ + async closeAllSessions(): Promise< void > { + const sessionIds = Array.from( this.processes.keys() ); + + await Promise.all( sessionIds.map( ( id ) => this.closeSession( id ) ) ); + } + + /** + * Get a session by ID. + */ + getSession( sessionId: string ): AcpSession | undefined { + return this.processes.get( sessionId )?.session; + } + + /** + * Get all active sessions. + */ + getAllSessions(): AcpSession[] { + return Array.from( this.processes.values() ).map( ( p ) => p.session ); + } + + /** + * Get sessions for a specific site. + */ + getSessionsForSite( siteId: string ): AcpSession[] { + return Array.from( this.processes.values() ) + .filter( ( p ) => p.siteId === siteId ) + .map( ( p ) => p.session ); + } + + /** + * Check if a session is active. + */ + isSessionActive( sessionId: string ): boolean { + const session = this.getSession( sessionId ); + return session?.state === 'ready'; + } +} + +// Singleton instance +let processManagerInstance: AcpProcessManager | null = null; + +/** + * Get the singleton ACP process manager instance. + */ +export function getAcpProcessManager(): AcpProcessManager { + if ( ! processManagerInstance ) { + processManagerInstance = new AcpProcessManager(); + } + return processManagerInstance; +} diff --git a/src/modules/acp/lib/acp-registry.ts b/src/modules/acp/lib/acp-registry.ts new file mode 100644 index 0000000000..861e9b1910 --- /dev/null +++ b/src/modules/acp/lib/acp-registry.ts @@ -0,0 +1,291 @@ +/** + * ACP Registry Client + * + * Fetches and caches the official ACP agent registry from: + * https://cdn.agentclientprotocol.com/registry/v1/latest/registry.json + */ + +import { app } from 'electron'; +import * as fs from 'fs/promises'; +import * as path from 'path'; + +const REGISTRY_URL = 'https://cdn.agentclientprotocol.com/registry/v1/latest/registry.json'; +const CACHE_TTL_MS = 24 * 60 * 60 * 1000; // 24 hours + +/** + * Distribution types for ACP agents. + */ +export interface NpxDistribution { + package: string; + args?: string[]; + env?: Record< string, string >; +} + +export interface BinaryTarget { + archive: string; + cmd: string; + args?: string[]; + env?: Record< string, string >; +} + +export interface BinaryDistribution { + 'darwin-aarch64'?: BinaryTarget; + 'darwin-arm64'?: BinaryTarget; + 'darwin-x86_64'?: BinaryTarget; + 'darwin-x64'?: BinaryTarget; + 'linux-aarch64'?: BinaryTarget; + 'linux-arm64'?: BinaryTarget; + 'linux-x86_64'?: BinaryTarget; + 'linux-x64'?: BinaryTarget; + 'windows-aarch64'?: BinaryTarget; + 'windows-arm64'?: BinaryTarget; + 'windows-x86_64'?: BinaryTarget; + 'windows-x64'?: BinaryTarget; +} + +export interface AgentDistribution { + npx?: NpxDistribution; + binary?: BinaryDistribution; +} + +/** + * Agent entry from the ACP registry. + */ +export interface RegistryAgent { + id: string; + name: string; + version: string; + description: string; + repository?: string; + authors: string[]; + license: string; + icon?: string; + distribution: AgentDistribution; +} + +/** + * The full ACP registry structure. + */ +export interface AcpRegistry { + version: string; + agents: RegistryAgent[]; + extensions: unknown[]; +} + +/** + * Cached registry with timestamp. + */ +interface CachedRegistry { + fetchedAt: number; + registry: AcpRegistry; +} + +// In-memory cache +let registryCache: CachedRegistry | null = null; + +/** + * Get the cache file path. + */ +function getCacheFilePath(): string { + const userDataPath = app.getPath( 'userData' ); + return path.join( userDataPath, 'acp-registry-cache.json' ); +} + +/** + * Load registry from disk cache. + */ +async function loadFromDiskCache(): Promise< CachedRegistry | null > { + try { + const cacheFile = getCacheFilePath(); + const data = await fs.readFile( cacheFile, 'utf-8' ); + return JSON.parse( data ) as CachedRegistry; + } catch { + return null; + } +} + +/** + * Save registry to disk cache. + */ +async function saveToDiskCache( cached: CachedRegistry ): Promise< void > { + try { + const cacheFile = getCacheFilePath(); + await fs.writeFile( cacheFile, JSON.stringify( cached, null, 2 ) ); + } catch ( error ) { + console.warn( 'Failed to save ACP registry cache:', error ); + } +} + +/** + * Fetch the registry from the CDN. + */ +async function fetchRegistry(): Promise< AcpRegistry > { + const response = await fetch( REGISTRY_URL, { + headers: { + Accept: 'application/json', + 'User-Agent': 'WordPress-Studio/1.0', + }, + } ); + + if ( ! response.ok ) { + throw new Error( + `Failed to fetch ACP registry: ${ response.status } ${ response.statusText }` + ); + } + + return ( await response.json() ) as AcpRegistry; +} + +/** + * Get the ACP registry, using cache if available and fresh. + */ +export async function getAcpRegistry(): Promise< AcpRegistry > { + const now = Date.now(); + + // Check in-memory cache first + if ( registryCache && now - registryCache.fetchedAt < CACHE_TTL_MS ) { + return registryCache.registry; + } + + // Check disk cache + const diskCache = await loadFromDiskCache(); + if ( diskCache && now - diskCache.fetchedAt < CACHE_TTL_MS ) { + registryCache = diskCache; + return diskCache.registry; + } + + // Fetch fresh registry + try { + const registry = await fetchRegistry(); + const cached: CachedRegistry = { + fetchedAt: now, + registry, + }; + + registryCache = cached; + await saveToDiskCache( cached ); + + return registry; + } catch ( error ) { + // If fetch fails but we have stale cache, use it + if ( diskCache ) { + console.warn( 'Using stale ACP registry cache due to fetch error:', error ); + registryCache = diskCache; + return diskCache.registry; + } + + throw error; + } +} + +/** + * Get the current platform key for binary distributions. + */ +export function getPlatformKey(): keyof BinaryDistribution | null { + const arch = process.arch === 'arm64' ? 'aarch64' : 'x86_64'; + + switch ( process.platform ) { + case 'darwin': + return `darwin-${ arch }` as keyof BinaryDistribution; + case 'linux': + return `linux-${ arch }` as keyof BinaryDistribution; + case 'win32': + return `windows-${ arch }` as keyof BinaryDistribution; + default: + return null; + } +} + +function getPlatformKeys(): Array< keyof BinaryDistribution > { + const keys: Array< keyof BinaryDistribution > = []; + const isArm = process.arch === 'arm64'; + + if ( process.platform === 'darwin' ) { + keys.push( isArm ? 'darwin-aarch64' : 'darwin-x86_64' ); + keys.push( isArm ? 'darwin-arm64' : 'darwin-x64' ); + } + + if ( process.platform === 'linux' ) { + keys.push( isArm ? 'linux-aarch64' : 'linux-x86_64' ); + keys.push( isArm ? 'linux-arm64' : 'linux-x64' ); + } + + if ( process.platform === 'win32' ) { + keys.push( isArm ? 'windows-aarch64' : 'windows-x86_64' ); + keys.push( isArm ? 'windows-arm64' : 'windows-x64' ); + } + + return keys; +} + +/** + * Get the command and args to run an agent. + * + * Returns null if the agent can't be run on this platform. + */ +export function getAgentCommand( + agent: RegistryAgent +): { command: string; args: string[]; env?: Record< string, string > } | null { + const { distribution } = agent; + + // Prefer npx distribution (more portable) + if ( distribution.npx ) { + return { + command: 'npx', + // Use --yes to auto-accept package installation (avoids "Ok to proceed?" prompt) + // Use --quiet to suppress npx's own output (spinners, install messages) + args: [ '--yes', '--quiet', distribution.npx.package, ...( distribution.npx.args ?? [] ) ], + env: distribution.npx.env, + }; + } + + // Fall back to binary distribution + if ( distribution.binary ) { + const platformKeys = getPlatformKeys(); + if ( platformKeys.length === 0 ) { + return null; + } + + const target = platformKeys + .map( ( key ) => distribution.binary?.[ key ] ) + .find( Boolean ); + if ( ! target ) { + return null; + } + + // For now, assume the binary is installed and in PATH + // TODO: Implement binary download and management + const binaryName = path.basename( target.cmd ).replace( /^\.\//, '' ); + + return { + command: binaryName, + args: target.args ?? [], + env: target.env, + }; + } + + return null; +} + +/** + * Force refresh the registry cache. + */ +export async function refreshRegistry(): Promise< AcpRegistry > { + registryCache = null; + + try { + await fs.unlink( getCacheFilePath() ); + } catch { + // Ignore if file doesn't exist + } + + return getAcpRegistry(); +} + +/** + * Get a specific agent by ID from the registry. + */ +export async function getRegistryAgent( agentId: string ): Promise< RegistryAgent | null > { + const registry = await getAcpRegistry(); + return registry.agents.find( ( a ) => a.id === agentId ) ?? null; +} diff --git a/src/modules/acp/lib/agent-detection.ts b/src/modules/acp/lib/agent-detection.ts new file mode 100644 index 0000000000..10de7621f3 --- /dev/null +++ b/src/modules/acp/lib/agent-detection.ts @@ -0,0 +1,347 @@ +/** + * Agent Detection + * + * Detects available ACP agents using the official registry and checking system PATH. + */ + +import { exec } from 'child_process'; +import fs from 'fs'; +import nodePath from 'path'; +import { promisify } from 'util'; +import { BUILTIN_AGENTS } from '../config/agents'; +import { getAcpRegistry, getAgentCommand, type RegistryAgent } from './acp-registry'; +import type { AgentConfig, AgentStatus } from '../types'; + +const execAsync = promisify( exec ); + +function getNvmBinPaths( home: string ): string[] { + const paths: string[] = []; + const nvmDir = process.env.NVM_DIR ?? nodePath.join( home, '.nvm' ); + const versionsDir = nodePath.join( nvmDir, 'versions', 'node' ); + + if ( process.env.NVM_BIN ) { + paths.push( process.env.NVM_BIN ); + } + + try { + if ( fs.existsSync( versionsDir ) ) { + const entries = fs.readdirSync( versionsDir, { withFileTypes: true } ); + for ( const entry of entries ) { + if ( ! entry.isDirectory() ) { + continue; + } + paths.push( nodePath.join( versionsDir, entry.name, 'bin' ) ); + } + } + } catch { + // Ignore filesystem errors and fall back to default paths + } + + return paths; +} + +function getSupplementalPaths( home: string ): string[] { + const pnpmHome = process.env.PNPM_HOME ?? nodePath.join( home, '.local', 'share', 'pnpm' ); + return [ + nodePath.join( home, '.asdf', 'bin' ), + nodePath.join( home, '.asdf', 'shims' ), + pnpmHome, + nodePath.join( home, '.pnpm' ), + nodePath.join( home, '.bun', 'bin' ), + nodePath.join( home, '.deno', 'bin' ), + ]; +} + +/** + * Get the augmented PATH for agent detection. + * On macOS/Linux, GUI apps don't inherit shell PATH, so we add common paths. + */ +function getAugmentedPath(): string { + const basePath = process.env.PATH ?? ''; + const home = process.env.HOME ?? ''; + + if ( process.platform === 'darwin' ) { + const additionalPaths = [ + '/usr/local/bin', + '/opt/homebrew/bin', + '/opt/local/bin', + `${ home }/.local/bin`, + `${ home }/.npm-global/bin`, + `${ home }/.cargo/bin`, + `${ home }/go/bin`, + // Common Node.js version managers + `${ home }/.volta/bin`, + `${ home }/.fnm/current/bin`, + ...getNvmBinPaths( home ), + ...getSupplementalPaths( home ), + ]; + return `${ additionalPaths.join( ':' ) }:${ basePath }`; + } + + if ( process.platform === 'linux' ) { + const additionalPaths = [ + '/usr/local/bin', + `${ home }/.local/bin`, + `${ home }/.npm-global/bin`, + `${ home }/.cargo/bin`, + `${ home }/go/bin`, + // Common Node.js version managers + `${ home }/.volta/bin`, + `${ home }/.fnm/current/bin`, + ...getNvmBinPaths( home ), + ...getSupplementalPaths( home ), + ]; + return `${ additionalPaths.join( ':' ) }:${ basePath }`; + } + + return basePath; +} + +/** + * Check if a command exists in the system PATH. + */ +async function commandExists( command: string ): Promise< boolean > { + try { + const checkCommand = process.platform === 'win32' ? `where ${ command }` : `which ${ command }`; + + await execAsync( checkCommand, { + timeout: 5000, + env: { + ...process.env, + PATH: getAugmentedPath(), + }, + } ); + + return true; + } catch { + return false; + } +} + +// Cache npx availability check +let npxAvailable: boolean | null = null; + +/** + * Check if npx is available (for agents distributed via npm packages). + */ +async function isNpxAvailable(): Promise< boolean > { + if ( npxAvailable !== null ) { + return npxAvailable; + } + npxAvailable = await commandExists( 'npx' ); + return npxAvailable; +} + +/** + * Convert a registry agent to our AgentConfig format. + */ +function registryAgentToConfig( + agent: RegistryAgent, + options: { + isInstalled: boolean; + isAvailable: boolean; + commandInfoOverride?: { command: string; args: string[]; env?: Record< string, string > } | null; + } +): AgentConfig { + const commandInfo = options.commandInfoOverride ?? getAgentCommand( agent ); + + return { + id: agent.id, + name: agent.name, + description: agent.description, + provider: 'acp', + icon: agent.icon, + command: commandInfo?.command, + args: commandInfo?.args, + env: commandInfo?.env, + isInstalled: options.isInstalled, + status: options.isAvailable ? 'available' : 'unavailable', + // Note: codex-acp is the ACP wrapper and uses standard stdio, not TTY. + // Only the interactive codex CLI needs TTY, but we don't use that directly. + requiresTty: false, + }; +} + +/** + * Alternative binary commands for agents (by agent ID). + * These are checked first before falling back to npx. + * + * Note: Do NOT include standard CLI tools as alternatives for ACP adapters. + * For example, `codex` (OpenAI CLI) does NOT speak ACP - you need `codex-acp` + * or `npx @zed-industries/codex-acp`. Same for `claude` vs `claude-code-acp`. + */ +const ALTERNATIVE_BINARIES: Record< string, string[] > = { + 'factory-droid': [ 'factory-droid', 'droid' ], + 'codex-acp': [ 'codex-acp' ], + 'claude-code-acp': [ 'claude-code-acp' ], + opencode: [ 'opencode' ], +}; + +/** + * NPX package fallbacks for agents where the registry only lists binary distribution + * but an npm package also exists. + */ +const NPX_PACKAGE_FALLBACKS: Record< string, string > = { + 'codex-acp': '@zed-industries/codex-acp', +}; + +/** + * Detect if a registry agent is available on this system. + */ +async function detectRegistryAgent( agent: RegistryAgent ): Promise< AgentConfig > { + const commandInfo = getAgentCommand( agent ); + const npxFallback = NPX_PACKAGE_FALLBACKS[ agent.id ]; + + // If no distribution and no npx fallback, agent is unavailable + if ( ! commandInfo && ! npxFallback ) { + return registryAgentToConfig( agent, { isInstalled: false, isAvailable: false } ); + } + + let isInstalled = false; + let isAvailable = false; + let resolvedCommandInfo = commandInfo ?? { command: 'npx', args: [ '--yes', '--quiet', npxFallback! ], env: {} }; + + // First, check if there's a locally installed binary (preferred over npx) + const alternativeBinaries = ALTERNATIVE_BINARIES[ agent.id ] ?? []; + for ( const cmd of alternativeBinaries ) { + if ( await commandExists( cmd ) ) { + isInstalled = true; + isAvailable = true; + // Use the local binary instead of npx + resolvedCommandInfo = { + command: cmd, + args: [], + env: commandInfo?.env, + }; + break; + } + } + + // If no local binary found, check npx fallback + if ( ! isInstalled ) { + const isNpxBased = resolvedCommandInfo.command === 'npx'; + if ( isNpxBased ) { + isAvailable = await isNpxAvailable(); + } else { + // Binary-based agent - check if binary exists + if ( await commandExists( resolvedCommandInfo.command ) ) { + isInstalled = true; + isAvailable = true; + } else if ( npxFallback ) { + // Binary not found, try npx fallback + if ( await isNpxAvailable() ) { + isAvailable = true; + resolvedCommandInfo = { + command: 'npx', + args: [ '--yes', '--quiet', npxFallback ], + env: commandInfo?.env, + }; + } + } + } + } + + return registryAgentToConfig( agent, { + isInstalled, + isAvailable, + commandInfoOverride: resolvedCommandInfo, + } ); +} + +// Temporary filter: only show these agents (comment out others for now) +const ENABLED_AGENT_IDS = [ + 'wpcom', // WordPress AI + 'anthropic-builtin', // Built-in Assistant + 'claude-code-acp', // Claude Code + 'opencode', // OpenCode + 'codex-acp', // Codex +]; + +/** + * Detect all available ACP agents. + * + * Combines built-in agents with agents from the ACP registry. + */ +export async function detectInstalledAgents(): Promise< AgentConfig[] > { + // Start with built-in agents (always available) + // Filter to only enabled agents + const agents: AgentConfig[] = BUILTIN_AGENTS.filter( ( agent ) => + ENABLED_AGENT_IDS.includes( agent.id ) + ); + + try { + // Fetch registry and detect each agent + const registry = await getAcpRegistry(); + // Filter to only enabled ACP agents + const enabledRegistryAgents = registry.agents.filter( ( agent ) => + ENABLED_AGENT_IDS.includes( agent.id ) + ); + const detectionPromises = enabledRegistryAgents.map( detectRegistryAgent ); + const detectedAgents = await Promise.all( detectionPromises ); + + agents.push( ...detectedAgents ); + } catch ( error ) { + console.error( 'Failed to fetch ACP registry:', error ); + // Continue with just built-in agents if registry fetch fails + } + + return agents; +} + +/** + * Detect a specific agent by ID. + */ +export async function detectAgentById( agentId: string ): Promise< AgentConfig | null > { + // Check built-in agents first + const builtinAgent = BUILTIN_AGENTS.find( ( a ) => a.id === agentId ); + if ( builtinAgent ) { + return { + ...builtinAgent, + isInstalled: true, + status: 'available', + }; + } + + // Check registry + try { + const registry = await getAcpRegistry(); + const registryAgent = registry.agents.find( ( a ) => a.id === agentId ); + + if ( registryAgent ) { + return detectRegistryAgent( registryAgent ); + } + } catch ( error ) { + console.error( 'Failed to detect agent from registry:', error ); + } + + return null; +} + +/** + * Get the status of all agents. + */ +export async function getAgentStatusDict(): Promise< Record< string, AgentStatus > > { + const agents = await detectInstalledAgents(); + + const statusDict: Record< string, AgentStatus > = {}; + + for ( const agent of agents ) { + statusDict[ agent.id ] = agent.status ?? 'unavailable'; + } + + return statusDict; +} + +/** + * Refresh agent detection. + */ +export async function refreshAgentDetection(): Promise< AgentConfig[] > { + // Reset npx cache + npxAvailable = null; + return detectInstalledAgents(); +} + +/** + * Export the augmented PATH for use by the process manager. + */ +export { getAugmentedPath }; diff --git a/src/modules/acp/lib/ipc-handlers.ts b/src/modules/acp/lib/ipc-handlers.ts new file mode 100644 index 0000000000..13805dbba9 --- /dev/null +++ b/src/modules/acp/lib/ipc-handlers.ts @@ -0,0 +1,374 @@ +/** + * ACP IPC Handlers + * + * IPC handlers for ACP operations in the main process. + */ + +import { BrowserWindow, type IpcMainInvokeEvent } from 'electron'; +import type { SessionNotification } from '@agentclientprotocol/sdk'; +import { sendIpcEventToRendererWithWindow } from 'src/ipc-utils'; +import { SiteServer } from 'src/site-server'; +import { loadUserData, updateAppdata } from 'src/storage/user-data'; +import { DEFAULT_AGENT_ID } from '../config/agents'; +import { createFullAccessCallbacksHandler } from './acp-callbacks'; +import { getAcpProcessManager } from './acp-process-manager'; +import { detectInstalledAgents, detectAgentById } from './agent-detection'; +import type { AgentConfig, AcpSession } from '../types'; + +/** + * Store for session-specific event handlers so they can be cleaned up. + */ +interface SessionHandlers { + updateHandler: ( sessionId: string, notification: SessionNotification ) => void; + errorHandler: ( sessionId: string, error: Error ) => void; + closedHandler: ( sessionId: string, code: number | null, signal: string | null ) => void; +} +const sessionHandlers = new Map< string, SessionHandlers >(); + +/** + * Transform SDK SessionNotification to our expected format for the renderer. + * + * SDK uses flat structures: + * - agent_message_chunk: { content: ContentBlock, sessionUpdate: "agent_message_chunk" } + * - agent_thought_chunk: { content: ContentBlock, sessionUpdate: "agent_thought_chunk" } + * - tool_call: { toolCallId, title, rawInput, ..., sessionUpdate: "tool_call" } + * - tool_call_update: { toolCallId, rawOutput, content, status, ..., sessionUpdate: "tool_call_update" } + */ +function transformSessionUpdate( sessionId: string, notification: SessionNotification ): { + sessionId: string; + type: string; + text?: string; + tool_use?: { id: string; name: string; input: Record< string, unknown > }; + tool_result?: { tool_use_id: string; output?: string; error?: string }; + thinking?: string; + done?: boolean; +} | null { + const update = notification.update; + + // Handle different update types from the SDK + switch ( update.sessionUpdate ) { + case 'agent_message_chunk': { + // Text content from agent - SDK has { content: ContentBlock } + const content = update.content; + if ( content?.type === 'text' ) { + const textContent = content as { type: 'text'; text: string }; + return { + sessionId, + type: 'text', + text: textContent.text, + }; + } + return null; + } + + case 'agent_thought_chunk': { + // Thinking/reasoning content - same structure as agent_message_chunk + const content = update.content; + if ( content?.type === 'text' ) { + const textContent = content as { type: 'text'; text: string }; + return { + sessionId, + type: 'thinking', + thinking: textContent.text, + }; + } + return null; + } + + case 'tool_call': { + // Tool call from agent - SDK has flat structure + // update IS the ToolCall (with toolCallId, title, rawInput, etc.) + return { + sessionId, + type: 'tool_use', + tool_use: { + id: update.toolCallId, + name: update.title, + input: ( update.rawInput ?? {} ) as Record< string, unknown >, + }, + }; + } + + case 'tool_call_update': { + // Tool result - SDK has flat structure + // update IS the ToolCallUpdate (with toolCallId, rawOutput, content, status, etc.) + // Extract text output from rawOutput or content + let output = ''; + + if ( update.rawOutput !== undefined ) { + output = typeof update.rawOutput === 'string' + ? update.rawOutput + : JSON.stringify( update.rawOutput ); + } else if ( Array.isArray( update.content ) ) { + // Extract text from content array + output = update.content + .map( ( c ) => { + if ( c.type === 'content' && c.content ) { + const contentBlocks = Array.isArray( c.content ) ? c.content : [ c.content ]; + return contentBlocks + .filter( ( block ): block is { type: 'text'; text: string } => + typeof block === 'object' && block !== null && 'type' in block && block.type === 'text' + ) + .map( ( block ) => block.text ) + .join( '\n' ); + } + return ''; + } ) + .filter( Boolean ) + .join( '\n' ); + } + + // Only emit tool_result if we have content or a completed/failed status + if ( output || update.status === 'completed' || update.status === 'failed' ) { + return { + sessionId, + type: 'tool_result', + tool_result: { + tool_use_id: update.toolCallId, + output: output || '(completed)', + error: update.status === 'failed' ? output : undefined, + }, + }; + } + return null; + } + + case 'plan': + case 'available_commands_update': + case 'current_mode_update': + case 'config_option_update': + case 'session_info_update': + // These don't need to be forwarded to the UI for now + return null; + + default: + console.log( `ACP: Unhandled session update type: ${ ( update as { sessionUpdate: string } ).sessionUpdate }` ); + return null; + } +} + +/** + * Get the list of available agents (built-in + detected ACP agents). + */ +export async function getAvailableAgents( _event: IpcMainInvokeEvent ): Promise< AgentConfig[] > { + return detectInstalledAgents(); +} + +/** + * Detect installed agents and return the list. + */ +export async function detectAgents( _event: IpcMainInvokeEvent ): Promise< AgentConfig[] > { + return detectInstalledAgents(); +} + +/** + * Get the currently selected agent ID. + */ +export async function getSelectedAgentId( _event: IpcMainInvokeEvent ): Promise< string > { + const userData = await loadUserData(); + return userData.selectedAgentId ?? DEFAULT_AGENT_ID; +} + +/** + * Set the selected agent ID. + */ +export async function setSelectedAgentId( + _event: IpcMainInvokeEvent, + agentId: string +): Promise< void > { + // Verify the agent exists + const agent = await detectAgentById( agentId ); + if ( ! agent ) { + throw new Error( `Unknown agent: ${ agentId }` ); + } + + await updateAppdata( { selectedAgentId: agentId } ); +} + +/** + * Clean up event handlers for a session. + */ +function cleanupSessionHandlers( sessionId: string ): void { + const handlers = sessionHandlers.get( sessionId ); + if ( handlers ) { + const processManager = getAcpProcessManager(); + processManager.off( 'session_update', handlers.updateHandler ); + processManager.off( 'session_error', handlers.errorHandler ); + processManager.off( 'session_closed', handlers.closedHandler ); + sessionHandlers.delete( sessionId ); + } +} + +/** + * Create a new ACP session for a site. + */ +export async function createAcpSession( + event: IpcMainInvokeEvent, + agentId: string, + siteId: string +): Promise< AcpSession > { + // Get the site's working directory + const site = SiteServer.get( siteId ); + if ( ! site ) { + throw new Error( `Site not found: ${ siteId }` ); + } + + const workingDirectory = site.details.path; + const processManager = getAcpProcessManager(); + + // Create callback handler for this site + const callbackHandler = createFullAccessCallbacksHandler( workingDirectory ); + + // Set up event forwarding to renderer + const parentWindow = BrowserWindow.fromWebContents( event.sender ); + + // Create the session first to get the session ID + const session = await processManager.createSession( + agentId, + siteId, + workingDirectory, + callbackHandler + ); + + const createdSessionId = session.id; + + // Create session-specific handlers that only process events for this session + const sessionUpdateHandler = ( eventSessionId: string, notification: SessionNotification ) => { + // Only handle events for this specific session + if ( eventSessionId !== createdSessionId ) { + return; + } + const transformed = transformSessionUpdate( eventSessionId, notification ); + if ( transformed ) { + sendIpcEventToRendererWithWindow( parentWindow, 'acp-session-update', transformed ); + } + }; + + const sessionErrorHandler = ( eventSessionId: string, error: Error ) => { + // Only handle events for this specific session + if ( eventSessionId !== createdSessionId ) { + return; + } + sendIpcEventToRendererWithWindow( parentWindow, 'acp-session-error', { + sessionId: eventSessionId, + error: error.message, + } ); + }; + + const sessionClosedHandler = ( + eventSessionId: string, + code: number | null, + signal: string | null + ) => { + // Only handle events for this specific session + if ( eventSessionId !== createdSessionId ) { + return; + } + sendIpcEventToRendererWithWindow( parentWindow, 'acp-session-closed', { + sessionId: eventSessionId, + code, + signal, + } ); + // Clean up handlers when session closes + cleanupSessionHandlers( createdSessionId ); + }; + + // Store handlers for cleanup + sessionHandlers.set( createdSessionId, { + updateHandler: sessionUpdateHandler, + errorHandler: sessionErrorHandler, + closedHandler: sessionClosedHandler, + } ); + + // Add event listeners + processManager.on( 'session_update', sessionUpdateHandler ); + processManager.on( 'session_error', sessionErrorHandler ); + processManager.on( 'session_closed', sessionClosedHandler ); + + return session; +} + +/** + * Send a prompt to an ACP session. + */ +export async function sendAcpPrompt( + _event: IpcMainInvokeEvent, + sessionId: string, + prompt: string +): Promise< { stopReason: string } > { + const processManager = getAcpProcessManager(); + + const result = await processManager.sendPrompt( sessionId, prompt ); + + return { stopReason: result.stopReason }; +} + +/** + * Send an approval response to an ACP session. + * Note: With the official SDK, approvals are handled via the Client.requestPermission callback. + * This handler is kept for API compatibility but may not be needed. + */ +export async function sendAcpApproval( + _event: IpcMainInvokeEvent, + _sessionId: string, + _approvalId: string, + _approved: boolean, + _selectedOption?: string +): Promise< void > { + // Approvals are now handled through the SDK's requestPermission callback + // in the Client interface, not as a separate IPC call. + console.warn( 'sendAcpApproval called but approvals are handled via SDK callbacks' ); +} + +/** + * Close an ACP session. + */ +export async function closeAcpSession( + _event: IpcMainInvokeEvent, + sessionId: string +): Promise< void > { + const processManager = getAcpProcessManager(); + + // Clean up event handlers first + cleanupSessionHandlers( sessionId ); + + await processManager.closeSession( sessionId ); +} + +/** + * Get an ACP session by ID. + */ +export async function getAcpSession( + _event: IpcMainInvokeEvent, + sessionId: string +): Promise< AcpSession | null > { + const processManager = getAcpProcessManager(); + + return processManager.getSession( sessionId ) ?? null; +} + +/** + * Get all active ACP sessions for a site. + */ +export async function getAcpSessionsForSite( + _event: IpcMainInvokeEvent, + siteId: string +): Promise< AcpSession[] > { + const processManager = getAcpProcessManager(); + + return processManager.getSessionsForSite( siteId ); +} + +/** + * Close all ACP sessions. + */ +export async function closeAllAcpSessions( _event: IpcMainInvokeEvent ): Promise< void > { + const processManager = getAcpProcessManager(); + + // Clean up all event handlers + for ( const sessionId of sessionHandlers.keys() ) { + cleanupSessionHandlers( sessionId ); + } + + await processManager.closeAllSessions(); +} From 2f61e39cbed702ead08814bd1de2366f8e65395b Mon Sep 17 00:00:00 2001 From: James LePage <36246732+Jameswlepage@users.noreply.github.com> Date: Fri, 30 Jan 2026 21:45:02 -0800 Subject: [PATCH 02/20] Update Opencode agent icon with actual logo design --- src/modules/acp/config/agents.ts | 159 +++++++++++++++++++++++++++++++ 1 file changed, 159 insertions(+) create mode 100644 src/modules/acp/config/agents.ts diff --git a/src/modules/acp/config/agents.ts b/src/modules/acp/config/agents.ts new file mode 100644 index 0000000000..90be2afa9c --- /dev/null +++ b/src/modules/acp/config/agents.ts @@ -0,0 +1,159 @@ +/** + * Agent Configuration + * + * Defines all available agents including built-in and ACP-based agents. + */ + +import type { AgentConfig } from '../types'; + +// ============================================================================ +// Agent Icons (SVG strings) +// ============================================================================ + +export const AGENT_ICONS = { + // WordPress - Simple Icons + wpcom: ``, + + // Anthropic - Simple Icons + anthropic: ``, + + // Claude Code - Same as Anthropic with terminal accent + claudeCode: ``, + + // OpenAI/Codex - Simple Icons + codex: ``, + + // Google Gemini - Simple Icons + gemini: ``, + + // GitHub Copilot - Simple Icons (GitHub logo) + copilot: ``, + + // OpenCode - Custom logo + opencode: ``, + + // Goose - Custom (Block's goose agent) + goose: ``, + + // Generic fallback + generic: ``, +}; + +// ============================================================================ +// Built-in Agents +// ============================================================================ + +export const BUILTIN_AGENTS: AgentConfig[] = [ + { + id: 'wpcom', + name: 'WordPress AI', + description: 'AI-powered WordPress assistant with WordPress.com integration', + provider: 'wpcom', + icon: AGENT_ICONS.wpcom, + isInstalled: true, + status: 'available', + }, + { + id: 'anthropic-builtin', + name: 'Built-in Assistant (Anthropic)', + description: 'Local AI assistant powered by Claude with WP-CLI access', + provider: 'anthropic-builtin', + icon: AGENT_ICONS.anthropic, + apiKeyEnvVar: 'ANTHROPIC_API_KEY', + isInstalled: true, + status: 'available', + }, +]; + +// ============================================================================ +// ACP Agent Definitions +// ============================================================================ + +// ACP agents are now loaded dynamically from the official registry: +// https://cdn.agentclientprotocol.com/registry/v1/latest/registry.json +// See: src/modules/acp/lib/acp-registry.ts + +// ============================================================================ +// Default Agent +// ============================================================================ + +export const DEFAULT_AGENT_ID = 'wpcom'; + +// ============================================================================ +// Utility Functions +// ============================================================================ + +/** + * Get all built-in agents (always available). + */ +export function getBuiltinAgents(): AgentConfig[] { + return BUILTIN_AGENTS; +} + +/** + * Get a built-in agent config by ID. + * For ACP agents, use detectAgentById from agent-detection.ts + */ +export function getBuiltinAgentById( id: string ): AgentConfig | undefined { + return BUILTIN_AGENTS.find( ( agent ) => agent.id === id ); +} + +/** + * Get the icon for an agent (with fallback to generic icon). + * Prefers icons from the registry if available, falls back to local icons. + */ +export function getAgentIcon( agentId: string ): string { + // Check built-in agents first + const builtinAgent = getBuiltinAgentById( agentId ); + if ( builtinAgent?.icon ) { + return builtinAgent.icon; + } + + // Return generic fallback + return AGENT_ICONS.generic; +} From 5d6bb25e812a037d20fbcafd772e32139e2cf2fb Mon Sep 17 00:00:00 2001 From: James LePage <36246732+Jameswlepage@users.noreply.github.com> Date: Fri, 30 Jan 2026 21:45:04 -0800 Subject: [PATCH 03/20] Update AI Settings Instructions UI - Change Install All button from primary to link variant - Simplify description text to 'Install instructions so agents know how to use Studio' --- src/components/ai-settings-modal.tsx | 410 +++++++++++++++++++++++++++ 1 file changed, 410 insertions(+) create mode 100644 src/components/ai-settings-modal.tsx diff --git a/src/components/ai-settings-modal.tsx b/src/components/ai-settings-modal.tsx new file mode 100644 index 0000000000..2572cfe327 --- /dev/null +++ b/src/components/ai-settings-modal.tsx @@ -0,0 +1,410 @@ +import { Spinner, TabPanel } from '@wordpress/components'; +import { Icon, check } from '@wordpress/icons'; +import { useI18n } from '@wordpress/react-i18n'; +import { useCallback, useEffect, useMemo, useState } from 'react'; +import Button from 'src/components/button'; +import Modal from 'src/components/modal'; +import { AgentIcon } from 'src/components/agent-selector/agent-icon'; +import { ModelSelector } from 'src/components/model-selector'; +import { cx } from 'src/lib/cx'; +import { getIpcApi } from 'src/lib/get-ipc-api'; +import { SkillsPanel } from 'src/modules/agent-skills'; +import { + DEFAULT_AGENT_INSTRUCTIONS, + INSTRUCTION_FILE_TYPES, + INSTRUCTION_FILES, + type InstructionFileType, +} from 'src/modules/agent-instructions/constants'; +import { agentChatSelectors, agentChatThunks } from 'src/stores/agent-chat-slice'; +import { useAppDispatch, useRootSelector } from 'src/stores'; +import type { AgentConfig } from 'src/modules/acp/types'; +import { useAgentChat } from 'src/hooks/use-agent-chat'; + +interface AiSettingsModalProps { + isOpen: boolean; + onClose: () => void; + siteId: string; +} + +function getAgentStatusLabel( agent: AgentConfig, __: ( text: string ) => string ): string | null { + if ( agent.provider === 'wpcom' ) { + return __( 'Cloud' ); + } + + if ( agent.provider === 'anthropic-builtin' ) { + return __( 'Built-in' ); + } + + if ( agent.status === 'unavailable' || agent.status === 'error' ) { + return __( 'Not available' ); + } + + if ( agent.isInstalled === false ) { + return agent.command === 'npx' ? __( 'Runs via npx' ) : __( 'Not installed' ); + } + + return __( 'Installed' ); +} + +function AgentSettingsPanel( { siteId }: { siteId: string } ) { + const { __ } = useI18n(); + const dispatch = useAppDispatch(); + const availableAgents = useRootSelector( agentChatSelectors.selectAvailableAgents ); + const selectedAgentId = useRootSelector( agentChatSelectors.selectSelectedAgentId ); + const selectedAgent = useRootSelector( agentChatSelectors.selectSelectedAgent ); + const isLoadingAgents = useRootSelector( agentChatSelectors.selectIsLoadingAgents ); + + // Get model info from the agent chat hook + const { availableModels, currentModelId, setModel } = useAgentChat( { siteId } ); + + useEffect( () => { + if ( availableAgents.length === 0 ) { + void dispatch( agentChatThunks.loadAvailableAgents() ); + } + }, [ dispatch, availableAgents.length ] ); + + const handleRefresh = useCallback( () => { + void dispatch( agentChatThunks.loadAvailableAgents() ); + }, [ dispatch ] ); + + const handleSelectAgent = useCallback( + ( agentId: string ) => { + void dispatch( agentChatThunks.selectAgent( agentId ) ); + }, + [ dispatch ] + ); + + const groupedAgents = useMemo( + () => [ + { title: __( 'Chat' ), agents: availableAgents.filter( ( a ) => a.provider === 'wpcom' ) }, + { + title: __( 'Built-in' ), + agents: availableAgents.filter( ( a ) => a.provider === 'anthropic-builtin' ), + }, + { title: __( 'Agents' ), agents: availableAgents.filter( ( a ) => a.provider === 'acp' ) }, + ], + [ availableAgents, __ ] + ); + + const isAgentAvailable = ( agent: AgentConfig ) => + agent.status !== 'unavailable' && agent.status !== 'error'; + + return ( +
+ { __( 'Pick which agent handles AI conversations.' ) } +
++ { __( 'Install instructions so agents know how to use Studio' ) } +
+
+ { DEFAULT_AGENT_INSTRUCTIONS }
+
+ + { __( + 'Skills provide specialized capabilities for the AI assistant. Install skills to help the assistant with specific tasks.' + ) } +
+ + { /* Error display */ } + { ( error || installError ) && ( +{ __( 'No skills available in the repository.' ) }
++ { __( + 'The WordPress/agent-skills repository may not exist yet or does not contain a skills/ directory.' + ) } +
+ +{ __( 'Could not load skills from the repository.' ) }
+ ++ { __( 'Skills from ' ) } + + WordPress/agent-skills + +
+ +{ skill.description }
+ + { /* Optional capabilities */ } +{ description }
++ { __( 'Enhance the AI assistant with specialized capabilities' ) } +
++ { __( 'Skills from ' ) } + + WordPress/agent-skills + +
+