Compare commits
1 Commits
main
...
dependabot
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dd8cb04af1 |
7
.github/workflows/ci.yml
vendored
7
.github/workflows/ci.yml
vendored
@ -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:
|
||||
|
||||
55
.github/workflows/pages.yml
vendored
55
.github/workflows/pages.yml
vendored
@ -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
|
||||
@ -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",
|
||||
|
||||
16
CHANGELOG.md
16
CHANGELOG.md
@ -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)
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
169
docs/VISION.md
169
docs/VISION.md
@ -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.
|
||||
198
docs/agents.md
198
docs/agents.md
@ -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.
|
||||
187
docs/config.md
187
docs/config.md
@ -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`.
|
||||
@ -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.
|
||||
@ -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.
|
||||
198
docs/flows.md
198
docs/flows.md
@ -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`.
|
||||
@ -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.
|
||||
108
docs/install.md
108
docs/install.md
@ -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.
|
||||
@ -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.
|
||||
@ -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.
|
||||
@ -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.
|
||||
@ -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.
|
||||
@ -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.
|
||||
211
docs/sessions.md
211
docs/sessions.md
@ -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.
|
||||
@ -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
|
||||
|
||||
@ -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",
|
||||
},
|
||||
},
|
||||
}),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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(/<(https?:\/\/[^\s<>]+)>/g, '<a href="$1">$1</a>');
|
||||
out = out.replace(/\\\|/g, "|");
|
||||
out = out.replace(/<br>/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) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[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));
|
||||
}
|
||||
@ -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>`;
|
||||
}
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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> {
|
||||
|
||||
@ -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,
|
||||
}),
|
||||
);
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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);
|
||||
},
|
||||
|
||||
@ -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 (
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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");
|
||||
}
|
||||
@ -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: {
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
|
||||
@ -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;
|
||||
};
|
||||
|
||||
|
||||
@ -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;
|
||||
}
|
||||
@ -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 {
|
||||
|
||||
@ -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 {};
|
||||
},
|
||||
|
||||
@ -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 }),
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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
|
||||
},
|
||||
|
||||
@ -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
|
||||
},
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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 () => {
|
||||
|
||||
@ -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);
|
||||
});
|
||||
|
||||
Loading…
Reference in New Issue
Block a user