UNPKG

@earendil-works/pi-coding-agent

Version:

Coding agent CLI with read, bash, edit, write tools and session management

481 lines 16.3 kB
/** * RPC Client for programmatic access to the coding agent. * * Spawns the agent in RPC mode and provides a typed API for all operations. */ import { spawn } from "node:child_process"; import { attachJsonlLineReader, serializeJsonLine } from "./jsonl.js"; // ============================================================================ // RPC Client // ============================================================================ export class RpcClient { process = null; stopReadingStdout = null; eventListeners = []; pendingRequests = new Map(); requestId = 0; stderr = ""; exitError = null; options; constructor(options = {}) { this.options = options; } /** * Start the RPC agent process. */ async start() { if (this.process) { throw new Error("Client already started"); } this.exitError = null; const cliPath = this.options.cliPath ?? "dist/cli.js"; const args = ["--mode", "rpc"]; if (this.options.provider) { args.push("--provider", this.options.provider); } if (this.options.model) { args.push("--model", this.options.model); } if (this.options.args) { args.push(...this.options.args); } const childProcess = spawn("node", [cliPath, ...args], { cwd: this.options.cwd, env: { ...process.env, ...this.options.env }, stdio: ["pipe", "pipe", "pipe"], }); this.process = childProcess; // Collect stderr for debugging childProcess.stderr?.on("data", (data) => { this.stderr += data.toString(); process.stderr.write(data); }); childProcess.once("exit", (code, signal) => { if (this.process !== childProcess) return; const error = this.createProcessExitError(code, signal); this.exitError = error; this.rejectPendingRequests(error); }); childProcess.once("error", (error) => { if (this.process !== childProcess) return; const processError = new Error(`Agent process error: ${error.message}. Stderr: ${this.stderr}`); this.exitError = processError; this.rejectPendingRequests(processError); }); childProcess.stdin?.on("error", (error) => { if (this.process !== childProcess) return; const stdinError = this.exitError ?? new Error(`Agent process stdin error: ${error.message}. Stderr: ${this.stderr}`); this.exitError = stdinError; this.rejectPendingRequests(stdinError); }); // Set up strict JSONL reader for stdout. this.stopReadingStdout = attachJsonlLineReader(childProcess.stdout, (line) => { this.handleLine(line); }); // Wait a moment for process to initialize await new Promise((resolve) => setTimeout(resolve, 100)); if (this.process.exitCode !== null) { const error = this.exitError ?? this.createProcessExitError(this.process.exitCode, this.process.signalCode); this.exitError = error; throw error; } } /** * Stop the RPC agent process. */ async stop() { if (!this.process) return; this.stopReadingStdout?.(); this.stopReadingStdout = null; this.process.kill("SIGTERM"); // Wait for process to exit await new Promise((resolve) => { const timeout = setTimeout(() => { this.process?.kill("SIGKILL"); resolve(); }, 1000); this.process?.on("exit", () => { clearTimeout(timeout); resolve(); }); }); this.process = null; this.pendingRequests.clear(); } /** * Subscribe to agent events. */ onEvent(listener) { this.eventListeners.push(listener); return () => { const index = this.eventListeners.indexOf(listener); if (index !== -1) { this.eventListeners.splice(index, 1); } }; } /** * Get collected stderr output (useful for debugging). */ getStderr() { return this.stderr; } // ========================================================================= // Command Methods // ========================================================================= /** * Send a prompt to the agent. * Returns immediately after sending; use onEvent() to receive streaming events. * Use waitForIdle() to wait for completion. */ async prompt(message, images) { await this.send({ type: "prompt", message, images }); } /** * Queue a steering message to interrupt the agent mid-run. */ async steer(message, images) { await this.send({ type: "steer", message, images }); } /** * Queue a follow-up message to be processed after the agent finishes. */ async followUp(message, images) { await this.send({ type: "follow_up", message, images }); } /** * Abort current operation. */ async abort() { await this.send({ type: "abort" }); } /** * Start a new session, optionally with parent tracking. * @param parentSession - Optional parent session path for lineage tracking * @returns Object with `cancelled: true` if an extension cancelled the new session */ async newSession(parentSession) { const response = await this.send({ type: "new_session", parentSession }); return this.getData(response); } /** * Get current session state. */ async getState() { const response = await this.send({ type: "get_state" }); return this.getData(response); } /** * Set model by provider and ID. */ async setModel(provider, modelId) { const response = await this.send({ type: "set_model", provider, modelId }); return this.getData(response); } /** * Cycle to next model. */ async cycleModel() { const response = await this.send({ type: "cycle_model" }); return this.getData(response); } /** * Get list of available models. */ async getAvailableModels() { const response = await this.send({ type: "get_available_models" }); return this.getData(response).models; } /** * Set thinking level. */ async setThinkingLevel(level) { await this.send({ type: "set_thinking_level", level }); } /** * Cycle thinking level. */ async cycleThinkingLevel() { const response = await this.send({ type: "cycle_thinking_level" }); return this.getData(response); } /** * Set steering mode. */ async setSteeringMode(mode) { await this.send({ type: "set_steering_mode", mode }); } /** * Set follow-up mode. */ async setFollowUpMode(mode) { await this.send({ type: "set_follow_up_mode", mode }); } /** * Compact session context. */ async compact(customInstructions) { const response = await this.send({ type: "compact", customInstructions }); return this.getData(response); } /** * Set auto-compaction enabled/disabled. */ async setAutoCompaction(enabled) { await this.send({ type: "set_auto_compaction", enabled }); } /** * Set auto-retry enabled/disabled. */ async setAutoRetry(enabled) { await this.send({ type: "set_auto_retry", enabled }); } /** * Abort in-progress retry. */ async abortRetry() { await this.send({ type: "abort_retry" }); } /** * Execute a bash command. */ async bash(command) { const response = await this.send({ type: "bash", command }); return this.getData(response); } /** * Abort running bash command. */ async abortBash() { await this.send({ type: "abort_bash" }); } /** * Get session statistics. */ async getSessionStats() { const response = await this.send({ type: "get_session_stats" }); return this.getData(response); } /** * Export session to HTML. */ async exportHtml(outputPath) { const response = await this.send({ type: "export_html", outputPath }); return this.getData(response); } /** * Switch to a different session file. * @returns Object with `cancelled: true` if an extension cancelled the switch */ async switchSession(sessionPath) { const response = await this.send({ type: "switch_session", sessionPath }); return this.getData(response); } /** * Fork from a specific message. * @returns Object with `text` (the message text) and `cancelled` (if extension cancelled) */ async fork(entryId) { const response = await this.send({ type: "fork", entryId }); return this.getData(response); } /** * Clone the current active branch into a new session. * @returns Object with `cancelled: true` if an extension cancelled the clone */ async clone() { const response = await this.send({ type: "clone" }); return this.getData(response); } /** * Get messages available for forking. */ async getForkMessages() { const response = await this.send({ type: "get_fork_messages" }); return this.getData(response).messages; } /** * Get session entries in append order, optionally only those after the `since` entry id. */ async getEntries(since) { const response = await this.send({ type: "get_entries", since }); return this.getData(response); } /** * Get the session entry tree. */ async getTree() { const response = await this.send({ type: "get_tree" }); return this.getData(response); } /** * Get text of last assistant message. */ async getLastAssistantText() { const response = await this.send({ type: "get_last_assistant_text" }); return this.getData(response).text; } /** * Set the session display name. */ async setSessionName(name) { await this.send({ type: "set_session_name", name }); } /** * Get all messages in the session. */ async getMessages() { const response = await this.send({ type: "get_messages" }); return this.getData(response).messages; } /** * Get available commands (extension commands, prompt templates, skills). */ async getCommands() { const response = await this.send({ type: "get_commands" }); return this.getData(response).commands; } // ========================================================================= // Helpers // ========================================================================= /** * Wait for agent to become idle (no streaming). * Resolves when agent_settled event is received. */ waitForIdle(timeout = 60000) { return new Promise((resolve, reject) => { const timer = setTimeout(() => { unsubscribe(); reject(new Error(`Timeout waiting for agent to become idle. Stderr: ${this.stderr}`)); }, timeout); const unsubscribe = this.onEvent((event) => { if (event.type === "agent_settled") { clearTimeout(timer); unsubscribe(); resolve(); } }); }); } /** * Collect events until agent becomes idle. */ collectEvents(timeout = 60000) { return new Promise((resolve, reject) => { const events = []; const timer = setTimeout(() => { unsubscribe(); reject(new Error(`Timeout collecting events. Stderr: ${this.stderr}`)); }, timeout); const unsubscribe = this.onEvent((event) => { events.push(event); if (event.type === "agent_settled") { clearTimeout(timer); unsubscribe(); resolve(events); } }); }); } /** * Send prompt and wait for completion, returning all events. */ async promptAndWait(message, images, timeout = 60000) { const eventsPromise = this.collectEvents(timeout); await this.prompt(message, images); return eventsPromise; } // ========================================================================= // Internal // ========================================================================= handleLine(line) { try { const data = JSON.parse(line); // Check if it's a response to a pending request if (data.type === "response" && data.id && this.pendingRequests.has(data.id)) { const pending = this.pendingRequests.get(data.id); this.pendingRequests.delete(data.id); pending.resolve(data); return; } // Otherwise it's an event for (const listener of this.eventListeners) { listener(data); } } catch { // Ignore non-JSON lines } } createProcessExitError(code, signal) { return new Error(`Agent process exited (code=${code} signal=${signal}). Stderr: ${this.stderr}`); } rejectPendingRequests(error) { for (const pending of this.pendingRequests.values()) { pending.reject(error); } this.pendingRequests.clear(); } async send(command) { const childProcess = this.process; const stdin = childProcess?.stdin; if (!childProcess || !stdin) { throw new Error("Client not started"); } if (this.exitError) { throw this.exitError; } if (childProcess.exitCode !== null) { const error = this.createProcessExitError(childProcess.exitCode, childProcess.signalCode); this.exitError = error; throw error; } if (stdin.destroyed || !stdin.writable) { const error = new Error(`Agent process stdin is not writable. Stderr: ${this.stderr}`); this.exitError = error; throw error; } const id = `req_${++this.requestId}`; const fullCommand = { ...command, id }; return new Promise((resolve, reject) => { const timeout = setTimeout(() => { this.pendingRequests.delete(id); reject(new Error(`Timeout waiting for response to ${command.type}. Stderr: ${this.stderr}`)); }, 30000); this.pendingRequests.set(id, { resolve: (response) => { clearTimeout(timeout); resolve(response); }, reject: (error) => { clearTimeout(timeout); reject(error); }, }); try { stdin.write(serializeJsonLine(fullCommand)); } catch (error) { const writeError = error instanceof Error ? error : new Error(String(error)); const pending = this.pendingRequests.get(id); this.pendingRequests.delete(id); pending?.reject(writeError); } }); } getData(response) { if (!response.success) { const errorResponse = response; throw new Error(errorResponse.error); } // Type assertion: we trust response.data matches T based on the command sent. // This is safe because each public method specifies the correct T for its command. const successResponse = response; return successResponse.data; } } //# sourceMappingURL=rpc-client.js.map