Make ACP the default engine for local adapters (#9238)

## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - Adapter packages are the bridge between the control plane and local
agent harnesses such as Claude Code, Codex, and Gemini CLI.
> - ACP support was concentrated in a separate `acpx_local` adapter,
which made ACP feel like a separate agent choice instead of an execution
capability of the harness adapters.
> - Claude, Codex, and Gemini now have ACP-capable harnesses, so the
native adapter should own ACP selection, fallback, config, transcript
parsing, and environment diagnostics.
> - The standalone ACPX adapter still needs a compatibility path for
existing rows, but it should not be offered as an active adapter for new
agents.
> - This pull request moves the shared ACP runtime into
`@paperclipai/acpx-engine`, wires Claude/Codex/Gemini local adapters to
prefer ACP when prerequisites are available, and retires `acpx_local` to
a tombstone.
> - The benefit is one adapter per harness, richer ACP transcripts by
default where possible, and a migration path for existing Claude/Codex
ACPX agents.

## Linked Issues or Issue Description

Closes #5932 — the broken default `acpx_local` Claude path is replaced
by native `claude_local` ACP support, existing Claude/Codex ACPX rows
migrate to native adapters, and new agents no longer choose the
standalone ACPX adapter.

Refs #4893 — original merged ACPX local adapter runtime that this PR
replaces with native per-harness ACP engines.
Refs #6590 — prior ACPX-Claude seamlessness work folded into the new
native Claude ACP path.
Refs #197 — related open generic ACP/Kiro adapter work; this PR does not
close it because Kiro/custom generic ACP remains a separate adapter
decision.
Refs #7018 — related Kimi-specific `acpx_local` shell failure; this PR
retires the built-in standalone adapter but does not add a native Kimi
adapter.
Refs #8864 — related ACPX prompt/API guidance PR; this PR moves runtime
guidance into the shared/native ACP engine path instead of the old
standalone adapter.
Refs #8881 — related `acpx_local` POSIX shell failure from the old
`acpx` pin; this PR updates ACP dependencies but does not claim
custom/OMP ACP support as a first-class native adapter.
Refs #8964 — related open `acpx_local` stderr cleanup PR; this PR makes
the old runtime path obsolete for new agents but keeps it as a
non-closing reference.

Problem description:

- The standalone `acpx_local` adapter duplicates Claude/Codex agent
choices that already have first-class local adapters.
- ACP should be an execution engine capability of each harness adapter
when the underlying harness supports ACP.
- Existing `acpx_local` agents should either migrate to native harness
adapters or fail with an explicit retirement message instead of silently
falling back to the process adapter.

## What Changed

- Added `@paperclipai/acpx-engine` as the shared ACP execution,
session-codec, CLI formatter, and UI parser package.
- Wired `claude_local`, `codex_local`, and `gemini_local` to auto-select
ACP by default when prerequisites pass, with `engine=cli` opt-out and
`engine=acp` strict mode.
- Added ACP config schema/UI fields, environment checks, session-codec
preservation, transcript parsing, and adapter capability metadata for
the native adapters.
- Retired `acpx_local` to a server tombstone, removed its
UI/package/runtime image surface, and added a migration for existing
Claude/Codex ACPX agents.
- Updated package manifests, lockfile, release tooling, docs, Kubernetes
sandbox defaults, and tests.

## Verification

- `corepack pnpm --filter @paperclipai/acpx-engine typecheck`
- `corepack pnpm --filter @paperclipai/adapter-claude-local typecheck`
- `corepack pnpm --filter @paperclipai/adapter-codex-local typecheck`
- `corepack pnpm --filter @paperclipai/adapter-gemini-local typecheck`
- `corepack pnpm --filter @paperclipai/acpx-engine exec vitest run`
- `corepack pnpm --filter @paperclipai/adapter-claude-local exec vitest
run src/server/acp.test.ts src/server/execute.acp-fallback.test.ts
src/ui/build-config.test.ts`
- `corepack pnpm --filter @paperclipai/adapter-codex-local exec vitest
run src/server/acp.test.ts src/ui/build-config.test.ts`
- `corepack pnpm --filter @paperclipai/adapter-gemini-local exec vitest
run src/server/acp.test.ts src/ui/build-config.test.ts
src/ui/parse-stdout.test.ts`
- `corepack pnpm --filter @paperclipai/plugin-sdk ensure-build-deps &&
corepack pnpm --filter @paperclipai/server exec tsc --noEmit`
- `corepack pnpm --filter @paperclipai/server exec vitest run
src/__tests__/adapter-routes.test.ts
src/__tests__/adapter-session-codecs.test.ts
src/__tests__/adapter-models.test.ts`
- `corepack pnpm --filter @paperclipai/ui typecheck`
- `corepack pnpm --filter @paperclipai/ui exec vitest run
src/adapters/metadata.test.ts
src/adapters/adapter-display-registry.test.ts
src/components/AgentConfigForm.test.ts
src/components/AgentConfigForm.render.test.tsx
src/components/transcript/RunTranscriptView.test.tsx`
- `node --test scripts/bootstrap-npm-package.test.mjs
scripts/release-package-map.test.mjs
scripts/verify-release-registry-state.test.mjs`

Note: the server typecheck script calls `pnpm` internally; this dev
shell exposes pnpm through Corepack only, so I ran the two script steps
manually with `corepack pnpm`.

## Risks

- Migration changes existing `acpx_local` Claude/Codex agents to native
adapter types and clears old ACPX task sessions/runtime state.
- Custom ACP commands remain on the retired tombstone and will need a
separate future adapter/plugin path.
- ACP auto-selection depends on local Node and ACP server command
prerequisites; remote and unsupported environments fall back to CLI
unless `engine=acp` is explicit.
- `@paperclipai/acpx-engine` is a new public package and needs npm
trusted-publishing bootstrap before release automation can publish it.

> For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and
discuss it in `#dev` before opening the PR. Feature PRs that overlap
with planned core work may need to be redirected — check the roadmap
first. See `CONTRIBUTING.md`.

## Model Used

OpenAI GPT-5 via Codex coding agent. Exact hosted model build and
context-window size are not exposed in this runtime. Tool use included
shell execution, repository editing, GitHub CLI operations, and local
test/typecheck execution.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Devin Foley 2026-07-08 19:05:03 -07:00 committed by GitHub
parent 187a929d97
commit eedc7ddef2
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
128 changed files with 4847 additions and 3483 deletions

View File

@ -24,7 +24,6 @@ COPY packages/adapter-utils/package.json packages/adapter-utils/
COPY packages/mcp-server/package.json packages/mcp-server/
COPY packages/skills-catalog/package.json packages/skills-catalog/
COPY packages/teams-catalog/package.json packages/teams-catalog/
COPY packages/adapters/acpx-local/package.json packages/adapters/acpx-local/
COPY packages/adapters/claude-local/package.json packages/adapters/claude-local/
COPY packages/adapters/codex-local/package.json packages/adapters/codex-local/
COPY packages/adapters/cursor-cloud/package.json packages/adapters/cursor-cloud/

View File

@ -37,7 +37,6 @@
},
"dependencies": {
"@clack/prompts": "^0.10.0",
"@paperclipai/adapter-acpx-local": "workspace:*",
"@paperclipai/adapter-claude-local": "workspace:*",
"@paperclipai/adapter-codex-local": "workspace:*",
"@paperclipai/adapter-cursor-cloud": "workspace:*",

View File

@ -1,5 +1,4 @@
import type { CLIAdapterModule } from "@paperclipai/adapter-utils";
import { printAcpxStreamEvent } from "@paperclipai/adapter-acpx-local/cli";
import { printClaudeStreamEvent } from "@paperclipai/adapter-claude-local/cli";
import { printCodexStreamEvent } from "@paperclipai/adapter-codex-local/cli";
import { printCursorStreamEvent } from "@paperclipai/adapter-cursor-local/cli";
@ -19,11 +18,6 @@ const claudeLocalCLIAdapter: CLIAdapterModule = {
formatStdoutEvent: printClaudeStreamEvent,
};
const acpxLocalCLIAdapter: CLIAdapterModule = {
type: "acpx_local",
formatStdoutEvent: printAcpxStreamEvent,
};
const codexLocalCLIAdapter: CLIAdapterModule = {
type: "codex_local",
formatStdoutEvent: printCodexStreamEvent,
@ -76,7 +70,6 @@ const openclawGatewayCLIAdapter: CLIAdapterModule = {
const adaptersByType = new Map<string, CLIAdapterModule>(
[
acpxLocalCLIAdapter,
claudeLocalCLIAdapter,
codexLocalCLIAdapter,
openCodeLocalCLIAdapter,

View File

@ -195,14 +195,14 @@ PR CI now checks changed release-enabled package manifests against npm. That cat
The first publish of a brand-new package still needs one human maintainer with npm write access.
After that, trusted publishing can take over.
Example for `@paperclipai/adapter-acpx-local` from the repo root:
Example for a newly added public package from the repo root:
```bash
# safe preview
pnpm run release:bootstrap-package -- @paperclipai/adapter-acpx-local
pnpm run release:bootstrap-package -- @paperclipai/new-package
# one-time first publish from an authenticated maintainer machine
pnpm run release:bootstrap-package -- @paperclipai/adapter-acpx-local --publish --otp 123456
pnpm run release:bootstrap-package -- @paperclipai/new-package --publish --otp 123456
```
The helper script:
@ -223,7 +223,7 @@ The helper now requires `--otp <code>` up front for `--publish`, so it fails bef
After that first publish succeeds:
1. open `https://www.npmjs.com/package/@paperclipai/adapter-acpx-local`
1. open `https://www.npmjs.com/package/@paperclipai/new-package`
2. go to `Settings``Trusted publishing`
3. add repository `paperclipai/paperclip`
4. set workflow filename to `release.yml`

View File

@ -1,14 +0,0 @@
# syntax=docker/dockerfile:1.6
ARG BASE_TAG=dev
FROM paperclipai/agent-runtime-base:${BASE_TAG}
USER root
# acpx is the ACPX wrapper that bridges to claude / codex backends.
# Verified npm package name: "acpx" (bin 'acpx' → dist/cli.js).
RUN npm install -g acpx@latest \
&& { chown -R 1000:1000 /usr/lib/node_modules || true; }
USER 1000:1000
# Verify the CLI is on PATH for the shim's exec.LookPath
RUN command -v acpx >/dev/null 2>&1 || (echo "acpx not on PATH"; exit 1)

View File

@ -10,7 +10,7 @@ Container images for running coding-agent harnesses in sandboxed environments (f
- **`agent-runtime-codex`**: Extends base with `@openai/codex`.
- **`agent-runtime-gemini`**: Extends base with `@google/gemini-cli` plus headless auth-mode settings.
- **`agent-runtime-claude`**: Extends base with `@anthropic-ai/claude-code` (symlinked as `claude-code`).
- **`agent-runtime-acpx`** / **`agent-runtime-hermes`**: Dockerfiles included in the bake group, not in the default publish scope (hermes is a stub until a CLI package exists).
- **`agent-runtime-hermes`**: Dockerfile included in the bake group, not in the default publish scope (stub until a CLI package exists).
## Base Image Contents

View File

@ -1,5 +1,5 @@
group "default" {
targets = ["base", "claude", "codex", "gemini", "acpx", "opencode", "pi", "hermes"]
targets = ["base", "claude", "codex", "gemini", "opencode", "pi", "hermes"]
}
variable "VERSION" { default = "dev" }
@ -51,19 +51,6 @@ target "gemini" {
}
}
target "acpx" {
context = "."
dockerfile = "docker/agent-runtime/Dockerfile.acpx"
platforms = ["linux/amd64"]
tags = ["${REGISTRY}/agent-runtime-acpx:${VERSION}"]
args = {
BASE_TAG = "${VERSION}"
}
contexts = {
"paperclipai/agent-runtime-base:${VERSION}" = "target:base"
}
}
target "opencode" {
context = "."
dockerfile = "docker/agent-runtime/Dockerfile.opencode"

View File

@ -18,9 +18,8 @@ When a heartbeat fires, Paperclip:
| Adapter | Type Key | Description |
|---------|----------|-------------|
| [Claude Code](/adapters/claude-local) | `claude_local` | Runs Claude Code CLI locally |
| [Codex](/adapters/codex-local) | `codex_local` | Runs OpenAI Codex CLI locally |
| ACPX Local | `acpx_local` | Runs Claude, Codex, or a custom ACP agent through ACPX with live structured event streaming |
| [Claude Code](/adapters/claude-local) | `claude_local` | Runs Claude Code CLI locally, with a native ACP engine when available |
| [Codex](/adapters/codex-local) | `codex_local` | Runs OpenAI Codex CLI locally, with a native ACP engine when available |
| [Gemini CLI](/adapters/gemini-local) | `gemini_local` | Runs Gemini CLI locally (experimental — adapter package exists, not yet in stable type enum) |
| OpenCode | `opencode_local` | Runs OpenCode CLI locally (multi-provider `provider/model`) |
| Cursor | `cursor` | Runs Cursor in background mode |
@ -115,8 +114,8 @@ my-adapter/
## Choosing an Adapter
- **Need a coding agent?** Use `claude_local`, `codex_local`, `acpx_local`, `opencode_local`, `hermes_local`, or install `droid_local` as an external plugin
- **Need the richest live run feedback (especially for sandbox workers)?** Use `acpx_local` — see [Feedback granularity](#feedback-granularity)
- **Need a coding agent?** Use `claude_local`, `codex_local`, `opencode_local`, `hermes_local`, or install `droid_local` as an external plugin
- **Need the richest live run feedback?** Use `claude_local`, `codex_local`, or `gemini_local` with `adapterConfig.engine` set to `acp` when the execution environment satisfies the ACP prerequisites — see [Feedback granularity](#feedback-granularity)
- **Need Hermes on another host or already running as a service?** Use `hermes_gateway`
- **Need to run a script or command?** Use `process`
- **Need to call a custom external service?** Use `http`
@ -128,11 +127,11 @@ Adapter choice determines how much structured, live detail a run's transcript ca
Rough tiers, richest first:
1. **`acpx_local` — full structured event stream.** ACPX emits a JSONL event per meaningful runtime moment: `acpx.session` (agent, mode, session identity), `acpx.status` (progress text plus context-window usage), `acpx.text_delta` (assistant/thinking token deltas), `acpx.tool_call` (tool title, call id, and status updates as the call progresses), `acpx.result` (stop reason summary), and `acpx.error` (code, message, retryability). The transcript renders these as live-updating message, thinking, tool, and status blocks, and repeated `acpx.tool_call` status updates fold into a single tool card instead of stacking duplicates.
1. **Native ACP engine (`claude_local`, `codex_local`, or `gemini_local` with `engine: "acp"`) — full structured event stream.** ACP emits a JSONL event per meaningful runtime moment: `acpx.session` (agent, mode, session identity), `acpx.status` (progress text plus context-window usage), `acpx.text_delta` (assistant/thinking token deltas), `acpx.tool_call` (tool title, call id, and status updates as the call progresses), `acpx.result` (stop reason summary), and `acpx.error` (code, message, retryability). The transcript renders these as live-updating message, thinking, tool, and status blocks, and repeated `acpx.tool_call` status updates fold into a single tool card instead of stacking duplicates.
2. **CLI wrappers (`claude_local`, `codex_local`, `cursor`, `opencode_local`, …).** These parse each CLI's own streaming JSON output. You get assistant text, tool calls/results, and a final usage/cost summary, but granularity is limited to what the CLI prints — some emit tool progress, others only call/finish pairs.
3. **Generic adapters (`process`, `http`).** Plain stdout/stderr lines with no structured transcript — you see raw output only.
**Recommendation:** for sandbox workers, prefer `acpx_local`. Sandbox run logs are streamed live, so the richer the event stream, the more useful the live transcript and status line are while a remote run is in flight. ACPX's status events (including context usage) and incremental tool-call updates give the closest thing to watching the agent work locally.
**Recommendation:** use the native ACP engine on `claude_local`, `codex_local`, or `gemini_local` when the selected execution environment supports it. Rich ACP status events (including context usage) and incremental tool-call updates give the closest thing to watching the agent work locally.
## UI Parser Contract

View File

@ -39,6 +39,10 @@
"clean": "rm -rf dist",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"acpx": "^0.12.0",
"picocolors": "^1.1.1"
},
"devDependencies": {
"@types/node": "^22.19.21",
"typescript": "^5.7.3"

View File

@ -0,0 +1,21 @@
export const DEFAULT_ACP_ENGINE_AGENT = "claude";
export const DEFAULT_ACP_ENGINE_MODE = "persistent";
export const DEFAULT_ACP_ENGINE_PERMISSION_MODE = "approve-all";
export const DEFAULT_ACP_ENGINE_NON_INTERACTIVE_PERMISSIONS = "deny";
export const DEFAULT_ACP_ENGINE_TIMEOUT_SEC = 0;
export const DEFAULT_ACP_ENGINE_WARM_HANDLE_IDLE_MS = 0;
export const ACPX_ADAPTER_AGENT_IDS = {
claude_local: "claude",
codex_local: "codex",
gemini_local: "gemini",
custom_acp: "custom",
} as const;
export type AcpxAdapterType = keyof typeof ACPX_ADAPTER_AGENT_IDS;
export type AcpxAgentId = (typeof ACPX_ADAPTER_AGENT_IDS)[AcpxAdapterType];
export function acpxAgentIdForAdapterType(adapterType: string | null | undefined): AcpxAgentId | null {
if (!adapterType) return null;
return ACPX_ADAPTER_AGENT_IDS[adapterType as AcpxAdapterType] ?? null;
}

View File

@ -1,10 +1,20 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { execFile } from "node:child_process";
import { promisify } from "node:util";
import { afterEach, describe, expect, it } from "vitest";
import type { AcpRuntimeOptions } from "acpx/runtime";
import { DEFAULT_REMOTE_SANDBOX_ADAPTER_TIMEOUT_SEC } from "@paperclipai/adapter-utils/execution-target";
import { createAcpxLocalExecutor } from "./execute.js";
import {
createAcpxEngineExecutor,
findAncestorBin,
geminiVersionSupportsNativeAcpFlag,
parseGeminiVersionParts,
rewriteGeminiAcpFlagForVersion,
} from "./execute.js";
const execFileAsync = promisify(execFile);
const tempRoots: string[] = [];
@ -63,13 +73,14 @@ async function runExecutor(
options: {
context?: Record<string, unknown>;
executionTransport?: Record<string, unknown>;
authToken?: string;
executionTarget?: Record<string, unknown>;
} = {},
) {
const runtimeOptions: Record<string, unknown>[] = [];
const meta: Record<string, unknown>[] = [];
const logs: Array<{ stream: string; text: string }> = [];
const execute = createAcpxLocalExecutor({
const execute = createAcpxEngineExecutor({
createRuntime: (options) => {
runtimeOptions.push(options as unknown as Record<string, unknown>);
return buildRuntime() as never;
@ -86,6 +97,7 @@ async function runExecutor(
config,
context: options.context ?? {},
executionTransport: options.executionTransport,
authToken: options.authToken,
executionTarget: options.executionTarget,
onLog: async (stream: "stdout" | "stderr", text: string) => {
logs.push({ stream, text });
@ -99,7 +111,53 @@ async function runExecutor(
return { logs, meta, runtimeOptions, result };
}
describe("acpx_local runtime skill isolation", () => {
describe("shared ACPX engine runtime behavior", () => {
it("includes Paperclip env and API access notes in the ACPX prompt without leaking the token", async () => {
const { meta } = await runExecutor(
{ agent: "custom", agentCommand: "node ./fake-acp.js" },
{
authToken: "runtime-secret-token",
context: {
taskId: "issue-1",
wakeReason: "issue_assigned",
paperclipWake: {
reason: "issue_assigned",
issue: { id: "issue-1", identifier: "TEST-1" },
},
},
},
);
const prompt = String(meta[0]?.prompt ?? "");
const promptMetrics = meta[0]?.promptMetrics as Record<string, number> | undefined;
expect(prompt).toContain("Paperclip runtime note:");
expect(prompt).toContain("PAPERCLIP_AGENT_ID");
expect(prompt).toContain("PAPERCLIP_API_KEY");
expect(prompt).toContain("PAPERCLIP_WAKE_PAYLOAD_JSON");
expect(prompt).toContain("Paperclip API access note:");
expect(prompt).toContain('PAPERCLIP_API_BASE="${PAPERCLIP_API_URL%/}"; PAPERCLIP_API_BASE="${PAPERCLIP_API_BASE%/api}"');
expect(prompt).toContain("$PAPERCLIP_API_BASE/api/agents/me");
expect(prompt).toContain("$PAPERCLIP_API_BASE/api/issues/$PAPERCLIP_TASK_ID");
expect(prompt).toContain("X-Paperclip-Run-Id");
expect(prompt).not.toContain("$PAPERCLIP_API_URL/api/");
expect(prompt).not.toContain("/api/issues/{id}");
expect(prompt).not.toContain("-d '{...}'");
expect(prompt).not.toContain("runtime-secret-token");
expect(promptMetrics?.runtimeNoteChars).toBeGreaterThan(0);
});
it("does not show a scoped issue API command when the task id is unavailable", async () => {
const { meta } = await runExecutor(
{ agent: "custom", agentCommand: "node ./fake-acp.js" },
{ authToken: "runtime-secret-token" },
);
const prompt = String(meta[0]?.prompt ?? "");
expect(prompt).toContain("Paperclip API access note:");
expect(prompt).toContain("Use a real issue id from the current context before making issue write requests.");
expect(prompt).not.toContain("$PAPERCLIP_API_BASE/api/issues/$PAPERCLIP_TASK_ID");
});
it.skipIf(process.platform === "win32")("materializes ACPX Claude skills without symlinked descendants", async () => {
const root = await makeTempRoot();
const skillRoot = path.join(root, "skills");
@ -400,7 +458,7 @@ describe("acpx_local runtime skill isolation", () => {
}
const logs: Array<{ stream: string; text: string }> = [];
const execute = createAcpxLocalExecutor({
const execute = createAcpxEngineExecutor({
createRuntime: () => ({
ensureSession: async () => {
throw new FakeAcpRuntimeError(
@ -462,7 +520,7 @@ describe("acpx_local runtime skill isolation", () => {
const stateDir = path.join(root, "state");
const runtimeOptions: AcpRuntimeOptions[] = [];
const execute = createAcpxLocalExecutor({
const execute = createAcpxEngineExecutor({
createRuntime: (options) => {
runtimeOptions.push(options as unknown as AcpRuntimeOptions);
return buildRuntime() as never;
@ -485,7 +543,7 @@ describe("acpx_local runtime skill isolation", () => {
expect(result.exitCode).toBe(0);
const verboseFlags = runtimeOptions.map((options) => (options as { verbose?: boolean }).verbose);
// verbose is scoped to the claude agent (PAPA-388); the custom agent here
// verbose is scoped to the claude agent; the custom agent here
// should not opt in to ACPX runtime verbose session-event logs.
expect(verboseFlags.every((flag) => flag === false)).toBe(true);
@ -500,9 +558,60 @@ describe("acpx_local runtime skill isolation", () => {
expect(wrapper).toContain("exec node ./fake-acp.js");
});
it.skipIf(process.platform === "win32")("drops benign ACP nes/close cleanup stderr but keeps it in the run log", async () => {
const root = await makeTempRoot();
const stateDir = path.join(root, "state");
const execute = createAcpxEngineExecutor({
createRuntime: () => buildRuntime() as never,
});
const fakeAgentPath = path.join(root, "fake-acp.sh");
await fs.writeFile(
fakeAgentPath,
[
"#!/usr/bin/env bash",
"echo \"Error handling request { method: 'nes/close' } { code: -32601, message: '\\\"Method not found\\\": nes/close' }\" >&2",
"echo \"some genuine crash: TypeError: x is not a function\" >&2",
"",
].join("\n"),
{ mode: 0o700 },
);
const result = await execute({
runId: "run-nes-close-1",
agent: { id: "agent-1", companyId: "company-1" },
runtime: {},
config: {
agent: "custom",
agentCommand: fakeAgentPath,
stateDir,
},
context: {},
onLog: async () => {},
onMeta: async () => {},
} as never);
expect(result.exitCode).toBe(0);
const wrapperFile = (await fs.readdir(path.join(stateDir, "wrappers"))).find((name) => name.endsWith(".sh"));
expect(wrapperFile).toBeTruthy();
const wrapperPath = path.join(stateDir, "wrappers", wrapperFile!);
const { stderr } = await execFileAsync("bash", [wrapperPath], {
env: { ...process.env, PAPERCLIP_RUN_ID: "run-nes-close-1" },
});
expect(stderr).not.toContain("nes/close");
expect(stderr).toContain("some genuine crash: TypeError: x is not a function");
const runLog = await fs.readFile(path.join(stateDir, "run-stderr", "run-nes-close-1.log"), "utf8");
expect(runLog).toContain("nes/close");
expect(runLog).toContain("some genuine crash: TypeError: x is not a function");
});
it("passes Paperclip env through the ACP agent wrapper instead of process.env", async () => {
let observedApiKeyDuringStream: string | undefined;
const execute = createAcpxLocalExecutor({
const execute = createAcpxEngineExecutor({
createRuntime: () => ({
ensureSession: async () => ({
backendSessionId: "backend-session",
@ -662,7 +771,7 @@ describe("acpx_local runtime skill isolation", () => {
const verboseByAgent: Record<string, boolean | undefined> = {};
for (const agent of ["claude", "codex", "custom"] as const) {
const runtimeOptions: AcpRuntimeOptions[] = [];
const execute = createAcpxLocalExecutor({
const execute = createAcpxEngineExecutor({
createRuntime: (options) => {
runtimeOptions.push(options as AcpRuntimeOptions);
return buildRuntime() as never;
@ -702,9 +811,181 @@ describe("acpx_local runtime skill isolation", () => {
expect(await pathExists(path.join(cwd, ".claude", "settings.local.json"))).toBe(false);
});
it("changes the ACPX session fingerprint when the resolved secret manifest rotates", async () => {
const root = await makeTempRoot();
const baseConfig = {
agent: "custom",
agentCommand: "node ./fake-acp.js",
stateDir: path.join(root, "state"),
};
const first = await runExecutor(baseConfig, {
context: {
paperclipSecrets: {
manifest: [
{
configPath: "env.API_TOKEN",
envKey: "API_TOKEN",
secretId: "secret-1",
bindingId: "binding-1",
secretKey: "api-token",
version: 1,
provider: "local_encrypted",
},
],
},
},
});
const second = await runExecutor(baseConfig, {
context: {
paperclipSecrets: {
manifest: [
{
configPath: "env.API_TOKEN",
envKey: "API_TOKEN",
secretId: "secret-1",
bindingId: "binding-1",
secretKey: "api-token",
version: 2,
provider: "local_encrypted",
},
],
},
},
});
expect(first.result.sessionParams?.configFingerprint).toBeTypeOf("string");
expect(second.result.sessionParams?.configFingerprint).toBeTypeOf("string");
expect(first.result.sessionParams?.configFingerprint).not.toBe(second.result.sessionParams?.configFingerprint);
});
});
describe("acpx_local execution timeouts", () => {
describe("findAncestorBin", () => {
async function writeFakeBin(dir: string, name: string) {
const binDir = path.join(dir, "node_modules", ".bin");
await fs.mkdir(binDir, { recursive: true });
const binPath = path.join(binDir, name);
await fs.writeFile(binPath, "#!/usr/bin/env bash\necho ok\n", { mode: 0o755 });
return binPath;
}
it("finds the binary in the start directory's own node_modules/.bin", async () => {
const root = await makeTempRoot();
const packageDir = path.join(root, "node_modules", "@paperclipai", "adapter-utils");
await fs.mkdir(packageDir, { recursive: true });
const expectedBin = await writeFakeBin(packageDir, "claude-agent-acp");
const resolved = await findAncestorBin(packageDir, "claude-agent-acp");
expect(resolved).toBe(expectedBin);
});
it("finds the binary hoisted to an ancestor node_modules/.bin", async () => {
const root = await makeTempRoot();
const packageDir = path.join(root, "node_modules", "@paperclipai", "adapter-utils");
await fs.mkdir(packageDir, { recursive: true });
const expectedBin = await writeFakeBin(root, "claude-agent-acp");
const resolved = await findAncestorBin(packageDir, "claude-agent-acp");
expect(resolved).toBe(expectedBin);
});
it("returns null when the binary is not present in any ancestor", async () => {
const root = await makeTempRoot();
const packageDir = path.join(root, "node_modules", "@paperclipai", "adapter-utils");
await fs.mkdir(packageDir, { recursive: true });
const resolved = await findAncestorBin(packageDir, "claude-agent-acp");
expect(resolved).toBeNull();
});
it("terminates at the filesystem root instead of looping forever", async () => {
const resolved = await findAncestorBin("/", "definitely-not-a-real-bin-name-xyz");
expect(resolved).toBeNull();
});
});
describe("gemini ACP flag selection", () => {
it("parses semantic version parts from gemini --version output", () => {
expect(parseGeminiVersionParts("0.30.0")).toEqual([0, 30, 0]);
expect(parseGeminiVersionParts("gemini-cli v1.2.3\n")).toEqual([1, 2, 3]);
expect(parseGeminiVersionParts("no version here")).toBeNull();
expect(parseGeminiVersionParts(null)).toBeNull();
});
it("keeps --acp for gemini >= 0.33.0 and unknown versions", () => {
expect(geminiVersionSupportsNativeAcpFlag([0, 33, 0])).toBe(true);
expect(geminiVersionSupportsNativeAcpFlag([0, 34, 1])).toBe(true);
expect(geminiVersionSupportsNativeAcpFlag([1, 0, 0])).toBe(true);
expect(geminiVersionSupportsNativeAcpFlag(null)).toBe(true);
expect(rewriteGeminiAcpFlagForVersion("gemini --acp", [0, 33, 0])).toBe("gemini --acp");
});
it("downgrades --acp to --experimental-acp for gemini < 0.33.0", () => {
expect(geminiVersionSupportsNativeAcpFlag([0, 30, 0])).toBe(false);
expect(geminiVersionSupportsNativeAcpFlag([0, 32, 9])).toBe(false);
expect(rewriteGeminiAcpFlagForVersion("gemini --acp", [0, 30, 0])).toBe("gemini --experimental-acp");
expect(rewriteGeminiAcpFlagForVersion("/opt/bin/gemini --acp", [0, 30, 0])).toBe(
"/opt/bin/gemini --experimental-acp",
);
});
async function writeFakeGemini(binDir: string, version: string) {
await fs.mkdir(binDir, { recursive: true });
const binPath = path.join(binDir, "gemini");
await fs.writeFile(binPath, `#!/bin/sh\necho "${version}"\n`, { mode: 0o755 });
}
function pathWithFakeBin(binDir: string): string {
return [binDir, process.env.PATH ?? ""].filter(Boolean).join(path.delimiter);
}
async function readGeminiWrapperScript(stateDir: string): Promise<string> {
const wrappersDir = path.join(stateDir, "wrappers");
const names = await fs.readdir(wrappersDir);
const scriptName = names.find((name) => name.endsWith(".sh"));
expect(scriptName).toBeTypeOf("string");
return fs.readFile(path.join(wrappersDir, scriptName!), "utf8");
}
it("writes a gemini wrapper that execs a multi-word command instead of a single quoted token", async () => {
const root = await makeTempRoot();
const stateDir = path.join(root, "state");
const binDir = path.join(root, "bin");
await writeFakeGemini(binDir, "0.33.0");
await runExecutor({
agent: "gemini",
stateDir,
env: { HOME: path.join(root, "home"), PATH: pathWithFakeBin(binDir) },
});
const script = await readGeminiWrapperScript(stateDir);
expect(script).toContain('exec gemini --acp "$@"');
expect(script).not.toContain("'gemini --acp'");
});
it("downgrades the built-in gemini command flag when the local CLI predates --acp", async () => {
const root = await makeTempRoot();
const stateDir = path.join(root, "state");
const binDir = path.join(root, "bin");
await writeFakeGemini(binDir, "0.30.0");
await runExecutor({
agent: "gemini",
stateDir,
env: { HOME: path.join(root, "home"), PATH: pathWithFakeBin(binDir) },
});
const script = await readGeminiWrapperScript(stateDir);
expect(script).toContain('exec gemini --experimental-acp "$@"');
});
});
describe("shared ACP engine execution timeouts", () => {
it("applies the 4h sandbox backstop when timeoutSec is unset on a sandbox execution target", async () => {
const root = await makeTempRoot();
const stateDir = path.join(root, "state");
@ -837,7 +1118,7 @@ describe("acpx_local execution timeouts", () => {
releaseTurn = resolve;
});
const execute = createAcpxLocalExecutor({
const execute = createAcpxEngineExecutor({
createRuntime: () => ({
ensureSession: async () => ({
backendSessionId: "backend-session",

View File

@ -1,6 +1,8 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { execFile } from "node:child_process";
import { promisify } from "node:util";
import { createHash, randomUUID } from "node:crypto";
import { fileURLToPath } from "node:url";
import type { AdapterExecutionContext, AdapterExecutionResult } from "@paperclipai/adapter-utils";
@ -21,6 +23,7 @@ import {
buildPaperclipEnv,
ensureAbsoluteDirectory,
ensurePathInEnv,
ensurePaperclipSkillSymlink,
joinPromptSections,
materializePaperclipSkillCopy,
parseObject,
@ -30,6 +33,7 @@ import {
renderTemplate,
resolvePaperclipInstanceRootForAdapter,
resolvePaperclipDesiredSkillNames,
removeMaintainerOnlySkillSymlinks,
rewriteWorkspaceCwdEnvVarsForExecution,
shapePaperclipWorkspaceEnvForExecution,
stringifyPaperclipWakePayload,
@ -50,21 +54,21 @@ import {
type AcpRuntimeTurnResult,
} from "acpx/runtime";
import {
DEFAULT_ACPX_LOCAL_AGENT,
DEFAULT_ACPX_LOCAL_MODE,
DEFAULT_ACPX_LOCAL_NON_INTERACTIVE_PERMISSIONS,
DEFAULT_ACPX_LOCAL_PERMISSION_MODE,
DEFAULT_ACPX_LOCAL_TIMEOUT_SEC,
DEFAULT_ACPX_LOCAL_WARM_HANDLE_IDLE_MS,
} from "../index.js";
DEFAULT_ACP_ENGINE_AGENT,
DEFAULT_ACP_ENGINE_MODE,
DEFAULT_ACP_ENGINE_NON_INTERACTIVE_PERMISSIONS,
DEFAULT_ACP_ENGINE_PERMISSION_MODE,
DEFAULT_ACP_ENGINE_TIMEOUT_SEC,
DEFAULT_ACP_ENGINE_WARM_HANDLE_IDLE_MS,
} from "./constants.js";
const __moduleDir = path.dirname(fileURLToPath(import.meta.url));
const defaultModuleDir = path.dirname(fileURLToPath(import.meta.url));
const WRAPPER_CLEANUP_RETENTION_MS = 15 * 60 * 1000;
const PAPERCLIP_MANAGED_CODEX_SKILLS_MANIFEST = ".paperclip-managed-skills.json";
type AcpxRuntimeFactory = (options: AcpRuntimeOptions) => AcpRuntime;
interface RuntimeCacheEntry {
export interface RuntimeCacheEntry {
runtime: AcpRuntime;
handle: AcpRuntimeHandle;
fingerprint: string;
@ -72,10 +76,19 @@ interface RuntimeCacheEntry {
cleanupTimer?: NodeJS.Timeout;
}
interface ExecuteDeps {
interface AcpxEngineSettings {
adapterType: string;
moduleDir: string;
packageRootDir: string;
}
export interface AcpxEngineExecutorOptions {
createRuntime?: AcpxRuntimeFactory;
now?: () => number;
warmHandles?: Map<string, RuntimeCacheEntry>;
adapterType?: string;
moduleDir?: string;
packageRootDir?: string;
}
interface AcpxPreparedRuntime {
@ -108,6 +121,15 @@ interface AcpxPreparedRuntime {
const defaultWarmHandles = new Map<string, RuntimeCacheEntry>();
function resolveEngineSettings(options: AcpxEngineExecutorOptions): AcpxEngineSettings {
const moduleDir = path.resolve(options.moduleDir ?? defaultModuleDir);
return {
adapterType: options.adapterType?.trim() || "acp_engine",
moduleDir,
packageRootDir: path.resolve(options.packageRootDir ?? path.resolve(moduleDir, "../..")),
};
}
function stableJson(value: unknown): string {
if (Array.isArray(value)) return `[${value.map(stableJson).join(",")}]`;
if (value && typeof value === "object") {
@ -133,31 +155,101 @@ function defaultPaperclipInstanceDir(): string {
}
function defaultStateDir(companyId: string, agentId: string): string {
return path.join(defaultPaperclipInstanceDir(), "companies", companyId, "acpx-local", "agents", agentId);
return path.join(defaultPaperclipInstanceDir(), "companies", companyId, "acp-engine", "agents", agentId);
}
function resolveManagedCodexHomeDir(companyId: string): string {
return path.join(defaultPaperclipInstanceDir(), "companies", companyId, "codex-home");
}
function packageRootDir(): string {
return path.resolve(__moduleDir, "../..");
// Walk up from startDir looking for `node_modules/.bin/<binName>`. This matches
// npm/pnpm binary hoisting in packaged installs while preserving monorepo dev.
export async function findAncestorBin(startDir: string, binName: string): Promise<string | null> {
let current = path.resolve(startDir);
while (true) {
const candidate = path.join(current, "node_modules", ".bin", binName);
if (await pathExists(candidate)) return candidate;
const parent = path.dirname(current);
if (parent === current) return null;
current = parent;
}
}
function resolveBuiltInAgentCommand(agent: string): string | null {
const binName =
agent === "claude"
? "claude-agent-acp"
: agent === "codex"
? "codex-acp"
: null;
interface BuiltInAgentCommand {
command: string;
shellCommand: string;
}
async function resolveBuiltInAgentCommand(agent: string, packageRootDir: string): Promise<BuiltInAgentCommand | null> {
if (agent === "gemini") {
return { command: "gemini --acp", shellCommand: "gemini --acp" };
}
const binName = agent === "claude" ? "claude-agent-acp" : agent === "codex" ? "codex-acp" : null;
if (!binName) return null;
return path.join(packageRootDir(), "node_modules", ".bin", binName);
const resolved = (await findAncestorBin(packageRootDir, binName)) ?? binName;
return { command: resolved, shellCommand: shellQuote(resolved) };
}
const execFileAsync = promisify(execFile);
// Gemini CLI renamed --experimental-acp to --acp in 0.33.0. acpx normally
// rewrites the flag itself, but the agent wrapper script hides the gemini
// command from acpx's detection, so the engine must downgrade it here.
const GEMINI_NATIVE_ACP_FLAG_MIN_VERSION = [0, 33, 0] as const;
const GEMINI_VERSION_PROBE_TIMEOUT_MS = 2000;
export function parseGeminiVersionParts(output: string | null | undefined): number[] | null {
const match = output?.match(/(\d+)\.(\d+)\.(\d+)/);
if (!match) return null;
return [Number(match[1]), Number(match[2]), Number(match[3])];
}
export function geminiVersionSupportsNativeAcpFlag(parts: number[] | null): boolean {
if (!parts) return true;
for (let index = 0; index < GEMINI_NATIVE_ACP_FLAG_MIN_VERSION.length; index += 1) {
const diff = (parts[index] ?? 0) - GEMINI_NATIVE_ACP_FLAG_MIN_VERSION[index];
if (diff !== 0) return diff > 0;
}
return true;
}
export function rewriteGeminiAcpFlagForVersion(commandShell: string, versionParts: number[] | null): string {
if (geminiVersionSupportsNativeAcpFlag(versionParts)) return commandShell;
return commandShell
.trim()
.split(/\s+/)
.map((token) => (token === "--acp" ? "--experimental-acp" : token))
.join(" ");
}
function geminiAcpCommandTokens(commandShell: string): string[] | null {
const tokens = commandShell.trim().split(/\s+/);
const bin = tokens[0];
if (!bin || bin.startsWith("'") || bin.startsWith('"')) return null;
if (path.basename(bin) !== "gemini") return null;
if (!tokens.includes("--acp")) return null;
return tokens;
}
async function normalizeGeminiAcpCommandShell(commandShell: string, env: NodeJS.ProcessEnv): Promise<string> {
const tokens = geminiAcpCommandTokens(commandShell);
if (!tokens) return commandShell;
let versionParts: number[] | null = null;
try {
const { stdout } = await execFileAsync(tokens[0], ["--version"], {
timeout: GEMINI_VERSION_PROBE_TIMEOUT_MS,
encoding: "utf8",
env,
});
versionParts = parseGeminiVersionParts(stdout);
} catch {
return commandShell;
}
return rewriteGeminiAcpFlagForVersion(commandShell, versionParts);
}
function normalizeAgent(config: Record<string, unknown>): string {
const agent = asString(config.agent, DEFAULT_ACPX_LOCAL_AGENT).trim();
return agent || DEFAULT_ACPX_LOCAL_AGENT;
const agent = asString(config.agent, DEFAULT_ACP_ENGINE_AGENT).trim();
return agent || DEFAULT_ACP_ENGINE_AGENT;
}
async function pathExists(candidate: string): Promise<boolean> {
@ -301,8 +393,9 @@ async function buildSkillSetKey(input: {
async function resolveSelectedRuntimeSkills(
config: Record<string, unknown>,
moduleDir: string,
): Promise<{ allSkills: PaperclipSkillEntry[]; selectedSkills: PaperclipSkillEntry[]; desiredSkillNames: string[] }> {
const allSkills = await readPaperclipRuntimeSkillEntries(config, __moduleDir);
const allSkills = await readPaperclipRuntimeSkillEntries(config, moduleDir);
const desiredSkillNames = resolvePaperclipDesiredSkillNames(config, allSkills);
const desiredSet = new Set(desiredSkillNames);
return {
@ -315,13 +408,14 @@ async function resolveSelectedRuntimeSkills(
async function prepareClaudeSkillRuntime(input: {
stateDir: string;
config: Record<string, unknown>;
moduleDir: string;
onLog: AdapterExecutionContext["onLog"];
}): Promise<{
identity: Record<string, unknown>;
promptInstructions: string;
commandNotes: string[];
}> {
const { selectedSkills, desiredSkillNames } = await resolveSelectedRuntimeSkills(input.config);
const { allSkills, selectedSkills, desiredSkillNames } = await resolveSelectedRuntimeSkills(input.config, input.moduleDir);
const skillSetKey = await buildSkillSetKey({ skills: selectedSkills, label: "claude" });
const bundleRoot = path.join(input.stateDir, "runtime-skills", "claude", skillSetKey);
const skillsHome = path.join(bundleRoot, ".claude", "skills");
@ -443,6 +537,7 @@ async function prepareCodexSkillRuntime(input: {
companyId: string;
config: Record<string, unknown>;
env: Record<string, string>;
moduleDir: string;
onLog: AdapterExecutionContext["onLog"];
}): Promise<{ identity: Record<string, unknown>; commandNotes: string[] }> {
const envConfig = parseObject(input.config.env);
@ -462,7 +557,7 @@ async function prepareCodexSkillRuntime(input: {
targetHome: managedCodexHome,
onLog: input.onLog,
});
const { allSkills, selectedSkills, desiredSkillNames } = await resolveSelectedRuntimeSkills(input.config);
const { allSkills, selectedSkills, desiredSkillNames } = await resolveSelectedRuntimeSkills(input.config, input.moduleDir);
const skillSetKey = await buildSkillSetKey({ skills: selectedSkills, label: "codex" });
const skillsHome = path.join(effectiveCodexHome, "skills");
await fs.mkdir(skillsHome, { recursive: true });
@ -507,19 +602,76 @@ async function prepareCodexSkillRuntime(input: {
};
}
function resolveGeminiSkillsHome(config: Record<string, unknown>): string {
const envConfig = parseObject(config.env);
const configuredHome =
typeof envConfig.HOME === "string" && envConfig.HOME.trim().length > 0
? path.resolve(envConfig.HOME.trim())
: os.homedir();
return path.join(configuredHome, ".gemini", "skills");
}
async function prepareGeminiSkillRuntime(input: {
config: Record<string, unknown>;
moduleDir: string;
onLog: AdapterExecutionContext["onLog"];
}): Promise<{ identity: Record<string, unknown>; commandNotes: string[] }> {
const { selectedSkills, desiredSkillNames } = await resolveSelectedRuntimeSkills(input.config, input.moduleDir);
const skillSetKey = await buildSkillSetKey({ skills: selectedSkills, label: "gemini" });
const skillsHome = resolveGeminiSkillsHome(input.config);
await fs.mkdir(skillsHome, { recursive: true });
const allowedSkillNames = selectedSkills.map((entry) => entry.runtimeName);
const removedSkills = await removeMaintainerOnlySkillSymlinks(skillsHome, allowedSkillNames);
for (const skillName of removedSkills) {
await input.onLog("stdout", `[paperclip] Removed maintainer-only ACPX Gemini skill "${skillName}" from ${skillsHome}\n`);
}
for (const entry of selectedSkills) {
const target = path.join(skillsHome, entry.runtimeName);
try {
const result = await ensurePaperclipSkillSymlink(entry.source, target);
if (result === "created" || result === "repaired") {
await input.onLog(
"stdout",
`[paperclip] ${result === "repaired" ? "Repaired" : "Linked"} ACPX Gemini skill "${entry.runtimeName}" into ${skillsHome}\n`,
);
}
} catch (err) {
await input.onLog(
"stderr",
`[paperclip] Failed to link ACPX Gemini skill "${entry.key}" into ${skillsHome}: ${err instanceof Error ? err.message : String(err)}\n`,
);
}
}
return {
identity: {
mode: "gemini",
skillSetKey,
desiredSkillNames,
selectedSkills: selectedSkills.map((entry) => entry.runtimeName).sort(),
skillsHome,
},
commandNotes: selectedSkills.length > 0
? [`Prepared ${selectedSkills.length} ACPX Gemini skill(s) at ${skillsHome}.`]
: [],
};
}
function normalizeMode(config: Record<string, unknown>): "persistent" | "oneshot" {
return asString(config.mode, DEFAULT_ACPX_LOCAL_MODE) === "oneshot" ? "oneshot" : "persistent";
return asString(config.mode, DEFAULT_ACP_ENGINE_MODE) === "oneshot" ? "oneshot" : "persistent";
}
function normalizePermissionMode(config: Record<string, unknown>): "approve-all" | "approve-reads" | "deny-all" {
const value = asString(config.permissionMode, DEFAULT_ACPX_LOCAL_PERMISSION_MODE).trim();
const value = asString(config.permissionMode, DEFAULT_ACP_ENGINE_PERMISSION_MODE).trim();
if (value === "approve-reads" || value === "deny-all") return value;
if (value === "default") return "approve-reads";
return "approve-all";
}
function normalizeNonInteractivePermissions(config: Record<string, unknown>): "deny" | "fail" {
return asString(config.nonInteractivePermissions, DEFAULT_ACPX_LOCAL_NON_INTERACTIVE_PERMISSIONS) === "fail"
return asString(config.nonInteractivePermissions, DEFAULT_ACP_ENGINE_NON_INTERACTIVE_PERMISSIONS) === "fail"
? "fail"
: "deny";
}
@ -586,7 +738,7 @@ function uniqueSorted(values: Array<string | null | undefined>): string[] {
return [...new Set(values.filter((value): value is string => typeof value === "string" && value.length > 0))].sort();
}
// Phase 4.1 (PAPA-388): the Claude Code SDK that `claude-agent-acp` runs uses
// The Claude Code SDK that `claude-agent-acp` runs uses
// `settingSources: ["user", "project", "local"]`. By writing a per-worktree
// `.claude/settings.local.json` we override the user's potentially-restrictive
// `~/.claude/settings.json` (e.g. `defaultMode: "dontAsk"`, which silently
@ -700,7 +852,9 @@ async function writeAgentWrapper(input: {
`stderr_dir=${shellQuote(input.childStderrDir)}`,
"if [[ -n \"${PAPERCLIP_RUN_ID:-}\" ]]; then",
" mkdir -p \"$stderr_dir\"",
" exec 2> >(tee -a \"$stderr_dir/$PAPERCLIP_RUN_ID.log\" >&2)",
// Keep the run-stderr file unfiltered, but do not forward the known-benign
// ACP nes/close cleanup RPC error to Paperclip's live stderr stream.
" exec 2> >(tee -a \"$stderr_dir/$PAPERCLIP_RUN_ID.log\" | grep -Ev \"method: ['\\\"]nes/close['\\\"].*-32601\" >&2 || true)",
"fi",
`exec ${input.agentCommandShell} "$@"`,
"",
@ -739,9 +893,12 @@ async function cleanupStaleAgentWrappers(input: { wrappersDir: string; currentFi
async function buildRuntime(input: {
ctx: AdapterExecutionContext;
engine: AcpxEngineSettings;
}): Promise<AcpxPreparedRuntime> {
const { runId, agent, config, context, authToken } = input.ctx;
const workspaceContext = parseObject(context.paperclipWorkspace);
const secretsContext = parseObject(context.paperclipSecrets);
const secretManifest = Array.isArray(secretsContext.manifest) ? secretsContext.manifest : [];
const workspaceCwd = asString(workspaceContext.cwd, "");
const workspaceSource = asString(workspaceContext.source, "");
const workspaceStrategy = asString(workspaceContext.strategy, "");
@ -785,7 +942,7 @@ async function buildRuntime(input: {
// local/SSH runs keep the historical "0 = no adapter timeout" behavior.
const timeoutResolution = resolveAdapterExecutionTargetTimeout(
executionTarget,
asNumber(config.timeoutSec, DEFAULT_ACPX_LOCAL_TIMEOUT_SEC),
asNumber(config.timeoutSec, DEFAULT_ACP_ENGINE_TIMEOUT_SEC),
);
const timeoutSec = timeoutResolution.timeoutSec;
const stateDir = path.resolve(asString(config.stateDir, "") || defaultStateDir(agent.companyId, agent.id));
@ -858,6 +1015,7 @@ async function buildRuntime(input: {
const preparedSkills = await prepareClaudeSkillRuntime({
stateDir,
config,
moduleDir: input.engine.moduleDir,
onLog: input.ctx.onLog,
});
skillPromptInstructions = preparedSkills.promptInstructions;
@ -879,12 +1037,24 @@ async function buildRuntime(input: {
companyId: agent.companyId,
config,
env,
moduleDir: input.engine.moduleDir,
onLog: input.ctx.onLog,
});
skillsIdentity = preparedSkills.identity;
skillCommandNotes.push(...preparedSkills.commandNotes);
} else if (acpxAgent === "gemini") {
const preparedSkills = await prepareGeminiSkillRuntime({
config,
moduleDir: input.engine.moduleDir,
onLog: input.ctx.onLog,
});
skillsIdentity = preparedSkills.identity;
skillCommandNotes.push(...preparedSkills.commandNotes);
} else {
const desired = resolvePaperclipDesiredSkillNames(config, await readPaperclipRuntimeSkillEntries(config, __moduleDir));
const desired = resolvePaperclipDesiredSkillNames(
config,
await readPaperclipRuntimeSkillEntries(config, input.engine.moduleDir),
);
skillsIdentity = { mode: "custom_unsupported", desiredSkillNames: desired };
if (desired.length > 0) {
skillCommandNotes.push("Selected Paperclip skills are tracked only; ACPX custom commands do not expose a runtime skill contract yet.");
@ -892,9 +1062,19 @@ async function buildRuntime(input: {
}
const configuredCommand = asString(config.agentCommand, "").trim();
const builtInCommand = resolveBuiltInAgentCommand(acpxAgent);
const agentCommand = configuredCommand || builtInCommand || null;
const agentCommandShell = configuredCommand || (builtInCommand ? shellQuote(builtInCommand) : "");
const builtInCommand = await resolveBuiltInAgentCommand(acpxAgent, input.engine.packageRootDir);
let agentCommand = configuredCommand || builtInCommand?.command || null;
let agentCommandShell = configuredCommand || builtInCommand?.shellCommand || "";
if (acpxAgent === "gemini" && agentCommandShell) {
const normalized = await normalizeGeminiAcpCommandShell(
agentCommandShell,
ensurePathInEnv({ ...process.env, ...env }),
);
if (normalized !== agentCommandShell) {
agentCommandShell = normalized;
agentCommand = normalized;
}
}
const childStderrDir = path.join(stateDir, "run-stderr");
const childStderrLogPath = agentCommand ? path.join(childStderrDir, `${runId}.log`) : null;
const wrapper = agentCommand
@ -929,6 +1109,7 @@ async function buildRuntime(input: {
defaultMode: paperclipClaudeSettings.defaultMode,
}
: null,
secretManifestHash: shortHash(secretManifest),
});
const taskKey = asString(input.ctx.runtime.taskKey, "") || wakeTaskId || workspaceId || "default";
const sessionKey = `paperclip:${agent.companyId}:${agent.id}:${taskKey}:${fingerprint}`;
@ -1021,7 +1202,40 @@ async function applySessionConfigOptions(input: {
}
}
async function buildPrompt(ctx: AdapterExecutionContext, resumedSession: boolean): Promise<{
function renderPaperclipEnvNote(env: Record<string, string>): string {
const paperclipKeys = Object.keys(env)
.filter((key) => key.startsWith("PAPERCLIP_"))
.sort();
if (paperclipKeys.length === 0) return "";
return [
"Paperclip runtime note:",
`The following PAPERCLIP_* environment variables are available in this run: ${paperclipKeys.join(", ")}`,
"Do not assume these variables are missing without checking your shell environment.",
].join("\n");
}
function renderApiAccessNote(env: Record<string, string>): string {
if (!env.PAPERCLIP_API_URL || !env.PAPERCLIP_API_KEY) return "";
const lines = [
"Paperclip API access note:",
"Use terminal commands with curl to make Paperclip API requests.",
"Normalize the base URL before adding API paths:",
` PAPERCLIP_API_BASE="\${PAPERCLIP_API_URL%/}"; PAPERCLIP_API_BASE="\${PAPERCLIP_API_BASE%/api}"`,
"GET example:",
` curl -s -H "Authorization: Bearer $PAPERCLIP_API_KEY" "$PAPERCLIP_API_BASE/api/agents/me"`,
];
if (env.PAPERCLIP_TASK_ID) {
lines.push(
"Scoped issue comment example:",
` curl -s -X POST -H "Authorization: Bearer $PAPERCLIP_API_KEY" -H "Content-Type: application/json" -H "X-Paperclip-Run-Id: $PAPERCLIP_RUN_ID" -d '{"body":"Status update from agent."}' "$PAPERCLIP_API_BASE/api/issues/$PAPERCLIP_TASK_ID/comments"`,
);
} else {
lines.push("Use a real issue id from the current context before making issue write requests.");
}
return lines.join("\n");
}
async function buildPrompt(ctx: AdapterExecutionContext, resumedSession: boolean, env: Record<string, string>): Promise<{
prompt: string;
promptMetrics: Record<string, number>;
commandNotes: string[];
@ -1073,12 +1287,16 @@ async function buildPrompt(ctx: AdapterExecutionContext, resumedSession: boolean
const renderedPrompt = shouldUseResumeDeltaPrompt ? "" : renderTemplate(promptTemplate, templateData);
const sessionHandoffNote = asString(context.paperclipSessionHandoffMarkdown, "").trim();
const taskContextNote = asString(context.paperclipTaskMarkdown, "").trim();
const paperclipEnvNote = renderPaperclipEnvNote(env);
const apiAccessNote = renderApiAccessNote(env);
const prompt = joinPromptSections([
promptInstructionsPrefix,
renderedBootstrapPrompt,
wakePrompt,
sessionHandoffNote,
taskContextNote,
paperclipEnvNote,
apiAccessNote,
renderedPrompt,
]);
@ -1092,6 +1310,7 @@ async function buildPrompt(ctx: AdapterExecutionContext, resumedSession: boolean
wakePromptChars: wakePrompt.length,
sessionHandoffChars: sessionHandoffNote.length,
taskContextChars: taskContextNote.length,
runtimeNoteChars: paperclipEnvNote.length + apiAccessNote.length,
heartbeatPromptChars: renderedPrompt.length,
},
};
@ -1389,16 +1608,17 @@ function warmHandleMatches(
runtime: AcpRuntime,
handle: AcpRuntimeHandle,
): boolean {
return entry?.runtime === runtime && entry.handle === handle;
return entry !== undefined && entry.runtime === runtime && entry.handle === handle;
}
export function createAcpxLocalExecutor(deps: ExecuteDeps = {}) {
export function createAcpxEngineExecutor(deps: AcpxEngineExecutorOptions = {}) {
const createRuntime = deps.createRuntime ?? createAcpRuntime;
const now = deps.now ?? (() => Date.now());
const warmHandles = deps.warmHandles ?? defaultWarmHandles;
const engine = resolveEngineSettings(deps);
return async function executeAcpxLocal(ctx: AdapterExecutionContext): Promise<AdapterExecutionResult> {
const prepared = await buildRuntime({ ctx });
return async function executeAcpxEngine(ctx: AdapterExecutionContext): Promise<AdapterExecutionResult> {
const prepared = await buildRuntime({ ctx, engine });
// State the effective wall-clock timeout and its source up front so a
// later timeout is diagnosable from the run log alone. Goes to stderr:
// the acpx stdout log stream carries JSON acpx.* event payloads and must
@ -1407,7 +1627,7 @@ export function createAcpxLocalExecutor(deps: ExecuteDeps = {}) {
"stderr",
`[paperclip] ${formatAdapterExecutionTimeoutStartLogLine(prepared.timeoutResolution)}\n`,
);
const warmIdleMs = asNumber(ctx.config.warmHandleIdleMs, DEFAULT_ACPX_LOCAL_WARM_HANDLE_IDLE_MS);
const warmIdleMs = asNumber(ctx.config.warmHandleIdleMs, DEFAULT_ACP_ENGINE_WARM_HANDLE_IDLE_MS);
await cleanupIdleHandles({ handles: warmHandles, now: now(), idleMs: warmIdleMs });
const previousParams = parseObject(ctx.runtime.sessionParams);
@ -1421,8 +1641,7 @@ export function createAcpxLocalExecutor(deps: ExecuteDeps = {}) {
permissionMode: prepared.permissionMode,
nonInteractivePermissions: prepared.nonInteractivePermissions,
timeoutMs: prepared.timeoutSec > 0 ? prepared.timeoutSec * 1000 : undefined,
// Scope ACPX runtime verbose logs to the claude agent only — that's the
// surface we know needs the extra session-event detail (PAPA-388). codex
// Scope ACPX runtime verbose logs to the claude agent only. Codex
// and custom agents already emit their own per-tool output and don't
// benefit from doubling the log volume.
verbose: prepared.acpxAgent === "claude",
@ -1544,7 +1763,7 @@ export function createAcpxLocalExecutor(deps: ExecuteDeps = {}) {
summary: message,
};
}
const { prompt, promptMetrics, commandNotes } = await buildPrompt(ctx, resumedSession);
const { prompt, promptMetrics, commandNotes } = await buildPrompt(ctx, resumedSession, prepared.env);
const runPrompt = joinPromptSections([prepared.skillPromptInstructions, prompt]);
await emitAcpxLog(ctx, {
type: "acpx.session",
@ -1561,7 +1780,7 @@ export function createAcpxLocalExecutor(deps: ExecuteDeps = {}) {
});
if (ctx.onMeta) {
await ctx.onMeta({
adapterType: "acpx_local",
adapterType: engine.adapterType,
command: prepared.agentCommand ?? prepared.acpxAgent,
cwd: prepared.cwd,
commandNotes: [
@ -1757,4 +1976,5 @@ export function createAcpxLocalExecutor(deps: ExecuteDeps = {}) {
};
}
export const execute = createAcpxLocalExecutor();
export const execute = createAcpxEngineExecutor();

View File

@ -0,0 +1,5 @@
export * from "./constants.js";
export { createAcpxEngineExecutor, execute } from "./execute.js";
export { sessionCodec } from "./session-codec.js";
export { printAcpxStreamEvent } from "./cli.js";
export { parseAcpxStdoutLine } from "./ui.js";

View File

@ -28,6 +28,7 @@ export type {
ConfigFieldSchema,
AdapterConfigSchema,
AdapterRuntimeCommandSpec,
AcpTargetDescriptor,
ServerAdapterModule,
QuotaWindow,
ProviderQuotaResult,

View File

@ -37,7 +37,6 @@ const ADAPTER_MANAGED_SESSION_POLICY: SessionCompactionPolicy = {
};
export const LEGACY_SESSIONED_ADAPTER_TYPES = new Set([
"acpx_local",
"claude_local",
"codex_local",
"cursor_cloud",
@ -49,11 +48,6 @@ export const LEGACY_SESSIONED_ADAPTER_TYPES = new Set([
]);
export const ADAPTER_SESSION_MANAGEMENT: Record<string, AdapterSessionManagement> = {
acpx_local: {
supportsSessionResume: true,
nativeContextManagement: "confirmed",
defaultSessionCompaction: ADAPTER_MANAGED_SESSION_POLICY,
},
claude_local: {
supportsSessionResume: true,
nativeContextManagement: "confirmed",

View File

@ -348,10 +348,20 @@ export interface AdapterRuntimeCommandSpec {
installCommand?: string | null;
}
export interface AcpTargetDescriptor {
agentId: "claude" | "codex" | "gemini" | "custom" | (string & {});
skillsMode: "ephemeral" | "unsupported";
prerequisites: {
nodeRange?: string;
packages?: string[];
};
}
export interface ServerAdapterModule {
type: string;
execute(ctx: AdapterExecutionContext): Promise<AdapterExecutionResult>;
testEnvironment(ctx: AdapterEnvironmentTestContext): Promise<AdapterEnvironmentTestResult>;
acp?: AcpTargetDescriptor;
listSkills?: (ctx: AdapterSkillContext) => Promise<AdapterSkillSnapshot>;
syncSkills?: (ctx: AdapterSkillContext, desiredSkills: string[]) => Promise<AdapterSkillSnapshot>;
sessionCodec?: AdapterSessionCodec;
@ -482,6 +492,24 @@ export interface CreateConfigValues {
cheapModelEnabled?: boolean;
chrome: boolean;
dangerouslySkipPermissions: boolean;
claudeEngine?: "auto" | "cli" | "acp";
claudeAcpAgentCommand?: string;
claudeAcpMode?: "persistent" | "oneshot";
claudeAcpNonInteractivePermissions?: "deny" | "fail";
claudeAcpStateDir?: string;
claudeAcpWarmHandleIdleMs?: number;
codexEngine?: "auto" | "cli" | "acp";
codexAcpAgentCommand?: string;
codexAcpMode?: "persistent" | "oneshot";
codexAcpNonInteractivePermissions?: "deny" | "fail";
codexAcpStateDir?: string;
codexAcpWarmHandleIdleMs?: number;
geminiEngine?: "auto" | "cli" | "acp";
geminiAcpAgentCommand?: string;
geminiAcpMode?: "persistent" | "oneshot";
geminiAcpNonInteractivePermissions?: "deny" | "fail";
geminiAcpStateDir?: string;
geminiAcpWarmHandleIdleMs?: number;
search: boolean;
fastMode: boolean;
dangerouslyBypassSandbox: boolean;

View File

@ -1,64 +0,0 @@
{
"name": "@paperclipai/adapter-acpx-local",
"version": "0.3.1",
"license": "MIT",
"homepage": "https://github.com/paperclipai/paperclip",
"bugs": {
"url": "https://github.com/paperclipai/paperclip/issues"
},
"repository": {
"type": "git",
"url": "https://github.com/paperclipai/paperclip",
"directory": "packages/adapters/acpx-local"
},
"type": "module",
"exports": {
".": "./src/index.ts",
"./server": "./src/server/index.ts",
"./ui": "./src/ui/index.ts",
"./cli": "./src/cli/index.ts"
},
"publishConfig": {
"access": "public",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
},
"./server": {
"types": "./dist/server/index.d.ts",
"import": "./dist/server/index.js"
},
"./ui": {
"types": "./dist/ui/index.d.ts",
"import": "./dist/ui/index.js"
},
"./cli": {
"types": "./dist/cli/index.d.ts",
"import": "./dist/cli/index.js"
}
},
"main": "./dist/index.js",
"types": "./dist/index.d.ts"
},
"files": [
"dist",
"skills"
],
"scripts": {
"build": "tsc",
"clean": "rm -rf dist",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@agentclientprotocol/claude-agent-acp": "^0.52.0",
"@paperclipai/adapter-utils": "workspace:*",
"@zed-industries/codex-acp": "^0.16.0",
"acpx": "^0.12.0",
"picocolors": "^1.1.1"
},
"devDependencies": {
"@types/node": "^22.19.21",
"typescript": "^5.7.3"
}
}

View File

@ -1,121 +0,0 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { printAcpxStreamEvent } from "./format-event.js";
function emit(payload: Record<string, unknown>): string {
return JSON.stringify(payload);
}
interface CapturedOutput {
log: string[];
stdout: string[];
}
function captureOutput(): { capture: CapturedOutput; restore: () => void } {
const log: string[] = [];
const stdout: string[] = [];
const logSpy = vi.spyOn(console, "log").mockImplementation((value?: unknown) => {
log.push(String(value ?? ""));
});
const stdoutSpy = vi.spyOn(process.stdout, "write").mockImplementation(((chunk: unknown) => {
stdout.push(String(chunk ?? ""));
return true;
}) as typeof process.stdout.write);
return {
capture: { log, stdout },
restore: () => {
logSpy.mockRestore();
stdoutSpy.mockRestore();
},
};
}
function strip(value: string): string {
return value.replace(/\x1b\[[0-9;]*m/g, "");
}
describe("printAcpxStreamEvent", () => {
let captured: CapturedOutput;
let restore: () => void;
beforeEach(() => {
const result = captureOutput();
captured = result.capture;
restore = result.restore;
});
afterEach(() => {
restore();
});
it("renders acpx.session as a labeled session header", () => {
printAcpxStreamEvent(
emit({
type: "acpx.session",
agent: "claude",
acpSessionId: "acp-1",
mode: "persistent",
permissionMode: "approve-all",
}),
false,
);
expect(captured.log.map(strip)).toEqual(["claude session: acp-1 [persistent / approve-all]"]);
});
it("streams output text_delta to stdout for live progress", () => {
printAcpxStreamEvent(
emit({ type: "acpx.text_delta", text: "hello", channel: "output" }),
false,
);
expect(captured.log).toEqual([]);
expect(captured.stdout.map(strip)).toEqual(["hello"]);
});
it("renders thought text_delta on its own line", () => {
printAcpxStreamEvent(
emit({ type: "acpx.text_delta", text: "thinking…", channel: "thought" }),
false,
);
expect(captured.log.map(strip)).toEqual(["thinking…"]);
});
it("renders tool_call with status and id", () => {
printAcpxStreamEvent(
emit({
type: "acpx.tool_call",
name: "read",
toolCallId: "tool-1",
status: "running",
text: "read README.md",
}),
false,
);
expect(captured.log.map(strip)).toEqual([
"tool_call: read [running] (tool-1)",
"read README.md",
]);
});
it("renders status events with optional context window", () => {
printAcpxStreamEvent(
emit({ type: "acpx.status", tag: "context_window", used: 100, size: 200000 }),
false,
);
expect(captured.log.map(strip)).toEqual(["status: context_window (100/200000 ctx)"]);
});
it("renders acpx.result and acpx.error", () => {
printAcpxStreamEvent(emit({ type: "acpx.result", summary: "completed", stopReason: "end_turn" }), false);
printAcpxStreamEvent(emit({ type: "acpx.error", message: "auth required" }), false);
expect(captured.log.map(strip)).toEqual(["result: completed", "error: auth required"]);
});
it("falls back to plain output for non-JSON lines", () => {
printAcpxStreamEvent("not json", false);
expect(captured.log).toEqual(["not json"]);
});
it("still emits unknown / non-JSON lines when debug is enabled", () => {
printAcpxStreamEvent("not json", true);
expect(strip(captured.log[0])).toBe("not json");
});
});

View File

@ -1 +0,0 @@
export { printAcpxStreamEvent } from "./format-event.js";

View File

@ -1,60 +0,0 @@
import type { AdapterModel } from "@paperclipai/adapter-utils";
export const type = "acpx_local";
export const label = "ACPX";
export const DEFAULT_ACPX_LOCAL_AGENT = "claude";
export const DEFAULT_ACPX_LOCAL_MODE = "persistent";
export const DEFAULT_ACPX_LOCAL_PERMISSION_MODE = "approve-all";
export const DEFAULT_ACPX_LOCAL_NON_INTERACTIVE_PERMISSIONS = "deny";
export const DEFAULT_ACPX_LOCAL_TIMEOUT_SEC = 0;
export const DEFAULT_ACPX_LOCAL_WARM_HANDLE_IDLE_MS = 0;
export const acpxAgentOptions = [
{ id: "claude", label: "Claude via ACPX" },
{ id: "codex", label: "Codex via ACPX" },
{ id: "custom", label: "Custom ACP command" },
] as const;
export const models: AdapterModel[] = [];
export const agentConfigurationDoc = `# acpx_local agent configuration
Adapter: acpx_local
Use when:
- The agent should run through Agent Client Protocol via ACPX on the Paperclip host or a managed execution environment.
- You want one built-in adapter that can target Claude, Codex, or a custom ACP server command.
- You want the richest live run feedback. acpx_local streams structured JSONL events (acpx.session, acpx.status, acpx.text_delta, acpx.tool_call, acpx.result, acpx.error) that the UI renders as live message, thinking, tool, and status blocks.
- The agent runs on a sandbox execution target. Sandbox run logs stream live, so
acpx_local's granular events make remote runs as observable as local ones.
Prefer acpx_local for sandbox workers, especially when watching progress in
real time.
Don't use when:
- You depend on CLI-wrapper-specific behavior of claude_local or codex_local (their session files, CLI flags, or CLI-version-specific output).
- The host cannot satisfy ACPX's Node >=22.12.0 prerequisite.
- The agent runtime is not an ACP server and cannot be launched through ACPX.
Core fields:
- agent (string, optional): claude, codex, or custom. Defaults to claude.
- agentCommand (string, optional): custom ACP command when agent=custom, or an override for a built-in ACP agent command.
- mode (string, optional): persistent or oneshot. Defaults to persistent. Paperclip keeps session state persistent and may close the live process between runs.
- cwd (string, optional): default absolute working directory fallback for the agent process.
- permissionMode (string, optional): defaults to approve-all, meaning ACPX permission requests are auto-approved.
- nonInteractivePermissions (string, optional): fallback behavior when ACPX cannot ask interactively. Supported values are deny and fail.
- stateDir (string, optional): ACPX state directory. Defaults to a Paperclip-managed company/agent scoped location.
- instructionsFilePath (string, optional): absolute path to a markdown instructions file used by Paperclip prompt construction.
- promptTemplate (string, optional): run prompt template.
- bootstrapPromptTemplate (string, optional): first-run bootstrap prompt template.
- model (string, optional): requested ACP model. Claude and Codex ACP agents both receive this through ACP session config.
- effort/modelReasoningEffort (string, optional): requested thinking effort. Claude uses effort; Codex uses modelReasoningEffort/reasoning_effort.
- fastMode (boolean, optional): for ACPX Codex, request Codex fast mode through ACP session config.
- timeoutSec (number, optional): run timeout in seconds. Defaults to 0, meaning no adapter timeout for local/SSH execution. Sandbox execution targets default to a 4h wall-clock backstop when timeoutSec is unset; the output-inactivity monitor remains the primary hang detector.
- warmHandleIdleMs (number, optional): live ACPX process idle window after a successful persistent run. Defaults to 0, meaning Paperclip shuts the process down after each run while retaining ACPX session state.
- env (object, optional): KEY=VALUE environment variables or secret bindings.
Dependency decision:
- acpx_local declares direct dependencies on acpx, @agentclientprotocol/claude-agent-acp, and @zed-industries/codex-acp so the built-in adapter has deterministic package resolution instead of relying on globally installed ACP commands.
- ACPX currently requires Node >=22.12.0. Paperclip keeps the repo-wide Node >=20 engine and surfaces the stricter runtime prerequisite through acpx_local diagnostics.
`;

View File

@ -1,81 +0,0 @@
import type { AdapterConfigSchema } from "@paperclipai/adapter-utils";
import {
DEFAULT_ACPX_LOCAL_AGENT,
DEFAULT_ACPX_LOCAL_NON_INTERACTIVE_PERMISSIONS,
DEFAULT_ACPX_LOCAL_TIMEOUT_SEC,
DEFAULT_ACPX_LOCAL_WARM_HANDLE_IDLE_MS,
acpxAgentOptions,
} from "../index.js";
export function getConfigSchema(): AdapterConfigSchema {
return {
fields: [
{
key: "agent",
label: "ACP agent",
type: "select",
default: DEFAULT_ACPX_LOCAL_AGENT,
required: true,
options: acpxAgentOptions.map((agent) => ({ value: agent.id, label: agent.label })),
hint: "Choose the ACP agent launched through ACPX.",
},
{
key: "agentCommand",
label: "Agent command",
type: "text",
hint: "Required for custom agents; optional override for built-in Claude or Codex ACP commands.",
},
{
key: "nonInteractivePermissions",
label: "Non-interactive permissions",
type: "select",
default: DEFAULT_ACPX_LOCAL_NON_INTERACTIVE_PERMISSIONS,
options: [
{ value: "deny", label: "Deny" },
{ value: "fail", label: "Fail" },
],
hint: "Fallback if the ACP agent asks for input outside an interactive session. Paperclip still auto-approves permissions by default.",
},
{
key: "cwd",
label: "Working directory",
type: "text",
hint: "Absolute fallback directory. Paperclip execution workspaces can override this at runtime.",
},
{
key: "stateDir",
label: "State directory",
type: "text",
hint: "Optional ACPX session state directory. Defaults to Paperclip-managed company/agent scoped storage.",
},
{
key: "fastMode",
label: "Codex fast mode",
type: "toggle",
default: false,
hint: "Only applies when ACP agent is Codex. Requests Codex Fast mode through ACP session config.",
meta: { visibleWhen: { key: "agent", values: ["codex"] } },
},
{
key: "timeoutSec",
label: "Timeout seconds",
type: "number",
default: DEFAULT_ACPX_LOCAL_TIMEOUT_SEC,
hint: "Wall-clock timeout for a run. 0 uses the target default: no adapter timeout on local/SSH, 4 hours on sandbox targets. Set a negative value (e.g. -1) to disable the adapter timeout everywhere, including sandboxes.",
},
{
key: "warmHandleIdleMs",
label: "Warm process idle ms",
type: "number",
default: DEFAULT_ACPX_LOCAL_WARM_HANDLE_IDLE_MS,
hint: "Defaults to 0, which closes the ACPX process after each run while retaining persistent session state.",
},
{
key: "env",
label: "Environment JSON",
type: "textarea",
hint: "Optional JSON object of environment values or secret bindings.",
},
],
};
}

View File

@ -1,5 +0,0 @@
export { execute, createAcpxLocalExecutor } from "./execute.js";
export { testEnvironment } from "./test.js";
export { getConfigSchema } from "./config-schema.js";
export { sessionCodec } from "./session-codec.js";
export { listAcpxSkills, syncAcpxSkills } from "./skills.js";

View File

@ -1,67 +0,0 @@
import path from "node:path";
import { fileURLToPath } from "node:url";
import type {
AdapterSkillContext,
AdapterSkillSnapshot,
} from "@paperclipai/adapter-utils";
import {
buildRuntimeMountedSkillSnapshot,
readPaperclipRuntimeSkillEntries,
resolvePaperclipDesiredSkillNames,
} from "@paperclipai/adapter-utils/server-utils";
const __moduleDir = path.dirname(fileURLToPath(import.meta.url));
type AcpxSkillAgent = "claude" | "codex" | "custom";
function normalizeAcpxSkillAgent(config: Record<string, unknown>): AcpxSkillAgent {
const configured = typeof config.agent === "string" ? config.agent.trim() : "";
if (configured === "codex" || configured === "custom") return configured;
if (configured === "claude" || configured === "") return "claude";
return "claude";
}
function configuredDetail(agent: AcpxSkillAgent): string {
if (agent === "codex") {
return "Will be linked into the effective CODEX_HOME/skills/ directory for the next ACPX Codex session.";
}
return "Will be mounted into the next ACPX Claude session.";
}
function unsupportedDetail(): string {
return "Desired state is stored in Paperclip only; custom ACP commands need an explicit skill integration contract before runtime sync is available.";
}
async function buildAcpxSkillSnapshot(config: Record<string, unknown>): Promise<AdapterSkillSnapshot> {
const acpxAgent = normalizeAcpxSkillAgent(config);
const availableEntries = await readPaperclipRuntimeSkillEntries(config, __moduleDir);
const desiredSkills = resolvePaperclipDesiredSkillNames(config, availableEntries);
const supported = acpxAgent !== "custom";
const warnings: string[] = supported
? []
: [
"Custom ACP commands do not expose a Paperclip skill integration contract yet; selected skills are tracked only.",
];
return buildRuntimeMountedSkillSnapshot({
adapterType: "acpx_local",
availableEntries,
desiredSkills,
supported,
mode: supported ? "ephemeral" : "unsupported",
configuredDetail: configuredDetail(acpxAgent),
unsupportedDetail: unsupportedDetail(),
warnings,
});
}
export async function listAcpxSkills(ctx: AdapterSkillContext): Promise<AdapterSkillSnapshot> {
return buildAcpxSkillSnapshot(ctx.config);
}
export async function syncAcpxSkills(
ctx: AdapterSkillContext,
_desiredSkills: string[],
): Promise<AdapterSkillSnapshot> {
return buildAcpxSkillSnapshot(ctx.config);
}

View File

@ -1,49 +0,0 @@
import { afterEach, describe, expect, it } from "vitest";
import { testEnvironment } from "./test.js";
const originalNodeVersion = process.version;
function setNodeVersion(version: string): void {
Object.defineProperty(process, "version", {
configurable: true,
enumerable: true,
value: version,
});
}
afterEach(() => {
setNodeVersion(originalNodeVersion);
});
describe("acpx_local environment diagnostics", () => {
it("does not force healthy default Claude diagnostics to warn", async () => {
setNodeVersion("v22.12.0");
const result = await testEnvironment({
adapterType: "acpx_local",
companyId: "test-company",
config: { agent: "claude" },
});
expect(result.status).toBe("pass");
expect(result.checks).toContainEqual(
expect.objectContaining({
code: "acpx_agent_selected",
level: "info",
message: "ACP agent selected: claude",
}),
);
expect(result.checks).toContainEqual(
expect.objectContaining({
code: "acpx_runtime_scaffold",
level: "info",
}),
);
expect(result.checks).not.toContainEqual(
expect.objectContaining({
code: "acpx_runtime_scaffold",
level: "warn",
}),
);
});
});

View File

@ -1,295 +0,0 @@
import { createRequire } from "node:module";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import type {
AdapterEnvironmentCheck,
AdapterEnvironmentTestContext,
AdapterEnvironmentTestResult,
} from "@paperclipai/adapter-utils";
import {
asString,
parseObject,
} from "@paperclipai/adapter-utils/server-utils";
const require = createRequire(import.meta.url);
const MIN_NODE_MAJOR = 22;
const MIN_NODE_MINOR = 12;
const MIN_NODE_PATCH = 0;
function summarizeStatus(checks: AdapterEnvironmentCheck[]): AdapterEnvironmentTestResult["status"] {
if (checks.some((check) => check.level === "error")) return "fail";
if (checks.some((check) => check.level === "warn")) return "warn";
return "pass";
}
function nodeVersionMeetsMinimum(version: string): boolean {
const [major = 0, minor = 0, patch = 0] = version
.replace(/^v/, "")
.split(".")
.map((part) => Number.parseInt(part, 10));
if (major > MIN_NODE_MAJOR) return true;
if (major < MIN_NODE_MAJOR) return false;
if (minor > MIN_NODE_MINOR) return true;
if (minor < MIN_NODE_MINOR) return false;
return patch >= MIN_NODE_PATCH;
}
function isNonEmpty(value: unknown): value is string {
return typeof value === "string" && value.trim().length > 0;
}
function getStringEnv(configEnv: Record<string, string>, key: string): string | undefined {
const configured = configEnv[key];
if (typeof configured === "string") return configured;
return process.env[key];
}
function credentialSource(configEnv: Record<string, string>, key: string): string {
return typeof configEnv[key] === "string" ? "adapter config env" : "server environment";
}
async function readJsonObject(filePath: string): Promise<Record<string, unknown> | null> {
try {
const parsed = JSON.parse(await fs.readFile(filePath, "utf8")) as unknown;
return typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)
? parsed as Record<string, unknown>
: null;
} catch {
return null;
}
}
function readNestedString(record: Record<string, unknown>, pathSegments: string[]): string | null {
let current: unknown = record;
for (const segment of pathSegments) {
if (typeof current !== "object" || current === null || Array.isArray(current)) return null;
current = (current as Record<string, unknown>)[segment];
}
return isNonEmpty(current) ? current.trim() : null;
}
async function hasClaudeSubscriptionCredentials(configDir: string): Promise<boolean> {
for (const filename of [".credentials.json", "credentials.json"]) {
const credentials = await readJsonObject(path.join(configDir, filename));
if (!credentials) continue;
if (readNestedString(credentials, ["claudeAiOauth", "accessToken"])) return true;
}
return false;
}
async function hasCodexNativeCredentials(codexHome: string): Promise<boolean> {
const auth = await readJsonObject(path.join(codexHome, "auth.json"));
if (!auth) return false;
return Boolean(
readNestedString(auth, ["accessToken"]) ||
readNestedString(auth, ["tokens", "access_token"]) ||
readNestedString(auth, ["OPENAI_API_KEY"]),
);
}
async function buildCredentialHintChecks(
agent: string,
configEnv: Record<string, string>,
): Promise<AdapterEnvironmentCheck[]> {
if (agent === "claude") {
const bedrockFlag = getStringEnv(configEnv, "CLAUDE_CODE_USE_BEDROCK");
const bedrockBaseUrl = getStringEnv(configEnv, "ANTHROPIC_BEDROCK_BASE_URL");
const hasBedrock =
bedrockFlag === "1" ||
/^true$/i.test(bedrockFlag ?? "") ||
isNonEmpty(bedrockBaseUrl);
const bedrockSourceKey = isNonEmpty(bedrockFlag)
? "CLAUDE_CODE_USE_BEDROCK"
: "ANTHROPIC_BEDROCK_BASE_URL";
const anthropicApiKey = getStringEnv(configEnv, "ANTHROPIC_API_KEY");
const claudeConfigDir = isNonEmpty(getStringEnv(configEnv, "CLAUDE_CONFIG_DIR"))
? path.resolve(getStringEnv(configEnv, "CLAUDE_CONFIG_DIR") as string)
: path.join(os.homedir(), ".claude");
if (hasBedrock) {
return [{
code: "acpx_claude_bedrock_auth_detected",
level: "info",
message: "Claude credential hint: Bedrock auth indicators are configured.",
detail: `Detected in ${credentialSource(configEnv, bedrockSourceKey)}.`,
hint: "Ensure AWS credentials and AWS_REGION are available to the ACPX-launched Claude agent.",
}];
}
if (isNonEmpty(anthropicApiKey)) {
return [{
code: "acpx_claude_anthropic_api_key_detected",
level: "info",
message: "Claude credential hint: ANTHROPIC_API_KEY is set.",
detail: `Detected in ${credentialSource(configEnv, "ANTHROPIC_API_KEY")}.`,
}];
}
if (await hasClaudeSubscriptionCredentials(claudeConfigDir)) {
return [{
code: "acpx_claude_subscription_auth_detected",
level: "info",
message: "Claude credential hint: local Claude subscription credentials were found.",
detail: `Credentials found in ${claudeConfigDir}.`,
}];
}
return [{
code: "acpx_claude_credentials_missing",
level: "info",
message: "Claude credential hint: no Claude API, Bedrock, or local subscription credentials were detected.",
hint: "Set ANTHROPIC_API_KEY, configure Bedrock, or run `claude login` before starting an ACPX Claude agent.",
}];
}
if (agent === "codex") {
const openAiApiKey = getStringEnv(configEnv, "OPENAI_API_KEY");
const codexHome = isNonEmpty(getStringEnv(configEnv, "CODEX_HOME"))
? path.resolve(getStringEnv(configEnv, "CODEX_HOME") as string)
: path.join(os.homedir(), ".codex");
if (isNonEmpty(openAiApiKey)) {
return [{
code: "acpx_codex_openai_api_key_detected",
level: "info",
message: "Codex credential hint: OPENAI_API_KEY is set.",
detail: `Detected in ${credentialSource(configEnv, "OPENAI_API_KEY")}.`,
}];
}
if (await hasCodexNativeCredentials(codexHome)) {
return [{
code: "acpx_codex_native_auth_detected",
level: "info",
message: "Codex credential hint: local Codex auth configuration was found.",
detail: `Credentials found in ${path.join(codexHome, "auth.json")}.`,
}];
}
return [{
code: "acpx_codex_credentials_missing",
level: "info",
message: "Codex credential hint: no OpenAI API key or local Codex auth configuration was detected.",
hint: "Set OPENAI_API_KEY or run `codex login` before starting an ACPX Codex agent.",
}];
}
return [];
}
function resolvePackage(name: string): AdapterEnvironmentCheck {
try {
const resolved = require.resolve(`${name}/package.json`);
return {
code: `acpx_package_${name.replace(/[^a-z0-9]+/gi, "_").toLowerCase()}_present`,
level: "info",
message: `${name} is resolvable.`,
detail: resolved,
};
} catch {
return {
code: `acpx_package_${name.replace(/[^a-z0-9]+/gi, "_").toLowerCase()}_missing`,
level: "error",
message: `${name} is not resolvable from the acpx_local adapter package.`,
hint: "Run pnpm install so the ACPX adapter dependencies are installed.",
};
}
}
async function checkDirectory(pathValue: string, code: string, label: string): Promise<AdapterEnvironmentCheck | null> {
const dir = pathValue.trim();
if (!dir) return null;
try {
await fs.mkdir(dir, { recursive: true });
await fs.access(dir);
return {
code,
level: "info",
message: `${label} is writable: ${dir}`,
};
} catch (err) {
return {
code: `${code}_invalid`,
level: "error",
message: err instanceof Error ? err.message : `${label} is not writable.`,
detail: dir,
};
}
}
export async function testEnvironment(
ctx: AdapterEnvironmentTestContext,
): Promise<AdapterEnvironmentTestResult> {
const config = parseObject(ctx.config);
const envConfig = parseObject(config.env);
const configEnv: Record<string, string> = {};
for (const [key, value] of Object.entries(envConfig)) {
if (typeof value === "string") configEnv[key] = value;
}
const checks: AdapterEnvironmentCheck[] = [];
const nodeVersion = process.version;
checks.push({
code: nodeVersionMeetsMinimum(nodeVersion) ? "acpx_node_supported" : "acpx_node_unsupported",
level: nodeVersionMeetsMinimum(nodeVersion) ? "info" : "error",
message: nodeVersionMeetsMinimum(nodeVersion)
? `Node ${nodeVersion} satisfies ACPX's >=22.12.0 requirement.`
: `Node ${nodeVersion} does not satisfy ACPX's >=22.12.0 requirement.`,
hint: nodeVersionMeetsMinimum(nodeVersion)
? undefined
: "Run acpx_local agents with Node >=22.12.0 or use claude_local/codex_local on Node 20.",
});
checks.push(resolvePackage("acpx"));
checks.push(resolvePackage("@agentclientprotocol/claude-agent-acp"));
checks.push(resolvePackage("@zed-industries/codex-acp"));
const agent = asString(config.agent, "claude");
if (!["claude", "codex", "custom"].includes(agent)) {
checks.push({
code: "acpx_agent_invalid",
level: "error",
message: `Unsupported ACP agent: ${agent}`,
hint: "Use agent=claude, agent=codex, or agent=custom.",
});
} else {
checks.push({
code: "acpx_agent_selected",
level: "info",
message: `ACP agent selected: ${agent}`,
});
checks.push(...await buildCredentialHintChecks(agent, configEnv));
}
if (agent === "custom" && !asString(config.agentCommand, "")) {
checks.push({
code: "acpx_custom_command_missing",
level: "error",
message: "agentCommand is required when agent=custom.",
});
}
const stateDirCheck = await checkDirectory(asString(config.stateDir, ""), "acpx_state_dir_writable", "ACPX state directory");
if (stateDirCheck) checks.push(stateDirCheck);
const permissionMode = asString(config.permissionMode, "approve-all");
checks.push({
code: "acpx_permission_mode",
level: "info",
message: `Effective permission mode: ${permissionMode || "approve-all"}`,
});
checks.push({
code: "acpx_runtime_scaffold",
level: "info",
message: "acpx_local runtime execution is available through the bundled ACPX runtime.",
});
return {
adapterType: ctx.adapterType,
status: summarizeStatus(checks),
checks,
testedAt: new Date().toISOString(),
};
}

View File

@ -1,147 +0,0 @@
import type { CreateConfigValues } from "@paperclipai/adapter-utils";
import {
DEFAULT_ACPX_LOCAL_AGENT,
DEFAULT_ACPX_LOCAL_MODE,
DEFAULT_ACPX_LOCAL_NON_INTERACTIVE_PERMISSIONS,
DEFAULT_ACPX_LOCAL_PERMISSION_MODE,
DEFAULT_ACPX_LOCAL_TIMEOUT_SEC,
DEFAULT_ACPX_LOCAL_WARM_HANDLE_IDLE_MS,
} from "../index.js";
function parseCommaArgs(value: string): string[] {
return value
.split(",")
.map((item) => item.trim())
.filter(Boolean);
}
function parseEnvVars(text: string): Record<string, string> {
const env: Record<string, string> = {};
for (const line of text.split(/\r?\n/)) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith("#")) continue;
const eq = trimmed.indexOf("=");
if (eq <= 0) continue;
const key = trimmed.slice(0, eq).trim();
const value = trimmed.slice(eq + 1);
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) continue;
env[key] = value;
}
return env;
}
function parseEnvBindings(bindings: unknown): Record<string, unknown> {
if (typeof bindings !== "object" || bindings === null || Array.isArray(bindings)) return {};
const env: Record<string, unknown> = {};
for (const [key, raw] of Object.entries(bindings)) {
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) continue;
if (typeof raw === "string") {
env[key] = { type: "plain", value: raw };
continue;
}
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) continue;
const rec = raw as Record<string, unknown>;
if (rec.type === "plain" && typeof rec.value === "string") {
env[key] = { type: "plain", value: rec.value };
continue;
}
if (rec.type === "secret_ref" && typeof rec.secretId === "string") {
env[key] = {
type: "secret_ref",
secretId: rec.secretId,
...(typeof rec.version === "number" || rec.version === "latest"
? { version: rec.version }
: {}),
};
}
}
return env;
}
function parseJsonObject(text: string): Record<string, unknown> | null {
const trimmed = text.trim();
if (!trimmed) return null;
try {
const parsed = JSON.parse(trimmed);
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return null;
return parsed as Record<string, unknown>;
} catch {
return null;
}
}
function readNumber(value: unknown, fallback: number): number {
if (typeof value === "number" && Number.isFinite(value)) return value;
if (typeof value === "string" && value.trim()) {
const parsed = Number(value);
if (Number.isFinite(parsed)) return parsed;
}
return fallback;
}
export function buildAcpxLocalConfig(v: CreateConfigValues): Record<string, unknown> {
const schemaValues = v.adapterSchemaValues ?? {};
const agent = String(schemaValues.agent || DEFAULT_ACPX_LOCAL_AGENT);
const ac: Record<string, unknown> = {
agent,
mode: schemaValues.mode || DEFAULT_ACPX_LOCAL_MODE,
permissionMode: schemaValues.permissionMode || DEFAULT_ACPX_LOCAL_PERMISSION_MODE,
nonInteractivePermissions:
schemaValues.nonInteractivePermissions || DEFAULT_ACPX_LOCAL_NON_INTERACTIVE_PERMISSIONS,
timeoutSec: readNumber(schemaValues.timeoutSec, DEFAULT_ACPX_LOCAL_TIMEOUT_SEC),
warmHandleIdleMs: readNumber(schemaValues.warmHandleIdleMs, DEFAULT_ACPX_LOCAL_WARM_HANDLE_IDLE_MS),
};
for (const key of [
"agentCommand",
"cwd",
"stateDir",
"instructionsFilePath",
"promptTemplate",
"bootstrapPromptTemplate",
]) {
const value = schemaValues[key];
if (typeof value === "string" && value.trim()) ac[key] = value.trim();
}
if (!ac.cwd && v.cwd) ac.cwd = v.cwd;
if (!ac.instructionsFilePath && v.instructionsFilePath) ac.instructionsFilePath = v.instructionsFilePath;
if (!ac.promptTemplate && v.promptTemplate) ac.promptTemplate = v.promptTemplate;
if (!ac.bootstrapPromptTemplate && v.bootstrapPrompt) ac.bootstrapPromptTemplate = v.bootstrapPrompt;
if (v.model?.trim()) ac.model = v.model.trim();
if (v.thinkingEffort) {
ac[agent === "codex" ? "modelReasoningEffort" : "effort"] = v.thinkingEffort;
}
if (schemaValues.fastMode === true) ac.fastMode = true;
const env = parseEnvBindings(v.envBindings);
const legacy = parseEnvVars(v.envVars);
for (const [key, value] of Object.entries(legacy)) {
if (!Object.prototype.hasOwnProperty.call(env, key)) {
env[key] = { type: "plain", value };
}
}
if (typeof schemaValues.env === "string") {
const schemaEnv = parseJsonObject(schemaValues.env);
if (schemaEnv) Object.assign(env, schemaEnv);
} else if (typeof schemaValues.env === "object" && schemaValues.env !== null && !Array.isArray(schemaValues.env)) {
Object.assign(env, schemaValues.env as Record<string, unknown>);
}
if (Object.keys(env).length > 0) ac.env = env;
if (v.workspaceStrategyType === "git_worktree") {
ac.workspaceStrategy = {
type: "git_worktree",
...(v.workspaceBaseRef ? { baseRef: v.workspaceBaseRef } : {}),
...(v.workspaceBranchTemplate ? { branchTemplate: v.workspaceBranchTemplate } : {}),
...(v.worktreeParentDir ? { worktreeParentDir: v.worktreeParentDir } : {}),
};
}
const runtimeServices = parseJsonObject(v.runtimeServicesJson ?? "");
if (runtimeServices && Array.isArray(runtimeServices.services)) {
ac.workspaceRuntime = runtimeServices;
}
if (v.command) ac.command = v.command;
if (v.extraArgs) ac.extraArgs = parseCommaArgs(v.extraArgs);
return ac;
}

View File

@ -1,2 +0,0 @@
export { parseAcpxStdoutLine } from "./parse-stdout.js";
export { buildAcpxLocalConfig } from "./build-config.js";

View File

@ -1,214 +0,0 @@
import { describe, expect, it } from "vitest";
import { parseAcpxStdoutLine } from "./parse-stdout.js";
const TS = "2026-04-30T00:00:00.000Z";
function emit(payload: Record<string, unknown>): string {
return JSON.stringify(payload);
}
describe("parseAcpxStdoutLine", () => {
it("renders an init entry from acpx.session", () => {
const entries = parseAcpxStdoutLine(
emit({
type: "acpx.session",
agent: "claude",
acpSessionId: "acp-1",
runtimeSessionName: "runtime-1",
mode: "persistent",
permissionMode: "approve-all",
}),
TS,
);
expect(entries).toEqual([
{
kind: "init",
ts: TS,
model: "claude (persistent / approve-all)",
sessionId: "acp-1",
},
]);
});
it("routes output text_delta to the assistant transcript", () => {
const entries = parseAcpxStdoutLine(
emit({ type: "acpx.text_delta", text: "hello", channel: "output", tag: "agent_message_chunk" }),
TS,
);
expect(entries).toEqual([
{ kind: "assistant", ts: TS, text: "hello", delta: true },
]);
});
it("routes thought text_delta to the thinking transcript", () => {
const entries = parseAcpxStdoutLine(
emit({ type: "acpx.text_delta", text: "thinking…", channel: "thought" }),
TS,
);
expect(entries).toEqual([
{ kind: "thinking", ts: TS, text: "thinking…", delta: true },
]);
});
it("falls back to stream when channel is missing", () => {
const entries = parseAcpxStdoutLine(
emit({ type: "acpx.text_delta", text: "thinking…", stream: "thought" }),
TS,
);
expect(entries[0]).toMatchObject({ kind: "thinking" });
});
it("renders status events as system text with optional ctx usage", () => {
expect(
parseAcpxStdoutLine(
emit({ type: "acpx.status", text: "thinking", tag: "agent_thought_chunk" }),
TS,
),
).toEqual([{ kind: "system", ts: TS, text: "thinking" }]);
expect(
parseAcpxStdoutLine(
emit({ type: "acpx.status", tag: "context_window", used: 12000, size: 200000 }),
TS,
),
).toEqual([{ kind: "system", ts: TS, text: "context_window (12000/200000 ctx)" }]);
});
it("emits a tool_call entry that preserves toolCallId, status, and input", () => {
const entries = parseAcpxStdoutLine(
emit({
type: "acpx.tool_call",
name: "read",
toolCallId: "tool-1",
status: "running",
text: "read README.md",
}),
TS,
);
expect(entries).toEqual([
{
kind: "tool_call",
ts: TS,
name: "read",
toolUseId: "tool-1",
input: { text: "read README.md", status: "running" },
},
]);
});
it("merges explicit tool_call input payload with status text", () => {
const entries = parseAcpxStdoutLine(
emit({
type: "acpx.tool_call",
name: "read",
toolCallId: "tool-3",
status: "in_progress",
text: "reading README.md",
input: { file: "README.md" },
}),
TS,
);
expect(entries).toEqual([
{
kind: "tool_call",
ts: TS,
name: "read",
toolUseId: "tool-3",
input: { file: "README.md", status: "in_progress", text: "reading README.md" },
},
]);
});
it("keeps terminal tool_call status while preserving existing input", () => {
const entries = parseAcpxStdoutLine(
emit({
type: "acpx.tool_call",
name: "read",
toolCallId: "tool-4",
status: "completed",
text: "ok",
input: { file: "README.md", status: "running" },
}),
TS,
);
expect(entries).toEqual([
{
kind: "tool_call",
ts: TS,
name: "read",
toolUseId: "tool-4",
input: { file: "README.md", status: "running", text: "ok" },
},
{
kind: "tool_result",
ts: TS,
toolUseId: "tool-4",
toolName: "read",
content: "ok",
isError: false,
},
]);
});
it("emits a paired tool_result entry when a tool_call reports terminal status", () => {
const completed = parseAcpxStdoutLine(
emit({
type: "acpx.tool_call",
name: "read",
toolCallId: "tool-1",
status: "completed",
text: "ok",
}),
TS,
);
expect(completed[1]).toEqual({
kind: "tool_result",
ts: TS,
toolUseId: "tool-1",
toolName: "read",
content: "ok",
isError: false,
});
const failed = parseAcpxStdoutLine(
emit({
type: "acpx.tool_call",
name: "edit",
toolCallId: "tool-2",
status: "failed",
text: "permission denied",
}),
TS,
);
expect(failed[1]).toMatchObject({ kind: "tool_result", isError: true, content: "permission denied" });
});
it("renders acpx.result with summary fallback to stopReason", () => {
const entries = parseAcpxStdoutLine(
emit({ type: "acpx.result", summary: "completed", stopReason: "end_turn" }),
TS,
);
expect(entries[0]).toMatchObject({ kind: "result", text: "completed", subtype: "end_turn", isError: false });
});
it("treats acpx.error as a stderr entry", () => {
const entries = parseAcpxStdoutLine(
emit({ type: "acpx.error", message: "auth required", code: "ACP_AUTH" }),
TS,
);
expect(entries).toEqual([{ kind: "stderr", ts: TS, text: "auth required" }]);
});
it("renders unknown acpx.* events as system entries", () => {
const entries = parseAcpxStdoutLine(
emit({ type: "acpx.misc", message: "unhandled" }),
TS,
);
expect(entries).toEqual([{ kind: "system", ts: TS, text: "unhandled" }]);
});
it("falls back to a stdout entry for non-JSON lines", () => {
const entries = parseAcpxStdoutLine("not json", TS);
expect(entries).toEqual([{ kind: "stdout", ts: TS, text: "not json" }]);
});
});

View File

@ -1,8 +0,0 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src"
},
"include": ["src"]
}

View File

@ -1,7 +0,0 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
environment: "node",
},
});

View File

@ -53,6 +53,7 @@
"probe:quota:raw": "pnpm exec tsx src/cli/quota-probe.ts --json --raw-cli"
},
"dependencies": {
"@agentclientprotocol/claude-agent-acp": "^0.52.0",
"@paperclipai/adapter-utils": "workspace:*",
"picocolors": "^1.1.1"
},

View File

@ -1,4 +1,5 @@
import pc from "picocolors";
import { printAcpxStreamEvent } from "@paperclipai/adapter-utils/acpx-engine/cli";
function asErrorText(value: unknown): string {
if (typeof value === "string") return value;
@ -51,6 +52,10 @@ export function printClaudeStreamEvent(raw: string, debug: boolean): void {
}
const type = typeof parsed.type === "string" ? parsed.type : "";
if (type.startsWith("acpx.")) {
printAcpxStreamEvent(line, debug);
return;
}
if (type === "system" && parsed.subtype === "init") {
const model = typeof parsed.model === "string" ? parsed.model : "unknown";

View File

@ -35,6 +35,7 @@ export const agentConfigurationDoc = `# claude_local agent configuration
Adapter: claude_local
Core fields:
- engine (string, optional): execution engine. Leave unset/auto to use ACP when prerequisites pass and fall back to the Claude Code CLI with diagnostics. Use "cli" to pin the CLI lane or "acp" to require ACP.
- cwd (string, optional): default absolute working directory fallback for the agent process (created if missing when possible)
- instructionsFilePath (string, optional): absolute path to a markdown instructions file injected at runtime
- model (string, optional): Claude model id
@ -49,10 +50,19 @@ Core fields:
- workspaceStrategy (object, optional): execution workspace strategy; currently supports { type: "git_worktree", baseRef?, branchTemplate?, worktreeParentDir? }
- workspaceRuntime (object, optional): reserved for workspace runtime metadata; workspace runtime services are manually controlled from the workspace UI and are not auto-started by heartbeats
ACP fields (only when engine="acp"):
- agentCommand (string, optional): override for the Claude ACP server command; defaults to the package-local claude-agent-acp binary
- mode (string, optional, default "persistent"): ACP session mode ("persistent" or "oneshot")
- stateDir (string, optional): ACP session state directory; defaults to Paperclip-managed company/agent scoped storage
- nonInteractivePermissions (string, optional, default "deny"): fallback when the ACP agent asks for input outside an interactive session
- warmHandleIdleMs (number, optional, default 0): keep the ACP process warm for this many ms after a successful run
Operational fields:
- timeoutSec (number, optional): run timeout in seconds
- graceSec (number, optional): SIGTERM grace period in seconds
Notes:
- The Claude ACP lane requires Node >=22.12.0 and @agentclientprotocol/claude-agent-acp to be installed with this adapter package. Auto engine selection falls back to CLI when those prerequisites are unavailable; explicit engine="acp" fails loudly.
- For ACP runs, model selection is passed through ANTHROPIC_MODEL at ACP server startup; Paperclip-managed Claude permissions and ephemeral skill materialization are handled by the shared ACP engine.
- When Paperclip realizes a workspace/runtime for a run, it injects PAPERCLIP_WORKSPACE_* and PAPERCLIP_RUNTIME_* env vars for agent-side tooling.
`;

View File

@ -0,0 +1,375 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import type { AdapterExecutionContext, AdapterInvocationMeta } from "@paperclipai/adapter-utils";
import {
buildClaudeAcpConfig,
createClaudeAcpExecutor,
nodeVersionMeetsClaudeAcpMinimum,
resolveClaudeExecutionEngine,
resolveClaudeExecutionEngineForRun,
testClaudeAcpEnvironment,
} from "./acp.js";
type FakeRuntimeOptions = Record<string, unknown>;
type FakeRuntimeEvent = { type: string; text?: string; stream?: string; tag?: string };
type FakeRuntimeHandle = {
sessionKey: string;
backend: string;
runtimeSessionName: string;
cwd?: string;
acpxRecordId: string;
backendSessionId: string;
agentSessionId: string;
};
type FakeRuntimeTurnResult = { status: "completed" | "failed" | "cancelled"; stopReason?: string };
type FakeRuntimeTurn = {
requestId: string;
events: AsyncIterable<FakeRuntimeEvent>;
result: Promise<FakeRuntimeTurnResult>;
cancel: () => Promise<void>;
closeStream: () => Promise<void>;
};
const tempRoots: string[] = [];
const originalNodeVersion = process.version;
function setNodeVersion(version: string): void {
Object.defineProperty(process, "version", {
configurable: true,
enumerable: true,
value: version,
});
}
afterEach(async () => {
setNodeVersion(originalNodeVersion);
await Promise.all(tempRoots.splice(0).map((root) => fs.rm(root, { recursive: true, force: true })));
});
class FakeRuntime {
ensureInputs: Array<{
sessionKey: string;
agent: string;
mode: "persistent" | "oneshot";
cwd?: string;
resumeSessionId?: string;
}> = [];
startInputs: Array<{ handle: FakeRuntimeHandle; text: string; requestId: string; timeoutMs?: number }> = [];
closeInputs: Array<{ handle: FakeRuntimeHandle; reason: string; discardPersistentState?: boolean }> = [];
setConfigInputs: Array<{ handle: FakeRuntimeHandle; key: string; value: string }> = [];
ensureCount = 0;
constructor(
readonly options: FakeRuntimeOptions,
readonly events: FakeRuntimeEvent[] = [
{ type: "text_delta", text: "hello", stream: "output", tag: "agent_message_chunk" },
],
readonly terminal: FakeRuntimeTurnResult = { status: "completed", stopReason: "end_turn" },
) {}
async ensureSession(input: {
sessionKey: string;
agent: string;
mode: "persistent" | "oneshot";
cwd?: string;
resumeSessionId?: string;
}): Promise<FakeRuntimeHandle> {
this.ensureInputs.push(input);
this.ensureCount += 1;
return {
sessionKey: input.sessionKey,
backend: "acpx",
runtimeSessionName: `runtime-${this.ensureCount}`,
cwd: input.cwd,
acpxRecordId: `record-${this.ensureCount}`,
backendSessionId: `acp-${this.ensureCount}`,
agentSessionId: `agent-${this.ensureCount}`,
};
}
startTurn(input: {
handle: FakeRuntimeHandle;
text: string;
requestId: string;
timeoutMs?: number;
}): FakeRuntimeTurn {
this.startInputs.push(input);
const events = this.events;
const terminal = this.terminal;
return {
requestId: input.requestId,
events: {
[Symbol.asyncIterator]: async function* () {
for (const event of events) yield event;
},
},
result: Promise.resolve(terminal),
cancel: async () => {},
closeStream: async () => {},
};
}
runTurn(): AsyncIterable<FakeRuntimeEvent> {
throw new Error("not used");
}
getCapabilities() {
return { controls: [] };
}
getStatus() {
return Promise.resolve({});
}
async setConfigOption(input: { handle: FakeRuntimeHandle; key: string; value: string }) {
this.setConfigInputs.push(input);
}
async setMode() {}
async cancel() {}
async close(input: { handle: FakeRuntimeHandle; reason: string; discardPersistentState?: boolean }) {
this.closeInputs.push(input);
}
}
async function makeTempRoot(prefix: string) {
const root = await fs.mkdtemp(path.join(os.tmpdir(), prefix));
tempRoots.push(root);
return root;
}
async function createRuntimeSkill(root: string) {
const source = path.join(root, "skills", "review");
await fs.mkdir(source, { recursive: true });
await fs.writeFile(path.join(source, "SKILL.md"), "---\n---\nUse the review skill.\n", "utf8");
return {
key: "company/review",
runtimeName: "review",
source,
};
}
function buildContext(root: string, overrides: Partial<AdapterExecutionContext> = {}): AdapterExecutionContext {
return {
runId: "run-1",
agent: {
id: "agent-1",
companyId: "company-1",
name: "Claude ACP",
adapterType: "claude_local",
adapterConfig: {},
},
runtime: {
sessionId: null,
sessionParams: null,
sessionDisplayId: null,
taskKey: "PAP-1",
},
config: {
engine: "acp",
cwd: root,
stateDir: path.join(root, "state"),
promptTemplate: "Do the assigned work.",
},
context: {
issueId: "issue-1",
paperclipTaskMarkdown: "Task context",
paperclipWorkspace: {
cwd: root,
source: "project_workspace",
workspaceId: "workspace-1",
},
},
onLog: async () => {},
...overrides,
};
}
describe("claude_local ACP lane", () => {
it("maps Claude config to the ACPX Claude target", () => {
expect(buildClaudeAcpConfig({
engine: "acp",
cwd: "/repo",
model: "claude-opus-4-7",
effort: "high",
agentCommand: "custom-claude-acp",
warmHandleIdleMs: 25,
})).toMatchObject({
agent: "claude",
cwd: "/repo",
model: "claude-opus-4-7",
effort: "high",
agentCommand: "custom-claude-acp",
mode: "persistent",
permissionMode: "approve-all",
nonInteractivePermissions: "deny",
warmHandleIdleMs: 25,
});
});
it("checks the Node version required by the Claude ACP runtime", () => {
setNodeVersion("v22.11.0");
expect(nodeVersionMeetsClaudeAcpMinimum()).toBe(false);
setNodeVersion("v22.12.0");
expect(nodeVersionMeetsClaudeAcpMinimum()).toBe(true);
});
it("defaults to ACP when prerequisites pass and falls back to CLI only for auto resolution", async () => {
const root = await makeTempRoot("paperclip-claude-acp-default-");
const commandPath = path.join(root, "bin", "claude-agent-acp");
await fs.mkdir(path.dirname(commandPath), { recursive: true });
await fs.writeFile(commandPath, "#!/usr/bin/env sh\n", "utf8");
setNodeVersion("v22.12.0");
expect(resolveClaudeExecutionEngine({})).toEqual({ engine: "acp", explicit: false });
await expect(
resolveClaudeExecutionEngineForRun({
config: { agentCommand: commandPath },
executionTarget: null,
}),
).resolves.toEqual({ engine: "acp", explicit: false });
await expect(
resolveClaudeExecutionEngineForRun({
config: { engine: "cli", agentCommand: commandPath },
executionTarget: null,
}),
).resolves.toEqual({ engine: "cli", explicit: true });
setNodeVersion("v22.11.0");
await expect(
resolveClaudeExecutionEngineForRun({
config: { agentCommand: commandPath },
executionTarget: null,
}),
).resolves.toMatchObject({
engine: "cli",
explicit: false,
fallbackReason: expect.stringContaining("Node"),
});
await expect(
resolveClaudeExecutionEngineForRun({
config: { engine: "acp", agentCommand: "/missing/claude-agent-acp" },
executionTarget: null,
}),
).resolves.toEqual({ engine: "acp", explicit: true });
});
it("reports ACP prerequisites for the ACP lane", async () => {
const root = await makeTempRoot("paperclip-claude-acp-env-");
const commandPath = path.join(root, "bin", "claude-agent-acp");
await fs.mkdir(path.dirname(commandPath), { recursive: true });
await fs.writeFile(commandPath, "#!/usr/bin/env sh\n", "utf8");
setNodeVersion("v22.12.0");
const result = await testClaudeAcpEnvironment({
adapterType: "claude_local",
companyId: "company-1",
config: {
engine: "acp",
cwd: root,
agentCommand: commandPath,
},
});
expect(result.status).toBe("pass");
expect(result.checks).toContainEqual(
expect.objectContaining({
code: "claude_engine_selected",
level: "info",
}),
);
expect(result.checks).toContainEqual(
expect.objectContaining({
code: "claude_acp_command_resolvable",
level: "info",
}),
);
expect(result.checks).toContainEqual(
expect.objectContaining({
code: "claude_acp_runtime_scaffold",
level: "info",
}),
);
});
it("executes through ACPX with Claude model env, settings.local.json, and ephemeral skills", async () => {
const root = await makeTempRoot("paperclip-claude-acp-exec-");
const skill = await createRuntimeSkill(root);
const runtimes: FakeRuntime[] = [];
const meta: AdapterInvocationMeta[] = [];
const execute = createClaudeAcpExecutor({
createRuntime: (options: FakeRuntimeOptions) => {
const runtime = new FakeRuntime(options);
runtimes.push(runtime);
return runtime as never;
},
});
const result = await execute(buildContext(root, {
config: {
engine: "acp",
cwd: root,
stateDir: path.join(root, "state"),
model: "claude-opus-4-7",
effort: "high",
promptTemplate: "Do the assigned work.",
paperclipRuntimeSkills: [skill],
paperclipSkillSync: { desiredSkills: [skill.key] },
},
onMeta: async (payload: AdapterInvocationMeta) => {
meta.push(payload);
},
}));
expect(result.exitCode).toBe(0);
expect(result.sessionParams).toMatchObject({
agent: "claude",
mode: "persistent",
acpSessionId: "acp-1",
workspaceId: "workspace-1",
});
expect(result.sessionParams?.skills).toMatchObject({
mode: "claude",
selectedSkills: ["review"],
});
const skillRoot = (result.sessionParams?.skills as { skillRoot?: string }).skillRoot;
expect(skillRoot).toBeTruthy();
await expect(fs.readFile(path.join(skillRoot!, "review", "SKILL.md"), "utf8")).resolves.toContain("review skill");
expect(runtimes[0]?.setConfigInputs.map((input) => [input.key, input.value])).toEqual([["effort", "high"]]);
expect(meta[0]?.commandNotes?.join("\n")).toContain("set via ANTHROPIC_MODEL");
expect(meta[0]?.env?.ANTHROPIC_MODEL).toBe("claude-opus-4-7");
const settings = JSON.parse(await fs.readFile(path.join(root, ".claude", "settings.local.json"), "utf8"));
expect(settings.permissions.defaultMode).toBe("default");
expect(settings.permissions.allow).toEqual(expect.arrayContaining(["Bash(curl:*)", "Bash(env)"]));
});
it("resumes compatible ACP sessions on later Claude ACP runs", async () => {
const root = await makeTempRoot("paperclip-claude-acp-resume-");
const runtimes: FakeRuntime[] = [];
const execute = createClaudeAcpExecutor({
createRuntime: (options: FakeRuntimeOptions) => {
const runtime = new FakeRuntime(options);
runtimes.push(runtime);
return runtime as never;
},
});
const first = await execute(buildContext(root));
const second = await execute(buildContext(root, {
runtime: {
sessionId: first.sessionId ?? null,
sessionParams: first.sessionParams ?? null,
sessionDisplayId: first.sessionDisplayId ?? null,
taskKey: "PAP-1",
},
}));
expect(second.exitCode).toBe(0);
expect(runtimes).toHaveLength(2);
expect(runtimes[1]?.ensureInputs[0]?.resumeSessionId).toBe("acp-1");
});
});

View File

@ -0,0 +1,348 @@
import fs from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
import type {
AdapterEnvironmentCheck,
AdapterEnvironmentTestContext,
AdapterEnvironmentTestResult,
AdapterExecutionContext,
AdapterExecutionResult,
} from "@paperclipai/adapter-utils";
import { readAdapterExecutionTarget } from "@paperclipai/adapter-utils/execution-target";
import {
DEFAULT_ACP_ENGINE_MODE,
DEFAULT_ACP_ENGINE_NON_INTERACTIVE_PERMISSIONS,
DEFAULT_ACP_ENGINE_PERMISSION_MODE,
DEFAULT_ACP_ENGINE_WARM_HANDLE_IDLE_MS,
} from "@paperclipai/adapter-utils/acpx-engine/constants";
import type { AcpxEngineExecutorOptions } from "@paperclipai/adapter-utils/acpx-engine/execute";
import {
asNumber,
asString,
parseObject,
} from "@paperclipai/adapter-utils/server-utils";
const moduleDir = path.dirname(fileURLToPath(import.meta.url));
const packageRootDir = path.resolve(moduleDir, "../..");
const MIN_ACP_NODE_VERSION = "22.12.0";
export type ClaudeExecutionEngine = "cli" | "acp";
export interface ClaudeEngineSelection {
engine: ClaudeExecutionEngine;
explicit: boolean;
fallbackReason?: string;
}
type ClaudeEngineResolutionInput =
Pick<AdapterExecutionContext, "config"> &
Partial<Pick<AdapterExecutionContext, "executionTarget" | "executionTransport">>;
type ClaudeAcpExecutorOptions = Omit<
AcpxEngineExecutorOptions,
"adapterType" | "moduleDir" | "packageRootDir"
>;
type ClaudeAcpExecutor = (ctx: AdapterExecutionContext) => Promise<AdapterExecutionResult>;
function normalizeEngine(value: unknown): ClaudeEngineSelection {
const raw = typeof value === "string" ? value.trim().toLowerCase() : "";
if (raw === "acp") return { engine: "acp", explicit: true };
if (raw === "cli") return { engine: "cli", explicit: true };
return { engine: "acp", explicit: false };
}
export function resolveClaudeExecutionEngine(config: Record<string, unknown>): ClaudeEngineSelection {
return normalizeEngine(config.engine);
}
export async function resolveClaudeExecutionEngineForRun(
input: ClaudeEngineResolutionInput,
): Promise<ClaudeEngineSelection> {
const selection = normalizeEngine(input.config.engine);
if (selection.explicit || selection.engine !== "acp") return selection;
const fallbackReason = await defaultClaudeAcpFallbackReason(input);
if (!fallbackReason) return selection;
return { engine: "cli", explicit: false, fallbackReason };
}
export function formatClaudeAcpFallbackMessage(reason: string): string {
return `[paperclip] Claude ACP default unavailable; falling back to Claude CLI. ${reason} Set engine=acp to require ACP or engine=cli to silence this fallback.\n`;
}
function firstNonEmptyString(...values: unknown[]): string | undefined {
for (const value of values) {
if (typeof value !== "string") continue;
const trimmed = value.trim();
if (trimmed.length > 0) return trimmed;
}
return undefined;
}
export function buildClaudeAcpConfig(config: Record<string, unknown>): Record<string, unknown> {
const agentCommand = firstNonEmptyString(config.agentCommand, config.acpAgentCommand);
const stateDir = firstNonEmptyString(config.stateDir, config.acpStateDir);
const mode = firstNonEmptyString(config.mode, config.acpMode) ?? DEFAULT_ACP_ENGINE_MODE;
const permissionMode =
firstNonEmptyString(config.permissionMode, config.acpPermissionMode) ??
DEFAULT_ACP_ENGINE_PERMISSION_MODE;
const nonInteractivePermissions =
firstNonEmptyString(config.nonInteractivePermissions, config.acpNonInteractivePermissions) ??
DEFAULT_ACP_ENGINE_NON_INTERACTIVE_PERMISSIONS;
const warmHandleIdleMs =
config.warmHandleIdleMs ??
config.acpWarmHandleIdleMs ??
DEFAULT_ACP_ENGINE_WARM_HANDLE_IDLE_MS;
return {
...config,
agent: "claude",
mode,
permissionMode,
nonInteractivePermissions,
warmHandleIdleMs,
...(agentCommand ? { agentCommand } : {}),
...(stateDir ? { stateDir } : {}),
};
}
function withClaudeAcpDefaults(options: ClaudeAcpExecutorOptions): AcpxEngineExecutorOptions {
return {
...options,
adapterType: "claude_local",
moduleDir,
packageRootDir,
};
}
export function createClaudeAcpExecutor(options: ClaudeAcpExecutorOptions = {}): ClaudeAcpExecutor {
let executor: ClaudeAcpExecutor | null = null;
return async (ctx) => {
let currentExecutor = executor;
if (!currentExecutor) {
const { createAcpxEngineExecutor } = await import("@paperclipai/adapter-utils/acpx-engine/execute");
currentExecutor = createAcpxEngineExecutor(withClaudeAcpDefaults(options));
executor = currentExecutor;
}
return currentExecutor({
...ctx,
config: buildClaudeAcpConfig(ctx.config),
});
};
}
function parseVersion(version: string): [number, number, number] {
const match = version.match(/^v?(\d+)\.(\d+)\.(\d+)/);
if (!match) return [0, 0, 0];
return [Number(match[1]), Number(match[2]), Number(match[3])];
}
export function nodeVersionMeetsClaudeAcpMinimum(version = process.version): boolean {
const [major, minor, patch] = parseVersion(version);
const [minMajor, minMinor, minPatch] = parseVersion(MIN_ACP_NODE_VERSION);
if (major !== minMajor) return major > minMajor;
if (minor !== minMinor) return minor > minMinor;
return patch >= minPatch;
}
async function pathExists(candidate: string): Promise<boolean> {
return fs.access(candidate).then(() => true).catch(() => false);
}
function hasPathSeparator(command: string): boolean {
return command.includes("/") || command.includes("\\");
}
function looksLikeShellCommand(command: string): boolean {
return /\s/.test(command.trim());
}
async function findCommandOnPath(binName: string): Promise<string | null> {
const pathValue = process.env.PATH ?? "";
for (const segment of pathValue.split(path.delimiter)) {
if (!segment) continue;
const candidate = path.join(segment, binName);
if (await pathExists(candidate)) return candidate;
}
return null;
}
async function findAncestorBin(startDir: string, binName: string): Promise<string | null> {
let current = path.resolve(startDir);
while (true) {
const candidate = path.join(current, "node_modules", ".bin", binName);
if (await pathExists(candidate)) return candidate;
const parent = path.dirname(current);
if (parent === current) return null;
current = parent;
}
}
async function commandIsResolvable(command: string): Promise<boolean> {
const trimmed = command.trim();
if (!trimmed) return false;
if (looksLikeShellCommand(trimmed)) return true;
if (path.isAbsolute(trimmed) || hasPathSeparator(trimmed)) return pathExists(trimmed);
return (await findCommandOnPath(trimmed)) !== null;
}
async function resolveClaudeAcpCommand(config: Record<string, unknown>): Promise<string> {
const configured = firstNonEmptyString(config.agentCommand, config.acpAgentCommand);
if (configured) return configured;
return (
(await findAncestorBin(packageRootDir, "claude-agent-acp")) ??
(await findCommandOnPath("claude-agent-acp")) ??
path.join(packageRootDir, "node_modules", ".bin", "claude-agent-acp")
);
}
async function defaultClaudeAcpFallbackReason(
input: ClaudeEngineResolutionInput,
): Promise<string | null> {
const target = readAdapterExecutionTarget({
executionTarget: input.executionTarget,
legacyRemoteExecution: input.executionTransport?.remoteExecution,
});
if (target?.kind === "remote") {
return "Claude ACP currently supports only the local Paperclip host, but this run targets a remote environment.";
}
if (!nodeVersionMeetsClaudeAcpMinimum()) {
return `Node ${process.version} does not satisfy Claude ACP's Node >=${MIN_ACP_NODE_VERSION} prerequisite.`;
}
const command = await resolveClaudeAcpCommand(input.config);
if (!(await commandIsResolvable(command))) {
return `Claude ACP server command is not available: ${command}.`;
}
return null;
}
function summarizeStatus(checks: AdapterEnvironmentCheck[]): AdapterEnvironmentTestResult["status"] {
if (checks.some((check) => check.level === "error")) return "fail";
if (checks.some((check) => check.level === "warn")) return "warn";
return "pass";
}
function isNonEmpty(value: unknown): value is string {
return typeof value === "string" && value.trim().length > 0;
}
export async function testClaudeAcpEnvironment(
ctx: AdapterEnvironmentTestContext,
): Promise<AdapterEnvironmentTestResult> {
const checks: AdapterEnvironmentCheck[] = [];
const config = parseObject(ctx.config);
const target = ctx.executionTarget ?? null;
const targetIsRemote = target?.kind === "remote";
checks.push({
code: "claude_engine_selected",
level: "info",
message: "Execution engine selected: ACP.",
hint: "Set engine=cli to use the existing Claude Code CLI lane.",
});
if (targetIsRemote) {
checks.push({
code: "claude_acp_remote_target_unsupported",
level: "error",
message: "Claude ACP currently runs on the local Paperclip host and cannot target a remote execution environment.",
hint: "Use engine=cli for remote or sandbox Claude runs.",
});
}
const cwd = asString(config.cwd, process.cwd());
try {
await fs.mkdir(cwd, { recursive: true });
checks.push({
code: "claude_acp_cwd_valid",
level: "info",
message: `Working directory is valid: ${cwd}`,
});
} catch (err) {
checks.push({
code: "claude_acp_cwd_invalid",
level: "error",
message: err instanceof Error ? err.message : "Invalid working directory",
detail: cwd,
});
}
checks.push({
code: nodeVersionMeetsClaudeAcpMinimum() ? "claude_acp_node_supported" : "claude_acp_node_unsupported",
level: nodeVersionMeetsClaudeAcpMinimum() ? "info" : "error",
message: nodeVersionMeetsClaudeAcpMinimum()
? `Node ${process.version} satisfies Claude ACP runtime requirements.`
: `Node ${process.version} does not satisfy Claude ACP runtime requirements.`,
hint: nodeVersionMeetsClaudeAcpMinimum()
? undefined
: `Run Claude ACP with Node >=${MIN_ACP_NODE_VERSION} or switch engine=cli.`,
});
const command = await resolveClaudeAcpCommand(config);
const commandResolvable = await commandIsResolvable(command);
checks.push({
code: commandResolvable ? "claude_acp_command_resolvable" : "claude_acp_command_missing",
level: commandResolvable ? "info" : "error",
message: commandResolvable
? `Claude ACP server command is executable: ${command}`
: `Claude ACP server command is not available: ${command}`,
hint: commandResolvable
? undefined
: "Install dependencies so @agentclientprotocol/claude-agent-acp is present, or set agentCommand to a valid Claude ACP server command.",
});
const envConfig = parseObject(config.env);
const considerHostEnv = !targetIsRemote;
const hasBedrock =
envConfig.CLAUDE_CODE_USE_BEDROCK === "1" ||
envConfig.CLAUDE_CODE_USE_BEDROCK === "true" ||
(considerHostEnv && process.env.CLAUDE_CODE_USE_BEDROCK === "1") ||
(considerHostEnv && process.env.CLAUDE_CODE_USE_BEDROCK === "true") ||
isNonEmpty(envConfig.ANTHROPIC_BEDROCK_BASE_URL) ||
(considerHostEnv && isNonEmpty(process.env.ANTHROPIC_BEDROCK_BASE_URL));
const configApiKey = envConfig.ANTHROPIC_API_KEY;
const hostApiKey = considerHostEnv ? process.env.ANTHROPIC_API_KEY : undefined;
if (hasBedrock) {
checks.push({
code: "claude_acp_bedrock_auth",
level: "info",
message: "AWS Bedrock auth detected. Claude ACP will use Bedrock for inference.",
hint: "Ensure AWS credentials and AWS_REGION are configured in this environment.",
});
} else if (isNonEmpty(configApiKey) || isNonEmpty(hostApiKey)) {
const source = isNonEmpty(configApiKey) ? "adapter config env" : "server environment";
checks.push({
code: "claude_acp_anthropic_api_key_detected",
level: "warn",
message: "ANTHROPIC_API_KEY is set. Claude ACP will use API-key auth instead of subscription credentials.",
detail: `Detected in ${source}.`,
hint: "Unset ANTHROPIC_API_KEY if you want subscription-based Claude login behavior.",
});
} else if (!targetIsRemote) {
checks.push({
code: "claude_acp_subscription_mode_possible",
level: "info",
message: "ANTHROPIC_API_KEY is not set; subscription-based auth can be used if Claude is logged in.",
});
}
const mode = firstNonEmptyString(config.mode, config.acpMode) ?? DEFAULT_ACP_ENGINE_MODE;
const warmHandleIdleMs = asNumber(
config.warmHandleIdleMs ?? config.acpWarmHandleIdleMs,
DEFAULT_ACP_ENGINE_WARM_HANDLE_IDLE_MS,
);
checks.push({
code: "claude_acp_runtime_scaffold",
level: "info",
message: "Claude ACP runtime execution is available through the shared ACP engine.",
detail: `mode=${mode}; warmHandleIdleMs=${warmHandleIdleMs}`,
});
return {
adapterType: ctx.adapterType,
status: summarizeStatus(checks),
checks,
testedAt: new Date().toISOString(),
};
}

View File

@ -0,0 +1,73 @@
import type { AdapterConfigSchema } from "@paperclipai/adapter-utils";
import {
DEFAULT_ACP_ENGINE_MODE,
DEFAULT_ACP_ENGINE_NON_INTERACTIVE_PERMISSIONS,
DEFAULT_ACP_ENGINE_WARM_HANDLE_IDLE_MS,
} from "@paperclipai/adapter-utils/acpx-engine/constants";
const acpVisible = { visibleWhen: { key: "engine", values: ["acp"] } };
export function getConfigSchema(): AdapterConfigSchema {
return {
fields: [
{
key: "engine",
label: "Execution engine",
type: "select",
default: "auto",
options: [
{ value: "auto", label: "Auto (ACP preferred)" },
{ value: "cli", label: "Claude CLI" },
{ value: "acp", label: "ACP" },
],
hint: "Auto uses ACP when prerequisites pass and falls back to Claude CLI with diagnostics.",
},
{
key: "agentCommand",
label: "ACP server command",
type: "text",
hint: "Optional override for the Claude ACP server command. Defaults to the package-local claude-agent-acp binary.",
meta: acpVisible,
},
{
key: "mode",
label: "ACP session mode",
type: "select",
default: DEFAULT_ACP_ENGINE_MODE,
options: [
{ value: "persistent", label: "Persistent" },
{ value: "oneshot", label: "One-shot" },
],
hint: "Persistent keeps ACP session state between runs. One-shot starts fresh each run.",
meta: acpVisible,
},
{
key: "nonInteractivePermissions",
label: "ACP non-interactive permissions",
type: "select",
default: DEFAULT_ACP_ENGINE_NON_INTERACTIVE_PERMISSIONS,
options: [
{ value: "deny", label: "Deny" },
{ value: "fail", label: "Fail" },
],
hint: "Fallback if the ACP agent asks for input outside an interactive session.",
meta: acpVisible,
},
{
key: "stateDir",
label: "ACP state directory",
type: "text",
hint: "Optional ACP session state directory. Defaults to Paperclip-managed company/agent scoped storage.",
meta: acpVisible,
},
{
key: "warmHandleIdleMs",
label: "ACP warm process idle ms",
type: "number",
default: DEFAULT_ACP_ENGINE_WARM_HANDLE_IDLE_MS,
hint: "Defaults to 0, which closes the ACP process after each run while retaining persistent session state.",
meta: acpVisible,
},
],
};
}

View File

@ -0,0 +1,117 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const {
ensureAdapterExecutionTargetCommandResolvable,
ensureAdapterExecutionTargetRuntimeCommandInstalled,
executeClaudeAcp,
resolveAdapterExecutionTargetCommandForLogs,
runAdapterExecutionTargetProcess,
} = vi.hoisted(() => ({
ensureAdapterExecutionTargetCommandResolvable: vi.fn(async () => undefined),
ensureAdapterExecutionTargetRuntimeCommandInstalled: vi.fn(async () => undefined),
executeClaudeAcp: vi.fn(async () => {
throw new Error('Transform failed with 1 error: execute.ts:818:0: ERROR: Unexpected "<<"');
}),
resolveAdapterExecutionTargetCommandForLogs: vi.fn(async () => "claude"),
runAdapterExecutionTargetProcess: vi.fn(async () => ({
exitCode: 0,
signal: null,
timedOut: false,
stdout: [
JSON.stringify({ type: "system", subtype: "init", session_id: "claude-session-1", model: "claude-sonnet" }),
JSON.stringify({
type: "assistant",
session_id: "claude-session-1",
message: { content: [{ type: "text", text: "hello" }] },
}),
JSON.stringify({
type: "result",
session_id: "claude-session-1",
result: "hello",
usage: { input_tokens: 1, cache_read_input_tokens: 0, output_tokens: 1 },
}),
].join("\n"),
stderr: "",
pid: 123,
startedAt: new Date().toISOString(),
})),
}));
vi.mock("./acp.js", () => ({
createClaudeAcpExecutor: () => executeClaudeAcp,
formatClaudeAcpFallbackMessage: (reason: string) =>
`[paperclip] Claude ACP default unavailable; falling back to Claude CLI. ${reason} Set engine=acp to require ACP or engine=cli to silence this fallback.\n`,
resolveClaudeExecutionEngineForRun: async (ctx: { config: Record<string, unknown> }) =>
ctx.config.engine === "acp"
? { engine: "acp", explicit: true }
: { engine: "acp", explicit: false },
}));
vi.mock("@paperclipai/adapter-utils/execution-target", async () => {
const actual = await vi.importActual<typeof import("@paperclipai/adapter-utils/execution-target")>(
"@paperclipai/adapter-utils/execution-target",
);
return {
...actual,
ensureAdapterExecutionTargetCommandResolvable,
ensureAdapterExecutionTargetRuntimeCommandInstalled,
resolveAdapterExecutionTargetCommandForLogs,
runAdapterExecutionTargetProcess,
};
});
import { execute } from "./execute.js";
function buildContext(config: Record<string, unknown> = {}) {
return {
runId: "run-1",
agent: {
id: "agent-1",
companyId: "company-1",
name: "Claude Coder",
adapterType: "claude_local",
adapterConfig: {},
},
runtime: {
sessionId: null,
sessionParams: null,
sessionDisplayId: null,
taskKey: null,
},
config,
context: {},
onLog: vi.fn(async () => {}),
};
}
describe("claude_local ACP startup fallback", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("falls back to Claude CLI when auto-selected ACP fails before execution starts", async () => {
const ctx = buildContext();
const result = await execute(ctx as never);
expect(result.exitCode).toBe(0);
expect(executeClaudeAcp).toHaveBeenCalledTimes(1);
expect(runAdapterExecutionTargetProcess).toHaveBeenCalledTimes(1);
expect(ctx.onLog).toHaveBeenCalledWith(
"stderr",
expect.stringContaining("Claude ACP startup failed"),
);
expect(ctx.onLog).toHaveBeenCalledWith(
"stderr",
expect.stringContaining('Unexpected "<<"'),
);
});
it("keeps explicit ACP strict when startup fails", async () => {
const ctx = buildContext({ engine: "acp" });
await expect(execute(ctx as never)).rejects.toThrow('Unexpected "<<"');
expect(runAdapterExecutionTargetProcess).not.toHaveBeenCalled();
});
});

View File

@ -68,8 +68,14 @@ import { isBedrockModelId } from "./models.js";
import { prepareClaudePromptBundle } from "./prompt-cache.js";
import { buildClaudeExecutionPermissionArgs } from "./permissions.js";
import { SANDBOX_INSTALL_COMMAND } from "../index.js";
import {
createClaudeAcpExecutor,
formatClaudeAcpFallbackMessage,
resolveClaudeExecutionEngineForRun,
} from "./acp.js";
const __moduleDir = path.dirname(fileURLToPath(import.meta.url));
const executeClaudeAcp = createClaudeAcpExecutor();
interface ClaudeExecutionInput {
runId: string;
@ -367,6 +373,23 @@ export async function runClaudeLogin(input: {
}
export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExecutionResult> {
const engineSelection = await resolveClaudeExecutionEngineForRun(ctx);
if (engineSelection.engine === "acp") {
try {
return await executeClaudeAcp(ctx);
} catch (err) {
if (engineSelection.explicit) throw err;
const reason = err instanceof Error ? err.message : String(err);
await ctx.onLog(
"stderr",
formatClaudeAcpFallbackMessage(`Claude ACP startup failed: ${reason}`),
);
}
}
if (!engineSelection.explicit && engineSelection.fallbackReason) {
await ctx.onLog("stderr", formatClaudeAcpFallbackMessage(engineSelection.fallbackReason));
}
const { runId, agent, runtime, config, context, onLog, onMeta, onSpawn, authToken } = ctx;
const executionTarget = readAdapterExecutionTarget({
executionTarget: ctx.executionTarget,

View File

@ -1,4 +1,6 @@
export { claudeSessionCwdMatchesExecutionTarget, execute, runClaudeLogin } from "./execute.js";
export * from "./acp.js";
export { getConfigSchema } from "./config-schema.js";
export { listClaudeSkills, syncClaudeSkills } from "./skills.js";
export { listClaudeModels, refreshClaudeModels, resetClaudeModelsCacheForTests } from "./models.js";
export { testEnvironment } from "./test.js";
@ -27,6 +29,7 @@ export {
claudeConfigDir,
} from "./quota.js";
import type { AdapterSessionCodec } from "@paperclipai/adapter-utils";
import { sessionCodec as acpxSessionCodec } from "@paperclipai/adapter-utils/acpx-engine/session-codec";
function readNonEmptyString(value: unknown): string | null {
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
@ -37,7 +40,7 @@ export const sessionCodec: AdapterSessionCodec = {
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return null;
const record = raw as Record<string, unknown>;
const sessionId = readNonEmptyString(record.sessionId) ?? readNonEmptyString(record.session_id);
if (!sessionId) return null;
if (!sessionId) return acpxSessionCodec.deserialize(raw);
const cwd =
readNonEmptyString(record.cwd) ??
readNonEmptyString(record.workdir) ??
@ -60,7 +63,7 @@ export const sessionCodec: AdapterSessionCodec = {
serialize(params: Record<string, unknown> | null) {
if (!params) return null;
const sessionId = readNonEmptyString(params.sessionId) ?? readNonEmptyString(params.session_id);
if (!sessionId) return null;
if (!sessionId) return acpxSessionCodec.serialize(params);
const cwd =
readNonEmptyString(params.cwd) ??
readNonEmptyString(params.workdir) ??
@ -82,6 +85,11 @@ export const sessionCodec: AdapterSessionCodec = {
},
getDisplayId(params: Record<string, unknown> | null) {
if (!params) return null;
return readNonEmptyString(params.sessionId) ?? readNonEmptyString(params.session_id);
return (
readNonEmptyString(params.sessionId) ??
readNonEmptyString(params.session_id) ??
acpxSessionCodec.getDisplayId?.(params) ??
null
);
},
};

View File

@ -36,6 +36,7 @@ import { isBedrockModelId } from "./models.js";
import { buildClaudeProbePermissionArgs } from "./permissions.js";
import { materializeRemoteClaudeConfig, prepareClaudeConfigSeed } from "./claude-config.js";
import { SANDBOX_INSTALL_COMMAND } from "../index.js";
import { resolveClaudeExecutionEngineForRun, testClaudeAcpEnvironment } from "./acp.js";
function summarizeStatus(checks: AdapterEnvironmentCheck[]): AdapterEnvironmentTestResult["status"] {
if (checks.some((check) => check.level === "error")) return "fail";
@ -88,7 +89,24 @@ function summarizeProbeDetail(stdout: string, stderr: string): string | null {
export async function testEnvironment(
ctx: AdapterEnvironmentTestContext,
): Promise<AdapterEnvironmentTestResult> {
const engineSelection = await resolveClaudeExecutionEngineForRun({
config: parseObject(ctx.config),
executionTarget: ctx.executionTarget,
});
if (engineSelection.engine === "acp") {
return testClaudeAcpEnvironment(ctx);
}
const checks: AdapterEnvironmentCheck[] = [];
if (!engineSelection.explicit && engineSelection.fallbackReason) {
checks.push({
code: "claude_acp_default_fallback",
level: "warn",
message: "Claude ACP default is unavailable; testing the Claude CLI fallback lane.",
detail: engineSelection.fallbackReason,
hint: "Fix the ACP prerequisite to use the default ACP lane, or set engine=cli to pin the CLI lane.",
});
}
const config = parseObject(ctx.config);
const command = asString(config.command, "claude");
const target = ctx.executionTarget ?? null;

View File

@ -0,0 +1,50 @@
import { describe, expect, it } from "vitest";
import type { CreateConfigValues } from "@paperclipai/adapter-utils";
import { buildClaudeLocalConfig } from "./build-config.js";
function makeValues(overrides: Partial<CreateConfigValues> = {}): CreateConfigValues {
return {
adapterType: "claude_local",
cwd: "",
instructionsFilePath: "",
promptTemplate: "",
model: "claude-opus-4-7",
thinkingEffort: "",
chrome: false,
dangerouslySkipPermissions: true,
claudeEngine: "auto",
search: false,
fastMode: false,
dangerouslyBypassSandbox: false,
command: "",
args: "",
extraArgs: "",
envVars: "",
envBindings: {},
url: "",
bootstrapPrompt: "",
payloadTemplateJson: "",
workspaceStrategyType: "project_primary",
workspaceBaseRef: "",
workspaceBranchTemplate: "",
worktreeParentDir: "",
runtimeServicesJson: "",
maxTurnsPerRun: 1000,
heartbeatEnabled: false,
intervalSec: 300,
...overrides,
};
}
describe("buildClaudeLocalConfig", () => {
it("omits engine for the auto default so runtime fallback remains available", () => {
const config = buildClaudeLocalConfig(makeValues({ claudeEngine: "auto" }));
expect(config).not.toHaveProperty("engine");
});
it("persists explicit engine pins", () => {
expect(buildClaudeLocalConfig(makeValues({ claudeEngine: "cli" }))).toMatchObject({ engine: "cli" });
expect(buildClaudeLocalConfig(makeValues({ claudeEngine: "acp" }))).toMatchObject({ engine: "acp" });
});
});

View File

@ -64,6 +64,19 @@ function parseJsonObject(text: string): Record<string, unknown> | null {
export function buildClaudeLocalConfig(v: CreateConfigValues): Record<string, unknown> {
const ac: Record<string, unknown> = {};
if (v.claudeEngine === "cli") ac.engine = "cli";
if (v.claudeEngine === "acp") {
ac.engine = "acp";
if (v.claudeAcpAgentCommand) ac.agentCommand = v.claudeAcpAgentCommand;
if (v.claudeAcpMode) ac.mode = v.claudeAcpMode;
if (v.claudeAcpNonInteractivePermissions) {
ac.nonInteractivePermissions = v.claudeAcpNonInteractivePermissions;
}
if (v.claudeAcpStateDir) ac.stateDir = v.claudeAcpStateDir;
if (typeof v.claudeAcpWarmHandleIdleMs === "number") {
ac.warmHandleIdleMs = v.claudeAcpWarmHandleIdleMs;
}
}
if (v.cwd) ac.cwd = v.cwd;
if (v.instructionsFilePath) ac.instructionsFilePath = v.instructionsFilePath;
if (v.model) ac.model = v.model;

View File

@ -1,4 +1,5 @@
import type { TranscriptEntry } from "@paperclipai/adapter-utils";
import { parseAcpxStdoutLine } from "@paperclipai/adapter-utils/acpx-engine/ui";
function asRecord(value: unknown): Record<string, unknown> | null {
if (typeof value !== "object" || value === null || Array.isArray(value)) return null;
@ -41,6 +42,10 @@ export function parseClaudeStdoutLine(line: string, ts: string): TranscriptEntry
}
const type = typeof parsed.type === "string" ? parsed.type : "";
if (type.startsWith("acpx.")) {
return parseAcpxStdoutLine(line, ts);
}
if (type === "system" && parsed.subtype === "init") {
return [
{

View File

@ -52,6 +52,7 @@
"probe:quota": "pnpm exec tsx src/cli/quota-probe.ts --json"
},
"dependencies": {
"@agentclientprotocol/codex-acp": "^1.1.0",
"@paperclipai/adapter-utils": "workspace:*",
"picocolors": "^1.1.1"
},

View File

@ -1,4 +1,5 @@
import pc from "picocolors";
import { printAcpxStreamEvent } from "@paperclipai/adapter-utils/acpx-engine/cli";
function asRecord(value: unknown): Record<string, unknown> | null {
if (typeof value !== "object" || value === null || Array.isArray(value)) return null;
@ -152,6 +153,10 @@ export function printCodexStreamEvent(raw: string, _debug: boolean): void {
}
const type = asString(parsed.type);
if (type.startsWith("acpx.")) {
printAcpxStreamEvent(line, _debug);
return;
}
if (type === "thread.started") {
const threadId = asString(parsed.thread_id);

View File

@ -69,6 +69,7 @@ export const agentConfigurationDoc = `# codex_local agent configuration
Adapter: codex_local
Core fields:
- engine (string, optional): leave unset/auto to use ACP when prerequisites pass and fall back to the Codex CLI with diagnostics. Use "cli" to pin the CLI lane or "acp" to require ACP.
- cwd (string, optional): default absolute working directory fallback for the agent process (created if missing when possible)
- instructionsFilePath (string, optional): absolute path to a markdown instructions file prepended to stdin prompt at runtime
- model (string, optional): Codex model id
@ -87,6 +88,11 @@ Operational fields:
- timeoutSec (number, optional): run timeout in seconds
- graceSec (number, optional): SIGTERM grace period in seconds
- outputInactivityTimeoutMs (number | null, optional): inactivity monitor around the codex child. Resets on every parsed JSONL event from stdout. Defaults to 7 * 60_000 ms when unset or non-positive. Set to \`null\` to disable the monitor entirely (only do this for known-slow tasks; the platform-level 1h silent-run safety net still applies). On fire, the adapter sends SIGTERM to the process group, waits 5s, then SIGKILL, and surfaces the run as failed with errorMessage "monitor: no codex output for {N}m {S}s".
- agentCommand (string, optional): ACP server command override used only when engine="acp"; defaults to the package-local codex-acp binary
- mode (string, optional): ACP session mode when engine="acp"; persistent or oneshot
- nonInteractivePermissions (string, optional): ACP non-interactive permission fallback when engine="acp"; deny or fail
- stateDir (string, optional): ACP state directory override when engine="acp"
- warmHandleIdleMs (number, optional): warm ACP process idle timeout when engine="acp"; defaults to 0
Notes:
- Prompts are piped via stdin (Codex receives "-" prompt argument).
@ -97,4 +103,5 @@ Notes:
- Some model/tool combinations reject certain effort levels (for example minimal with web search enabled).
- Fast mode is supported on GPT-5.5, GPT-5.4 and manual model IDs. When enabled for those models, Paperclip applies \`service_tier="fast"\` and \`features.fast_mode=true\`.
- When Paperclip realizes a workspace/runtime for a run, it injects PAPERCLIP_WORKSPACE_* and PAPERCLIP_RUNTIME_* env vars for agent-side tooling.
- Codex ACP is the preferred auto lane when Node >=22.13.0 and the Codex ACP server are available. It reuses shared ACP prompt/runtime guidance, selected skill materialization into CODEX_HOME/skills, model/reasoning/fast-mode session config, and existing quota-window reporting. Auto selection falls back to CLI when ACP prerequisites are unavailable; explicit engine="acp" fails loudly.
`;

View File

@ -0,0 +1,406 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import type { AdapterExecutionContext, AdapterInvocationMeta } from "@paperclipai/adapter-utils";
import {
buildCodexAcpConfig,
createCodexAcpExecutor,
nodeVersionMeetsCodexAcpMinimum,
resolveCodexExecutionEngine,
resolveCodexExecutionEngineForRun,
testCodexAcpEnvironment,
} from "./acp.js";
type FakeRuntimeOptions = Record<string, unknown>;
type FakeRuntimeEvent = { type: string; text?: string; stream?: string; tag?: string };
type FakeRuntimeHandle = {
sessionKey: string;
backend: string;
runtimeSessionName: string;
cwd?: string;
acpxRecordId: string;
backendSessionId: string;
agentSessionId: string;
};
type FakeRuntimeTurnResult = { status: "completed" | "failed" | "cancelled"; stopReason?: string };
type FakeRuntimeTurn = {
requestId: string;
events: AsyncIterable<FakeRuntimeEvent>;
result: Promise<FakeRuntimeTurnResult>;
cancel: () => Promise<void>;
closeStream: () => Promise<void>;
};
const tempRoots: string[] = [];
const originalNodeVersion = process.version;
const originalPaperclipHome = process.env.PAPERCLIP_HOME;
const originalPaperclipInstanceId = process.env.PAPERCLIP_INSTANCE_ID;
function setNodeVersion(version: string): void {
Object.defineProperty(process, "version", {
configurable: true,
enumerable: true,
value: version,
});
}
afterEach(async () => {
setNodeVersion(originalNodeVersion);
if (originalPaperclipHome === undefined) delete process.env.PAPERCLIP_HOME;
else process.env.PAPERCLIP_HOME = originalPaperclipHome;
if (originalPaperclipInstanceId === undefined) delete process.env.PAPERCLIP_INSTANCE_ID;
else process.env.PAPERCLIP_INSTANCE_ID = originalPaperclipInstanceId;
await Promise.all(tempRoots.splice(0).map((root) => fs.rm(root, { recursive: true, force: true })));
});
class FakeRuntime {
ensureInputs: Array<{
sessionKey: string;
agent: string;
mode: "persistent" | "oneshot";
cwd?: string;
resumeSessionId?: string;
}> = [];
startInputs: Array<{ handle: FakeRuntimeHandle; text: string; requestId: string; timeoutMs?: number }> = [];
closeInputs: Array<{ handle: FakeRuntimeHandle; reason: string; discardPersistentState?: boolean }> = [];
setConfigInputs: Array<{ handle: FakeRuntimeHandle; key: string; value: string }> = [];
ensureCount = 0;
constructor(
readonly options: FakeRuntimeOptions,
readonly events: FakeRuntimeEvent[] = [
{ type: "text_delta", text: "hello", stream: "output", tag: "agent_message_chunk" },
],
readonly terminal: FakeRuntimeTurnResult = { status: "completed", stopReason: "end_turn" },
) {}
async ensureSession(input: {
sessionKey: string;
agent: string;
mode: "persistent" | "oneshot";
cwd?: string;
resumeSessionId?: string;
}): Promise<FakeRuntimeHandle> {
this.ensureInputs.push(input);
this.ensureCount += 1;
return {
sessionKey: input.sessionKey,
backend: "acpx",
runtimeSessionName: `runtime-${this.ensureCount}`,
cwd: input.cwd,
acpxRecordId: `record-${this.ensureCount}`,
backendSessionId: `acp-${this.ensureCount}`,
agentSessionId: `agent-${this.ensureCount}`,
};
}
startTurn(input: {
handle: FakeRuntimeHandle;
text: string;
requestId: string;
timeoutMs?: number;
}): FakeRuntimeTurn {
this.startInputs.push(input);
const events = this.events;
const terminal = this.terminal;
return {
requestId: input.requestId,
events: {
[Symbol.asyncIterator]: async function* () {
for (const event of events) yield event;
},
},
result: Promise.resolve(terminal),
cancel: async () => {},
closeStream: async () => {},
};
}
runTurn(): AsyncIterable<FakeRuntimeEvent> {
throw new Error("not used");
}
getCapabilities() {
return { controls: [] };
}
getStatus() {
return Promise.resolve({});
}
async setConfigOption(input: { handle: FakeRuntimeHandle; key: string; value: string }) {
this.setConfigInputs.push(input);
}
async setMode() {}
async cancel() {}
async close(input: { handle: FakeRuntimeHandle; reason: string; discardPersistentState?: boolean }) {
this.closeInputs.push(input);
}
}
async function makeTempRoot(prefix: string) {
const root = await fs.mkdtemp(path.join(os.tmpdir(), prefix));
tempRoots.push(root);
process.env.PAPERCLIP_HOME = path.join(root, "paperclip-home");
process.env.PAPERCLIP_INSTANCE_ID = "test";
return root;
}
async function createRuntimeSkill(root: string) {
const source = path.join(root, "skills", "review");
await fs.mkdir(source, { recursive: true });
await fs.writeFile(path.join(source, "SKILL.md"), "---\n---\nUse the review skill.\n", "utf8");
return {
key: "company/review",
runtimeName: "review",
source,
};
}
function buildContext(root: string, overrides: Partial<AdapterExecutionContext> = {}): AdapterExecutionContext {
return {
runId: "run-1",
agent: {
id: "agent-1",
companyId: "company-1",
name: "Codex ACP",
adapterType: "codex_local",
adapterConfig: {},
},
runtime: {
sessionId: null,
sessionParams: null,
sessionDisplayId: null,
taskKey: "PAP-1",
},
config: {
engine: "acp",
cwd: root,
stateDir: path.join(root, "state"),
env: {
CODEX_HOME: path.join(root, "codex-home"),
},
promptTemplate: "Do the assigned work.",
},
context: {
issueId: "issue-1",
paperclipTaskMarkdown: "Task context",
paperclipWorkspace: {
cwd: root,
source: "project_workspace",
workspaceId: "workspace-1",
},
},
onLog: async () => {},
...overrides,
};
}
describe("codex_local ACP lane", () => {
it("defaults to ACP when prerequisites pass and falls back to CLI only for auto resolution", async () => {
const root = await makeTempRoot("paperclip-codex-acp-default-");
const commandPath = path.join(root, "bin", "codex-acp");
await fs.mkdir(path.dirname(commandPath), { recursive: true });
await fs.writeFile(commandPath, "#!/usr/bin/env sh\n", "utf8");
setNodeVersion("v22.13.0");
expect(resolveCodexExecutionEngine({})).toEqual({ engine: "acp", explicit: false });
await expect(
resolveCodexExecutionEngineForRun({
config: { agentCommand: commandPath },
executionTarget: null,
}),
).resolves.toEqual({ engine: "acp", explicit: false });
await expect(
resolveCodexExecutionEngineForRun({
config: { engine: "cli", agentCommand: commandPath },
executionTarget: null,
}),
).resolves.toEqual({ engine: "cli", explicit: true });
expect(resolveCodexExecutionEngine({ engine: "acp" })).toEqual({
engine: "acp",
explicit: true,
});
setNodeVersion("v22.12.0");
await expect(
resolveCodexExecutionEngineForRun({
config: { agentCommand: commandPath },
executionTarget: null,
}),
).resolves.toMatchObject({
engine: "cli",
explicit: false,
fallbackReason: expect.stringContaining("Node"),
});
await expect(
resolveCodexExecutionEngineForRun({
config: { engine: "acp", agentCommand: "/missing/codex-acp" },
executionTarget: null,
}),
).resolves.toEqual({ engine: "acp", explicit: true });
});
it("maps Codex config to the ACPX Codex target", () => {
expect(buildCodexAcpConfig({
engine: "acp",
cwd: "/repo",
command: "codex",
model: "gpt-5.5",
modelReasoningEffort: "high",
fastMode: true,
agentCommand: "custom-codex-acp",
warmHandleIdleMs: 25,
})).toMatchObject({
agent: "codex",
cwd: "/repo",
command: "codex",
model: "gpt-5.5",
modelReasoningEffort: "high",
fastMode: true,
agentCommand: "custom-codex-acp",
mode: "persistent",
permissionMode: "approve-all",
nonInteractivePermissions: "deny",
warmHandleIdleMs: 25,
});
});
it("checks the Node version required by the ACPX runtime", () => {
setNodeVersion("v22.12.0");
expect(nodeVersionMeetsCodexAcpMinimum()).toBe(false);
setNodeVersion("v22.13.0");
expect(nodeVersionMeetsCodexAcpMinimum()).toBe(true);
});
it("reports ACP prerequisites for the ACP lane", async () => {
const root = await makeTempRoot("paperclip-codex-acp-env-");
const commandPath = path.join(root, "bin", "codex-acp");
await fs.mkdir(path.dirname(commandPath), { recursive: true });
await fs.writeFile(commandPath, "#!/usr/bin/env sh\n", "utf8");
setNodeVersion("v22.13.0");
const result = await testCodexAcpEnvironment({
adapterType: "codex_local",
companyId: "company-1",
config: {
engine: "acp",
cwd: root,
agentCommand: commandPath,
env: { OPENAI_API_KEY: "test-key" },
},
});
expect(result.status).toBe("pass");
expect(result.checks).toContainEqual(
expect.objectContaining({
code: "codex_engine_selected",
level: "info",
}),
);
expect(result.checks).toContainEqual(
expect.objectContaining({
code: "codex_acp_command_resolvable",
level: "info",
}),
);
expect(result.checks).toContainEqual(
expect.objectContaining({
code: "codex_acp_runtime_scaffold",
level: "info",
}),
);
});
it("executes through ACPX with Codex session config and ephemeral skills", async () => {
const root = await makeTempRoot("paperclip-codex-acp-exec-");
const skill = await createRuntimeSkill(root);
const runtimes: FakeRuntime[] = [];
const meta: AdapterInvocationMeta[] = [];
const execute = createCodexAcpExecutor({
createRuntime: (options: FakeRuntimeOptions) => {
const runtime = new FakeRuntime(options);
runtimes.push(runtime);
return runtime as never;
},
});
const result = await execute(buildContext(root, {
config: {
engine: "acp",
cwd: root,
stateDir: path.join(root, "state"),
env: {
CODEX_HOME: path.join(root, "codex-home"),
},
model: "gpt-5.5",
modelReasoningEffort: "high",
fastMode: true,
promptTemplate: "Do the assigned work.",
paperclipRuntimeSkills: [skill],
paperclipSkillSync: { desiredSkills: [skill.key] },
},
onMeta: async (payload: AdapterInvocationMeta) => {
meta.push(payload);
},
}));
expect(result.exitCode).toBe(0);
expect(result.sessionParams).toMatchObject({
agent: "codex",
mode: "persistent",
acpSessionId: "acp-1",
workspaceId: "workspace-1",
});
expect(result.sessionParams?.skills).toMatchObject({
mode: "codex",
selectedSkills: ["review"],
});
const skillsHome = (result.sessionParams?.skills as { skillsHome?: string }).skillsHome;
expect(skillsHome).toBeTruthy();
await expect(fs.readFile(path.join(skillsHome!, "review", "SKILL.md"), "utf8")).resolves.toContain("review skill");
expect(runtimes[0]?.ensureInputs[0]).toMatchObject({
agent: "codex",
mode: "persistent",
cwd: root,
});
expect(runtimes[0]?.setConfigInputs.map((input) => [input.key, input.value])).toEqual([
["model", "gpt-5.5"],
["reasoning_effort", "high"],
["service_tier", "fast"],
["features.fast_mode", "true"],
]);
expect(meta[0]?.commandNotes?.join("\n")).toContain("Prepared ACPX Codex skill home");
expect(meta[0]?.env?.CODEX_HOME).toBe(path.join(root, "codex-home"));
});
it("resumes compatible ACP sessions on later Codex ACP runs", async () => {
const root = await makeTempRoot("paperclip-codex-acp-resume-");
const runtimes: FakeRuntime[] = [];
const execute = createCodexAcpExecutor({
createRuntime: (options: FakeRuntimeOptions) => {
const runtime = new FakeRuntime(options);
runtimes.push(runtime);
return runtime as never;
},
});
const first = await execute(buildContext(root));
const second = await execute(buildContext(root, {
runtime: {
sessionId: first.sessionId ?? null,
sessionParams: first.sessionParams ?? null,
sessionDisplayId: first.sessionDisplayId ?? null,
taskKey: "PAP-1",
},
}));
expect(second.exitCode).toBe(0);
expect(runtimes).toHaveLength(2);
expect(runtimes[1]?.ensureInputs[0]?.resumeSessionId).toBe("acp-1");
});
});

View File

@ -0,0 +1,359 @@
import fs from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
import type {
AdapterEnvironmentCheck,
AdapterEnvironmentTestContext,
AdapterEnvironmentTestResult,
AdapterExecutionContext,
AdapterExecutionResult,
} from "@paperclipai/adapter-utils";
import { readAdapterExecutionTarget } from "@paperclipai/adapter-utils/execution-target";
import {
DEFAULT_ACP_ENGINE_MODE,
DEFAULT_ACP_ENGINE_NON_INTERACTIVE_PERMISSIONS,
DEFAULT_ACP_ENGINE_PERMISSION_MODE,
DEFAULT_ACP_ENGINE_WARM_HANDLE_IDLE_MS,
} from "@paperclipai/adapter-utils/acpx-engine/constants";
import type { AcpxEngineExecutorOptions } from "@paperclipai/adapter-utils/acpx-engine/execute";
import {
asNumber,
asString,
parseObject,
} from "@paperclipai/adapter-utils/server-utils";
const moduleDir = path.dirname(fileURLToPath(import.meta.url));
const packageRootDir = path.resolve(moduleDir, "../..");
const MIN_ACP_NODE_VERSION = "22.13.0";
export type CodexExecutionEngine = "cli" | "acp";
export interface CodexEngineSelection {
engine: CodexExecutionEngine;
explicit: boolean;
fallbackReason?: string;
}
type CodexEngineResolutionInput =
Pick<AdapterExecutionContext, "config"> &
Partial<Pick<AdapterExecutionContext, "executionTarget" | "executionTransport">>;
type CodexAcpExecutorOptions = Omit<
AcpxEngineExecutorOptions,
"adapterType" | "moduleDir" | "packageRootDir"
>;
type CodexAcpExecutor = (ctx: AdapterExecutionContext) => Promise<AdapterExecutionResult>;
function normalizeEngine(value: unknown): CodexEngineSelection {
const raw = typeof value === "string" ? value.trim().toLowerCase() : "";
if (raw === "acp") return { engine: "acp", explicit: true };
if (raw === "cli") return { engine: "cli", explicit: true };
return { engine: "acp", explicit: false };
}
export function resolveCodexExecutionEngine(config: Record<string, unknown>): CodexEngineSelection {
return normalizeEngine(config.engine);
}
export async function resolveCodexExecutionEngineForRun(
input: CodexEngineResolutionInput,
): Promise<CodexEngineSelection> {
const selection = normalizeEngine(input.config.engine);
if (selection.explicit || selection.engine !== "acp") return selection;
const fallbackReason = await defaultCodexAcpFallbackReason(input);
if (!fallbackReason) return selection;
return { engine: "cli", explicit: false, fallbackReason };
}
export function formatCodexAcpFallbackMessage(reason: string): string {
return `[paperclip] Codex ACP default unavailable; falling back to Codex CLI. ${reason} Set engine=acp to require ACP or engine=cli to silence this fallback.\n`;
}
function firstNonEmptyString(...values: unknown[]): string | undefined {
for (const value of values) {
if (typeof value !== "string") continue;
const trimmed = value.trim();
if (trimmed.length > 0) return trimmed;
}
return undefined;
}
export function buildCodexAcpConfig(config: Record<string, unknown>): Record<string, unknown> {
const agentCommand = firstNonEmptyString(config.agentCommand, config.acpAgentCommand);
const stateDir = firstNonEmptyString(config.stateDir, config.acpStateDir);
const mode = firstNonEmptyString(config.mode, config.acpMode) ?? DEFAULT_ACP_ENGINE_MODE;
const permissionMode =
firstNonEmptyString(config.permissionMode, config.acpPermissionMode) ??
DEFAULT_ACP_ENGINE_PERMISSION_MODE;
const nonInteractivePermissions =
firstNonEmptyString(config.nonInteractivePermissions, config.acpNonInteractivePermissions) ??
DEFAULT_ACP_ENGINE_NON_INTERACTIVE_PERMISSIONS;
const warmHandleIdleMs =
config.warmHandleIdleMs ??
config.acpWarmHandleIdleMs ??
DEFAULT_ACP_ENGINE_WARM_HANDLE_IDLE_MS;
return {
...config,
agent: "codex",
mode,
permissionMode,
nonInteractivePermissions,
warmHandleIdleMs,
...(agentCommand ? { agentCommand } : {}),
...(stateDir ? { stateDir } : {}),
};
}
function withCodexAcpDefaults(options: CodexAcpExecutorOptions): AcpxEngineExecutorOptions {
return {
...options,
adapterType: "codex_local",
moduleDir,
packageRootDir,
};
}
export function createCodexAcpExecutor(options: CodexAcpExecutorOptions = {}): CodexAcpExecutor {
let executor: CodexAcpExecutor | null = null;
return async (ctx) => {
let currentExecutor = executor;
if (!currentExecutor) {
const { createAcpxEngineExecutor } = await import("@paperclipai/adapter-utils/acpx-engine/execute");
currentExecutor = createAcpxEngineExecutor(withCodexAcpDefaults(options));
executor = currentExecutor;
}
return currentExecutor({
...ctx,
config: buildCodexAcpConfig(ctx.config),
});
};
}
function parseVersion(version: string): [number, number, number] {
const match = version.match(/^v?(\d+)\.(\d+)\.(\d+)/);
if (!match) return [0, 0, 0];
return [Number(match[1]), Number(match[2]), Number(match[3])];
}
export function nodeVersionMeetsCodexAcpMinimum(version = process.version): boolean {
const [major, minor, patch] = parseVersion(version);
const [minMajor, minMinor, minPatch] = parseVersion(MIN_ACP_NODE_VERSION);
if (major !== minMajor) return major > minMajor;
if (minor !== minMinor) return minor > minMinor;
return patch >= minPatch;
}
async function pathExists(candidate: string): Promise<boolean> {
return fs.access(candidate).then(() => true).catch(() => false);
}
function hasPathSeparator(command: string): boolean {
return command.includes("/") || command.includes("\\");
}
function looksLikeShellCommand(command: string): boolean {
return /\s/.test(command.trim());
}
async function findCommandOnPath(binName: string): Promise<string | null> {
const pathValue = process.env.PATH ?? "";
for (const segment of pathValue.split(path.delimiter)) {
if (!segment) continue;
const candidate = path.join(segment, binName);
if (await pathExists(candidate)) return candidate;
}
return null;
}
async function findAncestorBin(startDir: string, binName: string): Promise<string | null> {
let current = path.resolve(startDir);
while (true) {
const candidate = path.join(current, "node_modules", ".bin", binName);
if (await pathExists(candidate)) return candidate;
const parent = path.dirname(current);
if (parent === current) return null;
current = parent;
}
}
async function commandIsResolvable(command: string): Promise<boolean> {
const trimmed = command.trim();
if (!trimmed) return false;
if (looksLikeShellCommand(trimmed)) return true;
if (path.isAbsolute(trimmed) || hasPathSeparator(trimmed)) return pathExists(trimmed);
return (await findCommandOnPath(trimmed)) !== null;
}
async function resolveCodexAcpCommand(config: Record<string, unknown>): Promise<string> {
const configured = firstNonEmptyString(config.agentCommand, config.acpAgentCommand);
if (configured) return configured;
return (
(await findAncestorBin(packageRootDir, "codex-acp")) ??
(await findCommandOnPath("codex-acp")) ??
path.join(packageRootDir, "node_modules", ".bin", "codex-acp")
);
}
async function defaultCodexAcpFallbackReason(
input: CodexEngineResolutionInput,
): Promise<string | null> {
const target = readAdapterExecutionTarget({
executionTarget: input.executionTarget,
legacyRemoteExecution: input.executionTransport?.remoteExecution,
});
if (target?.kind === "remote") {
return "Codex ACP currently supports only the local Paperclip host, but this run targets a remote environment.";
}
if (!nodeVersionMeetsCodexAcpMinimum()) {
return `Node ${process.version} does not satisfy Codex ACP's Node >=${MIN_ACP_NODE_VERSION} prerequisite.`;
}
const command = await resolveCodexAcpCommand(input.config);
if (!(await commandIsResolvable(command))) {
return `Codex ACP server command is not available: ${command}.`;
}
return null;
}
function summarizeStatus(checks: AdapterEnvironmentCheck[]): AdapterEnvironmentTestResult["status"] {
if (checks.some((check) => check.level === "error")) return "fail";
if (checks.some((check) => check.level === "warn")) return "warn";
return "pass";
}
function isNonEmpty(value: unknown): value is string {
return typeof value === "string" && value.trim().length > 0;
}
async function hasCodexNativeCredentials(codexHome: string): Promise<boolean> {
const raw = await fs.readFile(path.join(codexHome, "auth.json"), "utf8").catch(() => null);
if (!raw) return false;
try {
const parsed = JSON.parse(raw) as unknown;
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return false;
const record = parsed as Record<string, unknown>;
return isNonEmpty(record.OPENAI_API_KEY) || isNonEmpty(record.refresh_token);
} catch {
return false;
}
}
export async function testCodexAcpEnvironment(
ctx: AdapterEnvironmentTestContext,
): Promise<AdapterEnvironmentTestResult> {
const checks: AdapterEnvironmentCheck[] = [];
const config = parseObject(ctx.config);
const target = ctx.executionTarget ?? null;
const targetIsRemote = target?.kind === "remote";
checks.push({
code: "codex_engine_selected",
level: "info",
message: "Execution engine selected: ACP.",
hint: "Set engine=cli to use the existing Codex CLI lane.",
});
if (targetIsRemote) {
checks.push({
code: "codex_acp_remote_target_unsupported",
level: "error",
message: "Codex ACP currently runs on the local Paperclip host and cannot target a remote execution environment.",
hint: "Use engine=cli for remote or sandbox Codex runs.",
});
}
const cwd = asString(config.cwd, process.cwd());
try {
await fs.mkdir(cwd, { recursive: true });
checks.push({
code: "codex_acp_cwd_valid",
level: "info",
message: `Working directory is valid: ${cwd}`,
});
} catch (err) {
checks.push({
code: "codex_acp_cwd_invalid",
level: "error",
message: err instanceof Error ? err.message : "Invalid working directory",
detail: cwd,
});
}
checks.push({
code: nodeVersionMeetsCodexAcpMinimum() ? "codex_acp_node_supported" : "codex_acp_node_unsupported",
level: nodeVersionMeetsCodexAcpMinimum() ? "info" : "error",
message: nodeVersionMeetsCodexAcpMinimum()
? `Node ${process.version} satisfies ACP runtime requirements.`
: `Node ${process.version} does not satisfy ACP runtime requirements.`,
hint: nodeVersionMeetsCodexAcpMinimum()
? undefined
: `Run Codex ACP with Node >=${MIN_ACP_NODE_VERSION} or switch engine=cli.`,
});
const command = await resolveCodexAcpCommand(config);
const commandResolvable = await commandIsResolvable(command);
checks.push({
code: commandResolvable ? "codex_acp_command_resolvable" : "codex_acp_command_missing",
level: commandResolvable ? "info" : "error",
message: commandResolvable
? `Codex ACP server command is executable: ${command}`
: `Codex ACP server command is not available: ${command}`,
hint: commandResolvable
? undefined
: "Install dependencies so @agentclientprotocol/codex-acp is present, or set agentCommand to a valid Codex ACP server command.",
});
const envConfig = parseObject(config.env);
const considerHostEnv = !targetIsRemote;
const configApiKey = envConfig.OPENAI_API_KEY;
const hostApiKey = considerHostEnv ? process.env.OPENAI_API_KEY : undefined;
if (isNonEmpty(configApiKey) || isNonEmpty(hostApiKey)) {
const source = isNonEmpty(configApiKey) ? "adapter config env" : "server environment";
checks.push({
code: "codex_acp_openai_api_key_detected",
level: "info",
message: "OPENAI_API_KEY is set for Codex ACP authentication.",
detail: `Detected in ${source}.`,
});
} else if (!targetIsRemote) {
const codexHome = isNonEmpty(envConfig.CODEX_HOME)
? envConfig.CODEX_HOME
: path.join(process.env.HOME ?? "", ".codex");
if (codexHome && await hasCodexNativeCredentials(codexHome)) {
checks.push({
code: "codex_acp_native_auth_detected",
level: "info",
message: "Codex ACP can use Codex native authentication.",
detail: `Credentials found in ${path.join(codexHome, "auth.json")}.`,
});
} else {
checks.push({
code: "codex_acp_credentials_missing",
level: "warn",
message: "No Codex ACP credentials were detected.",
hint: "Set OPENAI_API_KEY or run `codex login` before starting a Codex ACP agent.",
});
}
}
const mode = firstNonEmptyString(config.mode, config.acpMode) ?? DEFAULT_ACP_ENGINE_MODE;
const warmHandleIdleMs = asNumber(
config.warmHandleIdleMs ?? config.acpWarmHandleIdleMs,
DEFAULT_ACP_ENGINE_WARM_HANDLE_IDLE_MS,
);
checks.push({
code: "codex_acp_runtime_scaffold",
level: "info",
message: "Codex ACP runtime execution is available through the shared ACP engine.",
detail: `mode=${mode}; warmHandleIdleMs=${warmHandleIdleMs}`,
});
return {
adapterType: ctx.adapterType,
status: summarizeStatus(checks),
checks,
testedAt: new Date().toISOString(),
};
}

View File

@ -0,0 +1,73 @@
import type { AdapterConfigSchema } from "@paperclipai/adapter-utils";
import {
DEFAULT_ACP_ENGINE_MODE,
DEFAULT_ACP_ENGINE_NON_INTERACTIVE_PERMISSIONS,
DEFAULT_ACP_ENGINE_WARM_HANDLE_IDLE_MS,
} from "@paperclipai/adapter-utils/acpx-engine/constants";
const acpVisible = { visibleWhen: { key: "engine", values: ["acp"] } };
export function getConfigSchema(): AdapterConfigSchema {
return {
fields: [
{
key: "engine",
label: "Execution engine",
type: "select",
default: "auto",
options: [
{ value: "auto", label: "Auto (ACP preferred)" },
{ value: "cli", label: "Codex CLI" },
{ value: "acp", label: "ACP" },
],
hint: "Auto uses ACP when prerequisites pass and falls back to Codex CLI with diagnostics.",
},
{
key: "agentCommand",
label: "ACP server command",
type: "text",
hint: "Optional override for the Codex ACP server command. Defaults to the package-local codex-acp binary.",
meta: acpVisible,
},
{
key: "mode",
label: "ACP session mode",
type: "select",
default: DEFAULT_ACP_ENGINE_MODE,
options: [
{ value: "persistent", label: "Persistent" },
{ value: "oneshot", label: "One-shot" },
],
hint: "Persistent keeps ACP session state between runs. One-shot starts fresh each run.",
meta: acpVisible,
},
{
key: "nonInteractivePermissions",
label: "ACP non-interactive permissions",
type: "select",
default: DEFAULT_ACP_ENGINE_NON_INTERACTIVE_PERMISSIONS,
options: [
{ value: "deny", label: "Deny" },
{ value: "fail", label: "Fail" },
],
hint: "Fallback if the ACP agent asks for input outside an interactive session.",
meta: acpVisible,
},
{
key: "stateDir",
label: "ACP state directory",
type: "text",
hint: "Optional ACP session state directory. Defaults to Paperclip-managed company/agent scoped storage.",
meta: acpVisible,
},
{
key: "warmHandleIdleMs",
label: "ACP warm process idle ms",
type: "number",
default: DEFAULT_ACP_ENGINE_WARM_HANDLE_IDLE_MS,
hint: "Defaults to 0, which closes the ACP process after each run while retaining persistent session state.",
meta: acpVisible,
},
],
};
}

View File

@ -0,0 +1,161 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const {
ensureAdapterExecutionTargetCommandResolvable,
ensureAdapterExecutionTargetRuntimeCommandInstalled,
executeCodexAcp,
prepareCodexRuntimeConfig,
readPaperclipRuntimeSkillEntries,
resolveAdapterExecutionTargetCommandForLogs,
runAdapterExecutionTargetProcess,
tempCodexHome,
} = vi.hoisted(() => ({
ensureAdapterExecutionTargetCommandResolvable: vi.fn(async () => undefined),
ensureAdapterExecutionTargetRuntimeCommandInstalled: vi.fn(async () => undefined),
executeCodexAcp: vi.fn(async () => {
throw new Error('Transform failed with 1 error: execute.ts:818:0: ERROR: Unexpected "<<"');
}),
prepareCodexRuntimeConfig: vi.fn(async () => ({ cleanup: vi.fn(async () => undefined), notes: [] })),
readPaperclipRuntimeSkillEntries: vi.fn(async () => []),
resolveAdapterExecutionTargetCommandForLogs: vi.fn(async () => "codex"),
runAdapterExecutionTargetProcess: vi.fn(async () => ({
exitCode: 0,
signal: null,
timedOut: false,
stdout: [
JSON.stringify({ type: "thread.started", thread_id: "codex-thread-1" }),
JSON.stringify({
type: "item.completed",
item: { type: "agent_message", text: "hello" },
}),
JSON.stringify({
type: "turn.completed",
usage: { input_tokens: 1, cached_input_tokens: 0, output_tokens: 1 },
}),
].join("\n"),
stderr: "",
pid: 123,
startedAt: new Date().toISOString(),
})),
tempCodexHome: "/tmp/paperclip-codex-acp-fallback-test-home",
}));
vi.mock("./acp.js", () => ({
createCodexAcpExecutor: () => executeCodexAcp,
formatCodexAcpFallbackMessage: (reason: string) =>
`[paperclip] Codex ACP default unavailable; falling back to Codex CLI. ${reason} Set engine=acp to require ACP or engine=cli to silence this fallback.\n`,
resolveCodexExecutionEngineForRun: async (ctx: { config: Record<string, unknown> }) =>
ctx.config.engine === "acp"
? { engine: "acp", explicit: true }
: { engine: "acp", explicit: false },
}));
vi.mock("@paperclipai/adapter-utils/execution-target", async () => {
const actual = await vi.importActual<typeof import("@paperclipai/adapter-utils/execution-target")>(
"@paperclipai/adapter-utils/execution-target",
);
return {
...actual,
ensureAdapterExecutionTargetCommandResolvable,
ensureAdapterExecutionTargetRuntimeCommandInstalled,
resolveAdapterExecutionTargetCommandForLogs,
runAdapterExecutionTargetProcess,
};
});
vi.mock("@paperclipai/adapter-utils/server-utils", async () => {
const actual = await vi.importActual<typeof import("@paperclipai/adapter-utils/server-utils")>(
"@paperclipai/adapter-utils/server-utils",
);
return {
...actual,
readPaperclipRuntimeSkillEntries,
};
});
vi.mock("./codex-home.js", async () => {
const actual = await vi.importActual<typeof import("./codex-home.js")>("./codex-home.js");
return {
...actual,
evaluateCodexCredentialReadiness: vi.fn(async () => ({
managed: true,
authMode: "api",
ready: true,
effectiveHome: tempCodexHome,
sharedSourceHome: tempCodexHome,
})),
isManagedCodexHomePath: vi.fn(() => true),
prepareManagedCodexHome: vi.fn(async () => ({ status: "seeded", home: tempCodexHome })),
resolveManagedCodexHomeDir: vi.fn(() => tempCodexHome),
seedManagedCodexHome: vi.fn(async () => ({ status: "seeded", home: tempCodexHome })),
};
});
vi.mock("./runtime-config.js", async () => {
const actual = await vi.importActual<typeof import("./runtime-config.js")>("./runtime-config.js");
return {
...actual,
prepareCodexRuntimeConfig,
};
});
import { execute } from "./execute.js";
function buildContext(config: Record<string, unknown> = {}) {
return {
runId: "run-1",
agent: {
id: "agent-1",
companyId: "company-1",
name: "Codex Coder",
adapterType: "codex_local",
adapterConfig: {},
},
runtime: {
sessionId: null,
sessionParams: null,
sessionDisplayId: null,
taskKey: null,
},
config: {
outputInactivityTimeoutMs: null,
env: { OPENAI_API_KEY: "test-key" },
...config,
},
context: {},
onLog: vi.fn(async () => {}),
};
}
describe("codex_local ACP startup fallback", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("falls back to Codex CLI when auto-selected ACP fails before execution starts", async () => {
const ctx = buildContext();
const result = await execute(ctx as never);
expect(result.exitCode).toBe(0);
expect(result.summary).toBe("hello");
expect(executeCodexAcp).toHaveBeenCalledTimes(1);
expect(runAdapterExecutionTargetProcess).toHaveBeenCalledTimes(1);
expect(ctx.onLog).toHaveBeenCalledWith(
"stderr",
expect.stringContaining("Codex ACP startup failed"),
);
expect(ctx.onLog).toHaveBeenCalledWith(
"stderr",
expect.stringContaining('Unexpected "<<"'),
);
});
it("keeps explicit ACP strict when startup fails", async () => {
const ctx = buildContext({ engine: "acp" });
await expect(execute(ctx as never)).rejects.toThrow('Unexpected "<<"');
expect(runAdapterExecutionTargetProcess).not.toHaveBeenCalled();
});
});

View File

@ -50,7 +50,7 @@ describe("codex managed-home auth fail-fast", () => {
companyId: "company-1",
name: "CodexCoder",
adapterType: "codex_local",
adapterConfig: {},
adapterConfig: { engine: "cli" },
},
runtime: {
sessionId: null,
@ -59,6 +59,7 @@ describe("codex managed-home auth fail-fast", () => {
taskKey: null,
},
config: {
engine: "cli",
command: "codex",
cwd: workspaceDir,
env: {

View File

@ -64,8 +64,14 @@ import {
formatOutputInactivityMonitorErrorMessage,
resolveCodexInactivityTimeout,
} from "./output-inactivity-monitor.js";
import {
createCodexAcpExecutor,
formatCodexAcpFallbackMessage,
resolveCodexExecutionEngineForRun,
} from "./acp.js";
const __moduleDir = path.dirname(fileURLToPath(import.meta.url));
const executeCodexAcp = createCodexAcpExecutor();
const CODEX_ROLLOUT_NOISE_RE =
/^\d{4}-\d{2}-\d{2}T[^\s]+\s+ERROR\s+codex_core::rollout::list:\s+state db missing rollout path for thread\s+[a-z0-9-]+$/i;
@ -322,6 +328,23 @@ export async function ensureCodexSkillsInjected(
}
export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExecutionResult> {
const engineSelection = await resolveCodexExecutionEngineForRun(ctx);
if (engineSelection.engine === "acp") {
try {
return await executeCodexAcp(ctx);
} catch (err) {
if (engineSelection.explicit) throw err;
const reason = err instanceof Error ? err.message : String(err);
await ctx.onLog(
"stderr",
formatCodexAcpFallbackMessage(`Codex ACP startup failed: ${reason}`),
);
}
}
if (!engineSelection.explicit && engineSelection.fallbackReason) {
await ctx.onLog("stderr", formatCodexAcpFallbackMessage(engineSelection.fallbackReason));
}
const { runId, agent, runtime, config, context, onLog, onMeta, onSpawn, authToken } = ctx;
const promptTemplate = asString(

View File

@ -1,4 +1,6 @@
export { execute, ensureCodexSkillsInjected } from "./execute.js";
export * from "./acp.js";
export { getConfigSchema } from "./config-schema.js";
export {
reconcileManagedCodexHome,
isManagedCodexHomePath,
@ -25,6 +27,7 @@ export {
codexHomeDir,
} from "./quota.js";
import type { AdapterSessionCodec } from "@paperclipai/adapter-utils";
import { sessionCodec as acpxSessionCodec } from "@paperclipai/adapter-utils/acpx-engine/session-codec";
function readNonEmptyString(value: unknown): string | null {
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
@ -35,7 +38,7 @@ export const sessionCodec: AdapterSessionCodec = {
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return null;
const record = raw as Record<string, unknown>;
const sessionId = readNonEmptyString(record.sessionId) ?? readNonEmptyString(record.session_id);
if (!sessionId) return null;
if (!sessionId) return acpxSessionCodec.deserialize(raw);
const cwd =
readNonEmptyString(record.cwd) ??
readNonEmptyString(record.workdir) ??
@ -54,7 +57,7 @@ export const sessionCodec: AdapterSessionCodec = {
serialize(params: Record<string, unknown> | null) {
if (!params) return null;
const sessionId = readNonEmptyString(params.sessionId) ?? readNonEmptyString(params.session_id);
if (!sessionId) return null;
if (!sessionId) return acpxSessionCodec.serialize(params);
const cwd =
readNonEmptyString(params.cwd) ??
readNonEmptyString(params.workdir) ??
@ -72,6 +75,11 @@ export const sessionCodec: AdapterSessionCodec = {
},
getDisplayId(params: Record<string, unknown> | null) {
if (!params) return null;
return readNonEmptyString(params.sessionId) ?? readNonEmptyString(params.session_id);
return (
readNonEmptyString(params.sessionId) ??
readNonEmptyString(params.session_id) ??
acpxSessionCodec.getDisplayId?.(params) ??
null
);
},
};

View File

@ -126,6 +126,7 @@ describe("codex remote environment diagnostics", () => {
companyId: "company-1",
adapterType: "codex_local",
config: {
engine: "cli",
command: "codex",
},
executionTarget: remoteTarget,
@ -199,6 +200,7 @@ describe("codex remote environment diagnostics", () => {
companyId: "company-1",
adapterType: "codex_local",
config: {
engine: "cli",
command: "codex",
env: {
OPENAI_API_KEY: "sk-test",
@ -249,7 +251,7 @@ describe("codex remote environment diagnostics", () => {
const result = await testEnvironment({
companyId: "company-1",
adapterType: "codex_local",
config: { command: "codex" },
config: { engine: "cli", command: "codex" },
executionTarget: remoteTarget,
environmentName: "QA Daytona",
});

View File

@ -25,6 +25,7 @@ import { SANDBOX_INSTALL_COMMAND } from "../index.js";
import { codexHomeDir, readCodexAuthInfo } from "./quota.js";
import { buildCodexExecArgs } from "./codex-args.js";
import { prepareManagedCodexHome } from "./codex-home.js";
import { resolveCodexExecutionEngineForRun, testCodexAcpEnvironment } from "./acp.js";
function summarizeStatus(checks: AdapterEnvironmentCheck[]): AdapterEnvironmentTestResult["status"] {
if (checks.some((check) => check.level === "error")) return "fail";
@ -194,7 +195,24 @@ async function prepareCodexHelloProbe(input: {
export async function testEnvironment(
ctx: AdapterEnvironmentTestContext,
): Promise<AdapterEnvironmentTestResult> {
const engineSelection = await resolveCodexExecutionEngineForRun({
config: parseObject(ctx.config),
executionTarget: ctx.executionTarget,
});
if (engineSelection.engine === "acp") {
return testCodexAcpEnvironment(ctx);
}
const checks: AdapterEnvironmentCheck[] = [];
if (!engineSelection.explicit && engineSelection.fallbackReason) {
checks.push({
code: "codex_acp_default_fallback",
level: "warn",
message: "Codex ACP default is unavailable; testing the Codex CLI fallback lane.",
detail: engineSelection.fallbackReason,
hint: "Fix the ACP prerequisite to use the default ACP lane, or set engine=cli to pin the CLI lane.",
});
}
const config = parseObject(ctx.config);
const command = asString(config.command, "codex");
const target = ctx.executionTarget ?? null;

View File

@ -36,6 +36,17 @@ function makeValues(overrides: Partial<CreateConfigValues> = {}): CreateConfigVa
}
describe("buildCodexLocalConfig", () => {
it("omits engine for the auto default so runtime fallback remains available", () => {
const config = buildCodexLocalConfig(makeValues({ codexEngine: "auto" }));
expect(config).not.toHaveProperty("engine");
});
it("persists explicit engine pins", () => {
expect(buildCodexLocalConfig(makeValues({ codexEngine: "cli" }))).toMatchObject({ engine: "cli" });
expect(buildCodexLocalConfig(makeValues({ codexEngine: "acp" }))).toMatchObject({ engine: "acp" });
});
it("persists the fastMode toggle into adapter config", () => {
const config = buildCodexLocalConfig(
makeValues({

View File

@ -69,6 +69,14 @@ export function buildCodexLocalConfig(v: CreateConfigValues): Record<string, unk
if (v.instructionsFilePath) ac.instructionsFilePath = v.instructionsFilePath;
if (v.model) ac.model = v.model;
if (v.thinkingEffort) ac.modelReasoningEffort = v.thinkingEffort;
if (v.codexEngine === "cli" || v.codexEngine === "acp") ac.engine = v.codexEngine;
if (v.codexEngine === "acp") {
if (v.codexAcpAgentCommand) ac.agentCommand = v.codexAcpAgentCommand;
ac.mode = v.codexAcpMode ?? "persistent";
ac.nonInteractivePermissions = v.codexAcpNonInteractivePermissions ?? "deny";
if (v.codexAcpStateDir) ac.stateDir = v.codexAcpStateDir;
ac.warmHandleIdleMs = v.codexAcpWarmHandleIdleMs ?? 0;
}
ac.timeoutSec = 0;
ac.graceSec = 15;
const env = parseEnvBindings(v.envBindings);

View File

@ -1,4 +1,5 @@
import { type TranscriptEntry } from "@paperclipai/adapter-utils";
import { parseAcpxStdoutLine } from "@paperclipai/adapter-utils/acpx-engine/ui";
function safeJsonParse(text: string): unknown {
try {
@ -228,6 +229,9 @@ export function parseCodexStdoutLine(line: string, ts: string): TranscriptEntry[
}
const type = asString(parsed.type);
if (type.startsWith("acpx.")) {
return parseAcpxStdoutLine(line, ts);
}
if (type === "thread.started") {
const threadId = asString(parsed.thread_id);

View File

@ -1,4 +1,5 @@
import pc from "picocolors";
import { printAcpxStreamEvent } from "@paperclipai/adapter-utils/acpx-engine/cli";
function asRecord(value: unknown): Record<string, unknown> | null {
if (typeof value !== "object" || value === null || Array.isArray(value)) return null;
@ -120,6 +121,11 @@ export function printGeminiStreamEvent(raw: string, _debug: boolean): void {
const type = asString(parsed.type);
if (type.startsWith("acpx.")) {
printAcpxStreamEvent(line, _debug);
return;
}
if (type === "system") {
const subtype = asString(parsed.subtype);
if (subtype === "init") {

View File

@ -52,16 +52,23 @@ Core fields:
- instructionsFilePath (string, optional): absolute path to a markdown instructions file prepended to the run prompt
- promptTemplate (string, optional): run prompt template
- model (string, optional): Gemini model id. Defaults to auto.
- engine (string, optional): leave unset/auto to use ACP when prerequisites pass and fall back to the Gemini CLI with diagnostics. Use "cli" to pin the CLI lane or "acp" to require ACP.
- sandbox (boolean, optional): run in sandbox mode (default: false, passes --sandbox=none)
- command (string, optional): defaults to "gemini"
- extraArgs (string[], optional): additional CLI args
- env (object, optional): KEY=VALUE environment variables
- agentCommand (string, optional): ACP server command override used only when engine="acp"; defaults to gemini --acp
- mode (string, optional): ACP session mode when engine="acp"; persistent or oneshot
- nonInteractivePermissions (string, optional): ACP non-interactive permission fallback when engine="acp"; deny or fail
- stateDir (string, optional): ACP state directory override when engine="acp"
- warmHandleIdleMs (number, optional): warm ACP process idle timeout when engine="acp"; defaults to 0
Operational fields:
- timeoutSec (number, optional): run timeout in seconds
- graceSec (number, optional): SIGTERM grace period in seconds
Notes:
- Gemini ACP is the preferred auto lane when Node >=20 and the local Gemini CLI command is available. It runs Gemini CLI's native \`gemini --acp\` server through Paperclip's shared ACP engine, including selected skill links, Paperclip runtime prompt/env guidance, model config, and persistent ACP session state. Auto selection falls back to the CLI lane when ACP prerequisites are unavailable; explicit engine="acp" fails loudly.
- Runs use --prompt for non-interactive execution, not stdin.
- The adapter sets a headless-safe terminal/browser environment for Gemini CLI child processes so unattended runs do not wait on browser auth or 256-color terminal prompts.
- Sessions resume with --resume when stored session cwd matches the current cwd.

View File

@ -0,0 +1,368 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import type { AdapterExecutionContext, AdapterInvocationMeta } from "@paperclipai/adapter-utils";
import {
buildGeminiAcpConfig,
createGeminiAcpExecutor,
nodeVersionMeetsGeminiAcpMinimum,
resolveGeminiExecutionEngine,
resolveGeminiExecutionEngineForRun,
testGeminiAcpEnvironment,
} from "./acp.js";
type FakeRuntimeOptions = Record<string, unknown>;
type FakeRuntimeEvent = { type: string; text?: string; stream?: string; tag?: string };
type FakeRuntimeHandle = {
sessionKey: string;
backend: string;
runtimeSessionName: string;
cwd?: string;
acpxRecordId: string;
backendSessionId: string;
agentSessionId: string;
};
type FakeRuntimeTurnResult = { status: "completed" | "failed" | "cancelled"; stopReason?: string };
type FakeRuntimeTurn = {
requestId: string;
events: AsyncIterable<FakeRuntimeEvent>;
result: Promise<FakeRuntimeTurnResult>;
cancel: () => Promise<void>;
closeStream: () => Promise<void>;
};
const tempRoots: string[] = [];
const originalNodeVersion = process.version;
const originalPath = process.env.PATH;
const originalHome = process.env.HOME;
const originalGeminiApiKey = process.env.GEMINI_API_KEY;
function setNodeVersion(version: string): void {
Object.defineProperty(process, "version", {
configurable: true,
enumerable: true,
value: version,
});
}
afterEach(async () => {
setNodeVersion(originalNodeVersion);
if (originalPath === undefined) delete process.env.PATH;
else process.env.PATH = originalPath;
if (originalHome === undefined) delete process.env.HOME;
else process.env.HOME = originalHome;
if (originalGeminiApiKey === undefined) delete process.env.GEMINI_API_KEY;
else process.env.GEMINI_API_KEY = originalGeminiApiKey;
await Promise.all(tempRoots.splice(0).map((root) => fs.rm(root, { recursive: true, force: true })));
});
class FakeRuntime {
ensureInputs: Array<{
sessionKey: string;
agent: string;
mode: "persistent" | "oneshot";
cwd?: string;
resumeSessionId?: string;
}> = [];
startInputs: Array<{ handle: FakeRuntimeHandle; text: string; requestId: string; timeoutMs?: number }> = [];
closeInputs: Array<{ handle: FakeRuntimeHandle; reason: string; discardPersistentState?: boolean }> = [];
setConfigInputs: Array<{ handle: FakeRuntimeHandle; key: string; value: string }> = [];
ensureCount = 0;
constructor(
readonly options: FakeRuntimeOptions,
readonly events: FakeRuntimeEvent[] = [
{ type: "text_delta", text: "hello", stream: "output", tag: "agent_message_chunk" },
],
readonly terminal: FakeRuntimeTurnResult = { status: "completed", stopReason: "end_turn" },
) {}
async ensureSession(input: {
sessionKey: string;
agent: string;
mode: "persistent" | "oneshot";
cwd?: string;
resumeSessionId?: string;
}): Promise<FakeRuntimeHandle> {
this.ensureInputs.push(input);
this.ensureCount += 1;
return {
sessionKey: input.sessionKey,
backend: "acpx",
runtimeSessionName: `runtime-${this.ensureCount}`,
cwd: input.cwd,
acpxRecordId: `record-${this.ensureCount}`,
backendSessionId: `acp-${this.ensureCount}`,
agentSessionId: `agent-${this.ensureCount}`,
};
}
startTurn(input: {
handle: FakeRuntimeHandle;
text: string;
requestId: string;
timeoutMs?: number;
}): FakeRuntimeTurn {
this.startInputs.push(input);
const events = this.events;
const terminal = this.terminal;
return {
requestId: input.requestId,
events: {
[Symbol.asyncIterator]: async function* () {
for (const event of events) yield event;
},
},
result: Promise.resolve(terminal),
cancel: async () => {},
closeStream: async () => {},
};
}
runTurn(): AsyncIterable<FakeRuntimeEvent> {
throw new Error("not used");
}
getCapabilities() {
return { controls: [] };
}
getStatus() {
return Promise.resolve({});
}
async setConfigOption(input: { handle: FakeRuntimeHandle; key: string; value: string }) {
this.setConfigInputs.push(input);
}
async setMode() {}
async cancel() {}
async close(input: { handle: FakeRuntimeHandle; reason: string; discardPersistentState?: boolean }) {
this.closeInputs.push(input);
}
}
async function makeTempRoot(prefix: string) {
const root = await fs.mkdtemp(path.join(os.tmpdir(), prefix));
tempRoots.push(root);
return root;
}
function buildContext(root: string, overrides: Partial<AdapterExecutionContext> = {}): AdapterExecutionContext {
return {
runId: "run-1",
agent: {
id: "agent-1",
companyId: "company-1",
name: "Gemini ACP",
adapterType: "gemini_local",
adapterConfig: {},
},
runtime: {
sessionId: null,
sessionParams: null,
sessionDisplayId: null,
taskKey: "PAP-1",
},
config: {
engine: "acp",
cwd: root,
stateDir: path.join(root, "state"),
command: "fake-gemini",
promptTemplate: "Do the assigned work.",
},
context: {
issueId: "issue-1",
paperclipTaskMarkdown: "Task context",
paperclipWorkspace: {
cwd: root,
source: "project_workspace",
workspaceId: "workspace-1",
},
},
onLog: async () => {},
...overrides,
};
}
describe("gemini_local ACP lane", () => {
it("maps Gemini config to the ACPX Gemini target", () => {
expect(buildGeminiAcpConfig({
engine: "acp",
cwd: "/repo",
model: "gemini-2.5-pro",
command: "/opt/gemini",
warmHandleIdleMs: 25,
})).toMatchObject({
agent: "gemini",
cwd: "/repo",
model: "gemini-2.5-pro",
agentCommand: "/opt/gemini --acp",
mode: "persistent",
permissionMode: "approve-all",
nonInteractivePermissions: "deny",
warmHandleIdleMs: 25,
});
expect(buildGeminiAcpConfig({ engine: "acp", model: "auto" })).not.toHaveProperty("model");
expect(buildGeminiAcpConfig({ engine: "acp", agentCommand: "custom-gemini-acp" })).toMatchObject({
agentCommand: "custom-gemini-acp",
});
});
it("checks the Node version required by the Gemini ACP runtime", () => {
setNodeVersion("v19.9.0");
expect(nodeVersionMeetsGeminiAcpMinimum()).toBe(false);
setNodeVersion("v20.0.0");
expect(nodeVersionMeetsGeminiAcpMinimum()).toBe(true);
});
it("defaults to ACP when prerequisites pass and falls back to CLI only for auto resolution", async () => {
const root = await makeTempRoot("paperclip-gemini-acp-default-");
const commandPath = path.join(root, "bin", "gemini");
await fs.mkdir(path.dirname(commandPath), { recursive: true });
await fs.writeFile(commandPath, "#!/usr/bin/env sh\n", "utf8");
setNodeVersion("v20.0.0");
expect(resolveGeminiExecutionEngine({})).toEqual({ engine: "acp", explicit: false });
await expect(
resolveGeminiExecutionEngineForRun({
config: { command: commandPath },
executionTarget: null,
}),
).resolves.toEqual({ engine: "acp", explicit: false });
await expect(
resolveGeminiExecutionEngineForRun({
config: { engine: "cli", command: commandPath },
executionTarget: null,
}),
).resolves.toEqual({ engine: "cli", explicit: true });
expect(resolveGeminiExecutionEngine({ engine: "acp" })).toEqual({
engine: "acp",
explicit: true,
});
setNodeVersion("v19.9.0");
await expect(
resolveGeminiExecutionEngineForRun({
config: { command: commandPath },
executionTarget: null,
}),
).resolves.toMatchObject({
engine: "cli",
explicit: false,
fallbackReason: expect.stringContaining("Node"),
});
await expect(
resolveGeminiExecutionEngineForRun({
config: { engine: "acp", command: "/missing/gemini" },
executionTarget: null,
}),
).resolves.toEqual({ engine: "acp", explicit: true });
});
it("falls back to the CLI lane for remote auto runs", async () => {
await expect(
resolveGeminiExecutionEngineForRun({
config: {},
executionTarget: {
kind: "remote",
transport: "ssh",
remoteCwd: "/work",
spec: {
host: "127.0.0.1",
port: 22,
username: "fixture",
remoteCwd: "/work",
remoteWorkspacePath: "/work",
privateKey: null,
knownHosts: null,
strictHostKeyChecking: true,
},
},
}),
).resolves.toMatchObject({
engine: "cli",
explicit: false,
fallbackReason: expect.stringContaining("remote environment"),
});
});
it("executes Gemini through the shared ACP runtime", async () => {
const root = await makeTempRoot("paperclip-gemini-acp-run-");
process.env.HOME = path.join(root, "home");
const runtime = new FakeRuntime({});
const metas: AdapterInvocationMeta[] = [];
const logs: Array<{ stream: string; text: string }> = [];
const execute = createGeminiAcpExecutor({
createRuntime: (options) => {
Object.assign(runtime.options, options);
return runtime as never;
},
});
const result = await execute(buildContext(root, {
onMeta: async (meta) => {
metas.push(meta);
},
onLog: async (stream, text) => {
logs.push({ stream, text });
},
}));
expect(runtime.ensureInputs[0]).toMatchObject({
agent: "gemini",
mode: "persistent",
cwd: root,
});
expect(runtime.startInputs[0]?.text).toContain("Do the assigned work.");
expect(result).toMatchObject({
exitCode: 0,
provider: "acpx",
sessionId: "acp-1",
sessionDisplayId: "agent-1",
summary: "hello",
});
expect(result.sessionParams).toMatchObject({
agent: "gemini",
acpSessionId: "acp-1",
cwd: root,
});
expect(metas[0]).toMatchObject({
adapterType: "gemini_local",
command: "fake-gemini --acp",
});
expect(logs.some((entry) => entry.text.includes("\"type\":\"acpx.session\""))).toBe(true);
});
it("reports Gemini ACP environment readiness", async () => {
const root = await makeTempRoot("paperclip-gemini-acp-env-");
const bin = path.join(root, "bin");
await fs.mkdir(bin, { recursive: true });
await fs.writeFile(path.join(bin, "gemini"), "#!/usr/bin/env sh\n", "utf8");
process.env.PATH = `${bin}${path.delimiter}${process.env.PATH ?? ""}`;
process.env.GEMINI_API_KEY = "test-key";
setNodeVersion("v20.0.0");
const result = await testGeminiAcpEnvironment({
adapterType: "gemini_local",
companyId: "company-1",
config: {
engine: "acp",
cwd: root,
},
});
expect(result.status).toBe("pass");
expect(result.checks).toEqual(
expect.arrayContaining([
expect.objectContaining({ code: "gemini_engine_selected" }),
expect.objectContaining({ code: "gemini_acp_command_resolvable" }),
expect.objectContaining({ code: "gemini_acp_credentials_detected" }),
]),
);
});
});

View File

@ -0,0 +1,347 @@
import fs from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
import type {
AdapterEnvironmentCheck,
AdapterEnvironmentTestContext,
AdapterEnvironmentTestResult,
AdapterExecutionContext,
AdapterExecutionResult,
} from "@paperclipai/adapter-utils";
import { readAdapterExecutionTarget } from "@paperclipai/adapter-utils/execution-target";
import {
DEFAULT_ACP_ENGINE_MODE,
DEFAULT_ACP_ENGINE_NON_INTERACTIVE_PERMISSIONS,
DEFAULT_ACP_ENGINE_PERMISSION_MODE,
DEFAULT_ACP_ENGINE_WARM_HANDLE_IDLE_MS,
} from "@paperclipai/adapter-utils/acpx-engine/constants";
import type { AcpxEngineExecutorOptions } from "@paperclipai/adapter-utils/acpx-engine/execute";
import {
asNumber,
asString,
parseObject,
} from "@paperclipai/adapter-utils/server-utils";
import { DEFAULT_GEMINI_LOCAL_MODEL } from "../index.js";
const moduleDir = path.dirname(fileURLToPath(import.meta.url));
const packageRootDir = path.resolve(moduleDir, "../..");
const MIN_ACP_NODE_VERSION = "20.0.0";
export type GeminiExecutionEngine = "cli" | "acp";
export interface GeminiEngineSelection {
engine: GeminiExecutionEngine;
explicit: boolean;
fallbackReason?: string;
}
type GeminiEngineResolutionInput =
Pick<AdapterExecutionContext, "config"> &
Partial<Pick<AdapterExecutionContext, "executionTarget" | "executionTransport">>;
type GeminiAcpExecutorOptions = Omit<
AcpxEngineExecutorOptions,
"adapterType" | "moduleDir" | "packageRootDir"
>;
type GeminiAcpExecutor = (ctx: AdapterExecutionContext) => Promise<AdapterExecutionResult>;
function normalizeEngine(value: unknown): GeminiEngineSelection {
const raw = typeof value === "string" ? value.trim().toLowerCase() : "";
if (raw === "acp") return { engine: "acp", explicit: true };
if (raw === "cli") return { engine: "cli", explicit: true };
return { engine: "acp", explicit: false };
}
export function resolveGeminiExecutionEngine(config: Record<string, unknown>): GeminiEngineSelection {
return normalizeEngine(config.engine);
}
export async function resolveGeminiExecutionEngineForRun(
input: GeminiEngineResolutionInput,
): Promise<GeminiEngineSelection> {
const selection = normalizeEngine(input.config.engine);
if (selection.explicit || selection.engine !== "acp") return selection;
const fallbackReason = await defaultGeminiAcpFallbackReason(input);
if (!fallbackReason) return selection;
return { engine: "cli", explicit: false, fallbackReason };
}
export function formatGeminiAcpFallbackMessage(reason: string): string {
return `[paperclip] Gemini ACP default unavailable; falling back to Gemini CLI. ${reason} Set engine=acp to require ACP or engine=cli to silence this fallback.\n`;
}
function firstNonEmptyString(...values: unknown[]): string | undefined {
for (const value of values) {
if (typeof value !== "string") continue;
const trimmed = value.trim();
if (trimmed.length > 0) return trimmed;
}
return undefined;
}
export function buildGeminiAcpConfig(config: Record<string, unknown>): Record<string, unknown> {
const configuredAgentCommand = firstNonEmptyString(config.agentCommand, config.acpAgentCommand);
const configuredGeminiCommand = firstNonEmptyString(config.command);
const agentCommand = configuredAgentCommand ?? (configuredGeminiCommand ? `${configuredGeminiCommand} --acp` : undefined);
const stateDir = firstNonEmptyString(config.stateDir, config.acpStateDir);
const mode = firstNonEmptyString(config.mode, config.acpMode) ?? DEFAULT_ACP_ENGINE_MODE;
const permissionMode =
firstNonEmptyString(config.permissionMode, config.acpPermissionMode) ??
DEFAULT_ACP_ENGINE_PERMISSION_MODE;
const nonInteractivePermissions =
firstNonEmptyString(config.nonInteractivePermissions, config.acpNonInteractivePermissions) ??
DEFAULT_ACP_ENGINE_NON_INTERACTIVE_PERMISSIONS;
const warmHandleIdleMs =
config.warmHandleIdleMs ??
config.acpWarmHandleIdleMs ??
DEFAULT_ACP_ENGINE_WARM_HANDLE_IDLE_MS;
const next: Record<string, unknown> = {
...config,
agent: "gemini",
mode,
permissionMode,
nonInteractivePermissions,
warmHandleIdleMs,
...(agentCommand ? { agentCommand } : {}),
...(stateDir ? { stateDir } : {}),
};
const model = asString(next.model, "").trim();
if (!model || model === DEFAULT_GEMINI_LOCAL_MODEL) delete next.model;
return next;
}
function withGeminiAcpDefaults(options: GeminiAcpExecutorOptions): AcpxEngineExecutorOptions {
return {
...options,
adapterType: "gemini_local",
moduleDir,
packageRootDir,
};
}
export function createGeminiAcpExecutor(options: GeminiAcpExecutorOptions = {}): GeminiAcpExecutor {
let executor: GeminiAcpExecutor | null = null;
return async (ctx) => {
let currentExecutor = executor;
if (!currentExecutor) {
const { createAcpxEngineExecutor } = await import("@paperclipai/adapter-utils/acpx-engine/execute");
currentExecutor = createAcpxEngineExecutor(withGeminiAcpDefaults(options));
executor = currentExecutor;
}
return currentExecutor({
...ctx,
config: buildGeminiAcpConfig(ctx.config),
});
};
}
function parseVersion(version: string): [number, number, number] {
const match = version.match(/^v?(\d+)\.(\d+)\.(\d+)/);
if (!match) return [0, 0, 0];
return [Number(match[1]), Number(match[2]), Number(match[3])];
}
export function nodeVersionMeetsGeminiAcpMinimum(version = process.version): boolean {
const [major, minor, patch] = parseVersion(version);
const [minMajor, minMinor, minPatch] = parseVersion(MIN_ACP_NODE_VERSION);
if (major !== minMajor) return major > minMajor;
if (minor !== minMinor) return minor > minMinor;
return patch >= minPatch;
}
async function pathExists(candidate: string): Promise<boolean> {
return fs.access(candidate).then(() => true).catch(() => false);
}
function hasPathSeparator(command: string): boolean {
return command.includes("/") || command.includes("\\");
}
function firstShellToken(command: string): string | null {
const trimmed = command.trim();
if (!trimmed) return null;
if (trimmed.startsWith("'") || trimmed.startsWith("\"")) return null;
return trimmed.split(/\s+/, 1)[0] ?? null;
}
async function findCommandOnPath(binName: string, pathValue = process.env.PATH ?? ""): Promise<string | null> {
for (const segment of pathValue.split(path.delimiter)) {
if (!segment) continue;
const candidate = path.join(segment, binName);
if (await pathExists(candidate)) return candidate;
}
return null;
}
function resolveConfigPath(config: Record<string, unknown>): string {
const envConfig = parseObject(config.env);
return typeof envConfig.PATH === "string" && envConfig.PATH.trim().length > 0
? envConfig.PATH
: process.env.PATH ?? "";
}
async function commandIsResolvable(command: string, pathValue = process.env.PATH ?? ""): Promise<boolean> {
const token = firstShellToken(command);
if (!token) return true;
if (path.isAbsolute(token) || hasPathSeparator(token)) return pathExists(token);
return (await findCommandOnPath(token, pathValue)) !== null;
}
function resolveGeminiAcpCommand(config: Record<string, unknown>): string {
const configured = firstNonEmptyString(config.agentCommand, config.acpAgentCommand);
if (configured) return configured;
const geminiCommand = firstNonEmptyString(config.command) ?? "gemini";
return `${geminiCommand} --acp`;
}
async function defaultGeminiAcpFallbackReason(
input: GeminiEngineResolutionInput,
): Promise<string | null> {
const target = readAdapterExecutionTarget({
executionTarget: input.executionTarget,
legacyRemoteExecution: input.executionTransport?.remoteExecution,
});
if (target?.kind === "remote") {
return "Gemini ACP currently supports only the local Paperclip host, but this run targets a remote environment.";
}
if (!nodeVersionMeetsGeminiAcpMinimum()) {
return `Node ${process.version} does not satisfy Gemini ACP's Node >=${MIN_ACP_NODE_VERSION} prerequisite.`;
}
const command = resolveGeminiAcpCommand(input.config);
if (!(await commandIsResolvable(command, resolveConfigPath(input.config)))) {
return `Gemini ACP command is not available: ${command}.`;
}
return null;
}
function summarizeStatus(checks: AdapterEnvironmentCheck[]): AdapterEnvironmentTestResult["status"] {
if (checks.some((check) => check.level === "error")) return "fail";
if (checks.some((check) => check.level === "warn")) return "warn";
return "pass";
}
function isNonEmpty(value: unknown): value is string {
return typeof value === "string" && value.trim().length > 0;
}
export async function testGeminiAcpEnvironment(
ctx: AdapterEnvironmentTestContext,
): Promise<AdapterEnvironmentTestResult> {
const checks: AdapterEnvironmentCheck[] = [];
const config = parseObject(ctx.config);
const target = ctx.executionTarget ?? null;
const targetIsRemote = target?.kind === "remote";
checks.push({
code: "gemini_engine_selected",
level: "info",
message: "Execution engine selected: ACP.",
hint: "Set engine=cli to use the existing Gemini CLI lane.",
});
if (targetIsRemote) {
checks.push({
code: "gemini_acp_remote_target_unsupported",
level: "error",
message: "Gemini ACP currently runs on the local Paperclip host and cannot target a remote execution environment.",
hint: "Use engine=cli for remote or sandbox Gemini runs.",
});
}
const cwd = asString(config.cwd, process.cwd());
try {
await fs.mkdir(cwd, { recursive: true });
checks.push({
code: "gemini_acp_cwd_valid",
level: "info",
message: `Working directory is valid: ${cwd}`,
});
} catch (err) {
checks.push({
code: "gemini_acp_cwd_invalid",
level: "error",
message: err instanceof Error ? err.message : "Invalid working directory",
detail: cwd,
});
}
checks.push({
code: nodeVersionMeetsGeminiAcpMinimum() ? "gemini_acp_node_supported" : "gemini_acp_node_unsupported",
level: nodeVersionMeetsGeminiAcpMinimum() ? "info" : "error",
message: nodeVersionMeetsGeminiAcpMinimum()
? `Node ${process.version} satisfies ACP runtime requirements.`
: `Node ${process.version} does not satisfy ACP runtime requirements.`,
hint: nodeVersionMeetsGeminiAcpMinimum()
? undefined
: `Run Gemini ACP with Node >=${MIN_ACP_NODE_VERSION} or switch engine=cli.`,
});
const command = resolveGeminiAcpCommand(config);
const commandResolvable = await commandIsResolvable(command, resolveConfigPath(config));
checks.push({
code: commandResolvable ? "gemini_acp_command_resolvable" : "gemini_acp_command_missing",
level: commandResolvable ? "info" : "error",
message: commandResolvable
? `Gemini ACP command is executable: ${command}`
: `Gemini ACP command is not available: ${command}`,
hint: commandResolvable
? undefined
: "Install the Gemini CLI with ACP support, or set agentCommand to a valid Gemini ACP server command.",
});
const envConfig = parseObject(config.env);
const considerHostEnv = !targetIsRemote;
const hasGca = envConfig.GOOGLE_GENAI_USE_GCA === "true" || (considerHostEnv && process.env.GOOGLE_GENAI_USE_GCA === "true");
const configGeminiApiKey = envConfig.GEMINI_API_KEY;
const hostGeminiApiKey = considerHostEnv ? process.env.GEMINI_API_KEY : undefined;
const configGoogleApiKey = envConfig.GOOGLE_API_KEY;
const hostGoogleApiKey = considerHostEnv ? process.env.GOOGLE_API_KEY : undefined;
if (
isNonEmpty(configGeminiApiKey) ||
isNonEmpty(hostGeminiApiKey) ||
isNonEmpty(configGoogleApiKey) ||
isNonEmpty(hostGoogleApiKey) ||
hasGca
) {
const source = hasGca
? "Google account login (GCA)"
: isNonEmpty(configGeminiApiKey) || isNonEmpty(configGoogleApiKey)
? "adapter config env"
: "server environment";
checks.push({
code: "gemini_acp_credentials_detected",
level: "info",
message: "Gemini credentials are set for ACP authentication.",
detail: `Detected in ${source}.`,
});
} else if (!targetIsRemote) {
checks.push({
code: "gemini_acp_credentials_not_detected",
level: "warn",
message: "No Gemini ACP credentials were detected.",
hint: "Set GEMINI_API_KEY / GOOGLE_API_KEY, enable Google account auth, or run `gemini auth login` before starting a Gemini ACP agent.",
});
}
const mode = firstNonEmptyString(config.mode, config.acpMode) ?? DEFAULT_ACP_ENGINE_MODE;
const warmHandleIdleMs = asNumber(
config.warmHandleIdleMs ?? config.acpWarmHandleIdleMs,
DEFAULT_ACP_ENGINE_WARM_HANDLE_IDLE_MS,
);
checks.push({
code: "gemini_acp_runtime_scaffold",
level: "info",
message: "Gemini ACP runtime execution is available through the shared ACP engine.",
detail: `mode=${mode}; warmHandleIdleMs=${warmHandleIdleMs}`,
});
return {
adapterType: ctx.adapterType,
status: summarizeStatus(checks),
checks,
testedAt: new Date().toISOString(),
};
}

View File

@ -0,0 +1,73 @@
import type { AdapterConfigSchema } from "@paperclipai/adapter-utils";
import {
DEFAULT_ACP_ENGINE_MODE,
DEFAULT_ACP_ENGINE_NON_INTERACTIVE_PERMISSIONS,
DEFAULT_ACP_ENGINE_WARM_HANDLE_IDLE_MS,
} from "@paperclipai/adapter-utils/acpx-engine/constants";
const acpVisible = { visibleWhen: { key: "engine", values: ["acp"] } };
export function getConfigSchema(): AdapterConfigSchema {
return {
fields: [
{
key: "engine",
label: "Execution engine",
type: "select",
default: "auto",
options: [
{ value: "auto", label: "Auto (ACP preferred)" },
{ value: "cli", label: "Gemini CLI" },
{ value: "acp", label: "ACP" },
],
hint: "Auto uses ACP when prerequisites pass and falls back to Gemini CLI with diagnostics.",
},
{
key: "agentCommand",
label: "ACP server command",
type: "text",
hint: "Optional override for the Gemini ACP server command. Defaults to gemini --acp.",
meta: acpVisible,
},
{
key: "mode",
label: "ACP session mode",
type: "select",
default: DEFAULT_ACP_ENGINE_MODE,
options: [
{ value: "persistent", label: "Persistent" },
{ value: "oneshot", label: "One-shot" },
],
hint: "Persistent keeps ACP session state between runs. One-shot starts fresh each run.",
meta: acpVisible,
},
{
key: "nonInteractivePermissions",
label: "ACP non-interactive permissions",
type: "select",
default: DEFAULT_ACP_ENGINE_NON_INTERACTIVE_PERMISSIONS,
options: [
{ value: "deny", label: "Deny" },
{ value: "fail", label: "Fail" },
],
hint: "Fallback if the ACP agent asks for input outside an interactive session.",
meta: acpVisible,
},
{
key: "stateDir",
label: "ACP state directory",
type: "text",
hint: "Optional ACP session state directory. Defaults to Paperclip-managed company/agent scoped storage.",
meta: acpVisible,
},
{
key: "warmHandleIdleMs",
label: "ACP warm process idle ms",
type: "number",
default: DEFAULT_ACP_ENGINE_WARM_HANDLE_IDLE_MS,
hint: "Defaults to 0, which closes the ACP process after each run while retaining persistent session state.",
meta: acpVisible,
},
],
};
}

View File

@ -0,0 +1,128 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const {
ensureAdapterExecutionTargetCommandResolvable,
ensureAdapterExecutionTargetRuntimeCommandInstalled,
executeGeminiAcp,
readPaperclipRuntimeSkillEntries,
resolveAdapterExecutionTargetCommandForLogs,
runAdapterExecutionTargetProcess,
} = vi.hoisted(() => ({
ensureAdapterExecutionTargetCommandResolvable: vi.fn(async () => undefined),
ensureAdapterExecutionTargetRuntimeCommandInstalled: vi.fn(async () => undefined),
executeGeminiAcp: vi.fn(async () => {
throw new Error('Transform failed with 1 error: execute.ts:818:0: ERROR: Unexpected "<<"');
}),
readPaperclipRuntimeSkillEntries: vi.fn(async () => []),
resolveAdapterExecutionTargetCommandForLogs: vi.fn(async () => "gemini"),
runAdapterExecutionTargetProcess: vi.fn(async () => ({
exitCode: 0,
signal: null,
timedOut: false,
stdout: [
JSON.stringify({ type: "init", session_id: "gemini-session-1" }),
JSON.stringify({ type: "message", role: "assistant", content: "hello" }),
JSON.stringify({
type: "result",
status: "success",
stats: { input_tokens: 1, cached_input_tokens: 0, output_tokens: 1 },
}),
].join("\n"),
stderr: "",
pid: 123,
startedAt: new Date().toISOString(),
})),
}));
vi.mock("./acp.js", () => ({
createGeminiAcpExecutor: () => executeGeminiAcp,
formatGeminiAcpFallbackMessage: (reason: string) =>
`[paperclip] Gemini ACP default unavailable; falling back to Gemini CLI. ${reason} Set engine=acp to require ACP or engine=cli to silence this fallback.\n`,
resolveGeminiExecutionEngineForRun: async (ctx: { config: Record<string, unknown> }) =>
ctx.config.engine === "acp"
? { engine: "acp", explicit: true }
: { engine: "acp", explicit: false },
}));
vi.mock("@paperclipai/adapter-utils/execution-target", async () => {
const actual = await vi.importActual<typeof import("@paperclipai/adapter-utils/execution-target")>(
"@paperclipai/adapter-utils/execution-target",
);
return {
...actual,
ensureAdapterExecutionTargetCommandResolvable,
ensureAdapterExecutionTargetRuntimeCommandInstalled,
resolveAdapterExecutionTargetCommandForLogs,
runAdapterExecutionTargetProcess,
};
});
vi.mock("@paperclipai/adapter-utils/server-utils", async () => {
const actual = await vi.importActual<typeof import("@paperclipai/adapter-utils/server-utils")>(
"@paperclipai/adapter-utils/server-utils",
);
return {
...actual,
readPaperclipRuntimeSkillEntries,
};
});
import { execute } from "./execute.js";
function buildContext(config: Record<string, unknown> = {}) {
return {
runId: "run-1",
agent: {
id: "agent-1",
companyId: "company-1",
name: "Gemini Coder",
adapterType: "gemini_local",
adapterConfig: {},
},
runtime: {
sessionId: null,
sessionParams: null,
sessionDisplayId: null,
taskKey: null,
},
config: {
env: { GEMINI_API_KEY: "test-key" },
...config,
},
context: {},
onLog: vi.fn(async () => {}),
};
}
describe("gemini_local ACP startup fallback", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("falls back to Gemini CLI when auto-selected ACP fails before execution starts", async () => {
const ctx = buildContext();
const result = await execute(ctx as never);
expect(result.exitCode).toBe(0);
expect(result.summary).toBe("hello");
expect(executeGeminiAcp).toHaveBeenCalledTimes(1);
expect(runAdapterExecutionTargetProcess).toHaveBeenCalledTimes(1);
expect(ctx.onLog).toHaveBeenCalledWith(
"stderr",
expect.stringContaining("Gemini ACP startup failed"),
);
expect(ctx.onLog).toHaveBeenCalledWith(
"stderr",
expect.stringContaining('Unexpected "<<"'),
);
});
it("keeps explicit ACP strict when startup fails", async () => {
const ctx = buildContext({ engine: "acp" });
await expect(execute(ctx as never)).rejects.toThrow('Unexpected "<<"');
expect(runAdapterExecutionTargetProcess).not.toHaveBeenCalled();
});
});

View File

@ -57,8 +57,14 @@ import {
parseGeminiJsonl,
} from "./parse.js";
import { firstNonEmptyLine } from "./utils.js";
import {
createGeminiAcpExecutor,
formatGeminiAcpFallbackMessage,
resolveGeminiExecutionEngineForRun,
} from "./acp.js";
const __moduleDir = path.dirname(fileURLToPath(import.meta.url));
const executeGeminiAcp = createGeminiAcpExecutor();
function hasNonEmptyEnvValue(env: Record<string, string>, key: string): boolean {
const raw = env[key];
@ -195,6 +201,23 @@ async function buildGeminiSkillsDir(
}
export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExecutionResult> {
const engineSelection = await resolveGeminiExecutionEngineForRun(ctx);
if (engineSelection.engine === "acp") {
try {
return await executeGeminiAcp(ctx);
} catch (err) {
if (engineSelection.explicit) throw err;
const reason = err instanceof Error ? err.message : String(err);
await ctx.onLog(
"stderr",
formatGeminiAcpFallbackMessage(`Gemini ACP startup failed: ${reason}`),
);
}
}
if (!engineSelection.explicit && engineSelection.fallbackReason) {
await ctx.onLog("stderr", formatGeminiAcpFallbackMessage(engineSelection.fallbackReason));
}
const { runId, agent, runtime, config, context, onLog, onMeta, onSpawn, authToken } = ctx;
const executionTarget = readAdapterExecutionTarget({
executionTarget: ctx.executionTarget,

View File

@ -1,4 +1,6 @@
export { execute } from "./execute.js";
export * from "./acp.js";
export { getConfigSchema } from "./config-schema.js";
export { listGeminiSkills, syncGeminiSkills } from "./skills.js";
export { testEnvironment } from "./test.js";
export {
@ -10,6 +12,7 @@ export {
isGeminiTurnLimitResult,
} from "./parse.js";
import type { AdapterSessionCodec } from "@paperclipai/adapter-utils";
import { sessionCodec as acpxSessionCodec } from "@paperclipai/adapter-utils/acpx-engine/session-codec";
function readNonEmptyString(value: unknown): string | null {
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
@ -23,7 +26,7 @@ export const sessionCodec: AdapterSessionCodec = {
readNonEmptyString(record.sessionId) ??
readNonEmptyString(record.session_id) ??
readNonEmptyString(record.sessionID);
if (!sessionId) return null;
if (!sessionId) return acpxSessionCodec.deserialize(raw);
const cwd =
readNonEmptyString(record.cwd) ??
readNonEmptyString(record.workdir) ??
@ -45,7 +48,7 @@ export const sessionCodec: AdapterSessionCodec = {
readNonEmptyString(params.sessionId) ??
readNonEmptyString(params.session_id) ??
readNonEmptyString(params.sessionID);
if (!sessionId) return null;
if (!sessionId) return acpxSessionCodec.serialize(params);
const cwd =
readNonEmptyString(params.cwd) ??
readNonEmptyString(params.workdir) ??
@ -66,7 +69,9 @@ export const sessionCodec: AdapterSessionCodec = {
return (
readNonEmptyString(params.sessionId) ??
readNonEmptyString(params.session_id) ??
readNonEmptyString(params.sessionID)
readNonEmptyString(params.sessionID) ??
acpxSessionCodec.getDisplayId?.(params) ??
null
);
},
};

View File

@ -23,6 +23,10 @@ import {
import { DEFAULT_GEMINI_LOCAL_MODEL, SANDBOX_INSTALL_COMMAND } from "../index.js";
import { detectGeminiAuthRequired, detectGeminiQuotaExhausted, parseGeminiJsonl } from "./parse.js";
import { firstNonEmptyLine } from "./utils.js";
import {
resolveGeminiExecutionEngineForRun,
testGeminiAcpEnvironment,
} from "./acp.js";
function summarizeStatus(checks: AdapterEnvironmentCheck[]): AdapterEnvironmentTestResult["status"] {
if (checks.some((check) => check.level === "error")) return "fail";
@ -50,7 +54,24 @@ function summarizeProbeDetail(stdout: string, stderr: string, parsedError: strin
export async function testEnvironment(
ctx: AdapterEnvironmentTestContext,
): Promise<AdapterEnvironmentTestResult> {
const engineSelection = await resolveGeminiExecutionEngineForRun({
config: parseObject(ctx.config),
executionTarget: ctx.executionTarget,
});
if (engineSelection.engine === "acp") {
return testGeminiAcpEnvironment(ctx);
}
const checks: AdapterEnvironmentCheck[] = [];
if (!engineSelection.explicit && engineSelection.fallbackReason) {
checks.push({
code: "gemini_acp_default_fallback",
level: "warn",
message: "Gemini ACP default is unavailable; testing the Gemini CLI fallback lane.",
detail: engineSelection.fallbackReason,
hint: "Fix the ACP prerequisite to use the default ACP lane, or set engine=cli to pin the CLI lane.",
});
}
const config = parseObject(ctx.config);
const command = asString(config.command, "gemini");
const target = ctx.executionTarget ?? null;

View File

@ -0,0 +1,69 @@
import { describe, expect, it } from "vitest";
import type { CreateConfigValues } from "@paperclipai/adapter-utils";
import { buildGeminiLocalConfig } from "./build-config.js";
function makeValues(overrides: Partial<CreateConfigValues> = {}): CreateConfigValues {
return {
adapterType: "gemini_local",
cwd: "",
instructionsFilePath: "",
promptTemplate: "",
model: "gemini-2.5-pro",
thinkingEffort: "",
chrome: false,
dangerouslySkipPermissions: true,
search: false,
fastMode: false,
dangerouslyBypassSandbox: false,
command: "",
args: "",
extraArgs: "",
envVars: "",
envBindings: {},
url: "",
bootstrapPrompt: "",
payloadTemplateJson: "",
workspaceStrategyType: "project_primary",
workspaceBaseRef: "",
workspaceBranchTemplate: "",
worktreeParentDir: "",
runtimeServicesJson: "",
maxTurnsPerRun: 1000,
heartbeatEnabled: false,
intervalSec: 300,
...overrides,
};
}
describe("buildGeminiLocalConfig", () => {
it("omits engine for the auto default so runtime fallback remains available", () => {
const config = buildGeminiLocalConfig(makeValues({ geminiEngine: "auto" }));
expect(config).not.toHaveProperty("engine");
});
it("persists explicit engine pins", () => {
expect(buildGeminiLocalConfig(makeValues({ geminiEngine: "cli" }))).toMatchObject({ engine: "cli" });
expect(buildGeminiLocalConfig(makeValues({ geminiEngine: "acp" }))).toMatchObject({ engine: "acp" });
});
it("persists ACP fields when Gemini ACP is selected", () => {
const config = buildGeminiLocalConfig(makeValues({
geminiEngine: "acp",
geminiAcpAgentCommand: "custom-gemini --acp",
geminiAcpMode: "oneshot",
geminiAcpNonInteractivePermissions: "fail",
geminiAcpStateDir: "/tmp/gemini-acp",
geminiAcpWarmHandleIdleMs: 30,
}));
expect(config).toMatchObject({
engine: "acp",
agentCommand: "custom-gemini --acp",
mode: "oneshot",
nonInteractivePermissions: "fail",
stateDir: "/tmp/gemini-acp",
warmHandleIdleMs: 30,
});
});
});

View File

@ -55,6 +55,14 @@ export function buildGeminiLocalConfig(v: CreateConfigValues): Record<string, un
const ac: Record<string, unknown> = {};
if (v.cwd) ac.cwd = v.cwd;
if (v.instructionsFilePath) ac.instructionsFilePath = v.instructionsFilePath;
if (v.geminiEngine === "cli" || v.geminiEngine === "acp") ac.engine = v.geminiEngine;
if (v.geminiEngine === "acp") {
if (v.geminiAcpAgentCommand) ac.agentCommand = v.geminiAcpAgentCommand;
ac.mode = v.geminiAcpMode ?? "persistent";
ac.nonInteractivePermissions = v.geminiAcpNonInteractivePermissions ?? "deny";
if (v.geminiAcpStateDir) ac.stateDir = v.geminiAcpStateDir;
ac.warmHandleIdleMs = v.geminiAcpWarmHandleIdleMs ?? 0;
}
ac.model = v.model || DEFAULT_GEMINI_LOCAL_MODEL;
ac.timeoutSec = 0;
ac.graceSec = 15;

View File

@ -70,4 +70,12 @@ describe("parseGeminiStdoutLine", () => {
const line = JSON.stringify({ type: "message", role: "system", content: "ignored" });
expect(parseGeminiStdoutLine(line, ts)).toEqual([]);
});
it("delegates ACPX events to the shared ACPX transcript parser", () => {
const line = JSON.stringify({ type: "acpx.text_delta", text: "hello from acp" });
expect(parseGeminiStdoutLine(line, ts)).toEqual([
{ kind: "assistant", ts, text: "hello from acp", delta: true },
]);
});
});

View File

@ -1,4 +1,5 @@
import type { TranscriptEntry } from "@paperclipai/adapter-utils";
import { parseAcpxStdoutLine } from "@paperclipai/adapter-utils/acpx-engine/ui";
function safeJsonParse(text: string): unknown {
try {
@ -216,6 +217,10 @@ export function parseGeminiStdoutLine(line: string, ts: string): TranscriptEntry
const type = asString(parsed.type);
if (type.startsWith("acpx.")) {
return parseAcpxStdoutLine(line, ts);
}
if (type === "system") {
const subtype = asString(parsed.subtype);
if (subtype === "init") {

View File

@ -0,0 +1,97 @@
WITH migrated_agents AS (
SELECT
"id",
"company_id",
CASE lower(COALESCE(NULLIF("adapter_config" ->> 'agent', ''), 'claude'))
WHEN 'codex' THEN 'codex_local'
ELSE 'claude_local'
END AS "next_adapter_type",
CASE lower(COALESCE(NULLIF("adapter_config" ->> 'agent', ''), 'claude'))
WHEN 'codex' THEN
(
"adapter_config"
- 'agent'
- 'effort'
- 'reasoningEffort'
- 'thinkingEffort'
)
|| jsonb_build_object('engine', 'acp')
|| CASE
WHEN COALESCE(
"adapter_config" -> 'modelReasoningEffort',
"adapter_config" -> 'reasoningEffort',
"adapter_config" -> 'thinkingEffort',
"adapter_config" -> 'effort'
) IS NULL THEN '{}'::jsonb
ELSE jsonb_build_object(
'modelReasoningEffort',
COALESCE(
"adapter_config" -> 'modelReasoningEffort',
"adapter_config" -> 'reasoningEffort',
"adapter_config" -> 'thinkingEffort',
"adapter_config" -> 'effort'
)
)
END
ELSE
(
"adapter_config"
- 'agent'
- 'modelReasoningEffort'
- 'reasoningEffort'
- 'thinkingEffort'
)
|| jsonb_build_object('engine', 'acp')
|| CASE
WHEN COALESCE(
"adapter_config" -> 'effort',
"adapter_config" -> 'thinkingEffort',
"adapter_config" -> 'reasoningEffort',
"adapter_config" -> 'modelReasoningEffort'
) IS NULL THEN '{}'::jsonb
ELSE jsonb_build_object(
'effort',
COALESCE(
"adapter_config" -> 'effort',
"adapter_config" -> 'thinkingEffort',
"adapter_config" -> 'reasoningEffort',
"adapter_config" -> 'modelReasoningEffort'
)
)
END
END AS "next_adapter_config"
FROM "agents"
WHERE "adapter_type" = 'acpx_local'
AND lower(COALESCE(NULLIF("adapter_config" ->> 'agent', ''), 'claude')) IN ('claude', 'codex')
),
updated_agents AS (
UPDATE "agents"
SET
"adapter_type" = migrated_agents."next_adapter_type",
"adapter_config" = migrated_agents."next_adapter_config",
"updated_at" = now()
FROM migrated_agents
WHERE "agents"."id" = migrated_agents."id"
RETURNING
"agents"."id",
"agents"."company_id",
migrated_agents."next_adapter_type"
),
cleared_task_sessions AS (
DELETE FROM "agent_task_sessions"
USING updated_agents
WHERE "agent_task_sessions"."agent_id" = updated_agents."id"
AND "agent_task_sessions"."company_id" = updated_agents."company_id"
AND "agent_task_sessions"."adapter_type" = 'acpx_local'
RETURNING "agent_task_sessions"."id"
)
UPDATE "agent_runtime_state"
SET
"adapter_type" = updated_agents."next_adapter_type",
"session_id" = NULL,
"state_json" = '{}'::jsonb,
"last_error" = NULL,
"updated_at" = now()
FROM updated_agents
WHERE "agent_runtime_state"."agent_id" = updated_agents."id"
AND "agent_runtime_state"."company_id" = updated_agents."company_id";

View File

@ -939,6 +939,13 @@
"when": 1783025724120,
"tag": "0135_repair_run_responsible_user_updated_at_sweep",
"breakpoints": true
},
{
"idx": 136,
"version": "7",
"when": 1783555200000,
"tag": "0136_acpx_default_engine_migration",
"breakpoints": true
}
]
}

View File

@ -61,7 +61,7 @@ Common optional fields:
| Field | Default | Purpose |
|---|---|---|
| `backend` | `"sandbox-cr"` | `sandbox-cr` (alpha, requires agent-sandbox controller) or `job` (stable, one-shot entrypoint). |
| `adapterType` | `"claude_local"` | One of the supported adapter types (claude_local, codex_local, gemini_local, cursor_local, opencode_local, acpx_local, pi_local). Determines runtime image + env keys + egress allow-list. |
| `adapterType` | `"claude_local"` | One of the supported adapter types (claude_local, codex_local, gemini_local, cursor_local, opencode_local, pi_local). Determines runtime image + env keys + egress allow-list. |
| `namespacePrefix` | `"paperclip-"` | Prefix for the per-company tenant namespace. |
| `companySlug` | derived from companyId | Override the auto-derived company slug. |
| `imageRegistry` | (none) | Override the default registry for agent runtime images. |

View File

@ -40,12 +40,6 @@ const REGISTRY: Record<string, AdapterDefaults> = {
allowFqdns: ["api.anthropic.com", "api.openai.com", "openrouter.ai"],
probeCommand: ["opencode", "--version"],
},
acpx_local: {
runtimeImage: "ghcr.io/paperclipai/agent-runtime-acpx:v1",
envKeys: ["ANTHROPIC_API_KEY", "OPENAI_API_KEY"],
allowFqdns: ["api.anthropic.com", "api.openai.com"],
probeCommand: ["acpx", "--version"],
},
pi_local: {
runtimeImage: "ghcr.io/paperclipai/agent-runtime-pi:v1",
envKeys: ["ANTHROPIC_API_KEY"],

View File

@ -28,7 +28,7 @@ describe("adapter-defaults (built-in)", () => {
expect(() => getAdapterDefaults("nonexistent_local")).toThrow(/unknown adapter type/i);
});
it("KNOWN_ADAPTER_TYPES contains all 7 supported adapters", () => {
it("KNOWN_ADAPTER_TYPES contains all 6 supported adapters", () => {
expect(KNOWN_ADAPTER_TYPES).toEqual(
new Set([
"claude_local",
@ -36,7 +36,6 @@ describe("adapter-defaults (built-in)", () => {
"gemini_local",
"cursor_local",
"opencode_local",
"acpx_local",
"pi_local",
]),
);

View File

@ -30,7 +30,6 @@ export type AgentStatus = (typeof AGENT_STATUSES)[number];
export const AGENT_ADAPTER_TYPES = [
"process",
"http",
"acpx_local",
"claude_local",
"codex_local",
"cursor_cloud",

View File

@ -37,7 +37,6 @@ export interface EnvironmentCapabilities {
}
const REMOTE_MANAGED_ADAPTERS = new Set<AgentAdapterType>([
"acpx_local",
"claude_local",
"codex_local",
"cursor",

View File

@ -165,7 +165,7 @@ export const PAPERCLIP_ENUM_DESCRIPTIONS = {
"adapter_type": {
"process": "Agent runtime uses a local process adapter.",
"http": "Agent runtime uses a generic HTTP adapter.",
"acpx_local": "Agent runtime uses the local ACPX adapter.",
"acpx_local": "Agent runtime used the retired local ACPX adapter.",
"claude_local": "Agent runtime uses the local Claude adapter.",
"codex_local": "Agent runtime uses the local Codex adapter.",
"cursor_cloud": "Agent runtime uses the Cursor cloud adapter.",
@ -218,7 +218,7 @@ export const PAPERCLIP_ENUM_DESCRIPTIONS = {
"adapter_type": {
"process": "Agent runtime uses a local process adapter.",
"http": "Agent runtime uses a generic HTTP adapter.",
"acpx_local": "Agent runtime uses the local ACPX adapter.",
"acpx_local": "Agent runtime used the retired local ACPX adapter.",
"claude_local": "Agent runtime uses the local Claude adapter.",
"codex_local": "Agent runtime uses the local Codex adapter.",
"cursor_cloud": "Agent runtime uses the Cursor cloud adapter.",

View File

@ -20,8 +20,8 @@ function usage() {
" node scripts/bootstrap-npm-package.mjs <package-name-or-dir> [--publish --otp <code>] [--skip-build]",
"",
"Examples:",
" node scripts/bootstrap-npm-package.mjs @paperclipai/adapter-acpx-local",
" node scripts/bootstrap-npm-package.mjs packages/adapters/acpx-local --publish",
" node scripts/bootstrap-npm-package.mjs @paperclipai/plugin-workspace-diff",
" node scripts/bootstrap-npm-package.mjs packages/plugins/plugin-workspace-diff --publish",
"",
].join("\n"),
);

View File

@ -4,9 +4,9 @@ import test from "node:test";
import { buildPublishArgs, parseArgs, resolveTargetPackage } from "./bootstrap-npm-package.mjs";
test("parseArgs recognizes publish and skip-build flags", () => {
assert.deepEqual(parseArgs(["@paperclipai/adapter-acpx-local", "--publish", "--skip-build"]), {
assert.deepEqual(parseArgs(["@paperclipai/plugin-workspace-diff", "--publish", "--skip-build"]), {
help: false,
selector: "@paperclipai/adapter-acpx-local",
selector: "@paperclipai/plugin-workspace-diff",
publish: true,
skipBuild: true,
otp: null,
@ -14,9 +14,9 @@ test("parseArgs recognizes publish and skip-build flags", () => {
});
test("parseArgs accepts an explicit otp value", () => {
assert.deepEqual(parseArgs(["packages/adapters/acpx-local", "--publish", "--otp", "123456"]), {
assert.deepEqual(parseArgs(["packages/plugins/plugin-workspace-diff", "--publish", "--otp", "123456"]), {
help: false,
selector: "packages/adapters/acpx-local",
selector: "packages/plugins/plugin-workspace-diff",
publish: true,
skipBuild: false,
otp: "123456",
@ -24,9 +24,9 @@ test("parseArgs accepts an explicit otp value", () => {
});
test("parseArgs leaves otp null when omitted", () => {
assert.deepEqual(parseArgs(["packages/adapters/acpx-local", "--publish"]), {
assert.deepEqual(parseArgs(["packages/plugins/plugin-workspace-diff", "--publish"]), {
help: false,
selector: "packages/adapters/acpx-local",
selector: "packages/plugins/plugin-workspace-diff",
publish: true,
skipBuild: false,
otp: null,

View File

@ -1,43 +0,0 @@
#!/usr/bin/env node
import path from "node:path";
import fs from "node:fs/promises";
import { fileURLToPath } from "node:url";
const repoRoot = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
const playwrightPkgRoot = path.join(repoRoot, "node_modules/.pnpm/playwright@1.58.2/node_modules/playwright");
const { chromium } = await import(path.join(playwrightPkgRoot, "index.mjs"));
const baseUrl = process.env.STORYBOOK_BASE_URL ?? "http://127.0.0.1:6007";
const outDir = process.env.OUT_DIR ?? path.join(repoRoot, "screenshots/pap-2999");
await fs.mkdir(outDir, { recursive: true });
const stories = [
{ id: "adapters-acpx-local--skills-tab-claude", slug: "skills-claude" },
{ id: "adapters-acpx-local--skills-tab-codex", slug: "skills-codex" },
{ id: "adapters-acpx-local--skills-tab-custom", slug: "skills-custom" },
{ id: "adapters-acpx-local--skills-tab-loading", slug: "skills-loading" },
{ id: "adapters-acpx-local--skills-tab-empty-library", slug: "skills-empty-library" },
];
const themes = [
{ name: "light", apply: false },
{ name: "dark", apply: true },
];
const browser = await chromium.launch();
try {
const context = await browser.newContext({ viewport: { width: 1280, height: 1100 } });
const page = await context.newPage();
for (const story of stories) {
for (const theme of themes) {
const url = `${baseUrl}/iframe.html?args=&id=${story.id}&viewMode=story&globals=theme:${theme.name}`;
await page.goto(url, { waitUntil: "load" });
await page.waitForTimeout(1500);
const target = path.join(outDir, `${story.slug}-${theme.name}.png`);
await page.screenshot({ path: target, fullPage: true });
console.log(`captured ${target}`);
}
}
} finally {
await browser.close();
}

View File

@ -4,11 +4,6 @@
"name": "@paperclipai/adapter-utils",
"publishFromCi": true
},
{
"dir": "packages/adapters/acpx-local",
"name": "@paperclipai/adapter-acpx-local",
"publishFromCi": true
},
{
"dir": "packages/adapters/claude-local",
"name": "@paperclipai/adapter-claude-local",

View File

@ -13,7 +13,6 @@ const nonServerProjects = [
"@paperclipai/skills-catalog",
"@paperclipai/db",
"@paperclipai/adapter-utils",
"@paperclipai/adapter-acpx-local",
"@paperclipai/adapter-codex-local",
"@paperclipai/adapter-opencode-local",
"@paperclipai/plugin-sdk",

View File

@ -44,7 +44,6 @@
},
"dependencies": {
"@aws-sdk/client-s3": "^3.1075.0",
"@paperclipai/adapter-acpx-local": "workspace:*",
"@paperclipai/adapter-claude-local": "workspace:*",
"@paperclipai/adapter-codex-local": "workspace:*",
"@paperclipai/adapter-cursor-cloud": "workspace:*",

View File

@ -1,129 +0,0 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { testEnvironment } from "@paperclipai/adapter-acpx-local/server";
import type { AdapterEnvironmentCheck } from "@paperclipai/adapter-utils";
function credentialChecks(checks: AdapterEnvironmentCheck[]): AdapterEnvironmentCheck[] {
return checks.filter((check) => check.code.startsWith("acpx_claude_") || check.code.startsWith("acpx_codex_"));
}
describe("acpx_local environment credential diagnostics", () => {
beforeEach(() => {
vi.stubEnv("ANTHROPIC_API_KEY", "");
vi.stubEnv("ANTHROPIC_BEDROCK_BASE_URL", "");
vi.stubEnv("CLAUDE_CODE_USE_BEDROCK", "");
vi.stubEnv("CLAUDE_CONFIG_DIR", "");
vi.stubEnv("OPENAI_API_KEY", "");
vi.stubEnv("CODEX_HOME", "");
});
afterEach(() => {
vi.unstubAllEnvs();
});
it("emits an info-level Claude credential hint when ANTHROPIC_API_KEY is present", async () => {
const result = await testEnvironment({
companyId: "company-1",
adapterType: "acpx_local",
config: {
agent: "claude",
env: {
ANTHROPIC_API_KEY: "sk-ant-test",
},
},
});
expect(result.checks).toContainEqual(expect.objectContaining({
code: "acpx_claude_anthropic_api_key_detected",
level: "info",
}));
expect(result.checks.some((check) => check.code.startsWith("acpx_codex_"))).toBe(false);
});
it("emits an info-level Claude missing credential hint without changing diagnostic health", async () => {
const root = path.join(os.tmpdir(), `paperclip-acpx-claude-noauth-${Date.now()}-${Math.random().toString(16).slice(2)}`);
const claudeConfigDir = path.join(root, ".claude");
try {
await fs.mkdir(claudeConfigDir, { recursive: true });
const result = await testEnvironment({
companyId: "company-1",
adapterType: "acpx_local",
config: {
agent: "claude",
env: {
CLAUDE_CONFIG_DIR: claudeConfigDir,
},
},
});
expect(result.checks).toContainEqual(expect.objectContaining({
code: "acpx_claude_credentials_missing",
level: "info",
}));
expect(credentialChecks(result.checks).every((check) => check.level === "info")).toBe(true);
} finally {
await fs.rm(root, { recursive: true, force: true });
}
});
it("emits an info-level Codex credential hint when native auth is present", async () => {
const root = path.join(os.tmpdir(), `paperclip-acpx-codex-auth-${Date.now()}-${Math.random().toString(16).slice(2)}`);
const codexHome = path.join(root, ".codex");
try {
await fs.mkdir(codexHome, { recursive: true });
await fs.writeFile(path.join(codexHome, "auth.json"), JSON.stringify({ accessToken: "token" }), "utf8");
const result = await testEnvironment({
companyId: "company-1",
adapterType: "acpx_local",
config: {
agent: "codex",
env: {
CODEX_HOME: codexHome,
},
},
});
expect(result.checks).toContainEqual(expect.objectContaining({
code: "acpx_codex_native_auth_detected",
level: "info",
}));
expect(result.checks.some((check) => check.code.startsWith("acpx_claude_"))).toBe(false);
} finally {
await fs.rm(root, { recursive: true, force: true });
}
});
it("emits an info-level Codex missing credential hint without changing diagnostic health", async () => {
const root = path.join(os.tmpdir(), `paperclip-acpx-codex-noauth-${Date.now()}-${Math.random().toString(16).slice(2)}`);
const codexHome = path.join(root, ".codex");
try {
await fs.mkdir(codexHome, { recursive: true });
const result = await testEnvironment({
companyId: "company-1",
adapterType: "acpx_local",
config: {
agent: "codex",
env: {
CODEX_HOME: codexHome,
},
},
});
expect(result.checks).toContainEqual(expect.objectContaining({
code: "acpx_codex_credentials_missing",
level: "info",
}));
expect(credentialChecks(result.checks).every((check) => check.level === "info")).toBe(true);
} finally {
await fs.rm(root, { recursive: true, force: true });
}
});
});

View File

@ -1,767 +0,0 @@
import { describe, expect, it, vi } from "vitest";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import type { AdapterExecutionContext } from "@paperclipai/adapter-utils";
import { createAcpxLocalExecutor } from "@paperclipai/adapter-acpx-local/server";
import type {
AcpRuntime,
AcpRuntimeEvent,
AcpRuntimeHandle,
AcpRuntimeOptions,
AcpRuntimeTurn,
AcpRuntimeTurnResult,
} from "acpx/runtime";
type LogEntry = { stream: "stdout" | "stderr"; chunk: string };
type TestAcpRuntimeOptions = AcpRuntimeOptions & {
sessionOptions?: {
systemPrompt?: string | { append: string };
additionalRoots?: string[];
};
};
class FakeRuntime implements AcpRuntime {
ensureInputs: Array<{ sessionKey: string; agent: string; mode: "persistent" | "oneshot"; cwd?: string; resumeSessionId?: string }> = [];
startInputs: Array<{ handle: AcpRuntimeHandle; text: string; requestId: string; timeoutMs?: number }> = [];
closeInputs: Array<{ handle: AcpRuntimeHandle; reason: string; discardPersistentState?: boolean }> = [];
cancelInputs: Array<{ handle: AcpRuntimeHandle; reason?: string }> = [];
setModeInputs: Array<{ handle: AcpRuntimeHandle; mode: string }> = [];
setConfigInputs: Array<{ handle: AcpRuntimeHandle; key: string; value: string }> = [];
ensureCount = 0;
turnCount = 0;
nextEnsureError: Error | null = null;
constructor(
readonly options: TestAcpRuntimeOptions,
readonly events: AcpRuntimeEvent[] = [
{ type: "status", text: "thinking", tag: "agent_thought_chunk" },
{ type: "text_delta", text: "hello ", stream: "output", tag: "agent_message_chunk" },
{ type: "tool_call", text: "read README.md", title: "read", status: "running", toolCallId: "tool-1" },
{ type: "text_delta", text: "world", stream: "output", tag: "agent_message_chunk" },
],
readonly terminal: AcpRuntimeTurnResult = { status: "completed", stopReason: "end_turn" },
) {}
async ensureSession(input: { sessionKey: string; agent: string; mode: "persistent" | "oneshot"; cwd?: string; resumeSessionId?: string }): Promise<AcpRuntimeHandle> {
this.ensureInputs.push(input);
this.ensureCount += 1;
if (this.nextEnsureError) {
const err = this.nextEnsureError;
this.nextEnsureError = null;
throw err;
}
return {
sessionKey: input.sessionKey,
backend: "acpx",
runtimeSessionName: `runtime-${this.ensureCount}`,
cwd: input.cwd,
acpxRecordId: `record-${this.ensureCount}`,
backendSessionId: `acp-${this.ensureCount}`,
agentSessionId: `agent-${this.ensureCount}`,
};
}
startTurn(input: { handle: AcpRuntimeHandle; text: string; requestId: string; timeoutMs?: number }): AcpRuntimeTurn {
this.startInputs.push(input);
this.turnCount += 1;
let closed = false;
const events = this.events;
const terminal = this.terminal;
const cancelInputs = this.cancelInputs;
return {
requestId: input.requestId,
events: {
[Symbol.asyncIterator]: async function* () {
for (const event of events) {
if (closed) return;
yield event;
}
},
},
result: Promise.resolve(terminal),
cancel: async (args?: { reason?: string }) => {
cancelInputs.push({ handle: input.handle, reason: args?.reason });
closed = true;
},
closeStream: async () => {
closed = true;
},
};
}
runTurn(): AsyncIterable<AcpRuntimeEvent> {
throw new Error("not used");
}
getCapabilities() {
return { controls: [] };
}
getStatus() {
return Promise.resolve({});
}
async setMode(input: { handle: AcpRuntimeHandle; mode: string }) {
this.setModeInputs.push(input);
}
async setConfigOption(input: { handle: AcpRuntimeHandle; key: string; value: string }) {
this.setConfigInputs.push(input);
}
async cancel(input: { handle: AcpRuntimeHandle; reason?: string }) {
this.cancelInputs.push(input);
}
async close(input: { handle: AcpRuntimeHandle; reason: string; discardPersistentState?: boolean }) {
this.closeInputs.push(input);
}
}
async function createRuntimeSkill(root: string, input: {
key?: string;
runtimeName?: string;
body?: string;
}) {
const runtimeName = input.runtimeName ?? "paperclip-test-skill";
const key = input.key ?? `company/${runtimeName}`;
const source = path.join(root, "skills", runtimeName);
await fs.mkdir(source, { recursive: true });
await fs.writeFile(path.join(source, "SKILL.md"), input.body ?? "---\n---\nUse the test skill.\n", "utf8");
return {
key,
runtimeName,
source,
};
}
function parseStdoutLogs(logs: LogEntry[]) {
return logs
.filter((entry) => entry.stream === "stdout")
.flatMap((entry) => entry.chunk.trim().split(/\n+/).filter(Boolean))
.map((line) => JSON.parse(line) as Record<string, unknown>);
}
function buildContext(root: string, overrides: Partial<AdapterExecutionContext> = {}): AdapterExecutionContext {
return {
runId: "run-1",
agent: {
id: "agent-1",
companyId: "company-1",
name: "ACPX Coder",
adapterType: "acpx_local",
adapterConfig: {},
},
runtime: {
sessionId: null,
sessionParams: null,
sessionDisplayId: null,
taskKey: "PAP-1",
},
config: {
agent: "claude",
cwd: root,
stateDir: path.join(root, "state"),
promptTemplate: "Do the assigned work.",
},
context: {
issueId: "issue-1",
paperclipTaskMarkdown: "Task context",
},
onLog: async () => {},
...overrides,
};
}
describe("acpx_local execute", () => {
it("streams ACPX session, status, text, and tool events before returning success", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-acpx-success-"));
try {
const runtime = new FakeRuntime({} as AcpRuntimeOptions);
const logs: LogEntry[] = [];
let metaPermissionNote = "";
const execute = createAcpxLocalExecutor({
createRuntime: () => runtime,
});
const result = await execute(buildContext(root, {
onLog: async (stream, chunk) => logs.push({ stream, chunk }),
onMeta: async (meta) => {
metaPermissionNote = meta.commandNotes?.join("\n") ?? "";
},
}));
expect(result.exitCode).toBe(0);
expect(result.summary).toBe("hello world");
expect(result.sessionParams).toMatchObject({
agent: "claude",
cwd: root,
mode: "persistent",
acpSessionId: "acp-1",
});
expect(metaPermissionNote).toContain("Effective ACPX permission mode: approve-all");
const parsed = parseStdoutLogs(logs);
expect(parsed.map((event) => event.type)).toEqual([
"acpx.session",
"acpx.status",
"acpx.text_delta",
"acpx.tool_call",
"acpx.text_delta",
"acpx.result",
]);
} finally {
await fs.rm(root, { recursive: true, force: true });
}
});
it("closes successful persistent runs by default while retaining session state", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-acpx-close-success-"));
try {
const runtime = new FakeRuntime({} as AcpRuntimeOptions);
const execute = createAcpxLocalExecutor({
createRuntime: () => runtime,
});
const result = await execute(buildContext(root));
expect(result.exitCode).toBe(0);
expect(result.sessionParams).toMatchObject({
mode: "persistent",
acpSessionId: "acp-1",
});
expect(runtime.closeInputs).toEqual([
expect.objectContaining({
reason: "paperclip completed turn cleanup",
discardPersistentState: false,
}),
]);
} finally {
await fs.rm(root, { recursive: true, force: true });
}
});
it("applies requested Codex model, reasoning effort, and fast mode before starting the turn", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-acpx-codex-config-"));
try {
const runtime = new FakeRuntime({} as AcpRuntimeOptions);
const execute = createAcpxLocalExecutor({
createRuntime: () => runtime,
});
const result = await execute(buildContext(root, {
config: {
agent: "codex",
cwd: root,
stateDir: path.join(root, "state"),
promptTemplate: "Do the assigned work.",
model: "gpt-5.4",
modelReasoningEffort: "xhigh",
fastMode: true,
},
}));
expect(result.exitCode).toBe(0);
expect(result.model).toBe("gpt-5.4");
expect(runtime.setConfigInputs).toEqual([
expect.objectContaining({ key: "model", value: "gpt-5.4" }),
expect.objectContaining({ key: "reasoning_effort", value: "xhigh" }),
expect.objectContaining({ key: "service_tier", value: "fast" }),
expect.objectContaining({ key: "features.fast_mode", value: "true" }),
]);
expect(runtime.startInputs).toHaveLength(1);
} finally {
await fs.rm(root, { recursive: true, force: true });
}
});
it("logs a clear error when configured session options need unsupported runtime controls", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-acpx-missing-config-controls-"));
try {
const runtime = new FakeRuntime({} as AcpRuntimeOptions);
Object.defineProperty(runtime, "setConfigOption", { value: undefined });
const logs: LogEntry[] = [];
const execute = createAcpxLocalExecutor({
createRuntime: () => runtime,
});
const result = await execute(buildContext(root, {
config: {
agent: "codex",
cwd: root,
stateDir: path.join(root, "state"),
promptTemplate: "Do the assigned work.",
model: "gpt-5.4",
},
onLog: async (stream, chunk) => logs.push({ stream, chunk }),
}));
expect(result.exitCode).toBe(1);
expect(result.errorMessage).toContain("does not expose session config controls");
expect(logs).toEqual(expect.arrayContaining([
expect.objectContaining({
stream: "stderr",
chunk: expect.stringContaining("upgrade ACPX or remove configured model"),
}),
]));
expect(runtime.closeInputs).toEqual([
expect.objectContaining({
reason: "paperclip config cleanup",
discardPersistentState: false,
}),
]);
} finally {
await fs.rm(root, { recursive: true, force: true });
}
});
it("reuses a compatible warm session and starts fresh when cwd changes", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-acpx-reuse-"));
const other = path.join(root, "other");
await fs.mkdir(other);
try {
const runtimes: FakeRuntime[] = [];
const execute = createAcpxLocalExecutor({
createRuntime: (options) => {
const runtime = new FakeRuntime(options);
runtimes.push(runtime);
return runtime;
},
});
const warmConfig = {
agent: "claude",
cwd: root,
stateDir: path.join(root, "state"),
promptTemplate: "Do the assigned work.",
warmHandleIdleMs: 60_000,
};
const first = await execute(buildContext(root, { config: warmConfig }));
const second = await execute(buildContext(root, {
runtime: {
sessionId: first.sessionId ?? null,
sessionParams: first.sessionParams ?? null,
sessionDisplayId: first.sessionDisplayId ?? null,
taskKey: "PAP-1",
},
config: warmConfig,
}));
const third = await execute(buildContext(root, {
runtime: {
sessionId: first.sessionId ?? null,
sessionParams: first.sessionParams ?? null,
sessionDisplayId: first.sessionDisplayId ?? null,
taskKey: "PAP-1",
},
config: {
agent: "claude",
cwd: other,
stateDir: path.join(root, "state"),
promptTemplate: "Do the assigned work.",
warmHandleIdleMs: 60_000,
},
}));
expect(runtimes).toHaveLength(2);
expect(runtimes[0].ensureCount).toBe(1);
expect(runtimes[0].turnCount).toBe(2);
expect(runtimes[1].ensureCount).toBe(1);
expect(second.sessionParams?.acpSessionId).toBe("acp-1");
expect(third.sessionParams?.cwd).toBe(other);
} finally {
await fs.rm(root, { recursive: true, force: true });
}
});
it("closes duplicate warm handles from concurrent runs for the same session key", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-acpx-concurrent-"));
try {
const runtimes: FakeRuntime[] = [];
const warmHandles = new Map();
const execute = createAcpxLocalExecutor({
warmHandles,
createRuntime: (options) => {
const runtime = new FakeRuntime(options);
runtimes.push(runtime);
return runtime;
},
});
const [first, second] = await Promise.all([
execute(buildContext(root, {
runId: "run-1",
config: {
agent: "claude",
cwd: root,
stateDir: path.join(root, "state"),
promptTemplate: "Do the assigned work.",
warmHandleIdleMs: 60_000,
},
})),
execute(buildContext(root, {
runId: "run-2",
config: {
agent: "claude",
cwd: root,
stateDir: path.join(root, "state"),
promptTemplate: "Do the assigned work.",
warmHandleIdleMs: 60_000,
},
})),
]);
expect(first.exitCode).toBe(0);
expect(second.exitCode).toBe(0);
expect(runtimes).toHaveLength(2);
expect(warmHandles.size).toBe(1);
expect(runtimes.flatMap((runtime) => runtime.closeInputs).filter((input) =>
input.reason === "paperclip duplicate warm handle cleanup"
)).toHaveLength(1);
} finally {
await fs.rm(root, { recursive: true, force: true });
}
});
it("cleans configured warm handles after their idle window", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-acpx-warm-idle-"));
vi.useFakeTimers();
try {
let clock = 0;
const runtime = new FakeRuntime({} as AcpRuntimeOptions);
const warmHandles = new Map();
const execute = createAcpxLocalExecutor({
warmHandles,
now: () => clock,
createRuntime: () => runtime,
});
const result = await execute(buildContext(root, {
config: {
agent: "claude",
cwd: root,
stateDir: path.join(root, "state"),
promptTemplate: "Do the assigned work.",
warmHandleIdleMs: 1_000,
},
}));
expect(result.exitCode).toBe(0);
expect(warmHandles.size).toBe(1);
clock = 1_000;
await vi.advanceTimersByTimeAsync(1_000);
expect(warmHandles.size).toBe(0);
expect(runtime.closeInputs).toEqual([
expect.objectContaining({
reason: "paperclip idle cleanup",
discardPersistentState: false,
}),
]);
} finally {
vi.useRealTimers();
await fs.rm(root, { recursive: true, force: true });
}
});
it("retries with a fresh session when ACPX cannot resume the saved backend session", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-acpx-resume-"));
try {
const runtime = new FakeRuntime({} as AcpRuntimeOptions);
const firstExecute = createAcpxLocalExecutor({
createRuntime: () => runtime,
warmHandles: new Map(),
});
const initial = await firstExecute(buildContext(root));
const compatibleParams = {
...initial.sessionParams,
runtimeSessionName: "runtime-old",
acpSessionId: "acp-old",
};
runtime.nextEnsureError = new Error("session/load failed: no session acp-old");
const logs: LogEntry[] = [];
const execute = createAcpxLocalExecutor({
createRuntime: () => runtime,
warmHandles: new Map(),
});
const result = await execute(buildContext(root, {
runtime: {
sessionId: "acp-old",
sessionParams: compatibleParams,
sessionDisplayId: "acp-old",
taskKey: "PAP-1",
},
onLog: async (stream, chunk) => logs.push({ stream, chunk }),
}));
expect(result.exitCode).toBe(0);
expect(result.clearSession).toBe(true);
expect(runtime.ensureInputs.at(-2)?.resumeSessionId).toBe("acp-old");
expect(runtime.ensureInputs.at(-1)?.resumeSessionId).toBeUndefined();
expect(logs.some((entry) => entry.chunk.includes("retrying with a fresh session"))).toBe(true);
} finally {
await fs.rm(root, { recursive: true, force: true });
}
});
it("cancels and closes stale handles on timeout", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-acpx-timeout-"));
try {
const neverFinishes = new FakeRuntime(
{} as AcpRuntimeOptions,
[],
{ status: "cancelled", stopReason: "cancelled" },
);
neverFinishes.startTurn = function (input): AcpRuntimeTurn {
this.startInputs.push(input);
let resolveResult!: (value: AcpRuntimeTurnResult) => void;
const result = new Promise<AcpRuntimeTurnResult>((resolve) => {
resolveResult = resolve;
});
return {
requestId: input.requestId,
events: {
[Symbol.asyncIterator]: async function* () {
await new Promise((resolve) => setTimeout(resolve, 50));
},
},
result,
cancel: async (args?: { reason?: string }) => {
this.cancelInputs.push({ handle: input.handle, reason: args?.reason });
resolveResult({ status: "cancelled", stopReason: args?.reason });
},
closeStream: async () => {},
};
};
const execute = createAcpxLocalExecutor({ createRuntime: () => neverFinishes });
const result = await execute(buildContext(root, {
config: {
agent: "claude",
cwd: root,
stateDir: path.join(root, "state"),
promptTemplate: "Do the assigned work.",
timeoutSec: 0.01,
},
}));
expect(result.timedOut).toBe(true);
expect(result.errorCode).toBe("acpx_timeout");
expect(neverFinishes.cancelInputs.length).toBeGreaterThan(0);
expect(neverFinishes.closeInputs.at(-1)?.discardPersistentState).toBe(true);
} finally {
await fs.rm(root, { recursive: true, force: true });
}
});
it("returns structured auth errors", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-acpx-error-"));
try {
const runtime = new FakeRuntime({} as AcpRuntimeOptions);
runtime.nextEnsureError = new Error("authentication required: login first");
const execute = createAcpxLocalExecutor({ createRuntime: () => runtime });
const result = await execute(buildContext(root));
expect(result.exitCode).toBe(1);
expect(result.errorCode).toBe("acpx_auth_required");
expect(result.errorMeta).toMatchObject({ category: "auth" });
} finally {
await fs.rm(root, { recursive: true, force: true });
}
});
it("returns structured ACP protocol errors", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-acpx-protocol-"));
try {
const runtime = new FakeRuntime({} as AcpRuntimeOptions);
runtime.nextEnsureError = Object.assign(new Error("protocol init failed"), {
code: "ACP_SESSION_INIT_FAILED",
});
const execute = createAcpxLocalExecutor({ createRuntime: () => runtime });
const result = await execute(buildContext(root));
expect(result.exitCode).toBe(1);
expect(result.errorCode).toBe("acpx_session_init_failed");
expect(result.errorMeta).toMatchObject({
category: "protocol",
acpCode: "ACP_SESSION_INIT_FAILED",
});
} finally {
await fs.rm(root, { recursive: true, force: true });
}
});
it("materializes selected skills for ACPX Claude and passes public session metadata", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-acpx-claude-skills-"));
try {
const skill = await createRuntimeSkill(root, {});
let runtime: FakeRuntime | null = null;
let meta: Record<string, unknown> | null = null;
const execute = createAcpxLocalExecutor({
createRuntime: (options) => {
runtime = new FakeRuntime(options);
return runtime;
},
});
const result = await execute(buildContext(root, {
config: {
agent: "claude",
cwd: root,
stateDir: path.join(root, "state"),
promptTemplate: "Do the assigned work.",
paperclipRuntimeSkills: [skill],
paperclipSkillSync: {
desiredSkills: [skill.key],
},
},
onMeta: async (payload) => {
meta = payload as Record<string, unknown>;
},
}));
expect(result.exitCode).toBe(0);
expect(runtime?.options).not.toHaveProperty("sessionOptions");
const skillRoot = result.sessionParams?.skills && typeof result.sessionParams.skills === "object"
? (result.sessionParams.skills as { skillRoot?: string | null }).skillRoot
: null;
expect(skillRoot).toContain(path.join("state", "runtime-skills", "claude"));
await expect(fs.lstat(path.join(skillRoot!, skill.runtimeName))).resolves.toMatchObject({});
expect(result.sessionParams?.skills).toMatchObject({
mode: "claude",
selectedSkills: [skill.runtimeName],
});
expect(String(meta?.prompt ?? "")).toContain(`Skill root: ${skillRoot}`);
expect((meta?.commandNotes as string[]).join("\n")).toContain("Materialized 1 Paperclip skill");
} finally {
await fs.rm(root, { recursive: true, force: true });
}
});
it("includes skill content in the ACPX Claude session fingerprint", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-acpx-claude-fingerprint-"));
try {
const skill = await createRuntimeSkill(root, { body: "---\n---\nFirst version.\n" });
const runtimes: FakeRuntime[] = [];
const execute = createAcpxLocalExecutor({
createRuntime: (options) => {
const runtime = new FakeRuntime(options);
runtimes.push(runtime);
return runtime;
},
});
const context = buildContext(root, {
config: {
agent: "claude",
cwd: root,
stateDir: path.join(root, "state"),
promptTemplate: "Do the assigned work.",
paperclipRuntimeSkills: [skill],
paperclipSkillSync: {
desiredSkills: [skill.key],
},
},
});
const first = await execute(context);
await fs.writeFile(path.join(skill.source, "SKILL.md"), "---\n---\nSecond version.\n", "utf8");
const second = await execute({
...context,
runtime: {
sessionId: first.sessionId ?? null,
sessionParams: first.sessionParams ?? null,
sessionDisplayId: first.sessionDisplayId ?? null,
taskKey: "PAP-1",
},
});
expect(second.sessionParams?.configFingerprint).not.toBe(first.sessionParams?.configFingerprint);
expect(runtimes.at(-1)?.ensureInputs.at(-1)?.resumeSessionId).toBeUndefined();
} finally {
await fs.rm(root, { recursive: true, force: true });
}
});
it("materializes selected skills into the effective ACPX Codex CODEX_HOME", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-acpx-codex-skills-"));
try {
const skill = await createRuntimeSkill(root, {});
const codexHome = path.join(root, "codex-home");
let runtime: FakeRuntime | null = null;
let meta: Record<string, unknown> | null = null;
const execute = createAcpxLocalExecutor({
createRuntime: (options) => {
runtime = new FakeRuntime(options);
return runtime;
},
});
const result = await execute(buildContext(root, {
config: {
agent: "codex",
cwd: root,
stateDir: path.join(root, "state"),
promptTemplate: "Do the assigned work.",
env: { CODEX_HOME: codexHome },
paperclipRuntimeSkills: [skill],
paperclipSkillSync: {
desiredSkills: [skill.key],
},
},
onMeta: async (payload) => {
meta = payload as Record<string, unknown>;
},
}));
expect(result.exitCode).toBe(0);
await expect(fs.lstat(path.join(codexHome, "skills", skill.runtimeName))).resolves.toMatchObject({});
const wrapperPath = runtime?.options.agentRegistry.resolve("codex");
const wrapper = await fs.readFile(wrapperPath!, "utf8");
expect(wrapper).not.toContain("CODEX_HOME");
expect(wrapper).not.toContain(codexHome);
expect((meta?.env as Record<string, string>).CODEX_HOME).toBe(codexHome);
expect(result.sessionParams?.skills).toMatchObject({
mode: "codex",
codexHome,
selectedSkills: [skill.runtimeName],
});
} finally {
await fs.rm(root, { recursive: true, force: true });
}
});
it("keeps ACPX custom skill selection tracked without runtime materialization", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-acpx-custom-skills-"));
try {
const skill = await createRuntimeSkill(root, {});
let runtime: FakeRuntime | null = null;
let meta: Record<string, unknown> | null = null;
const execute = createAcpxLocalExecutor({
createRuntime: (options) => {
runtime = new FakeRuntime(options);
return runtime;
},
});
const result = await execute(buildContext(root, {
config: {
agent: "custom",
agentCommand: "custom-acp",
cwd: root,
stateDir: path.join(root, "state"),
promptTemplate: "Do the assigned work.",
paperclipRuntimeSkills: [skill],
paperclipSkillSync: {
desiredSkills: [skill.key],
},
},
onMeta: async (payload) => {
meta = payload as Record<string, unknown>;
},
}));
expect(result.exitCode).toBe(0);
expect(runtime?.options.sessionOptions).toBeUndefined();
await expect(fs.lstat(path.join(root, "state", "runtime-skills"))).rejects.toMatchObject({ code: "ENOENT" });
expect(result.sessionParams?.skills).toMatchObject({
mode: "custom_unsupported",
desiredSkillNames: [skill.key],
});
expect((meta?.commandNotes as string[]).join("\n")).toContain("tracked only");
} finally {
await fs.rm(root, { recursive: true, force: true });
}
});
});

View File

@ -1,77 +0,0 @@
import { describe, expect, it } from "vitest";
import {
listAcpxSkills,
syncAcpxSkills,
} from "@paperclipai/adapter-acpx-local/server";
describe("acpx local skill sync", () => {
const paperclipKey = "paperclipai/paperclip/paperclip";
it("reports ACPX Claude skills as supported runtime-mounted state", async () => {
const snapshot = await listAcpxSkills({
agentId: "agent-1",
companyId: "company-1",
adapterType: "acpx_local",
config: {
agent: "claude",
paperclipSkillSync: {
desiredSkills: [paperclipKey],
},
},
});
expect(snapshot.adapterType).toBe("acpx_local");
expect(snapshot.supported).toBe(true);
expect(snapshot.mode).toBe("ephemeral");
expect(snapshot.desiredSkills).toContain(paperclipKey);
expect(snapshot.entries.find((entry) => entry.key === paperclipKey)?.state).toBe("configured");
expect(snapshot.entries.find((entry) => entry.key === paperclipKey)?.detail).toContain("ACPX Claude session");
expect(snapshot.warnings).toEqual([]);
});
it("reports ACPX Codex skills with Codex home runtime detail", async () => {
const snapshot = await syncAcpxSkills({
agentId: "agent-2",
companyId: "company-1",
adapterType: "acpx_local",
config: {
agent: "codex",
paperclipSkillSync: {
desiredSkills: ["paperclip"],
},
},
}, ["paperclip"]);
expect(snapshot.supported).toBe(true);
expect(snapshot.mode).toBe("ephemeral");
expect(snapshot.desiredSkills).toContain(paperclipKey);
expect(snapshot.desiredSkills).not.toContain("paperclip");
expect(snapshot.entries.find((entry) => entry.key === paperclipKey)?.state).toBe("configured");
expect(snapshot.entries.find((entry) => entry.key === paperclipKey)?.detail).toContain("CODEX_HOME/skills/");
expect(snapshot.warnings).toEqual([]);
});
it("keeps ACPX custom skill selection tracked but unsupported", async () => {
const snapshot = await listAcpxSkills({
agentId: "agent-3",
companyId: "company-1",
adapterType: "acpx_local",
config: {
agent: "custom",
paperclipSkillSync: {
desiredSkills: [paperclipKey],
},
},
});
expect(snapshot.supported).toBe(false);
expect(snapshot.mode).toBe("unsupported");
expect(snapshot.desiredSkills).toContain(paperclipKey);
expect(snapshot.entries.find((entry) => entry.key === paperclipKey)?.desired).toBe(true);
expect(snapshot.entries.find((entry) => entry.key === paperclipKey)?.state).toBe("available");
expect(snapshot.entries.find((entry) => entry.key === paperclipKey)?.detail).toContain("stored in Paperclip only");
expect(snapshot.warnings).toContain(
"Custom ACP commands do not expose a Paperclip skill integration contract yet; selected skills are tracked only.",
);
});
});

View File

@ -37,11 +37,10 @@ describe("adapter model listing", () => {
expect(models).toEqual([]);
});
it("uses provider-prefixed ACPX fallback model labels", () => {
it("does not expose models for the retired acpx_local tombstone", () => {
const adapter = listServerAdapters().find((candidate) => candidate.type === "acpx_local");
expect(adapter?.models?.some((model) => model.label.startsWith("Claude: "))).toBe(true);
expect(adapter?.models?.some((model) => model.label.startsWith("Codex: "))).toBe(true);
expect(adapter?.models).toEqual([]);
});
it("returns codex fallback models when no OpenAI key is available", async () => {

View File

@ -142,6 +142,7 @@ describe("adapter routes", () => {
expect(typeof adapter.capabilities.supportsSkills).toBe("boolean");
expect(typeof adapter.capabilities.supportsLocalAgentJwt).toBe("boolean");
expect(typeof adapter.capabilities.requiresMaterializedRuntimeSkills).toBe("boolean");
expect(typeof adapter.capabilities.supportsAcp).toBe("boolean");
}
});
@ -160,6 +161,15 @@ describe("adapter routes", () => {
supportsSkills: true,
supportsLocalAgentJwt: true,
requiresMaterializedRuntimeSkills: false,
supportsAcp: true,
});
expect(codexLocal.acp).toMatchObject({
agentId: "codex",
skillsMode: "ephemeral",
prerequisites: {
nodeRange: ">=22.13.0",
packages: ["@agentclientprotocol/codex-acp"],
},
});
// process adapter should have no local capabilities
@ -170,6 +180,7 @@ describe("adapter routes", () => {
supportsSkills: false,
supportsLocalAgentJwt: false,
requiresMaterializedRuntimeSkills: false,
supportsAcp: false,
});
// cursor adapter should require materialized runtime skills
@ -177,6 +188,25 @@ describe("adapter routes", () => {
expect(cursorAdapter).toBeDefined();
expect(cursorAdapter.capabilities.requiresMaterializedRuntimeSkills).toBe(true);
expect(cursorAdapter.capabilities.supportsInstructionsBundle).toBe(true);
expect(cursorAdapter.capabilities.supportsAcp).toBe(false);
const geminiAdapter = res.body.find((a: any) => a.type === "gemini_local");
expect(geminiAdapter).toBeDefined();
expect(geminiAdapter.capabilities).toMatchObject({
supportsInstructionsBundle: true,
supportsSkills: true,
supportsLocalAgentJwt: true,
requiresMaterializedRuntimeSkills: true,
supportsAcp: true,
});
expect(geminiAdapter.acp).toMatchObject({
agentId: "gemini",
skillsMode: "ephemeral",
prerequisites: {
nodeRange: ">=20.0.0",
packages: ["@google/gemini-cli"],
},
});
const grokAdapter = res.body.find((a: any) => a.type === "grok_local");
expect(grokAdapter).toBeDefined();
@ -185,6 +215,7 @@ describe("adapter routes", () => {
supportsSkills: true,
supportsLocalAgentJwt: true,
requiresMaterializedRuntimeSkills: true,
supportsAcp: false,
});
const hermesLocal = res.body.find((a: any) => a.type === "hermes_local");
@ -195,6 +226,7 @@ describe("adapter routes", () => {
supportsSkills: true,
supportsLocalAgentJwt: true,
requiresMaterializedRuntimeSkills: false,
supportsAcp: false,
});
const hermesGateway = res.body.find((a: any) => a.type === "hermes_gateway");
@ -205,6 +237,7 @@ describe("adapter routes", () => {
supportsSkills: false,
supportsLocalAgentJwt: false,
requiresMaterializedRuntimeSkills: false,
supportsAcp: false,
});
});
@ -224,10 +257,10 @@ describe("adapter routes", () => {
expect(codexLocal).toBeDefined();
expect(codexLocal.capabilities.supportsSkills).toBe(true);
// acpx_local exposes runtime-aware skill snapshots for Claude/Codex/custom ACP agents
// acpx_local remains registered only as a tombstone for legacy rows.
const acpxLocal = res.body.find((a: any) => a.type === "acpx_local");
expect(acpxLocal).toBeDefined();
expect(acpxLocal.capabilities.supportsSkills).toBe(true);
expect(acpxLocal.capabilities.supportsSkills).toBe(false);
});
it("uses the active adapter when resolving config schema for a paused builtin override", async () => {
@ -251,27 +284,98 @@ describe("adapter routes", () => {
});
});
it("serves the built-in acpx_local config schema", async () => {
it("serves an empty tombstone config schema for retired acpx_local", async () => {
const app = createApp();
const res = await request(app).get("/api/adapters/acpx_local/config-schema");
expect(res.status, JSON.stringify(res.body)).toBe(200);
expect(res.body.fields).toEqual([]);
});
it("serves the built-in claude_local ACP engine config schema", async () => {
const app = createApp();
const paused = await request(app)
.patch("/api/adapters/claude_local/override")
.send({ paused: true });
expect(paused.status, JSON.stringify(paused.body)).toBe(200);
const res = await request(app).get("/api/adapters/claude_local/config-schema");
expect(res.status, JSON.stringify(res.body)).toBe(200);
expect(res.body.fields).toEqual(
expect.arrayContaining([
expect.objectContaining({
key: "agent",
default: "claude",
key: "engine",
default: "auto",
options: expect.arrayContaining([
expect.objectContaining({ value: "claude" }),
expect.objectContaining({ value: "codex" }),
expect.objectContaining({ value: "custom" }),
expect.objectContaining({ value: "auto" }),
expect.objectContaining({ value: "cli" }),
expect.objectContaining({ value: "acp" }),
]),
}),
expect.objectContaining({
key: "fastMode",
default: false,
meta: { visibleWhen: { key: "agent", values: ["codex"] } },
key: "agentCommand",
meta: { visibleWhen: { key: "engine", values: ["acp"] } },
}),
expect.objectContaining({
key: "warmHandleIdleMs",
default: 0,
}),
]),
);
});
it("serves the built-in codex_local ACP engine config schema", async () => {
const app = createApp();
const res = await request(app).get("/api/adapters/codex_local/config-schema");
expect(res.status, JSON.stringify(res.body)).toBe(200);
expect(res.body.fields).toEqual(
expect.arrayContaining([
expect.objectContaining({
key: "engine",
default: "auto",
options: expect.arrayContaining([
expect.objectContaining({ value: "auto" }),
expect.objectContaining({ value: "cli" }),
expect.objectContaining({ value: "acp" }),
]),
}),
expect.objectContaining({
key: "agentCommand",
meta: { visibleWhen: { key: "engine", values: ["acp"] } },
}),
expect.objectContaining({
key: "warmHandleIdleMs",
default: 0,
}),
]),
);
});
it("serves the built-in gemini_local ACP engine config schema", async () => {
const app = createApp();
const res = await request(app).get("/api/adapters/gemini_local/config-schema");
expect(res.status, JSON.stringify(res.body)).toBe(200);
expect(res.body.fields).toEqual(
expect.arrayContaining([
expect.objectContaining({
key: "engine",
default: "auto",
options: expect.arrayContaining([
expect.objectContaining({ value: "auto" }),
expect.objectContaining({ value: "cli" }),
expect.objectContaining({ value: "acp" }),
]),
}),
expect.objectContaining({
key: "agentCommand",
meta: { visibleWhen: { key: "engine", values: ["acp"] } },
}),
expect.objectContaining({
key: "warmHandleIdleMs",
@ -279,12 +383,6 @@ describe("adapter routes", () => {
}),
]),
);
const keys = res.body.fields.map((field: { key: string }) => field.key);
expect(keys).not.toContain("mode");
expect(keys).not.toContain("permissionMode");
expect(keys).not.toContain("instructionsFilePath");
expect(keys).not.toContain("promptTemplate");
expect(keys).not.toContain("bootstrapPromptTemplate");
});
it("serves built-in Hermes config schemas", async () => {
@ -309,7 +407,7 @@ describe("adapter routes", () => {
);
});
it("GET /api/adapters includes ACPX model availability", async () => {
it("GET /api/adapters lists acpx_local only as a model-less tombstone", async () => {
const app = createApp();
const res = await request(app).get("/api/adapters");
@ -317,7 +415,7 @@ describe("adapter routes", () => {
expect(res.status, JSON.stringify(res.body)).toBe(200);
const acpxLocal = res.body.find((a: any) => a.type === "acpx_local");
expect(acpxLocal).toBeDefined();
expect(acpxLocal.modelsCount).toBeGreaterThan(0);
expect(acpxLocal.modelsCount).toBe(0);
});
it("rejects signed-in users without org access", async () => {

View File

@ -13,7 +13,7 @@ import {
sessionCodec as opencodeSessionCodec,
isOpenCodeUnknownSessionError,
} from "@paperclipai/adapter-opencode-local/server";
import { sessionCodec as acpxSessionCodec } from "@paperclipai/adapter-acpx-local/server";
import { sessionCodec as acpxSessionCodec } from "@paperclipai/adapter-utils/acpx-engine/session-codec";
describe("adapter session codecs", () => {
it("normalizes claude session params with cwd", () => {
@ -37,6 +37,33 @@ describe("adapter session codecs", () => {
expect(claudeSessionCodec.getDisplayId?.(serialized ?? null)).toBe("claude-session-1");
});
it("preserves claude ACP session params for ACP lane resumes", () => {
const parsed = claudeSessionCodec.deserialize({
sessionKey: "paperclip:company:agent:task:fingerprint",
runtimeSessionName: "runtime-session-1",
acpxRecordId: "record-1",
acpSessionId: "acp-session-1",
agentSessionId: "agent-session-1",
agent: "claude",
cwd: "/tmp/claude-acp",
mode: "persistent",
stateDir: "/tmp/claude-acp-state",
configFingerprint: "fingerprint",
workspaceId: "workspace-1",
});
expect(parsed).toMatchObject({
runtimeSessionName: "runtime-session-1",
acpSessionId: "acp-session-1",
agent: "claude",
cwd: "/tmp/claude-acp",
configFingerprint: "fingerprint",
workspaceId: "workspace-1",
});
expect(claudeSessionCodec.serialize(parsed)).toEqual(parsed);
expect(claudeSessionCodec.getDisplayId?.(parsed)).toBe("runtime-session-1");
});
it("normalizes codex session params with cwd", () => {
const parsed = codexSessionCodec.deserialize({
sessionId: "codex-session-1",
@ -55,6 +82,33 @@ describe("adapter session codecs", () => {
expect(codexSessionCodec.getDisplayId?.(serialized ?? null)).toBe("codex-session-1");
});
it("preserves codex ACP session params for ACP lane resumes", () => {
const parsed = codexSessionCodec.deserialize({
sessionKey: "paperclip:company:agent:task:fingerprint",
runtimeSessionName: "runtime-session-1",
acpxRecordId: "record-1",
acpSessionId: "acp-session-1",
agentSessionId: "agent-session-1",
agent: "codex",
cwd: "/tmp/codex-acp",
mode: "persistent",
stateDir: "/tmp/codex-acp-state",
configFingerprint: "fingerprint",
workspaceId: "workspace-1",
});
expect(parsed).toMatchObject({
runtimeSessionName: "runtime-session-1",
acpSessionId: "acp-session-1",
agent: "codex",
cwd: "/tmp/codex-acp",
configFingerprint: "fingerprint",
workspaceId: "workspace-1",
});
expect(codexSessionCodec.serialize(parsed)).toEqual(parsed);
expect(codexSessionCodec.getDisplayId?.(parsed)).toBe("runtime-session-1");
});
it("normalizes opencode session params with cwd", () => {
const parsed = opencodeSessionCodec.deserialize({
sessionID: "opencode-session-1",
@ -109,6 +163,33 @@ describe("adapter session codecs", () => {
expect(geminiSessionCodec.getDisplayId?.(serialized ?? null)).toBe("gemini-session-1");
});
it("preserves gemini ACP session params for ACP lane resumes", () => {
const parsed = geminiSessionCodec.deserialize({
sessionKey: "paperclip:company:agent:task:fingerprint",
runtimeSessionName: "runtime-session-1",
acpxRecordId: "record-1",
acpSessionId: "acp-session-1",
agentSessionId: "agent-session-1",
agent: "gemini",
cwd: "/tmp/gemini-acp",
mode: "persistent",
stateDir: "/tmp/gemini-acp-state",
configFingerprint: "fingerprint",
workspaceId: "workspace-1",
});
expect(parsed).toMatchObject({
runtimeSessionName: "runtime-session-1",
acpSessionId: "acp-session-1",
agent: "gemini",
cwd: "/tmp/gemini-acp",
configFingerprint: "fingerprint",
workspaceId: "workspace-1",
});
expect(geminiSessionCodec.serialize(parsed)).toEqual(parsed);
expect(geminiSessionCodec.getDisplayId?.(parsed)).toBe("runtime-session-1");
});
it("preserves acpx session params required for compatibility checks", () => {
const parsed = acpxSessionCodec.deserialize({
sessionKey: "paperclip:company:agent:task:fingerprint",

View File

@ -108,6 +108,7 @@ describe("claude_local environment diagnostics", () => {
companyId: "company-1",
adapterType: "claude_local",
config: {
engine: "cli",
command: process.execPath,
cwd: process.cwd(),
},
@ -133,6 +134,7 @@ describe("claude_local environment diagnostics", () => {
companyId: "company-1",
adapterType: "claude_local",
config: {
engine: "cli",
command: process.execPath,
cwd: process.cwd(),
env: {
@ -160,6 +162,7 @@ describe("claude_local environment diagnostics", () => {
companyId: "company-1",
adapterType: "claude_local",
config: {
engine: "cli",
command: process.execPath,
cwd: process.cwd(),
},
@ -187,6 +190,7 @@ describe("claude_local environment diagnostics", () => {
companyId: "company-1",
adapterType: "claude_local",
config: {
engine: "cli",
command: process.execPath,
cwd: process.cwd(),
env: {
@ -217,6 +221,7 @@ describe("claude_local environment diagnostics", () => {
companyId: "company-1",
adapterType: "claude_local",
config: {
engine: "cli",
command: process.execPath,
cwd: process.cwd(),
},
@ -241,6 +246,7 @@ describe("claude_local environment diagnostics", () => {
companyId: "company-1",
adapterType: "claude_local",
config: {
engine: "cli",
command: process.execPath,
cwd,
},
@ -258,6 +264,7 @@ describe("claude_local environment diagnostics", () => {
companyId: "company-1",
adapterType: "claude_local",
config: {
engine: "cli",
command: process.execPath,
},
executionTarget: {
@ -298,6 +305,7 @@ describe("claude_local environment diagnostics", () => {
companyId: "company-1",
adapterType: "claude_local",
config: {
engine: "cli",
command: "claude",
},
executionTarget: {
@ -408,6 +416,7 @@ console.log(JSON.stringify({ type: "result", result: "hello", usage: { input_tok
companyId: "company-1",
adapterType: "claude_local",
config: {
engine: "cli",
command: commandPath,
env: { HOME: remoteHome },
},
@ -443,6 +452,7 @@ console.log(JSON.stringify({ type: "result", result: "hello", usage: { input_tok
companyId: "company-1",
adapterType: "claude_local",
config: {
engine: "cli",
command: commandPath,
cwd: workspace,
effort: "low",

View File

@ -360,9 +360,10 @@ describe("claude execute", () => {
try {
await execute({
runId: "run-fresh",
agent: { id: "agent-1", companyId: "co-1", name: "Test", adapterType: "claude_local", adapterConfig: {} },
agent: { id: "agent-1", companyId: "co-1", name: "Test", adapterType: "claude_local", adapterConfig: { engine: "cli" } },
runtime: { sessionId: null, sessionParams: null, sessionDisplayId: null, taskKey: null },
config: {
engine: "cli",
command: commandPath,
cwd: workspace,
env: { PAPERCLIP_TEST_CAPTURE_PATH: capturePath },
@ -390,9 +391,10 @@ describe("claude execute", () => {
try {
await execute({
runId: "run-resume",
agent: { id: "agent-1", companyId: "co-1", name: "Test", adapterType: "claude_local", adapterConfig: {} },
agent: { id: "agent-1", companyId: "co-1", name: "Test", adapterType: "claude_local", adapterConfig: { engine: "cli" } },
runtime: { sessionId: "11111111-1111-4111-8111-111111111111", sessionParams: null, sessionDisplayId: null, taskKey: null },
config: {
engine: "cli",
command: commandPath,
cwd: workspace,
env: { PAPERCLIP_TEST_CAPTURE_PATH: capturePath },
@ -428,9 +430,10 @@ describe("claude execute", () => {
try {
await execute({
runId: "run-notes-fresh",
agent: { id: "agent-1", companyId: "co-1", name: "Test", adapterType: "claude_local", adapterConfig: {} },
agent: { id: "agent-1", companyId: "co-1", name: "Test", adapterType: "claude_local", adapterConfig: { engine: "cli" } },
runtime: { sessionId: null, sessionParams: null, sessionDisplayId: null, taskKey: null },
config: {
engine: "cli",
command: commandPath,
cwd: workspace,
env: {},
@ -458,9 +461,10 @@ describe("claude execute", () => {
try {
await execute({
runId: "run-notes-resume",
agent: { id: "agent-1", companyId: "co-1", name: "Test", adapterType: "claude_local", adapterConfig: {} },
agent: { id: "agent-1", companyId: "co-1", name: "Test", adapterType: "claude_local", adapterConfig: { engine: "cli" } },
runtime: { sessionId: "11111111-1111-4111-8111-111111111111", sessionParams: null, sessionDisplayId: null, taskKey: null },
config: {
engine: "cli",
command: commandPath,
cwd: workspace,
env: {},
@ -490,9 +494,10 @@ describe("claude execute", () => {
try {
const result = await execute({
runId: "run-resume-fallback",
agent: { id: "agent-1", companyId: "co-1", name: "Test", adapterType: "claude_local", adapterConfig: {} },
agent: { id: "agent-1", companyId: "co-1", name: "Test", adapterType: "claude_local", adapterConfig: { engine: "cli" } },
runtime: { sessionId: "11111111-1111-4111-8111-111111111111", sessionParams: null, sessionDisplayId: null, taskKey: null },
config: {
engine: "cli",
command: commandPath,
cwd: workspace,
env: {
@ -559,9 +564,10 @@ describe("claude execute", () => {
try {
const result = await execute({
runId: "run-max-turns",
agent: { id: "agent-1", companyId: "co-1", name: "Test", adapterType: "claude_local", adapterConfig: {} },
agent: { id: "agent-1", companyId: "co-1", name: "Test", adapterType: "claude_local", adapterConfig: { engine: "cli" } },
runtime: { sessionId: null, sessionParams: null, sessionDisplayId: null, taskKey: null },
config: {
engine: "cli",
command: commandPath,
cwd: workspace,
promptTemplate: "Do work.",
@ -598,9 +604,10 @@ describe("claude execute", () => {
try {
const result = await execute({
runId: "run-max-turns-text",
agent: { id: "agent-1", companyId: "co-1", name: "Test", adapterType: "claude_local", adapterConfig: {} },
agent: { id: "agent-1", companyId: "co-1", name: "Test", adapterType: "claude_local", adapterConfig: { engine: "cli" } },
runtime: { sessionId: null, sessionParams: null, sessionDisplayId: null, taskKey: null },
config: {
engine: "cli",
command: commandPath,
cwd: workspace,
promptTemplate: "Do work.",
@ -633,9 +640,10 @@ describe("claude execute", () => {
try {
const result = await execute({
runId: "run-max-turns-fallback-text",
agent: { id: "agent-1", companyId: "co-1", name: "Test", adapterType: "claude_local", adapterConfig: {} },
agent: { id: "agent-1", companyId: "co-1", name: "Test", adapterType: "claude_local", adapterConfig: { engine: "cli" } },
runtime: { sessionId: null, sessionParams: null, sessionDisplayId: null, taskKey: null },
config: {
engine: "cli",
command: commandPath,
cwd: workspace,
promptTemplate: "Do work.",
@ -684,7 +692,7 @@ describe("claude execute", () => {
companyId: "company-1",
name: "Claude Coder",
adapterType: "claude_local",
adapterConfig: {},
adapterConfig: { engine: "cli" },
},
runtime: {
sessionId: null,
@ -693,6 +701,7 @@ describe("claude execute", () => {
taskKey: null,
},
config: {
engine: "cli",
command: "claude",
cwd: workspace,
env: {
@ -755,7 +764,7 @@ describe("claude execute", () => {
companyId: "company-1",
name: "Claude Coder",
adapterType: "claude_local",
adapterConfig: {},
adapterConfig: { engine: "cli" },
},
runtime: {
sessionId: null,
@ -764,6 +773,7 @@ describe("claude execute", () => {
taskKey: null,
},
config: {
engine: "cli",
command: commandPath,
cwd: localWorkspace,
env: {
@ -833,7 +843,7 @@ describe("claude execute", () => {
companyId: "company-1",
name: "Claude Coder",
adapterType: "claude_local",
adapterConfig: {},
adapterConfig: { engine: "cli" },
},
runtime: {
sessionId: null,
@ -842,6 +852,7 @@ describe("claude execute", () => {
taskKey: null,
},
config: {
engine: "cli",
command: commandPath,
cwd: workspace,
effort: "low",
@ -889,7 +900,7 @@ describe("claude execute", () => {
companyId: "company-1",
name: "Claude Coder",
adapterType: "claude_local",
adapterConfig: {},
adapterConfig: { engine: "cli" },
},
runtime: {
sessionId: null,
@ -898,6 +909,7 @@ describe("claude execute", () => {
taskKey: null,
},
config: {
engine: "cli",
command: commandPath,
cwd: workspace,
effort: "low",
@ -1030,7 +1042,7 @@ describe("claude execute", () => {
companyId: "company-1",
name: "Claude Coder",
adapterType: "claude_local",
adapterConfig: {},
adapterConfig: { engine: "cli" },
},
runtime: {
sessionId: null,
@ -1039,6 +1051,7 @@ describe("claude execute", () => {
taskKey: null,
},
config: {
engine: "cli",
command: commandPath,
cwd: workspace,
instructionsFilePath: instructionsPath,
@ -1070,7 +1083,7 @@ describe("claude execute", () => {
companyId: "company-1",
name: "Claude Coder",
adapterType: "claude_local",
adapterConfig: {},
adapterConfig: { engine: "cli" },
},
runtime: {
sessionId: null,
@ -1079,6 +1092,7 @@ describe("claude execute", () => {
taskKey: null,
},
config: {
engine: "cli",
command: commandPath,
cwd: workspace,
instructionsFilePath: instructionsPath,
@ -1195,7 +1209,7 @@ describe("claude execute", () => {
companyId: "company-1",
name: "Claude Coder",
adapterType: "claude_local",
adapterConfig: {},
adapterConfig: { engine: "cli" },
},
runtime: {
sessionId: null,
@ -1204,6 +1218,7 @@ describe("claude execute", () => {
taskKey: null,
},
config: {
engine: "cli",
command: commandPath,
cwd: workspace,
instructionsFilePath: instructionsPath,
@ -1226,7 +1241,7 @@ describe("claude execute", () => {
companyId: "company-1",
name: "Claude Coder",
adapterType: "claude_local",
adapterConfig: {},
adapterConfig: { engine: "cli" },
},
runtime: {
sessionId: null,
@ -1235,6 +1250,7 @@ describe("claude execute", () => {
taskKey: null,
},
config: {
engine: "cli",
command: commandPath,
cwd: workspace,
instructionsFilePath: instructionsPath,
@ -1301,7 +1317,7 @@ describe("claude execute", () => {
companyId: "company-1",
name: "Claude Coder",
adapterType: "claude_local",
adapterConfig: {},
adapterConfig: { engine: "cli" },
},
runtime: {
sessionId: null,
@ -1310,6 +1326,7 @@ describe("claude execute", () => {
taskKey: null,
},
config: {
engine: "cli",
command: commandPath,
cwd: workspace,
promptTemplate: "Follow the paperclip heartbeat.",
@ -1366,7 +1383,7 @@ describe("claude execute", () => {
companyId: "company-1",
name: "Claude Coder",
adapterType: "claude_local",
adapterConfig: {},
adapterConfig: { engine: "cli" },
},
runtime: {
sessionId: null,
@ -1375,6 +1392,7 @@ describe("claude execute", () => {
taskKey: null,
},
config: {
engine: "cli",
command: commandPath,
cwd: workspace,
promptTemplate: "Follow the paperclip heartbeat.",
@ -1422,7 +1440,7 @@ describe("claude execute", () => {
companyId: "company-1",
name: "Claude Coder",
adapterType: "claude_local",
adapterConfig: {},
adapterConfig: { engine: "cli" },
},
runtime: {
sessionId: null,
@ -1431,6 +1449,7 @@ describe("claude execute", () => {
taskKey: null,
},
config: {
engine: "cli",
command: commandPath,
cwd: workspace,
promptTemplate: "Follow the paperclip heartbeat.",
@ -1479,7 +1498,7 @@ describe("claude execute", () => {
companyId: "company-1",
name: "Claude Coder",
adapterType: "claude_local",
adapterConfig: {},
adapterConfig: { engine: "cli" },
},
runtime: {
sessionId: null,
@ -1488,6 +1507,7 @@ describe("claude execute", () => {
taskKey: null,
},
config: {
engine: "cli",
command: commandPath,
cwd: workspace,
promptTemplate: "Follow the paperclip heartbeat.",
@ -1515,9 +1535,10 @@ describe("claude execute", () => {
try {
const result = await execute({
runId: "run-poisoned-msgid",
agent: { id: "agent-1", companyId: "co-1", name: "Test", adapterType: "claude_local", adapterConfig: {} },
agent: { id: "agent-1", companyId: "co-1", name: "Test", adapterType: "claude_local", adapterConfig: { engine: "cli" } },
runtime: { sessionId: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", sessionParams: null, sessionDisplayId: null, taskKey: null },
config: {
engine: "cli",
command: commandPath,
cwd: workspace,
env: {
@ -1561,9 +1582,10 @@ describe("claude execute", () => {
try {
const result = await execute({
runId: "run-poisoned-fresh",
agent: { id: "agent-1", companyId: "co-1", name: "Test", adapterType: "claude_local", adapterConfig: {} },
agent: { id: "agent-1", companyId: "co-1", name: "Test", adapterType: "claude_local", adapterConfig: { engine: "cli" } },
runtime: { sessionId: null, sessionParams: null, sessionDisplayId: null, taskKey: null },
config: {
engine: "cli",
command: commandPath,
cwd: workspace,
env: { PAPERCLIP_TEST_CAPTURE_PATH: capturePath },
@ -1604,7 +1626,7 @@ describe("claude execute", () => {
try {
const result = await execute({
runId: "run-poisoned-retry",
agent: { id: "agent-1", companyId: "co-1", name: "Test", adapterType: "claude_local", adapterConfig: {} },
agent: { id: "agent-1", companyId: "co-1", name: "Test", adapterType: "claude_local", adapterConfig: { engine: "cli" } },
runtime: {
sessionId: "aaaaaaaa-0000-4000-8000-000000000004",
sessionParams: null,
@ -1612,6 +1634,7 @@ describe("claude execute", () => {
taskKey: null,
},
config: {
engine: "cli",
command: commandPath,
cwd: workspace,
env: { PAPERCLIP_TEST_CAPTURE_PATH: capturePath },

View File

@ -26,6 +26,7 @@ describe("codex_local environment diagnostics", () => {
companyId: "company-1",
adapterType: "codex_local",
config: {
engine: "cli",
command: process.execPath,
cwd,
},
@ -57,6 +58,7 @@ describe("codex_local environment diagnostics", () => {
companyId: "company-1",
adapterType: "codex_local",
config: {
engine: "cli",
command: process.execPath,
cwd,
env: { CODEX_HOME: codexHome },
@ -86,6 +88,7 @@ describe("codex_local environment diagnostics", () => {
companyId: "company-1",
adapterType: "codex_local",
config: {
engine: "cli",
command: process.execPath,
cwd,
env: { CODEX_HOME: codexHome },
@ -124,6 +127,7 @@ describe("codex_local environment diagnostics", () => {
companyId: "company-1",
adapterType: "codex_local",
config: {
engine: "cli",
command: "codex",
cwd,
env: {

View File

@ -1,4 +1,4 @@
import { describe, expect, it, vi } from "vitest";
import { afterEach, describe, expect, it, vi } from "vitest";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
@ -58,8 +58,20 @@ type LogEntry = {
chunk: string;
};
const codexHomeOverrides: Array<string | undefined> = [];
afterEach(() => {
while (codexHomeOverrides.length > 0) {
const previous = codexHomeOverrides.pop();
if (previous === undefined) delete process.env.CODEX_HOME;
else process.env.CODEX_HOME = previous;
}
});
async function seedSharedCodexAuth(homeRoot: string): Promise<void> {
const sharedCodexHome = path.join(homeRoot, ".codex");
codexHomeOverrides.push(process.env.CODEX_HOME);
process.env.CODEX_HOME = sharedCodexHome;
await fs.mkdir(sharedCodexHome, { recursive: true });
await fs.writeFile(path.join(sharedCodexHome, "auth.json"), '{"token":"shared"}\n', "utf8");
}
@ -140,7 +152,7 @@ describe("codex execute", () => {
companyId: "company-1",
name: "Codex Coder",
adapterType: "codex_local",
adapterConfig: {},
adapterConfig: { engine: "cli" },
},
runtime: {
sessionId: null,
@ -149,6 +161,7 @@ describe("codex execute", () => {
taskKey: null,
},
config: {
engine: "cli",
command: commandPath,
cwd: workspace,
env: {
@ -218,7 +231,7 @@ describe("codex execute", () => {
companyId: "company-1",
name: "Codex Coder",
adapterType: "codex_local",
adapterConfig: {},
adapterConfig: { engine: "cli" },
},
runtime: {
sessionId: null,
@ -227,6 +240,7 @@ describe("codex execute", () => {
taskKey: null,
},
config: {
engine: "cli",
command: commandPath,
cwd: workspace,
env: {
@ -280,7 +294,7 @@ describe("codex execute", () => {
companyId: "company-1",
name: "Codex Coder",
adapterType: "codex_local",
adapterConfig: {},
adapterConfig: { engine: "cli" },
},
runtime: {
sessionId: null,
@ -289,6 +303,7 @@ describe("codex execute", () => {
taskKey: null,
},
config: {
engine: "cli",
command: "codex",
cwd: workspace,
env: {
@ -346,7 +361,7 @@ describe("codex execute", () => {
companyId: "company-1",
name: "Codex Coder",
adapterType: "codex_local",
adapterConfig: {},
adapterConfig: { engine: "cli" },
},
runtime: {
sessionId: null,
@ -355,6 +370,7 @@ describe("codex execute", () => {
taskKey: null,
},
config: {
engine: "cli",
command: commandPath,
cwd: localWorkspace,
env: {
@ -414,7 +430,7 @@ describe("codex execute", () => {
companyId: "company-1",
name: "Codex Coder",
adapterType: "codex_local",
adapterConfig: {},
adapterConfig: { engine: "cli" },
},
runtime: {
sessionId: null,
@ -423,6 +439,7 @@ describe("codex execute", () => {
taskKey: null,
},
config: {
engine: "cli",
command: commandPath,
cwd: workspace,
env: {
@ -525,7 +542,7 @@ describe("codex execute", () => {
companyId: "company-1",
name: "Codex Coder",
adapterType: "codex_local",
adapterConfig: {},
adapterConfig: { engine: "cli" },
},
runtime: {
sessionId: null,
@ -534,6 +551,7 @@ describe("codex execute", () => {
taskKey: null,
},
config: {
engine: "cli",
command: commandPath,
cwd: workspace,
promptTemplate: "Follow the paperclip heartbeat.",
@ -578,7 +596,7 @@ describe("codex execute", () => {
companyId: "company-1",
name: "Codex Coder",
adapterType: "codex_local",
adapterConfig: {},
adapterConfig: { engine: "cli" },
},
runtime: {
sessionId: "codex-session-usage-limit",
@ -590,6 +608,7 @@ describe("codex execute", () => {
taskKey: null,
},
config: {
engine: "cli",
command: commandPath,
cwd: workspace,
model: "gpt-5.3-codex-spark",
@ -639,7 +658,7 @@ describe("codex execute", () => {
companyId: "company-1",
name: "Codex Coder",
adapterType: "codex_local",
adapterConfig: {},
adapterConfig: { engine: "cli" },
},
runtime: {
sessionId: null,
@ -651,6 +670,7 @@ describe("codex execute", () => {
taskKey: null,
},
config: {
engine: "cli",
command: commandPath,
cwd: workspace,
fastMode: true,
@ -715,7 +735,7 @@ describe("codex execute", () => {
companyId: "company-1",
name: "Codex Coder",
adapterType: "codex_local",
adapterConfig: {},
adapterConfig: { engine: "cli" },
},
runtime: {
sessionId: null,
@ -724,6 +744,7 @@ describe("codex execute", () => {
taskKey: null,
},
config: {
engine: "cli",
command: commandPath,
cwd: workspace,
env: {
@ -784,7 +805,7 @@ describe("codex execute", () => {
companyId: "company-1",
name: "Codex Coder",
adapterType: "codex_local",
adapterConfig: {},
adapterConfig: { engine: "cli" },
},
runtime: {
sessionId: null,
@ -793,6 +814,7 @@ describe("codex execute", () => {
taskKey: null,
},
config: {
engine: "cli",
command: commandPath,
cwd: workspace,
env: {
@ -870,7 +892,7 @@ describe("codex execute", () => {
companyId: "company-1",
name: "Codex Coder",
adapterType: "codex_local",
adapterConfig: {},
adapterConfig: { engine: "cli" },
},
runtime: {
sessionId: null,
@ -879,6 +901,7 @@ describe("codex execute", () => {
taskKey: null,
},
config: {
engine: "cli",
command: commandPath,
cwd: workspace,
env: {
@ -972,7 +995,7 @@ describe("codex execute", () => {
companyId: "company-1",
name: "Codex Coder",
adapterType: "codex_local",
adapterConfig: {},
adapterConfig: { engine: "cli" },
},
runtime: {
sessionId: null,
@ -984,6 +1007,7 @@ describe("codex execute", () => {
taskKey: null,
},
config: {
engine: "cli",
command: commandPath,
cwd: workspace,
instructionsFilePath: instructionsPath,
@ -1100,7 +1124,7 @@ describe("codex execute", () => {
companyId: "company-1",
name: "Codex Coder",
adapterType: "codex_local",
adapterConfig: {},
adapterConfig: { engine: "cli" },
},
runtime: {
sessionId: null,
@ -1109,6 +1133,7 @@ describe("codex execute", () => {
taskKey: null,
},
config: {
engine: "cli",
command: commandPath,
cwd: workspace,
env: {
@ -1210,7 +1235,7 @@ describe("codex execute", () => {
companyId: "company-1",
name: "Codex Coder",
adapterType: "codex_local",
adapterConfig: {},
adapterConfig: { engine: "cli" },
},
runtime: {
sessionId: null,
@ -1219,6 +1244,7 @@ describe("codex execute", () => {
taskKey: null,
},
config: {
engine: "cli",
command: commandPath,
cwd: workspace,
env: {

View File

@ -55,6 +55,7 @@ describe("gemini_local environment diagnostics", () => {
companyId: "company-1",
adapterType: "gemini_local",
config: {
engine: "cli",
command: process.execPath,
cwd,
},
@ -82,6 +83,7 @@ describe("gemini_local environment diagnostics", () => {
companyId: "company-1",
adapterType: "gemini_local",
config: {
engine: "cli",
command: "gemini",
cwd,
model: "gemini-2.5-pro",
@ -118,6 +120,7 @@ describe("gemini_local environment diagnostics", () => {
companyId: "company-1",
adapterType: "gemini_local",
config: {
engine: "cli",
command: "gemini",
cwd,
env: {
@ -139,6 +142,7 @@ describe("gemini_local environment diagnostics", () => {
companyId: "company-1",
adapterType: "gemini_local",
config: {
engine: "cli",
command: "gemini",
},
executionTarget: {

View File

@ -94,7 +94,7 @@ describe("gemini execute", () => {
companyId: "company-1",
name: "Gemini Coder",
adapterType: "gemini_local",
adapterConfig: {},
adapterConfig: { engine: "cli" },
},
runtime: {
sessionId: null,
@ -103,6 +103,7 @@ describe("gemini execute", () => {
taskKey: null,
},
config: {
engine: "cli",
command: commandPath,
cwd: workspace,
model: "gemini-2.5-pro",
@ -170,9 +171,10 @@ describe("gemini execute", () => {
try {
await execute({
runId: "run-yolo",
agent: { id: "a1", companyId: "c1", name: "G", adapterType: "gemini_local", adapterConfig: {} },
agent: { id: "a1", companyId: "c1", name: "G", adapterType: "gemini_local", adapterConfig: { engine: "cli" } },
runtime: { sessionId: null, sessionParams: null, sessionDisplayId: null, taskKey: null },
config: {
engine: "cli",
command: commandPath,
cwd: workspace,
env: { PAPERCLIP_TEST_CAPTURE_PATH: capturePath },
@ -221,9 +223,10 @@ describe("gemini execute", () => {
try {
const result = await execute({
runId: "run-turn-limit",
agent: { id: "a1", companyId: "c1", name: "G", adapterType: "gemini_local", adapterConfig: {} },
agent: { id: "a1", companyId: "c1", name: "G", adapterType: "gemini_local", adapterConfig: { engine: "cli" } },
runtime: { sessionId: null, sessionParams: null, sessionDisplayId: null, taskKey: null },
config: {
engine: "cli",
command: commandPath,
cwd: workspace,
},
@ -262,9 +265,10 @@ describe("gemini execute", () => {
try {
const result = await execute({
runId: "run-exit-53",
agent: { id: "a1", companyId: "c1", name: "G", adapterType: "gemini_local", adapterConfig: {} },
agent: { id: "a1", companyId: "c1", name: "G", adapterType: "gemini_local", adapterConfig: { engine: "cli" } },
runtime: { sessionId: null, sessionParams: null, sessionDisplayId: null, taskKey: null },
config: {
engine: "cli",
command: commandPath,
cwd: workspace,
},
@ -311,9 +315,10 @@ describe("gemini execute", () => {
try {
const result = await execute({
runId: "run-turn-limit-text",
agent: { id: "a1", companyId: "c1", name: "G", adapterType: "gemini_local", adapterConfig: {} },
agent: { id: "a1", companyId: "c1", name: "G", adapterType: "gemini_local", adapterConfig: { engine: "cli" } },
runtime: { sessionId: null, sessionParams: null, sessionDisplayId: null, taskKey: null },
config: {
engine: "cli",
command: commandPath,
cwd: workspace,
},
@ -355,7 +360,7 @@ describe("gemini execute", () => {
companyId: "company-1",
name: "Gemini Coder",
adapterType: "gemini_local",
adapterConfig: {},
adapterConfig: { engine: "cli" },
},
runtime: {
sessionId: "gemini-session-1",
@ -364,6 +369,7 @@ describe("gemini execute", () => {
taskKey: null,
},
config: {
engine: "cli",
command: commandPath,
cwd: workspace,
model: "gemini-2.5-pro",

Some files were not shown because too many files have changed in this diff Show More