Compare commits

..

1 Commits

Author SHA1 Message Date
dependabot[bot]
dd8cb04af1
chore(deps): bump pnpm/action-setup in the actions group
Bumps the actions group with 1 update: [pnpm/action-setup](https://github.com/pnpm/action-setup).


Updates `pnpm/action-setup` from 6.0.3 to 6.0.5
- [Release notes](https://github.com/pnpm/action-setup/releases)
- [Commits](https://github.com/pnpm/action-setup/compare/v6.0.3...v6.0.5)

---
updated-dependencies:
- dependency-name: pnpm/action-setup
  dependency-version: 6.0.5
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: actions
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-04 17:23:02 +00:00
52 changed files with 74 additions and 4099 deletions

View File

@ -16,7 +16,7 @@ env:
jobs:
scope:
runs-on: ubuntu-24.04
runs-on: blacksmith-16vcpu-ubuntu-2404
permissions:
contents: read
pull-requests: read
@ -71,7 +71,6 @@ jobs:
fi
;;
.github/workflows/ci.yml)
docs_only=false
;;
*)
docs_only=false
@ -91,7 +90,7 @@ jobs:
name: ${{ matrix.name }}
needs: scope
if: needs.scope.outputs.docs_only != 'true'
runs-on: ubuntu-24.04
runs-on: blacksmith-16vcpu-ubuntu-2404
permissions:
contents: read
strategy:
@ -142,7 +141,7 @@ jobs:
name: Docs
needs: scope
if: needs.scope.outputs.docs_changed == 'true'
runs-on: ubuntu-24.04
runs-on: blacksmith-16vcpu-ubuntu-2404
permissions:
contents: read
steps:

View File

@ -1,55 +0,0 @@
name: pages
on:
push:
branches:
- main
paths:
- "docs/**"
- "scripts/build-docs-site.mjs"
- "scripts/docs-site-assets.mjs"
- "CNAME"
- "package.json"
- ".github/workflows/pages.yml"
workflow_dispatch:
permissions:
contents: read
pages: write
id-token: write
concurrency:
group: pages
cancel-in-progress: false
jobs:
deploy:
name: Deploy docs
runs-on: ubuntu-latest
timeout-minutes: 10
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
steps:
- name: Check out
uses: actions/checkout@v4
- name: Set up Node
uses: actions/setup-node@v4
with:
node-version: "22"
- name: Build docs site
run: node scripts/build-docs-site.mjs
- name: Configure Pages
uses: actions/configure-pages@v5
- name: Upload artifact
uses: actions/upload-pages-artifact@v3
with:
path: dist/docs-site
- name: Deploy
id: deployment
uses: actions/deploy-pages@v4

View File

@ -28,7 +28,6 @@
"eslint/no-self-compare": "error",
"eslint/no-sequences": "error",
"eslint/no-shadow": "off",
"eslint/no-underscore-dangle": ["error", { "allow": ["_meta"] }],
"eslint/no-unmodified-loop-condition": "error",
"eslint/no-useless-call": "error",
"eslint/no-useless-computed-key": "error",

View File

@ -12,23 +12,7 @@ Repo: https://github.com/openclaw/acpx
### Fixes
## 2026.5.5 (v0.7.0)
### Changes
- Flows/authoring: add `decision()` and `decisionEdge()` helpers for constrained LLM branching on top of the existing `acp`, `parse`, and `switch` machinery. (#278) Thanks @JoshuaLelon.
### Breaking
### Fixes
- Runtime/embedding: preserve normalized ACP `detailCode` values on failed turn results and legacy error events, so embedders can branch on stable error detail codes. (#288) Thanks @kunchenguid.
- Runtime/config: persist advertised `configOptions` from `session/new` and `session/load` and expose their keys through handle-aware runtime capabilities. (#282) Thanks @samithaj.
- CLI/queue: ask active queue owners to send ACP `session/close` before `sessions close` terminates their adapter process. (#283) Thanks @codefromthecrypt.
- CLI/models: fail clearly when `--model` targets a non-Claude ACP agent that does not advertise ACP model support, and reject model ids outside an adapter's advertised `availableModels` instead of silently falling back to the adapter default.
- Windows/Claude: resolve the `claude.exe` executable from PATH before spawning Claude ACP sessions, so native Windows launches do not depend on shell-specific command lookup. (#289) Thanks @MikeChongCan.
- Client/ACP: send `session/close` from `closeSession()` instead of the experimental `nes/close` method, so adapters without NES support can tear down sessions cleanly. (#291) Thanks @hexsprite.
- Runtime/WSL: recognize Windows `.cmd` and `.bat` ACP agent wrappers for cwd translation, including wrappers installed on non-C drives. (#280) Thanks @solomonneas.
## 2026.4.25 (v0.6.0)

1
CNAME
View File

@ -1 +0,0 @@
acpx.sh

View File

@ -225,7 +225,6 @@ runtime and persists run state under `~/.acpx/flows/runs/`.
Flows are for multi-step ACP work where one prompt is not enough:
- `acp` steps keep model-shaped work in ACP
- `decision()` and `decisionEdge()` wrap constrained-choice ACP branching without adding a new node type
- `action` steps handle deterministic mechanics like shell commands or GitHub calls
- `compute` steps do local routing or shaping
- `checkpoint` steps pause for something outside the runtime

View File

@ -43,11 +43,11 @@ acpx [global_options] <agent> sessions [list | new [--name <name>] | ensure [--n
`<agent>` can be:
- built-in friendly name from [the README](https://github.com/openclaw/acpx/blob/main/README.md)
- built-in friendly name from [../README.md](../README.md)
- unknown token (treated as raw command)
- overridden by `--agent <command>` escape hatch
Additional built-in agent docs live in [the Agents page](agents.md).
Additional built-in agent docs live in [../agents/README.md](../agents/README.md).
Prompt options:
@ -198,7 +198,7 @@ acpx [global_options] claude sessions [list | new [--name <name>] | ensure [--na
Built-in command mapping: `claude -> npx -y @agentclientprotocol/claude-agent-acp`
Additional built-in agent docs live in [the Agents page](agents.md).
Additional built-in agent docs live in [../agents/README.md](../agents/README.md).
### Custom positional agents

View File

@ -1,169 +0,0 @@
---
title: Vision
description: Why acpx exists, what it should and should not become, and the design principles that guide what lands in core.
---
`acpx` should be the smallest useful ACP client: a lightweight CLI that lets one
agent talk to another agent through the Agent Client Protocol without PTY
scraping or adapter-specific glue.
The goal is not to build a giant orchestration layer. The goal is to make ACP
practical, robust, and easy to compose in real workflows.
Project overview: [`README.md`](https://github.com/openclaw/acpx/blob/main/README.md)
Contribution guide: [`CONTRIBUTING.md`](https://github.com/openclaw/acpx/blob/main/CONTRIBUTING.md)
## Core idea
`acpx` exists to make agent-to-agent communication over ACP reliable from the
command line.
It should work in two modes at the same time:
- as an agent-first CLI that humans can still drive directly when needed
- as a reusable backend for tools that do not want to reimplement session
storage, queueing, lifecycle handling, or harness-specific behavior
If a tool wants ACP sessions, structured output, queueing, and persistence, it
should be able to delegate those concerns to `acpx` instead of rebuilding them.
The primary user is another agent, orchestrator, or harness. Human usability
still matters, but it is a secondary constraint.
## Principles
### 1. Interoperability first
`acpx` should maximize interoperability across ACP adapters, agent harnesses,
and automation tools.
The standard is ACP, not the quirks of a single agent. Where adapters differ,
`acpx` should smooth the rough edges in a robust way without hiding important
protocol semantics.
This means:
- keep the wire-level behavior close to ACP
- normalize common incompatibilities when it improves portability
- preserve structured data so downstream tools can make their own decisions
- avoid features that lock users into one agent or one harness
### 2. Keep the core small
`acpx` should not try to do too many things at once.
It should stay focused on the problems that are central to being a strong ACP
client:
- starting and talking to ACP agents
- managing persistent sessions
- queueing prompts safely
- handling permissions and lifecycle concerns
- rendering structured responses for humans and machines
If a feature does not make `acpx` a better ACP client or backend, it probably
does not belong in core.
### 3. Robust by default
`acpx` should be dependable in long-running, automated, and multi-turn
workflows.
That means the defaults should favor:
- session continuity
- safe queueing behavior
- clear failure modes
- recoverable lifecycle management
- machine-readable output and stable exit behavior
Robustness matters more than novelty. A boring feature that works everywhere is
better than a clever feature that only works in one harness.
### 4. Conventions are API surface
In `acpx`, data models, config keys, keywords, flags, output shapes, and naming
conventions are part of the product surface.
They should be scrutinized multiple times before being added or changed.
Convenience is not enough. Every new convention creates long-term compatibility
cost.
This applies even to choices that may look small. For example, when `acpx`
defines `claude` instead of `claude-code`, that should be an intentional
convention, not a casual shortcut.
People and tools will build workflows on top of `acpx`. Once a keyword, flag,
field, or convention becomes part of those workflows, changing it casually can
break users and create unnecessary cruft. The default stance should be to add
fewer conventions, make them clearer, and keep them stable.
### 5. Fully customizable
`acpx` should be easy to customize locally and per project.
Static config should cover the common cases well. When users need more than
static JSON, they should be able to define and extend their local `acpx`
configuration programmatically in a controlled way, similar in spirit to Pi.
The point of customization is not to make the core bigger. The point is to let
users adapt `acpx` to their environment without forking it.
### 6. Backend-friendly
`acpx` should be useful even for tools whose end users never type `acpx`
directly.
Many tools want the benefits of ACP, but they do not want to own:
- session persistence
- queue ownership
- prompt serialization
- adapter process management
- permission policy behavior
- harness-specific operational details
`acpx` should be able to serve as that backend layer cleanly and predictably.
## Configuration and extension
Configuration should be a strength of `acpx`, not an afterthought.
Users should be able to define:
- default agents and agent commands
- project-local overrides
- permission policies
- output formats
- session behavior
- reusable local conventions
Over time, `acpx` should support a robust programmatic extension model for local
configuration when declarative config is not enough. That model should be
explicit, inspectable, and predictable.
## What acpx should enable
`acpx` should make it straightforward to:
- swap one ACP-capable agent for another without rewriting orchestration
- run persistent multi-turn sessions from shell scripts and CI-like tooling
- build higher-level tools on top of a stable session and queueing layer
- preserve structured agent output instead of scraping terminal text
- bridge differences between harnesses without hard-coding every harness into
downstream tools
## What acpx should not become
`acpx` should not become:
- a kitchen-sink automation framework
- a replacement for every agent harness
- a UI-heavy product with a thin CLI attached
- a pile of agent-specific special cases with no coherent core
The test for new features should be simple:
Does this make `acpx` more interoperable, more robust, or more useful as a
lightweight ACP backend?
If not, it should probably live outside the core.

View File

@ -1,198 +0,0 @@
---
title: Agents
description: Built-in agent registry — every friendly name acpx ships with, the ACP adapter it spawns, the upstream coding agent it wraps, and per-agent notes.
---
`acpx` ships with a registry of friendly agent names. Each one resolves to a specific ACP adapter command. Unknown names fall through as raw commands, and `--agent <command>` is the escape hatch for anything custom (see [Custom agents](custom-agents.md)).
The default agent for top-level commands like `acpx exec …` and `acpx prompt …` is `codex`.
## Built-in registry
| Agent | Adapter command | Wraps |
| ---------- | ---------------------------------------------- | --------------------------------------------------------------------------------------------------------------- |
| `pi` | `npx pi-acp` | [Pi Coding Agent](https://github.com/mariozechner/pi) |
| `openclaw` | `openclaw acp` | [OpenClaw ACP bridge](https://github.com/openclaw/openclaw) |
| `codex` | `npx @zed-industries/codex-acp` | [Codex CLI](https://codex.openai.com) |
| `claude` | `npx -y @agentclientprotocol/claude-agent-acp` | [Claude Code](https://claude.ai/code) |
| `gemini` | `gemini --acp` | [Gemini CLI](https://github.com/google/gemini-cli) |
| `cursor` | `cursor-agent acp` | [Cursor CLI](https://cursor.com/docs/cli/acp) |
| `copilot` | `copilot --acp --stdio` | [GitHub Copilot CLI](https://docs.github.com/copilot/how-tos/copilot-chat/use-copilot-chat-in-the-command-line) |
| `droid` | `droid exec --output-format acp` | [Factory Droid](https://www.factory.ai) |
| `iflow` | `iflow --experimental-acp` | [iFlow CLI](https://github.com/iflow-ai/iflow-cli) |
| `kilocode` | `npx -y @kilocode/cli acp` | [Kilocode](https://kilocode.ai) |
| `kimi` | `kimi acp` | [Kimi CLI](https://github.com/MoonshotAI/kimi-cli) |
| `kiro` | `kiro-cli-chat acp` | [Kiro CLI](https://kiro.dev) |
| `opencode` | `npx -y opencode-ai acp` | [OpenCode](https://opencode.ai) |
| `qoder` | `qodercli --acp` | [Qoder CLI](https://docs.qoder.com/cli/acp) |
| `qwen` | `qwen --acp` | [Qwen Code](https://github.com/QwenLM/qwen-code) |
| `trae` | `traecli acp serve` | [Trae CLI](https://docs.trae.cn/cli) |
`factory-droid` and `factorydroid` also resolve to the built-in `droid` adapter.
## Common shape
Every built-in agent supports the same command surface:
```bash
acpx <agent> [prompt_text...] # implicit prompt
acpx <agent> prompt [prompt_text...] # explicit prompt
acpx <agent> exec [prompt_text...] # one-shot, no saved session
acpx <agent> cancel [-s <name>] # cooperative session/cancel
acpx <agent> set-mode <mode> [-s <name>] # session/set_mode
acpx <agent> set <key> <value> [-s <name>] # session/set_config_option
acpx <agent> status [-s <name>]
acpx <agent> sessions [list | new | ensure | close | show | history | prune]
```
See [Prompting](prompting.md), [Sessions](sessions.md), and [Session control](session-control.md) for the cross-agent semantics.
## Per-agent notes
Notes that override or extend the cross-agent behavior live below.
### Codex
- Built-in name: `codex`
- Default command: `npx @zed-industries/codex-acp`
- Upstream: [zed-industries/codex-acp](https://github.com/zed-industries/codex-acp)
- Runtime config keys exposed by current `codex-acp` releases: `mode`, `model`, `reasoning_effort`.
- `acpx --model <id> codex …` applies the requested model after session creation via `session/set_config_option`.
- `acpx codex set thought_level <value>` is accepted as a compatibility alias for codex-acp's `reasoning_effort`.
### Claude
- Built-in name: `claude`
- Default command: `npx -y @agentclientprotocol/claude-agent-acp`
- Upstream: [agentclientprotocol/claude-agent-acp](https://github.com/agentclientprotocol/claude-agent-acp)
- The built-in package range is pinned by acpx so fresh installs pick up Claude model and ACP adapter fixes without depending on a globally installed adapter binary.
- On Windows, `acpx` resolves the `claude.exe` executable from `PATH` before spawning so launches do not depend on shell-specific command lookup.
- `--system-prompt` and `--append-system-prompt` forward through ACP `_meta.systemPrompt` on `session/new`, letting you replace or append to the Claude Code system prompt without leaving a persistent session. The value persists in `session_options.system_prompt` so ensure/reuse keeps the override. Other agents ignore the field.
### Pi
- Built-in name: `pi`
- Default command: `npx pi-acp`
- Upstream: [mariozechner/pi](https://github.com/mariozechner/pi)
### OpenClaw
- Built-in name: `openclaw`
- Default command: `openclaw acp`
- Upstream: [openclaw/openclaw](https://github.com/openclaw/openclaw)
For repo-local OpenClaw checkouts, override the built-in command in `~/.acpx/config.json` so `acpx openclaw …` spawns the ACP bridge directly without the `pnpm` wrapper:
```json
{
"agents": {
"openclaw": {
"command": "env OPENCLAW_HIDE_BANNER=1 OPENCLAW_SUPPRESS_NOTES=1 node scripts/run-node.mjs acp --url ws://127.0.0.1:18789 --token-file ~/.openclaw/gateway.token --session agent:main:main"
}
}
}
```
### Cursor
- Built-in name: `cursor`
- Default command: `cursor-agent acp`
- Upstream: [Cursor CLI](https://cursor.com/docs/cli/acp)
If your Cursor install exposes ACP as `agent acp` instead of `cursor-agent acp`, override:
```json
{ "agents": { "cursor": { "command": "agent acp" } } }
```
### Gemini
- Built-in name: `gemini`
- Default command: `gemini --acp`
- Upstream: [google/gemini-cli](https://github.com/google/gemini-cli)
### Copilot
- Built-in name: `copilot`
- Default command: `copilot --acp --stdio`
- Upstream: [GitHub Copilot CLI](https://docs.github.com/copilot/how-tos/copilot-chat/use-copilot-chat-in-the-command-line)
- Requires a Copilot CLI release that supports ACP stdio mode. Older `copilot` binaries fail before ACP startup.
### Droid (Factory)
- Built-in names: `droid`, `factory-droid`, `factorydroid`
- Default command: `droid exec --output-format acp`
- Upstream: [factory.ai](https://www.factory.ai)
### Qoder
- Built-in name: `qoder`
- Default command: `qodercli --acp`
- Upstream: [Qoder CLI](https://docs.qoder.com/cli/acp)
- Reuses the Qoder CLI login state. For non-interactive runs, set `QODER_PERSONAL_ACCESS_TOKEN`.
- `acpx qoder` forwards `--max-turns` and `--allowed-tools` into Qoder CLI startup flags when those session options are set, so you do not need a raw `--agent` override for them.
### iFlow
- Built-in name: `iflow`
- Default command: `iflow --experimental-acp`
- Upstream: [iflow-ai/iflow-cli](https://github.com/iflow-ai/iflow-cli)
### Kilocode
- Built-in name: `kilocode`
- Default command: `npx -y @kilocode/cli acp`
- Upstream: [kilocode.ai](https://kilocode.ai)
### Kimi
- Built-in name: `kimi`
- Default command: `kimi acp`
- Upstream: [MoonshotAI/kimi-cli](https://github.com/MoonshotAI/kimi-cli)
### Kiro
- Built-in name: `kiro`
- Default command: `kiro-cli-chat acp`
- Upstream: [kiro.dev](https://kiro.dev)
### OpenCode
- Built-in name: `opencode`
- Default command: `npx -y opencode-ai acp`
- Upstream: [opencode.ai](https://opencode.ai)
### Qwen
- Built-in name: `qwen`
- Default command: `qwen --acp`
- Upstream: [QwenLM/qwen-code](https://github.com/QwenLM/qwen-code)
### Trae
- Built-in name: `trae`
- Default command: `traecli acp serve`
- Upstream: [docs.trae.cn](https://docs.trae.cn/cli)
## Overriding a built-in
Any built-in can be replaced wholesale through config, including `args` for adapter sub-commands:
```json
{
"agents": {
"codex": {
"command": "/usr/local/bin/codex-acp",
"args": ["--profile", "ci"]
}
}
}
```
CLI flags still win over config. See [Config](config.md) for precedence rules.
## See also
- [Custom agents](custom-agents.md) — `--agent <command>` and unknown positional names.
- [Sessions](sessions.md) — how the agent command becomes part of the session scope key.
- [Authentication](config.md#authentication) — `ACPX_AUTH_*` env vars and config `auth` entries for ACP `authenticate` handshakes.

View File

@ -1,187 +0,0 @@
---
title: Config
description: Global and project JSON config files, supported keys, precedence rules, the agents map, and authentication via env or config.
---
`acpx` is configurable through two JSON files. CLI flags always win over config, and project config wins over global.
## Files and precedence
```text
1. Global ~/.acpx/config.json
2. Project <cwd>/.acpxrc.json
3. CLI flags
```
Each layer is a partial override merged on top of the previous one. Missing keys inherit; arrays and objects are replaced, not deep-merged (with the exception of the `agents` map, where keys merge and per-agent objects replace wholesale).
Inspect the resolved view:
```bash
acpx config show
```
Create a global template (only writes if the file does not already exist):
```bash
acpx config init
```
## Supported keys
```json
{
"defaultAgent": "codex",
"defaultPermissions": "approve-all",
"nonInteractivePermissions": "deny",
"authPolicy": "skip",
"ttl": 300,
"timeout": null,
"format": "text",
"agents": {
"my-custom": { "command": "./bin/my-acp-server", "args": ["acp"] }
},
"auth": {
"openai_api_key": "sk-…"
}
}
```
| Key | Type | Default | Notes |
| --------------------------- | ---------------- | ----------------- | -------------------------------------------------------------------------------------------------- |
| `defaultAgent` | string | `"codex"` | Used when top-level `prompt`, `exec`, `cancel`, `set*`, `sessions` runs without an explicit agent. |
| `defaultPermissions` | enum | `"approve-reads"` | `approve-all` / `approve-reads` / `deny-all`. |
| `nonInteractivePermissions` | enum | `"deny"` | `deny` or `fail` when no TTY is present. |
| `authPolicy` | enum | `"skip"` | Controls when ACP `authenticate` is attempted. |
| `ttl` | integer | `300` | Queue owner idle TTL in seconds. `0` disables idle shutdown. |
| `timeout` | number \| `null` | `null` | Default `--timeout` in seconds (decimal allowed). |
| `format` | enum | `"text"` | Default `--format`. |
| `agents` | object | `{}` | Override or add agent commands (see below). |
| `auth` | object | `{}` | ACP auth-method credential map (see below). |
CLI flags always override these values. For example, `--approve-all` wins over `defaultPermissions: "deny-all"`.
## The `agents` map
Custom agents and overrides live here:
```json
{
"agents": {
"my-agent": {
"command": "./bin/my-acp-server",
"args": ["acp", "--profile", "ci"]
},
"codex": {
"command": "/usr/local/bin/codex-acp",
"args": ["--mode", "stable"]
}
}
}
```
Rules:
- Keys are friendly names you would type at `acpx <name> …`.
- `command` is required; it can be a single executable or include in-string args (`"node ./bin/x.mjs"`).
- `args` is optional. If present, it is appended after the parsed `command` tokens.
- Custom agent `args` arrays are honored — required adapter sub-commands are no longer dropped silently.
- An entry that shares a name with a built-in **replaces** the built-in for that name.
Project config can shadow global config by re-declaring the same key:
```json
{ "agents": { "codex": { "command": "/usr/local/bin/codex-acp" } } }
```
Use this to point a particular repo at a vendored or pinned adapter.
## Authentication
ACP `authenticate` handshakes need credentials. `acpx` resolves them from two sources, in order:
1. `ACPX_AUTH_<METHOD_ID>` environment variable, where `<METHOD_ID>` is the upper-cased ACP auth-method id.
2. `auth.<methodId>` value in config.
```bash
ACPX_AUTH_OPENAI_API_KEY=sk-… acpx codex 'do the thing'
```
```json
{ "auth": { "openai_api_key": "sk-…" } }
```
Ambient provider env vars like `OPENAI_API_KEY` are still passed through to child agents in their environment, but they do **not** trigger ACP auth-method selection on their own. This is intentional — it avoids surprise login flows in adapters that interpret an ambient key as "go ahead and authenticate."
`authPolicy` controls when `acpx` invokes `authenticate` at all:
| Value | Behavior |
| -------- | -------------------------------------------------------------------------------------------- |
| `"skip"` | Do not call `authenticate`. Adapters that need auth must already be logged in. **(default)** |
| `"auto"` | Call `authenticate` when the adapter advertises required auth methods. |
## Environment variables
`acpx` does not define new env vars beyond `ACPX_AUTH_*`. Other ACP-relevant behavior:
- Session storage path is derived from the OS home directory (`~/.acpx/sessions`).
- Child adapter processes inherit the current environment by default.
- Some adapters look at their own env vars (e.g., `QODER_PERSONAL_ACCESS_TOKEN`) — see [Agents](agents.md) for per-adapter notes.
## Practical config recipes
### Make CI fail rather than deny
```json
{
"defaultPermissions": "approve-reads",
"nonInteractivePermissions": "fail",
"format": "json"
}
```
### Default to Claude with a longer timeout
```json
{
"defaultAgent": "claude",
"timeout": 1800,
"ttl": 0
}
```
### Vendor an internal Codex build for one repo
`<repo>/.acpxrc.json`:
```json
{
"agents": {
"codex": {
"command": "/opt/internal/codex-acp",
"args": ["--profile", "internal-stable"]
}
}
}
```
### Pin a custom agent name without colliding with a built-in
```json
{
"agents": {
"ci-bot": {
"command": "node ./scripts/ci-acp-bridge.mjs"
}
}
}
```
Then `acpx ci-bot 'run sanity checks'` resolves through the registry without any `--agent` flag.
## See also
- [Agents](agents.md) — built-in registry and per-agent notes.
- [Custom agents](custom-agents.md) — `--agent` escape hatch and unknown positional names.
- [Permissions](permissions.md) — `defaultPermissions` and non-interactive policy.
- [Output formats](output-formats.md) — `format` default and `--json-strict`.

View File

@ -1,136 +0,0 @@
---
title: Custom agents
description: Run any ACP-capable server through acpx — unknown positional names, --agent escape hatch, and config-defined custom agents.
---
`acpx` does not require an agent to be in the built-in registry. Any ACP-capable command can be the agent.
There are three ways to use a custom agent.
## 1. Unknown positional name
If you type a positional agent token that is not a built-in friendly name, `acpx` treats it as a raw command:
```bash
acpx my-agent 'review this patch'
acpx my-agent prompt 'do the thing'
acpx my-agent exec 'one-shot ask'
acpx my-agent sessions
```
The literal string `my-agent` becomes the spawn command. This is useful when you have an ACP server already on `PATH` under a name that is not a built-in.
## 2. `--agent <command>` escape hatch
For ad-hoc commands or paths with arguments and quoting, use `--agent`:
```bash
acpx --agent ./bin/my-custom-acp-server 'do something'
acpx --agent 'node ./scripts/acp-dev-server.mjs --mode ci' exec 'summarize changes'
```
Rules:
- Do not combine `--agent` with a positional agent token in the same command. That is a usage error.
- The resolved command string becomes the session scope key (`agentCommand`). Two different command strings are two different sessions, even if the underlying binary is the same.
- Empty commands and unterminated quoting are rejected as usage errors.
## 3. Config-defined agents
For commands you use repeatedly, define them in [`~/.acpx/config.json`](config.md#the-agents-map):
```json
{
"agents": {
"ci-bot": {
"command": "node ./scripts/ci-acp-bridge.mjs",
"args": ["--profile", "internal"]
}
}
}
```
Then call by friendly name:
```bash
acpx ci-bot 'run validation checks'
```
Custom names defined in config win over the built-in registry, so you can also override `codex`, `claude`, etc. with a vendored adapter.
## Session scope and the agent command
The agent command — whether built-in, unknown positional, `--agent`, or config-defined — is part of the session scope key:
```text
(agentCommand, absoluteCwd, optional name)
```
Practical implication: switching from `acpx --agent ./bin/v1` to `acpx --agent ./bin/v2` in the same repo gives you two independent session histories, not one shared session. Use a config entry with a stable friendly name to keep history continuous across binary upgrades.
## ACP requirements for custom agents
A custom agent must:
- Speak ACP over stdio (or whatever transport the adapter supports — most are stdio).
- Implement the standard ACP methods (`initialize`, `session/new`, `session/prompt`, `session/cancel`, `session/load`, `session/close`).
- Advertise `agentCapabilities` and `availableModels` honestly. `--model <id>` requires `availableModels` to include the requested id, and `set model <id>` calls `session/set_model`.
`fs/*` and `terminal/*` client methods are stable on the `acpx` side and respect cwd sandboxing — your adapter can request file reads, writes, and terminal calls and they will be routed through `acpx`'s permission policy.
## Practical examples
Local dev server with arguments:
```bash
acpx --agent 'node --inspect ./scripts/dev-acp.mjs --port 5555' \
codex sessions new
```
Wait — that runs through `--agent`, so `codex` would be a positional agent and conflict. The right form is one or the other:
```bash
acpx --agent 'node ./scripts/dev-acp.mjs' sessions new
acpx --agent 'node ./scripts/dev-acp.mjs' 'run a sanity check'
```
Per-repo override with config:
`<repo>/.acpxrc.json`:
```json
{
"agents": {
"internal": {
"command": "/opt/internal/acp-bridge",
"args": ["--profile", "stable"]
}
}
}
```
Then everywhere in that repo:
```bash
acpx internal sessions new
acpx internal 'review the latest commit'
acpx internal exec 'list TODO comments'
```
OpenClaw repo-local checkout (the canonical "override a built-in" example):
```json
{
"agents": {
"openclaw": {
"command": "env OPENCLAW_HIDE_BANNER=1 OPENCLAW_SUPPRESS_NOTES=1 node scripts/run-node.mjs acp --url ws://127.0.0.1:18789 --token-file ~/.openclaw/gateway.token --session agent:main:main"
}
}
}
```
## See also
- [Agents](agents.md) — built-in registry.
- [Config](config.md) — the `agents` map and precedence rules.
- [Sessions](sessions.md) — how the agent command participates in scope keys.

View File

@ -1,49 +0,0 @@
---
title: Exit codes
description: Stable acpx exit codes for scripting — success, runtime errors, usage errors, timeouts, no-session, permission denial, and interrupts.
---
`acpx` uses a small, stable set of exit codes so wrapping scripts can branch on them.
| Code | Meaning |
| ----- | ------------------------------------------------------------------- |
| `0` | Success |
| `1` | Agent / protocol / runtime error |
| `2` | CLI usage error (bad flags, conflicting flags, malformed `--agent`) |
| `3` | Timeout (`--timeout` exceeded) |
| `4` | No session found (prompt requires an explicit `sessions new`) |
| `5` | Permission denied (every request denied/cancelled, none approved) |
| `130` | Interrupted (`SIGINT` / `SIGTERM`) |
## Notes
- **`0`** is also returned by `cancel` when there is nothing to cancel. The text/JSON output makes the distinction.
- **`1`** is the catch-all for adapter errors, transport failures, and unexpected runtime errors. Stderr or the JSON error envelope contains details.
- **`2`** signals "you typed something `acpx` cannot run" — combining `--agent` with a positional agent token, mutually exclusive permission flags, missing required arguments, etc.
- **`3`** is reserved for `--timeout` expiry. Adapter-side timeouts that are not surfaced as `acpx` timeouts come through as `1`.
- **`4`** is the "directory walk found no active session" signal. Run `sessions new` (or `sessions ensure` for idempotent scripts) and retry.
- **`5`** only fires when at least one permission request happened, and every one ended in a denial or cancellation. If at least one was approved, the result reflects whatever the agent returned.
- **`130`** matches the conventional shell signal exit code for `Ctrl+C` (`128 + SIGINT`). `acpx` cancels cooperatively before exiting with this code.
## Branching example
```bash
if acpx --format quiet codex 'one-line summary' >summary.txt; then
echo "ok"
else
case $? in
2) echo "usage error" ;;
3) echo "timed out" ;;
4) echo "no session — run sessions new"; acpx codex sessions new ;;
5) echo "all denied" ;;
130) echo "interrupted" ;;
*) echo "agent or runtime error" ;;
esac
fi
```
## See also
- [Permissions](permissions.md) — what makes exit `5` happen.
- [Sessions](sessions.md) — what makes exit `4` happen and how to fix it.
- [Prompting](prompting.md) — `--timeout` and `--no-wait` semantics.

View File

@ -1,198 +0,0 @@
---
title: Flows
description: Multi-step ACP workflows in acpx — define a TypeScript flow, mix acp / action / compute / decision / checkpoint nodes, persist runs, and replay.
---
Flows are how `acpx` runs multi-step ACP work without turning one giant prompt into the workflow engine. They are TypeScript modules that the `acpx/flows` runtime executes step by step, persisting state under `~/.acpx/flows/runs/`.
> Flows are an experimental, opt-in surface. The authoring API is in `acpx/flows`; flows do not change how persistent sessions or `prompt` / `exec` work.
## When to use flows
Reach for a flow when one prompt is not enough — typically because:
- you need a deterministic branch (classify, then route)
- one ACP turn should not also run shell commands or call the GitHub API
- you want each step to be inspectable and replayable
- the workflow is the same across runs, but the input changes
For one-off asks, `acpx codex 'do the thing'` is the right tool. For "run this 6-step PR triage on every PR matching a query," a flow is the right tool.
## Run a flow
```bash
acpx flow run ./my-flow.ts
acpx flow run ./my-flow.ts --input-file ./flow-input.json
acpx flow run ./my-flow.ts --input-json '{"task":"FIX: …"}'
acpx flow run ./my-flow.ts --default-agent claude
acpx --timeout 1800 flow run ./my-flow.ts
```
What happens:
- The runtime loads the flow module from disk.
- A run id is generated and a run directory is created at `~/.acpx/flows/runs/<runId>/`.
- Steps execute in topological order. ACP steps reuse one implicit main session by default.
- Run state (graph, ACP transcripts, artifacts, errors) is persisted as the run progresses.
- The runtime exits when the graph terminates or a checkpoint pauses.
`--input-json` and `--input-file` are mutually exclusive ways to provide flow input. `--default-agent` supplies the default agent profile for `acp` nodes that do not pin one.
## Node types
Flows are graphs. Each node is one of:
| Node | Purpose |
| ------------ | ------------------------------------------------------------------------------------------- |
| `acp` | A model-shaped step — runs an ACP turn against an agent session. |
| `action` | A deterministic runtime-owned step — typically a shell command or HTTP call. |
| `compute` | A pure local function — shape inputs, route, format, derive values. |
| `decision` | A constrained-choice ACP branch — wraps `acp` + `parse` + `switch` for typed routing. |
| `checkpoint` | A pause point that requires something outside the runtime (human review, external trigger). |
Edges connect nodes. `decisionEdge()` produces typed edges out of a `decision()` node so the routing is explicit and replayable.
The runtime owns:
- graph execution and step ordering
- liveness and timeouts
- ACP session lifecycle
- persistence and replay
- routing through `decision` outcomes
The agent owns reasoning, summarization, and tool calls inside `acp` and `decision` nodes. The flow file does not implement the workflow engine — it declares it.
## Authoring surface
Define a flow with `defineFlow` from `acpx/flows`:
```ts
import { defineFlow, acp, action, compute, decision } from "acpx/flows";
export default defineFlow({
id: "triage",
input: { task: "string" },
steps: {
classify: decision({
agent: "codex",
prompt: ({ task }) => `Classify: ${task}\nLabels: bug | feat | doc`,
choices: ["bug", "feat", "doc"],
}),
fix: acp({ prompt: ({ task }) => `Implement and verify: ${task}` }),
write_doc: acp({ prompt: ({ task }) => `Draft docs entry for: ${task}` }),
},
edges: [
["classify", "fix", (out) => out === "bug" || out === "feat"],
["classify", "write_doc", (out) => out === "doc"],
],
});
```
The example above is illustrative — see `examples/flows/branch.flow.ts` for the canonical small `decision()` example.
## Workspace isolation
`acp` nodes can pin a per-step working directory:
```ts
acp({
cwd: "${workdir}/.work-tree",
prompt: ({ task }) => `Run inside the prepped tree: ${task}`,
});
```
This lets a flow `action` step (e.g., `git worktree add`) prepare an isolated workspace, then have downstream `acp` nodes operate inside that cwd. `examples/flows/workdir.flow.ts` shows the pattern end-to-end.
## Permissions
Flows can declare an explicit permission requirement. If a flow needs `approve-all` and you forget the flag, `acpx` fails fast before the first step runs and prints the flag to add:
```bash
acpx flow run examples/flows/pr-triage/pr-triage.flow.ts \
--input-json '{"repo":"openclaw/acpx","prNumber":150}'
# error: this flow requires --approve-all
```
```bash
# correct
acpx --approve-all flow run examples/flows/pr-triage/pr-triage.flow.ts \
--input-json '{"repo":"openclaw/acpx","prNumber":150}'
```
This is a guardrail for flows that make real changes — the PR-triage example can comment on or close GitHub PRs against a live repo.
## Run persistence
Each run produces a bundle under `~/.acpx/flows/runs/<runId>/`:
- step-by-step graph state with inputs and outputs
- ACP transcripts for every `acp` and `decision` step
- artifacts written by `action` steps (when the step opts in)
- final result or error
Bundles are immutable once a run terminates. They are the input for the [replay viewer](#replay-viewer).
## Timeouts
`acp` and `action` nodes use the global `--timeout` value as their default per-step timeout. If `--timeout` is not set, flows default to **15 minutes per active step**. Override per step in the flow definition when needed.
## Replay viewer
`examples/flows/replay-viewer/` is a browser app that visualizes saved run bundles:
- React Flow graph with per-node status
- recent-runs picker (live over WebSocket — in-progress runs update without refresh)
- ACP session inspection per step
- rewind/scrub through the run timeline
Run from the repo root:
```bash
pnpm viewer
```
The viewer is read-only. It opens a saved bundle and lets you inspect what happened; it does not re-run the flow.
## Example flows in the source tree
Under `examples/flows/`:
- `echo.flow.ts` — minimal one-step ACP flow that returns a JSON reply
- `branch.flow.ts``decision()` + `decisionEdge()` constrained-choice classification, then a deterministic branch
- `shell.flow.ts` — one runtime-owned shell `action` returning structured JSON
- `workdir.flow.ts``action` prepares a worktree, `acp` runs inside that cwd
- `two-turn.flow.ts` — same-session ACP example that uses tools across multiple steps
- `pr-triage/pr-triage.flow.ts` — larger end-to-end example with a written spec; can comment on or close real GitHub PRs against a live repo
The PR-triage example declares an explicit `approve-all` requirement, so it must be run with `--approve-all`.
## Practical examples
```bash
# Smallest possible run
acpx flow run examples/flows/echo.flow.ts \
--input-json '{"request":"Summarize this repo in one sentence."}'
# decision()/decisionEdge() routing
acpx flow run examples/flows/branch.flow.ts \
--input-json '{"task":"FIX: add a regression test for the reconnect bug"}'
# Runtime-owned shell action
acpx flow run examples/flows/shell.flow.ts \
--input-json '{"text":"hello from shell"}'
# Multi-turn same-session work
acpx flow run examples/flows/two-turn.flow.ts \
--input-json '{"topic":"How should we validate a new ACP adapter?"}'
# Live PR triage (declares approve-all)
acpx --approve-all flow run examples/flows/pr-triage/pr-triage.flow.ts \
--input-json '{"repo":"openclaw/acpx","prNumber":150}'
```
## See also
- [Architecture: acpx flows](https://github.com/openclaw/acpx/blob/main/docs/2026-03-25-acpx-flows-architecture.md) — full design doc.
- [Flow trace replay](https://github.com/openclaw/acpx/blob/main/docs/2026-03-26-acpx-flow-trace-replay.md) — replay format spec.
- [Flow permission requirements](https://github.com/openclaw/acpx/blob/main/docs/2026-03-28-acpx-flow-permission-requirements.md) — fail-fast permission gating.
- [`examples/flows/` in the source tree](https://github.com/openclaw/acpx/tree/main/examples/flows) — runnable flow examples and a colocated `README`.

View File

@ -1,55 +0,0 @@
---
title: Overview
permalink: /
description: "acpx is a headless CLI client for the Agent Client Protocol (ACP) — talk to coding agents from the command line, not the PTY."
---
## Try it
After a one-line install ([Quickstart](quickstart.md) walks through it), every coding agent is one command away.
```bash
# Persistent multi-turn session, scoped per repo.
acpx codex sessions new
acpx codex 'find the flaky test and fix it'
# Switch agents, same surface.
acpx claude 'refactor the auth middleware'
acpx gemini 'review this branch'
# One-shot, no saved context.
acpx codex exec 'summarize this repo in 5 bullets'
# Pipe structured ACP events into your own automation.
acpx --format json codex exec 'review changed files' \
| jq -r 'select(.type=="tool_call") | [.status,.title] | @tsv'
# Run a TypeScript multi-step flow against a real agent.
acpx flow run examples/flows/branch.flow.ts \
--input-json '{"task":"add a regression test for the reconnect bug"}'
```
`text` output is human readable, `--format json` emits NDJSON ACP messages, `--format quiet` keeps only the assistant text. Tool calls, thinking, and diffs come through as structured events instead of ANSI scraping.
## What acpx does
- **One CLI, every coding agent.** Built-in adapters for Codex, Claude, Pi, OpenClaw, Gemini, Cursor, Copilot, Droid, Qwen, Qoder, Trae, and more — plus `--agent` for any custom ACP server.
- **Persistent sessions.** Multi-turn conversations survive across invocations, scoped per repo. `-s <name>` runs parallel workstreams (`backend`, `docs`, `pr-842`).
- **Queue-aware prompts.** Submit while a turn is running; new prompts queue and drain in order. `--no-wait` enqueues and returns. `cancel` aborts cooperatively without tearing the session down.
- **Crash-resistant.** Dead agent processes are detected and reloaded automatically. `Ctrl+C` sends ACP `session/cancel` before any force-kill.
- **Structured output.** `text`, `json`, and `quiet` modes. Strict JSON mode keeps stderr quiet so machines can parse stdout cleanly.
- **Permission policy as a flag.** `--approve-all`, `--approve-reads` (default), `--deny-all`. Non-interactive policy is configurable. Sandbox to a `--cwd`.
- **Flows.** `acpx flow run <file>` executes a TypeScript workflow over multiple ACP turns plus deterministic `action` and `compute` steps. Run state persists under `~/.acpx/flows/runs/`.
- **Embeddable.** `acpx/runtime` and `acpx/flows` are public exports — build higher-level tools without re-implementing session storage, queue ownership, or ACP wire handling.
## Pick your path
- **Trying it.** [Install](install.md) → [Quickstart](quickstart.md). Two minutes from `npm i -g acpx` to your first turn.
- **Talking to a specific agent.** The [Agents](agents.md) page lists every built-in name and the upstream CLI it wraps.
- **Wiring an automation.** [Output formats](output-formats.md) for the JSON envelope, [Sessions](sessions.md) for scope rules, [Permissions](permissions.md) for policy.
- **Multi-step orchestration.** [Flows](flows.md) covers `acp` / `action` / `compute` / `decision` / `checkpoint` nodes and replay.
- **Looking up a flag.** The [CLI reference](CLI.md) is the long-form spec for every command, option, and exit code.
## Project
Active alpha; the [changelog](https://github.com/openclaw/acpx/blob/main/CHANGELOG.md) tracks each release. The CLI surface is still evolving — see the [Vision](VISION.md) for what stays in core and what does not. MIT licensed. Not affiliated with any specific agent vendor.

View File

@ -1,108 +0,0 @@
---
title: Install
description: Install acpx globally with npm, run it ad-hoc with npx, or build from source. Covers Node version, PATH, and updating.
---
`acpx` is published to npm as [`acpx`](https://www.npmjs.com/package/acpx). It is a single Node CLI — no service to host, no daemon to manage. Session state lives under `~/.acpx/`.
## Requirements
- Node.js **22.12 or newer** (see `engines.node` in `package.json`)
- The underlying coding agent CLI you plan to talk to (Codex, Claude, etc.)
`acpx` itself does not need a global install of every adapter. Built-in adapters that ship as npm packages (`pi-acp`, `@zed-industries/codex-acp`, `@agentclientprotocol/claude-agent-acp`, `@kilocode/cli`, `opencode-ai`) are auto-fetched with `npx` on first use.
## Global install (recommended)
```bash
npm install -g acpx@latest
```
Verify:
```bash
acpx --version
acpx --help
```
Global install is the default for most workflows because it keeps queue owners and persistent sessions warm between invocations.
## Run without installing
```bash
npx acpx@latest codex 'fix the failing tests'
```
`npx` works for one-off use but pays a small startup cost on every invocation. For repeated session reuse, prefer the global install.
## Update
```bash
npm install -g acpx@latest
```
Check what changed in the [changelog](https://github.com/openclaw/acpx/blob/main/CHANGELOG.md). Pre-1.0 releases can break CLI/runtime surface area between minor versions.
## Where data lives
| Path | What it stores |
| ----------------------------- | ------------------------------------------------------------------------------------- |
| `~/.acpx/sessions/*.json` | Persistent session records (scope key, last prompt, history previews, model, options) |
| `~/.acpx/queues/<hash>.sock` | Unix socket for active queue owners (named pipe on Windows) |
| `~/.acpx/queues/<hash>.lock` | Ownership lock file |
| `~/.acpx/flows/runs/<runId>/` | Persisted flow run bundles (graph state, ACP transcripts, artifacts) |
| `~/.acpx/config.json` | Optional global config (see [Config](config.md)) |
| `<cwd>/.acpxrc.json` | Optional project config (merged on top of global, CLI flags still win) |
Queue and IPC directories are created with owner-only permissions. `acpx` re-tightens permissions on previously-permissive directories at startup.
## Build from source
For development or to test an unreleased branch:
```bash
git clone https://github.com/openclaw/acpx.git
cd acpx
pnpm install
pnpm run build
node dist/cli.js --help
```
Run during development without rebuilding:
```bash
pnpm dev codex 'fix the tests'
```
See [`CONTRIBUTING.md`](https://github.com/openclaw/acpx/blob/main/CONTRIBUTING.md) for tests, lint, and the conformance suite.
## Tell your agent about acpx
If you are configuring an upstream coding agent (Pi, OpenClaw, Claude Code) to delegate work through `acpx`, paste this block into its harness instructions:
```text
I want you to use acpx to run coding agents over the Agent Client Protocol
(ACP) instead of scraping PTY sessions. Please do the following:
1. Install acpx globally (recommended) or use npx:
npm install -g acpx@latest
2. Install the acpx skill so you have the full reference available:
npx acpx@latest --skill install acpx
3. Read the acpx skill reference so you know every command, flag, and
workflow pattern:
https://raw.githubusercontent.com/openclaw/acpx/main/skills/acpx/SKILL.md
4. Full CLI reference with all options and examples:
https://acpx.sh/CLI.html
From now on, when you need to delegate work to a coding agent, use acpx
instead of spawning raw terminal sessions.
```
## Next steps
- [Quickstart](quickstart.md) — your first persistent session in two minutes.
- [Agents](agents.md) — every built-in agent name and what it wraps.
- [Config](config.md) — global + project JSON config.

View File

@ -1,144 +0,0 @@
---
title: Output formats
description: text, json, json-strict, and quiet modes — what each format emits, the JSON envelope, and how --suppress-reads affects payloads.
---
`acpx` streams agent activity in three output modes plus two modifiers. Pick the one that matches your consumer: a human terminal, an automation pipeline, or a script that only wants the final answer.
## `text` (default)
Human-readable stream:
- assistant text as it arrives
- `[thinking]` blocks for reasoning chunks
- `[tool] <title> (<status>)` blocks with output, diff previews, and plan updates
- `[done] <stopReason>` at the end
```bash
acpx codex 'review the auth module'
```
```text
[thinking] Reading src/auth and looking for token validation
[tool] Read src/auth/index.ts (completed)
[tool] Run grep -n 'verifyToken' src/auth (completed)
output:
src/auth/jwt.ts:42:export function verifyToken
The auth module is structured as …
[done] end_turn
```
`text` is best for interactive use. It is **not** stable for parsing — error messages, prompts, and progress updates can change between releases.
## `json`
NDJSON stream of raw ACP JSON-RPC messages on stdout:
```bash
acpx --format json codex exec 'review changed files' \
| jq -r 'select(.method=="session/update")'
```
```json
{"jsonrpc":"2.0","id":"req-1","method":"session/prompt","params":{"sessionId":"019c…","prompt":"hi"}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"Hello"}}}
{"jsonrpc":"2.0","id":"req-1","result":{"stopReason":"end_turn"}}
```
Hard rules for `json`:
- No acpx-specific event envelope wrapping ACP messages.
- No synthetic `type` / `stream` / `eventVersion` keys injected onto raw ACP traffic.
- No payload key renaming.
What you read on stdout is the same wire-level JSON that would have crossed the ACP transport, in submission order.
stderr can still contain prompts, progress, or warnings. If your script reads only stdout, that is fine. If you pipe both, see `--json-strict` below.
## `--format json --json-strict`
Strict JSON suppresses non-JSON output that would otherwise land on stderr:
```bash
acpx --format json --json-strict codex exec 'list TODO comments' > events.ndjson
```
`--json-strict` requires `--format json`. It guarantees:
- stdout is one ACP JSON-RPC message per line
- stderr stays quiet for non-error informational output
This is the right combination for "fully machine-consumed pipelines that should fail visibly on real errors."
## `quiet`
Final assistant text only — no tool blocks, no thinking, no `[done]`:
```bash
SUMMARY=$(acpx --format quiet codex exec 'one-line summary of this branch')
echo "$SUMMARY"
```
When the adapter includes final token usage and cost metadata in the prompt result, `acpx` emits that to **stderr** in `quiet` mode. stdout stays as the assistant text only.
`quiet` is unaffected by `--suppress-reads` because it does not print tool call output to begin with.
## `--suppress-reads`
Replaces raw read-file payloads with a placeholder so logs stay readable when an agent reads a large file:
| Mode | Effect of `--suppress-reads` |
| ------- | --------------------------------------------------------------------------------------- |
| `text` | Read-like tool outputs render as `[read output suppressed]`. |
| `json` | ACP `fs/read_text_file` responses and read-like tool-call outputs replace raw contents. |
| `quiet` | No effect (quiet mode prints assistant text only). |
```bash
acpx --suppress-reads codex exec 'inspect repo and report tool usage'
```
The replacement preserves the surrounding ACP message shape so json consumers can still parse the stream — only the content payload is masked.
## Session-control command output
Local query commands emit local JSON shapes (not ACP wire traffic) under `--format json`:
| Command | `text` | `json` | `quiet` |
| ----------------------- | --------------------------------- | ---------------------------------------------------------- | ---------------------- |
| `sessions list` | TSV: `id name cwd lastUsedAt` | array of session records | one id per line |
| `sessions show` | key/value lines | full session record object | record id |
| `sessions history` | TSV: `timestamp role textPreview` | `{ entries: [...] }` | record id |
| `sessions prune` | summary + pruned ids and time | `{ action, dryRun, count, bytesFreed, pruned }` | one pruned id per line |
| `sessions new`/`ensure` | record id | record + `acpxRecordId`/`acpxSessionId`/(`agentSessionId`) | record id |
| `status` | key/value lines | full status object | state token |
Closed sessions are marked `[closed]` in `text` and `quiet`.
## Identity fields in JSON
Session-control JSON always includes:
| Field | Meaning |
| ---------------- | ----------------------------------------------------------------- |
| `acpxRecordId` | Local record id (also what `text`/`quiet` print) |
| `acpxSessionId` | Acpx-side session id |
| `agentSessionId` | Provider-native id, **only present** when the adapter exposes one |
Do not assume the `acpxRecordId` can be passed to a native provider CLI. Use `agentSessionId` for that, when present.
## Picking a mode
| Use case | Pick |
| ----------------------------------------- | ----------------------------------------- |
| Interactive use, you are the reader | `text` (default) |
| Save full transcript for later replay | `json` (or `--format json --json-strict`) |
| Pipe into `jq` and parse events | `--format json` or `--json-strict` |
| Capture only the final answer in a script | `--format quiet` |
| Long agent runs that read large files | add `--suppress-reads` |
| Anywhere stdout must be 100% JSON | `--format json --json-strict` |
## See also
- [Sessions](sessions.md) — what session-control commands return.
- [Permissions](permissions.md) — how denials surface in each format.
- [CLI reference](CLI.md#output-formats) — full per-mode behavior table.

View File

@ -1,137 +0,0 @@
---
title: Permissions
description: Permission modes, non-interactive policy, and how acpx handles ACP permission requests for tool calls and file writes.
---
ACP agents request permission for tool actions like writing files, running shell commands, or fetching URLs. `acpx` mediates those requests against a policy you choose at the command line (or in [config](config.md)).
## Modes
Choose exactly one. The flags are mutually exclusive — passing more than one is a usage error.
| Flag | Behavior |
| ----------------- | ---------------------------------------------------------------------------- |
| `--approve-all` | Auto-approve every permission request without prompting. |
| `--approve-reads` | Auto-approve read/search requests; prompt for everything else. **(default)** |
| `--deny-all` | Auto-deny/reject every permission request whenever the protocol allows. |
Set a project default in `.acpxrc.json` or a global default in `~/.acpx/config.json`:
```json
{ "defaultPermissions": "approve-all" }
```
CLI flags always win over config.
## What counts as a "read"
Read/search requests in `--approve-reads`:
- Reading file contents (`fs/read_text_file` and read-shaped tool calls)
- Listing directories
- Search/grep tool calls
- Anything the adapter classifies as non-mutating
Everything else — write, edit, shell command, network call, etc. — falls into the prompt-or-deny path.
## Interactive prompting
In an interactive TTY, `--approve-reads` shows:
```text
Allow <tool>? (y/N)
```
`y` approves the single request. `N` (default) denies it. The agent decides what to do with a denial — most adapters surface it as a tool error and let the model choose to retry, ask differently, or give up.
There is no per-session "approve next 3" option. Every non-read request is its own prompt unless you pass `--approve-all`.
## Non-interactive policy
When there is no TTY (pipes, CI, queued prompts driven by another process), the prompt cannot be shown. `--non-interactive-permissions` decides what happens:
| Policy | Behavior |
| ------ | -------------------------------------------------------- |
| `deny` | Treat the un-promptable request as denied. **(default)** |
| `fail` | Fail the prompt with `PERMISSION_PROMPT_UNAVAILABLE`. |
Set a project default if you want CI runs to fail loudly:
```json
{ "nonInteractivePermissions": "fail" }
```
## Exit code 5
If, by the end of a prompt, every permission request was denied or cancelled and none were approved, `acpx` exits with code `5` (`PERMISSION_DENIED`). This makes the "agent could not do anything because permissions were locked down" case detectable from a wrapping script.
If at least one request was approved (auto or explicit), exit code is whatever the prompt result indicates — typically `0` for success, `1` for an agent/runtime error.
## Sandboxing with `--cwd`
`--cwd <dir>` sets the working directory the agent operates in. The ACP `fs/*` and `terminal/*` client methods that `acpx` implements honor cwd boundaries — adapters cannot escape that directory through `fs/read_text_file` or terminal calls routed through the client.
```bash
acpx --cwd ~/repos/api --approve-all codex 'fix everything you find'
```
## `--no-terminal`
Disables the ACP terminal capability for newly-spawned agent clients:
```bash
acpx --no-terminal codex exec 'summarize without spawning shell tools'
```
`acpx` advertises `clientCapabilities.terminal: false` during ACP `initialize`. Agents that respect the advertised capability will avoid terminal calls; agents that do not will get a hard error if they try.
This is a cleaner way to forbid shell access than blanket-denying every permission prompt, because the agent knows the capability is unavailable up front and can plan around it.
## Authentication
Permissions and auth are separate. ACP `authenticate` handshakes are configured through:
- `ACPX_AUTH_<METHOD_ID>` environment variables, e.g. `ACPX_AUTH_OPENAI_API_KEY=sk-…`
- Config `auth` map (see [Config](config.md#authentication))
Ambient provider env vars like `OPENAI_API_KEY` are still passed through to child agents, but they do **not** trigger ACP auth-method selection on their own. This avoids surprise login flows in adapters such as `codex-acp`.
## Permission flags in flows
Flow definitions can declare required permissions. If a flow needs `approve-all` and you run it without `--approve-all`, `acpx` fails fast before the flow starts and tells you which flag to pass.
```bash
# pr-triage example requires --approve-all
acpx --approve-all flow run examples/flows/pr-triage/pr-triage.flow.ts \
--input-json '{"repo":"openclaw/acpx","prNumber":150}'
```
See [Flows](flows.md#permissions) for how flow permission requirements work.
## Practical patterns
Read-only audit:
```bash
acpx --deny-all codex 'analyze this code without touching anything'
```
Trusted CI run:
```bash
acpx --approve-all --non-interactive-permissions fail \
codex exec 'apply formatter and run lint'
```
Local exploration with the default safety net:
```bash
# Default --approve-reads, prompts in TTY for writes
acpx codex 'investigate why the build is slow'
```
## See also
- [CLI reference](CLI.md#permission-modes) — full table.
- [Config](config.md) — `defaultPermissions`, `nonInteractivePermissions`.
- [Sessions](sessions.md) — how `--cwd` becomes part of the scope key.

View File

@ -1,181 +0,0 @@
---
title: Prompting
description: How acpx submits prompts — implicit vs explicit, persistent vs one-shot, stdin and --file input, queue submission with --no-wait, and timeouts.
---
`acpx` has one core operation: send a prompt to an ACP agent and stream the response. Everything else (sessions, queueing, cancel, mode) wraps that.
## Forms
The CLI accepts a prompt in five interchangeable ways:
```bash
# 1. Implicit, positional text. Defaults to codex when no agent is given.
acpx codex 'fix the failing tests'
acpx 'summarize this branch'
# 2. Explicit `prompt` subcommand.
acpx codex prompt 'fix the failing tests'
acpx prompt 'summarize this branch'
# 3. From stdin (piped).
echo 'review changed files' | acpx codex
git diff | acpx codex prompt
# 4. From a file.
acpx codex --file ./brief.md
acpx codex prompt -f ./brief.md
# 5. From stdin, with extra context appended.
git diff | acpx codex --file - 'and call out anything risky'
```
The `--file -` form is particularly handy for piping a long prompt from another tool while still tacking on a short instruction at the end.
## Persistent vs. one-shot
| Command | Reuses saved session? | Writes saved session? | Queue-aware? |
| -------- | ---------------------- | --------------------- | ------------ |
| `prompt` | Yes — resumes by scope | Updates history | Yes |
| (bare) | Same as `prompt` | Same as `prompt` | Yes |
| `exec` | No — temporary session | No | No |
`exec` is the right choice when:
- you want a stateless answer in a script (`SUMMARY=$(acpx --format quiet codex exec '…')`)
- you do not want to fork a session by accident
- you need machine-readable JSON output without later turns appended
`prompt` (or bare) is the right choice when:
- the conversation should remember earlier turns
- you want queue-aware submission with `--no-wait`
- you want `cancel` / `set-mode` / `set` to apply to the same session
## Implicit defaults
Top-level `acpx prompt …`, `acpx exec …`, `acpx cancel`, `acpx set-mode …`, `acpx set …`, and `acpx sessions …` all default to the `codex` agent. You can change the default for your environment by setting `defaultAgent` in [config](config.md):
```json
{ "defaultAgent": "claude" }
```
CLI flags still win, so `acpx codex …` always runs codex even if `defaultAgent` is `claude`.
## Prompt options
Available on `prompt`, the bare implicit form, and `exec`:
| Option | Description |
| --------------- | -------------------------------------------------------------------------- |
| `-s, --session` | Use a named session within the current cwd scope |
| `--no-wait` | Enqueue and return immediately if a prompt is already running |
| `-f, --file` | Read prompt text from a file (`-` reads stdin and still allows extra args) |
`--no-wait` is per-prompt; the next call without `--no-wait` will block normally.
## Queue submission
When a turn is already in flight for the target session, `acpx` does not spawn a second adapter. It submits to the running queue owner over local IPC. The submitter then either:
- **blocks** until the queued prompt completes (default), streaming events as they happen, or
- **returns** as soon as the owner acknowledges (`--no-wait`).
Queued prompts drain in submission order. After the queue empties, the owner stays alive for an idle TTL (`--ttl <seconds>`, default `300`).
```bash
# Long-running turn
acpx codex 'run the full test suite and triage failures'
# Queue follow-ups without waiting
acpx codex --no-wait 'after that, summarize root cause in 3 bullets'
acpx codex --no-wait 'and propose 1 minimal fix'
```
`Ctrl+C` while waiting on a queued or running prompt sends ACP `session/cancel` first, waits briefly for the cancelled completion, and falls back to a force-kill only if the agent does not respond. See [Session control](session-control.md) for the explicit `cancel` command.
## Timeouts
`--timeout <seconds>` caps how long `acpx` will wait for an agent response. It applies to:
- the active prompt turn
- the per-step default for [flows](flows.md) `acp` and `action` nodes (15 minutes when `--timeout` is omitted)
```bash
acpx --timeout 90 codex 'investigate the intermittent test timeout'
```
Decimal seconds are allowed. Negative or zero is rejected as a usage error.
If the timeout fires, `acpx` exits with code `3` and the agent process is cancelled cooperatively first.
## Models
`--model <id>` requests a specific model:
```bash
acpx --model claude-sonnet-4-6 claude 'do the thing'
acpx --model gpt-5.4 codex exec 'one-shot summary'
```
Behavior varies by adapter:
- **Claude** consumes the value as session-creation metadata.
- Other agents must advertise ACP models and support `session/set_model`. If they do not, `acpx` fails clearly instead of silently falling back to the adapter's default.
- Model ids must appear in the adapter's advertised `availableModels`. Unknown ids are rejected.
For mid-session model switches, use `set model <id>` instead. See [Session control](session-control.md#set-key-value).
## Codex compatibility aliases
Some Codex-specific knobs are surfaced through generic ACP methods:
```bash
acpx codex set thought_level high # alias -> codex-acp `reasoning_effort`
```
`thought_level` is intercepted and translated. Other keys pass through as-is via `session/set_config_option`.
## Permissions inside a prompt
Prompts can trigger permission requests for tool calls. The default policy auto-approves reads and prompts for writes; non-interactive runs default to deny. See [Permissions](permissions.md).
```bash
acpx --approve-all codex 'apply the patch and run tests'
acpx --deny-all codex 'analyze without using any tools'
```
## Reading prompt text
Whichever way you supply prompt text, `acpx` concatenates the file (or stdin) with positional args, separated by a blank line. That is what makes `--file -` plus appended args work.
If neither stdin is piped nor `--file` is provided and there are no positional args, `acpx` prints help and exits.
## Examples
```bash
# Implicit, codex default
acpx 'review the latest commit'
# Explicit agent and explicit verb
acpx claude prompt 'refactor src/auth into clearer modules'
# Stdin + appended ask
git log --oneline -n 20 | acpx codex --file - 'pick the 3 most important changes'
# One-shot JSON for automation
acpx --format json codex exec 'list TODO comments by file' \
| jq -r 'select(.method=="session/update")'
# Named session + fire-and-forget follow-up
acpx codex sessions new --name release
acpx codex -s release 'collect changes since v0.6.0'
acpx codex -s release --no-wait 'then draft release notes'
```
## See also
- [Sessions](sessions.md) — scope rules, queueing, and crash recovery in depth.
- [Session control](session-control.md) — `cancel`, `set-mode`, `set`.
- [Output formats](output-formats.md) — what gets emitted per format and `--suppress-reads`.
- [CLI reference](CLI.md#prompt-subcommand-explicit) — formal grammar.

View File

@ -1,136 +0,0 @@
---
title: Quickstart
description: From install to a persistent multi-turn ACP session in under two minutes. Covers your first prompt, named sessions, exec, and JSON output.
---
This walks through the smallest useful path: install `acpx`, point it at a coding agent, run a multi-turn session, then peek at the persisted state.
## 1. Install
```bash
npm install -g acpx@latest
acpx --version
```
If you would rather not install globally, every command below works with `npx acpx@latest …`. ([Install](install.md) covers options.)
## 2. Pick an agent
`acpx` ships with adapters for a dozen coding agents. The two most common starting points:
```bash
# Codex (OpenAI), via @zed-industries/codex-acp
acpx codex --help
# Claude Code, via @agentclientprotocol/claude-agent-acp
acpx claude --help
```
You only need the underlying agent CLI installed (or, for the npm-packaged adapters, nothing — `npx` fetches them on first use). [Agents](agents.md) lists every built-in.
## 3. Create a session
`acpx` requires an explicit session before the first prompt — this avoids surprise auto-creation in CI.
```bash
cd ~/repos/your-project
acpx codex sessions new
```
That creates a record under `~/.acpx/sessions/`, scoped to `(agentCommand, cwd)`.
## 4. Send a prompt
```bash
acpx codex 'find the slowest test in this repo and explain why'
```
You will see structured ACP events stream by — assistant text, `[tool]` blocks for each tool call, plan updates, and a final `[done] end_turn` line.
Keep going. The session is persistent:
```bash
acpx codex 'now propose a one-line fix for the slowest one'
acpx codex 'apply the fix and re-run that test'
```
Each prompt resumes the same session. If a prompt is already in flight, `acpx` queues new prompts onto the running owner instead of starting a second adapter — so you can fire-and-forget:
```bash
acpx codex --no-wait 'after the fix lands, summarize root cause in 3 lines'
```
## 5. Run something parallel
Named sessions let you split workstreams in the same repo:
```bash
acpx codex sessions new --name backend
acpx codex sessions new --name docs
acpx codex -s backend 'fix the checkout timeout'
acpx codex -s docs 'draft release notes from recent commits'
```
Sessions live side by side and resume independently.
## 6. One-shot, no saved context
Use `exec` for stateless asks:
```bash
acpx codex exec 'in 5 bullets, what does this repo do?'
acpx claude exec --file ./brief.md
```
`exec` never reads or writes a saved session record. Perfect for scripts.
## 7. Inspect what happened
```bash
acpx codex sessions # list sessions for codex in this scope
acpx codex sessions show # full metadata for the cwd default
acpx codex sessions history # last 20 turn previews
acpx codex status # running / idle / dead / no-session
```
To remove closed records once you are done:
```bash
acpx codex sessions prune --dry-run
acpx codex sessions prune --older-than 30
```
## 8. Pipe it into your tooling
`--format json` emits one ACP JSON-RPC message per line. `--format json --json-strict` adds the guarantee that nothing else lands on stdout.
```bash
acpx --format json codex exec 'review changed files for risky patterns' \
| jq -r 'select(.method=="session/update")'
```
Use `quiet` when you only want the final assistant text:
```bash
SUMMARY=$(acpx --format quiet codex exec 'one-line summary of this branch')
```
## 9. Lock down permissions
By default, `acpx` auto-approves reads and prompts for writes. Tighten or relax:
```bash
acpx --approve-all codex 'apply the patch and run tests'
acpx --deny-all codex 'analyze this code without using any tools'
acpx --non-interactive-permissions fail codex … # fail instead of deny when no TTY
```
[Permissions](permissions.md) has the full policy table.
## Where to next
- [Sessions](sessions.md) — scope rules, queueing, soft-close, prune.
- [Prompting](prompting.md) — implicit vs explicit, stdin, `--file`, `--no-wait`.
- [Output formats](output-formats.md) — text, json, json-strict, quiet, suppress-reads.
- [Flows](flows.md) — multi-step ACP workflows when one prompt is not enough.

View File

@ -1,112 +0,0 @@
---
title: Session control
description: cancel, set-mode, set, set model, and status — the verbs that adjust an in-flight or saved acpx session without restarting it.
---
These commands change live session state without restarting an adapter or losing history. They route through the queue owner when one is active, and reconnect directly otherwise.
## `cancel`
```bash
acpx codex cancel
acpx codex cancel -s backend
acpx cancel # defaults to codex
```
Sends ACP `session/cancel` cooperatively:
- If a queue owner is running, the cancel is delivered through IPC.
- If a prompt is mid-turn, the agent receives `session/cancel`, completes any pending writes, and resolves with `stopReason=cancelled`.
- If nothing is running, `acpx` prints `nothing to cancel` and exits success.
This is the same semantics as `Ctrl+C` during a foreground turn, but available without a TTY signal — useful from scripts and other agents.
## `set-mode`
```bash
acpx codex set-mode auto
acpx codex set-mode plan -s backend
acpx set-mode auto # defaults to codex
```
Calls ACP `session/set_mode`. The set of valid `<mode>` values is **adapter-defined** and not standardized across ACP. Common values seen in the wild:
| Adapter | Modes |
| -------- | ---------------------------------------------- |
| `codex` | adapter-defined (see codex-acp release notes) |
| `claude` | adapter-defined; `plan` and `auto` are typical |
| Others | check upstream agent docs |
Unsupported mode ids are rejected by the adapter, often as `Invalid params`. `acpx` surfaces that error code unchanged.
`set-mode` routes through the queue owner when active and falls back to a fresh client connection otherwise.
## `set <key> <value>`
```bash
acpx codex set thought_level high
acpx codex set reasoning_effort high
acpx claude set verbosity terse
acpx set model gpt-5.4 # defaults to codex
```
Calls ACP `session/set_config_option` with the literal `<key>` and `<value>`. Non-mode `set_config_option` values are persisted by `acpx` and replayed onto fresh adapter sessions, so options like Codex `reasoning_effort` survive a session fallback or reuse.
### Codex compatibility aliases
For Codex specifically, `thought_level` is accepted as an alias and translated to codex-acp's `reasoning_effort`. Other keys pass through unchanged.
### `set model <id>`
`set model <id>` is a special-case interception. Some adapters expose model switching via ACP `session/set_model` rather than `session/set_config_option`. `acpx` always sends `session/set_model` for the `model` key so it works on every adapter that supports either method.
```bash
acpx codex set model gpt-5.4
acpx claude set model claude-sonnet-4-6
```
For setting the model at session creation instead, use the `--model` global flag. See [Prompting](prompting.md#models).
## `status`
```bash
acpx codex status
acpx codex status -s backend
acpx status # defaults to codex
```
Reports local process status for the cwd-scoped session:
| State | Meaning |
| ------------ | -------------------------------------------------------------- |
| `running` | Queue owner alive and processing a prompt |
| `idle` | Saved session resumable, no queue owner running |
| `dead` | Saved adapter PID is gone; next prompt will respawn and reload |
| `no-session` | No saved record matches this scope |
Plus, when applicable: session id, agent command, pid, uptime, last prompt timestamp, and last known exit code or signal for `dead`.
`status` is local — it uses `kill(pid, 0)` semantics and does not touch the agent. It is safe to run from automation that polls for queue readiness.
### Output
- `text`: key/value lines (default).
- `json`: full record with `acpxRecordId`, `acpxSessionId`, optional `agentSessionId`, plus state and timestamps.
`idle` is meaningful: it means the persistent session is saved and resumable, but no queue owner is currently running. The next prompt will start an owner and reconnect.
## Routing rules
All four commands (`cancel`, `set-mode`, `set`, `status`) try the queue owner first when one exists for the target session. If no owner is running:
- `cancel` short-circuits with `nothing to cancel`.
- `set-mode` and `set` reconnect to the saved adapter session and apply the change directly.
- `status` simply reports `idle` or `dead`.
This means it is always safe to call these from scripts without worrying about whether a queue owner happens to be running.
## See also
- [Prompting](prompting.md) — `--no-wait` and timeouts.
- [Sessions](sessions.md) — scope rules and queue ownership.
- [CLI reference](CLI.md#cancel-command) — formal command grammar.

View File

@ -1,211 +0,0 @@
---
title: Sessions
description: Persistent multi-turn ACP sessions in acpx — scope rules, named sessions, soft-close, prune, queue ownership, and crash recovery.
---
`acpx` sessions are how multi-turn agent conversations survive between invocations. A session is a JSON record on disk plus, when active, a queue owner process that holds the live ACP connection.
## Scope key
Every session is keyed by a tuple:
```text
(agentCommand, absoluteCwd, optional name)
```
That is what makes `acpx codex` in `~/repos/api` and `acpx codex` in `~/repos/web` resume different conversations, and why `-s backend` and `-s docs` can run side by side in the same repo.
`agentCommand` comes from either the built-in registry, an unknown positional name (treated as a raw command), or `--agent <command>`. Two sessions with different commands are different sessions even if everything else matches.
## Lifecycle commands
```bash
acpx codex sessions # list (alias for `sessions list`)
acpx codex sessions list # list all sessions for codex (any cwd)
acpx codex sessions new # create a fresh cwd-scoped default session
acpx codex sessions new --name api # create a fresh named session
acpx codex sessions ensure # idempotent: existing or create
acpx codex sessions ensure --name api
acpx codex sessions show # metadata for the cwd-scoped default
acpx codex sessions show api # metadata for the named session
acpx codex sessions history # last 20 turn previews
acpx codex sessions history --limit 50
acpx codex sessions close # soft-close cwd default
acpx codex sessions close api # soft-close named session
acpx codex sessions prune --dry-run
acpx codex sessions prune --older-than 30
acpx codex sessions prune --before 2026-01-01 --include-history
```
Top-level `acpx sessions …` defaults to `codex`.
## Auto-resume by directory walk
Prompt commands (`acpx codex 'fix tests'`, `acpx codex prompt …`) resume an existing session rather than create one. Lookup is a directory walk:
1. Detect the nearest git root by walking up from the absolute `cwd`.
2. If a git root exists, walk from `cwd` up to that root **inclusive**, checking each directory.
3. If no git root is found, only check `cwd` exactly — no parent walk.
4. At each directory, find the first **active** (non-closed) session matching `(agentCommand, dir, optionalName)`.
5. If a match is found, use it. Otherwise exit with code `4` and tell you to run `sessions new`.
This means most workflows feel like "I was talking to codex in this repo", regardless of whether you happen to be in `src/` or `docs/` when the next prompt fires.
```bash
cd ~/repos/api/src/auth
acpx codex 'remind me what we changed' # resumes the session created at ~/repos/api
```
## Named sessions
`-s, --session <name>` adds the name into the scope key:
```bash
acpx codex sessions new --name backend
acpx codex sessions new --name docs
acpx codex -s backend 'fix the API pagination bug'
acpx codex -s docs 'rewrite the changelog'
```
Named sessions are independent. They do not share state, queue owners, or history.
## Sessions vs. ensure vs. new
| Command | If a matching session exists | If not |
| ----------------- | ----------------------------- | -------------------------------------------- |
| `sessions new` | Soft-close it, create a fresh | Create a fresh one |
| `sessions ensure` | Return it | Create a fresh one |
| (prompt commands) | Resume it | Exit `4` with guidance to run `sessions new` |
`new` is the explicit "I want to start over" verb. `ensure` is the idempotent "give me a session" verb for scripts. Bare prompt is conservative: it never auto-creates so you do not accidentally fork a session by running from the wrong directory.
## Soft-close
`sessions close` does not delete anything. It marks the record `closed: true` with `closedAt`, asks any active queue owner to send ACP `session/close`, and tears down adapter processes.
- Closed sessions stay on disk with their full record and history.
- Auto-resume by scope skips closed sessions.
- Closed sessions can still be loaded explicitly through embedding APIs.
- `sessions prune` is the explicit way to delete closed records.
## Prune
`sessions prune` removes closed records once you actually want them gone:
```bash
# Preview what would be deleted
acpx codex sessions prune --dry-run
# Delete closed sessions older than 30 days (by closeAt, falling back to lastUsedAt)
acpx codex sessions prune --older-than 30
# Delete closed sessions whose close time is before a date
acpx codex sessions prune --before 2026-01-01
# Also remove the per-session event-stream files
acpx codex sessions prune --include-history
```
Output:
- `text` — summary plus the pruned ids and close/last-used time
- `json``{ action, dryRun, count, bytesFreed, pruned }`
- `quiet` — one pruned session id per line
## Queue ownership
When a prompt is in flight, `acpx` becomes the **queue owner** for that session. Subsequent `acpx codex …` invocations submit through local IPC instead of starting a second adapter:
```bash
acpx codex 'run full test suite and triage failures'
# (still running)
acpx codex --no-wait 'after the suite, summarize root cause in 3 bullets'
acpx codex --no-wait 'and propose 1 follow-up fix'
```
Queue mechanics:
- Owner generates a Unix socket at `~/.acpx/queues/<hash>.sock` (named pipe on Windows) and a `<hash>.lock` ownership file.
- Sockets and lock files are owner-only.
- After the queue drains, the owner stays alive for an idle TTL (default `300s`) so quick follow-ups do not pay the spawn cost.
- Override TTL with `--ttl <seconds>`. `--ttl 0` keeps it alive indefinitely (until idle shutdown is otherwise triggered).
- Owner generation IDs are cryptographically random so rapid restarts cannot reuse a stale generation token.
## --no-wait
By default the submitter blocks until the queued prompt completes, streaming events back. `--no-wait` returns as soon as the running queue owner acknowledges the submission. Useful for scripted "queue up follow-ups" patterns.
```bash
acpx codex --no-wait 'after the current turn ends, write the release notes'
```
## Cancelling
`Ctrl+C` during an active turn sends ACP `session/cancel` first, waits briefly for `stopReason=cancelled`, and only force-kills if cancellation does not finish in time.
The `cancel` subcommand sends the same cooperative cancel without a terminal signal:
```bash
acpx codex cancel
acpx codex cancel -s backend
```
If nothing is running, `cancel` exits success with `nothing to cancel`.
See [Session control](session-control.md) for `set-mode`, `set <key> <value>`, and `set model`.
## Crash recovery
Saved sessions track adapter PIDs. If a saved PID is dead on the next prompt:
1. `acpx` respawns the agent.
2. Attempts ACP `session/load` with the saved provider session id.
3. Falls back to `session/new` if loading fails, transparently updating the saved record.
This makes long-running scripted sessions resilient to crashes, OS restarts, and adapter upgrades.
## Status
`acpx codex status` reports local process state:
| State | Meaning |
| ------------ | ------------------------------------------------------ |
| `running` | Queue owner alive and processing a prompt |
| `idle` | Saved session resumable, no queue owner running |
| `dead` | Saved PID is gone; next prompt will respawn and reload |
| `no-session` | No saved record matches this scope |
Status checks are local (`kill(pid, 0)` semantics) — they do not touch the agent.
## CWD scoping
`--cwd <dir>` sets both:
- the starting point for the directory-walk lookup
- the exact `cwd` for new sessions created with `sessions new`
```bash
acpx --cwd ~/repos/shop codex sessions new --name pr-842
acpx --cwd ~/repos/shop codex -s pr-842 'review PR #842'
```
CWD is stored as an absolute path in the scope key.
## Session metadata fields
`sessions show` and the JSON form of `sessions new`/`sessions ensure` and `status` include identity fields:
| Field | Meaning |
| ---------------- | ----------------------------------------------------------------- |
| `acpxRecordId` | Local record id printed in `text` and `quiet` output |
| `acpxSessionId` | acpx-side session id (always present) |
| `agentSessionId` | Provider-native session id, **only when** the adapter exposes one |
Do not pass an `acpx` session id to a native provider CLI unless `agentSessionId` is also present.
## See also
- [Prompting](prompting.md) — implicit prompt, `prompt`, `exec`, stdin, `--file`, `--no-wait`.
- [Session control](session-control.md) — `cancel`, `set-mode`, `set <key>`, `set model`.
- [Output formats](output-formats.md) — JSON envelope for sessions/status payloads.
- [CLI reference](CLI.md#sessions-subcommand) — long-form spec and exit codes.

View File

@ -10,7 +10,7 @@ They intentionally use the public authoring surface:
- export a flow via `defineFlow(...)`
- `echo.flow.ts`: one ACP step that returns a JSON reply
- `branch.flow.ts`: constrained-choice classification using `decision()` and `decisionEdge()`, followed by a deterministic branch into either `continue` or `checkpoint`
- `branch.flow.ts`: ACP classification followed by a deterministic branch into either `continue` or `checkpoint`
- `pr-triage/pr-triage.flow.ts`: a larger single-PR workflow example with a colocated written spec in `pr-triage/README.md`
- `replay-viewer/`: a browser app that visualizes saved flow run bundles with React Flow, a recent-runs picker, ACP session inspection, and a dedicated viewer spec in `docs/2026-03-27-flow-replay-viewer.md`
- `shell.flow.ts`: one native runtime-owned shell action that returns structured JSON

View File

@ -1,29 +1,32 @@
import { acp, checkpoint, decision, decisionEdge, defineFlow, extractJsonObject } from "acpx/flows";
import { acp, checkpoint, defineFlow, extractJsonObject } from "acpx/flows";
type BranchInput = {
task?: string;
};
const classifyChoices = ["continue", "checkpoint"] as const;
export default defineFlow({
name: "example-branch",
startAt: "classify",
nodes: {
classify: decision({
choices: classifyChoices,
question: ({ input }) => {
classify: acp({
async prompt({ input }) {
const task =
(input as BranchInput).task ??
"Investigate a flaky test and decide whether the request is clear enough to continue.";
return [
"Read the task below.",
"Pick `continue` if it is concrete and scoped.",
"Pick `checkpoint` if it is ambiguous or needs clarification.",
"If it is concrete and scoped, route `continue`.",
"If it is ambiguous or needs clarification, route `checkpoint`.",
"Return exactly one JSON object with this shape:",
"{",
' "route": "continue" | "checkpoint",',
' "reason": "short explanation"',
"}",
"",
`Task: ${task}`,
].join("\n");
},
parse: (text) => extractJsonObject(text),
}),
continue_lane: acp({
async prompt({ outputs }) {
@ -49,13 +52,15 @@ export default defineFlow({
}),
},
edges: [
decisionEdge({
{
from: "classify",
choices: classifyChoices,
cases: {
continue: "continue_lane",
checkpoint: "checkpoint_lane",
switch: {
on: "$.route",
cases: {
continue: "continue_lane",
checkpoint: "checkpoint_lane",
},
},
}),
},
],
});

View File

@ -1,6 +1,6 @@
{
"name": "acpx",
"version": "0.7.0",
"version": "0.6.1",
"description": "Headless CLI client for the Agent Client Protocol (ACP) — talk to coding agents from the command line",
"keywords": [
"acp",
@ -38,10 +38,9 @@
"build": "tsdown src/cli.ts src/flows.ts src/runtime.ts --format esm --dts --clean --platform node --target node22 --no-fixedExtension",
"build:test": "node -e \"require('node:fs').rmSync('dist-test',{recursive:true,force:true})\" && tsgo -p tsconfig.test.json",
"check": "pnpm run format:check && pnpm run typecheck && pnpm run lint && pnpm run build && pnpm run viewer:typecheck && pnpm run viewer:build && pnpm run test:coverage",
"check:docs": "pnpm run format:docs:check && pnpm run lint:docs && pnpm run docs:site",
"check:docs": "pnpm run format:docs:check && pnpm run lint:docs",
"conformance:run": "tsx conformance/runner/run.ts",
"dev": "tsx src/cli.ts",
"docs:site": "node scripts/build-docs-site.mjs",
"format": "oxfmt --write",
"format:check": "oxfmt --check",
"format:diff": "oxfmt --write && git --no-pager diff",

View File

@ -1,784 +0,0 @@
#!/usr/bin/env node
import fs from "node:fs";
import path from "node:path";
import {
brandMarkHtml,
css,
faviconSvg,
js,
preThemeScript,
themeToggleHtml,
} from "./docs-site-assets.mjs";
const root = process.cwd();
const docsDir = path.join(root, "docs");
const outDir = path.join(root, "dist", "docs-site");
const repoBase = "https://github.com/openclaw/acpx";
const repoEditBase = `${repoBase}/edit/main/docs`;
const cname = readCname();
const siteBase = cname ? `https://${cname}` : "";
const productName = "acpx";
const productTagline = "Talk to agents from the command line";
const productDescription =
"Headless CLI client for the Agent Client Protocol — persistent multi-turn sessions, queue-aware prompts, structured output, and multi-step flows for Codex, Claude, Pi, OpenClaw, and any ACP-capable agent.";
const installCommand = "npm install -g acpx";
const sections = [
["Start", ["index.md", "install.md", "quickstart.md"]],
["Agents", ["agents.md", "custom-agents.md"]],
["Sessions", ["sessions.md", "prompting.md", "session-control.md"]],
["Output & Policy", ["output-formats.md", "permissions.md", "config.md"]],
["Flows", ["flows.md"]],
["Reference", ["CLI.md", "exit-codes.md", "VISION.md"]],
];
const HIGHLIGHT_ALIASES = {
sh: "bash",
shell: "bash",
zsh: "bash",
console: "bash",
js: "ts",
javascript: "ts",
typescript: "ts",
jsonc: "json",
};
const HIGHLIGHT_RULES = {
bash: [
[/#[^\n]*/g, "com"],
[/'(?:[^'\\]|\\.)*'/g, "str"],
[/"(?:[^"\\]|\\.)*"/g, "str"],
[/`[^`]*`/g, "str"],
[/\$\{[^}]+\}|\$\w+/g, "var"],
[/\B-{1,2}[A-Za-z][\w-]*/g, "flag"],
[/\b\d+\b/g, "num"],
[/[|&;<>]+/g, "op"],
],
json: [
[/"(?:[^"\\]|\\.)*"(?=\s*:)/g, "prop"],
[/"(?:[^"\\]|\\.)*"/g, "str"],
[/-?\b\d+(?:\.\d+)?(?:[eE][+-]?\d+)?\b/g, "num"],
[/\b(?:true|false|null)\b/g, "lit"],
[/[{}[\],:]/g, "op"],
],
ts: [
[/\/\/[^\n]*/g, "com"],
[/\/\*[\s\S]*?\*\//g, "com"],
[/'(?:[^'\\]|\\.)*'/g, "str"],
[/"(?:[^"\\]|\\.)*"/g, "str"],
[/`(?:[^`\\]|\\.)*`/g, "str"],
[
/\b(?:const|let|var|function|return|import|export|from|default|async|await|if|else|for|while|switch|case|break|continue|new|class|interface|type|extends|implements|public|private|protected|readonly|as|in|of|typeof|instanceof|this|void|never)\b/g,
"kw",
],
[/\b(?:true|false|null|undefined)\b/g, "lit"],
[/-?\b\d+(?:\.\d+)?(?:[eE][+-]?\d+)?\b/g, "num"],
[/\b[A-Z][A-Za-z0-9_]*\b/g, "typ"],
],
};
// Internal architecture notes and stray dev docs are not part of the user-facing site.
// They remain in the repo and are reachable from GitHub.
const buildExcludes = [
/^\d{4}-\d{2}-\d{2}-/, // dated architecture notes
/^ACPX_ERROR_STRATEGY\.md$/,
/^json-patch-plus\.md$/,
];
fs.rmSync(outDir, { recursive: true, force: true });
fs.mkdirSync(outDir, { recursive: true });
const allPages = allMarkdown(docsDir).map((file) => {
const rel = path.relative(docsDir, file).replaceAll(path.sep, "/");
const raw = fs.readFileSync(file, "utf8");
const { frontmatter, body } = parseFrontmatter(raw);
const cleaned = stripStrayDirectives(body);
const title = frontmatter.title || firstHeading(cleaned) || titleize(path.basename(rel, ".md"));
return { file, rel, title, outRel: outPath(rel, frontmatter), markdown: cleaned, frontmatter };
});
const pages = allPages.filter((page) => !buildExcludes.some((re) => re.test(page.rel)));
const pageMap = new Map(pages.map((page) => [page.rel, page]));
const permalinkMap = new Map();
for (const page of pages) {
if (page.frontmatter.permalink) {
permalinkMap.set(normalizePermalink(page.frontmatter.permalink), page);
}
}
const nav = sections
.map(([name, rels]) => ({
name,
pages: rels.map((rel) => pageMap.get(rel)).filter(Boolean),
}))
.filter((section) => section.pages.length);
const sectionByRel = new Map();
for (const section of nav) {
for (const page of section.pages) {
sectionByRel.set(page.rel, section.name);
}
}
const orderedPages = nav.flatMap((s) => s.pages);
for (const page of pages) {
const html = markdownToHtml(page.markdown, page.rel);
const toc = tocFromHtml(html);
const idx = orderedPages.findIndex((p) => p.rel === page.rel);
const prev = idx > 0 ? orderedPages[idx - 1] : null;
const next = idx >= 0 && idx < orderedPages.length - 1 ? orderedPages[idx + 1] : null;
const sectionName = sectionByRel.get(page.rel) || "Reference";
const pageOut = path.join(outDir, page.outRel);
fs.mkdirSync(path.dirname(pageOut), { recursive: true });
fs.writeFileSync(pageOut, layout({ page, html, toc, prev, next, sectionName }), "utf8");
}
fs.writeFileSync(path.join(outDir, "favicon.svg"), faviconSvg(), "utf8");
fs.writeFileSync(path.join(outDir, ".nojekyll"), "", "utf8");
if (cname) {
fs.writeFileSync(path.join(outDir, "CNAME"), cname, "utf8");
}
validateLinks(outDir);
console.log(`built docs site: ${path.relative(root, outDir)}`);
function readCname() {
for (const candidate of [path.join(docsDir, "CNAME"), path.join(root, "CNAME")]) {
if (fs.existsSync(candidate)) {
return fs.readFileSync(candidate, "utf8").trim();
}
}
return "";
}
function parseFrontmatter(raw) {
const match = raw.match(/^---\n([\s\S]*?)\n---\n?/);
if (!match) {
return { frontmatter: {}, body: raw };
}
const fm = {};
for (const line of match[1].split("\n")) {
const m = line.match(/^([A-Za-z0-9_-]+):\s*(.*?)\s*$/);
if (!m) {
continue;
}
let value = m[2];
if (
(value.startsWith('"') && value.endsWith('"')) ||
(value.startsWith("'") && value.endsWith("'"))
) {
value = value.slice(1, -1);
}
fm[m[1]] = value;
}
return { frontmatter: fm, body: raw.slice(match[0].length) };
}
function stripStrayDirectives(body) {
return body
.replace(/\r\n/g, "\n")
.split("\n")
.filter((line) => !/^\s*\{:\s*[^}]*\}\s*$/.test(line))
.map((line) => line.replace(/\s*\{:\s*[^}]*\}\s*$/, ""))
.join("\n");
}
function normalizePermalink(value) {
let v = value.trim();
if (!v) {
return "/";
}
if (!v.startsWith("/")) {
v = `/${v}`;
}
if (v.length > 1 && v.endsWith("/")) {
v = v.slice(0, -1);
}
return v;
}
function allMarkdown(dir) {
return fs
.readdirSync(dir, { withFileTypes: true })
.flatMap((entry) => {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) {
return allMarkdown(full);
}
return entry.name.endsWith(".md") ? [full] : [];
})
.toSorted((a, b) => a.localeCompare(b));
}
function outPath(rel, frontmatter = {}) {
if (frontmatter.permalink) {
const permalink = normalizePermalink(frontmatter.permalink);
if (permalink === "/") {
return "index.html";
}
return `${permalink.slice(1)}/index.html`;
}
if (rel === "index.md") {
return "index.html";
}
if (rel === "README.md") {
return "index.html";
}
if (rel.endsWith("/README.md")) {
return rel.replace(/README\.md$/, "index.html");
}
return rel.replace(/\.md$/, ".html");
}
function firstHeading(markdown) {
return markdown.match(/^#\s+(.+)$/m)?.[1]?.trim();
}
function titleize(input) {
return input.replaceAll("-", " ").replace(/\b\w/g, (m) => m.toUpperCase());
}
function markdownToHtml(markdown, currentRel) {
const lines = markdown.replace(/\r\n/g, "\n").split("\n");
const html = [];
let paragraph = [];
let list = null;
let fence = null;
let blockquote = [];
const flushParagraph = () => {
if (!paragraph.length) {
return;
}
html.push(`<p>${inline(paragraph.join(" "), currentRel)}</p>`);
paragraph = [];
};
const closeList = () => {
if (!list) {
return;
}
html.push(`</${list}>`);
list = null;
};
const flushBlockquote = () => {
if (!blockquote.length) {
return;
}
const inner = markdownToHtml(blockquote.join("\n"), currentRel);
html.push(`<blockquote>${inner}</blockquote>`);
blockquote = [];
};
const splitRow = (line) => {
let trimmed = line.trim();
if (trimmed.startsWith("|")) {
trimmed = trimmed.slice(1);
}
if (trimmed.endsWith("|") && !trimmed.endsWith("\\|")) {
trimmed = trimmed.slice(0, -1);
}
const cells = [];
let current = "";
for (let idx = 0; idx < trimmed.length; idx++) {
const char = trimmed[idx];
if (char === "\\" && trimmed[idx + 1] === "|") {
current += "\\|";
idx += 1;
continue;
}
if (char === "|") {
cells.push(current.trim().replace(/\\\|/g, "|"));
current = "";
continue;
}
current += char;
}
cells.push(current.trim().replace(/\\\|/g, "|"));
return cells;
};
const isDivider = (line) => /^\s*\|?\s*:?-{2,}:?\s*(\|\s*:?-{2,}:?\s*)+\|?\s*$/.test(line);
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
const fenceMatch = line.match(/^```([\w+-]+)?\s*$/);
if (fenceMatch) {
flushParagraph();
closeList();
flushBlockquote();
if (fence) {
html.push(
`<pre><code class="language-${escapeAttr(fence.lang)}">${highlight(fence.lines.join("\n"), fence.lang)}</code></pre>`,
);
fence = null;
} else {
fence = { lang: fenceMatch[1] || "text", lines: [] };
}
continue;
}
if (fence) {
fence.lines.push(line);
continue;
}
if (/^>\s?/.test(line)) {
flushParagraph();
closeList();
blockquote.push(line.replace(/^>\s?/, ""));
continue;
}
flushBlockquote();
if (!line.trim()) {
flushParagraph();
closeList();
continue;
}
if (/^\s*---+\s*$/.test(line)) {
flushParagraph();
closeList();
html.push("<hr>");
continue;
}
const heading = line.match(/^(#{1,4})\s+(.+)$/);
if (heading) {
flushParagraph();
closeList();
const level = heading[1].length;
const text = heading[2].trim();
const id = slug(text);
const inner = inline(text, currentRel);
if (level === 1) {
html.push(`<h1 id="${id}">${inner}</h1>`);
} else {
html.push(
`<h${level} id="${id}"><a class="anchor" href="#${id}" aria-label="Anchor link">#</a>${inner}</h${level}>`,
);
}
continue;
}
if (
line.trimStart().startsWith("|") &&
line.includes("|", line.indexOf("|") + 1) &&
isDivider(lines[i + 1] || "")
) {
flushParagraph();
closeList();
const header = splitRow(line);
const aligns = splitRow(lines[i + 1]).map((cell) => {
const left = cell.startsWith(":");
const right = cell.endsWith(":");
return right && left ? "center" : right ? "right" : left ? "left" : "";
});
i += 1;
const rows = [];
while (i + 1 < lines.length && lines[i + 1].trimStart().startsWith("|")) {
i += 1;
rows.push(splitRow(lines[i]));
}
const th = header
.map(
(c, idx) =>
`<th${aligns[idx] ? ` style="text-align:${aligns[idx]}"` : ""}>${inline(c, currentRel)}</th>`,
)
.join("");
const tb = rows
.map(
(r) =>
`<tr>${r.map((c, idx) => `<td${aligns[idx] ? ` style="text-align:${aligns[idx]}"` : ""}>${inline(c, currentRel)}</td>`).join("")}</tr>`,
)
.join("");
html.push(`<table><thead><tr>${th}</tr></thead><tbody>${tb}</tbody></table>`);
continue;
}
const bullet = line.match(/^\s*-\s+(.+)$/);
const numbered = line.match(/^\s*\d+\.\s+(.+)$/);
if (bullet || numbered) {
flushParagraph();
const tag = bullet ? "ul" : "ol";
if (list && list !== tag) {
closeList();
}
if (!list) {
list = tag;
html.push(`<${tag}>`);
}
html.push(`<li>${inline((bullet || numbered)[1], currentRel)}</li>`);
continue;
}
paragraph.push(line.trim());
}
flushParagraph();
closeList();
flushBlockquote();
return html.join("\n");
}
function inline(text, currentRel) {
const stash = [];
let out = text.replace(/`([^`]+)`/g, (_, code) => {
stash.push(`<code>${escapeHtml(code)}</code>`);
return `@@ACPXCODE${stash.length - 1}@@`;
});
out = escapeHtml(out)
.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>")
.replace(/(^|[^*])\*([^*\s][^*]*?)\*(?!\*)/g, "$1<em>$2</em>")
.replace(/(^|[^_])_([^_\s][^_]*?)_(?!_)/g, "$1<em>$2</em>")
.replace(
/\[([^\]]+)\]\(([^)]+)\)/g,
(_, label, href) => `<a href="${escapeAttr(rewriteHref(href, currentRel))}">${label}</a>`,
)
.replace(/&lt;(https?:\/\/[^\s<>]+)&gt;/g, '<a href="$1">$1</a>');
out = out.replace(/\\\|/g, "|");
out = out.replace(/&lt;br&gt;/g, "<br>");
return out.replace(/@@ACPXCODE(\d+)@@/g, (_, i) => stash[Number(i)]);
}
function rewriteHref(href, currentRel) {
if (/^(https?:|mailto:|tel:|#)/.test(href)) {
return href;
}
const [raw, hash = ""] = href.split("#");
if (!raw) {
return hash ? `#${hash}` : "";
}
if (raw.startsWith("/")) {
const target = permalinkMap.get(normalizePermalink(raw));
if (target) {
const currentOut = pageMap.get(currentRel)?.outRel || outPath(currentRel);
const out = hrefToOutRel(target.outRel, currentOut);
return hash ? `${out}#${hash}` : out;
}
return href;
}
if (!raw.endsWith(".md")) {
return href;
}
const from = path.posix.dirname(currentRel);
const target = path.posix.normalize(path.posix.join(from, raw));
let rewritten = pageMap.get(target)?.outRel || outPath(target);
const currentOut = pageMap.get(currentRel)?.outRel || outPath(currentRel);
rewritten = hrefToOutRel(rewritten, currentOut);
return `${rewritten}${hash ? `#${hash}` : ""}`;
}
function tocFromHtml(html) {
const items = [];
const re = /<h([23]) id="([^"]+)">([\s\S]*?)<\/h[23]>/g;
let m;
while ((m = re.exec(html))) {
const text = m[3]
.replace(/<a class="anchor"[^>]*>.*?<\/a>/, "")
.replace(/<[^>]+>/g, "")
.trim();
items.push({ level: Number(m[1]), id: m[2], text });
}
if (items.length < 2) {
return "";
}
return `<nav class="toc" aria-label="On this page"><h2>On this page</h2>${items
.map((i) => `<a class="toc-l${i.level}" href="#${i.id}">${escapeHtml(i.text)}</a>`)
.join("")}</nav>`;
}
function isHomePage(page) {
if (page.frontmatter.permalink && normalizePermalink(page.frontmatter.permalink) === "/") {
return true;
}
return page.rel === "index.md" || page.rel === "README.md";
}
function homeHero(page) {
const description = page.frontmatter.description || productDescription;
const installRel = pageMap.get("install.md")?.outRel
? hrefToOutRel(pageMap.get("install.md").outRel, page.outRel)
: "install.html";
const quickstartRel = pageMap.get("quickstart.md")?.outRel
? hrefToOutRel(pageMap.get("quickstart.md").outRel, page.outRel)
: "quickstart.html";
const agents = [
"codex",
"claude",
"pi",
"openclaw",
"gemini",
"cursor",
"copilot",
"droid",
"qwen",
"qoder",
"opencode",
"kimi",
];
return `<header class="home-hero">
<p class="eyebrow"><span class="dot" aria-hidden="true"></span> Agent Client Protocol · Headless CLI</p>
<h1>Talk to agents <span class="accent">from the command line</span></h1>
<p class="lede">${escapeHtml(description)}</p>
<div class="home-cta">
<a class="btn btn-primary" href="${quickstartRel}">Quickstart</a>
<a class="btn btn-ghost" href="${repoBase}" rel="noopener">GitHub</a>
<div class="home-install" aria-label="Install with npm">
<span class="prompt" aria-hidden="true">$</span>
<code>${escapeHtml(installCommand)}</code>
</div>
</div>
<div class="home-services" aria-label="Built-in agents">
${agents.map((s) => `<span>${escapeHtml(s)}</span>`).join("")}
</div>
<p class="muted"><a href="${installRel}">Install options </a></p>
</header>`;
}
function standardHero(page, sectionName, editUrl) {
return `<header class="hero">
<div class="hero-text">
<p class="eyebrow">${escapeHtml(sectionName)}</p>
<h1>${escapeHtml(page.title)}</h1>
</div>
<div class="hero-meta">
<a class="repo" href="${repoBase}" rel="noopener">GitHub</a>
<a class="edit" href="${escapeAttr(editUrl)}" rel="noopener">Edit page</a>
</div>
</header>`;
}
function layout({ page, html, toc, prev, next, sectionName }) {
const depth = page.outRel.split("/").length - 1;
const rootPrefix = depth ? "../".repeat(depth) : "";
const editUrl = `${repoEditBase}/${page.rel}`;
const home = isHomePage(page);
const prevNext = !home && (prev || next) ? pageNavHtml(prev, next, page.outRel) : "";
const heroBlock = home ? homeHero(page) : standardHero(page, sectionName, editUrl);
const articleClass = home ? "doc doc-home" : "doc";
const tocBlock = home ? "" : toc;
const titleSuffix = home
? `${productName}${productTagline}`
: `${page.title}${productName}`;
const description =
page.frontmatter.description ||
(home ? productDescription : `${page.title}${productName} CLI documentation.`);
const canonicalUrl = pageCanonicalUrl(page);
const socialMeta = [
["link", "rel", "canonical", "href", canonicalUrl],
["meta", "property", "og:type", "content", "website"],
["meta", "property", "og:site_name", "content", productName],
["meta", "property", "og:title", "content", titleSuffix],
["meta", "property", "og:description", "content", description],
["meta", "property", "og:url", "content", canonicalUrl],
["meta", "name", "twitter:card", "content", "summary_large_image"],
["meta", "name", "twitter:title", "content", titleSuffix],
["meta", "name", "twitter:description", "content", description],
]
.map(tagHtml)
.join("\n ");
return `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>${escapeHtml(titleSuffix)}</title>
<meta name="description" content="${escapeAttr(description)}">
${socialMeta}
<link rel="icon" href="${rootPrefix}favicon.svg" type="image/svg+xml">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
<script>${preThemeScript()}</script>
<style>${css()}</style>
</head>
<body${home ? ' class="home"' : ""}>
<button class="nav-toggle" type="button" aria-label="Toggle navigation" aria-expanded="false">
<span aria-hidden="true"></span><span aria-hidden="true"></span><span aria-hidden="true"></span>
</button>
<div class="shell">
<aside class="sidebar">
<div class="sidebar-head">
<a class="brand" href="${hrefToOutRel("index.html", page.outRel)}" aria-label="${productName} docs home">
${brandMarkHtml()}
<span><strong>${escapeHtml(productName)}</strong><small>ACP CLI docs</small></span>
</a>
${themeToggleHtml()}
</div>
<label class="search"><span>Search</span><input id="doc-search" type="search" placeholder="sessions, flows, json"></label>
<nav>${navHtml(page)}</nav>
</aside>
<main>
${heroBlock}
<div class="doc-grid${home ? " doc-grid-home" : ""}">
<article class="${articleClass}">${html}${prevNext}</article>
${tocBlock}
</div>
</main>
</div>
<script>${js()}</script>
</body>
</html>`;
}
function pageCanonicalUrl(page) {
if (!siteBase) {
return page.outRel;
}
if (page.outRel === "index.html") {
return `${siteBase}/`;
}
const rel = page.outRel.endsWith("/index.html")
? page.outRel.slice(0, -"index.html".length)
: page.outRel;
return `${siteBase}/${rel}`;
}
function tagHtml([tag, k1, v1, k2, v2]) {
return tag === "link"
? `<link ${k1}="${v1}" ${k2}="${escapeAttr(v2)}">`
: `<meta ${k1}="${v1}" ${k2}="${escapeAttr(v2)}">`;
}
function pageNavHtml(prev, next, currentOutRel) {
const cell = (page, dir) => {
if (!page) {
return "";
}
return `<a class="page-nav-${dir}" href="${hrefToOutRel(page.outRel, currentOutRel)}"><small>${dir === "prev" ? "Previous" : "Next"}</small><span>${escapeHtml(page.title)}</span></a>`;
};
return `<nav class="page-nav" aria-label="Pager">${cell(prev, "prev")}${cell(next, "next")}</nav>`;
}
function navHtml(currentPage) {
return nav
.map(
(section) =>
`<section><h2>${escapeHtml(section.name)}</h2>${section.pages
.map((page) => {
const href = hrefToOutRel(page.outRel, currentPage.outRel);
const active = page.rel === currentPage.rel ? " active" : "";
return `<a class="nav-link${active}" href="${href}">${escapeHtml(navTitle(page))}</a>`;
})
.join("")}</section>`,
)
.join("");
}
function navTitle(page) {
if (page.rel === "index.md") {
return "Overview";
}
if (page.rel === "CLI.md") {
return "CLI Reference";
}
if (page.rel === "VISION.md") {
return "Vision";
}
return page.title.replace(/^`acpx\s*/, "").replace(/`$/, "");
}
function hrefToOutRel(targetOutRel, currentOutRel) {
const currentDir = path.posix.dirname(currentOutRel);
if (targetOutRel.endsWith("/index.html")) {
const targetDir = targetOutRel.slice(0, -"index.html".length);
const rel = path.posix.relative(currentDir, targetDir || ".") || ".";
return rel.endsWith("/") ? rel : `${rel}/`;
}
if (targetOutRel === "index.html") {
const rel = path.posix.relative(currentDir, ".") || ".";
return rel.endsWith("/") ? rel : `${rel}/`;
}
return path.posix.relative(currentDir, targetOutRel) || path.posix.basename(targetOutRel);
}
function slug(text) {
return text
.toLowerCase()
.replace(/`/g, "")
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-|-$/g, "");
}
function escapeHtml(value) {
return String(value ?? "").replace(
/[&<>"']/g,
(char) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[char],
);
}
function escapeAttr(value) {
return escapeHtml(value);
}
function highlight(code, lang) {
const resolved = HIGHLIGHT_ALIASES[lang] || lang;
const rules = HIGHLIGHT_RULES[resolved];
if (!rules) {
return escapeHtml(code);
}
let out = "";
let i = 0;
while (i < code.length) {
let bestKind = null;
let bestText = null;
for (const [re, kind] of rules) {
re.lastIndex = i;
const m = re.exec(code);
if (m && m.index === i) {
bestKind = kind;
bestText = m[0];
break;
}
}
if (bestText !== null) {
out += `<span class="hl-${bestKind}">${escapeHtml(bestText)}</span>`;
i += bestText.length;
} else {
out += escapeHtml(code[i]);
i += 1;
}
}
return out;
}
function validateLinks(outputDir) {
const failures = [];
const placeholderHrefs = /^(url|path|file|dir|name)$/i;
for (const file of allHtml(outputDir)) {
const html = fs.readFileSync(file, "utf8");
for (const match of html.matchAll(/href="([^"]+)"/g)) {
const href = match[1];
if (/^(#|https?:|mailto:|tel:|javascript:)/.test(href)) {
continue;
}
if (placeholderHrefs.test(href)) {
continue;
}
const [rawPath, anchor = ""] = href.split("#");
const targetPath = rawPath ? path.resolve(path.dirname(file), rawPath) : file;
const target =
fs.existsSync(targetPath) && fs.statSync(targetPath).isDirectory()
? path.join(targetPath, "index.html")
: targetPath;
if (!fs.existsSync(target)) {
failures.push(
`${path.relative(outputDir, file)}: ${href} -> missing ${path.relative(outputDir, target)}`,
);
continue;
}
if (anchor) {
const targetHtml = fs.readFileSync(target, "utf8");
if (!targetHtml.includes(`id="${anchor}"`) && !targetHtml.includes(`name="${anchor}"`)) {
failures.push(`${path.relative(outputDir, file)}: ${href} -> missing anchor`);
}
}
}
}
if (failures.length) {
throw new Error(`broken docs links:\n${failures.join("\n")}`);
}
}
function allHtml(dir) {
return fs
.readdirSync(dir, { withFileTypes: true })
.flatMap((entry) => {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) {
return allHtml(full);
}
return entry.name.endsWith(".html") ? [full] : [];
})
.toSorted((a, b) => a.localeCompare(b));
}

View File

@ -1,301 +0,0 @@
export function css() {
return `
:root{
--ink:#0b0e14;
--text:#1f2530;
--muted:#6a7282;
--subtle:#9aa1ab;
--bg:#f7f8fa;
--paper:#ffffff;
--accent:#0ea5e9;
--accent-soft:rgba(14,165,233,.10);
--accent-strong:#0284c7;
--accent-2:#a855f7;
--accent-3:#22c55e;
--accent-4:#f59e0b;
--line:#e5e7eb;
--line-soft:#eef0f3;
--code-bg:#0a0d14;
--code-fg:#e6edf3;
--code-inline-fg:#1c2128;
--pill-border:#dbe2eb;
--shadow-card:0 4px 14px rgba(15,17,21,.08);
--scrollbar:#cbd5e1;
}
:root[data-theme="dark"]{
--ink:#f3f5f9;
--text:#cdd3dd;
--muted:#8d96a4;
--subtle:#5d6371;
--bg:#08090f;
--paper:#13161f;
--accent:#38bdf8;
--accent-soft:rgba(56,189,248,.18);
--accent-strong:#7dd3fc;
--line:#23283a;
--line-soft:#1a1d28;
--code-bg:#040611;
--code-fg:#e6edf3;
--code-inline-fg:#e6edf3;
--pill-border:#2a2f3c;
--shadow-card:0 4px 18px rgba(0,0,0,.45);
--scrollbar:#3a4154;
}
:root{color-scheme:light}
:root[data-theme="dark"]{color-scheme:dark}
*{box-sizing:border-box}
html{scroll-behavior:smooth;scroll-padding-top:24px}
body{margin:0;background:var(--bg);color:var(--text);font-family:"Inter",ui-sans-serif,system-ui,-apple-system,Segoe UI,sans-serif;line-height:1.65;overflow-x:hidden;-webkit-font-smoothing:antialiased;font-feature-settings:"cv02","cv03","cv04","cv11";transition:background-color .18s,color .18s}
::selection{background:var(--accent);color:#04121d}
a{color:var(--accent);text-decoration:none;transition:color .12s}
a:hover{text-decoration:underline;text-underline-offset:.2em}
.shell{display:grid;grid-template-columns:268px minmax(0,1fr);min-height:100vh}
.sidebar{position:sticky;top:0;height:100vh;overflow:auto;padding:24px 22px;background:var(--paper);border-right:1px solid var(--line);scrollbar-width:thin;scrollbar-color:var(--line) transparent;transition:background-color .18s,border-color .18s}
.sidebar::-webkit-scrollbar{width:6px}
.sidebar::-webkit-scrollbar-thumb{background:var(--line);border-radius:6px}
.sidebar-head{display:flex;align-items:center;gap:10px;margin-bottom:24px}
.brand{display:flex;align-items:center;gap:11px;color:var(--ink);text-decoration:none;flex:1;min-width:0}
.brand:hover{text-decoration:none}
.brand .mark{flex:0 0 34px;width:34px;height:34px;border-radius:8px;background:linear-gradient(145deg,#08111f 0%,#101827 58%,#172554 100%);position:relative;overflow:hidden;display:grid;place-items:center;box-shadow:0 1px 0 rgba(255,255,255,.08) inset,0 10px 24px -13px rgba(14,165,233,.7)}
.brand .mark::before{content:"";position:absolute;inset:0;background:linear-gradient(135deg,rgba(125,211,252,.22),transparent 42%),linear-gradient(315deg,rgba(167,139,250,.2),transparent 48%);pointer-events:none}
.brand .mark::after{content:"";position:absolute;inset:1px;border-radius:7px;border:1px solid rgba(255,255,255,.1);pointer-events:none}
.brand .mark svg{position:relative;z-index:1;width:23px;height:23px;display:block}
.brand .mark .cursor{transform-origin:center;animation:acpx-blink 1.2s steps(2,jump-none) infinite}
@keyframes acpx-blink{0%,49%{opacity:1}50%,100%{opacity:.25}}
@media (prefers-reduced-motion: reduce){.brand .mark .cursor{animation:none}}
.brand strong{display:block;font-size:1.05rem;line-height:1.1;font-weight:600;letter-spacing:0;color:var(--ink)}
.brand small{display:block;color:var(--muted);font-size:.74rem;margin-top:3px;font-weight:400}
.theme-toggle{display:inline-flex;align-items:center;justify-content:center;flex:0 0 auto;width:34px;height:34px;border-radius:8px;border:1px solid var(--line);background:var(--paper);color:var(--muted);cursor:pointer;padding:0;transition:border-color .15s,color .15s,background-color .15s,transform .12s}
.theme-toggle:hover{border-color:var(--ink);color:var(--ink)}
.theme-toggle:active{transform:scale(.94)}
.theme-toggle svg{width:16px;height:16px;display:block}
.theme-icon-sun{display:none}
:root[data-theme="dark"] .theme-icon-sun{display:block}
:root[data-theme="dark"] .theme-icon-moon{display:none}
.search{display:block;margin:0 0 22px}
.search span{display:block;color:var(--muted);font-size:.7rem;font-weight:600;text-transform:uppercase;letter-spacing:0;margin-bottom:7px}
.search input{width:100%;border:1px solid var(--line);background:var(--paper);border-radius:8px;padding:9px 12px;font:inherit;font-size:.9rem;color:var(--text);outline:none;transition:border-color .15s,box-shadow .15s,background-color .18s}
.search input:focus{border-color:var(--accent);box-shadow:0 0 0 3px var(--accent-soft)}
nav section{margin:0 0 18px}
nav h2{font-size:.68rem;color:var(--muted);text-transform:uppercase;letter-spacing:0;margin:0 0 6px;font-weight:600}
.nav-link{display:block;color:var(--text);text-decoration:none;border-radius:6px;padding:5px 10px;margin:1px 0;font-size:.9rem;line-height:1.4;transition:background .12s,color .12s}
.nav-link:hover{background:var(--line-soft);color:var(--ink);text-decoration:none}
.nav-link.active{background:var(--accent-soft);color:var(--accent);font-weight:600}
main{min-width:0;padding:32px clamp(20px,4.5vw,56px) 80px;max-width:1180px;margin:0 auto;width:100%}
.hero{display:flex;align-items:flex-end;justify-content:space-between;gap:22px;border-bottom:1px solid var(--line);padding:8px 0 22px;margin-bottom:8px;flex-wrap:wrap}
.hero-text{min-width:0;flex:1 1 320px}
.eyebrow{margin:0 0 8px;color:var(--muted);font-weight:600;text-transform:uppercase;letter-spacing:0;font-size:.7rem}
.hero h1{font-size:2.25rem;line-height:1.1;letter-spacing:-.01em;margin:0;font-weight:700;color:var(--ink)}
.hero-meta{display:flex;gap:8px;flex:0 0 auto;flex-wrap:wrap}
.repo,.edit,.btn-ghost{border:1px solid var(--line);color:var(--text);text-decoration:none;border-radius:7px;padding:6px 11px;font-weight:500;font-size:.83rem;background:var(--paper);transition:border-color .15s,color .15s,background .15s}
.repo:hover,.edit:hover,.btn-ghost:hover{border-color:var(--ink);color:var(--ink);text-decoration:none}
.edit{color:var(--muted)}
.home-hero{padding:14px 0 28px;margin-bottom:8px;border-bottom:1px solid var(--line)}
.home-hero .eyebrow{display:inline-flex;align-items:center;gap:8px}
.home-hero .eyebrow .dot{width:7px;height:7px;border-radius:50%;background:var(--accent-3);box-shadow:0 0 0 3px rgba(34,197,94,.2)}
.home-hero h1{font-size:3.25rem;line-height:1.04;letter-spacing:-.015em;margin:0 0 .35em;font-weight:700;color:var(--ink)}
.home-hero h1 .accent{background:linear-gradient(110deg,var(--accent) 0%,var(--accent-2) 70%);-webkit-background-clip:text;background-clip:text;color:transparent}
.home-hero .lede{font-size:1.18rem;line-height:1.55;color:var(--text);margin:0 0 1.2em;max-width:60ch}
.home-cta{display:flex;flex-wrap:wrap;gap:10px;align-items:center;margin:0 0 18px}
.home-cta .btn{display:inline-flex;align-items:center;gap:7px;border-radius:8px;padding:10px 16px;font-weight:600;font-size:.92rem;text-decoration:none;transition:background .15s,border-color .15s,color .15s,transform .12s}
.home-cta .btn-primary{background:var(--ink);color:var(--paper);border:1px solid var(--ink)}
.home-cta .btn-primary:hover{background:var(--accent);border-color:var(--accent);color:#04121d;text-decoration:none}
.home-cta .btn-ghost{padding:10px 16px}
.home-install{display:flex;align-items:center;gap:12px;background:var(--code-bg);color:var(--code-fg);border-radius:8px;padding:10px 10px 10px 16px;font:500 .9rem/1.2 "JetBrains Mono","SF Mono",ui-monospace,monospace;max-width:32em;border:1px solid #1f2937}
.home-install .prompt{color:#7dd3fc;user-select:none;flex:0 0 auto}
.home-install code{flex:1;background:transparent;border:0;color:var(--code-fg);font:inherit;padding:0;white-space:pre;overflow:hidden;text-overflow:ellipsis}
.home-install .copy{flex:0 0 auto;background:rgba(255,255,255,.08);color:var(--code-fg);border:1px solid rgba(255,255,255,.16);border-radius:6px;padding:5px 11px;font:500 .72rem/1 "Inter",sans-serif;cursor:pointer;transition:background .15s,border-color .15s}
.home-install .copy:hover{background:rgba(255,255,255,.16)}
.home-install .copy.copied{background:var(--accent);border-color:var(--accent);color:#04121d}
.home-services{display:flex;flex-wrap:wrap;gap:6px;margin:6px 0 18px}
.home-services span{display:inline-block;padding:3px 9px;border:1px solid var(--line);border-radius:999px;font-size:.78rem;color:var(--muted);background:var(--paper);font-family:"JetBrains Mono","SF Mono",ui-monospace,monospace}
.doc-grid{display:grid;grid-template-columns:minmax(0,1fr);gap:48px;margin-top:24px}
.doc-grid-home{margin-top:8px}
@media(min-width:1180px){.doc-grid{grid-template-columns:minmax(0,72ch) 200px;justify-content:start}.doc-grid-home{grid-template-columns:minmax(0,76ch);justify-content:start}}
.doc{min-width:0;max-width:72ch;overflow-wrap:break-word}
.doc-home{max-width:76ch}
.doc h1{font-size:2.6rem;line-height:1.08;letter-spacing:-.015em;margin:0 0 .4em;font-weight:700;color:var(--ink)}
body:not(.home) .doc>h1:first-child{display:none}
.doc h2{font-size:1.45rem;line-height:1.2;margin:2em 0 .5em;font-weight:600;letter-spacing:-.005em;color:var(--ink);position:relative}
.doc h3{font-size:1.1rem;margin:1.7em 0 .35em;position:relative;font-weight:600;color:var(--ink);letter-spacing:0}
.doc h4{font-size:.98rem;margin:1.4em 0 .25em;color:var(--ink);position:relative;font-weight:600}
.doc h2:first-child,.doc h3:first-child,.doc h4:first-child{margin-top:.2em}
.doc :is(h2,h3,h4) .anchor{position:absolute;left:-1.05em;top:0;color:var(--subtle);opacity:0;text-decoration:none;font-weight:400;padding-right:.3em;transition:opacity .12s,color .12s}
.doc :is(h2,h3,h4):hover .anchor{opacity:.7}
.doc :is(h2,h3,h4) .anchor:hover{opacity:1;color:var(--accent);text-decoration:none}
.doc p{margin:0 0 1.05em}
.doc ul,.doc ol{padding-left:1.3rem;margin:0 0 1.15em}
.doc li{margin:.25em 0}
.doc li>p{margin:0 0 .4em}
.doc strong{font-weight:600;color:var(--ink)}
.doc em{font-style:italic}
.doc code{font-family:"JetBrains Mono","SF Mono",ui-monospace,monospace;font-size:.84em;background:var(--line-soft);border:1px solid var(--line);border-radius:5px;padding:.08em .35em;color:var(--code-inline-fg)}
.doc pre{position:relative;overflow:auto;background:var(--code-bg);color:var(--code-fg);border-radius:8px;padding:14px 18px;margin:1.3em 0;font-size:.85em;line-height:1.6;scrollbar-width:thin;scrollbar-color:#334155 transparent;border:1px solid #1f2937}
.doc pre::-webkit-scrollbar{height:8px;width:8px}
.doc pre::-webkit-scrollbar-thumb{background:#334155;border-radius:8px}
.doc pre code{display:block;background:transparent;border:0;color:inherit;padding:0;font-size:1em;white-space:pre}
.doc pre .hl-com{color:#7a8597;font-style:italic}
.doc pre .hl-str{color:#86efac}
.doc pre .hl-num{color:#fbbf24}
.doc pre .hl-kw{color:#c4b5fd;font-weight:500}
.doc pre .hl-lit{color:#f0abfc}
.doc pre .hl-flag{color:#7dd3fc}
.doc pre .hl-var{color:#fca5a5}
.doc pre .hl-prop{color:#7dd3fc}
.doc pre .hl-op{color:#94a3b8}
.doc pre .hl-typ{color:#fde68a}
.doc pre .copy{position:absolute;top:8px;right:8px;background:rgba(255,255,255,.06);color:var(--code-fg);border:1px solid rgba(255,255,255,.16);border-radius:6px;padding:3px 9px;font:500 .7rem/1 "Inter",sans-serif;cursor:pointer;opacity:0;transition:opacity .15s,background .15s,border-color .15s}
.doc pre:hover .copy,.doc pre .copy:focus{opacity:1}
.doc pre .copy:hover{background:rgba(255,255,255,.12)}
.doc pre .copy.copied{background:var(--accent);border-color:var(--accent);color:#04121d;opacity:1}
.doc blockquote{margin:1.4em 0;padding:10px 16px;border-left:3px solid var(--accent);background:var(--accent-soft);border-radius:0 8px 8px 0;color:var(--text)}
.doc blockquote p:last-child{margin-bottom:0}
.doc table{width:100%;border-collapse:collapse;margin:1.2em 0;font-size:.92em}
.doc th,.doc td{border-bottom:1px solid var(--line);padding:9px 10px;text-align:left;vertical-align:top}
.doc th{font-weight:600;color:var(--ink);background:var(--line-soft);border-bottom:1px solid var(--line)}
.doc hr{border:0;border-top:1px solid var(--line);margin:2.2em 0}
.toc{position:sticky;top:24px;align-self:start;font-size:.84rem;padding-left:14px;border-left:1px solid var(--line);max-height:calc(100vh - 48px);overflow:auto;scrollbar-width:thin;scrollbar-color:var(--line) transparent}
.toc::-webkit-scrollbar{width:5px}
.toc::-webkit-scrollbar-thumb{background:var(--line);border-radius:5px}
.toc h2{font-size:.66rem;color:var(--muted);text-transform:uppercase;letter-spacing:0;margin:0 0 10px;font-weight:600}
.toc a{display:block;color:var(--muted);text-decoration:none;padding:4px 0 4px 10px;line-height:1.35;border-left:2px solid transparent;margin-left:-12px;transition:color .12s,border-color .12s}
.toc a:hover{color:var(--ink);text-decoration:none}
.toc a.active{color:var(--accent);border-left-color:var(--accent);font-weight:500}
.toc-l3{padding-left:22px!important;font-size:.94em}
@media(max-width:1179px){.toc{display:none}}
.page-nav{display:grid;grid-template-columns:1fr 1fr;gap:14px;margin-top:48px;border-top:1px solid var(--line);padding-top:20px}
.page-nav>a{display:block;border:1px solid var(--line);background:var(--paper);border-radius:9px;padding:13px 16px;text-decoration:none;color:var(--text);transition:border-color .15s,transform .15s,box-shadow .15s,background-color .18s}
.page-nav>a:hover{border-color:var(--accent);text-decoration:none;color:var(--ink)}
.page-nav small{display:block;color:var(--muted);font-size:.7rem;text-transform:uppercase;letter-spacing:0;margin-bottom:5px;font-weight:600}
.page-nav span{display:block;font-weight:600;line-height:1.3;color:var(--ink)}
.page-nav-prev{text-align:left}
.page-nav-next{text-align:right;grid-column:2}
.page-nav-prev:only-child{grid-column:1}
.nav-toggle{display:none;position:fixed;top:14px;right:14px;top:calc(14px + env(safe-area-inset-top, 0px));right:calc(14px + env(safe-area-inset-right, 0px));z-index:20;width:40px;height:40px;border-radius:9px;background:var(--paper);border:1px solid var(--line);color:var(--ink);cursor:pointer;padding:10px 9px;flex-direction:column;align-items:stretch;justify-content:space-between;box-shadow:var(--shadow-card)}
.nav-toggle span{display:block;width:100%;height:2px;flex:0 0 2px;background:currentColor;border-radius:2px;transition:transform .2s,opacity .2s}
.nav-toggle[aria-expanded="true"] span:nth-child(1){transform:translateY(8px) rotate(45deg)}
.nav-toggle[aria-expanded="true"] span:nth-child(2){opacity:0}
.nav-toggle[aria-expanded="true"] span:nth-child(3){transform:translateY(-8px) rotate(-45deg)}
@media(max-width:900px){
.shell{display:block}
.sidebar{position:fixed;inset:0 30% 0 0;max-width:320px;height:100vh;z-index:15;transform:translateX(-100%);transition:transform .25s ease,background-color .18s,border-color .18s;box-shadow:0 18px 40px rgba(0,0,0,.18);background:var(--paper);pointer-events:none}
.sidebar.open{transform:translateX(0);pointer-events:auto}
.nav-toggle{display:flex}
main{padding:64px 18px 56px}
.hero{padding-top:6px}
.hero h1{font-size:1.8rem}
.home-hero h1{font-size:2.45rem}
.doc h1{font-size:2.1rem}
.hero-meta{width:100%;justify-content:flex-start}
.home-hero{padding-top:8px}
.doc{padding:0}
.doc-grid{margin-top:18px;gap:24px}
.doc :is(h2,h3,h4) .anchor{display:none}
}
@media(max-width:520px){
main{padding:60px 14px 48px}
.doc pre{margin-left:-14px;margin-right:-14px;border-radius:0;border-left:0;border-right:0}
.home-install{flex-wrap:wrap}
}
`;
}
export function js() {
return `
const themeRoot=document.documentElement;
function applyTheme(mode){themeRoot.dataset.theme=mode;document.querySelectorAll('[data-theme-toggle]').forEach(b=>b.setAttribute('aria-pressed',mode==='dark'?'true':'false'))}
function storedTheme(){try{return localStorage.getItem('theme')}catch(e){return null}}
function persistTheme(mode){try{localStorage.setItem('theme',mode)}catch(e){}}
applyTheme(themeRoot.dataset.theme==='dark'?'dark':'light');
document.querySelectorAll('[data-theme-toggle]').forEach(btn=>{btn.addEventListener('click',()=>{const next=themeRoot.dataset.theme==='dark'?'light':'dark';applyTheme(next);persistTheme(next)})});
const systemDark=window.matchMedia&&matchMedia('(prefers-color-scheme: dark)');
function onSystemChange(e){if(storedTheme())return;applyTheme(e.matches?'dark':'light')}
if(systemDark){if(systemDark.addEventListener)systemDark.addEventListener('change',onSystemChange);else if(systemDark.addListener)systemDark.addListener(onSystemChange)}
const sidebar=document.querySelector('.sidebar');
const toggle=document.querySelector('.nav-toggle');
const mobileNav=window.matchMedia('(max-width: 900px)');
const sidebarFocusable='a[href],button,input,select,textarea,[tabindex]';
function setSidebarFocusable(enabled){
sidebar?.querySelectorAll(sidebarFocusable).forEach((el)=>{
if(enabled){
if(el.dataset.sidebarTabindex!==undefined){
if(el.dataset.sidebarTabindex)el.setAttribute('tabindex',el.dataset.sidebarTabindex);
else el.removeAttribute('tabindex');
delete el.dataset.sidebarTabindex;
}
}else if(el.dataset.sidebarTabindex===undefined){
el.dataset.sidebarTabindex=el.getAttribute('tabindex')??'';
el.setAttribute('tabindex','-1');
}
});
}
function setSidebarOpen(open){
if(!sidebar||!toggle)return;
sidebar.classList.toggle('open',open);
toggle.setAttribute('aria-expanded',open?'true':'false');
if(mobileNav.matches){
sidebar.inert=!open;
if(open)sidebar.removeAttribute('aria-hidden');
else sidebar.setAttribute('aria-hidden','true');
setSidebarFocusable(open);
}else{
sidebar.inert=false;
sidebar.removeAttribute('aria-hidden');
setSidebarFocusable(true);
}
}
setSidebarOpen(false);
toggle?.addEventListener('click',()=>setSidebarOpen(!sidebar?.classList.contains('open')));
document.addEventListener('click',(e)=>{if(!sidebar?.classList.contains('open'))return;if(sidebar.contains(e.target)||toggle?.contains(e.target))return;setSidebarOpen(false)});
document.addEventListener('keydown',(e)=>{if(e.key==='Escape')setSidebarOpen(false)});
const syncSidebarForViewport=()=>setSidebarOpen(sidebar?.classList.contains('open')??false);
if(mobileNav.addEventListener)mobileNav.addEventListener('change',syncSidebarForViewport);
else mobileNav.addListener?.(syncSidebarForViewport);
const input=document.getElementById('doc-search');
input?.addEventListener('input',()=>{const q=input.value.trim().toLowerCase();document.querySelectorAll('nav section').forEach(sec=>{let any=false;sec.querySelectorAll('.nav-link').forEach(a=>{const m=!q||a.textContent.toLowerCase().includes(q);a.style.display=m?'block':'none';if(m)any=true});sec.style.display=any?'block':'none'})});
function attachCopy(target,getText){const btn=document.createElement('button');btn.type='button';btn.className='copy';btn.textContent='Copy';btn.addEventListener('click',async()=>{try{await navigator.clipboard.writeText(getText());btn.textContent='Copied';btn.classList.add('copied');setTimeout(()=>{btn.textContent='Copy';btn.classList.remove('copied')},1400)}catch{btn.textContent='Failed';setTimeout(()=>{btn.textContent='Copy'},1400)}});target.appendChild(btn)}
document.querySelectorAll('.doc pre').forEach(pre=>attachCopy(pre,()=>pre.querySelector('code')?.textContent??''));
document.querySelectorAll('.home-install').forEach(el=>attachCopy(el,()=>el.querySelector('code')?.textContent??''));
const tocLinks=document.querySelectorAll('.toc a');
if(tocLinks.length){const map=new Map();tocLinks.forEach(a=>{const id=a.getAttribute('href').slice(1);const el=document.getElementById(id);if(el)map.set(el,a)});const setActive=l=>{tocLinks.forEach(x=>x.classList.remove('active'));l.classList.add('active')};const obs=new IntersectionObserver(entries=>{const visible=entries.filter(e=>e.isIntersecting).sort((a,b)=>a.boundingClientRect.top-b.boundingClientRect.top);if(visible.length){const link=map.get(visible[0].target);if(link)setActive(link)}},{rootMargin:'-15% 0px -65% 0px',threshold:0});map.forEach((_,el)=>obs.observe(el))}
`;
}
export function preThemeScript() {
return `(function(){var s;try{s=localStorage.getItem('theme')}catch(e){}var d=window.matchMedia&&matchMedia('(prefers-color-scheme: dark)').matches;document.documentElement.dataset.theme=s||(d?'dark':'light')})();`;
}
export function themeToggleHtml() {
return `<button class="theme-toggle" type="button" aria-label="Toggle dark mode" aria-pressed="false" data-theme-toggle>
<svg class="theme-icon-moon" viewBox="0 0 20 20" aria-hidden="true"><path d="M14.6 12.1A6.5 6.5 0 0 1 7.4 2.7a6.5 6.5 0 1 0 7.2 9.4z" fill="currentColor"/></svg>
<svg class="theme-icon-sun" viewBox="0 0 20 20" aria-hidden="true"><circle cx="10" cy="10" r="3.4" fill="currentColor"/><g stroke="currentColor" stroke-width="1.6" stroke-linecap="round"><line x1="10" y1="2" x2="10" y2="4"/><line x1="10" y1="16" x2="10" y2="18"/><line x1="2" y1="10" x2="4" y2="10"/><line x1="16" y1="10" x2="18" y2="10"/><line x1="4.2" y1="4.2" x2="5.6" y2="5.6"/><line x1="14.4" y1="14.4" x2="15.8" y2="15.8"/><line x1="4.2" y1="15.8" x2="5.6" y2="14.4"/><line x1="14.4" y1="5.6" x2="15.8" y2="4.2"/></g></svg>
</button>`;
}
export function brandMarkHtml() {
return `<span class="mark" aria-hidden="true"><svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M4.8 6.8 10 12l-5.2 5.2" stroke="#7dd3fc" stroke-width="2.7" stroke-linecap="round" stroke-linejoin="round"/><path d="M13.2 7.2h2.9c1.7 0 3.1 1.4 3.1 3.1v.5c0 1.7-1.4 3.1-3.1 3.1h-1.4c-1.7 0-3.1 1.4-3.1 3.1v.5" stroke="#a78bfa" stroke-width="1.8" stroke-linecap="round"/><circle cx="13.2" cy="7.2" r="1.6" fill="#7dd3fc"/><circle cx="19.2" cy="12" r="1.6" fill="#a78bfa"/><rect class="cursor" x="13.2" y="16.2" width="6.6" height="2.5" rx="1.25" fill="#e0e7ff"/></svg></span>`;
}
export function faviconSvg() {
return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-label="acpx">
<defs>
<linearGradient id="bg" x1="0" y1="0" x2="64" y2="64" gradientUnits="userSpaceOnUse">
<stop offset="0" stop-color="#08111f"/>
<stop offset="0.58" stop-color="#101827"/>
<stop offset="1" stop-color="#172554"/>
</linearGradient>
<linearGradient id="wash" x1="6" y1="5" x2="58" y2="59" gradientUnits="userSpaceOnUse">
<stop offset="0" stop-color="#7dd3fc" stop-opacity="0.26"/>
<stop offset="1" stop-color="#a78bfa" stop-opacity="0.22"/>
</linearGradient>
</defs>
<rect width="64" height="64" rx="14" fill="url(#bg)"/>
<rect width="64" height="64" rx="14" fill="url(#wash)"/>
<rect x="1.5" y="1.5" width="61" height="61" rx="12.5" fill="none" stroke="#ffffff" stroke-width="1.5" opacity="0.12"/>
<path d="M14 18 28 32 14 46" fill="none" stroke="#7dd3fc" stroke-width="6" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M35 20h6c4.4 0 8 3.6 8 8v1.2c0 4.4-3.6 8-8 8h-2.5c-4.4 0-8 3.6-8 8V46" fill="none" stroke="#a78bfa" stroke-width="4.5" stroke-linecap="round"/>
<circle cx="35" cy="20" r="4" fill="#7dd3fc"/>
<circle cx="49" cy="32" r="4" fill="#a78bfa"/>
<rect x="34" y="44" width="17" height="6" rx="3" fill="#e0e7ff"/>
</svg>`;
}

View File

@ -31,7 +31,6 @@ Core capabilities:
- Stable ACP `authenticate` handshake via env/config credentials
- Structured streaming output (`text`, `json`, `quiet`) with optional `--suppress-reads`
- Built-in agent registry plus raw `--agent` escape hatch
- Experimental `flow run` support with `acpx/flows` helpers, including constrained-choice `decision()` branching
## Install

View File

@ -1,11 +1,6 @@
import { spawn } from "node:child_process";
import path from "node:path";
import { CopilotAcpUnsupportedError } from "../errors.js";
import {
buildSpawnCommandOptions,
readWindowsEnvValue,
resolveWindowsCommand,
} from "../spawn-command-options.js";
import { buildSpawnCommandOptions } from "../spawn-command-options.js";
import { type AcpClientOptions } from "../types.js";
import { basenameToken, splitCommandLine } from "./client-process.js";
@ -326,20 +321,3 @@ export function buildClaudeCodeOptionsMeta(
return meta;
}
export function resolveClaudeCodeExecutable(
platform: NodeJS.Platform = process.platform,
env: NodeJS.ProcessEnv = process.env,
): string | undefined {
if (platform !== "win32") {
return undefined;
}
if (readWindowsEnvValue(env, "CLAUDE_CODE_EXECUTABLE")) {
return undefined;
}
const resolved = resolveWindowsCommand("claude", env);
if (!resolved) {
return undefined;
}
return path.resolve(resolved);
}

View File

@ -199,11 +199,9 @@ function isWsl(options: ResolveSessionCwdOptions): boolean {
return existsSync("/proc/sys/fs/binfmt_misc/WSLInterop");
}
const WINDOWS_EXECUTABLE_EXTENSION_RE = /\.(?:exe|cmd|bat)$/u;
function isWindowsExecutableCommand(command: string): boolean {
const normalized = command.toLowerCase();
return WINDOWS_EXECUTABLE_EXTENSION_RE.test(normalized);
const normalized = command.replace(/\\/g, "/").toLowerCase();
return normalized.endsWith(".exe") || normalized.startsWith("/mnt/c/");
}
async function runWslpath(cwd: string): Promise<string> {

View File

@ -26,7 +26,6 @@ import {
type WaitForTerminalExitResponse,
type WriteTextFileRequest,
type WriteTextFileResponse,
type SessionConfigOption,
type SessionModelState,
} from "@agentclientprotocol/sdk";
import { resolveBuiltInAgentLaunch } from "../agent-registry.js";
@ -65,7 +64,6 @@ import {
isQoderAcpCommand,
resolveAgentCloseAfterStdinEndMs,
resolveClaudeAcpSessionCreateTimeoutMs,
resolveClaudeCodeExecutable,
resolveGeminiAcpStartupTimeoutMs,
resolveGeminiCommandArgs,
shouldIgnoreNonJsonAgentOutputLine,
@ -117,13 +115,11 @@ type LoadSessionOptions = {
export type SessionCreateResult = {
sessionId: string;
agentSessionId?: string;
configOptions?: SessionConfigOption[];
models?: SessionModelState;
};
export type SessionLoadResult = {
agentSessionId?: string;
configOptions?: SessionConfigOption[];
models?: SessionModelState;
};
@ -440,23 +436,13 @@ export class AcpClient {
await ensureCopilotAcpSupport(spawnCommand);
}
const agentSpawnOptions = buildAgentSpawnOptions(
this.options.cwd,
this.options.authCredentials,
);
const claudeAcp = isClaudeAcpCommand(spawnCommand, args);
if (claudeAcp) {
const claudeExe = resolveClaudeCodeExecutable(process.platform, agentSpawnOptions.env);
if (claudeExe) {
agentSpawnOptions.env.CLAUDE_CODE_EXECUTABLE = claudeExe;
this.log(`resolved system Claude Code executable: ${claudeExe}`);
}
}
const spawnedChild = spawn(
spawnCommand,
args,
buildSpawnCommandOptions(spawnCommand, agentSpawnOptions),
buildSpawnCommandOptions(
spawnCommand,
buildAgentSpawnOptions(this.options.cwd, this.options.authCredentials),
),
) as ChildProcessByStdio<Writable, Readable, Readable>;
try {
@ -674,7 +660,6 @@ export class AcpClient {
return {
sessionId: result.sessionId,
agentSessionId: extractRuntimeSessionId(result._meta),
configOptions: result.configOptions ?? undefined,
models: result.models ?? undefined,
};
}
@ -721,7 +706,6 @@ export class AcpClient {
return {
agentSessionId: extractRuntimeSessionId(response?._meta),
configOptions: response?.configOptions ?? undefined,
models: response?.models ?? undefined,
};
}
@ -858,7 +842,7 @@ export class AcpClient {
async closeSession(sessionId: string): Promise<void> {
const connection = this.getConnection();
await this.runConnectionRequest(() =>
connection.closeSession({
connection.unstable_closeNes({
sessionId,
}),
);

View File

@ -93,7 +93,6 @@ export type QueueTask = {
export type QueueOwnerControlHandlers = {
cancelPrompt: () => Promise<boolean>;
closeSession: (timeoutMs?: number) => Promise<boolean>;
setSessionMode: (modeId: string, timeoutMs?: number) => Promise<void>;
setSessionModel: (modelId: string, timeoutMs?: number) => Promise<void>;
setSessionConfigOption: (
@ -396,19 +395,6 @@ export class SessionQueueOwner {
return;
}
if (request.type === "close_session") {
this.handleControlRequest({
socket,
requestId: request.requestId,
run: async () => ({
type: "close_session_result",
requestId: request.requestId,
closed: await this.controlHandlers.closeSession(request.timeoutMs),
}),
});
return;
}
if (request.type === "set_mode") {
this.handleControlRequest({
socket,

View File

@ -23,9 +23,7 @@ import {
import {
parseQueueOwnerMessage,
type QueueCancelRequest,
type QueueCloseSessionRequest,
type QueueOwnerCancelResultMessage,
type QueueOwnerCloseSessionResultMessage,
type QueueOwnerMessage,
type QueueOwnerSetConfigOptionResultMessage,
type QueueOwnerSetModelResultMessage,
@ -598,35 +596,6 @@ async function submitSetConfigOptionToQueueOwner(
return response.response;
}
async function submitCloseSessionToQueueOwner(
owner: QueueOwnerRecord,
timeoutMs?: number,
): Promise<boolean | undefined> {
const request: QueueCloseSessionRequest = {
type: "close_session",
requestId: randomUUID(),
ownerGeneration: owner.ownerGeneration,
timeoutMs,
};
const response = await submitControlToQueueOwner(
owner,
request,
(message): message is QueueOwnerCloseSessionResultMessage =>
message.type === "close_session_result",
);
if (!response) {
return undefined;
}
if (response.requestId !== request.requestId) {
throw new QueueProtocolError("Queue owner returned mismatched close_session response", {
detailCode: "QUEUE_PROTOCOL_MALFORMED_MESSAGE",
origin: "queue",
retryable: true,
});
}
return response.closed;
}
export async function trySubmitToRunningOwner(
options: SubmitToQueueOwnerOptions,
): Promise<SessionSendOutcome | undefined> {
@ -674,41 +643,6 @@ export async function trySubmitToRunningOwner(
);
}
export async function tryCloseSessionOnRunningOwner(options: {
sessionId: string;
timeoutMs?: number;
verbose?: boolean;
}): Promise<boolean | undefined> {
const owner = await readQueueOwnerRecord(options.sessionId);
if (!owner) {
return undefined;
}
const closed = await submitCloseSessionToQueueOwner(owner, options.timeoutMs);
if (closed !== undefined) {
if (options.verbose) {
process.stderr.write(
`[acpx] requested session/close on active owner pid ${owner.pid} for session ${options.sessionId}\n`,
);
}
return closed;
}
const health = await probeQueueOwnerHealth(options.sessionId);
if (!health.hasLease) {
return undefined;
}
throw new QueueConnectionError(
"Session queue owner is running but not accepting close_session requests",
{
detailCode: "QUEUE_NOT_ACCEPTING_REQUESTS",
origin: "queue",
retryable: true,
},
);
}
export async function tryCancelOnRunningOwner(options: {
sessionId: string;
verbose?: boolean;

View File

@ -67,20 +67,12 @@ export type QueueSetConfigOptionRequest = {
timeoutMs?: number;
};
export type QueueCloseSessionRequest = {
type: "close_session";
requestId: string;
ownerGeneration?: number;
timeoutMs?: number;
};
export type QueueRequest =
| QueueSubmitRequest
| QueueCancelRequest
| QueueSetModeRequest
| QueueSetModelRequest
| QueueSetConfigOptionRequest
| QueueCloseSessionRequest;
| QueueSetConfigOptionRequest;
export type QueueOwnerAcceptedMessage = {
type: "accepted";
@ -130,13 +122,6 @@ export type QueueOwnerSetConfigOptionResultMessage = {
response: SetSessionConfigOptionResponse;
};
export type QueueOwnerCloseSessionResultMessage = {
type: "close_session_result";
requestId: string;
ownerGeneration?: number;
closed: boolean;
};
export type QueueOwnerErrorMessage = {
type: "error";
requestId: string;
@ -158,7 +143,6 @@ export type QueueOwnerMessage =
| QueueOwnerSetModeResultMessage
| QueueOwnerSetModelResultMessage
| QueueOwnerSetConfigOptionResultMessage
| QueueOwnerCloseSessionResultMessage
| QueueOwnerErrorMessage;
function asRecord(value: unknown): Record<string, unknown> | undefined {
@ -327,15 +311,6 @@ export function parseQueueRequest(raw: unknown): QueueRequest | null {
};
}
if (request.type === "close_session") {
return {
type: "close_session",
requestId: request.requestId,
ownerGeneration,
timeoutMs,
};
}
if (request.type === "set_mode") {
if (typeof request.modeId !== "string" || request.modeId.trim().length === 0) {
return null;

View File

@ -161,15 +161,6 @@ export async function runSessionQueueOwner(options: QueueOwnerRuntimeOptions): P
turnController.clearActiveController();
};
const closeActiveBackendSession = async (timeoutMs?: number): Promise<boolean> => {
const latestRecord = await resolveSessionRecord(options.sessionId);
if (!sharedClient.supportsCloseSession()) {
return false;
}
await withTimeout(sharedClient.closeSession(latestRecord.acpSessionId), timeoutMs);
return true;
};
const runPromptTurn = async <T>(run: () => Promise<T>): Promise<T> => {
turnController.beginTurn();
try {
@ -191,7 +182,6 @@ export async function runSessionQueueOwner(options: QueueOwnerRuntimeOptions): P
await applyPendingCancel();
return true;
},
closeSession: async (timeoutMs?: number) => await closeActiveBackendSession(timeoutMs),
setSessionMode: async (modeId: string, timeoutMs?: number) => {
await turnController.setSessionMode(modeId, timeoutMs);
},

View File

@ -18,7 +18,6 @@ import {
terminateProcess,
terminateQueueOwnerForSession,
tryCancelOnRunningOwner,
tryCloseSessionOnRunningOwner,
trySetConfigOptionOnRunningOwner,
trySetModelOnRunningOwner,
trySetModeOnRunningOwner,
@ -188,9 +187,6 @@ async function isLikelyMatchingProcess(pid: number, agentCommand: string): Promi
export async function closeSession(sessionId: string): Promise<SessionRecord> {
const record = await resolveSessionRecord(sessionId);
await tryCloseSessionOnRunningOwner({ sessionId: record.acpxRecordId }).catch(() => {
// Preserve local close semantics even if best-effort ACP session shutdown fails.
});
await terminateQueueOwnerForSession(record.acpxRecordId);
if (

View File

@ -1,7 +1,6 @@
import { AcpClient, type SessionCreateResult } from "../../acp/client.js";
import { formatErrorMessage } from "../../acp/error-normalization.js";
import { withInterrupt, withTimeout } from "../../async-control.js";
import { applyConfigOptionsToRecord } from "../../session/config-options.js";
import { createSessionConversation } from "../../session/conversation-model.js";
import { defaultSessionEventLog } from "../../session/event-log.js";
import { setCurrentModelId, syncAdvertisedModelState } from "../../session/mode-preference.js";
@ -80,7 +79,6 @@ async function createSessionRecordWithClient(
await withTimeout(client.start(), options.timeoutMs);
let sessionId: string;
let agentSessionId: string | undefined;
let sessionResult: Awaited<ReturnType<AcpClient["createSession" | "loadSession"]>>;
let sessionModels: SessionCreateResult["models"];
let requestedModelApplied = false;
@ -98,7 +96,6 @@ async function createSessionRecordWithClient(
);
sessionId = options.resumeSessionId;
agentSessionId = normalizeRuntimeSessionId(loadedSession.agentSessionId);
sessionResult = loadedSession;
sessionModels = loadedSession.models;
requestedModelApplied = await applyRequestedModelIfAdvertised({
client,
@ -120,7 +117,6 @@ async function createSessionRecordWithClient(
const createdSession = await withTimeout(client.createSession(cwd), options.timeoutMs);
sessionId = createdSession.sessionId;
agentSessionId = normalizeRuntimeSessionId(createdSession.agentSessionId);
sessionResult = createdSession;
sessionModels = createdSession.models;
requestedModelApplied = await applyRequestedModelIfAdvertised({
client,
@ -158,7 +154,6 @@ async function createSessionRecordWithClient(
};
persistSessionOptions(record, options.sessionOptions);
applyConfigOptionsToRecord(record, sessionResult);
syncAdvertisedModelState(record, sessionModels);
if (requestedModelApplied) {
setCurrentModelId(record, options.sessionOptions?.model);

View File

@ -1,7 +1,5 @@
export { FlowRunner } from "./flows/runtime.js";
export { acp, action, checkpoint, compute, defineFlow, shell } from "./flows/definition.js";
export { decision, decisionEdge } from "./flows/decision.js";
export type { DecisionDefinition } from "./flows/decision.js";
export type {
AcpNodeDefinition,
ActionNodeDefinition,

View File

@ -1,114 +0,0 @@
import { acp } from "./definition.js";
import { extractJsonObject } from "./json.js";
import type { AcpNodeDefinition, FlowEdge, FlowNodeContext } from "./types.js";
const DEFAULT_FIELD = "route";
const SIMPLE_FIELD_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/;
// All `acp` node fields except the ones the decision helper owns.
type DecisionAcpOptions = Omit<AcpNodeDefinition, "nodeType" | "prompt" | "parse">;
export type DecisionDefinition<TChoice extends string> = DecisionAcpOptions & {
question: string | ((context: FlowNodeContext) => string | Promise<string>);
choices: readonly TChoice[];
field?: string;
};
// Build an `acp` node that asks the model to pick one of `choices` and reply
// with a JSON object whose chosen field is validated. Pair with `decisionEdge`
// (or any `switch` edge keyed on `$.<field>`) to route on the result.
export function decision<TChoice extends string>(
definition: DecisionDefinition<TChoice>,
): AcpNodeDefinition {
const { question, choices, field: fieldOverride, ...acpOptions } = definition;
const field = normalizeField(fieldOverride);
assertValidChoices(choices);
const allowed = new Set<string>(choices);
return acp({
...acpOptions,
async prompt(context) {
const text = typeof question === "function" ? await question(context) : question;
return formatDecisionPrompt(text, choices, field);
},
parse(text) {
const raw = extractJsonObject(text);
if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
throw new Error(`Decision response must be a JSON object, got ${typeof raw}`);
}
const value = (raw as Record<string, unknown>)[field];
if (typeof value !== "string" || !allowed.has(value)) {
const allowedLabels = choices.map((choice) => JSON.stringify(choice)).join(", ");
throw new Error(
`Decision returned invalid ${field}=${JSON.stringify(value)}; expected one of ${allowedLabels}`,
);
}
return raw;
},
});
}
// Build the matching `switch` edge for a `decision` node. Typing `cases` as
// `Record<TChoice, string>` makes a missing case a compile error.
export function decisionEdge<TChoice extends string>(args: {
from: string;
choices: readonly TChoice[];
field?: string;
cases: Record<TChoice, string>;
}): FlowEdge {
const field = normalizeField(args.field);
assertValidChoices(args.choices);
for (const choice of args.choices) {
if (!Object.hasOwn(args.cases, choice)) {
throw new Error(`Decision edge is missing case for choice ${JSON.stringify(choice)}`);
}
}
return {
from: args.from,
switch: {
on: `$.${field}`,
cases: args.cases as Record<string, string>,
},
};
}
function assertValidChoices(choices: readonly string[]): void {
if (choices.length === 0) {
throw new Error("Decision choices must include at least one value");
}
const seen = new Set<string>();
for (const choice of choices) {
if (typeof choice !== "string" || choice.length === 0) {
throw new Error("Decision choices must be non-empty strings");
}
if (seen.has(choice)) {
throw new Error(`Decision choices must be unique; duplicate ${JSON.stringify(choice)}`);
}
seen.add(choice);
}
}
function normalizeField(fieldOverride: string | undefined): string {
const field = fieldOverride ?? DEFAULT_FIELD;
if (!SIMPLE_FIELD_PATTERN.test(field)) {
throw new Error(
`Decision field must be a simple JSON key matching ${SIMPLE_FIELD_PATTERN.source}`,
);
}
return field;
}
function formatDecisionPrompt(question: string, choices: readonly string[], field: string): string {
const allowed = choices.map((choice) => JSON.stringify(choice)).join(" | ");
return [
question,
"",
"Return exactly one JSON object with this shape:",
"{",
` ${JSON.stringify(field)}: ${allowed},`,
' "reason": "short justification"',
"}",
"",
"Do not include any other text outside the JSON object.",
].join("\n");
}

View File

@ -201,29 +201,8 @@ export class AcpxRuntime implements AcpxRuntimeLike {
});
}
async getCapabilities(input?: { handle?: AcpRuntimeHandle }): Promise<AcpRuntimeCapabilities> {
if (!input?.handle) {
return ACPX_CAPABILITIES;
}
const { handle } = this.resolveManagerHandle(input.handle);
const record = await this.options.sessionStore.load(handle.acpxRecordId ?? handle.sessionKey);
if (!record?.acpx?.config_options) {
return ACPX_CAPABILITIES;
}
const configOptionKeys = Array.from(
new Set(
record.acpx.config_options
.map((option) => option.id)
.filter((id): id is string => typeof id === "string" && id.trim().length > 0),
),
);
return {
...ACPX_CAPABILITIES,
...(configOptionKeys.length > 0 ? { configOptionKeys } : {}),
};
getCapabilities(_input?: { handle?: AcpRuntimeHandle }): AcpRuntimeCapabilities {
return ACPX_CAPABILITIES;
}
async getStatus(input: {

View File

@ -1,11 +1,11 @@
import { randomUUID } from "node:crypto";
import path from "node:path";
import type { SetSessionConfigOptionResponse } from "@agentclientprotocol/sdk";
import { AcpClient } from "../../acp/client.js";
import { normalizeOutputError } from "../../acp/error-normalization.js";
import { extractAcpError, isAcpResourceNotFoundError } from "../../acp/error-shapes.js";
import { withTimeout } from "../../async-control.js";
import { textPrompt, type PromptInput } from "../../prompt-content.js";
import { applyConfigOptionsToRecord } from "../../session/config-options.js";
import {
cloneSessionAcpxState,
cloneSessionConversation,
@ -71,6 +71,18 @@ function createDeferred<T>(): Deferred<T> {
return { promise, resolve, reject };
}
function applyConfigOptionsToRecord(
record: SessionRecord,
configOptions: SetSessionConfigOptionResponse["configOptions"] | undefined,
): void {
if (!configOptions) {
return;
}
const acpxState = cloneSessionAcpxState(record.acpx) ?? {};
acpxState.config_options = structuredClone(configOptions);
record.acpx = acpxState;
}
class AsyncEventQueue {
private readonly items: AcpRuntimeEvent[] = [];
private readonly waits: Deferred<AcpRuntimeEvent | null>[] = [];
@ -218,7 +230,6 @@ function legacyTerminalEventFromTurnResult(result: AcpRuntimeTurnResult): AcpRun
type: "error",
message: result.error.message,
...(result.error.code ? { code: result.error.code } : {}),
...(result.error.detailCode ? { detailCode: result.error.detailCode } : {}),
...(result.error.retryable === undefined ? {} : { retryable: result.error.retryable }),
};
}
@ -393,19 +404,14 @@ export class AcpRuntimeManager {
await client.start();
let sessionId: string;
let agentSessionId: string | undefined;
let sessionResult:
| Awaited<ReturnType<AcpClient["createSession"]>>
| Awaited<ReturnType<AcpClient["loadSession"]>>;
if (input.resumeSessionId) {
const loaded = await client.loadSession(input.resumeSessionId, cwd);
sessionId = input.resumeSessionId;
agentSessionId = loaded.agentSessionId;
sessionResult = loaded;
} else {
const created = await client.createSession(cwd);
sessionId = created.sessionId;
agentSessionId = created.agentSessionId;
sessionResult = created;
}
const record = createInitialRecord({
recordId: createRecordId(input.sessionKey, input.mode),
@ -418,7 +424,6 @@ export class AcpRuntimeManager {
this.closingActiveRecords.delete(record.acpxRecordId);
record.protocolVersion = client.initializeResult?.protocolVersion;
record.agentCapabilities = client.initializeResult?.agentCapabilities;
applyConfigOptionsToRecord(record, sessionResult);
applyLifecycleSnapshotToRecord(record, client.getAgentLifecycleSnapshot());
await this.options.sessionStore.save(record);
if (input.mode === "persistent") {
@ -716,7 +721,6 @@ export class AcpRuntimeManager {
error: {
message: normalized.message,
...(normalized.code ? { code: normalized.code } : {}),
...(normalized.detailCode ? { detailCode: normalized.detailCode } : {}),
...(normalized.retryable !== undefined ? { retryable: normalized.retryable } : {}),
},
});
@ -833,14 +837,14 @@ export class AcpRuntimeManager {
let targetRecord = record;
if (controller) {
const response = await controller.setSessionConfigOption(key, value);
applyConfigOptionsToRecord(targetRecord, response);
applyConfigOptionsToRecord(targetRecord, response?.configOptions);
} else {
const result = await this.withRuntimeControlSession(
record,
sessionMode,
async ({ client, sessionId, record: connectedRecord }) => {
const response = await client.setSessionConfigOption(sessionId, key, value);
applyConfigOptionsToRecord(connectedRecord, response);
applyConfigOptionsToRecord(connectedRecord, response?.configOptions);
if (key === "mode") {
setDesiredModeId(connectedRecord, value);
} else {

View File

@ -14,7 +14,6 @@ import {
SessionResumeRequiredError,
} from "../../errors.js";
import { incrementPerfCounter } from "../../perf-metrics.js";
import { applyConfigOptionsToRecord } from "../../session/config-options.js";
import {
getDesiredConfigOptions,
getDesiredModeId,
@ -291,7 +290,6 @@ export async function connectAndLoadSession(
options.timeoutMs,
);
reconcileAgentSessionId(record, loadResult.agentSessionId);
applyConfigOptionsToRecord(record, loadResult);
sessionModels = loadResult.models;
resumed = true;
} catch (error) {
@ -310,7 +308,6 @@ export async function connectAndLoadSession(
sessionId = createdSession.sessionId;
createdFreshSession = true;
pendingAgentSessionId = createdSession.agentSessionId;
applyConfigOptionsToRecord(record, createdSession);
sessionModels = createdSession.models;
}
} else {
@ -324,7 +321,6 @@ export async function connectAndLoadSession(
sessionId = createdSession.sessionId;
createdFreshSession = true;
pendingAgentSessionId = createdSession.agentSessionId;
applyConfigOptionsToRecord(record, createdSession);
sessionModels = createdSession.models;
}

View File

@ -116,14 +116,12 @@ export type AcpRuntimeEvent =
type: "error";
message: string;
code?: string;
detailCode?: string;
retryable?: boolean;
};
export type AcpRuntimeTurnResultError = {
message: string;
code?: string;
detailCode?: string;
retryable?: boolean;
};

View File

@ -1,19 +0,0 @@
import type { SessionCreateResult, SessionLoadResult } from "../acp/client.js";
import type { SessionAcpxState, SessionRecord } from "../types.js";
import { cloneSessionAcpxState } from "./conversation-model.js";
type ConfigOptionsResult = Pick<SessionCreateResult | SessionLoadResult, "configOptions">;
export function applyConfigOptionsToRecord(
record: SessionRecord,
result: ConfigOptionsResult | undefined,
): void {
const configOptions = result?.configOptions;
if (!configOptions) {
return;
}
const acpxState: SessionAcpxState = cloneSessionAcpxState(record.acpx) ?? {};
acpxState.config_options = structuredClone(configOptions);
record.acpx = acpxState;
}

View File

@ -2,12 +2,12 @@ import { spawn } from "node:child_process";
import fs from "node:fs";
import path from "node:path";
export function readWindowsEnvValue(env: NodeJS.ProcessEnv, key: string): string | undefined {
function readWindowsEnvValue(env: NodeJS.ProcessEnv, key: string): string | undefined {
const matchedKey = Object.keys(env).find((entry) => entry.toUpperCase() === key);
return matchedKey ? env[matchedKey] : undefined;
}
export function resolveWindowsCommand(
function resolveWindowsCommand(
command: string,
env: NodeJS.ProcessEnv = process.env,
): string | undefined {

View File

@ -1,5 +1,4 @@
import assert from "node:assert/strict";
import path from "node:path";
import { PassThrough } from "node:stream";
import test from "node:test";
import type { RequestPermissionRequest, RequestPermissionResponse } from "@agentclientprotocol/sdk";
@ -414,7 +413,6 @@ test("AcpClient client-method permission errors update permission stats", async
});
test("AcpClient createSession forwards claudeCode options in _meta", async () => {
const cwd = path.resolve("/tmp/acpx-client-meta");
const client = makeClient({
sessionOptions: {
model: "sonnet",
@ -434,7 +432,7 @@ test("AcpClient createSession forwards claudeCode options in _meta", async () =>
const result = await client.createSession("/tmp/acpx-client-meta");
assert.equal(result.sessionId, "session-123");
assert.deepEqual(capturedParams, {
cwd,
cwd: "/tmp/acpx-client-meta",
mcpServers: [],
_meta: {
claudeCode: {
@ -449,7 +447,6 @@ test("AcpClient createSession forwards claudeCode options in _meta", async () =>
});
test("AcpClient createSession forwards systemPrompt string in _meta", async () => {
const cwd = path.resolve("/tmp/acpx-client-system-prompt");
const client = makeClient({
sessionOptions: {
systemPrompt: "you are an obsidian assistant",
@ -466,7 +463,7 @@ test("AcpClient createSession forwards systemPrompt string in _meta", async () =
await client.createSession("/tmp/acpx-client-system-prompt");
assert.deepEqual(capturedParams, {
cwd,
cwd: "/tmp/acpx-client-system-prompt",
mcpServers: [],
_meta: {
systemPrompt: "you are an obsidian assistant",
@ -475,7 +472,6 @@ test("AcpClient createSession forwards systemPrompt string in _meta", async () =
});
test("AcpClient createSession forwards systemPrompt append in _meta alongside claudeCode options", async () => {
const cwd = path.resolve("/tmp/acpx-client-system-prompt-append");
const client = makeClient({
sessionOptions: {
model: "sonnet",
@ -493,7 +489,7 @@ test("AcpClient createSession forwards systemPrompt append in _meta alongside cl
await client.createSession("/tmp/acpx-client-system-prompt-append");
assert.deepEqual(capturedParams, {
cwd,
cwd: "/tmp/acpx-client-system-prompt-append",
mcpServers: [],
_meta: {
claudeCode: {
@ -507,7 +503,6 @@ test("AcpClient createSession forwards systemPrompt append in _meta alongside cl
});
test("AcpClient createSession forwards codex model metadata without setting it explicitly", async () => {
const cwd = path.resolve("/tmp/acpx-client-codex-model");
const client = makeClient({
agentCommand: "npx @zed-industries/codex-acp",
sessionOptions: {
@ -531,7 +526,7 @@ test("AcpClient createSession forwards codex model metadata without setting it e
const result = await client.createSession("/tmp/acpx-client-codex-model");
assert.equal(result.sessionId, "session-456");
assert.deepEqual(capturedNewSessionParams, {
cwd,
cwd: "/tmp/acpx-client-codex-model",
mcpServers: [],
_meta: {
claudeCode: {
@ -579,7 +574,7 @@ test("AcpClient closes sessions through session/close and clears the loaded sess
};
internals.loadedSessionId = "session-close-1";
internals.connection = {
closeSession: async (params: { sessionId: string }) => {
unstable_closeNes: async (params: { sessionId: string }) => {
capturedCloseSessionParams = params;
return {};
},

View File

@ -5,7 +5,6 @@ import path from "node:path";
import test from "node:test";
import { fileURLToPath } from "node:url";
import { TimeoutError } from "../src/async-control.js";
import { decision, decisionEdge } from "../src/flows/decision.js";
import { validateFlowDefinition } from "../src/flows/graph.js";
import { extractJsonObject, parseJsonObject, parseStrictJsonObject } from "../src/flows/json.js";
import { createRunId } from "../src/flows/runtime-support.js";
@ -95,196 +94,6 @@ test("createRunId slugifies flow names without regex backtracking", () => {
);
});
test("decision builds an acp node that scaffolds the prompt and validates the choice", async () => {
const node = decision({
choices: ["continue", "checkpoint"] as const,
question: "Decide.",
});
assert.equal(node.nodeType, "acp");
assert.equal(typeof node.prompt, "function");
assert.equal(typeof node.parse, "function");
const promptText = (await node.prompt({
input: undefined,
outputs: {},
results: {},
state: {} as never,
services: {},
})) as string;
assert.match(promptText, /Decide\./);
assert.match(promptText, /Return exactly one JSON object with this shape:/);
assert.match(promptText, /"route": "continue" \| "checkpoint"/);
assert.match(promptText, /"reason": "short justification"/);
const valid = (await node.parse?.('{"route":"continue","reason":"clear"}', {} as never)) as
| Record<string, unknown>
| undefined;
assert.deepEqual(valid, { route: "continue", reason: "clear" });
await assert.rejects(
async () => node.parse?.('{"route":"sideways"}', {} as never),
/Decision returned invalid route="sideways"; expected one of "continue", "checkpoint"/,
);
await assert.rejects(
async () => node.parse?.('{"reason":"none"}', {} as never),
/Decision returned invalid route=undefined/,
);
await assert.rejects(
async () => node.parse?.("[1,2,3]", {} as never),
/Decision response must be a JSON object/,
);
});
test("decision honors a custom field name and forwards acp options", async () => {
const node = decision({
field: "verdict",
choices: ["yes", "no"] as const,
question: () => Promise.resolve("Approve?"),
profile: "claude",
timeoutMs: 1234,
});
assert.equal(node.profile, "claude");
assert.equal(node.timeoutMs, 1234);
const promptText = (await node.prompt({
input: undefined,
outputs: {},
results: {},
state: {} as never,
services: {},
})) as string;
assert.match(promptText, /"verdict": "yes" \| "no"/);
const valid = (await node.parse?.('{"verdict":"yes"}', {} as never)) as
| Record<string, unknown>
| undefined;
assert.deepEqual(valid, { verdict: "yes" });
});
test("decisionEdge produces a switch edge keyed on the chosen field", () => {
const edge = decisionEdge({
from: "classify",
choices: ["continue", "checkpoint"] as const,
cases: { continue: "continue_lane", checkpoint: "checkpoint_lane" },
});
assert.deepEqual(edge, {
from: "classify",
switch: {
on: "$.route",
cases: { continue: "continue_lane", checkpoint: "checkpoint_lane" },
},
});
const customField = decisionEdge({
from: "approve",
field: "verdict",
choices: ["yes", "no"] as const,
cases: { yes: "ship", no: "rollback" },
});
assert.equal("switch" in customField && customField.switch.on, "$.verdict");
});
test("decision validates choices, field names, and edge cases", () => {
assert.throws(
() =>
decision({
choices: [],
question: "Choose.",
}),
/Decision choices must include at least one value/,
);
assert.throws(
() =>
decision({
choices: ["yes", "yes"],
question: "Choose.",
}),
/Decision choices must be unique/,
);
assert.throws(
() =>
decision({
field: "bad.path",
choices: ["yes"],
question: "Choose.",
}),
/Decision field must be a simple JSON key/,
);
assert.throws(
() =>
decisionEdge({
from: "classify",
choices: ["yes", "no"] as const,
cases: { yes: "ship" } as Record<"yes" | "no", string>,
}),
/Decision edge is missing case for choice "no"/,
);
});
test("FlowRunner routes through decision helpers", async () => {
await withTempHome(async () => {
const cwd = await fs.mkdtemp(path.join(os.tmpdir(), "acpx-flow-decision-cwd-"));
try {
const runner = new FlowRunner({
resolveAgent: () => ({
agentName: "mock",
agentCommand: MOCK_AGENT_COMMAND,
cwd,
}),
permissionMode: "approve-all",
ttlMs: 1_000,
});
const choices = ["continue", "checkpoint"] as const;
const flow = defineFlow({
name: "decision-branch-test",
startAt: "classify",
nodes: {
classify: decision({
session: {
isolated: true,
},
choices,
question: ({ input }) => {
const route = (input as { route: string }).route;
return `echo ${JSON.stringify({ route, reason: "mocked" })}`;
},
}),
continue_lane: action({
run: () => ({ ok: true }),
}),
checkpoint_lane: checkpoint({
run: () => ({ ok: false }),
}),
},
edges: [
decisionEdge({
from: "classify",
choices,
cases: {
continue: "continue_lane",
checkpoint: "checkpoint_lane",
},
}),
],
});
const result = await runner.run(flow, { route: "continue" });
assert.equal(result.state.status, "completed");
assert.deepEqual(result.state.outputs.classify, { route: "continue", reason: "mocked" });
assert.deepEqual(result.state.outputs.continue_lane, { ok: true });
assert.equal(result.state.outputs.checkpoint_lane, undefined);
} finally {
await fs.rm(cwd, { recursive: true, force: true });
}
});
});
test("flow node helpers validate node-local shape before runtime", () => {
const extensibleNode = compute({
run: () => ({ ok: true }),

View File

@ -1,12 +1,9 @@
#!/usr/bin/env node
import { randomUUID } from "node:crypto";
import { writeFileSync } from "node:fs";
import { Readable, Writable } from "node:stream";
import {
AgentSideConnection,
type CloseSessionRequest,
type CloseSessionResponse,
PROTOCOL_VERSION,
RequestError,
ndJsonStream,
@ -39,8 +36,6 @@ type MockAgentOptions = {
newSessionMeta?: Record<string, string>;
loadSessionMeta?: Record<string, string>;
supportsLoadSession: boolean;
supportsCloseSession: boolean;
closeSessionMarker?: string;
loadSessionNotFound: boolean;
loadSessionFailsOnEmpty: boolean;
setSessionModeFails: boolean;
@ -48,7 +43,6 @@ type MockAgentOptions = {
setSessionConfigInvalidParams: boolean;
setSessionModelFails: boolean;
setSessionModelInvalidParams: boolean;
advertiseConfigOptions: boolean;
advertiseModels: boolean;
replayLoadSessionUpdates: boolean;
loadReplayText: string;
@ -306,8 +300,6 @@ function parseMockAgentOptions(argv: string[]): MockAgentOptions {
const newSessionMeta: Record<string, string> = {};
const loadSessionMeta: Record<string, string> = {};
let supportsLoadSession = false;
let supportsCloseSession = false;
let closeSessionMarker: string | undefined;
let loadSessionNotFound = false;
let loadSessionFailsOnEmpty = false;
let setSessionModeFails = false;
@ -315,7 +307,6 @@ function parseMockAgentOptions(argv: string[]): MockAgentOptions {
let setSessionConfigInvalidParams = false;
let setSessionModelFails = false;
let setSessionModelInvalidParams = false;
let advertiseConfigOptions = false;
let advertiseModels = false;
let replayLoadSessionUpdates = false;
let loadReplayText = "replayed load session update";
@ -374,29 +365,12 @@ function parseMockAgentOptions(argv: string[]): MockAgentOptions {
continue;
}
if (token === "--advertise-config-options") {
advertiseConfigOptions = true;
continue;
}
if (token === "--replay-load-session-updates") {
supportsLoadSession = true;
replayLoadSessionUpdates = true;
continue;
}
if (token === "--supports-close-session") {
supportsCloseSession = true;
continue;
}
if (token === "--close-session-marker") {
supportsCloseSession = true;
closeSessionMarker = parseOptionValue(argv, index + 1, token);
index += 1;
continue;
}
if (token === "--ignore-sigterm") {
ignoreSigterm = true;
continue;
@ -442,8 +416,6 @@ function parseMockAgentOptions(argv: string[]): MockAgentOptions {
newSessionMeta: Object.keys(newSessionMeta).length > 0 ? { ...newSessionMeta } : undefined,
loadSessionMeta: Object.keys(loadSessionMeta).length > 0 ? { ...loadSessionMeta } : undefined,
supportsLoadSession,
supportsCloseSession,
closeSessionMarker,
loadSessionNotFound,
loadSessionFailsOnEmpty,
setSessionModeFails,
@ -451,7 +423,6 @@ function parseMockAgentOptions(argv: string[]): MockAgentOptions {
setSessionConfigInvalidParams,
setSessionModelFails,
setSessionModelInvalidParams,
advertiseConfigOptions,
advertiseModels,
replayLoadSessionUpdates,
loadReplayText,
@ -544,10 +515,7 @@ class MockAgent implements Agent {
return {
protocolVersion: PROTOCOL_VERSION,
authMethods: [],
agentCapabilities: {
...(this.options.supportsLoadSession ? { loadSession: true } : {}),
...(this.options.supportsCloseSession ? { sessionCapabilities: { close: {} } } : {}),
},
agentCapabilities: this.options.supportsLoadSession ? { loadSession: true } : {},
};
}
@ -572,11 +540,6 @@ class MockAgent implements Agent {
if (this.options.advertiseModels) {
response.models = buildModelsState(DEFAULT_MODEL_ID);
}
if (this.options.advertiseConfigOptions) {
response.configOptions = buildConfigOptions(
this.sessions.get(sessionId) ?? createSessionState(false),
);
}
return response;
}
@ -621,23 +584,10 @@ class MockAgent implements Agent {
const session = this.sessions.get(params.sessionId);
response.models = buildModelsState(session?.modelId ?? DEFAULT_MODEL_ID);
}
if (this.options.advertiseConfigOptions) {
response.configOptions = buildConfigOptions(
this.sessions.get(params.sessionId) ?? createSessionState(false),
);
}
return response;
}
async closeSession(params: CloseSessionRequest): Promise<CloseSessionResponse> {
this.sessions.delete(params.sessionId);
if (this.options.closeSessionMarker) {
writeFileSync(this.options.closeSessionMarker, `${params.sessionId}\n`, { flag: "a" });
}
return {};
}
async prompt(params: PromptRequest): Promise<PromptResponse> {
const session = this.sessions.get(params.sessionId);
if (!session) {

View File

@ -513,7 +513,6 @@ test("SessionQueueOwner emits typed invalid request payload errors", async () =>
const owner = await SessionQueueOwner.start(lease, {
cancelPrompt: async () => false,
closeSession: async () => false,
setSessionMode: async () => {
// no-op
},
@ -561,7 +560,6 @@ test("SessionQueueOwner emits typed shutdown errors for pending prompts", async
const owner = await SessionQueueOwner.start(lease, {
cancelPrompt: async () => false,
closeSession: async () => false,
setSessionMode: async () => {
// no-op
},
@ -630,7 +628,6 @@ test("SessionQueueOwner rejects prompts when queue depth exceeds the configured
lease,
{
cancelPrompt: async () => false,
closeSession: async () => false,
setSessionMode: async () => {
// no-op
},

View File

@ -15,7 +15,6 @@ test("SessionQueueOwner handles control requests and nextTask timeouts", async (
assert(lease);
let cancelled = 0;
let closeSessionCalls = 0;
const modes: string[] = [];
const configRequests: Array<{ id: string; value: string }> = [];
@ -24,10 +23,6 @@ test("SessionQueueOwner handles control requests and nextTask timeouts", async (
cancelled += 1;
return true;
},
closeSession: async () => {
closeSessionCalls += 1;
return true;
},
setSessionMode: async (modeId) => {
modes.push(modeId);
},
@ -110,30 +105,7 @@ test("SessionQueueOwner handles control requests and nextTask timeouts", async (
configLines.close();
configSocket.destroy();
const closeSocket = await connectSocket(lease.socketPath);
const closeLines = readline.createInterface({ input: closeSocket });
const closeIterator = closeLines[Symbol.asyncIterator]();
closeSocket.write(
`${JSON.stringify({
type: "close_session",
requestId: "req-close-session",
timeoutMs: 250,
})}\n`,
);
const closeAccepted = (await nextJsonLine(closeIterator)) as { type: string };
const closeResult = (await nextJsonLine(closeIterator)) as {
type: string;
closed: boolean;
};
assert.equal(closeAccepted.type, "accepted");
assert.equal(closeResult.type, "close_session_result");
assert.equal(closeResult.closed, true);
closeLines.close();
closeSocket.destroy();
assert.equal(cancelled, 1);
assert.equal(closeSessionCalls, 1);
assert.deepEqual(modes, ["plan"]);
assert.deepEqual(configRequests, [{ id: "thinking_level", value: "high" }]);
} finally {
@ -153,7 +125,6 @@ test("SessionQueueOwner enqueues fire-and-forget prompts and rejects invalid own
lease,
{
cancelPrompt: async () => false,
closeSession: async () => false,
setSessionMode: async () => {
// no-op
},

View File

@ -1,7 +1,6 @@
import assert from "node:assert/strict";
import test from "node:test";
import type { SetSessionConfigOptionResponse } from "@agentclientprotocol/sdk";
import { AcpxOperationalError } from "../src/errors.js";
import { AcpRuntimeManager } from "../src/runtime/engine/manager.js";
import type {
AcpRuntimeEvent,
@ -27,18 +26,8 @@ type FakeClient = {
};
start: () => Promise<void>;
close: () => Promise<void>;
createSession: (cwd: string) => Promise<{
sessionId: string;
agentSessionId?: string;
configOptions?: SetSessionConfigOptionResponse["configOptions"];
}>;
loadSession: (
sessionId: string,
cwd: string,
) => Promise<{
agentSessionId?: string;
configOptions?: SetSessionConfigOptionResponse["configOptions"];
}>;
createSession: (cwd: string) => Promise<{ sessionId: string; agentSessionId?: string }>;
loadSession: (sessionId: string, cwd: string) => Promise<{ agentSessionId?: string }>;
hasReusableSession: (sessionId: string) => boolean;
supportsLoadSession: () => boolean;
supportsCloseSession?: () => boolean;
@ -154,35 +143,12 @@ test("AcpRuntimeManager creates and resumes sessions through the client", async
close: async () => {},
createSession: async (cwd) => {
assert.equal(cwd, "/workspace");
return {
sessionId: "new-session",
agentSessionId: "agent-session",
configOptions: [
{
id: "mode",
name: "Mode",
type: "select",
currentValue: "ask",
options: [{ value: "ask", name: "Ask" }],
},
],
};
return { sessionId: "new-session", agentSessionId: "agent-session" };
},
loadSession: async (sessionId, cwd) => {
assert.equal(sessionId, "resume-session");
assert.equal(cwd, "/workspace");
return {
agentSessionId: "resumed-agent",
configOptions: [
{
id: "model",
name: "Model",
type: "select",
currentValue: "fast",
options: [{ value: "fast", name: "Fast" }],
},
],
};
return { agentSessionId: "resumed-agent" };
},
hasReusableSession: () => false,
supportsLoadSession: () => true,
@ -215,10 +181,6 @@ test("AcpRuntimeManager creates and resumes sessions through the client", async
assert.equal(created.acpSessionId, "new-session");
assert.equal(created.agentSessionId, "agent-session");
assert.equal(created.protocolVersion, 1);
assert.deepEqual(
created.acpx?.config_options?.map((option) => option.id),
["mode"],
);
assert.equal(created.eventLog.segment_count > 0, true);
assert.match(created.eventLog.active_path, /created-session/);
@ -230,10 +192,6 @@ test("AcpRuntimeManager creates and resumes sessions through the client", async
});
assert.equal(resumed.acpSessionId, "resume-session");
assert.equal(resumed.agentSessionId, "resumed-agent");
assert.deepEqual(
resumed.acpx?.config_options?.map((option) => option.id),
["model"],
);
assert.equal(constructed, 2);
});
@ -1565,12 +1523,7 @@ test("AcpRuntimeManager surfaces normalized prompt failures", async () => {
loadSessionWithOptions: async () => ({ agentSessionId: "unused" }),
getAgentLifecycleSnapshot: () => ({ running: true }),
prompt: async () => {
throw new AcpxOperationalError("prompt exploded", {
outputCode: "RUNTIME",
detailCode: "AGENT_DISCONNECTED",
origin: "acp",
retryable: true,
});
throw new Error("prompt exploded");
},
requestCancelActivePrompt: async () => false,
hasActivePrompt: () => false,
@ -1592,33 +1545,8 @@ test("AcpRuntimeManager surfaces normalized prompt failures", async () => {
const { events, result } = await collectTurn(turn);
assert.deepEqual(events, []);
assert.deepEqual(result, {
status: "failed",
error: {
code: "RUNTIME",
detailCode: "AGENT_DISCONNECTED",
message: "prompt exploded",
retryable: true,
},
});
const legacyEvents = await collectEvents(
manager.runTurn({
handle: createHandle("error-session"),
text: "hello",
mode: "prompt",
sessionMode: "persistent",
requestId: "req-error-legacy",
}),
);
assert.deepEqual(legacyEvents, [
{
type: "error",
code: "RUNTIME",
detailCode: "AGENT_DISCONNECTED",
message: "prompt exploded",
retryable: true,
},
]);
assert.equal(result.status, "failed");
assert.match(result.error?.message ?? "", /prompt exploded/);
});
test("AcpRuntimeManager rejects unsupported runtime attachment media types", async () => {
@ -1721,7 +1649,6 @@ test("AcpRuntimeManager fails persistent turns clearly when session/load is unav
status: "failed",
error: {
code: "RUNTIME",
detailCode: "SESSION_RESUME_REQUIRED",
message:
"Persistent ACP session persistent-backend-session could not be resumed: agent does not support session/load",
retryable: true,

View File

@ -394,7 +394,7 @@ test("AcpxRuntime falls back to plain runtimeSessionName handles and reuses a si
await runtime.probeAvailability();
assert.equal(runtime.isHealthy(), true);
assert.deepEqual(await runtime.getCapabilities(), {
assert.deepEqual(runtime.getCapabilities(), {
controls: ["session/set_mode", "session/set_config_option", "session/status"],
});
@ -425,68 +425,6 @@ test("AcpxRuntime falls back to plain runtimeSessionName handles and reuses a si
assert.equal(managerFactoryCalls, 1);
});
test("AcpxRuntime exposes advertised config option keys for resolved handles", async () => {
const encoded = encodeAcpxRuntimeHandleState({
name: "agent:codex:acp:test",
agent: "codex",
cwd: "/workspace",
mode: "persistent",
acpxRecordId: "agent:codex:acp:test",
backendSessionId: "sid-1",
agentSessionId: "inner-1",
});
const store = createFileSessionStore({ stateDir: "/tmp/acpx-runtime-config-options" });
await store.save(
createSessionRecord({
acpx: {
config_options: [
{
id: "mode",
name: "Mode",
type: "select",
currentValue: "ask",
options: [{ value: "ask", name: "Ask" }],
},
{
id: "model",
name: "Model",
type: "select",
currentValue: "fast",
options: [{ value: "fast", name: "Fast" }],
},
{
id: "mode",
name: "Mode",
type: "select",
currentValue: "ask",
options: [{ value: "ask", name: "Ask" }],
},
],
},
}),
);
const runtime = new AcpxRuntime({
cwd: "/workspace",
sessionStore: store,
agentRegistry: createAgentRegistry(),
permissionMode: "approve-reads",
});
assert.deepEqual(
await runtime.getCapabilities({
handle: {
sessionKey: "ignored-session-key",
backend: "acpx",
runtimeSessionName: encoded,
},
}),
{
controls: ["session/set_mode", "session/set_config_option", "session/status"],
configOptionKeys: ["mode", "model"],
},
);
});
test("createRuntimeStore is an alias for the file-backed session store", async (t) => {
const stateDir = await fs.mkdtemp(path.join(os.tmpdir(), "acpx-runtime-store-alias-"));
t.after(async () => {

View File

@ -3,7 +3,6 @@ import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import test from "node:test";
import { resolveClaudeCodeExecutable } from "../src/acp/agent-command.js";
import { resolveAgentSessionCwd } from "../src/acp/client-process.js";
import { buildAgentSpawnOptions, buildSpawnCommandOptions } from "../src/acp/client.js";
import { buildTerminalSpawnOptions } from "../src/acp/terminal-manager.js";
@ -132,11 +131,9 @@ test("buildSpawnCommandOptions keeps shell disabled for non-batch commands", asy
test("resolveAgentSessionCwd translates WSL cwd for Windows exe agents", async () => {
let capturedCwd: string | undefined;
const inputCwd = "/home/user/project";
const resolvedCwd = path.resolve(inputCwd);
const cwd = await resolveAgentSessionCwd(
inputCwd,
"/home/user/project",
'"/mnt/c/Users/User/AppData/Local/GitHub CLI/copilot/copilot.exe" --acp --stdio',
{
platform: "linux",
@ -148,7 +145,7 @@ test("resolveAgentSessionCwd translates WSL cwd for Windows exe agents", async (
},
);
assert.equal(capturedCwd, resolvedCwd);
assert.equal(capturedCwd, "/home/user/project");
assert.equal(cwd, "\\\\wsl.localhost\\Ubuntu\\home\\user\\project");
});
@ -160,8 +157,7 @@ test("resolveAgentSessionCwd leaves non-WSL and non-Windows agents on resolved c
throw new Error("wslpath should not run");
},
});
const inputCwd = "/home/user/project";
const wslNodeAgent = await resolveAgentSessionCwd(inputCwd, "node ./agent.js", {
const wslNodeAgent = await resolveAgentSessionCwd("/home/user/project", "node ./agent.js", {
platform: "linux",
existsSync: (filePath) => filePath === "/proc/sys/fs/binfmt_misc/WSLInterop",
runWslpath: async () => {
@ -170,60 +166,7 @@ test("resolveAgentSessionCwd leaves non-WSL and non-Windows agents on resolved c
});
assert.equal(nonWsl, path.resolve("relative/project"));
assert.equal(wslNodeAgent, path.resolve(inputCwd));
});
test("resolveAgentSessionCwd translates WSL cwd for Windows .cmd wrappers", async () => {
let capturedCwd: string | undefined;
const inputCwd = "/home/user/project";
const resolvedCwd = path.resolve(inputCwd);
const cwd = await resolveAgentSessionCwd(
inputCwd,
'"/mnt/c/Program Files/nodejs/npx.cmd" some-acp-agent --stdio',
{
platform: "linux",
existsSync: (filePath) => filePath === "/proc/sys/fs/binfmt_misc/WSLInterop",
runWslpath: async (value) => {
capturedCwd = value;
return "\\\\wsl.localhost\\Ubuntu\\home\\user\\project\n";
},
},
);
assert.equal(capturedCwd, resolvedCwd);
assert.equal(cwd, "\\\\wsl.localhost\\Ubuntu\\home\\user\\project");
});
test("resolveAgentSessionCwd translates WSL cwd for Windows agents on non-C drives", async () => {
let capturedCwd: string | undefined;
const inputCwd = "/home/user/project";
const resolvedCwd = path.resolve(inputCwd);
const cwd = await resolveAgentSessionCwd(inputCwd, "/mnt/d/tools/agent.bat --acp", {
platform: "linux",
existsSync: (filePath) => filePath === "/proc/sys/fs/binfmt_misc/WSLInterop",
runWslpath: async (value) => {
capturedCwd = value;
return "\\\\wsl.localhost\\Ubuntu\\home\\user\\project\n";
},
});
assert.equal(capturedCwd, resolvedCwd);
assert.equal(cwd, "\\\\wsl.localhost\\Ubuntu\\home\\user\\project");
});
test("resolveAgentSessionCwd does not translate WSL cwd for extension-less commands under /mnt/<drive>/", async () => {
const inputCwd = "/home/user/project";
const cwd = await resolveAgentSessionCwd(inputCwd, "/mnt/c/tools/linux-agent --acp", {
platform: "linux",
existsSync: (filePath) => filePath === "/proc/sys/fs/binfmt_misc/WSLInterop",
runWslpath: async () => {
throw new Error("wslpath should not run for extension-less /mnt/<drive>/ commands");
},
});
assert.equal(cwd, path.resolve(inputCwd));
assert.equal(wslNodeAgent, "/home/user/project");
});
test("resolveAgentSessionCwd rejects empty wslpath output", async () => {
@ -284,58 +227,3 @@ test("buildTerminalSpawnOptions keeps shell disabled for non-batch commands", as
await fs.rm(tempDir, { recursive: true, force: true });
}
});
test("resolveClaudeCodeExecutable finds claude.exe on PATH on Windows", async () => {
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "acpx-claude-exe-"));
try {
await fs.writeFile(path.join(tempDir, "claude.exe"), "");
const env = { PATH: tempDir, PATHEXT: ".COM;.EXE;.BAT;.CMD" } as NodeJS.ProcessEnv;
const result = resolveClaudeCodeExecutable("win32", env);
assert.equal(result, path.join(tempDir, "claude.exe"));
} finally {
await fs.rm(tempDir, { recursive: true, force: true });
}
});
test("resolveClaudeCodeExecutable returns undefined when CLAUDE_CODE_EXECUTABLE is already set", async () => {
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "acpx-claude-exe-"));
try {
await fs.writeFile(path.join(tempDir, "claude.exe"), "");
const env = {
PATH: tempDir,
PATHEXT: ".COM;.EXE;.BAT;.CMD",
CLAUDE_CODE_EXECUTABLE: "/custom/claude",
} as NodeJS.ProcessEnv;
const result = resolveClaudeCodeExecutable("win32", env);
assert.equal(result, undefined);
} finally {
await fs.rm(tempDir, { recursive: true, force: true });
}
});
test("resolveClaudeCodeExecutable respects case-insensitive env var on Windows", async () => {
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "acpx-claude-exe-"));
try {
await fs.writeFile(path.join(tempDir, "claude.exe"), "");
const env = {
PATH: tempDir,
PATHEXT: ".COM;.EXE;.BAT;.CMD",
claude_code_executable: "/custom/claude",
} as NodeJS.ProcessEnv;
const result = resolveClaudeCodeExecutable("win32", env);
assert.equal(result, undefined);
} finally {
await fs.rm(tempDir, { recursive: true, force: true });
}
});
test("resolveClaudeCodeExecutable returns undefined on non-Windows platforms", () => {
const result = resolveClaudeCodeExecutable("linux", { PATH: "/usr/bin" } as NodeJS.ProcessEnv);
assert.equal(result, undefined);
});
test("resolveClaudeCodeExecutable returns undefined when claude is not on PATH", () => {
const env = { PATH: "/nonexistent", PATHEXT: ".COM;.EXE;.BAT;.CMD" } as NodeJS.ProcessEnv;
const result = resolveClaudeCodeExecutable("win32", env);
assert.equal(result, undefined);
});