diff --git a/packages/adapter-utils/src/acpx-engine/execute.test.ts b/packages/adapter-utils/src/acpx-engine/execute.test.ts index c0c0a8eb0f..cb7c4acb09 100644 --- a/packages/adapter-utils/src/acpx-engine/execute.test.ts +++ b/packages/adapter-utils/src/acpx-engine/execute.test.ts @@ -776,12 +776,17 @@ describe("shared ACPX engine runtime behavior", () => { const root = await makeTempRoot(); const localCwd = path.join(root, "local"); const remoteCwd = "/workspace/remote"; - const { sessionInputs } = await runExecutor( + const { sessionInputs, runtimeOptions } = await runExecutor( { agent: "custom", agentCommand: "node ./fake-acp.js", cwd: localCwd, stateDir: path.join(root, "state") }, { context: { paperclipWorkspace: { cwd: localCwd, workspaceWorktreePath: localCwd } }, executionTarget: { kind: "remote", transport: "ssh", remoteCwd } }, ); const env = (sessionInputs[0]!.sessionOptions as { env: Record }).env; expect(env.PAPERCLIP_WORKSPACE_CWD).toBe(localCwd); + // The ssh remote transport is NOT the runner-backed process-session lane, so + // it stays byte-identical: no host-spawn redirect. `cwd` is the host cwd and + // `spawnCwd` is unset. + expect(runtimeOptions[0]!.cwd).toBe(localCwd); + expect(runtimeOptions[0]!.spawnCwd).toBeUndefined(); }); it("does not materialize credential wrapper scripts", async () => { @@ -888,6 +893,9 @@ describe("shared ACPX engine runtime behavior", () => { const { runtimeOptions } = await runExecutor({ agent: "custom", agentCommand: "node ./fake-acp.js", stateDir: path.join(root, "state") }); expect(runtimeOptions[0]!.verbose).toBe(false); expect(runtimeOptions[0]!.onAgentStderr).toBeTypeOf("function"); + // Local lane is byte-identical: no host-spawn redirect, so `spawnCwd` is + // unset and acpx falls back to `cwd`. + expect(runtimeOptions[0]!.spawnCwd).toBeUndefined(); }); it("starts sandbox ACP process sessions in the remote execution cwd", async () => { @@ -911,7 +919,7 @@ describe("shared ACPX engine runtime behavior", () => { }, ); - await runExecutor( + const { runtimeOptions, sessionInputs } = await runExecutor( { agent: "custom", agentCommand: "node ./fake-acp.js", stateDir, cwd: localCwd }, { authToken: "real-run-jwt", @@ -930,6 +938,18 @@ describe("shared ACPX engine runtime behavior", () => { args: ["-lc", "exec node ./fake-acp.js"], cwd: remoteCwd, }); + + // Host-spawn cwd decoupling: on the remote process-session lane the acpx + // runtime host-spawns the relay proxy, whose `chdir` must land in a + // HOST-valid dir — the engine's host `cwd` (`localCwd`) — while the advertised + // ACP `session/new` cwd and the in-sandbox `commandPayload.cwd` stay + // `remoteCwd`. `spawnCwd` carries the host-only redirect; it must differ from + // the advertised session cwd. (Threading proof; the acpx runtime honoring + // `spawnCwd ?? cwd` at the real host spawn is proven in remote-spawn-smoke.) + expect(runtimeOptions[0]!.cwd).toBe(remoteCwd); + expect(sessionInputs[0]!.cwd).toBe(remoteCwd); + expect(runtimeOptions[0]!.spawnCwd).toBe(localCwd); + expect(runtimeOptions[0]!.spawnCwd).not.toBe(sessionInputs[0]!.cwd); const payloadEnv = ((sessionPayload as Record | null)?.env ?? {}) as Record; expect(payloadEnv).toMatchObject({ PAPERCLIP_API_BRIDGE_MODE: "queue_v1", @@ -941,6 +961,48 @@ describe("shared ACPX engine runtime behavior", () => { expect(payloadEnv.PAPERCLIP_API_KEY).not.toBe("real-run-jwt"); }); + it("keeps the session fingerprint stable when only the host spawn cwd changes", async () => { + // `spawnCwd` (the host-only spawn redirect = the host `cwd`) must NOT enter + // the session fingerprint or compat key: two runs of the same session that + // stage into the same in-sandbox `remoteCwd` from DIFFERENT host worktrees + // must reuse — not invalidate — the staged runtime. So the fingerprint has to + // ignore the host cwd and key only on the advertised session cwd (`remoteCwd`). + const root = await makeTempRoot(); + const remoteCwd = path.join(root, "remote-workspace"); + await fs.mkdir(remoteCwd, { recursive: true }); + + const runOnce = async (hostWorktree: string) => { + const localCwd = path.join(root, hostWorktree); + await fs.mkdir(localCwd, { recursive: true }); + return runExecutor( + { agent: "custom", agentCommand: "node ./fake-acp.js", stateDir: path.join(root, hostWorktree, "state"), cwd: localCwd }, + { + authToken: "real-run-jwt", + executionTarget: { + kind: "remote", + transport: "sandbox", + providerKey: "fake-plugin", + remoteCwd, + runner: createLocalSandboxRunner(), + }, + }, + ); + }; + + const first = await runOnce("worktree-a"); + const second = await runOnce("worktree-b"); + + // Host cwd (and therefore `spawnCwd`) differs between the two runs... + expect(first.runtimeOptions[0]!.spawnCwd).not.toBe(second.runtimeOptions[0]!.spawnCwd); + // ...but the advertised session cwd — and thus the fingerprint — is identical. + expect(first.sessionInputs[0]!.cwd).toBe(remoteCwd); + expect(second.sessionInputs[0]!.cwd).toBe(remoteCwd); + const fp = (r: { result: { sessionParams?: unknown } }) => + (r.result.sessionParams as { configFingerprint?: string } | undefined)?.configFingerprint; + expect(fp(first)).toBeDefined(); + expect(fp(second)).toBe(fp(first)); + }); + it("routes child stderr in-process while keeping the unfiltered run log", async () => { const root = await makeTempRoot(); const stateDir = path.join(root, "state"); diff --git a/packages/adapter-utils/src/acpx-engine/execute.ts b/packages/adapter-utils/src/acpx-engine/execute.ts index 4403901506..fddf84b0ac 100644 --- a/packages/adapter-utils/src/acpx-engine/execute.ts +++ b/packages/adapter-utils/src/acpx-engine/execute.ts @@ -318,6 +318,13 @@ interface AcpxPreparedRuntime { acpxAgent: string; mode: "persistent" | "oneshot"; cwd: string; + // Host-only spawn cwd for the acpx runtime's host `spawn()` of the relay + // proxy on the remote process-session lane. On that lane `cwd` is the + // IN-SANDBOX `remoteCwd` (host-nonexistent), so the host proxy must `chdir` + // into a HOST-valid dir instead — the engine's host `cwd`. `undefined` on + // every other lane, where acpx falls back to `cwd` (byte-identical). It is + // deliberately NOT part of the session fingerprint / compat key. + hostSpawnCwd: string | undefined; workspaceId: string; workspaceRepoUrl: string; workspaceRepoRef: string; @@ -1720,6 +1727,11 @@ async function buildRuntime(input: { // → the HOST cwd (`sessionCwd` resolves both). Every cwd-keyed session site // reads `prepared.cwd`, so binding it once here keeps them consistent. cwd: sessionCwd, + // Only the remote process-session lane needs the host proxy's `spawn()` + // `chdir` redirected off the in-sandbox `sessionCwd` and onto the host + // `cwd` (which is where the workspace was staged FROM, so it is host-valid). + // Every other lane leaves it `undefined` → acpx falls back to `cwd`. + hostSpawnCwd: useRemoteProcessSession ? cwd : undefined, workspaceId, workspaceRepoUrl, workspaceRepoRef, @@ -2510,6 +2522,12 @@ export function createAcpxEngineExecutor(deps: AcpxEngineExecutorOptions = {}) { childStderrState.logPath = prepared.childStderrLogPath; const runtimeOptions: AcpRuntimeOptions = { cwd: prepared.cwd, + // Host-only spawn cwd for the relay proxy on the remote process-session + // lane; `undefined` elsewhere so acpx falls back to `cwd` (byte-identical). + // The advertised `session/new` cwd (`prepared.cwd` = `remoteCwd`) and the + // fingerprint / compat key are unaffected — this redirects ONLY the host + // `spawn()` `chdir`, not the in-sandbox data path. + spawnCwd: prepared.hostSpawnCwd, sessionStore: createRuntimeStore({ stateDir: prepared.stateDir }), agentRegistry: prepared.agentRegistry, permissionMode: prepared.permissionMode, diff --git a/packages/adapter-utils/src/acpx-engine/remote-spawn-smoke.test.ts b/packages/adapter-utils/src/acpx-engine/remote-spawn-smoke.test.ts new file mode 100644 index 0000000000..c8ddb04eca --- /dev/null +++ b/packages/adapter-utils/src/acpx-engine/remote-spawn-smoke.test.ts @@ -0,0 +1,126 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { afterEach, expect, it } from "vitest"; +import type { AcpRuntimeOptions } from "acpx/runtime"; +import { createAcpRuntime, createAgentRegistry, createRuntimeStore } from "acpx/runtime"; + +// Load-bearing repro for the remote-ACP "process session" lane host-spawn bug. +// +// The engine threads ONE cwd (`sessionCwd`) into every cwd-keyed site: the ACP +// `session/new` cwd, the session fingerprint/compat key, AND the acpx HOST +// `spawn()` of the host-local relay proxy. On the remote lane that value is the +// IN-SANDBOX `remoteCwd`, which does not exist on the host, so libuv's pre-`exec` +// `chdir` fails and acpx raises `AgentSpawnError` at `ensure_session`. +// +// A faithful full-engine remote-lane repro is NOT possible without a live +// sandbox: the only local stand-in for a sandbox runs its commands as host child +// processes, so every `remoteCwd`-derived operation (workspace staging, the +// callback bridge, the process-session bridge) executes on the HOST filesystem — +// either materializing `remoteCwd` on the host (masking the host-spawn ENOENT) +// or failing earlier at a different phase. So we split the proof at its two real +// seams: (1) here — the acpx runtime's REAL host `spawn()` honoring +// `spawnCwd ?? cwd` (real `createAcpRuntime`, real libuv `chdir`); and (2) the +// engine → `runtimeOptions` threading (`execute.test.ts`, which asserts the +// engine sets `spawnCwd` host-valid on the remote lane and `undefined` +// elsewhere, with the advertised `session/new` cwd staying `remoteCwd`). +// End-to-end validation against a real sandbox is board-run. + +const repoRoot = fileURLToPath(new URL("../../../..", import.meta.url)); +// A dedicated ACP agent fixture that reports, on stderr, the working directory +// the host actually spawned it in (`SPAWN_CWD`) and the `cwd` advertised on +// `session/new` (`SESSION_NEW_CWD`). +const fixturePath = path.join(repoRoot, "scripts", "mcp-fixtures", "servers", "acp-cwd-report-agent.mjs"); +const tempRoots: string[] = []; + +type PatchedAcpRuntimeOptions = AcpRuntimeOptions & { + spawnCwd?: string; +}; + +type PatchedEnsureSessionOptions = Parameters["ensureSession"]>[0]; + +afterEach(async () => { + await Promise.all(tempRoots.splice(0).map((root) => fs.rm(root, { recursive: true, force: true }))); +}); + +async function makeTempDir(prefix: string): Promise { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), prefix)); + tempRoots.push(dir); + return dir; +} + +/** + * Drive a REAL acpx runtime through `ensureSession`, which performs the real + * host `spawn()` (and its libuv `chdir`). `cwd` is the advertised session cwd; + * `spawnCwd`, when set, is the host-only spawn cwd the acpx patch consumes as + * `spawnCwd ?? cwd`. + */ +async function ensureRealAcpSession(input: { cwd: string; spawnCwd?: string }) { + const stateRoot = await makeTempDir("paperclip-acpx-remote-spawn-state-"); + const stderrChunks: string[] = []; + const agentCommand = `${JSON.stringify(process.execPath.replaceAll("\\", "/"))} ${JSON.stringify(fixturePath.replaceAll("\\", "/"))}`; + const runtimeOptions: PatchedAcpRuntimeOptions = { + cwd: input.cwd, + // `spawnCwd` is the host-only knob added by patches/acpx@0.12.0.patch; when + // unset acpx falls back to `cwd`, so every non-proxy lane is byte-identical. + ...(input.spawnCwd ? { spawnCwd: input.spawnCwd } : {}), + sessionStore: createRuntimeStore({ stateDir: path.join(stateRoot, "state") }), + agentRegistry: createAgentRegistry({ overrides: { custom: agentCommand } }), + permissionMode: "approve-all", + nonInteractivePermissions: "deny", + onAgentStderr: (chunk: string) => stderrChunks.push(chunk), + }; + const runtime = createAcpRuntime(runtimeOptions); + + try { + const sessionInput: PatchedEnsureSessionOptions = { + sessionKey: "remote-spawn-smoke", + agent: "custom", + mode: "oneshot", + cwd: input.cwd, + sessionOptions: { env: {} }, + }; + const handle = await runtime.ensureSession(sessionInput); + await (runtime as { close: (i: unknown) => Promise }).close({ handle, reason: "done" }).catch(() => {}); + return { resolved: true as const, stderr: stderrChunks.join("") }; + } catch (err) { + return { resolved: false as const, error: err as NodeJS.ErrnoException & { cause?: NodeJS.ErrnoException }, stderr: stderrChunks.join("") }; + } +} + +it("reproduces host-spawn ENOENT when the advertised session cwd is host-nonexistent", async () => { + // The in-sandbox `remoteCwd` that does not exist on the host. Intentionally + // NOT created: this is what trips the acpx host `spawn()` `chdir`. + const sandboxParent = await makeTempDir("paperclip-acpx-remote-spawn-sandbox-"); + const remoteCwd = path.join(sandboxParent, "does-not-exist-on-host", "workspace"); + + const outcome = await ensureRealAcpSession({ cwd: remoteCwd }); + + // Before the fix the engine feeds `remoteCwd` as the host spawn cwd, so acpx's + // real `spawn()` `chdir`s into a host-nonexistent dir and fails ENOENT. This is + // the exact `ensure_session` failure the remote lane hits in production. + expect(outcome.resolved, JSON.stringify(outcome)).toBe(false); + if (outcome.resolved) return; + expect(outcome.error.name).toBe("AgentSpawnError"); + expect(outcome.error.cause?.code).toBe("ENOENT"); +}); + +it("spawnCwd redirects the host spawn to a host-valid dir while the advertised session cwd stays remoteCwd", async () => { + const sandboxParent = await makeTempDir("paperclip-acpx-remote-spawn-sandbox-"); + // Host-nonexistent in-sandbox cwd — the advertised `session/new` cwd. + const remoteCwd = path.join(sandboxParent, "does-not-exist-on-host", "workspace"); + // Host-valid dir the proxy actually spawns in (the engine's host `cwd`). + const hostSpawnCwd = await makeTempDir("paperclip-acpx-remote-spawn-host-"); + + const outcome = await ensureRealAcpSession({ cwd: remoteCwd, spawnCwd: hostSpawnCwd }); + + // With `spawnCwd` set the host `spawn()` `chdir`s into the host-valid dir, so + // the session comes up instead of failing at `ensure_session`. + expect(outcome.resolved, JSON.stringify(outcome)).toBe(true); + // The host process really ran in `spawnCwd`... + expect(outcome.stderr).toContain(`SPAWN_CWD=${await fs.realpath(hostSpawnCwd)}`); + // ...while the in-sandbox data path (the advertised `session/new` cwd) is + // unchanged — still `remoteCwd`. + expect(outcome.stderr).toContain(`SESSION_NEW_CWD=${remoteCwd}`); +}); diff --git a/packages/adapter-utils/src/acpx-engine/spawn-smoke.test.ts b/packages/adapter-utils/src/acpx-engine/spawn-smoke.test.ts index 577385c86d..3d336c7b8d 100644 --- a/packages/adapter-utils/src/acpx-engine/spawn-smoke.test.ts +++ b/packages/adapter-utils/src/acpx-engine/spawn-smoke.test.ts @@ -1,3 +1,4 @@ +import { spawn } from "node:child_process"; import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; @@ -44,3 +45,31 @@ it("spawns a real Node ACP agent with per-session env on this platform", async ( expect(stderr).toContain("nes/close"); expect(stderr).toContain("paperclip-acp-echo-agent started"); }); + +it("captures the Node error shape for a host-invalid spawn cwd", async () => { + // Regression anchor for the primitive behind the remote-lane bug: a host + // `spawn()` whose `cwd` does not exist fails BEFORE `exec`, when libuv + // `chdir`s into it. The command itself (`process.execPath`) is valid, so the + // failure is unambiguously the missing cwd — the exact condition acpx hits + // when it host-spawns the relay proxy with the in-sandbox `remoteCwd`. + const missingCwd = path.join(os.tmpdir(), "paperclip-acpx-missing-spawn-cwd", "nested", "does-not-exist"); + + const err = await new Promise((resolve, reject) => { + const child = spawn(process.execPath, ["-e", "0"], { + cwd: missingCwd, + stdio: ["pipe", "pipe", "pipe"], + }); + child.once("error", resolve); + child.once("spawn", () => { + child.kill("SIGKILL"); + reject(new Error("expected spawn to fail with a host-invalid cwd, but it started")); + }); + }); + + expect(err.code).toBe("ENOENT"); + // libuv attributes the failed pre-`exec` `chdir` to the command spawn, not to + // the missing cwd — `syscall`/`path` point at the executable. This misdirection + // is precisely why the remote-lane failure was hard to diagnose. + expect(err.syscall).toBe(`spawn ${process.execPath}`); + expect(err.path).toBe(process.execPath); +}); diff --git a/patches/acpx@0.12.0.patch b/patches/acpx@0.12.0.patch index 09d7e94d80..a01c1dc19f 100644 --- a/patches/acpx@0.12.0.patch +++ b/patches/acpx@0.12.0.patch @@ -1,38 +1,8 @@ ---- a/dist/runtime.d.ts -+++ b/dist/runtime.d.ts -@@ -266,6 +266,7 @@ - timeoutMs?: number; - probeAgent?: string; - verbose?: boolean; -+ onAgentStderr?: (chunk: string) => void; - onPermissionRequest?: (req: AcpPermissionRequest, ctx: { - signal: AbortSignal; - }) => Promise; ---- a/dist/session-options-jkYbBxGE.d.ts -+++ b/dist/session-options-jkYbBxGE.d.ts -@@ -84,6 +84,7 @@ - terminal?: boolean; - suppressSdkConsoleErrors?: boolean; - verbose?: boolean; -+ onAgentStderr?: (chunk: string) => void; - sessionOptions?: { - model?: string; - allowedTools?: string[]; ---- a/dist/runtime.js -+++ b/dist/runtime.js -@@ -744,7 +744,8 @@ - this.deps = deps; - } - createClient(options) { -- return this.deps.clientFactory?.(options) ?? new AcpClient(options); -+ const clientOptions = { ...options, onAgentStderr: this.options.onAgentStderr }; -+ return this.deps.clientFactory?.(clientOptions) ?? new AcpClient(clientOptions); - } - async readPendingPersistentClient(record, options) { - const pendingClient = this.pendingPersistentClients.get(record.acpxRecordId); +diff --git a/dist/live-checkpoint-ClPCSdrW.js b/dist/live-checkpoint-ClPCSdrW.js +index 243c9d13bcba520923b63adfddad75cf2d94362d..336a1699b80b9e99416f9da4346ca73ee2ad1626 100644 --- a/dist/live-checkpoint-ClPCSdrW.js +++ b/dist/live-checkpoint-ClPCSdrW.js -@@ -1532,7 +1532,7 @@ +@@ -1532,7 +1532,7 @@ const ZED_TAG_KEYS = /* @__PURE__ */ new Set([ "RedactedThinking", "ToolUse" ]); @@ -41,10 +11,16 @@ const OPAQUE_VALUE_PATHS = /* @__PURE__ */ new Set([ "agent_capabilities", "messages.Agent.content.ToolUse.input", -@@ -2562,1 +2562,1 @@ +@@ -2557,7 +2557,7 @@ function readCommandLineChar(state) { + escaping: false, + hasPart: true + }; - if (state.ch === "\\" && state.quote !== "'") return { + if (process.platform !== "win32" && state.ch === "\\" && state.quote !== "'") return { -@@ -3960,6 +3960,10 @@ + current: state.current, + quote: state.quote, + escaping: true, +@@ -3960,6 +3960,10 @@ var AcpClient = class { const startupStderr = []; child.stderr.on("data", (chunk) => { this.captureStartupStderr(startupStderr, chunk); @@ -55,3 +31,52 @@ if (!this.options.verbose) return; process.stderr.write(chunk); }); +@@ -3994,7 +3998,7 @@ var AcpClient = class { + geminiAcp: isGeminiAcpCommand(spawnCommand, args), + copilotAcp: isCopilotAcpCommand(spawnCommand, args), + claudeAcp: isClaudeAcpCommand(spawnCommand, args), +- spawnOptions: buildAgentSpawnOptions(this.options.cwd, this.options.authCredentials, this.options.sessionOptions?.env) ++ spawnOptions: buildAgentSpawnOptions(this.options.spawnCwd ?? this.options.cwd, this.options.authCredentials, this.options.sessionOptions?.env) + }; + } + logAgentLaunch(plan) { +diff --git a/dist/runtime.d.ts b/dist/runtime.d.ts +index ccdbe5b032521518022223733049b8b38793473b..3d4e04231e78efeecd8540735b08e1e43547ff2d 100644 +--- a/dist/runtime.d.ts ++++ b/dist/runtime.d.ts +@@ -266,6 +266,8 @@ type AcpRuntimeOptions = { + timeoutMs?: number; + probeAgent?: string; + verbose?: boolean; ++ onAgentStderr?: (chunk: string) => void; ++ spawnCwd?: string; + onPermissionRequest?: (req: AcpPermissionRequest, ctx: { + signal: AbortSignal; + }) => Promise; +diff --git a/dist/runtime.js b/dist/runtime.js +index 6c9cc999e50a11c399c68b3a0f1b7af4bc2317c0..33b5054b2906502d1d4b512bfa259bd2ba5a9f05 100644 +--- a/dist/runtime.js ++++ b/dist/runtime.js +@@ -744,7 +744,8 @@ var AcpRuntimeManager = class { + this.deps = deps; + } + createClient(options) { +- return this.deps.clientFactory?.(options) ?? new AcpClient(options); ++ const clientOptions = { ...options, onAgentStderr: this.options.onAgentStderr, spawnCwd: this.options.spawnCwd }; ++ return this.deps.clientFactory?.(clientOptions) ?? new AcpClient(clientOptions); + } + async readPendingPersistentClient(record, options) { + const pendingClient = this.pendingPersistentClients.get(record.acpxRecordId); +diff --git a/dist/session-options-jkYbBxGE.d.ts b/dist/session-options-jkYbBxGE.d.ts +index 9d37f377fb6a0828e0d2bc5a48754f3aa71509a4..680bc080fc5d6ffd266ed1b27d3d5056add9d980 100644 +--- a/dist/session-options-jkYbBxGE.d.ts ++++ b/dist/session-options-jkYbBxGE.d.ts +@@ -84,6 +84,8 @@ type AcpClientOptions = { + terminal?: boolean; + suppressSdkConsoleErrors?: boolean; + verbose?: boolean; ++ onAgentStderr?: (chunk: string) => void; ++ spawnCwd?: string; + sessionOptions?: { + model?: string; + allowedTools?: string[]; diff --git a/scripts/mcp-fixtures/servers/acp-cwd-report-agent.mjs b/scripts/mcp-fixtures/servers/acp-cwd-report-agent.mjs new file mode 100644 index 0000000000..1cfe5e3d06 --- /dev/null +++ b/scripts/mcp-fixtures/servers/acp-cwd-report-agent.mjs @@ -0,0 +1,59 @@ +#!/usr/bin/env node +// A minimal ACP agent fixture that reports, on its stderr, the working +// directory the host actually spawned it in (`process.cwd()`) and the `cwd` +// advertised on the `session/new` request. Used by the remote-lane host-spawn +// smoke test to prove the `spawnCwd` decoupling: the host `spawn()` chdir is +// redirected to a host-valid dir while the advertised session cwd is unchanged. +import { randomUUID } from "node:crypto"; +import { createInterface } from "node:readline"; + +function writeMessage(message) { + process.stdout.write(`${JSON.stringify(message)}\n`); +} + +// Emit the real spawn cwd as early as possible so a consumer capturing stderr +// sees it even if the session never advances past initialize. +process.stderr.write(`SPAWN_CWD=${process.cwd()}\n`); + +async function handleRequest(request) { + if (request.method === "initialize") { + process.stderr.write("paperclip-acp-cwd-report-agent started\n"); + return { + protocolVersion: 1, + agentCapabilities: { loadSession: false, sessionCapabilities: { close: {} } }, + agentInfo: { name: "paperclip-acp-cwd-report-agent", version: "1.0.0" }, + }; + } + if (request.method === "session/new") { + process.stderr.write(`SESSION_NEW_CWD=${request.params?.cwd ?? ""}\n`); + return { sessionId: randomUUID() }; + } + if (request.method === "session/prompt") { + writeMessage({ + jsonrpc: "2.0", + method: "session/update", + params: { + sessionId: request.params.sessionId, + update: { sessionUpdate: "agent_message_chunk", content: { type: "text", text: "ok" } }, + }, + }); + return { stopReason: "end_turn" }; + } + if (request.method === "session/close" || request.method === "session/set_mode" || request.method === "session/set_config_option") return {}; + if (request.method === "session/cancel") return null; + throw new Error(`Unsupported ACP method: ${request.method}`); +} + +const lines = createInterface({ input: process.stdin }); +lines.on("line", async (line) => { + let request; + try { + request = JSON.parse(line); + const result = await handleRequest(request); + if (request.id !== undefined && result !== null) writeMessage({ jsonrpc: "2.0", id: request.id, result }); + } catch (error) { + if (request?.id !== undefined) { + writeMessage({ jsonrpc: "2.0", id: request.id, error: { code: -32603, message: String(error?.message ?? error) } }); + } + } +});