Compare commits
24 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d46e156102 | ||
|
|
f6803dad1e | ||
|
|
0132c82713 | ||
|
|
9fedaec7c2 | ||
|
|
0f3a34a9eb | ||
|
|
f5fa1862e0 | ||
|
|
c89b344f45 | ||
|
|
2d1e30d00c | ||
|
|
fd67b109b3 | ||
|
|
43013ead1a | ||
|
|
032bc40466 | ||
|
|
c6bc937a29 | ||
|
|
97f301dc69 | ||
|
|
436fd4fd66 | ||
|
|
a23310ee99 | ||
|
|
03d87dd493 | ||
|
|
1a9fdabfd7 | ||
|
|
541742f800 | ||
|
|
e1a3546669 | ||
|
|
2480c48806 | ||
|
|
119b84ee31 | ||
|
|
7f3c177e7c | ||
|
|
7ac8cf0bb3 | ||
|
|
69f527646b |
11
.github/workflows/ci.yml
vendored
11
.github/workflows/ci.yml
vendored
@ -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 }}
|
||||
|
||||
|
||||
4
.github/workflows/conformance-nightly.yml
vendored
4
.github/workflows/conformance-nightly.yml
vendored
@ -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
55
.github/workflows/pages.yml
vendored
Normal 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
|
||||
2
.github/workflows/release.yml
vendored
2
.github/workflows/release.yml
vendored
@ -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 }}
|
||||
|
||||
|
||||
@ -1,9 +1,9 @@
|
||||
{
|
||||
"$schema": "./node_modules/oxfmt/configuration_schema.json",
|
||||
"experimentalSortImports": {
|
||||
"sortImports": {
|
||||
"newlinesBetween": false,
|
||||
},
|
||||
"experimentalSortPackageJson": {
|
||||
"sortPackageJson": {
|
||||
"sortScripts": true,
|
||||
},
|
||||
"tabWidth": 2,
|
||||
|
||||
136
.oxlintrc.json
136
.oxlintrc.json
@ -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"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
18
CHANGELOG.md
18
CHANGELOG.md
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
38
docs/CLI.md
38
docs/CLI.md
@ -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
169
docs/VISION.md
Normal 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
198
docs/agents.md
Normal 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
187
docs/config.md
Normal 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
136
docs/custom-agents.md
Normal 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
49
docs/exit-codes.md
Normal 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
198
docs/flows.md
Normal 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
55
docs/index.md
Normal 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
108
docs/install.md
Normal 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
144
docs/output-formats.md
Normal 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
137
docs/permissions.md
Normal 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
181
docs/prompting.md
Normal 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
136
docs/quickstart.md
Normal 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
112
docs/session-control.md
Normal 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
211
docs/sessions.md
Normal 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.
|
||||
@ -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
|
||||
|
||||
@ -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",
|
||||
},
|
||||
},
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
@ -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}`;
|
||||
}
|
||||
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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";
|
||||
|
||||
@ -270,7 +270,7 @@ function ModeButton({
|
||||
children: ReactNode;
|
||||
label: string;
|
||||
active: boolean;
|
||||
onClick(): void;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -11,7 +11,7 @@ export function useGraphLayout(bundle: LoadedRunBundle | null) {
|
||||
|
||||
if (!bundle) {
|
||||
setLayout(null);
|
||||
return;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
setLayout(null);
|
||||
|
||||
@ -37,7 +37,7 @@ export function useStickyAutoFollow(options: {
|
||||
useEffect(() => {
|
||||
const scrollContainer = scrollContainerRef.current;
|
||||
if (!enabled || !scrollContainer) {
|
||||
return;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const handleWheel = (event: WheelEvent) => {
|
||||
|
||||
@ -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}`);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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 "";
|
||||
}
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -19,5 +19,6 @@ export default defineConfig({
|
||||
build: {
|
||||
outDir: path.resolve(__dirname, "dist"),
|
||||
emptyOutDir: true,
|
||||
chunkSizeWarningLimit: 1_500,
|
||||
},
|
||||
});
|
||||
|
||||
4347
package-lock.json
generated
4347
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
25
package.json
25
package.json
@ -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
650
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
784
scripts/build-docs-site.mjs
Normal file
784
scripts/build-docs-site.mjs
Normal 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(/<(https?:\/\/[^\s<>]+)>/g, '<a href="$1">$1</a>');
|
||||
out = out.replace(/\\\|/g, "|");
|
||||
out = out.replace(/<br>/g, "<br>");
|
||||
return out.replace(/@@ACPXCODE(\d+)@@/g, (_, i) => stash[Number(i)]);
|
||||
}
|
||||
|
||||
function rewriteHref(href, currentRel) {
|
||||
if (/^(https?:|mailto:|tel:|#)/.test(href)) {
|
||||
return href;
|
||||
}
|
||||
const [raw, hash = ""] = href.split("#");
|
||||
if (!raw) {
|
||||
return hash ? `#${hash}` : "";
|
||||
}
|
||||
if (raw.startsWith("/")) {
|
||||
const target = permalinkMap.get(normalizePermalink(raw));
|
||||
if (target) {
|
||||
const currentOut = pageMap.get(currentRel)?.outRel || outPath(currentRel);
|
||||
const out = hrefToOutRel(target.outRel, currentOut);
|
||||
return hash ? `${out}#${hash}` : out;
|
||||
}
|
||||
return href;
|
||||
}
|
||||
if (!raw.endsWith(".md")) {
|
||||
return href;
|
||||
}
|
||||
const from = path.posix.dirname(currentRel);
|
||||
const target = path.posix.normalize(path.posix.join(from, raw));
|
||||
let rewritten = pageMap.get(target)?.outRel || outPath(target);
|
||||
const currentOut = pageMap.get(currentRel)?.outRel || outPath(currentRel);
|
||||
rewritten = hrefToOutRel(rewritten, currentOut);
|
||||
return `${rewritten}${hash ? `#${hash}` : ""}`;
|
||||
}
|
||||
|
||||
function tocFromHtml(html) {
|
||||
const items = [];
|
||||
const re = /<h([23]) id="([^"]+)">([\s\S]*?)<\/h[23]>/g;
|
||||
let m;
|
||||
while ((m = re.exec(html))) {
|
||||
const text = m[3]
|
||||
.replace(/<a class="anchor"[^>]*>.*?<\/a>/, "")
|
||||
.replace(/<[^>]+>/g, "")
|
||||
.trim();
|
||||
items.push({ level: Number(m[1]), id: m[2], text });
|
||||
}
|
||||
if (items.length < 2) {
|
||||
return "";
|
||||
}
|
||||
return `<nav class="toc" aria-label="On this page"><h2>On this page</h2>${items
|
||||
.map((i) => `<a class="toc-l${i.level}" href="#${i.id}">${escapeHtml(i.text)}</a>`)
|
||||
.join("")}</nav>`;
|
||||
}
|
||||
|
||||
function isHomePage(page) {
|
||||
if (page.frontmatter.permalink && normalizePermalink(page.frontmatter.permalink) === "/") {
|
||||
return true;
|
||||
}
|
||||
return page.rel === "index.md" || page.rel === "README.md";
|
||||
}
|
||||
|
||||
function homeHero(page) {
|
||||
const description = page.frontmatter.description || productDescription;
|
||||
const installRel = pageMap.get("install.md")?.outRel
|
||||
? hrefToOutRel(pageMap.get("install.md").outRel, page.outRel)
|
||||
: "install.html";
|
||||
const quickstartRel = pageMap.get("quickstart.md")?.outRel
|
||||
? hrefToOutRel(pageMap.get("quickstart.md").outRel, page.outRel)
|
||||
: "quickstart.html";
|
||||
const agents = [
|
||||
"codex",
|
||||
"claude",
|
||||
"pi",
|
||||
"openclaw",
|
||||
"gemini",
|
||||
"cursor",
|
||||
"copilot",
|
||||
"droid",
|
||||
"qwen",
|
||||
"qoder",
|
||||
"opencode",
|
||||
"kimi",
|
||||
];
|
||||
return `<header class="home-hero">
|
||||
<p class="eyebrow"><span class="dot" aria-hidden="true"></span> Agent Client Protocol · Headless CLI</p>
|
||||
<h1>Talk to agents <span class="accent">from the command line</span></h1>
|
||||
<p class="lede">${escapeHtml(description)}</p>
|
||||
<div class="home-cta">
|
||||
<a class="btn btn-primary" href="${quickstartRel}">Quickstart</a>
|
||||
<a class="btn btn-ghost" href="${repoBase}" rel="noopener">GitHub</a>
|
||||
<div class="home-install" aria-label="Install with npm">
|
||||
<span class="prompt" aria-hidden="true">$</span>
|
||||
<code>${escapeHtml(installCommand)}</code>
|
||||
</div>
|
||||
</div>
|
||||
<div class="home-services" aria-label="Built-in agents">
|
||||
${agents.map((s) => `<span>${escapeHtml(s)}</span>`).join("")}
|
||||
</div>
|
||||
<p class="muted"><a href="${installRel}">Install options →</a></p>
|
||||
</header>`;
|
||||
}
|
||||
|
||||
function standardHero(page, sectionName, editUrl) {
|
||||
return `<header class="hero">
|
||||
<div class="hero-text">
|
||||
<p class="eyebrow">${escapeHtml(sectionName)}</p>
|
||||
<h1>${escapeHtml(page.title)}</h1>
|
||||
</div>
|
||||
<div class="hero-meta">
|
||||
<a class="repo" href="${repoBase}" rel="noopener">GitHub</a>
|
||||
<a class="edit" href="${escapeAttr(editUrl)}" rel="noopener">Edit page</a>
|
||||
</div>
|
||||
</header>`;
|
||||
}
|
||||
|
||||
function layout({ page, html, toc, prev, next, sectionName }) {
|
||||
const depth = page.outRel.split("/").length - 1;
|
||||
const rootPrefix = depth ? "../".repeat(depth) : "";
|
||||
const editUrl = `${repoEditBase}/${page.rel}`;
|
||||
const home = isHomePage(page);
|
||||
const prevNext = !home && (prev || next) ? pageNavHtml(prev, next, page.outRel) : "";
|
||||
const heroBlock = home ? homeHero(page) : standardHero(page, sectionName, editUrl);
|
||||
const articleClass = home ? "doc doc-home" : "doc";
|
||||
const tocBlock = home ? "" : toc;
|
||||
const titleSuffix = home
|
||||
? `${productName} — ${productTagline}`
|
||||
: `${page.title} — ${productName}`;
|
||||
const description =
|
||||
page.frontmatter.description ||
|
||||
(home ? productDescription : `${page.title} — ${productName} CLI documentation.`);
|
||||
const canonicalUrl = pageCanonicalUrl(page);
|
||||
const socialMeta = [
|
||||
["link", "rel", "canonical", "href", canonicalUrl],
|
||||
["meta", "property", "og:type", "content", "website"],
|
||||
["meta", "property", "og:site_name", "content", productName],
|
||||
["meta", "property", "og:title", "content", titleSuffix],
|
||||
["meta", "property", "og:description", "content", description],
|
||||
["meta", "property", "og:url", "content", canonicalUrl],
|
||||
["meta", "name", "twitter:card", "content", "summary_large_image"],
|
||||
["meta", "name", "twitter:title", "content", titleSuffix],
|
||||
["meta", "name", "twitter:description", "content", description],
|
||||
]
|
||||
.map(tagHtml)
|
||||
.join("\n ");
|
||||
return `<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>${escapeHtml(titleSuffix)}</title>
|
||||
<meta name="description" content="${escapeAttr(description)}">
|
||||
${socialMeta}
|
||||
<link rel="icon" href="${rootPrefix}favicon.svg" type="image/svg+xml">
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
|
||||
<script>${preThemeScript()}</script>
|
||||
<style>${css()}</style>
|
||||
</head>
|
||||
<body${home ? ' class="home"' : ""}>
|
||||
<button class="nav-toggle" type="button" aria-label="Toggle navigation" aria-expanded="false">
|
||||
<span aria-hidden="true"></span><span aria-hidden="true"></span><span aria-hidden="true"></span>
|
||||
</button>
|
||||
<div class="shell">
|
||||
<aside class="sidebar">
|
||||
<div class="sidebar-head">
|
||||
<a class="brand" href="${hrefToOutRel("index.html", page.outRel)}" aria-label="${productName} docs home">
|
||||
${brandMarkHtml()}
|
||||
<span><strong>${escapeHtml(productName)}</strong><small>ACP CLI docs</small></span>
|
||||
</a>
|
||||
${themeToggleHtml()}
|
||||
</div>
|
||||
<label class="search"><span>Search</span><input id="doc-search" type="search" placeholder="sessions, flows, json…"></label>
|
||||
<nav>${navHtml(page)}</nav>
|
||||
</aside>
|
||||
<main>
|
||||
${heroBlock}
|
||||
<div class="doc-grid${home ? " doc-grid-home" : ""}">
|
||||
<article class="${articleClass}">${html}${prevNext}</article>
|
||||
${tocBlock}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
<script>${js()}</script>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
function pageCanonicalUrl(page) {
|
||||
if (!siteBase) {
|
||||
return page.outRel;
|
||||
}
|
||||
if (page.outRel === "index.html") {
|
||||
return `${siteBase}/`;
|
||||
}
|
||||
const rel = page.outRel.endsWith("/index.html")
|
||||
? page.outRel.slice(0, -"index.html".length)
|
||||
: page.outRel;
|
||||
return `${siteBase}/${rel}`;
|
||||
}
|
||||
|
||||
function tagHtml([tag, k1, v1, k2, v2]) {
|
||||
return tag === "link"
|
||||
? `<link ${k1}="${v1}" ${k2}="${escapeAttr(v2)}">`
|
||||
: `<meta ${k1}="${v1}" ${k2}="${escapeAttr(v2)}">`;
|
||||
}
|
||||
|
||||
function pageNavHtml(prev, next, currentOutRel) {
|
||||
const cell = (page, dir) => {
|
||||
if (!page) {
|
||||
return "";
|
||||
}
|
||||
return `<a class="page-nav-${dir}" href="${hrefToOutRel(page.outRel, currentOutRel)}"><small>${dir === "prev" ? "Previous" : "Next"}</small><span>${escapeHtml(page.title)}</span></a>`;
|
||||
};
|
||||
return `<nav class="page-nav" aria-label="Pager">${cell(prev, "prev")}${cell(next, "next")}</nav>`;
|
||||
}
|
||||
|
||||
function navHtml(currentPage) {
|
||||
return nav
|
||||
.map(
|
||||
(section) =>
|
||||
`<section><h2>${escapeHtml(section.name)}</h2>${section.pages
|
||||
.map((page) => {
|
||||
const href = hrefToOutRel(page.outRel, currentPage.outRel);
|
||||
const active = page.rel === currentPage.rel ? " active" : "";
|
||||
return `<a class="nav-link${active}" href="${href}">${escapeHtml(navTitle(page))}</a>`;
|
||||
})
|
||||
.join("")}</section>`,
|
||||
)
|
||||
.join("");
|
||||
}
|
||||
|
||||
function navTitle(page) {
|
||||
if (page.rel === "index.md") {
|
||||
return "Overview";
|
||||
}
|
||||
if (page.rel === "CLI.md") {
|
||||
return "CLI Reference";
|
||||
}
|
||||
if (page.rel === "VISION.md") {
|
||||
return "Vision";
|
||||
}
|
||||
return page.title.replace(/^`acpx\s*/, "").replace(/`$/, "");
|
||||
}
|
||||
|
||||
function hrefToOutRel(targetOutRel, currentOutRel) {
|
||||
const currentDir = path.posix.dirname(currentOutRel);
|
||||
if (targetOutRel.endsWith("/index.html")) {
|
||||
const targetDir = targetOutRel.slice(0, -"index.html".length);
|
||||
const rel = path.posix.relative(currentDir, targetDir || ".") || ".";
|
||||
return rel.endsWith("/") ? rel : `${rel}/`;
|
||||
}
|
||||
if (targetOutRel === "index.html") {
|
||||
const rel = path.posix.relative(currentDir, ".") || ".";
|
||||
return rel.endsWith("/") ? rel : `${rel}/`;
|
||||
}
|
||||
return path.posix.relative(currentDir, targetOutRel) || path.posix.basename(targetOutRel);
|
||||
}
|
||||
|
||||
function slug(text) {
|
||||
return text
|
||||
.toLowerCase()
|
||||
.replace(/`/g, "")
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-|-$/g, "");
|
||||
}
|
||||
|
||||
function escapeHtml(value) {
|
||||
return String(value ?? "").replace(
|
||||
/[&<>"']/g,
|
||||
(char) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[char],
|
||||
);
|
||||
}
|
||||
|
||||
function escapeAttr(value) {
|
||||
return escapeHtml(value);
|
||||
}
|
||||
|
||||
function highlight(code, lang) {
|
||||
const resolved = HIGHLIGHT_ALIASES[lang] || lang;
|
||||
const rules = HIGHLIGHT_RULES[resolved];
|
||||
if (!rules) {
|
||||
return escapeHtml(code);
|
||||
}
|
||||
let out = "";
|
||||
let i = 0;
|
||||
while (i < code.length) {
|
||||
let bestKind = null;
|
||||
let bestText = null;
|
||||
for (const [re, kind] of rules) {
|
||||
re.lastIndex = i;
|
||||
const m = re.exec(code);
|
||||
if (m && m.index === i) {
|
||||
bestKind = kind;
|
||||
bestText = m[0];
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (bestText !== null) {
|
||||
out += `<span class="hl-${bestKind}">${escapeHtml(bestText)}</span>`;
|
||||
i += bestText.length;
|
||||
} else {
|
||||
out += escapeHtml(code[i]);
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function validateLinks(outputDir) {
|
||||
const failures = [];
|
||||
const placeholderHrefs = /^(url|path|file|dir|name)$/i;
|
||||
for (const file of allHtml(outputDir)) {
|
||||
const html = fs.readFileSync(file, "utf8");
|
||||
for (const match of html.matchAll(/href="([^"]+)"/g)) {
|
||||
const href = match[1];
|
||||
if (/^(#|https?:|mailto:|tel:|javascript:)/.test(href)) {
|
||||
continue;
|
||||
}
|
||||
if (placeholderHrefs.test(href)) {
|
||||
continue;
|
||||
}
|
||||
const [rawPath, anchor = ""] = href.split("#");
|
||||
const targetPath = rawPath ? path.resolve(path.dirname(file), rawPath) : file;
|
||||
const target =
|
||||
fs.existsSync(targetPath) && fs.statSync(targetPath).isDirectory()
|
||||
? path.join(targetPath, "index.html")
|
||||
: targetPath;
|
||||
if (!fs.existsSync(target)) {
|
||||
failures.push(
|
||||
`${path.relative(outputDir, file)}: ${href} -> missing ${path.relative(outputDir, target)}`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
if (anchor) {
|
||||
const targetHtml = fs.readFileSync(target, "utf8");
|
||||
if (!targetHtml.includes(`id="${anchor}"`) && !targetHtml.includes(`name="${anchor}"`)) {
|
||||
failures.push(`${path.relative(outputDir, file)}: ${href} -> missing anchor`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (failures.length) {
|
||||
throw new Error(`broken docs links:\n${failures.join("\n")}`);
|
||||
}
|
||||
}
|
||||
|
||||
function allHtml(dir) {
|
||||
return fs
|
||||
.readdirSync(dir, { withFileTypes: true })
|
||||
.flatMap((entry) => {
|
||||
const full = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
return allHtml(full);
|
||||
}
|
||||
return entry.name.endsWith(".html") ? [full] : [];
|
||||
})
|
||||
.toSorted((a, b) => a.localeCompare(b));
|
||||
}
|
||||
301
scripts/docs-site-assets.mjs
Normal file
301
scripts/docs-site-assets.mjs
Normal 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>`;
|
||||
}
|
||||
@ -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,
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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> {
|
||||
|
||||
@ -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,
|
||||
}),
|
||||
);
|
||||
|
||||
@ -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
51
src/acp/model-support.ts
Normal 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)}.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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)`,
|
||||
);
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
|
||||
|
||||
36
src/cli/session/model-helpers.ts
Normal file
36
src/cli/session/model-helpers.ts
Normal 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;
|
||||
}
|
||||
@ -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,
|
||||
|
||||
@ -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);
|
||||
},
|
||||
|
||||
@ -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,
|
||||
});
|
||||
|
||||
|
||||
@ -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 (
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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
114
src/flows/decision.ts
Normal 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");
|
||||
}
|
||||
@ -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[] = [];
|
||||
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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: {
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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;
|
||||
};
|
||||
|
||||
|
||||
19
src/session/config-options.ts
Normal file
19
src/session/config-options.ts
Normal 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;
|
||||
}
|
||||
@ -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 {
|
||||
|
||||
@ -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 {};
|
||||
},
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
4
test/fixtures/flow-shell.flow.ts
vendored
4
test/fixtures/flow-shell.flow.ts
vendored
@ -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),
|
||||
|
||||
2
test/fixtures/flow-wait.flow.ts
vendored
2
test/fixtures/flow-wait.flow.ts
vendored
@ -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({
|
||||
|
||||
@ -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")
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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",
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@ -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 });
|
||||
}
|
||||
|
||||
@ -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
|
||||
},
|
||||
|
||||
@ -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
|
||||
},
|
||||
|
||||
@ -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);
|
||||
});
|
||||
|
||||
@ -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];
|
||||
|
||||
@ -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();
|
||||
});
|
||||
|
||||
|
||||
@ -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 });
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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[] = [];
|
||||
|
||||
@ -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 () => {
|
||||
|
||||
@ -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> {
|
||||
|
||||
@ -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
Loading…
Reference in New Issue
Block a user