Run this example:
examples/ide-plugin-ts/—npm install && npm run compile, then F5 inside VS Code
Scenario: you’re building a VS Code / Zed / JetBrains / Neovim plugin that adds “chat with your codebase” to the editor. You want process isolation (one Agentao per workspace), language-agnostic glue (your plugin might be TypeScript), and the ability to resume a conversation after the IDE restarts.
IDE main process
│
├─ Extension host (Node / JVM / Rust)
│ │
│ ▼
│ ACPClient (stdio JSON-RPC)
│ │ ▲
│ ▼ │
│ subprocess: `agentao --acp --stdio`
│ ├─ working_directory = workspace root
│ ├─ MCP: filesystem + git (auto-loaded from .agentao/mcp.json)
│ └─ Skills: repo conventions (from .agentao/skills/)
│
└─ UI: chat panel, inline suggestions, diff review
// acp-client.ts
import { spawn, ChildProcessWithoutNullStreams } from "node:child_process";
import readline from "node:readline";
export class ACPClient {
private proc: ChildProcessWithoutNullStreams;
private rl: readline.Interface;
private nextId = 1;
private pending = new Map<number, (r: any) => void>();
private notifHandlers = new Map<string, (p: any) => void>();
constructor(workspaceRoot: string) {
this.proc = spawn("agentao", ["--acp", "--stdio"], {
cwd: workspaceRoot,
env: { ...process.env, AGENTAO_WORKING_DIRECTORY: workspaceRoot },
});
this.rl = readline.createInterface({ input: this.proc.stdout });
this.rl.on("line", (line) => this.handleLine(line));
this.proc.stderr.on("data", (d) => console.error("[agentao]", d.toString()));
}
async initialize(): Promise<any> {
return this.request("initialize", {
protocolVersion: 1,
clientCapabilities: { fs: { readFile: true, writeFile: true } },
});
}
async newSession(cwd: string): Promise<string> {
const r = await this.request("session/new", { cwd, mcpServers: [] });
return r.sessionId;
}
async prompt(sessionId: string, text: string): Promise<any> {
return this.request("session/prompt", {
sessionId,
prompt: [{ type: "text", text }],
});
}
async cancel(sessionId: string): Promise<void> {
await this.request("session/cancel", { sessionId });
}
onNotification(method: string, handler: (p: any) => void) {
this.notifHandlers.set(method, handler);
}
private request(method: string, params: any): Promise<any> {
return new Promise((resolve, reject) => {
const id = this.nextId++;
this.pending.set(id, (msg) => {
if (msg.error) reject(Object.assign(new Error(msg.error.message), msg.error));
else resolve(msg.result);
});
this.proc.stdin.write(JSON.stringify({ jsonrpc: "2.0", id, method, params }) + "\n");
});
}
private handleLine(line: string) {
const msg = JSON.parse(line);
if (msg.id !== undefined && this.pending.has(msg.id)) {
this.pending.get(msg.id)!(msg);
this.pending.delete(msg.id);
} else if (msg.method) {
const h = this.notifHandlers.get(msg.method);
if (h) h(msg.params);
}
}
}
// extension.ts
import * as vscode from "vscode";
import { ACPClient } from "./acp-client";
export async function activate(ctx: vscode.ExtensionContext) {
const ws = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath;
if (!ws) return;
const client = new ACPClient(ws);
await client.initialize();
const sessionId = await client.newSession(ws);
client.onNotification("session/update", (p) => {
const { update } = p;
if (update.sessionUpdate === "agent_message_chunk") {
chatPanel.append(update.content.text);
} else if (update.sessionUpdate === "tool_call_start") {
chatPanel.showToolSpinner(update.toolCall);
}
});
client.onNotification("session/request_permission", async (p) => {
const tool = p.toolCall.toolName;
const pick = await vscode.window.showQuickPick(
["Allow once", "Allow always", "Deny"],
{ placeHolder: `Agentao wants to run ${tool}` }
);
return { outcome: pick === "Deny" ? { outcome: "cancelled" }
: { outcome: "selected", optionId: "allow" } };
});
ctx.subscriptions.push(
vscode.commands.registerCommand("agentao.ask", async () => {
const q = await vscode.window.showInputBox({ prompt: "Ask Agentao" });
if (q) await client.prompt(sessionId, q);
}),
vscode.commands.registerCommand("agentao.cancel", () => client.cancel(sessionId)),
);
}
ACP’s session/load (advertised by loadSession: true in initialize) lets you hand the same sessionId back after a restart. Agentao will replay stored history into agent.messages.
// on startup
const saved = ctx.globalState.get<string>("agentao.sessionId");
const sessionId = saved
? (await client.request("session/load", { sessionId: saved }), saved)
: await client.newSession(ws);
ctx.globalState.update("agentao.sessionId", sessionId);
Drop .agentao/acp.json at workspace root so users can customize without touching extension code:
{
"mcpServers": {
"filesystem": {
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-filesystem", "."]
}
},
"permissions": {
"mode": "WORKSPACE_WRITE",
"rules": [
{ "tool": "run_shell_command", "action": "ask" }
]
}
}
| Day-2 bug | Root cause | Fix |
|---|---|---|
| Plugin hangs after crash | Child process orphaned on SIGKILL | On exit, stderr close, restart with exponential backoff |
Huge stdout line crashes readline |
NDJSON frame > default buffer | Use readline.createInterface({ input, crlfDelay: Infinity }) + raise max |
| Permission prompts stack up | User clicked a pending ask mid-reply; cancel didn’t cascade | session/cancel rejects all outstanding permission requests with cancelled |
| Path traversal via tool args | Tool called on a path outside workspace | Rely on working_directory pin (6.4 golden rule) |
| Multi-root workspaces | Single ACPClient can’t service two roots | Spawn one subprocess per root |
The full project lives in-repo at examples/ide-plugin-ts/ — see the top-of-page “Run this example” link.
cd examples/ide-plugin-ts
npm install && npm run compile
# Open this directory in VS Code, then press F5 to launch the extension host