Compare commits

...

24 Commits
v0.6.0 ... main

Author SHA1 Message Date
Peter Steinberger
d46e156102
docs: refresh docs site logo
Some checks failed
CI / scope (push) Has been cancelled
pages / Deploy docs (push) Has been cancelled
CI / ${{ matrix.name }} (pnpm run build, Build) (push) Has been cancelled
CI / Docs (push) Has been cancelled
CI / ${{ matrix.name }} (pnpm run conformance:run -- --case acp.v1.initialize.handshake, Conformance Smoke) (push) Has been cancelled
CI / ${{ matrix.name }} (pnpm run format:check, Format) (push) Has been cancelled
CI / ${{ matrix.name }} (pnpm run lint, Lint) (push) Has been cancelled
CI / ${{ matrix.name }} (pnpm run test:coverage, Test, 22) (push) Has been cancelled
CI / ${{ matrix.name }} (pnpm run typecheck, Typecheck) (push) Has been cancelled
2026-05-06 02:06:49 +01:00
Peter Steinberger
f6803dad1e
docs: polish docs site code blocks 2026-05-06 00:42:40 +01:00
Peter Steinberger
0132c82713
docs: preserve inline code in docs site 2026-05-05 22:10:43 +01:00
Peter Steinberger
9fedaec7c2
docs: add GitHub Pages docs site 2026-05-05 22:06:47 +01:00
Peter Steinberger
0f3a34a9eb
chore: release 0.7.0
Some checks failed
CI / scope (push) Has been cancelled
Release / release (push) Has been cancelled
CI / ${{ matrix.name }} (pnpm run build, Build) (push) Has been cancelled
CI / ${{ matrix.name }} (pnpm run conformance:run -- --case acp.v1.initialize.handshake, Conformance Smoke) (push) Has been cancelled
CI / ${{ matrix.name }} (pnpm run format:check, Format) (push) Has been cancelled
CI / ${{ matrix.name }} (pnpm run lint, Lint) (push) Has been cancelled
CI / ${{ matrix.name }} (pnpm run test:coverage, Test, 22) (push) Has been cancelled
CI / ${{ matrix.name }} (pnpm run typecheck, Typecheck) (push) Has been cancelled
CI / Docs (push) Has been cancelled
2026-05-05 20:47:39 +01:00
Peter Steinberger
f5fa1862e0
ci: run full checks for workflow changes 2026-05-05 20:42:14 +01:00
Peter Steinberger
c89b344f45
ci: use GitHub-hosted runners for CI 2026-05-05 20:41:23 +01:00
Joshua Lelon Mitchell
2d1e30d00c
feat(flows): add decision() helper for constrained LLM branching (#278)
Wraps the existing acp + parse + switch pattern into a typed helper that
scaffolds the JSON-with-reason prompt and validates the chosen value
against the supplied choices tuple. decisionEdge() builds the matching
switch edge with exhaustively-typed cases. Returns a plain AcpNodeDefinition
so no schema, snapshot, or replay-viewer changes are required.

Rewrites examples/flows/branch.flow.ts to use the helper.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 20:28:05 +01:00
Peter Steinberger
fd67b109b3
Create CNAME 2026-05-05 20:25:47 +01:00
Peter Steinberger
43013ead1a
fix(runtime): preserve ACP session details and close active owners 2026-05-05 19:15:51 +01:00
Peter Steinberger
032bc40466
test: make Windows path assertions portable 2026-05-05 11:38:24 +01:00
Solomon Neas
c6bc937a29
fix: recognize .cmd/.bat as Windows-side commands for WSL cwd translation
Recognize Windows .cmd/.bat ACP agent wrappers when translating WSL cwd, including non-C drive installs.\n\nGate: pnpm run check locally on PR branch; rebased onto main after #289 and #291.\n\nThanks @solomonneas.
2026-05-05 10:51:02 +01:00
Jordan Baker
97f301dc69
fix: closeSession uses session/close not nes/close
Send session/close from AcpClient.closeSession instead of the experimental nes/close method.\n\nGate: pnpm run check locally on PR branch; rebased onto main after #289.\n\nThanks @hexsprite.
2026-05-05 10:50:08 +01:00
Mike Chong
436fd4fd66
fix(win32): resolve Claude Code executable path for ACP spawn
Resolve claude.exe from PATH before spawning Claude ACP sessions on native Windows.\n\nGate: pnpm run check; GitHub CI green on "3637bce325a32bcf083626a3e9d4ee6534b19594".\n\nThanks @MikeChongCan.
2026-05-05 10:48:54 +01:00
dependabot[bot]
a23310ee99
chore(deps): bump pnpm/action-setup to 6.0.5
Bump pnpm/action-setup from 6.0.3 to 6.0.5.
2026-05-05 10:33:07 +01:00
Peter Steinberger
03d87dd493
fix: satisfy unknown-session conformance
Some checks failed
CI / scope (push) Has been cancelled
CI / ${{ matrix.name }} (pnpm run build, Build) (push) Has been cancelled
CI / ${{ matrix.name }} (pnpm run conformance:run -- --case acp.v1.initialize.handshake, Conformance Smoke) (push) Has been cancelled
CI / ${{ matrix.name }} (pnpm run format:check, Format) (push) Has been cancelled
CI / ${{ matrix.name }} (pnpm run lint, Lint) (push) Has been cancelled
CI / ${{ matrix.name }} (pnpm run test:coverage, Test, 22) (push) Has been cancelled
CI / ${{ matrix.name }} (pnpm run typecheck, Typecheck) (push) Has been cancelled
CI / Docs (push) Has been cancelled
2026-05-03 16:18:04 +01:00
Vincent Koc
1a9fdabfd7
fix(security): avoid prototype setter in replay patches
Some checks failed
CI / scope (push) Has been cancelled
CI / Docs (push) Has been cancelled
CI / ${{ matrix.name }} (pnpm run build, Build) (push) Has been cancelled
CI / ${{ matrix.name }} (pnpm run conformance:run -- --case acp.v1.initialize.handshake, Conformance Smoke) (push) Has been cancelled
CI / ${{ matrix.name }} (pnpm run format:check, Format) (push) Has been cancelled
CI / ${{ matrix.name }} (pnpm run lint, Lint) (push) Has been cancelled
CI / ${{ matrix.name }} (pnpm run test:coverage, Test, 22) (push) Has been cancelled
CI / ${{ matrix.name }} (pnpm run typecheck, Typecheck) (push) Has been cancelled
2026-04-30 03:38:06 -07:00
Vincent Koc
541742f800
fix(security): harden flow sanitizer paths 2026-04-30 03:33:29 -07:00
Peter Steinberger
e1a3546669
chore: drop stale npm lockfile
Some checks failed
CI / scope (push) Has been cancelled
CI / ${{ matrix.name }} (pnpm run build, Build) (push) Has been cancelled
CI / ${{ matrix.name }} (pnpm run conformance:run -- --case acp.v1.initialize.handshake, Conformance Smoke) (push) Has been cancelled
CI / ${{ matrix.name }} (pnpm run format:check, Format) (push) Has been cancelled
CI / ${{ matrix.name }} (pnpm run lint, Lint) (push) Has been cancelled
CI / ${{ matrix.name }} (pnpm run test:coverage, Test, 22) (push) Has been cancelled
CI / ${{ matrix.name }} (pnpm run typecheck, Typecheck) (push) Has been cancelled
CI / Docs (push) Has been cancelled
2026-04-27 11:55:10 +01:00
Peter Steinberger
2480c48806
fix: require advertised ACP model support
Some checks are pending
CI / scope (push) Waiting to run
CI / ${{ matrix.name }} (pnpm run build, Build) (push) Blocked by required conditions
CI / ${{ matrix.name }} (pnpm run conformance:run -- --case acp.v1.initialize.handshake, Conformance Smoke) (push) Blocked by required conditions
CI / ${{ matrix.name }} (pnpm run format:check, Format) (push) Blocked by required conditions
CI / ${{ matrix.name }} (pnpm run lint, Lint) (push) Blocked by required conditions
CI / ${{ matrix.name }} (pnpm run test:coverage, Test, 22) (push) Blocked by required conditions
CI / ${{ matrix.name }} (pnpm run typecheck, Typecheck) (push) Blocked by required conditions
CI / Docs (push) Blocked by required conditions
2026-04-25 21:52:18 +01:00
Peter Steinberger
119b84ee31
test: dedupe session record fixtures
Some checks are pending
CI / scope (push) Waiting to run
CI / ${{ matrix.name }} (pnpm run build, Build) (push) Blocked by required conditions
CI / ${{ matrix.name }} (pnpm run conformance:run -- --case acp.v1.initialize.handshake, Conformance Smoke) (push) Blocked by required conditions
CI / ${{ matrix.name }} (pnpm run format:check, Format) (push) Blocked by required conditions
CI / ${{ matrix.name }} (pnpm run lint, Lint) (push) Blocked by required conditions
CI / ${{ matrix.name }} (pnpm run test:coverage, Test, 22) (push) Blocked by required conditions
CI / ${{ matrix.name }} (pnpm run typecheck, Typecheck) (push) Blocked by required conditions
CI / Docs (push) Blocked by required conditions
2026-04-25 11:32:53 +01:00
Peter Steinberger
7f3c177e7c
refactor: dedupe session and ACP helpers 2026-04-25 11:32:44 +01:00
Peter Steinberger
7ac8cf0bb3
chore: bump version to 0.6.1 2026-04-25 11:24:40 +01:00
Peter Steinberger
69f527646b
chore: strengthen lint rules 2026-04-25 11:23:44 +01:00
102 changed files with 5381 additions and 5486 deletions

View File

@ -16,7 +16,7 @@ env:
jobs:
scope:
runs-on: blacksmith-16vcpu-ubuntu-2404
runs-on: ubuntu-24.04
permissions:
contents: read
pull-requests: read
@ -71,6 +71,7 @@ jobs:
fi
;;
.github/workflows/ci.yml)
docs_only=false
;;
*)
docs_only=false
@ -90,7 +91,7 @@ jobs:
name: ${{ matrix.name }}
needs: scope
if: needs.scope.outputs.docs_only != 'true'
runs-on: blacksmith-16vcpu-ubuntu-2404
runs-on: ubuntu-24.04
permissions:
contents: read
strategy:
@ -120,7 +121,7 @@ jobs:
submodules: false
- name: Setup pnpm
uses: pnpm/action-setup@v6.0.3
uses: pnpm/action-setup@v6.0.5
with:
version: ${{ env.PNPM_VERSION }}
@ -141,7 +142,7 @@ jobs:
name: Docs
needs: scope
if: needs.scope.outputs.docs_changed == 'true'
runs-on: blacksmith-16vcpu-ubuntu-2404
runs-on: ubuntu-24.04
permissions:
contents: read
steps:
@ -153,7 +154,7 @@ jobs:
submodules: false
- name: Setup pnpm
uses: pnpm/action-setup@v6.0.3
uses: pnpm/action-setup@v6.0.5
with:
version: ${{ env.PNPM_VERSION }}

View File

@ -39,7 +39,7 @@ jobs:
submodules: false
- name: Setup pnpm
uses: pnpm/action-setup@v6.0.3
uses: pnpm/action-setup@v6.0.5
with:
version: ${{ env.PNPM_VERSION }}
@ -113,7 +113,7 @@ jobs:
submodules: false
- name: Setup pnpm
uses: pnpm/action-setup@v6.0.3
uses: pnpm/action-setup@v6.0.5
with:
version: ${{ env.PNPM_VERSION }}

55
.github/workflows/pages.yml vendored Normal file
View File

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

View File

@ -29,7 +29,7 @@ jobs:
with:
fetch-depth: 0
- uses: pnpm/action-setup@v6.0.3
- uses: pnpm/action-setup@v6.0.5
with:
version: ${{ env.PNPM_VERSION }}

View File

@ -1,9 +1,9 @@
{
"$schema": "./node_modules/oxfmt/configuration_schema.json",
"experimentalSortImports": {
"sortImports": {
"newlinesBetween": false,
},
"experimentalSortPackageJson": {
"sortPackageJson": {
"sortScripts": true,
},
"tabWidth": 2,

View File

@ -8,19 +8,137 @@
},
"rules": {
"curly": "error",
"eslint-plugin-unicorn/prefer-array-find": "off",
"eslint-plugin-unicorn/prefer-array-find": "error",
"eslint/no-array-constructor": "error",
"eslint/no-await-in-loop": "off",
"eslint/no-new": "off",
"eslint/no-constructor-return": "error",
"eslint/no-div-regex": "error",
"eslint/no-empty-pattern": "error",
"eslint/no-else-return": "error",
"eslint/no-extra-label": "error",
"eslint/no-lone-blocks": "error",
"eslint/no-multi-str": "error",
"eslint/no-new": "error",
"eslint/no-underscore-dangle": ["error", { "allow": ["_meta"] }],
"eslint/no-new-wrappers": "error",
"eslint/no-object-constructor": "error",
"eslint/no-proto": "error",
"eslint/no-regex-spaces": "error",
"eslint/no-return-assign": "error",
"eslint/no-self-compare": "error",
"eslint/no-sequences": "error",
"eslint/no-shadow": "off",
"eslint/no-unmodified-loop-condition": "off",
"oxc/no-accumulating-spread": "off",
"oxc/no-async-endpoint-handlers": "off",
"oxc/no-map-spread": "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",
"eslint/no-useless-concat": "error",
"eslint/no-useless-constructor": "error",
"eslint/no-var": "error",
"eslint/no-warning-comments": "error",
"eslint/prefer-exponentiation-operator": "error",
"eslint/prefer-numeric-literals": "error",
"eslint/radix": "error",
"eslint/unicode-bom": "error",
"eslint/yoda": "error",
"import/no-absolute-path": "error",
"import/no-empty-named-blocks": "error",
"import/no-self-import": "error",
"node/no-exports-assign": "error",
"oxc/no-accumulating-spread": "error",
"oxc/no-async-endpoint-handlers": "error",
"oxc/no-map-spread": "error",
"promise/no-new-statics": "error",
"typescript/adjacent-overload-signatures": "error",
"typescript/ban-tslint-comment": "error",
"typescript/consistent-return": "error",
"typescript/no-empty-object-type": ["error", { "allowInterfaces": "with-single-extends" }],
"typescript/no-explicit-any": "error",
"typescript/no-extraneous-class": "off",
"typescript/no-extraneous-class": "error",
"typescript/no-meaningless-void-operator": "error",
"typescript/no-non-null-asserted-nullish-coalescing": "error",
"typescript/no-unnecessary-qualifier": "error",
"typescript/no-unnecessary-type-arguments": "error",
"typescript/no-unnecessary-type-assertion": "error",
"typescript/no-unnecessary-type-constraint": "error",
"typescript/no-unnecessary-type-conversion": "error",
"typescript/no-unnecessary-type-parameters": "error",
"typescript/no-unsafe-type-assertion": "off",
"typescript/no-useless-default-assignment": "error",
"typescript/prefer-find": "error",
"typescript/prefer-function-type": "error",
"typescript/prefer-includes": "error",
"typescript/prefer-reduce-type-parameter": "error",
"typescript/prefer-return-this-type": "error",
"typescript/prefer-ts-expect-error": "error",
"typescript/switch-exhaustiveness-check": [
"error",
{ "considerDefaultExhaustiveForUnions": true }
],
"unicorn/consistent-date-clone": "error",
"unicorn/consistent-empty-array-spread": "error",
"unicorn/consistent-function-scoping": "off",
"unicorn/require-post-message-target-origin": "off"
"unicorn/no-console-spaces": "error",
"unicorn/no-instanceof-array": "error",
"unicorn/no-length-as-slice-end": "error",
"unicorn/no-negation-in-equality-check": "error",
"unicorn/no-new-buffer": "error",
"unicorn/no-typeof-undefined": "error",
"unicorn/no-unnecessary-array-flat-depth": "error",
"unicorn/no-unnecessary-array-splice-count": "error",
"unicorn/no-unnecessary-slice-end": "error",
"unicorn/no-useless-error-capture-stack-trace": "error",
"unicorn/no-useless-promise-resolve-reject": "error",
"unicorn/prefer-array-some": "error",
"unicorn/prefer-date-now": "error",
"unicorn/prefer-dom-node-text-content": "error",
"unicorn/prefer-keyboard-event-key": "error",
"unicorn/prefer-math-min-max": "error",
"unicorn/prefer-negative-index": "error",
"unicorn/prefer-node-protocol": "error",
"unicorn/prefer-number-properties": "error",
"unicorn/prefer-optional-catch-binding": "error",
"unicorn/prefer-prototype-methods": "error",
"unicorn/prefer-regexp-test": "error",
"unicorn/prefer-set-size": "error",
"unicorn/prefer-string-slice": "error",
"unicorn/require-array-join-separator": "error",
"unicorn/require-number-to-fixed-digits-argument": "error",
"unicorn/require-post-message-target-origin": "error",
"unicorn/throw-new-error": "error"
},
"ignorePatterns": ["dist/", "dist-test/", "node_modules/", "*.tgz"]
"ignorePatterns": [
"dist/",
"dist-test/",
"examples/flows/replay-viewer/dist/",
"node_modules/",
"package-lock.json",
"pnpm-lock.yaml",
"*.tgz",
"**/.cache/**",
"**/build/**",
"**/coverage/**",
"**/dist/**",
"**/dist-test/**",
"**/node_modules/**"
],
"overrides": [
{
"files": [
"**/*.test.ts",
"**/*.test.tsx",
"**/*.integration.ts",
"**/*.live.integration.ts",
"**/*test-harness.ts",
"**/*test-helpers.ts",
"**/*test-support.ts"
],
"rules": {
"eslint/no-warning-comments": "off",
"typescript/no-explicit-any": "off",
"typescript/no-floating-promises": "off",
"typescript/unbound-method": "off"
}
}
]
}

View File

@ -12,6 +12,24 @@ 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)
### Changes

1
CNAME Normal file
View File

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

View File

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

View File

@ -43,11 +43,11 @@ acpx [global_options] <agent> sessions [list | new [--name <name>] | ensure [--n
`<agent>` can be:
- built-in friendly name from [../README.md](../README.md)
- built-in friendly name from [the README](https://github.com/openclaw/acpx/blob/main/README.md)
- unknown token (treated as raw command)
- overridden by `--agent <command>` escape hatch
Additional built-in agent docs live in [../agents/README.md](../agents/README.md).
Additional built-in agent docs live in [the Agents page](agents.md).
Prompt options:
@ -102,22 +102,22 @@ or close a PR if you run it against a live repository.
All global options:
| Option | Description | Details |
| ---------------------------------------- | ---------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `--agent <command>` | Raw ACP agent command (escape hatch) | Do not combine with positional agent token. |
| `--cwd <dir>` | Working directory | Defaults to current directory. Stored as absolute path for scoping. |
| `--approve-all` | Auto-approve all permissions | Permission mode `approve-all`. |
| `--approve-reads` | Auto-approve reads/searches, prompt for others | Default permission mode. |
| `--deny-all` | Deny all permissions | Permission mode `deny-all`. |
| `--format <fmt>` | Output format | `text` (default), `json`, `quiet`. |
| `--suppress-reads` | Suppress read file contents | Replaces raw read payloads with `[read output suppressed]`. |
| `--json-strict` | Strict JSON mode | Requires `--format json`; suppresses non-JSON stderr output. |
| `--no-terminal` | Disable ACP terminal capability | Advertises `clientCapabilities.terminal: false` during ACP initialize for new agent clients. |
| `--non-interactive-permissions <policy>` | Non-TTY prompt policy | `deny` (default) or `fail` when approval prompt cannot be shown. |
| `--timeout <seconds>` | Max wait time for agent response | Must be positive. Decimal seconds allowed. |
| `--ttl <seconds>` | Queue owner idle TTL before shutdown | Default `300`. `0` disables TTL. |
| `--model <id>` | Set agent model | Passed through to agent-specific session creation metadata when applicable; if the agent advertises models, `acpx` also applies it via ACP `session/set_model`. |
| `--verbose` | Enable verbose logs | Prints ACP/debug details to stderr. |
| Option | Description | Details |
| ---------------------------------------- | ---------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `--agent <command>` | Raw ACP agent command (escape hatch) | Do not combine with positional agent token. |
| `--cwd <dir>` | Working directory | Defaults to current directory. Stored as absolute path for scoping. |
| `--approve-all` | Auto-approve all permissions | Permission mode `approve-all`. |
| `--approve-reads` | Auto-approve reads/searches, prompt for others | Default permission mode. |
| `--deny-all` | Deny all permissions | Permission mode `deny-all`. |
| `--format <fmt>` | Output format | `text` (default), `json`, `quiet`. |
| `--suppress-reads` | Suppress read file contents | Replaces raw read payloads with `[read output suppressed]`. |
| `--json-strict` | Strict JSON mode | Requires `--format json`; suppresses non-JSON stderr output. |
| `--no-terminal` | Disable ACP terminal capability | Advertises `clientCapabilities.terminal: false` during ACP initialize for new agent clients. |
| `--non-interactive-permissions <policy>` | Non-TTY prompt policy | `deny` (default) or `fail` when approval prompt cannot be shown. |
| `--timeout <seconds>` | Max wait time for agent response | Must be positive. Decimal seconds allowed. |
| `--ttl <seconds>` | Queue owner idle TTL before shutdown | Default `300`. `0` disables TTL. |
| `--model <id>` | Set agent model | Claude-compatible adapters may consume session creation metadata; other agents must advertise ACP models and support `session/set_model`, otherwise `acpx` fails clearly instead of silently falling back. |
| `--verbose` | Enable verbose logs | Prints ACP/debug details to stderr. |
Permission flags are mutually exclusive. Using more than one of `--approve-all`, `--approve-reads`, `--deny-all` is a usage error.
@ -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 [../agents/README.md](../agents/README.md).
Additional built-in agent docs live in [the Agents page](agents.md).
### Custom positional agents

169
docs/VISION.md Normal file
View File

@ -0,0 +1,169 @@
---
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 Normal file
View File

@ -0,0 +1,198 @@
---
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 Normal file
View File

@ -0,0 +1,187 @@
---
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`.

136
docs/custom-agents.md Normal file
View File

@ -0,0 +1,136 @@
---
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.

49
docs/exit-codes.md Normal file
View File

@ -0,0 +1,49 @@
---
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 Normal file
View File

@ -0,0 +1,198 @@
---
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`.

55
docs/index.md Normal file
View File

@ -0,0 +1,55 @@
---
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 Normal file
View File

@ -0,0 +1,108 @@
---
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.

144
docs/output-formats.md Normal file
View File

@ -0,0 +1,144 @@
---
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.

137
docs/permissions.md Normal file
View File

@ -0,0 +1,137 @@
---
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.

181
docs/prompting.md Normal file
View File

@ -0,0 +1,181 @@
---
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.

136
docs/quickstart.md Normal file
View File

@ -0,0 +1,136 @@
---
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.

112
docs/session-control.md Normal file
View File

@ -0,0 +1,112 @@
---
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 Normal file
View File

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

View File

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

View File

@ -1,32 +1,29 @@
import { acp, checkpoint, defineFlow, extractJsonObject } from "acpx/flows";
import { acp, checkpoint, decision, decisionEdge, 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: acp({
async prompt({ input }) {
classify: decision({
choices: classifyChoices,
question: ({ 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.",
"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"',
"}",
"Pick `continue` if it is concrete and scoped.",
"Pick `checkpoint` if it is ambiguous or needs clarification.",
"",
`Task: ${task}`,
].join("\n");
},
parse: (text) => extractJsonObject(text),
}),
continue_lane: acp({
async prompt({ outputs }) {
@ -52,15 +49,13 @@ export default defineFlow({
}),
},
edges: [
{
decisionEdge({
from: "classify",
switch: {
on: "$.route",
cases: {
continue: "continue_lane",
checkpoint: "checkpoint_lane",
},
choices: classifyChoices,
cases: {
continue: "continue_lane",
checkpoint: "checkpoint_lane",
},
},
}),
],
});

View File

@ -519,7 +519,7 @@ async function prepareWorkspace(pr) {
localBranch,
pushRemote,
pushRef: headRef,
isCrossRepository: Boolean(prData.head.repo.full_name !== prData.base.repo.full_name),
isCrossRepository: prData.head.repo.full_name !== prData.base.repo.full_name,
});
return {
@ -536,7 +536,7 @@ async function prepareWorkspace(pr) {
flowDir: metaDir,
linkedIssueNumber,
changedFiles: Array.isArray(files) ? files : [],
isCrossRepository: Boolean(prData.head.repo.full_name !== prData.base.repo.full_name),
isCrossRepository: prData.head.repo.full_name !== prData.base.repo.full_name,
};
}
@ -1188,7 +1188,7 @@ function loadPullRequestInput(input) {
}
function formatPrTriageRunTitle(pr) {
const repoName = pr.repo.split("/").filter(Boolean).at(-1) ?? pr.repo;
const repoName = pr.repo.split("/").findLast(Boolean) ?? pr.repo;
return `PR-triage-${repoName}-${pr.prNumber}`;
}

View File

@ -124,7 +124,7 @@ function resolveCurrentSessionId(bundle: ViewerRunLiveState): string | null {
}
const sessions = Object.values(bundle.sessions);
return sessions.length === 1 ? sessions[0]!.id : null;
return sessions.length === 1 ? sessions[0].id : null;
}
function replayBundledSession(
@ -245,7 +245,7 @@ function inferPersistedLiveTurn(
return null;
}
const normalizedMessages = messages as NonNullable<SessionRecord["messages"]>;
const normalizedMessages = messages;
const messageStart = findLastUserMessageIndex(normalizedMessages);
if (messageStart == null) {

View File

@ -116,7 +116,7 @@ export function createReplayLiveSyncServer(options: ReplayLiveSyncOptions): Repl
sendMessage(client.socket, {
type: "error",
code: "protocol_error",
message: `Unsupported replay protocol: ${message.protocol}`,
message: "Unsupported replay protocol.",
});
}
return;

View File

@ -84,7 +84,7 @@ export async function createReplayViewerServer(
response.setHeader("content-type", "application/json; charset=utf-8");
response.end(
JSON.stringify({
error: error instanceof Error ? error.message : String(error),
error: formatPublicError(error),
}),
);
return;
@ -189,13 +189,14 @@ export async function handleApiRequest(
response.setHeader("content-type", contentTypeFor(relativePath ?? ""));
response.end(payload);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
const code =
error instanceof Error &&
/outside run bundle|outside runs directory|not allowed|required/.test(error.message)
? 400
: 404;
writeJson(response, code, { error: message });
writeJson(response, code, {
error: code === 400 ? "Invalid run bundle file request" : "Run bundle file not found",
});
}
return true;
}
@ -274,6 +275,19 @@ function writeJson(response: http.ServerResponse, statusCode: number, value: unk
response.end(JSON.stringify(value));
}
function formatPublicError(error: unknown): string {
if (error instanceof Error) {
return "Replay viewer request failed";
}
if (typeof error === "string") {
return error;
}
if (typeof error === "number" || typeof error === "boolean" || typeof error === "bigint") {
return String(error);
}
return "Unknown error";
}
function contentTypeFor(filePath: string): string {
if (filePath.endsWith(".json")) {
return "application/json; charset=utf-8";

View File

@ -270,7 +270,7 @@ function ModeButton({
children: ReactNode;
label: string;
active: boolean;
onClick(): void;
onClick: () => void;
}) {
return (
<button

View File

@ -11,8 +11,8 @@ type InspectorPanelProps = {
sessionRevealProgress: number | null;
liveStreaming: boolean;
activeTab: "attempt" | "session" | "events";
onTabChange(tab: "attempt" | "session" | "events"): void;
onSessionChange(sessionId: string): void;
onTabChange: (tab: "attempt" | "session" | "events") => void;
onSessionChange: (sessionId: string) => void;
};
export function InspectorPanel({
@ -49,7 +49,6 @@ export function InspectorPanel({
{activeTab === "session" ? (
<SessionTab
scrollContainerRef={bodyRef}
selectedAttempt={selectedAttempt}
sessionItems={sessionItems}
activeSessionId={activeSessionId}
sessionRevealProgress={sessionRevealProgress}
@ -71,7 +70,7 @@ function TabButton({
}: {
tab: "attempt" | "session" | "events";
activeTab: "attempt" | "session" | "events";
onTabChange(tab: "attempt" | "session" | "events"): void;
onTabChange: (tab: "attempt" | "session" | "events") => void;
}) {
return (
<button

View File

@ -15,7 +15,7 @@ export function ConversationMessage({
useEffect(() => {
if (!animate) {
setEntered(true);
return;
return undefined;
}
setEntered(false);
@ -125,10 +125,6 @@ function ToolEventCard({
);
}
function formatToolStatus(status: string): string {
return status.replace(/_/g, " ").trim();
}
function resolveToolStatusTone(
status: string | undefined,
isError: boolean,

View File

@ -1,12 +1,11 @@
import { useRef, type RefObject } from "react";
import { useStickyAutoFollow } from "../../hooks/use-sticky-auto-follow.js";
import { resolveSessionRenderState } from "../../lib/session-render-state.js";
import type { SelectedAttemptView, SessionListItemView } from "../../lib/view-model.js";
import type { SessionListItemView } from "../../lib/view-model.js";
import { ConversationMessage } from "./conversation-message.js";
export function SessionTab({
scrollContainerRef,
selectedAttempt,
sessionItems,
activeSessionId,
sessionRevealProgress,
@ -14,12 +13,11 @@ export function SessionTab({
onSessionChange,
}: {
scrollContainerRef: RefObject<HTMLDivElement | null>;
selectedAttempt: SelectedAttemptView;
sessionItems: SessionListItemView[];
activeSessionId: string | null;
sessionRevealProgress: number | null;
liveStreaming: boolean;
onSessionChange(sessionId: string): void;
onSessionChange: (sessionId: string) => void;
}) {
const activeSession =
sessionItems.find((session) => session.id === activeSessionId) ?? sessionItems[0] ?? null;

View File

@ -11,15 +11,15 @@ type StepTimelineProps = {
currentNodeLabel: string;
currentMeta: string;
playing: boolean;
onSelect(index: number): void;
onPlay(): void;
onPause(): void;
onReset(): void;
onJumpToEnd(): void;
onSeekStart(): void;
onSeek(value: number): void;
onSeekCommit(value: number): void;
onPlaybackRateChange(playbackRate: number): void;
onSelect: (index: number) => void;
onPlay: () => void;
onPause: () => void;
onReset: () => void;
onJumpToEnd: () => void;
onSeekStart: () => void;
onSeek: (value: number) => void;
onSeekCommit: (value: number) => void;
onPlaybackRateChange: (playbackRate: number) => void;
};
export function StepTimeline({
@ -134,7 +134,7 @@ function IconButton({
}: {
children: ReactNode;
label: string;
onClick(): void;
onClick: () => void;
disabled?: boolean;
primary?: boolean;
}) {
@ -161,7 +161,7 @@ function SpeedButton({
children: ReactNode;
label: string;
active: boolean;
onClick(): void;
onClick: () => void;
}) {
return (
<button

View File

@ -29,7 +29,7 @@ export function useGraphCamera({
useEffect(() => {
if (!flowInstance?.viewportInitialized || !runId || viewMode !== "overview") {
return;
return undefined;
}
lastFollowTargetRef.current = null;
@ -55,11 +55,11 @@ export function useGraphCamera({
!currentNodeId ||
!currentNodePosition
) {
return;
return undefined;
}
const followTargetKey = `${runId}:${layoutKey}:${currentNodeId}`;
if (lastFollowTargetRef.current === followTargetKey) {
return;
return undefined;
}
lastFollowTargetRef.current = followTargetKey;
@ -95,5 +95,5 @@ export function useGraphCamera({
}
function easeOutCubic(value: number): number {
return 1 - Math.pow(1 - value, 3);
return 1 - (1 - value) ** 3;
}

View File

@ -11,7 +11,7 @@ export function useGraphLayout(bundle: LoadedRunBundle | null) {
if (!bundle) {
setLayout(null);
return;
return undefined;
}
setLayout(null);

View File

@ -37,7 +37,7 @@ export function useStickyAutoFollow(options: {
useEffect(() => {
const scrollContainer = scrollContainerRef.current;
if (!enabled || !scrollContainer) {
return;
return undefined;
}
const handleWheel = (event: WheelEvent) => {

View File

@ -1,8 +1,6 @@
import fastJsonPatch from "fast-json-patch";
import type { ReplayJsonPatchOperation } from "../types.js";
const { applyPatch, compare } = fastJsonPatch;
export function applyReplayPatch<TState extends object>(
state: TState,
ops: ReplayJsonPatchOperation[],
@ -10,12 +8,13 @@ export function applyReplayPatch<TState extends object>(
let nextDocument = structuredClone(state) as unknown;
for (const op of ops) {
assertSafePatchOperation(op);
if (op.op === "append") {
applyAppendOperation(nextDocument, op.path, op.value);
continue;
}
nextDocument = applyPatch(nextDocument, [op], true, false).newDocument;
nextDocument = fastJsonPatch.applyPatch(nextDocument, [op], true, false).newDocument;
}
return nextDocument as TState;
@ -25,13 +24,13 @@ export function createReplayPatch<TState extends object>(
previousState: TState,
nextState: TState,
): ReplayJsonPatchOperation[] {
const rawOps = compare(previousState, nextState) as ReplayJsonPatchOperation[];
const rawOps = fastJsonPatch.compare(previousState, nextState) as ReplayJsonPatchOperation[];
if (rawOps.length === 0) {
return rawOps;
}
const normalized: ReplayJsonPatchOperation[] = [];
let workingState = structuredClone(previousState) as TState;
let workingState = structuredClone(previousState);
for (const op of rawOps) {
const nextOp = normalizeReplayOperation(workingState, op);
@ -42,8 +41,8 @@ export function createReplayPatch<TState extends object>(
return normalized;
}
function normalizeReplayOperation<TState extends object>(
state: TState,
function normalizeReplayOperation(
state: object,
op: ReplayJsonPatchOperation,
): ReplayJsonPatchOperation {
if (op.op === "replace") {
@ -134,6 +133,7 @@ function getValueAtPointer(document: unknown, path: string): unknown {
}
if (current && typeof current === "object") {
assertSafeObjectKey(token, path);
current = (current as Record<string, unknown>)[token];
continue;
}
@ -167,7 +167,13 @@ function setValueAtPointer(document: unknown, path: string, value: unknown): voi
if (!parent || typeof parent !== "object") {
throw new Error(`Cannot set value at non-object parent for ${path}`);
}
(parent as Record<string, unknown>)[lastToken] = value;
assertSafeObjectKey(lastToken, path);
Object.defineProperty(parent, lastToken, {
configurable: true,
enumerable: true,
value,
writable: true,
});
}
function resolveParentPointer(
@ -181,7 +187,7 @@ function resolveParentPointer(
let current = document;
for (let index = 0; index < tokens.length - 1; index += 1) {
const token = tokens[index]!;
const token = tokens[index];
if (Array.isArray(current)) {
const arrayIndex = Number(token);
if (!Number.isInteger(arrayIndex)) {
@ -193,6 +199,7 @@ function resolveParentPointer(
if (!current || typeof current !== "object") {
throw new Error(`Invalid JSON Pointer parent for ${path}`);
}
assertSafeObjectKey(token, path);
current = (current as Record<string, unknown>)[token];
}
@ -208,3 +215,22 @@ function resolveParentPointer(
lastToken: tokens.at(-1)!,
};
}
function assertSafePatchOperation(op: ReplayJsonPatchOperation): void {
assertSafePointer(op.path);
if ("from" in op) {
assertSafePointer(op.from);
}
}
function assertSafePointer(path: string): void {
for (const token of decodePointer(path)) {
assertSafeObjectKey(token, path);
}
}
function assertSafeObjectKey(token: string, path: string): void {
if (token === "__proto__" || token === "prototype" || token === "constructor") {
throw new Error(`Unsafe JSON Pointer key in ${path}`);
}
}

View File

@ -40,7 +40,7 @@ function readRequestedRunIdFromPath(pathname: string): string | null {
return null;
}
const rawRunId = pathname.slice(RUN_PATH_PREFIX.length).split("/").filter(Boolean)[0] ?? "";
const rawRunId = pathname.slice(RUN_PATH_PREFIX.length).split("/").find(Boolean) ?? "";
const runId = decodeURIComponent(rawRunId).trim();
return runId.length > 0 ? runId : null;
}

View File

@ -160,7 +160,7 @@ export function revealConversationTranscript(
return sessionSlice;
}
const firstHighlightedIndex = highlightedIndexes[0]!;
const firstHighlightedIndex = highlightedIndexes[0];
const lastHighlightedIndex = highlightedIndexes.at(-1)!;
const visiblePrefix = sessionSlice.slice(0, firstHighlightedIndex);
const highlightedSlice = sessionSlice.slice(firstHighlightedIndex, lastHighlightedIndex + 1);
@ -343,7 +343,7 @@ function describeMessage(
"textBlocks" | "toolUses" | "toolResults" | "hiddenPayloads" | "parts"
> {
if (!message || typeof message !== "object") {
const text = String(message ?? "");
const text = primitiveText(message);
return {
textBlocks: [text].filter(Boolean),
toolUses: [],
@ -394,7 +394,7 @@ function describeStructuredMessage(
if (Array.isArray(content)) {
for (const [index, part] of content.entries()) {
if (!part || typeof part !== "object") {
const text = String(part ?? "").trim();
const text = primitiveText(part).trim();
if (text) {
textBlocks.push(text);
contentParts.push({ type: "text", text });
@ -414,8 +414,12 @@ function describeStructuredMessage(
if ("ToolUse" in part) {
const toolUse = (part as { ToolUse?: Record<string, unknown> }).ToolUse;
if (toolUse && typeof toolUse === "object") {
const toolUseId =
typeof toolUse.id === "string" || typeof toolUse.id === "number"
? String(toolUse.id)
: `tool-use-${index}`;
const toolUseView = {
id: String(toolUse.id ?? `tool-use-${index}`),
id: toolUseId,
name: typeof toolUse.name === "string" ? toolUse.name : "Tool call",
summary: summarizeToolUse(toolUse),
raw: toolUse,
@ -667,3 +671,18 @@ function truncate(value: string, maxLength: number): string {
}
return `${normalized.slice(0, maxLength - 1)}`;
}
function primitiveText(value: unknown): string {
if (value == null) {
return "";
}
if (
typeof value === "string" ||
typeof value === "number" ||
typeof value === "boolean" ||
typeof value === "bigint"
) {
return String(value);
}
return "";
}

View File

@ -139,12 +139,11 @@ export function buildGraph(
const graphEdges = expandedEdges.map((edge) => {
const isTraversed = actualTransitions.has(`${edge.source}->${edge.target}`);
const isSelected = Boolean(
const isSelected =
!terminalSelectionSettled &&
selectedStep != null &&
visibleSteps.at(-2)?.nodeId === edge.source &&
selectedStep.nodeId === edge.target,
);
selectedStep.nodeId === edge.target;
const isBackEdge = backEdgeIds.has(edge.edgeId);
const stroke = isSelected
? "var(--edge-active)"
@ -753,7 +752,7 @@ function computeTailDepths(
memo.set(nodeId, null);
return null;
}
const childDepth = visit(targets[0]!);
const childDepth = visit(targets[0]);
const depth = childDepth == null ? null : childDepth + 1;
memo.set(nodeId, depth);
return depth;

View File

@ -19,5 +19,6 @@ export default defineConfig({
build: {
outDir: path.resolve(__dirname, "dist"),
emptyOutDir: true,
chunkSizeWarningLimit: 1_500,
},
});

4347
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -1,6 +1,6 @@
{
"name": "acpx",
"version": "0.6.0",
"version": "0.7.0",
"description": "Headless CLI client for the Agent Client Protocol (ACP) — talk to coding agents from the command line",
"keywords": [
"acp",
@ -36,17 +36,18 @@
},
"scripts": {
"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})\" && tsc -p tsconfig.test.json",
"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",
"check:docs": "pnpm run format:docs:check && pnpm run lint:docs && pnpm run docs:site",
"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",
"format:docs": "git ls-files 'docs/**/*.md' 'examples/flows/**/*.md' 'README.md' | xargs oxfmt --write",
"format:docs:check": "git ls-files 'docs/**/*.md' 'examples/flows/**/*.md' 'README.md' | xargs oxfmt --check",
"lint": "oxlint --type-aware src && pnpm run lint:persisted-key-casing && pnpm run lint:flow-schema-terms",
"lint": "oxlint --type-aware --deny-warnings src scripts examples test && pnpm run lint:persisted-key-casing && pnpm run lint:flow-schema-terms",
"lint:docs": "markdownlint-cli2 README.md docs/**/*.md examples/flows/**/*.md",
"lint:fix": "oxlint --type-aware --fix src && pnpm run format",
"lint:flow-schema-terms": "tsx scripts/lint-flow-schema-terms.ts",
@ -59,7 +60,7 @@
"test:coverage": "pnpm run build:test && node --experimental-test-coverage --test-coverage-exclude=dist-test/test/**/*.js --test-coverage-lines=83 --test-coverage-branches=76 --test-coverage-functions=86 --test dist-test/test/*.test.js",
"test:live": "pnpm run build:test && node --test dist-test/test/cursor-live.integration.js",
"typecheck": "tsgo --noEmit",
"typecheck:tsc": "tsc --noEmit",
"typecheck:tsc": "tsgo --noEmit",
"viewer": "tsx examples/flows/replay-viewer/server.ts start",
"viewer:build": "vite build --config examples/flows/replay-viewer/vite.config.ts",
"viewer:dev": "tsx examples/flows/replay-viewer/server.ts start",
@ -67,14 +68,14 @@
"viewer:preview": "tsx examples/flows/replay-viewer/server.ts start",
"viewer:status": "tsx examples/flows/replay-viewer/server.ts status",
"viewer:stop": "tsx examples/flows/replay-viewer/server.ts stop",
"viewer:typecheck": "tsc -p examples/flows/replay-viewer/tsconfig.json --noEmit && tsc -p examples/flows/replay-viewer/tsconfig.server.json --noEmit"
"viewer:typecheck": "tsgo -p examples/flows/replay-viewer/tsconfig.json --noEmit && tsgo -p examples/flows/replay-viewer/tsconfig.server.json --noEmit"
},
"dependencies": {
"@agentclientprotocol/sdk": "^0.20.0",
"@agentclientprotocol/sdk": "^0.21.0",
"commander": "^14.0.3",
"skillflag": "^0.1.4",
"tsx": "^4.21.0",
"zod": "^4.3.6"
"zod": "^4.4.2"
},
"devDependencies": {
"@types/node": "^25.6.0",
@ -82,7 +83,7 @@
"@types/react-dom": "^19.2.3",
"@types/react-test-renderer": "^19.1.0",
"@types/ws": "^8.18.1",
"@typescript/native-preview": "7.0.0-dev.20260424.2",
"@typescript/native-preview": "7.0.0-dev.20260503.1",
"@vitejs/plugin-react": "^6.0.1",
"@xyflow/react": "^12.10.2",
"elkjs": "^0.11.1",
@ -90,9 +91,9 @@
"husky": "^9.1.7",
"lint-staged": "^16.4.0",
"markdownlint-cli2": "^0.22.1",
"oxfmt": "^0.46.0",
"oxlint": "^1.61.0",
"oxlint-tsgolint": "^0.21.1",
"oxfmt": "^0.47.0",
"oxlint": "^1.62.0",
"oxlint-tsgolint": "^0.22.1",
"react": "^19.2.5",
"react-dom": "^19.2.5",
"react-test-renderer": "^19.2.5",

650
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

784
scripts/build-docs-site.mjs Normal file
View File

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

View File

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

View File

@ -40,7 +40,7 @@ function makeRecord(): SessionRecord {
}
function assertSerializationPolicy(): void {
const persisted = serializeSessionRecordForDisk(makeRecord()) as Record<string, unknown>;
const persisted = serializeSessionRecordForDisk(makeRecord());
const violations = findPersistedKeyPolicyViolations(persisted);
assert.equal(
violations.length,

View File

@ -31,6 +31,7 @@ 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
@ -155,7 +156,7 @@ Behavior:
- `set-mode` mode ids are adapter-defined; unsupported values are rejected by the adapter (often `Invalid params`).
- `set`: calls ACP `session/set_config_option`.
- For codex, `thought_level` is accepted as a compatibility alias for codex-acp `reasoning_effort`.
- `--model <id>`: passed through to agent-specific session creation metadata when applicable; if the agent advertises models, `acpx` also applies it via `session/set_model`.
- `--model <id>`: Claude-compatible adapters may consume session creation metadata; other agents must advertise ACP models and support `session/set_model`, otherwise `acpx` fails clearly instead of silently falling back.
- `set model <id>`: calls `session/set_model`. This is the generic ACP method for mid-session model switching.
- `set-mode`/`set` route through queue-owner IPC when active, otherwise reconnect directly.
@ -202,7 +203,7 @@ Behavior:
- `--suppress-reads`: suppress raw read-file contents while preserving the selected format
- `--timeout <seconds>`: max wait time (positive number)
- `--ttl <seconds>`: queue owner idle TTL before shutdown (default `300`, `0` disables TTL)
- `--model <id>`: request an agent model during session creation; when the agent advertises models, `acpx` also applies it via `session/set_model`
- `--model <id>`: request an agent model during session creation; non-Claude agents must advertise ACP models and support `session/set_model`
- `--verbose`: verbose ACP/debug logs to stderr
Permission flags are mutually exclusive.

View File

@ -1,6 +1,11 @@
import { spawn } from "node:child_process";
import path from "node:path";
import { CopilotAcpUnsupportedError } from "../errors.js";
import { buildSpawnCommandOptions } from "../spawn-command-options.js";
import {
buildSpawnCommandOptions,
readWindowsEnvValue,
resolveWindowsCommand,
} from "../spawn-command-options.js";
import { type AcpClientOptions } from "../types.js";
import { basenameToken, splitCommandLine } from "./client-process.js";
@ -152,54 +157,12 @@ function compareVersionParts(left: readonly number[], right: readonly number[]):
}
async function detectGeminiVersion(command: string): Promise<GeminiVersion | undefined> {
return await new Promise<GeminiVersion | undefined>((resolve) => {
const child = spawn(
command,
["--version"],
buildSpawnCommandOptions(command, {
stdio: ["ignore", "pipe", "pipe"],
windowsHide: true,
}),
);
let stdout = "";
let stderr = "";
let settled = false;
const finish = (value: GeminiVersion | undefined) => {
if (settled) {
return;
}
settled = true;
clearTimeout(timer);
child.removeAllListeners();
child.stdout?.removeAllListeners();
child.stderr?.removeAllListeners();
resolve(value);
};
const timer = setTimeout(() => {
child.kill("SIGKILL");
finish(undefined);
}, GEMINI_VERSION_TIMEOUT_MS);
child.stdout?.setEncoding("utf8");
child.stderr?.setEncoding("utf8");
child.stdout?.on("data", (chunk: string) => {
stdout += chunk;
});
child.stderr?.on("data", (chunk: string) => {
stderr += chunk;
});
child.once("error", () => {
finish(undefined);
});
child.once("close", () => {
const versionLine = `${stdout}\n${stderr}`
.split(/\r?\n/)
.map((line) => line.trim())
.find((line) => /\d+\.\d+\.\d+/.test(line));
finish(parseGeminiVersion(versionLine));
});
});
const output = await readCommandOutput(command, ["--version"], GEMINI_VERSION_TIMEOUT_MS);
const versionLine = output
?.split(/\r?\n/)
.map((line) => line.trim())
.find((line) => /\d+\.\d+\.\d+/.test(line));
return parseGeminiVersion(versionLine);
}
export async function resolveGeminiCommandArgs(
@ -363,3 +326,20 @@ export function buildClaudeCodeOptionsMeta(
return meta;
}
export function resolveClaudeCodeExecutable(
platform: NodeJS.Platform = process.platform,
env: NodeJS.ProcessEnv = process.env,
): string | undefined {
if (platform !== "win32") {
return undefined;
}
if (readWindowsEnvValue(env, "CLAUDE_CODE_EXECUTABLE")) {
return undefined;
}
const resolved = resolveWindowsCommand("claude", env);
if (!resolved) {
return undefined;
}
return path.resolve(resolved);
}

View File

@ -199,9 +199,11 @@ 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.replace(/\\/g, "/").toLowerCase();
return normalized.endsWith(".exe") || normalized.startsWith("/mnt/c/");
const normalized = command.toLowerCase();
return WINDOWS_EXECUTABLE_EXTENSION_RE.test(normalized);
}
async function runWslpath(cwd: string): Promise<string> {

View File

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

View File

@ -9,7 +9,7 @@ function asRecord(value: unknown): Record<string, unknown> | undefined {
return value as Record<string, unknown>;
}
function toAcpErrorPayload(value: unknown): OutputErrorAcpPayload | undefined {
export function toAcpErrorPayload(value: unknown): OutputErrorAcpPayload | undefined {
const record = asRecord(value);
if (!record) {
return undefined;

51
src/acp/model-support.ts Normal file
View File

@ -0,0 +1,51 @@
import type { SessionModelState } from "@agentclientprotocol/sdk";
import { isClaudeAcpCommand } from "./agent-command.js";
import { splitCommandLine } from "./client-process.js";
export class RequestedModelUnsupportedError extends Error {
constructor(message: string) {
super(message);
this.name = "RequestedModelUnsupportedError";
}
}
export function supportsLegacyClaudeCodeModelMetadata(agentCommand: string | undefined): boolean {
if (!agentCommand) {
return false;
}
const { command, args } = splitCommandLine(agentCommand);
return isClaudeAcpCommand(command, args);
}
export function formatAvailableModelIds(models: SessionModelState | undefined): string {
const ids =
models?.availableModels
.map((model) => model.modelId.trim())
.filter((modelId) => modelId.length > 0) ?? [];
return ids.length > 0 ? ids.join(", ") : "none advertised";
}
export function assertRequestedModelSupported(params: {
requestedModel: string;
models: SessionModelState | undefined;
agentCommand?: string;
context: "apply" | "replay";
}): void {
if (!params.models) {
if (supportsLegacyClaudeCodeModelMetadata(params.agentCommand)) {
return;
}
const action = params.context === "replay" ? "replay saved model" : "apply --model";
throw new RequestedModelUnsupportedError(
`Cannot ${action} "${params.requestedModel}": the ACP agent did not advertise model support. Generic model selection requires ACP models plus session/set_model support, or an adapter-specific startup model flag.`,
);
}
const advertised = new Set(params.models.availableModels.map((model) => model.modelId));
if (!advertised.has(params.requestedModel)) {
const action = params.context === "replay" ? "replay saved model" : "apply --model";
throw new RequestedModelUnsupportedError(
`Cannot ${action} "${params.requestedModel}": the ACP agent did not advertise that model. Available models: ${formatAvailableModelIds(params.models)}.`,
);
}
}

View File

@ -30,6 +30,7 @@ import {
resolvePermissionMode,
resolveSessionNameFromFlags,
type ExecFlags,
type GlobalFlags,
type PromptFlags,
type SessionsHistoryFlags,
type SessionsNewFlags,
@ -156,6 +157,70 @@ function resolveRequestedOutputPolicy(globalFlags: {
};
}
type ResolvedAgentInvocation = ReturnType<typeof resolveAgentInvocation>;
function sessionOptionsFromGlobalFlags(
globalFlags: GlobalFlags,
): NonNullable<Parameters<SessionModule["createSession"]>[0]["sessionOptions"]> {
return {
model: globalFlags.model,
allowedTools: globalFlags.allowedTools,
maxTurns: globalFlags.maxTurns,
systemPrompt: globalFlags.systemPrompt,
};
}
function buildSessionStartOptions(params: {
agent: ResolvedAgentInvocation;
flags: SessionsNewFlags;
globalFlags: GlobalFlags;
config: ResolvedAcpxConfig;
permissionMode: ReturnType<typeof resolvePermissionMode>;
}): Parameters<SessionModule["createSession"]>[0] {
return {
agentCommand: params.agent.agentCommand,
cwd: params.agent.cwd,
name: params.flags.name,
resumeSessionId: params.flags.resumeSession,
mcpServers: params.config.mcpServers,
permissionMode: params.permissionMode,
nonInteractivePermissions: params.globalFlags.nonInteractivePermissions,
authCredentials: params.config.auth,
authPolicy: params.globalFlags.authPolicy,
terminal: params.globalFlags.terminal,
timeoutMs: params.globalFlags.timeout,
verbose: params.globalFlags.verbose,
sessionOptions: sessionOptionsFromGlobalFlags(params.globalFlags),
};
}
function missingScopedSessionMessage(
agent: ResolvedAgentInvocation,
sessionName: string | undefined,
): string {
return sessionName
? `No named session "${sessionName}" for cwd ${agent.cwd} and agent ${agent.agentName}`
: `No cwd session for ${agent.cwd} and agent ${agent.agentName}`;
}
async function findScopedSessionOrThrow(
agent: ResolvedAgentInvocation,
sessionName: string | undefined,
): Promise<SessionRecord> {
const record = await findSession({
agentCommand: agent.agentCommand,
cwd: agent.cwd,
name: sessionName,
includeClosed: true,
});
if (!record) {
throw new Error(missingScopedSessionMessage(agent, sessionName));
}
return record;
}
async function findRoutedSessionOrThrow(
agentCommand: string,
agentName: string,
@ -589,11 +654,7 @@ export async function handleSessionsClose(
});
if (!record) {
throw new Error(
sessionName
? `No named session "${sessionName}" for cwd ${agent.cwd} and agent ${agent.agentName}`
: `No cwd session for ${agent.cwd} and agent ${agent.agentName}`,
);
throw new Error(missingScopedSessionMessage(agent, sessionName));
}
const closed = await closeSession(record.acpxRecordId);
@ -625,26 +686,9 @@ export async function handleSessionsNew(
}
}
const created = await createSession({
agentCommand: agent.agentCommand,
cwd: agent.cwd,
name: flags.name,
resumeSessionId: flags.resumeSession,
mcpServers: config.mcpServers,
permissionMode,
nonInteractivePermissions: globalFlags.nonInteractivePermissions,
authCredentials: config.auth,
authPolicy: globalFlags.authPolicy,
terminal: globalFlags.terminal,
timeoutMs: globalFlags.timeout,
verbose: globalFlags.verbose,
sessionOptions: {
model: globalFlags.model,
allowedTools: globalFlags.allowedTools,
maxTurns: globalFlags.maxTurns,
systemPrompt: globalFlags.systemPrompt,
},
});
const created = await createSession(
buildSessionStartOptions({ agent, flags, globalFlags, config, permissionMode }),
);
printCreatedSessionBanner(created, agent.agentName, globalFlags.format, globalFlags.jsonStrict);
@ -667,26 +711,9 @@ export async function handleSessionsEnsure(
const agent = resolveAgentInvocation(explicitAgentName, globalFlags, config);
const [{ ensureSession }, { printCreatedSessionBanner, printEnsuredSessionByFormat }] =
await Promise.all([loadSessionModule(), loadOutputRenderModule()]);
const result = await ensureSession({
agentCommand: agent.agentCommand,
cwd: agent.cwd,
name: flags.name,
resumeSessionId: flags.resumeSession,
mcpServers: config.mcpServers,
permissionMode,
nonInteractivePermissions: globalFlags.nonInteractivePermissions,
authCredentials: config.auth,
authPolicy: globalFlags.authPolicy,
terminal: globalFlags.terminal,
timeoutMs: globalFlags.timeout,
verbose: globalFlags.verbose,
sessionOptions: {
model: globalFlags.model,
allowedTools: globalFlags.allowedTools,
maxTurns: globalFlags.maxTurns,
systemPrompt: globalFlags.systemPrompt,
},
});
const result = await ensureSession(
buildSessionStartOptions({ agent, flags, globalFlags, config, permissionMode }),
);
if (result.created) {
printCreatedSessionBanner(
@ -846,20 +873,7 @@ export async function handleSessionsShow(
): Promise<void> {
const globalFlags = resolveGlobalFlags(command, config);
const agent = resolveAgentInvocation(explicitAgentName, globalFlags, config);
const record = await findSession({
agentCommand: agent.agentCommand,
cwd: agent.cwd,
name: sessionName,
includeClosed: true,
});
if (!record) {
throw new Error(
sessionName
? `No named session "${sessionName}" for cwd ${agent.cwd} and agent ${agent.agentName}`
: `No cwd session for ${agent.cwd} and agent ${agent.agentName}`,
);
}
const record = await findScopedSessionOrThrow(agent, sessionName);
printSessionDetailsByFormat(record, globalFlags.format);
}
@ -873,20 +887,7 @@ export async function handleSessionsHistory(
): Promise<void> {
const globalFlags = resolveGlobalFlags(command, config);
const agent = resolveAgentInvocation(explicitAgentName, globalFlags, config);
const record = await findSession({
agentCommand: agent.agentCommand,
cwd: agent.cwd,
name: sessionName,
includeClosed: true,
});
if (!record) {
throw new Error(
sessionName
? `No named session "${sessionName}" for cwd ${agent.cwd} and agent ${agent.agentName}`
: `No cwd session for ${agent.cwd} and agent ${agent.agentName}`,
);
}
const record = await findScopedSessionOrThrow(agent, sessionName);
printSessionHistoryByFormat(record, flags.limit, globalFlags.format);
}

View File

@ -155,7 +155,7 @@ export function parseDaysOlderThan(value: string): number {
export function parsePruneBeforeDate(value: string): Date {
const date = new Date(value);
if (isNaN(date.getTime())) {
if (Number.isNaN(date.getTime())) {
throw new InvalidArgumentError(
`--before must be a valid date (e.g. 2026-01-01 or 2026-01-01T00:00:00Z)`,
);

View File

@ -93,6 +93,7 @@ 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: (
@ -395,6 +396,19 @@ export class SessionQueueOwner {
return;
}
if (request.type === "close_session") {
this.handleControlRequest({
socket,
requestId: request.requestId,
run: async () => ({
type: "close_session_result",
requestId: request.requestId,
closed: await this.controlHandlers.closeSession(request.timeoutMs),
}),
});
return;
}
if (request.type === "set_mode") {
this.handleControlRequest({
socket,

View File

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

View File

@ -1,4 +1,5 @@
import type { SetSessionConfigOptionResponse } from "@agentclientprotocol/sdk";
import { toAcpErrorPayload } from "../../acp/error-shapes.js";
import { isAcpJsonRpcMessage } from "../../acp/jsonrpc.js";
import { isPromptInput, textPrompt } from "../../prompt-content.js";
import {
@ -66,12 +67,20 @@ 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;
| QueueSetConfigOptionRequest
| QueueCloseSessionRequest;
export type QueueOwnerAcceptedMessage = {
type: "accepted";
@ -121,6 +130,13 @@ 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;
@ -142,6 +158,7 @@ export type QueueOwnerMessage =
| QueueOwnerSetModeResultMessage
| QueueOwnerSetModelResultMessage
| QueueOwnerSetConfigOptionResultMessage
| QueueOwnerCloseSessionResultMessage
| QueueOwnerErrorMessage;
function asRecord(value: unknown): Record<string, unknown> | undefined {
@ -171,25 +188,6 @@ function isOutputErrorOrigin(value: unknown): value is OutputErrorOrigin {
return typeof value === "string" && OUTPUT_ERROR_ORIGINS.includes(value as OutputErrorOrigin);
}
function parseAcpError(value: unknown): OutputErrorAcpPayload | undefined {
const record = asRecord(value);
if (!record) {
return undefined;
}
if (typeof record.code !== "number" || !Number.isFinite(record.code)) {
return undefined;
}
if (typeof record.message !== "string" || record.message.length === 0) {
return undefined;
}
return {
code: record.code,
message: record.message,
data: record.data,
};
}
function parseSessionOptions(value: unknown): QueueSessionOptions | null | undefined {
if (value == null) {
return undefined;
@ -329,6 +327,15 @@ 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;
@ -537,7 +544,7 @@ export function parseQueueOwnerMessage(raw: unknown): QueueOwnerMessage | null {
? message.detailCode
: undefined;
const retryable = typeof message.retryable === "boolean" ? message.retryable : undefined;
const acp = parseAcpError(message.acp);
const acp = toAcpErrorPayload(message.acp);
const outputAlreadyEmitted =
typeof message.outputAlreadyEmitted === "boolean" ? message.outputAlreadyEmitted : undefined;

View File

@ -0,0 +1,36 @@
import type { AcpClient, SessionCreateResult } from "../../acp/client.js";
import { assertRequestedModelSupported } from "../../acp/model-support.js";
import { withTimeout } from "../../async-control.js";
export async function applyRequestedModelIfAdvertised(params: {
client: AcpClient;
sessionId: string;
requestedModel: string | undefined;
models: SessionCreateResult["models"];
agentCommand?: string;
timeoutMs?: number;
}): Promise<boolean> {
const requestedModel =
typeof params.requestedModel === "string" ? params.requestedModel.trim() : "";
if (!requestedModel) {
return false;
}
assertRequestedModelSupported({
requestedModel,
models: params.models,
agentCommand: params.agentCommand,
context: "apply",
});
if (!params.models) {
return false;
}
if (params.models.currentModelId === requestedModel) {
return true;
}
await withTimeout(
params.client.setSessionModel(params.sessionId, requestedModel),
params.timeoutMs,
);
return true;
}

View File

@ -2,6 +2,8 @@ import { withTimeout } from "../../async-control.js";
import {
withConnectedSession,
type FullConnectedSessionController,
type WithConnectedSessionOptions,
type WithConnectedSessionResult,
} from "../../runtime/engine/connected-session.js";
import {
setCurrentModelId,
@ -65,10 +67,24 @@ export type RunSessionSetModelDirectOptions = {
onClientClosed?: () => void;
};
export async function runSessionSetModeDirect(
options: RunSessionSetModeDirectOptions,
): Promise<SessionSetModeResult> {
const result = await withConnectedSession({
type DirectConnectedSessionOptions = {
sessionRecordId: string;
mcpServers?: McpServer[];
nonInteractivePermissions?: NonInteractivePermissionPolicy;
authCredentials?: Record<string, string>;
authPolicy?: AuthPolicy;
terminal?: boolean;
timeoutMs?: number;
verbose?: boolean;
onClientAvailable?: (controller: ActiveSessionController) => void;
onClientClosed?: () => void;
};
function buildDirectConnectedSessionOptions<T>(
options: DirectConnectedSessionOptions,
run: WithConnectedSessionOptions<T>["run"],
): WithConnectedSessionOptions<T> {
return {
sessionRecordId: options.sessionRecordId,
loadRecord: resolveSessionRecord,
saveRecord: writeSessionRecord,
@ -83,12 +99,13 @@ export async function runSessionSetModeDirect(
options.onClientAvailable?.(controller);
},
onClientClosed: options.onClientClosed,
run: async ({ client, sessionId, record }) => {
await withTimeout(client.setSessionMode(sessionId, options.modeId), options.timeoutMs);
setDesiredModeId(record, options.modeId);
},
});
run,
};
}
function toSessionMutationResult(
result: Pick<WithConnectedSessionResult<unknown>, "record" | "resumed" | "loadError">,
): Pick<SessionSetModeResult, "record" | "resumed" | "loadError"> {
return {
record: result.record,
resumed: result.resumed,
@ -96,57 +113,38 @@ export async function runSessionSetModeDirect(
};
}
export async function runSessionSetModeDirect(
options: RunSessionSetModeDirectOptions,
): Promise<SessionSetModeResult> {
const result = await withConnectedSession(
buildDirectConnectedSessionOptions(options, async ({ client, sessionId, record }) => {
await withTimeout(client.setSessionMode(sessionId, options.modeId), options.timeoutMs);
setDesiredModeId(record, options.modeId);
}),
);
return toSessionMutationResult(result);
}
export async function runSessionSetModelDirect(
options: RunSessionSetModelDirectOptions,
): Promise<SessionSetModelResult> {
const result = await withConnectedSession({
sessionRecordId: options.sessionRecordId,
loadRecord: resolveSessionRecord,
saveRecord: writeSessionRecord,
mcpServers: options.mcpServers,
nonInteractivePermissions: options.nonInteractivePermissions,
authCredentials: options.authCredentials,
authPolicy: options.authPolicy,
terminal: options.terminal,
timeoutMs: options.timeoutMs,
verbose: options.verbose,
onClientAvailable: (controller: FullConnectedSessionController) => {
options.onClientAvailable?.(controller);
},
onClientClosed: options.onClientClosed,
run: async ({ client, sessionId, record }) => {
const result = await withConnectedSession(
buildDirectConnectedSessionOptions(options, async ({ client, sessionId, record }) => {
await withTimeout(client.setSessionModel(sessionId, options.modelId), options.timeoutMs);
setDesiredModelId(record, options.modelId);
setCurrentModelId(record, options.modelId);
},
});
}),
);
return {
record: result.record,
resumed: result.resumed,
loadError: result.loadError,
};
return toSessionMutationResult(result);
}
export async function runSessionSetConfigOptionDirect(
options: RunSessionSetConfigOptionDirectOptions,
): Promise<SessionSetConfigOptionResult> {
const result = await withConnectedSession({
sessionRecordId: options.sessionRecordId,
loadRecord: resolveSessionRecord,
saveRecord: writeSessionRecord,
mcpServers: options.mcpServers,
nonInteractivePermissions: options.nonInteractivePermissions,
authCredentials: options.authCredentials,
authPolicy: options.authPolicy,
terminal: options.terminal,
timeoutMs: options.timeoutMs,
verbose: options.verbose,
onClientAvailable: (controller: FullConnectedSessionController) => {
options.onClientAvailable?.(controller);
},
onClientClosed: options.onClientClosed,
run: async ({ client, sessionId, record }) => {
const result = await withConnectedSession(
buildDirectConnectedSessionOptions(options, async ({ client, sessionId, record }) => {
const response = await withTimeout(
client.setSessionConfigOption(sessionId, options.configId, options.value),
options.timeoutMs,
@ -157,8 +155,8 @@ export async function runSessionSetConfigOptionDirect(
setDesiredConfigOption(record, options.configId, options.value);
}
return response;
},
});
}),
);
return {
record: result.record,

View File

@ -161,6 +161,15 @@ 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 {
@ -182,6 +191,7 @@ export async function runSessionQueueOwner(options: QueueOwnerRuntimeOptions): P
await applyPendingCancel();
return true;
},
closeSession: async (timeoutMs?: number) => await closeActiveBackendSession(timeoutMs),
setSessionMode: async (modeId: string, timeoutMs?: number) => {
await turnController.setSessionMode(modeId, timeoutMs);
},

View File

@ -4,6 +4,7 @@ import {
isRetryablePromptError,
normalizeOutputError,
} from "../../acp/error-normalization.js";
import { assertRequestedModelSupported } from "../../acp/model-support.js";
import { InterruptedError, withInterrupt, withTimeout } from "../../async-control.js";
export { InterruptedError, TimeoutError } from "../../async-control.js";
import { formatPerfMetric, measurePerf, startPerfTimer } from "../../perf-metrics.js";
@ -37,52 +38,32 @@ import {
} from "../../session/persistence.js";
import type {
AcpJsonRpcMessage,
AcpMessageDirection,
AuthPolicy,
ClientOperation,
McpServer,
NonInteractivePermissionPolicy,
OutputErrorAcpPayload,
OutputErrorCode,
OutputErrorOrigin,
OutputFormatter,
PermissionMode,
PromptInput,
RunPromptResult,
SessionNotification,
SessionRecord,
SessionResumePolicy,
SessionSendResult,
} from "../../types.js";
import { type QueueOwnerMessage, type QueueTask, waitMs } from "../queue/ipc.js";
import { type QueueOwnerActiveSessionController } from "../queue/owner-turn-controller.js";
import type { RunOnceOptions, SessionSendOptions } from "./contracts.js";
import { applyRequestedModelIfAdvertised } from "./model-helpers.js";
const INTERRUPT_CANCEL_WAIT_MS = 2_500;
type RunSessionPromptOptions = {
type RunSessionPromptOptions = Omit<
SessionSendOptions,
"errorEmissionPolicy" | "maxQueueDepth" | "sessionId" | "ttlMs" | "waitForCompletion"
> & {
sessionRecordId: string;
prompt: PromptInput;
resumePolicy?: SessionResumePolicy;
mcpServers?: McpServer[];
permissionMode: PermissionMode;
nonInteractivePermissions?: NonInteractivePermissionPolicy;
authCredentials?: Record<string, string>;
authPolicy?: AuthPolicy;
terminal?: boolean;
outputFormatter: OutputFormatter;
onAcpMessage?: (direction: AcpMessageDirection, message: AcpJsonRpcMessage) => void;
onSessionUpdate?: (notification: SessionNotification) => void;
onClientOperation?: (operation: ClientOperation) => void;
timeoutMs?: number;
suppressSdkConsoleErrors?: boolean;
verbose?: boolean;
promptRetries?: number;
sessionOptions?: SessionAgentOptions;
onClientAvailable?: (controller: ActiveSessionController) => void;
onClientClosed?: () => void;
onPromptActive?: () => Promise<void> | void;
client?: AcpClient;
};
type ActiveSessionController = QueueOwnerActiveSessionController;
@ -149,29 +130,6 @@ function toPromptResult(
};
}
async function applyRequestedModelIfAdvertised(params: {
client: AcpClient;
sessionId: string;
requestedModel: string | undefined;
models: import("../../acp/client.js").SessionCreateResult["models"];
timeoutMs?: number;
}): Promise<boolean> {
const requestedModel =
typeof params.requestedModel === "string" ? params.requestedModel.trim() : "";
if (!requestedModel || !params.models) {
return false;
}
if (params.models.currentModelId === requestedModel) {
return true;
}
await withTimeout(
params.client.setSessionModel(params.sessionId, requestedModel),
params.timeoutMs,
);
return true;
}
async function applyPromptModelIfAdvertised(params: {
client: AcpClient;
sessionId: string;
@ -181,10 +139,26 @@ async function applyPromptModelIfAdvertised(params: {
}): Promise<void> {
const requestedModel =
typeof params.requestedModel === "string" ? params.requestedModel.trim() : "";
if (!requestedModel || !Array.isArray(params.record.acpx?.available_models)) {
if (!requestedModel) {
return;
}
if (params.record.acpx.current_model_id === requestedModel) {
const availableModels = params.record.acpx?.available_models;
assertRequestedModelSupported({
requestedModel,
models: Array.isArray(availableModels)
? {
currentModelId: params.record.acpx?.current_model_id ?? "",
availableModels: availableModels.map((modelId) => ({ modelId, name: modelId })),
}
: undefined,
agentCommand: params.record.agentCommand,
context: "apply",
});
if (!Array.isArray(availableModels)) {
return;
}
if (params.record.acpx?.current_model_id === requestedModel) {
setDesiredModelId(params.record, requestedModel);
return;
}
@ -429,7 +403,7 @@ async function runSessionPrompt(options: RunSessionPromptOptions): Promise<Sessi
return;
}
const batch = pendingMessages.splice(0, pendingMessages.length);
const batch = pendingMessages.splice(0);
await measurePerf("session.events.flush_pending", async () => {
await eventWriter.appendMessages(batch, { checkpoint });
});
@ -780,6 +754,7 @@ export async function runOnce(options: RunOnceOptions): Promise<RunPromptResult>
sessionId,
requestedModel: options.sessionOptions?.model,
models: createdSession.models,
agentCommand: options.agentCommand,
timeoutMs: options.timeoutMs,
});

View File

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

View File

@ -1,6 +1,7 @@
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";
@ -21,6 +22,7 @@ import type {
SessionCreateWithClientResult,
SessionEnsureOptions,
} from "./contracts.js";
import { applyRequestedModelIfAdvertised } from "./model-helpers.js";
import { setSessionModel } from "./session-control.js";
function persistSessionOptions(
@ -70,29 +72,6 @@ function persistSessionOptions(
delete record.acpx.session_options;
}
async function applyRequestedModelIfAdvertised(params: {
client: AcpClient;
sessionId: string;
requestedModel: string | undefined;
models: SessionCreateResult["models"];
timeoutMs?: number;
}): Promise<boolean> {
const requestedModel =
typeof params.requestedModel === "string" ? params.requestedModel.trim() : "";
if (!requestedModel || !params.models) {
return false;
}
if (params.models.currentModelId === requestedModel) {
return true;
}
await withTimeout(
params.client.setSessionModel(params.sessionId, requestedModel),
params.timeoutMs,
);
return true;
}
async function createSessionRecordWithClient(
client: AcpClient,
options: SessionCreateOptions,
@ -101,6 +80,7 @@ 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;
@ -118,12 +98,14 @@ async function createSessionRecordWithClient(
);
sessionId = options.resumeSessionId;
agentSessionId = normalizeRuntimeSessionId(loadedSession.agentSessionId);
sessionResult = loadedSession;
sessionModels = loadedSession.models;
requestedModelApplied = await applyRequestedModelIfAdvertised({
client,
sessionId,
requestedModel: options.sessionOptions?.model,
models: sessionModels,
agentCommand: options.agentCommand,
timeoutMs: options.timeoutMs,
});
} catch (error) {
@ -138,12 +120,14 @@ 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,
sessionId,
requestedModel: options.sessionOptions?.model,
models: sessionModels,
agentCommand: options.agentCommand,
timeoutMs: options.timeoutMs,
});
}
@ -174,6 +158,7 @@ async function createSessionRecordWithClient(
};
persistSessionOptions(record, options.sessionOptions);
applyConfigOptionsToRecord(record, sessionResult);
syncAdvertisedModelState(record, sessionModels);
if (requestedModelApplied) {
setCurrentModelId(record, options.sessionOptions?.model);

View File

@ -1,5 +1,7 @@
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,

114
src/flows/decision.ts Normal file
View File

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

View File

@ -38,9 +38,9 @@ export function parseJsonObject(
}
if (mode === "fenced" || mode === "compat") {
const fencedMatch = trimmed.match(/```(?:json)?\s*([\s\S]*?)```/i);
if (fencedMatch) {
const fenced = tryParse(fencedMatch[1].trim());
const fencedText = extractFencedJsonText(trimmed);
if (fencedText !== null) {
const fenced = tryParse(fencedText);
if (fenced.ok) {
return fenced.value;
}
@ -84,6 +84,36 @@ function tryParse(text: string): { ok: true; value: unknown } | { ok: false } {
}
}
function extractFencedJsonText(text: string): string | null {
const openingFenceIndex = text.indexOf("```");
if (openingFenceIndex === -1) {
return null;
}
let contentStart = openingFenceIndex + 3;
if (
text.slice(contentStart, contentStart + 4).toLowerCase() === "json" &&
isFenceWhitespace(text[contentStart + 4])
) {
contentStart += 4;
}
while (isFenceWhitespace(text[contentStart])) {
contentStart += 1;
}
const closingFenceIndex = text.indexOf("```", contentStart);
if (closingFenceIndex === -1) {
return null;
}
return text.slice(contentStart, closingFenceIndex).trim();
}
function isFenceWhitespace(char: string | undefined): boolean {
return char === " " || char === "\n" || char === "\r" || char === "\t";
}
function extractBalancedJsonCandidates(text: string): string[] {
const candidates: string[] = [];

View File

@ -214,10 +214,7 @@ export function normalizeFlowRunTitle(value: string | undefined): string | undef
export function createRunId(flowName: string): string {
const stamp = isoNow().replaceAll(":", "").replaceAll(".", "");
const slug = flowName
.replace(/[^a-z0-9]+/gi, "-")
.replace(/^-+|-+$/g, "")
.toLowerCase();
const slug = slugifyAsciiIdPart(flowName);
return `${stamp}-${slug}-${randomUUID().slice(0, 8)}`;
}
@ -236,13 +233,45 @@ export function createSessionName(
}
export function createSessionBundleId(handle: string, key: string): string {
const safeHandle = handle
.replace(/[^a-z0-9]+/gi, "-")
.replace(/^-+|-+$/g, "")
.toLowerCase();
const safeHandle = slugifyAsciiIdPart(handle);
return `${safeHandle || "session"}-${stableShortHash(key)}`;
}
function slugifyAsciiIdPart(value: string): string {
let slug = "";
let lastWasSeparator = false;
for (const char of value) {
const safeChar = toLowerAsciiAlphaNumeric(char);
if (safeChar) {
slug += safeChar;
lastWasSeparator = false;
continue;
}
if (slug.length > 0 && !lastWasSeparator) {
slug += "-";
lastWasSeparator = true;
}
}
return lastWasSeparator ? slug.slice(0, -1) : slug;
}
function toLowerAsciiAlphaNumeric(char: string): string | null {
const code = char.charCodeAt(0);
if (code >= 48 && code <= 57) {
return char;
}
if (code >= 65 && code <= 90) {
return String.fromCharCode(code + 32);
}
if (code >= 97 && code <= 122) {
return char;
}
return null;
}
export function createIsolatedSessionBinding(
flowName: string,
runId: string,

View File

@ -201,8 +201,29 @@ export class AcpxRuntime implements AcpxRuntimeLike {
});
}
getCapabilities(_input?: { handle?: AcpRuntimeHandle }): AcpRuntimeCapabilities {
return ACPX_CAPABILITIES;
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 } : {}),
};
}
async getStatus(input: {

View File

@ -1,11 +1,11 @@
import { randomUUID } from "node:crypto";
import path from "node:path";
import type { SetSessionConfigOptionResponse } from "@agentclientprotocol/sdk";
import { AcpClient } from "../../acp/client.js";
import { normalizeOutputError } from "../../acp/error-normalization.js";
import { extractAcpError, isAcpResourceNotFoundError } from "../../acp/error-shapes.js";
import { withTimeout } from "../../async-control.js";
import { textPrompt, type PromptInput } from "../../prompt-content.js";
import { applyConfigOptionsToRecord } from "../../session/config-options.js";
import {
cloneSessionAcpxState,
cloneSessionConversation,
@ -71,18 +71,6 @@ 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>[] = [];
@ -230,6 +218,7 @@ 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 }),
};
}
@ -404,14 +393,19 @@ 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),
@ -424,6 +418,7 @@ 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") {
@ -721,6 +716,7 @@ export class AcpRuntimeManager {
error: {
message: normalized.message,
...(normalized.code ? { code: normalized.code } : {}),
...(normalized.detailCode ? { detailCode: normalized.detailCode } : {}),
...(normalized.retryable !== undefined ? { retryable: normalized.retryable } : {}),
},
});
@ -837,14 +833,14 @@ export class AcpRuntimeManager {
let targetRecord = record;
if (controller) {
const response = await controller.setSessionConfigOption(key, value);
applyConfigOptionsToRecord(targetRecord, response?.configOptions);
applyConfigOptionsToRecord(targetRecord, response);
} else {
const result = await this.withRuntimeControlSession(
record,
sessionMode,
async ({ client, sessionId, record: connectedRecord }) => {
const response = await client.setSessionConfigOption(sessionId, key, value);
applyConfigOptionsToRecord(connectedRecord, response?.configOptions);
applyConfigOptionsToRecord(connectedRecord, response);
if (key === "mode") {
setDesiredModeId(connectedRecord, value);
} else {

View File

@ -5,6 +5,7 @@ import {
isAcpQueryClosedBeforeResponseError,
isAcpResourceNotFoundError,
} from "../../acp/error-normalization.js";
import { assertRequestedModelSupported } from "../../acp/model-support.js";
import { InterruptedError, TimeoutError, withTimeout } from "../../async-control.js";
import {
SessionConfigOptionReplayError,
@ -13,6 +14,7 @@ import {
SessionResumeRequiredError,
} from "../../errors.js";
import { incrementPerfCounter } from "../../perf-metrics.js";
import { applyConfigOptionsToRecord } from "../../session/config-options.js";
import {
getDesiredConfigOptions,
getDesiredModeId,
@ -154,18 +156,25 @@ async function replayDesiredModel(params: {
sessionId: string;
desiredModelId: string | undefined;
previousSessionId: string;
record: SessionRecord;
models: import("../../acp/client.js").SessionLoadResult["models"] | undefined;
timeoutMs?: number;
verbose?: boolean;
}): Promise<void> {
if (!params.desiredModelId || !params.models) {
return;
}
if (params.models.currentModelId === params.desiredModelId) {
if (!params.desiredModelId) {
return;
}
try {
assertRequestedModelSupported({
requestedModel: params.desiredModelId,
models: params.models,
agentCommand: params.record.agentCommand,
context: "replay",
});
if (!params.models || params.models.currentModelId === params.desiredModelId) {
return;
}
await withTimeout(
params.client.setSessionModel(params.sessionId, params.desiredModelId),
params.timeoutMs,
@ -282,6 +291,7 @@ export async function connectAndLoadSession(
options.timeoutMs,
);
reconcileAgentSessionId(record, loadResult.agentSessionId);
applyConfigOptionsToRecord(record, loadResult);
sessionModels = loadResult.models;
resumed = true;
} catch (error) {
@ -300,6 +310,7 @@ export async function connectAndLoadSession(
sessionId = createdSession.sessionId;
createdFreshSession = true;
pendingAgentSessionId = createdSession.agentSessionId;
applyConfigOptionsToRecord(record, createdSession);
sessionModels = createdSession.models;
}
} else {
@ -313,6 +324,7 @@ export async function connectAndLoadSession(
sessionId = createdSession.sessionId;
createdFreshSession = true;
pendingAgentSessionId = createdSession.agentSessionId;
applyConfigOptionsToRecord(record, createdSession);
sessionModels = createdSession.models;
}
@ -331,6 +343,7 @@ export async function connectAndLoadSession(
sessionId,
desiredModelId,
previousSessionId: originalSessionId,
record,
models: sessionModels,
timeoutMs: options.timeoutMs,
verbose: options.verbose,

View File

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

View File

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

View File

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

View File

@ -1,4 +1,5 @@
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";
@ -413,6 +414,7 @@ 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",
@ -432,7 +434,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: "/tmp/acpx-client-meta",
cwd,
mcpServers: [],
_meta: {
claudeCode: {
@ -447,6 +449,7 @@ 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",
@ -463,7 +466,7 @@ test("AcpClient createSession forwards systemPrompt string in _meta", async () =
await client.createSession("/tmp/acpx-client-system-prompt");
assert.deepEqual(capturedParams, {
cwd: "/tmp/acpx-client-system-prompt",
cwd,
mcpServers: [],
_meta: {
systemPrompt: "you are an obsidian assistant",
@ -472,6 +475,7 @@ 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",
@ -489,7 +493,7 @@ test("AcpClient createSession forwards systemPrompt append in _meta alongside cl
await client.createSession("/tmp/acpx-client-system-prompt-append");
assert.deepEqual(capturedParams, {
cwd: "/tmp/acpx-client-system-prompt-append",
cwd,
mcpServers: [],
_meta: {
claudeCode: {
@ -503,6 +507,7 @@ 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: {
@ -526,7 +531,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: "/tmp/acpx-client-codex-model",
cwd,
mcpServers: [],
_meta: {
claudeCode: {
@ -574,7 +579,7 @@ test("AcpClient closes sessions through session/close and clears the loaded sess
};
internals.loadedSessionId = "session-close-1";
internals.connection = {
unstable_closeNes: async (params: { sessionId: string }) => {
closeSession: async (params: { sessionId: string }) => {
capturedCloseSessionParams = params;
return {};
},

View File

@ -292,7 +292,10 @@ async function writeFixture(
for (const [index, definition] of cases.entries()) {
const id = definition.id;
assert.equal(typeof id, "string");
requiredCases.push(id as string);
if (typeof id !== "string") {
throw new TypeError("Conformance case id must be a string.");
}
requiredCases.push(id);
const fileName = `${String(index + 1).padStart(3, "0")}-${id}.json`;
await fs.writeFile(

View File

@ -1,6 +1,5 @@
import assert from "node:assert/strict";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import test from "node:test";
import type { SessionModelState, SetSessionConfigOptionResponse } from "@agentclientprotocol/sdk";
@ -8,7 +7,10 @@ import {
connectAndLoadSession,
type ConnectedSessionController,
} from "../src/runtime/engine/reconnect.js";
import type { SessionRecord } from "../src/types.js";
import {
makeSessionRecord as makeSessionRecordFixture,
withTempHome as withTempHomeFixture,
} from "./runtime-test-helpers.js";
type FakeClient = {
hasReusableSession: (sessionId: string) => boolean;
@ -632,6 +634,62 @@ test("connectAndLoadSession replays desired model on a fresh session", async ()
});
});
test("connectAndLoadSession fails clearly when saved model cannot be replayed generically", async () => {
await withTempHome(async (homeDir) => {
const cwd = path.join(homeDir, "workspace");
await fs.mkdir(cwd, { recursive: true });
const record = makeSessionRecord({
acpxRecordId: "model-replay-unsupported-record",
acpSessionId: "stale-session",
agentCommand: "agent",
cwd,
acpx: {
session_options: {
model: "gpt-5.4",
},
},
});
const client: FakeClient = {
hasReusableSession: () => false,
start: async () => {},
getAgentLifecycleSnapshot: () => ({
running: true,
}),
supportsLoadSession: () => false,
loadSessionWithOptions: async () => {
throw new Error("loadSessionWithOptions should not be called");
},
createSession: async () => ({
sessionId: "fresh-session",
agentSessionId: "fresh-runtime",
}),
setSessionMode: async () => {},
setSessionModel: async () => {
throw new Error("setSessionModel should not be called");
},
};
await assert.rejects(
async () =>
await connectAndLoadSession({
client: client as never,
record,
activeController: ACTIVE_CONTROLLER,
}),
(error: unknown) => {
assert(error instanceof Error);
assert.equal(error.name, "SessionModelReplayError");
assert.match(error.message, /did not advertise model support/);
return true;
},
);
assert.equal(record.acpSessionId, "stale-session");
});
});
test("connectAndLoadSession restores the original session when desired model replay fails", async () => {
await withTempHome(async (homeDir) => {
const cwd = path.join(homeDir, "workspace");
@ -885,68 +943,11 @@ test("connectAndLoadSession reuses an already loaded client session", async () =
});
function makeSessionRecord(
overrides: Partial<SessionRecord> & {
acpxRecordId: string;
acpSessionId: string;
agentCommand: string;
cwd: string;
},
): SessionRecord {
const timestamp = "2026-01-01T00:00:00.000Z";
return {
schema: "acpx.session.v1",
acpxRecordId: overrides.acpxRecordId,
acpSessionId: overrides.acpSessionId,
agentSessionId: overrides.agentSessionId,
agentCommand: overrides.agentCommand,
cwd: path.resolve(overrides.cwd),
name: overrides.name,
createdAt: overrides.createdAt ?? timestamp,
lastUsedAt: overrides.lastUsedAt ?? timestamp,
lastSeq: overrides.lastSeq ?? 0,
lastRequestId: overrides.lastRequestId,
eventLog: overrides.eventLog ?? {
active_path: ".stream.ndjson",
segment_count: 1,
max_segment_bytes: 1024,
max_segments: 1,
last_write_at: overrides.lastUsedAt ?? timestamp,
last_write_error: null,
},
closed: overrides.closed ?? false,
closedAt: overrides.closedAt,
pid: overrides.pid,
agentStartedAt: overrides.agentStartedAt,
lastPromptAt: overrides.lastPromptAt,
lastAgentExitCode: overrides.lastAgentExitCode,
lastAgentExitSignal: overrides.lastAgentExitSignal,
lastAgentExitAt: overrides.lastAgentExitAt,
lastAgentDisconnectReason: overrides.lastAgentDisconnectReason,
protocolVersion: overrides.protocolVersion,
agentCapabilities: overrides.agentCapabilities,
title: overrides.title ?? null,
messages: overrides.messages ?? [],
updated_at: overrides.updated_at ?? overrides.lastUsedAt ?? timestamp,
cumulative_token_usage: overrides.cumulative_token_usage ?? {},
request_token_usage: overrides.request_token_usage ?? {},
acpx: overrides.acpx,
};
overrides: Parameters<typeof makeSessionRecordFixture>[0],
): ReturnType<typeof makeSessionRecordFixture> {
return makeSessionRecordFixture(overrides, { defaultName: false, defaultAcpx: false });
}
async function withTempHome(run: (homeDir: string) => Promise<void>): Promise<void> {
const originalHome = process.env.HOME;
const homeDir = await fs.mkdtemp(path.join(os.tmpdir(), "acpx-connect-load-home-"));
process.env.HOME = homeDir;
try {
await run(homeDir);
} finally {
if (originalHome == null) {
delete process.env.HOME;
} else {
process.env.HOME = originalHome;
}
await fs.rm(homeDir, { recursive: true, force: true });
}
await withTempHomeFixture("acpx-connect-load-home-", run);
}

View File

@ -6,7 +6,7 @@ export default defineFlow({
nodes: {
prepare: action({
run: ({ input }) => ({
text: String((input as { text?: string }).text ?? "").toUpperCase(),
text: ((input as { text?: string }).text ?? "").toUpperCase(),
}),
}),
run_shell: shell({
@ -17,7 +17,7 @@ export default defineFlow({
"process.stdout.write(JSON.stringify({ value: process.env.FLOW_TEXT, cwd: process.cwd() }))",
],
env: {
FLOW_TEXT: String((outputs.prepare as { text: string }).text),
FLOW_TEXT: (outputs.prepare as { text: string }).text,
},
}),
parse: (result) => extractJsonObject(result.stdout),

View File

@ -6,7 +6,7 @@ export default defineFlow({
nodes: {
prepare: action({
run: ({ input }) => ({
ticket: String((input as { ticket?: string }).ticket ?? "review"),
ticket: (input as { ticket?: string }).ticket ?? "review",
}),
}),
wait_for_human: checkpoint({

View File

@ -5,8 +5,10 @@ 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";
import {
FlowRunner,
acp,
@ -79,6 +81,210 @@ test("parseJsonObject supports strict and fenced-only modes", () => {
);
});
test("parseJsonObject parses fenced JSON without regex backtracking", () => {
assert.deepEqual(parseJsonObject('```JSON\r\n{"ok":true}\n```', { mode: "fenced" }), {
ok: true,
});
assert.throws(() => parseJsonObject("```json\n", { mode: "fenced" }), /Could not parse JSON/);
});
test("createRunId slugifies flow names without regex backtracking", () => {
assert.match(
createRunId(" Publish: BIG Result!!! "),
/^\d{4}-.*-publish-big-result-[a-f0-9-]+$/,
);
});
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 }),
@ -343,7 +549,7 @@ test("FlowRunner executes isolated ACP nodes and branches deterministically", as
}),
route: compute({
run: ({ outputs }) => ({
next: String((outputs.first as { next: string }).next),
next: (outputs.first as { next: string }).next,
}),
}),
yes: action({
@ -508,10 +714,10 @@ test("FlowRunner writes isolated ACP bundle traces and artifacts", async () => {
assert.ok(traceEvents.some((event) => event.type === "acp_response_parsed"));
const record = JSON.parse(
await fs.readFile(path.join(result.runDir, manifest.sessions[0]!.recordPath), "utf8"),
await fs.readFile(path.join(result.runDir, manifest.sessions[0].recordPath), "utf8"),
) as { messages: unknown[]; lastSeq: number };
const bundledEvents = (
await fs.readFile(path.join(result.runDir, manifest.sessions[0]!.eventsPath), "utf8")
await fs.readFile(path.join(result.runDir, manifest.sessions[0].eventsPath), "utf8")
)
.trim()
.split("\n")
@ -594,10 +800,10 @@ test("FlowRunner writes persistent ACP bundle traces and session bindings", asyn
assert.ok(traceEvents.some((event) => event.type === "acp_response_parsed"));
const record = JSON.parse(
await fs.readFile(path.join(result.runDir, manifest.sessions[0]!.recordPath), "utf8"),
await fs.readFile(path.join(result.runDir, manifest.sessions[0].recordPath), "utf8"),
) as { messages: unknown[]; lastSeq: number };
const bundledEvents = (
await fs.readFile(path.join(result.runDir, manifest.sessions[0]!.eventsPath), "utf8")
await fs.readFile(path.join(result.runDir, manifest.sessions[0].eventsPath), "utf8")
)
.trim()
.split("\n")

View File

@ -40,6 +40,13 @@ const FLOW_WORKDIR_FIXTURE_PATH = fileURLToPath(
const MOCK_AGENT_COMMAND = `node ${JSON.stringify(MOCK_AGENT_PATH)}`;
const LOAD_CAPABLE_MOCK_AGENT_COMMAND = `${MOCK_AGENT_COMMAND} --supports-load-session`;
const unsafeCodeCharEscapes = Object.freeze({
"<": "\\u003C",
">": "\\u003E",
"\u2028": "\\u2028",
"\u2029": "\\u2029",
});
type CliRunResult = {
code: number | null;
signal: NodeJS.Signals | null;
@ -223,10 +230,7 @@ test("integration: flow run executes function and shell actions from --input-fil
assert.equal(payload.status, "completed");
assert.equal(payload.outputs?.prepare?.text, "SMOKE");
assert.equal(payload.outputs?.finalize?.value, "SMOKE");
assert.equal(
await fs.realpath(String(payload.outputs?.finalize?.cwd ?? "")),
await fs.realpath(cwd),
);
assert.equal(await fs.realpath(payload.outputs?.finalize?.cwd ?? ""), await fs.realpath(cwd));
} finally {
await fs.rm(cwd, { recursive: true, force: true });
}
@ -289,7 +293,9 @@ test("integration: flow run finalizes interrupted bundles on SIGHUP", async () =
assert.equal(finalState.currentNode, "slow");
assert.equal(finalState.currentAttemptId, "slow#1");
assert.match(String(finalState.statusDetail ?? ""), /Failed in slow: Interrupted/);
const statusDetail =
typeof finalState.statusDetail === "string" ? finalState.statusDetail : "";
assert.match(statusDetail, /Failed in slow: Interrupted/);
const traceEvents = (await fs.readFile(path.join(runDir, "trace.ndjson"), "utf8"))
.trim()
@ -340,9 +346,7 @@ test("integration: flow run fails ACP nodes promptly when the agent disconnects
"failed",
);
assert.match(
String(
(finalState.results as Record<string, { error?: string }>).slow?.error ?? result.stderr,
),
(finalState.results as Record<string, { error?: string }>).slow?.error ?? result.stderr,
/agent disconnected/i,
);
} finally {
@ -465,7 +469,7 @@ test("integration: flow run preserves approve-all through persistent ACP writes"
' startAt: "write_file",',
" nodes: {",
" write_file: acp({",
` prompt: () => ${JSON.stringify(`write ${writePath} hello`)},`,
` prompt: () => ${jsStringLiteral(`write ${writePath} hello`)},`,
" parse: (text) => ({ reply: text }),",
" }),",
" },",
@ -516,6 +520,17 @@ test("integration: flow run preserves approve-all through persistent ACP writes"
});
});
function jsStringLiteral(value: string): string {
return escapeUnsafeCodeChars(JSON.stringify(value));
}
function escapeUnsafeCodeChars(value: string): string {
return value.replace(
/[<>\u2028\u2029]/g,
(char) => unsafeCodeCharEscapes[char as keyof typeof unsafeCodeCharEscapes],
);
}
test('integration: flow run resolves "acpx/flows" imports for external flow files', async () => {
await withTempHome(async (homeDir) => {
const cwd = await fs.mkdtemp(path.join(os.tmpdir(), "acpx-integration-cwd-"));
@ -868,14 +883,22 @@ test("integration: qoder session reuse preserves persisted startup flags", async
test("integration: exec forwards model, allowed-tools, and max-turns in session/new _meta", async () => {
await withTempHome(async (homeDir) => {
const cwd = await fs.mkdtemp(path.join(os.tmpdir(), "acpx-integration-cwd-"));
const claudeCompatibleAgentCommand = `${MOCK_AGENT_COMMAND} --claude-agent-acp`;
try {
const created = await runCli([...baseAgentArgs(cwd), "sessions", "new"], homeDir);
const created = await runCli(
["--agent", claudeCompatibleAgentCommand, "--approve-all", "--cwd", cwd, "sessions", "new"],
homeDir,
);
assert.equal(created.code, 0, created.stderr);
const result = await runCli(
[
...baseAgentArgs(cwd),
"--agent",
claudeCompatibleAgentCommand,
"--approve-all",
"--cwd",
cwd,
"--format",
"json",
"--model",
@ -970,7 +993,7 @@ test("integration: exec --model calls session/set_model when agent advertises mo
});
});
test("integration: exec --model skips session/set_model when agent does not advertise models", async () => {
test("integration: exec --model fails when agent does not advertise models", async () => {
await withTempHome(async (homeDir) => {
const cwd = await fs.mkdtemp(path.join(os.tmpdir(), "acpx-integration-cwd-"));
@ -979,11 +1002,11 @@ test("integration: exec --model skips session/set_model when agent does not adve
[...baseAgentArgs(cwd), "--format", "json", "--model", "sonnet", "exec", "echo hello"],
homeDir,
);
assert.equal(result.code, 0, result.stderr);
assert.notEqual(result.code, 0, "expected non-zero exit");
assert.match(`${result.stderr}\n${result.stdout}`, /did not advertise model support/);
const payloads = parseJsonRpcOutputLines(result.stdout);
// _meta.claudeCode.options.model should still be sent
const createRequest = payloads.find((payload) => payload.method === "session/new") as
| { params?: { _meta?: Record<string, unknown> } }
| undefined;
@ -1001,6 +1024,41 @@ test("integration: exec --model skips session/set_model when agent does not adve
});
});
test("integration: exec --model rejects models not advertised by the agent", async () => {
await withTempHome(async (homeDir) => {
const cwd = await fs.mkdtemp(path.join(os.tmpdir(), "acpx-integration-cwd-"));
const modelAgentCommand = `${MOCK_AGENT_COMMAND} --advertise-models`;
try {
const result = await runCli(
[
"--agent",
modelAgentCommand,
"--approve-all",
"--cwd",
cwd,
"--format",
"json",
"--model",
"missing-model",
"exec",
"echo hello",
],
homeDir,
);
assert.notEqual(result.code, 0, "expected non-zero exit");
assert.match(`${result.stderr}\n${result.stdout}`, /did not advertise that model/);
assert.match(`${result.stderr}\n${result.stdout}`, /default-model, fast-model, smart-model/);
const payloads = parseJsonRpcOutputLines(result.stdout);
const setModelRequest = payloads.find((payload) => payload.method === "session/set_model");
assert.equal(setModelRequest, undefined, "session/set_model should not be called");
} finally {
await fs.rm(cwd, { recursive: true, force: true });
}
});
});
test("integration: prompt --model updates existing session model before prompt", async () => {
await withTempHome(async (homeDir) => {
const cwd = await fs.mkdtemp(path.join(os.tmpdir(), "acpx-integration-cwd-"));
@ -1059,7 +1117,7 @@ test("integration: exec --model fails when session/set_model fails", async () =>
"--format",
"quiet",
"--model",
"bad-model",
"fast-model",
"exec",
"echo hello",
],
@ -1088,7 +1146,7 @@ test("integration: sessions new --model fails when session/set_model fails", asy
"--cwd",
cwd,
"--model",
"bad-model",
"fast-model",
"sessions",
"new",
],
@ -1194,7 +1252,7 @@ test("integration: status shows model after session creation with --model", asyn
"--cwd",
cwd,
"--model",
"gpt-5.4",
"smart-model",
"sessions",
"new",
],
@ -1214,7 +1272,7 @@ test("integration: status shows model after session creation with --model", asyn
mode?: string;
availableModels?: string[];
};
assert.equal(statusPayload.model, "gpt-5.4");
assert.equal(statusPayload.model, "smart-model");
assert(Array.isArray(statusPayload.availableModels), "expected availableModels array");
// Check status text
@ -1223,7 +1281,7 @@ test("integration: status shows model after session creation with --model", asyn
homeDir,
);
assert.equal(statusText.code, 0, statusText.stderr);
assert.match(statusText.stdout, /model: gpt-5\.4/);
assert.match(statusText.stdout, /model: smart-model/);
} finally {
await fs.rm(cwd, { recursive: true, force: true });
}
@ -2451,7 +2509,7 @@ test("integration: prompt retries stop after partial prompt output", async () =>
homeDir,
);
assert.notEqual(result.code, 0, result.stderr);
assert.equal(/retrying in/.test(result.stderr), false, result.stderr);
assert.equal(result.stderr.includes("retrying in"), false, result.stderr);
const payloads = parseJsonRpcOutputLines(result.stdout);
const partialUpdates = payloads.filter(
@ -2482,7 +2540,7 @@ test("integration: exec retries stop after partial prompt output", async () => {
homeDir,
);
assert.equal(result.code, 1, result.stderr);
assert.equal(/retrying in/.test(result.stderr), false, result.stderr);
assert.equal(result.stderr.includes("retrying in"), false, result.stderr);
const payloads = parseJsonRpcOutputLines(result.stdout);
const partialUpdates = payloads.filter(

View File

@ -1,9 +1,12 @@
#!/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,
@ -36,6 +39,8 @@ type MockAgentOptions = {
newSessionMeta?: Record<string, string>;
loadSessionMeta?: Record<string, string>;
supportsLoadSession: boolean;
supportsCloseSession: boolean;
closeSessionMarker?: string;
loadSessionNotFound: boolean;
loadSessionFailsOnEmpty: boolean;
setSessionModeFails: boolean;
@ -43,6 +48,7 @@ type MockAgentOptions = {
setSessionConfigInvalidParams: boolean;
setSessionModelFails: boolean;
setSessionModelInvalidParams: boolean;
advertiseConfigOptions: boolean;
advertiseModels: boolean;
replayLoadSessionUpdates: boolean;
loadReplayText: string;
@ -115,6 +121,8 @@ function describePromptBlocks(prompt: ContentBlock[]): string {
return { type: "text", text: block.text };
case "image":
return { type: "image", mimeType: block.mimeType, bytes: block.data.length };
case "audio":
return { type: "audio", mimeType: block.mimeType, bytes: block.data.length };
case "resource_link":
return { type: "resource_link", uri: block.uri };
case "resource":
@ -123,6 +131,8 @@ function describePromptBlocks(prompt: ContentBlock[]): string {
uri: block.resource.uri,
hasText: "text" in block.resource && typeof block.resource.text === "string",
};
default:
return { type: (block as { type: string }).type };
}
}),
);
@ -296,6 +306,8 @@ 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;
@ -303,6 +315,7 @@ 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";
@ -361,12 +374,29 @@ 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;
@ -377,6 +407,10 @@ function parseMockAgentOptions(argv: string[]): MockAgentOptions {
continue;
}
if (token === "--claude-agent-acp") {
continue;
}
if (token === "--load-replay-text") {
supportsLoadSession = true;
replayLoadSessionUpdates = true;
@ -408,6 +442,8 @@ 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,
@ -415,6 +451,7 @@ function parseMockAgentOptions(argv: string[]): MockAgentOptions {
setSessionConfigInvalidParams,
setSessionModelFails,
setSessionModelInvalidParams,
advertiseConfigOptions,
advertiseModels,
replayLoadSessionUpdates,
loadReplayText,
@ -507,7 +544,10 @@ class MockAgent implements Agent {
return {
protocolVersion: PROTOCOL_VERSION,
authMethods: [],
agentCapabilities: this.options.supportsLoadSession ? { loadSession: true } : {},
agentCapabilities: {
...(this.options.supportsLoadSession ? { loadSession: true } : {}),
...(this.options.supportsCloseSession ? { sessionCapabilities: { close: {} } } : {}),
},
};
}
@ -532,6 +572,11 @@ 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;
}
@ -576,14 +621,30 @@ 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) {
throw new Error(`Unknown session: ${params.sessionId}`);
throw RequestError.internalError(
{ sessionId: params.sessionId },
`Unknown session: ${params.sessionId}`,
);
}
session.pendingPrompt?.abort();
@ -1058,4 +1119,8 @@ if (mockAgentOptions.ignoreSigterm) {
});
}
new AgentSideConnection((connection) => new MockAgent(connection, mockAgentOptions), stream);
const connection = new AgentSideConnection(
(agentConnection) => new MockAgent(agentConnection, mockAgentOptions),
stream,
);
void connection;

View File

@ -23,14 +23,14 @@ function makeRequest(kind: RequestPermissionRequest["toolCall"]["kind"]): Reques
kind,
title: "tool call",
},
options: BASE_OPTIONS.map((option) => ({ ...option })),
options: BASE_OPTIONS.map((option) => Object.assign({}, option)),
} as RequestPermissionRequest;
}
function makeRequestWithTitle(
title: string | undefined,
kind: RequestPermissionRequest["toolCall"]["kind"] = undefined,
options: PermissionChoice[] = BASE_OPTIONS.map((option) => ({ ...option })),
kind?: RequestPermissionRequest["toolCall"]["kind"],
options: PermissionChoice[] = BASE_OPTIONS.map((option) => Object.assign({}, option)),
): RequestPermissionRequest {
return {
sessionId: "session-1",
@ -39,7 +39,7 @@ function makeRequestWithTitle(
kind,
title,
},
options: options.map((option) => ({ ...option })),
options: options.map((option) => Object.assign({}, option)),
} as RequestPermissionRequest;
}

View File

@ -91,7 +91,7 @@ test("serialized session record satisfies persisted key policy", () => {
});
test("persisted key policy rejects camelCase acpx-owned keys", () => {
const persisted = serializeSessionRecordForDisk(makeRecord()) as Record<string, unknown>;
const persisted = serializeSessionRecordForDisk(makeRecord());
persisted.requestId = "bad";
const violations = findPersistedKeyPolicyViolations(persisted);

View File

@ -25,7 +25,7 @@ test("parsePromptSource rejects image blocks with non-image mime types", () => {
),
(error: unknown) =>
error instanceof PromptInputValidationError &&
/image block mimeType must start with image\//.test(error.message),
error.message.includes("image block mimeType must start with image/"),
);
});
@ -35,7 +35,7 @@ test("parsePromptSource rejects image blocks with invalid base64 payloads", () =
parsePromptSource(JSON.stringify([{ type: "image", mimeType: "image/png", data: "%%%" }])),
(error: unknown) =>
error instanceof PromptInputValidationError &&
/image block data must be valid base64/.test(error.message),
error.message.includes("image block data must be valid base64"),
);
});
@ -88,7 +88,7 @@ test("parsePromptSource rejects invalid text and resource block shapes", () => {
() => parsePromptSource(JSON.stringify([{ type: "text", text: 123 }])),
(error: unknown) =>
error instanceof PromptInputValidationError &&
/text block must include a string text field/.test(error.message),
error.message.includes("text block must include a string text field"),
);
assert.throws(
@ -103,7 +103,7 @@ test("parsePromptSource rejects invalid text and resource block shapes", () => {
),
(error: unknown) =>
error instanceof PromptInputValidationError &&
/resource_link block must include a non-empty uri/.test(error.message),
error.message.includes("resource_link block must include a non-empty uri"),
);
assert.throws(
@ -121,7 +121,9 @@ test("parsePromptSource rejects invalid text and resource block shapes", () => {
),
(error: unknown) =>
error instanceof PromptInputValidationError &&
/resource block resource must include a non-empty uri and optional text/.test(error.message),
error.message.includes(
"resource block resource must include a non-empty uri and optional text",
),
);
});

View File

@ -1,6 +1,5 @@
import assert from "node:assert/strict";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import test from "node:test";
import { fileURLToPath } from "node:url";
@ -9,9 +8,12 @@ import {
runSessionSetModelDirect,
runSessionSetModeDirect,
} from "../src/cli/session/prompt-runner.js";
import { serializeSessionRecordForDisk } from "../src/session/persistence.js";
import { resolveSessionRecord } from "../src/session/persistence/repository.js";
import type { SessionRecord } from "../src/types.js";
import {
makeSessionRecord as makeSessionRecordFixture,
withTempHome as withTempHomeFixture,
writeSessionRecordFile as writeSessionRecord,
} from "./runtime-test-helpers.js";
const MOCK_AGENT_PATH = fileURLToPath(new URL("./mock-agent.js", import.meta.url));
@ -211,78 +213,12 @@ test("runSessionSetModelDirect updates current and desired model", async () => {
});
});
function makeSessionRecord(
overrides: Partial<SessionRecord> & {
acpxRecordId: string;
acpSessionId: string;
agentCommand: string;
cwd: string;
},
): SessionRecord {
const timestamp = "2026-01-01T00:00:00.000Z";
return {
schema: "acpx.session.v1",
acpxRecordId: overrides.acpxRecordId,
acpSessionId: overrides.acpSessionId,
agentSessionId: overrides.agentSessionId,
agentCommand: overrides.agentCommand,
cwd: path.resolve(overrides.cwd),
name: overrides.name,
createdAt: overrides.createdAt ?? timestamp,
lastUsedAt: overrides.lastUsedAt ?? timestamp,
lastSeq: overrides.lastSeq ?? 0,
lastRequestId: overrides.lastRequestId,
eventLog: overrides.eventLog ?? {
active_path: ".stream.ndjson",
segment_count: 1,
max_segment_bytes: 1024,
max_segments: 1,
last_write_at: overrides.lastUsedAt ?? timestamp,
last_write_error: null,
},
closed: overrides.closed ?? false,
closedAt: overrides.closedAt,
pid: overrides.pid,
agentStartedAt: overrides.agentStartedAt,
lastPromptAt: overrides.lastPromptAt,
lastAgentExitCode: overrides.lastAgentExitCode,
lastAgentExitSignal: overrides.lastAgentExitSignal,
lastAgentExitAt: overrides.lastAgentExitAt,
lastAgentDisconnectReason: overrides.lastAgentDisconnectReason,
protocolVersion: overrides.protocolVersion,
agentCapabilities: overrides.agentCapabilities,
title: overrides.title ?? null,
messages: overrides.messages ?? [],
updated_at: overrides.updated_at ?? overrides.lastUsedAt ?? timestamp,
cumulative_token_usage: overrides.cumulative_token_usage ?? {},
request_token_usage: overrides.request_token_usage ?? {},
acpx: overrides.acpx,
};
}
async function writeSessionRecord(homeDir: string, record: SessionRecord): Promise<void> {
const sessionDir = path.join(homeDir, ".acpx", "sessions");
await fs.mkdir(sessionDir, { recursive: true });
await fs.writeFile(
path.join(sessionDir, `${encodeURIComponent(record.acpxRecordId)}.json`),
`${JSON.stringify(serializeSessionRecordForDisk(record), null, 2)}\n`,
"utf8",
);
}
async function withTempHome(run: (homeDir: string) => Promise<void>): Promise<void> {
const originalHome = process.env.HOME;
const homeDir = await fs.mkdtemp(path.join(os.tmpdir(), "acpx-prompt-runner-home-"));
process.env.HOME = homeDir;
try {
await run(homeDir);
} finally {
if (originalHome == null) {
delete process.env.HOME;
} else {
process.env.HOME = originalHome;
}
await fs.rm(homeDir, { recursive: true, force: true });
}
await withTempHomeFixture("acpx-prompt-runner-home-", run);
}
function makeSessionRecord(
overrides: Parameters<typeof makeSessionRecordFixture>[0],
): ReturnType<typeof makeSessionRecordFixture> {
return makeSessionRecordFixture(overrides, { defaultName: false, defaultAcpx: false });
}

View File

@ -513,6 +513,7 @@ 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
},
@ -560,6 +561,7 @@ 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
},
@ -628,6 +630,7 @@ test("SessionQueueOwner rejects prompts when queue depth exceeds the configured
lease,
{
cancelPrompt: async () => false,
closeSession: async () => false,
setSessionMode: async () => {
// no-op
},

View File

@ -15,6 +15,7 @@ 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 }> = [];
@ -23,6 +24,10 @@ 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);
},
@ -105,7 +110,30 @@ 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 {
@ -125,6 +153,7 @@ test("SessionQueueOwner enqueues fire-and-forget prompts and rejects invalid own
lease,
{
cancelPrompt: async () => false,
closeSession: async () => false,
setSessionMode: async () => {
// no-op
},

View File

@ -54,3 +54,11 @@ test("createReplayPatch normalizes string growth and array growth to JSON Patch+
);
assert.deepEqual(applyReplayPatch(previous, ops), next);
});
test("applyReplayPatch rejects prototype pollution pointer keys", () => {
assert.throws(
() => applyReplayPatch({}, [{ op: "add", path: "/__proto__/polluted", value: true }]),
/Unsafe JSON Pointer key/,
);
assert.equal(({} as { polluted?: boolean }).polluted, undefined);
});

View File

@ -333,7 +333,15 @@ function createMessageInbox(socket: WebSocket) {
}> = [];
socket.on("message", (data) => {
const message = JSON.parse(data.toString()) as ReplayServerMessage;
const text =
typeof data === "string"
? data
: Array.isArray(data)
? Buffer.concat(data).toString("utf8")
: Buffer.isBuffer(data)
? data.toString("utf8")
: Buffer.from(new Uint8Array(data)).toString("utf8");
const message = JSON.parse(text) as ReplayServerMessage;
for (let index = 0; index < waiters.length; index += 1) {
const waiter = waiters[index];

View File

@ -16,6 +16,22 @@ Object.assign(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }, {
IS_REACT_ACT_ENVIRONMENT: true,
});
function createRenderer(element: Parameters<typeof create>[0]): ReturnType<typeof create> {
const originalError = console.error;
console.error = ((message?: unknown, ...args: unknown[]) => {
if (typeof message === "string" && message.includes("react-test-renderer is deprecated")) {
return;
}
originalError(message, ...args);
}) as typeof console.error;
try {
return create(element);
} finally {
console.error = originalError;
}
}
test("useRunBundleLoader bootstrap stays stable after recent-runs state updates", async () => {
const run: RunBundleSummary = {
runId: "2026-03-31T200000000Z-pr-triage-live",
@ -59,7 +75,7 @@ test("useRunBundleLoader bootstrap stays stable after recent-runs state updates"
let renderer: ReturnType<typeof create> | null = null;
await act(async () => {
renderer = create(createElement(Harness));
renderer = createRenderer(createElement(Harness));
await flushReactWork();
});
@ -124,7 +140,7 @@ test("useRunBundleLoader ignores stale bootstrap results after a newer live runs
let renderer: ReturnType<typeof create> | null = null;
try {
await act(async () => {
renderer = create(createElement(Harness));
renderer = createRenderer(createElement(Harness));
await flushReactWork();
});
@ -194,7 +210,7 @@ test("useRunBundleLoader waits for recent runs instead of loading the bundled sa
let renderer: ReturnType<typeof create> | null = null;
await act(async () => {
renderer = create(createElement(Harness));
renderer = createRenderer(createElement(Harness));
await flushReactWork();
});
@ -252,7 +268,7 @@ test("useRunBundleLoader auto-loads the first recent run when the list becomes n
let renderer: ReturnType<typeof create> | null = null;
await act(async () => {
renderer = create(createElement(Harness));
renderer = createRenderer(createElement(Harness));
await flushReactWork();
});
@ -351,7 +367,7 @@ test("useRunBundleLoader ignores stale recent-run loads when a newer live select
let renderer: ReturnType<typeof create> | null = null;
try {
await act(async () => {
renderer = create(createElement(Harness));
renderer = createRenderer(createElement(Harness));
await flushReactWork();
});
@ -429,7 +445,7 @@ test("useRunBundleLoader resyncs runs when a live runs patch cannot be applied",
let renderer: ReturnType<typeof create> | null = null;
try {
await act(async () => {
renderer = create(createElement(Harness));
renderer = createRenderer(createElement(Harness));
await flushReactWork();
});
@ -519,7 +535,7 @@ test("useRunBundleLoader resyncs the selected run when a live run patch cannot b
let renderer: ReturnType<typeof create> | null = null;
try {
await act(async () => {
renderer = create(createElement(Harness));
renderer = createRenderer(createElement(Harness));
await flushReactWork();
});

View File

@ -143,7 +143,7 @@ test("replay viewer blocks file reads that escape the runs directory via runId",
`${viewerServer.baseUrl}/api/runs/..%2F..%2Fsessions/files/session-secret.json`,
);
assert.equal(response.status, 400);
assert.match(await response.text(), /outside runs directory/i);
assert.deepEqual(await response.json(), { error: "Invalid run bundle file request" });
} finally {
await viewerServer.close().catch(() => {});
await fs.rm(fakeHome, { recursive: true, force: true });

View File

@ -126,7 +126,7 @@ test("buildGraph applies playback progress to the active node during preview", (
});
const timeline = buildPlaybackTimeline(bundle);
const preview = derivePlaybackPreview(timeline, timeline.segments[1]!.startMs + 200);
const preview = derivePlaybackPreview(timeline, timeline.segments[1].startMs + 200);
const graph = buildGraph(bundle, preview!.activeStepIndex, preview);
const nodeMap = new Map(graph.nodes.map((node) => [node.id, node.data]));
@ -271,7 +271,7 @@ test("buildGraphLayout uses layered routing and sinks terminal chains", async ()
assert.ok(layout);
assert.ok(layout.nodePositions.finalize);
assert.ok(layout.nodePositions.comment_and_escalate_to_human);
assert.ok(layout.edgeRoutes["judge_solution->bug_or_feature-0-0"]?.points.length! >= 2);
assert.ok(layout.edgeRoutes["judge_solution->bug_or_feature-0-0"]?.points.length >= 2);
assert.ok(layout.nodePositions.finalize.y > layout.nodePositions.comment_and_escalate_to_human.y);
});
@ -415,7 +415,7 @@ test("revealConversationTranscript keeps prior session messages visible while st
},
},
});
bundle.steps[0]!.trace!.conversation = {
bundle.steps[0].trace!.conversation = {
sessionId: "main-bundle",
messageStart: 2,
messageEnd: 3,
@ -514,7 +514,7 @@ test("buildPlaybackTimeline and anchors support continuous preview with discrete
assert.equal(playbackAnchorMs(timeline, 0), 0);
assert.equal(playbackAnchorMs(timeline, 1), timeline.segments[1]?.startMs);
const preview = derivePlaybackPreview(timeline, timeline.segments[1]!.startMs + 120);
const preview = derivePlaybackPreview(timeline, timeline.segments[1].startMs + 120);
assert.equal(preview?.activeStepIndex, 1);
assert.equal(preview?.nearestStepIndex, 1);

View File

@ -1,6 +1,7 @@
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,
@ -26,8 +27,18 @@ type FakeClient = {
};
start: () => Promise<void>;
close: () => Promise<void>;
createSession: (cwd: string) => Promise<{ sessionId: string; agentSessionId?: string }>;
loadSession: (sessionId: string, cwd: string) => Promise<{ agentSessionId?: string }>;
createSession: (cwd: string) => Promise<{
sessionId: string;
agentSessionId?: string;
configOptions?: SetSessionConfigOptionResponse["configOptions"];
}>;
loadSession: (
sessionId: string,
cwd: string,
) => Promise<{
agentSessionId?: string;
configOptions?: SetSessionConfigOptionResponse["configOptions"];
}>;
hasReusableSession: (sessionId: string) => boolean;
supportsLoadSession: () => boolean;
supportsCloseSession?: () => boolean;
@ -143,12 +154,35 @@ 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" };
return {
sessionId: "new-session",
agentSessionId: "agent-session",
configOptions: [
{
id: "mode",
name: "Mode",
type: "select",
currentValue: "ask",
options: [{ value: "ask", name: "Ask" }],
},
],
};
},
loadSession: async (sessionId, cwd) => {
assert.equal(sessionId, "resume-session");
assert.equal(cwd, "/workspace");
return { agentSessionId: "resumed-agent" };
return {
agentSessionId: "resumed-agent",
configOptions: [
{
id: "model",
name: "Model",
type: "select",
currentValue: "fast",
options: [{ value: "fast", name: "Fast" }],
},
],
};
},
hasReusableSession: () => false,
supportsLoadSession: () => true,
@ -181,6 +215,10 @@ 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/);
@ -192,6 +230,10 @@ 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);
});
@ -1523,7 +1565,12 @@ test("AcpRuntimeManager surfaces normalized prompt failures", async () => {
loadSessionWithOptions: async () => ({ agentSessionId: "unused" }),
getAgentLifecycleSnapshot: () => ({ running: true }),
prompt: async () => {
throw new Error("prompt exploded");
throw new AcpxOperationalError("prompt exploded", {
outputCode: "RUNTIME",
detailCode: "AGENT_DISCONNECTED",
origin: "acp",
retryable: true,
});
},
requestCancelActivePrompt: async () => false,
hasActivePrompt: () => false,
@ -1545,8 +1592,33 @@ test("AcpRuntimeManager surfaces normalized prompt failures", async () => {
const { events, result } = await collectTurn(turn);
assert.deepEqual(events, []);
assert.equal(result.status, "failed");
assert.match(result.error?.message ?? "", /prompt exploded/);
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,
},
]);
});
test("AcpRuntimeManager rejects unsupported runtime attachment media types", async () => {
@ -1649,6 +1721,7 @@ test("AcpRuntimeManager fails persistent turns clearly when session/load is unav
status: "failed",
error: {
code: "RUNTIME",
detailCode: "SESSION_RESUME_REQUIRED",
message:
"Persistent ACP session persistent-backend-session could not be resumed: agent does not support session/load",
retryable: true,

View File

@ -2,8 +2,15 @@ import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import type { AcpAgentRegistry, AcpRuntimeOptions, AcpSessionStore } from "../src/runtime.js";
import { serializeSessionRecordForDisk } from "../src/session/persistence.js";
import type { SessionRecord } from "../src/types.js";
export type MakeSessionRecordOptions = {
defaultName?: boolean;
defaultAcpx?: boolean;
resolveCwd?: boolean;
};
export function makeSessionRecord(
overrides: Partial<SessionRecord> & {
acpxRecordId: string;
@ -11,16 +18,19 @@ export function makeSessionRecord(
agentCommand: string;
cwd: string;
},
options: MakeSessionRecordOptions = {},
): SessionRecord {
const timestamp = "2026-01-01T00:00:00.000Z";
const defaultName = options.defaultName ?? true;
const defaultAcpx = options.defaultAcpx ?? true;
return {
schema: "acpx.session.v1",
acpxRecordId: overrides.acpxRecordId,
acpSessionId: overrides.acpSessionId,
agentSessionId: overrides.agentSessionId,
agentCommand: overrides.agentCommand,
cwd: path.resolve(overrides.cwd),
name: overrides.name ?? overrides.acpxRecordId,
cwd: options.resolveCwd === false ? overrides.cwd : path.resolve(overrides.cwd),
name: overrides.name ?? (defaultName ? overrides.acpxRecordId : undefined),
createdAt: overrides.createdAt ?? timestamp,
lastUsedAt: overrides.lastUsedAt ?? timestamp,
lastSeq: overrides.lastSeq ?? 0,
@ -49,7 +59,7 @@ export function makeSessionRecord(
updated_at: overrides.updated_at ?? overrides.lastUsedAt ?? timestamp,
cumulative_token_usage: overrides.cumulative_token_usage ?? {},
request_token_usage: overrides.request_token_usage ?? {},
acpx: overrides.acpx ?? {},
acpx: overrides.acpx ?? (defaultAcpx ? {} : undefined),
};
}
@ -62,6 +72,52 @@ export async function withTempDir<T>(prefix: string, fn: (dir: string) => Promis
}
}
export async function withTempHome<T>(
prefix: string,
run: (homeDir: string) => Promise<T>,
): Promise<T> {
const originalHome = process.env.HOME;
const tempHome = await fs.mkdtemp(path.join(os.tmpdir(), prefix));
process.env.HOME = tempHome;
try {
return await run(tempHome);
} finally {
if (originalHome == null) {
delete process.env.HOME;
} else {
process.env.HOME = originalHome;
}
await fs.rm(tempHome, { recursive: true, force: true });
}
}
export function sessionFilePath(homeDir: string, acpxRecordId: string): string {
return path.join(homeDir, ".acpx", "sessions", `${encodeURIComponent(acpxRecordId)}.json`);
}
export async function writeSessionRecordFile(
homeDir: string,
record: SessionRecord,
): Promise<void> {
const filePath = sessionFilePath(homeDir, record.acpxRecordId);
await fs.mkdir(path.dirname(filePath), { recursive: true });
await fs.writeFile(
filePath,
`${JSON.stringify(serializeSessionRecordForDisk(record), null, 2)}\n`,
"utf8",
);
}
export async function fileExists(filePath: string): Promise<boolean> {
try {
await fs.access(filePath);
return true;
} catch {
return false;
}
}
export class InMemorySessionStore implements AcpSessionStore {
readonly records = new Map<string, SessionRecord>();
readonly savedRecordIds: string[] = [];

View File

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

View File

@ -2,11 +2,16 @@ import assert from "node:assert/strict";
import { spawn } from "node:child_process";
import { once } from "node:events";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import test from "node:test";
import { serializeSessionRecordForDisk } from "../src/session/persistence.js";
import type { SessionRecord } from "../src/types.js";
import {
fileExists,
makeSessionRecord as makeSessionRecordFixture,
sessionFilePath,
withTempHome as withTempHomeFixture,
writeSessionRecordFile as writeSessionRecord,
} from "./runtime-test-helpers.js";
type SessionModule = typeof import("../src/session/session.js");
@ -450,92 +455,13 @@ async function loadSessionModule(): Promise<SessionModule> {
}
async function withTempHome(run: (homeDir: string) => Promise<void>): Promise<void> {
const originalHome = process.env.HOME;
const tempHome = await fs.mkdtemp(path.join(os.tmpdir(), "acpx-test-home-"));
process.env.HOME = tempHome;
try {
await run(tempHome);
} finally {
if (originalHome == null) {
delete process.env.HOME;
} else {
process.env.HOME = originalHome;
}
await fs.rm(tempHome, { recursive: true, force: true });
}
await withTempHomeFixture("acpx-test-home-", run);
}
function makeSessionRecord(
overrides: Partial<SessionRecord> & {
acpxRecordId: string;
acpSessionId: string;
agentCommand: string;
cwd: string;
},
): SessionRecord {
const timestamp = "2026-01-01T00:00:00.000Z";
return {
schema: "acpx.session.v1",
acpxRecordId: overrides.acpxRecordId,
acpSessionId: overrides.acpSessionId,
agentSessionId: overrides.agentSessionId,
agentCommand: overrides.agentCommand,
cwd: path.resolve(overrides.cwd),
name: overrides.name,
createdAt: overrides.createdAt ?? timestamp,
lastUsedAt: overrides.lastUsedAt ?? timestamp,
lastSeq: overrides.lastSeq ?? 0,
lastRequestId: overrides.lastRequestId,
eventLog: overrides.eventLog ?? {
active_path: `.stream.ndjson`,
segment_count: 1,
max_segment_bytes: 1024,
max_segments: 1,
last_write_at: overrides.lastUsedAt ?? timestamp,
last_write_error: null,
},
closed: overrides.closed ?? false,
closedAt: overrides.closedAt,
pid: overrides.pid,
agentStartedAt: overrides.agentStartedAt,
lastPromptAt: overrides.lastPromptAt,
lastAgentExitCode: overrides.lastAgentExitCode,
lastAgentExitSignal: overrides.lastAgentExitSignal,
lastAgentExitAt: overrides.lastAgentExitAt,
lastAgentDisconnectReason: overrides.lastAgentDisconnectReason,
protocolVersion: overrides.protocolVersion,
agentCapabilities: overrides.agentCapabilities,
title: overrides.title ?? null,
messages: overrides.messages ?? [],
updated_at: overrides.updated_at ?? overrides.lastUsedAt ?? timestamp,
cumulative_token_usage: overrides.cumulative_token_usage ?? {},
request_token_usage: overrides.request_token_usage ?? {},
acpx: overrides.acpx,
};
}
function sessionFilePath(homeDir: string, acpxRecordId: string): string {
return path.join(homeDir, ".acpx", "sessions", `${encodeURIComponent(acpxRecordId)}.json`);
}
async function writeSessionRecord(homeDir: string, record: SessionRecord): Promise<void> {
const filePath = sessionFilePath(homeDir, record.acpxRecordId);
await fs.mkdir(path.dirname(filePath), { recursive: true });
await fs.writeFile(
filePath,
`${JSON.stringify(serializeSessionRecordForDisk(record), null, 2)}\n`,
"utf8",
);
}
async function fileExists(filePath: string): Promise<boolean> {
try {
await fs.access(filePath);
return true;
} catch {
return false;
}
overrides: Parameters<typeof makeSessionRecordFixture>[0],
): ReturnType<typeof makeSessionRecordFixture> {
return makeSessionRecordFixture(overrides, { defaultName: false, defaultAcpx: false });
}
async function waitForExit(pid: number | undefined): Promise<boolean> {

View File

@ -1,10 +1,14 @@
import assert from "node:assert/strict";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import test from "node:test";
import { serializeSessionRecordForDisk } from "../src/session/persistence.js";
import type { SessionRecord } from "../src/types.js";
import {
fileExists,
makeSessionRecord as makeSessionRecordFixture,
sessionFilePath,
withTempHome as withTempHomeFixture,
writeSessionRecordFile as writeSessionRecord,
} from "./runtime-test-helpers.js";
type SessionModule = typeof import("../src/session/session.js");
@ -16,92 +20,13 @@ async function loadSessionModule(): Promise<SessionModule> {
}
async function withTempHome(run: (homeDir: string) => Promise<void>): Promise<void> {
const originalHome = process.env.HOME;
const tempHome = await fs.mkdtemp(path.join(os.tmpdir(), "acpx-prune-test-"));
process.env.HOME = tempHome;
try {
await run(tempHome);
} finally {
if (originalHome == null) {
delete process.env.HOME;
} else {
process.env.HOME = originalHome;
}
await fs.rm(tempHome, { recursive: true, force: true });
}
await withTempHomeFixture("acpx-prune-test-", run);
}
function makeSessionRecord(
overrides: Partial<SessionRecord> & {
acpxRecordId: string;
acpSessionId: string;
agentCommand: string;
cwd: string;
},
): SessionRecord {
const timestamp = "2026-01-01T00:00:00.000Z";
return {
schema: "acpx.session.v1",
acpxRecordId: overrides.acpxRecordId,
acpSessionId: overrides.acpSessionId,
agentSessionId: overrides.agentSessionId,
agentCommand: overrides.agentCommand,
cwd: path.resolve(overrides.cwd),
name: overrides.name,
createdAt: overrides.createdAt ?? timestamp,
lastUsedAt: overrides.lastUsedAt ?? timestamp,
lastSeq: overrides.lastSeq ?? 0,
lastRequestId: overrides.lastRequestId,
eventLog: overrides.eventLog ?? {
active_path: `.stream.ndjson`,
segment_count: 1,
max_segment_bytes: 1024,
max_segments: 1,
last_write_at: overrides.lastUsedAt ?? timestamp,
last_write_error: null,
},
closed: overrides.closed ?? false,
closedAt: overrides.closedAt,
pid: overrides.pid,
agentStartedAt: overrides.agentStartedAt,
lastPromptAt: overrides.lastPromptAt,
lastAgentExitCode: overrides.lastAgentExitCode,
lastAgentExitSignal: overrides.lastAgentExitSignal,
lastAgentExitAt: overrides.lastAgentExitAt,
lastAgentDisconnectReason: overrides.lastAgentDisconnectReason,
protocolVersion: overrides.protocolVersion,
agentCapabilities: overrides.agentCapabilities,
title: overrides.title ?? null,
messages: overrides.messages ?? [],
updated_at: overrides.updated_at ?? overrides.lastUsedAt ?? timestamp,
cumulative_token_usage: overrides.cumulative_token_usage ?? {},
request_token_usage: overrides.request_token_usage ?? {},
acpx: overrides.acpx,
};
}
function sessionFilePath(homeDir: string, acpxRecordId: string): string {
return path.join(homeDir, ".acpx", "sessions", `${encodeURIComponent(acpxRecordId)}.json`);
}
async function writeSessionRecord(homeDir: string, record: SessionRecord): Promise<void> {
const filePath = sessionFilePath(homeDir, record.acpxRecordId);
await fs.mkdir(path.dirname(filePath), { recursive: true });
await fs.writeFile(
filePath,
`${JSON.stringify(serializeSessionRecordForDisk(record), null, 2)}\n`,
"utf8",
);
}
async function fileExists(filePath: string): Promise<boolean> {
try {
await fs.access(filePath);
return true;
} catch {
return false;
}
overrides: Parameters<typeof makeSessionRecordFixture>[0],
): ReturnType<typeof makeSessionRecordFixture> {
return makeSessionRecordFixture(overrides, { defaultName: false, defaultAcpx: false });
}
test("pruneSessions returns empty result when no closed sessions exist", async () => {

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