From 88da9d800fbf596547c40976807d835f7d1e529d Mon Sep 17 00:00:00 2001 From: Andrey Bragin Date: Tue, 4 Aug 2026 15:10:58 +0200 Subject: [PATCH] Add an interactive console ACP simple client example (#936) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Description Testing this agent means wiring it into an editor's ACP integration, which is an overkill when you just want to watch the JSON and reply with JSON. This adds a second example next to `examples/steering.ts`: a minimal interactive ACP client that spawns the built agent, prints every JSON-RPC message in both directions, and turns what you type into prompts. ``` npm run build npm run example:simple-client ``` All four ways a client can get a message to this agent are reachable from the prompt" | Input | Wire | | --- | --- | | `` | `session/prompt`; buffered client-side if a turn is running | | `!steer ` | `_session/steering`, injected into the running turn | | `!queue ` | `session/prompt` sent mid-turn, so the agent queues it | | `!cancel`, Ctrl-C | `session/cancel` | ## Additional options * `--debug-port=` rebuilds with source maps and starts the agent under * `--inspect-brk`, paused, so a debugger can attach and step through `src/*.ts` — replacing a hand-written editor run configuration. Teardown cancels any live turn, closes the agent's stdin (its clean-exit path), waits, and only then closes the connection, so neither side dies of EPIPE and nothing is orphaned. --- examples/simple-client.ts | 704 ++++++++++++++++++++++++++++++++++++++ package.json | 1 + 2 files changed, 705 insertions(+) create mode 100644 examples/simple-client.ts diff --git a/examples/simple-client.ts b/examples/simple-client.ts new file mode 100644 index 00000000..92f1b706 --- /dev/null +++ b/examples/simple-client.ts @@ -0,0 +1,704 @@ +/** + * Example: a dead-simple interactive ACP client for this agent. + * + * This is the "no editor required" way to exercise the agent: it launches + * `dist/index.js` as a subprocess, speaks ACP over its stdio using the SDK, and + * prints every JSON-RPC message that crosses the wire in both directions. Each + * line you type becomes a `session/prompt`; the agent's answer streams back as + * `session/update` notifications. + * + * Every message is preceded by a header line naming its direction: + * + * [agent] ◂ inbound — everything the agent sends us + * [user] ▸ outbound — everything we send the agent, including our own + * permission answers and `session/cancel` + * + * The label and the arrow carry the direction, so it survives NO_COLOR and + * redirection to a file (`2> wire.log`). The JSON itself is printed in full, + * flush left and undecorated — select it and you have valid JSON on the + * clipboard. This exists to show you the protocol, not to summarize it. + * + * There are four ways for a client to get a message to this agent, and all four + * are reachable from the prompt so you can watch what each one looks like on + * the wire. Plain text is the first; the rest are `!`-prefixed commands, chosen + * so they cannot be confused with the agent's own slash commands (`/compact` + * and friends are ordinary prompt text here). `!!` escapes a literal `!`. + * + * `session/prompt`. Typed mid-turn it is *buffered* by this + * client and sent once the running turn responds. + * !steer `_session/steering` — injected into the turn that is + * already running, so it can change course between tool + * calls. Answers "injected", or "startedNewTurn" if the turn + * beat us to the finish. Either way the reply arrives as + * `session/update` notifications that can outlast the + * `stopReason` of the turn you steered. See ./steering.ts. + * !queue `session/prompt` sent mid-turn anyway. The agent advertises + * `promptQueueing`, so it takes the backlog instead of us — + * same ordering, different owner. + * !cancel `session/cancel` for the running turn, as Ctrl-C does. + * + * Run (build the agent first so `dist/index.js` exists): + * + * npm run build + * node examples/simple-client.ts + * + * or, equivalently, `npm run example:simple-client`. + * + * To debug the agent itself, pass a port. The agent is then rebuilt with source + * maps and started under `--inspect-brk=`, i.e. paused before its first + * line, so you can attach (IntelliJ: Run → Attach to Node.js/Chrome; VS Code: + * "Attach to Node Process"), set breakpoints in `src/*.ts`, and resume: + * + * node examples/simple-client.ts --debug-port=9229 + * + * The client will sit waiting for `initialize` until you attach and resume — + * that is expected, ACP has no request timeout. + * + * (Node < 22.18 needs `node --experimental-strip-types examples/simple-client.ts`.) + * + * Keys: Enter sends (or buffers), Ctrl-C cancels the running turn (again to + * quit), Ctrl-D drains anything buffered and exits. `!help` lists the commands. + * + * Flags: --debug-port=, --no-build, --help. + * Env: AGENT_ENTRY (default dist/index.js), CWD (default process.cwd()). + * + * Requires the agent to be authenticated, since it talks to the real model. + * + * Note: this client does not advertise the `elicitation` capability, so the + * agent disables its AskUserQuestion tool instead of asking us to render a + * form. That is the one intentional behaviour difference from a full client. + */ +import { spawn } from "node:child_process"; +import { once } from "node:events"; +import { existsSync } from "node:fs"; +import path from "node:path"; +import readline from "node:readline"; +import { Readable, Writable } from "node:stream"; +import { fileURLToPath } from "node:url"; +import { + PROTOCOL_VERSION, + client as acpClient, + methods, + ndJsonStream, +} from "@agentclientprotocol/sdk"; +// Type-only: Node's type stripping does not elide value imports, and these two +// exist only in the .d.ts — importing them normally is a runtime SyntaxError. +import type { AnyMessage, Stream } from "@agentclientprotocol/sdk"; + +// ---------------------------------------------------------------- steering + +/** + * The steering extension (../steering_protocol.md, and ./steering.ts for a + * scripted walkthrough). It is not part of ACP proper, so it has no entry in + * `methods` and is sent as a raw method name with hand-written types. + */ +const STEERING_METHOD = "_session/steering"; + +type SteeringRequest = { + sessionId: string; + prompt: Array<{ type: "text"; text: string }>; +}; + +/** Both outcomes are successes; they say where the message landed, not whether + * it worked. "startedNewTurn" means the turn finished before we got there. */ +type SteeringResponse = { + outcome: "injected" | "startedNewTurn"; +}; + +// ---------------------------------------------------------------- arguments + +const ARGV = process.argv.slice(2); + +/** Reads `--name=value` or `--name value`; returns "" for a valueless flag. */ +function flag(name: string): string | undefined { + const index = ARGV.findIndex((arg) => arg === `--${name}` || arg.startsWith(`--${name}=`)); + if (index === -1) return undefined; + const [, ...rest] = ARGV[index].split("="); + return rest.length > 0 ? rest.join("=") : (ARGV[index + 1] ?? ""); +} + +if (ARGV.includes("--help") || ARGV.includes("-h")) { + process.stdout.write( + [ + "Usage: node examples/simple-client.ts [--debug-port=] [--no-build]", + "", + " --debug-port= rebuild with source maps and start the agent under", + " --inspect-brk=, paused until a debugger attaches", + " --no-build skip the tsc rebuild and use dist/ as-is", + "", + "Env: AGENT_ENTRY (default dist/index.js), CWD (default process.cwd()).", + "", + ].join("\n"), + ); + process.exit(0); +} + +// The built agent entry. Rebuilt below unless --no-build is passed. +const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const AGENT_ENTRY = process.env.AGENT_ENTRY ?? path.join(repoRoot, "dist", "index.js"); +const CWD = process.env.CWD ?? process.cwd(); +const BUILD = !ARGV.includes("--no-build"); + +const debugPortArg = flag("debug-port"); +const DEBUG_PORT = debugPortArg === undefined ? undefined : Number(debugPortArg); +if ( + DEBUG_PORT !== undefined && + (!Number.isInteger(DEBUG_PORT) || DEBUG_PORT < 1 || DEBUG_PORT > 65535) +) { + process.stderr.write(`--debug-port expects a port between 1 and 65535, got "${debugPortArg}"\n`); + process.exit(1); +} + +/** Shown by `!help`, and once at startup. */ +const HELP = [ + " send it — as a new turn, or buffered until this turn ends", + "!steer inject into the running turn (_session/steering)", + "!queue send session/prompt now and let the agent queue it", + "!cancel session/cancel the running turn (same as Ctrl-C)", + "!help this list; !! sends a line that really starts with '!'", +]; + +// How long to wait for a cancelled turn to settle, and for the agent to exit +// after we close its stdin, before escalating during shutdown. +const CANCEL_TIMEOUT_MS = 5_000; +const EXIT_TIMEOUT_MS = 5_000; + +// ---------------------------------------------------------------- output + +const COLOR = process.stderr.isTTY === true && !process.env.NO_COLOR; +const paint = (code: string, text: string) => (COLOR ? `\x1b[${code}m${text}\x1b[0m` : text); +const dim = (text: string) => paint("2", text); + +const TTY = process.stdin.isTTY === true && process.stderr.isTTY === true; +const startedAt = Date.now(); + +let rl: readline.Interface | null = null; +let shuttingDown = false; +let queue: string[] = []; +// How many `session/prompt` requests are awaiting a response. Usually 0 or 1; +// `!queue` deliberately drives it higher to exercise the agent's own queueing. +let inFlight = 0; +const turnRunning = () => inFlight > 0; +let cancelRequested = false; +let lastUpdateKind = ""; +// False until `initialize` returns — an agent still paused under --inspect-brk +// will never act on stdin EOF, so shutdown skips straight to signalling it. +let agentReady = false; + +/** + * The single writer for everything this process prints. It wipes the half-typed + * input line, writes the block, then asks readline to redraw its prompt with the + * buffer intact — without this, streamed output and typing shred each other. + * (One caveat: a soft-wrapped input line leaves a stale fragment behind, since + * clearLine only clears the row the cursor is on.) + */ +function writeBlock(text: string) { + if (TTY) { + readline.cursorTo(process.stderr, 0); + readline.clearLine(process.stderr, 0); + } + process.stderr.write(text.endsWith("\n") ? text : `${text}\n`); + if (TTY) rl?.prompt(true); +} + +function log(message: string) { + writeBlock(`${dim("[client]")} ${message}`); +} + +function refreshPrompt() { + if (!rl) return; + const queued = queue.length > 0 ? `[${queue.length} queued] ` : ""; + const running = inFlight > 1 ? `[${inFlight} turns] ` : ""; + const busy = turnRunning() ? dim(`⋯ ${lastUpdateKind} `) : ""; + rl.setPrompt(`${busy}${running}${queued}› `); + if (TTY) rl.prompt(true); +} + +const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); + +// ---------------------------------------------------------------- wire log + +/** A permissive view of the JSON-RPC union, purely for the summary line. */ +type WireMessage = { + id?: string | number | null; + method?: string; + params?: { update?: { sessionUpdate?: string } }; + result?: { stopReason?: string }; + error?: { code: number; message: string }; +}; + +/** One-line summary: method (plus session update kind), or which reply this is. */ +function describe(message: AnyMessage): string { + const wire = message as WireMessage; + const id = wire.id === undefined || wire.id === null ? "" : ` #${wire.id}`; + if (wire.method !== undefined) { + const kind = wire.params?.update?.sessionUpdate; + return `${wire.method}${kind ? ` · ${kind}` : ""}${id}`; + } + if (wire.error !== undefined) { + return `error${id} · ${wire.error.code} ${wire.error.message}`; + } + const stopReason = wire.result?.stopReason; + return `result${id}${stopReason ? ` · ${stopReason}` : ""}`; +} + +/** + * Prints one JSON-RPC message. The SDK exposes no logging hook, so this is + * called from a stream tap (see `tapStream`) that sits between the SDK and the + * transport — what you see is exactly what is written to / read from the pipe. + */ +function logMessage(direction: "agent" | "user", message: AnyMessage) { + const inbound = direction === "agent"; + const color = inbound ? "36" : "35"; + const elapsed = ((Date.now() - startedAt) / 1000).toFixed(3); + const header = [ + dim(`+${elapsed}s`), + paint(`1;${color}`, inbound ? "[agent]" : "[user] "), + paint(color, inbound ? "◂" : "▸"), + dim(describe(message)), + ].join(" "); + // The body is printed flush left and unadorned so that selecting it in the + // terminal yields valid JSON you can paste straight into a tool. + writeBlock(`${header}\n${JSON.stringify(message, null, 2)}`); +} + +/** + * Wraps a transport stream so every message is logged post-parse/pre-stringify. + * A TransformStream per direction is the only complete interposition point: the + * SDK reads inbound with a single reader and takes a fresh writer per outbound + * message, and both survive being handed a transform. + */ +function tapStream(inner: Stream): Stream { + const inbound = new TransformStream({ + transform(message, controller) { + logMessage("agent", message); + controller.enqueue(message); + }, + }); + const outbound = new TransformStream({ + transform(message, controller) { + logMessage("user", message); + controller.enqueue(message); + }, + }); + // The SDK never closes its writable, so this pipe only settles on failure — + // typically the agent died and its stdin is gone. Teardown handles the rest; + // without the catch this would be an unhandled rejection. + outbound.readable.pipeTo(inner.writable).catch((err) => { + if (!shuttingDown) log(`transport write failed: ${err}`); + }); + return { readable: inner.readable.pipeThrough(inbound), writable: outbound.writable }; +} + +// ---------------------------------------------------------------- build + +/** Runs the repo's tsc, sending its diagnostics to our stderr. */ +async function build(sourceMaps: boolean) { + const localTsc = path.join(repoRoot, "node_modules", "typescript", "bin", "tsc"); + const useLocal = existsSync(localTsc); + const args = sourceMaps ? ["--sourceMap"] : []; + log(`building agent (tsc${sourceMaps ? " --sourceMap" : ""}) …`); + + const child = useLocal + ? spawn(process.execPath, [localTsc, ...args], { cwd: repoRoot, stdio: ["ignore", 2, 2] }) + : spawn(process.platform === "win32" ? "npx.cmd" : "npx", ["tsc", ...args], { + cwd: repoRoot, + stdio: ["ignore", 2, 2], + }); + + const [code] = (await once(child, "exit")) as [number | null]; + if (code !== 0) { + log(`build failed (tsc exit ${code}) — fix the errors above, or pass --no-build`); + process.exit(1); + } +} + +// ---------------------------------------------------------------- main + +async function main() { + if (BUILD) { + await build(DEBUG_PORT !== undefined); + } else if (!existsSync(AGENT_ENTRY)) { + log(`${AGENT_ENTRY} not found — run \`npm run build\` first, or unset --no-build`); + process.exit(1); + } + + // An ACP client launches the agent as a subprocess and speaks JSON-RPC over + // its stdin/stdout. Its stderr is piped rather than inherited so that agent + // logs go through writeBlock() and don't corrupt the readline input line. + const nodeArgs = + DEBUG_PORT === undefined ? [AGENT_ENTRY] : [`--inspect-brk=${DEBUG_PORT}`, AGENT_ENTRY]; + const child = spawn(process.execPath, nodeArgs, { + cwd: repoRoot, + stdio: ["pipe", "pipe", "pipe"], + env: process.env, + }); + child.on("error", (err) => { + log(`failed to spawn agent (${AGENT_ENTRY}): ${err}`); + process.exit(1); + }); + // A write racing the agent's exit surfaces as an unhandled 'error' event that + // would take this client down with it. + child.stdin?.on("error", (err: NodeJS.ErrnoException) => { + if (err.code !== "EPIPE") log(`agent stdin error: ${err}`); + }); + child.stdout?.on("error", () => {}); + process.stderr.on("error", () => {}); + + pumpAgentStderr(child); + + child.on("exit", (code, signal) => { + if (!shuttingDown) log(`agent exited (code ${code}, signal ${signal})`); + }); + + const stream = tapStream( + ndJsonStream( + Writable.toWeb(child.stdin!) as WritableStream, + Readable.toWeb(child.stdout!) as unknown as ReadableStream, + ), + ); + + const connection = acpClient({ name: "simple-client" }) + // The tap already prints every update; this handler exists so the prompt line + // can show what the agent is currently doing. + .onNotification(methods.client.session.update, (ctx) => { + lastUpdateKind = ctx.params.update.sessionUpdate; + }) + // Auto-approve, chosen by `kind` and never by a hard-coded optionId: for + // ExitPlanMode this agent uses permission-*mode* names as option ids + // (auto / acceptEdits / default / plan / bypassPermissions). + .onRequest(methods.client.session.requestPermission, (ctx) => { + const options = ctx.params.options; + const option = options.find((o) => o.kind === "allow_once") ?? options[0]; + if (!option) return { outcome: { outcome: "cancelled" } }; + log(`auto-approving permission: ${option.name} (${option.kind}/${option.optionId})`); + return { outcome: { outcome: "selected", optionId: option.optionId } }; + }) + // Minimal file-system stubs. This agent performs its own file I/O inside the + // Claude Agent SDK subprocess and never calls these today. + .onRequest(methods.client.fs.readTextFile, () => ({ content: "" })) + .onRequest(methods.client.fs.writeTextFile, () => ({})) + .connect(stream); + + const agent = connection.agent; + + // Signals are wired up *before* the first request. Under --inspect-brk the + // wait below is unbounded, and a client that dies without running shutdown() + // orphans a paused agent still holding the debug port. + const onInterrupt = () => { + if (turnRunning() && !cancelRequested) { + cancelRequested = true; + const dropped = queue.length; + queue = []; + // A notification, not a request: every turn in flight settles through its + // own session/prompt response, normally with stopReason "cancelled". + void agent.notify(methods.agent.session.cancel, { sessionId }); + log(`sent session/cancel${dropped > 0 ? `, dropped ${dropped} queued line(s)` : ""}`); + refreshPrompt(); + return; + } + void shutdown(turnRunning() ? "^C while cancelling" : "^C"); + }; + // In terminal mode readline puts stdin in raw mode and owns ^C, so this only + // ever fires for an explicit `kill -INT`; with piped stdin it is the ^C path. + process.on("SIGINT", onInterrupt); + process.on("SIGTERM", () => void shutdown("SIGTERM")); + + // 1. Initialize. Under --inspect-brk the agent is paused before it reads a + // single byte, so this request stays unanswered until a debugger attaches + // and resumes it. ACP has no request timeout, so waiting is safe; the + // interval below just keeps the terminal from looking dead. + let nudge: NodeJS.Timeout | undefined; + if (DEBUG_PORT !== undefined) { + log(`agent is PAUSED before its first line (--inspect-brk=${DEBUG_PORT})`); + log(`attach a debugger to port ${DEBUG_PORT} and resume it — breakpoints go in src/*.ts`); + nudge = setInterval( + () => log(`still waiting for a debugger on port ${DEBUG_PORT} … (Ctrl-C to give up)`), + 10_000, + ); + } + let init; + try { + init = await agent.request(methods.agent.initialize, { + protocolVersion: PROTOCOL_VERSION, + clientCapabilities: { + fs: { readTextFile: true, writeTextFile: true }, + terminal: false, + }, + }); + } finally { + if (nudge) clearInterval(nudge); + } + agentReady = true; + log(`connected to ${init.agentInfo?.name ?? "agent"} ${init.agentInfo?.version ?? ""}`.trim()); + + // Steering is advertised at the TOP-LEVEL `_meta` of the initialize result — + // a sibling of `agentCapabilities`, not nested inside it. Prompt queueing is + // a Claude-specific capability flag and does live inside. + const initMeta = init._meta as { steering?: { supported?: boolean } } | null | undefined; + const steeringSupported = initMeta?.steering?.supported === true; + const queueMeta = init.agentCapabilities?._meta as + { claudeCode?: { promptQueueing?: boolean } } | null | undefined; + log( + `agent supports: steering=${steeringSupported} ` + + `promptQueueing=${queueMeta?.claudeCode?.promptQueueing === true}`, + ); + + // 2. Open a session. + let sessionId: string; + try { + const session = await agent.request(methods.agent.session.new, { cwd: CWD, mcpServers: [] }); + sessionId = session.sessionId; + } catch (err) { + log(`session/new failed: ${err}`); + log("if this is an auth error, log in first: node dist/index.js --cli auth login"); + await shutdown("session/new failed", 1); + return; + } + log(`session ${sessionId} in ${CWD}`); + + // 3. Turns. There are four ways to get a message to the agent, and the client + // can drive all of them (see `handleCommand`); plain text uses the first. + // + // session/prompt a new turn, or — for text typed mid-turn — one + // buffered here and sent when the turn responds + // session/prompt sent mid-turn anyway, so the *agent* queues it + // (!queue) (it advertises `_meta.claudeCode.promptQueueing`) + // _session/steering injected into the turn that is already running + // (!steer) + // session/cancel abandon the running turn + // (!cancel, ^C) + let draining = false; + + /** One `session/prompt` round-trip. Several may be in flight at once when + * `!queue` is used; the agent runs them in order. */ + async function sendPrompt(text: string) { + inFlight += 1; + cancelRequested = false; + refreshPrompt(); + try { + const result = await agent.request(methods.agent.session.prompt, { + sessionId, + prompt: [{ type: "text", text }], + }); + log(`turn ended: ${result.stopReason}`); + } catch (err) { + // The error response itself was already printed by the tap; this is + // the human-readable "your prompt did not run". + log(`prompt failed: ${err}`); + if (connection.signal.aborted) queue = []; + } finally { + inFlight -= 1; + refreshPrompt(); + exitWhenIdle(); + } + } + + /** Sends the buffered lines one turn at a time, oldest first. */ + async function drain() { + if (draining) return; + draining = true; + try { + while (queue.length > 0 && !shuttingDown) { + await sendPrompt(queue.shift()!); + if (connection.signal.aborted) break; + } + } finally { + draining = false; + refreshPrompt(); + exitWhenIdle(); + } + } + + /** A piped stdin ends; exiting has to wait for the work already handed out. */ + function exitWhenIdle() { + if (inputClosed && !draining && queue.length === 0 && inFlight === 0) { + void shutdown("input closed, queue drained"); + } + } + + /** + * Client-side commands, prefixed with `!` so they cannot collide with the + * agent's own slash commands (`/compact` and friends, which are just prompt + * text as far as this client is concerned). `!!` escapes a literal `!`. + */ + function handleCommand(line: string) { + const word = line.split(/\s+/)[0]; + const text = line.slice(word.length).trim(); + switch (word) { + case "!steer": + if (text.length === 0) { + log("usage: !steer — injects into the turn that is running"); + break; + } + if (!steeringSupported) log("agent did not advertise steering; trying anyway"); + void steer(text); + break; + case "!queue": + if (text.length === 0) { + log("usage: !queue — sends session/prompt now, agent queues it"); + break; + } + void sendPrompt(text); + break; + case "!cancel": + if (!turnRunning()) { + log("nothing to cancel"); + break; + } + onInterrupt(); + break; + case "!help": + for (const entry of HELP) log(entry); + break; + default: + log(`unknown command ${word} — try !help`); + break; + } + refreshPrompt(); + } + + /** `_session/steering`: deliver a message to the turn already in progress. */ + async function steer(text: string) { + const params: SteeringRequest = { sessionId, prompt: [{ type: "text", text }] }; + try { + const result = await agent.request(STEERING_METHOD, params); + // Either way the steered message's own output arrives as `session/update` + // notifications, not in this response — and it may well keep streaming + // after the turn we steered has already answered with its stopReason. + log( + result.outcome === "injected" + ? "steer outcome: injected into the running turn" + : "steer outcome: startedNewTurn — the turn had already finished, so the " + + "agent began a new one that no session/prompt of ours is tracking", + ); + } catch (err) { + log(`steer rejected: ${err}`); + } + } + + let inputClosed = false; + let steerHinted = false; + + rl = readline.createInterface({ + input: process.stdin, + output: process.stderr, + terminal: TTY, + crlfDelay: Infinity, + historySize: 200, + }); + + rl.on("line", (raw) => { + const line = raw.trim(); + if (line.length === 0 || shuttingDown) { + refreshPrompt(); + return; + } + if (line.startsWith("!") && !line.startsWith("!!")) { + handleCommand(line); + return; + } + // "!!steer …" is how you send text that begins with the command prefix. + queue.push(line.startsWith("!!") ? line.slice(1) : line); + if (turnRunning()) { + log(`buffered — will send when this turn ends (${queue.length} queued)`); + if (!steerHinted) { + steerHinted = true; + log("…or use !steer to inject into the running turn instead"); + } + } + refreshPrompt(); + void drain(); + }); + + rl.on("SIGINT", onInterrupt); + + rl.on("close", () => { + inputClosed = true; + exitWhenIdle(); + }); + + void connection.closed.then(() => { + if (!shuttingDown) void shutdown("connection closed"); + }); + + for (const entry of HELP) log(entry); + log("Enter sends, Ctrl-C cancels a turn, Ctrl-D exits"); + refreshPrompt(); + + /** + * Order matters here. The agent exits cleanly on stdin EOF (src/index.ts wires + * `connection.closed` to `agent.dispose()`), whereas killing it skips disposal + * and can orphan the Claude Agent SDK subprocess. Closing our connection first + * would cancel the reader on the agent's stdout and make it die of EPIPE + * mid-write, so that comes last. + */ + async function shutdown(reason: string, exitCode = 0) { + if (shuttingDown) return; + shuttingDown = true; + log(`shutting down (${reason})`); + queue = []; + rl?.removeAllListeners("close"); + rl?.close(); + + if (turnRunning()) { + if (!cancelRequested) { + try { + await agent.notify(methods.agent.session.cancel, { sessionId }); + } catch { + // The connection may already be gone; teardown continues regardless. + } + } + const deadline = Date.now() + CANCEL_TIMEOUT_MS; + while (turnRunning() && Date.now() < deadline) await delay(50); + } + + child.stdin?.end(); + const exited = await Promise.race([ + once(child, "exit").then(() => true), + delay(agentReady ? EXIT_TIMEOUT_MS : 250).then(() => false), + ]); + if (!exited) { + log("agent did not exit on stdin EOF; sending SIGTERM"); + child.kill("SIGTERM"); + const stopped = await Promise.race([ + once(child, "exit").then(() => true), + delay(2_000).then(() => false), + ]); + if (!stopped) child.kill("SIGKILL"); + } + + connection.close(); + process.exit(exitCode); + } +} + +/** Streams the agent's stderr through writeBlock, flagging inspector messages. */ +function pumpAgentStderr(child: ReturnType) { + let buffered = ""; + child.stderr?.setEncoding("utf8"); + child.stderr?.on("data", (chunk: string) => { + buffered += chunk; + const lines = buffered.split("\n"); + buffered = lines.pop() ?? ""; + for (const line of lines) { + if (line.length === 0) continue; + if (line.includes("Debugger listening on")) { + writeBlock(paint("1;33", `[agent] ${line.trim()}`)); + } else if (line.includes("address already in use")) { + writeBlock(paint("1;31", `[agent] ${line.trim()}`)); + log("that inspector port is taken — pass a different --debug-port"); + } else { + writeBlock(dim(`[agent:stderr] ${line}`)); + } + } + }); +} + +main().catch((err) => { + // Tearing down rejects whatever request main() is awaiting; that is the + // teardown finishing its job, not a failure, and shutdown() owns the exit. + if (shuttingDown) return; + log(`fatal: ${err?.stack ?? err}`); + process.exit(1); +}); diff --git a/package.json b/package.json index 62693c63..3bf5e9e2 100644 --- a/package.json +++ b/package.json @@ -29,6 +29,7 @@ "build": "tsc", "start": "node dist/index.js", "dev": "npm run build && npm run start", + "example:simple-client": "node --experimental-strip-types --disable-warning=ExperimentalWarning examples/simple-client.ts", "lint": "eslint src --ext .ts", "lint:fix": "eslint src --ext .ts --fix", "format": "prettier --write .",