Compare commits
67 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 | ||
|
|
0cc6ebad9b | ||
|
|
2b92de84e7 | ||
|
|
521fda38a5 | ||
|
|
013f2fd830 | ||
|
|
b7cc36c503 | ||
|
|
51123f8de5 | ||
|
|
4787ba3efa | ||
|
|
957c60475e | ||
|
|
ea2c1cfb5a | ||
|
|
3435b3f77c | ||
|
|
6e0e1a70b2 | ||
|
|
3b94ac9c39 | ||
|
|
0ad1577c1e | ||
|
|
cf895c8c6c | ||
|
|
475a767169 | ||
|
|
e42e921366 | ||
|
|
445d975b99 | ||
|
|
1098b0d6ca | ||
|
|
8afcc68c86 | ||
|
|
232999cd59 | ||
|
|
e0cb576534 | ||
|
|
9bd6421c29 | ||
|
|
b03c475f1e | ||
|
|
3a0007e99d | ||
|
|
3d967954c1 | ||
|
|
b201fd13bb | ||
|
|
4aafd95551 | ||
|
|
98ae7f9f26 | ||
|
|
351f1588c1 | ||
|
|
486f596acb | ||
|
|
9a540808bd | ||
|
|
03467406ec | ||
|
|
051d5e25ff | ||
|
|
c5dea03e96 | ||
|
|
3f0c05dc2b | ||
|
|
0b93bfedc0 | ||
|
|
be5199464f | ||
|
|
b9395bdf94 | ||
|
|
63e561dcab | ||
|
|
efdc6cc252 | ||
|
|
81ac6d3cc4 | ||
|
|
7c5f5e4d13 | ||
|
|
6f2ac13dd7 |
13
.github/workflows/ci.yml
vendored
13
.github/workflows/ci.yml
vendored
@ -12,11 +12,11 @@ concurrency:
|
||||
|
||||
env:
|
||||
NODE_VERSION: 24
|
||||
PNPM_VERSION: 10.23.0
|
||||
PNPM_VERSION: 10.33.2
|
||||
|
||||
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@v5
|
||||
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@v5
|
||||
uses: pnpm/action-setup@v6.0.5
|
||||
with:
|
||||
version: ${{ env.PNPM_VERSION }}
|
||||
|
||||
|
||||
6
.github/workflows/conformance-nightly.yml
vendored
6
.github/workflows/conformance-nightly.yml
vendored
@ -22,7 +22,7 @@ concurrency:
|
||||
|
||||
env:
|
||||
NODE_VERSION: 24
|
||||
PNPM_VERSION: 10.23.0
|
||||
PNPM_VERSION: 10.33.2
|
||||
|
||||
jobs:
|
||||
conformance-mock:
|
||||
@ -39,7 +39,7 @@ jobs:
|
||||
submodules: false
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v5
|
||||
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@v5
|
||||
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
|
||||
4
.github/workflows/release.yml
vendored
4
.github/workflows/release.yml
vendored
@ -11,7 +11,7 @@ concurrency:
|
||||
|
||||
env:
|
||||
NODE_VERSION: 24
|
||||
PNPM_VERSION: 10.23.0
|
||||
PNPM_VERSION: 10.33.2
|
||||
|
||||
jobs:
|
||||
release:
|
||||
@ -29,7 +29,7 @@ jobs:
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- uses: pnpm/action-setup@v5
|
||||
- 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"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
312
CHANGELOG.md
312
CHANGELOG.md
@ -1,56 +1,197 @@
|
||||
# Changelog
|
||||
|
||||
<!-- markdownlint-disable MD024 -->
|
||||
|
||||
Repo: https://github.com/openclaw/acpx
|
||||
|
||||
## Unreleased
|
||||
|
||||
### Changes
|
||||
|
||||
- Conformance/ACP: add a data-driven ACP core v1 conformance suite with CI smoke coverage, nightly coverage, and a hardened runner that reports startup failures cleanly and scopes filesystem checks to the session cwd. (#130) Thanks @lynnzc.
|
||||
- CLI/prompts: add `--prompt-retries` to retry transient prompt failures with exponential backoff while preserving strict JSON behavior and avoiding replay after prompt side effects. (#142) Thanks @lupuletic and @dutifulbob.
|
||||
- Output: add `--suppress-reads` to mask raw file-read bodies in text and JSON output while keeping normal tool activity visible. (#136) Thanks @hayatosc.
|
||||
- Agents/droid: add `factory-droid` and `factorydroid` aliases for the built-in Factory Droid adapter and sync the built-in docs. Thanks @vincentkoc.
|
||||
- Agents/built-ins: bump the default pinned `@zed-industries/codex-acp` and `@agentclientprotocol/claude-agent-acp` package ranges to the latest published releases.
|
||||
- Flows/workflows: add an initial `flow run` command, an `acpx/flows` runtime surface, and file-backed flow run state under `~/.acpx/flows/runs` for user-authored workflow modules. Thanks @osolmaz.
|
||||
- Flows/workspaces: let `acp` nodes bind to an explicit per-step cwd, add a native isolated-workspace example, and default active flow steps to a 15 minute timeout unless overridden. Thanks @osolmaz.
|
||||
- Flows/replay: store flow runs as trace bundles with `manifest.json`, `flow.json`, `trace.ndjson`, projections, bundled session replay data, and per-attempt ACP/action receipts for later inspection. Thanks @osolmaz.
|
||||
- Flows/replay viewer: add a React Flow-based replay viewer example that replays saved run bundles and shows the bundled ACP session beside the graph. Thanks @osolmaz.
|
||||
- Flows/replay viewer: keep recent runs and the active recent-run view live over a WebSocket snapshot/patch transport so in-progress runs update without manual refresh while rewind stays available.
|
||||
- Flows/permissions: let flows declare explicit required permission modes, fail fast when a flow requires an explicit `--approve-all` grant, and preserve the granted mode through persistent ACP queue-owner paths. Thanks @osolmaz.
|
||||
- Agents/qoder: add built-in Qoder CLI ACP support via `qoder -> qodercli --acp` and document Qoder-specific auth notes.
|
||||
- Agents/qoder: forward `--allowed-tools` and `--max-turns` session options into Qoder CLI startup flags, including persisted session reuse, without requiring a raw `--agent` override.
|
||||
- Runtime/embedding: add a supported `acpx/runtime` API for embedding ACPX session lifecycle, turn execution, status/control, and file-backed runtime storage. Thanks @osolmaz.
|
||||
### Breaking
|
||||
|
||||
### 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
|
||||
|
||||
- CLI/claude: add `--system-prompt <text>` and `--append-system-prompt <text>` global flags that forward through ACP `_meta.systemPrompt` on `session/new`, letting callers replace or append to the Claude Code system prompt without dropping out of persistent acpx sessions. The value is persisted in `session_options.system_prompt` so ensure/reuse flows keep the override. Codex and other agents ignore the field. (#229) Thanks @Vercantez.
|
||||
- CLI/sessions: add `sessions prune` with `--dry-run`, age filters, and `--include-history` so closed session records and optional event streams can be cleaned up explicitly. (#227) Thanks @coder999999999.
|
||||
- Runtime/embedding: add `startTurn(...)` turn handles so embedders can observe live runtime events separately from terminal completion, cancel a turn, or close only the event stream while preserving `runTurn(...)` compatibility. (#262) Thanks @enki.
|
||||
- CLI/ACP: add `--no-terminal` to disable advertised ACP terminal capability for new agent clients. (#155) Thanks @DMQ.
|
||||
- Agents/built-ins: bump the default `@agentclientprotocol/claude-agent-acp`, `@zed-industries/codex-acp`, and `pi-acp` package ranges so fresh built-in launches pick up the latest adapter releases. (#253, #275) Thanks @flowforgelab.
|
||||
- Conformance/ACP: add a post-success drain case that catches late tool updates emitted after `session/prompt` resolves. (#252) Thanks @logofet85-ai.
|
||||
- Docs/session identity: clarify when CLI output shows ACPX runtime session IDs versus backend agent session IDs.
|
||||
- Dependencies/CI: update ACP SDK, runtime dependencies, TypeScript-native tooling, formatter/lint tooling, and workflow actions.
|
||||
|
||||
### Breaking
|
||||
|
||||
### Fixes
|
||||
|
||||
- CLI/runtime: persist non-mode `session/set_config_option` values and replay them on fresh adapter sessions, so options such as Codex `reasoning_effort` survive session fallback/reuse. (#138)
|
||||
- CLI/prompt: honor `--model` when sending prompts to existing persistent sessions, including queued owner paths. (#211) Thanks @skywills.
|
||||
- Runtime/persistent sessions: keep reusable persistent ACP clients warm across turns and close pooled clients during runtime close. (#265) Thanks @Sway-Chan.
|
||||
- Runtime/ACP: drain late post-success session updates before closing prompt turns so adapters that resolve `session/prompt` before final updates do not drop assistant output. (#251) Thanks @logofet85-ai.
|
||||
- Runtime/embedding: reuse the saved persistent session when sending runtime controls instead of creating a new backend session for control operations.
|
||||
- CLI/sessions: persist the submitted prompt at turn start so `sessions history` and `sessions read` no longer report `No history` while an active prompt is already running. (#157)
|
||||
- Runtime/WSL: translate session cwd with `wslpath` when running under WSL and spawning Windows `.exe` ACP agents, so `session/new` and `session/load` receive paths the agent can access. (#232)
|
||||
- Client/auth: require explicit `ACPX_AUTH_*` env vars or config `auth` entries for ACP auth-method selection, so ambient provider env like `OPENAI_API_KEY` no longer triggers unintended login flows in adapters such as `codex-acp`.
|
||||
- Config/agents: honor custom agent `args` arrays from config instead of silently dropping required adapter subcommands. (#199) Thanks @log-li.
|
||||
- CLI/queue: tighten persistent queue and IPC socket directories to owner-only permissions, including previously-created permissive directories. (#216) Thanks @garagon.
|
||||
- CLI/queue: use cryptographically random owner generation IDs so rapid queue owner restarts cannot reuse a stale generation token. (#207) Thanks @Yuan-ManX.
|
||||
- Output/errors: add text-mode remediation hints for auth-required, missing-session, ACP session failures, timeouts, provider rate limits, and invalid model names while keeping JSON error payloads stable. (#256) Thanks @SJeffZhang.
|
||||
- CLI/quiet output: emit final token usage and cost metadata to stderr when adapters include it in the ACP prompt result, while keeping quiet stdout as assistant text only. (#257)
|
||||
- CLI/status: report resumable persistent sessions as `idle` when no queue owner is running, instead of marking pre-prompt or TTL-expired sessions as dead. (#185)
|
||||
- Client/ACP: use the locked ACP SDK close API path so session closing stays compatible with the current SDK.
|
||||
- Runtime/doctor: guarantee `doctor().details` contains strings even when probe failures include Error or object values. (#267)
|
||||
- Replay viewer: protect run-bundle file reads from run-id boundary escapes.
|
||||
|
||||
## 2026.4.8 (v0.5.3)
|
||||
|
||||
### Changes
|
||||
|
||||
- Dependencies: upgrade Vite to 8.0.7. (#231) Thanks @hxy91819.
|
||||
|
||||
### Breaking
|
||||
|
||||
### Fixes
|
||||
|
||||
## 2026.4.7 (v0.5.2)
|
||||
|
||||
### Changes
|
||||
|
||||
### Breaking
|
||||
|
||||
### Fixes
|
||||
|
||||
- Sessions/reset: close the live backend session when discarding persistent state so reset flows start a fresh ACP session instead of silently reopening the old one. (#228) Thanks @dutifulbob.
|
||||
|
||||
## 2026.4.6 (v0.5.1)
|
||||
|
||||
### Changes
|
||||
|
||||
### Breaking
|
||||
|
||||
### Fixes
|
||||
|
||||
- Runtime/processes: own built-in adapter launches so child processes are managed consistently. (#226) Thanks @dutifulbob.
|
||||
|
||||
## 2026.4.6 (v0.5.0)
|
||||
|
||||
### Changes
|
||||
|
||||
- Flows: validate flow definitions and require `defineFlow`. (#219) Thanks @osolmaz.
|
||||
- Runtime/embedding: add a supported `acpx/runtime` API for embedding ACPX session lifecycle, turn execution, status/control, and file-backed runtime storage. (#220) Thanks @osolmaz.
|
||||
- Runtime/prompt turns: stabilize runtime prompt turn handling. (#222) Thanks @osolmaz.
|
||||
|
||||
### Breaking
|
||||
|
||||
### Fixes
|
||||
|
||||
## 2026.4.4 (v0.4.1)
|
||||
|
||||
### Changes
|
||||
|
||||
- Flows/replay viewer: keep recent runs and the active recent-run view live over a WebSocket snapshot/patch transport so in-progress runs update without manual refresh while rewind stays available. (#205) Thanks @osolmaz.
|
||||
- Agents/built-ins: bump the default pinned `@zed-industries/codex-acp` and `@agentclientprotocol/claude-agent-acp` package ranges. (#215) Thanks @osolmaz.
|
||||
- Dependencies: update ACP SDK, TypeScript, and TypeScript-native dev tooling. (#200, #202, #203)
|
||||
|
||||
### Breaking
|
||||
|
||||
### Fixes
|
||||
|
||||
## 2026.3.29 (v0.4.0)
|
||||
|
||||
### Changes
|
||||
|
||||
- Flows/workflows: add an initial `flow run` command, an `acpx/flows` runtime surface, and file-backed flow run state under `~/.acpx/flows/runs` for user-authored workflow modules. (#179) Thanks @osolmaz.
|
||||
- Flows/replay: store flow runs as trace bundles with `manifest.json`, `flow.json`, `trace.ndjson`, projections, bundled session replay data, and per-attempt ACP/action receipts for later inspection. (#181) Thanks @osolmaz.
|
||||
- Flows/replay viewer: add a React Flow-based replay viewer example that replays saved run bundles and shows the bundled ACP session beside the graph. (#183) Thanks @osolmaz.
|
||||
- Flows/permissions: let flows declare explicit required permission modes, fail fast when a flow requires an explicit `--approve-all` grant, and preserve the granted mode through persistent ACP queue-owner paths. (#186) Thanks @osolmaz.
|
||||
- Flows/workspaces: let ACP validation choose PR test plans and broaden PR-triage refactor judgment. (#189, #190) Thanks @osolmaz.
|
||||
- Flows/titles: add a flow run title API. (#197) Thanks @osolmaz.
|
||||
- Agents/trae: add built-in Trae agent support backed by `trae-cli`. (#171) Thanks @hqwuzhaoyi.
|
||||
- Agents/qoder: add built-in Qoder CLI ACP support via `qoder -> qodercli --acp` and document Qoder-specific auth notes. (#178) Thanks @xinyuan0801.
|
||||
- Agents/codex: support `--model` for Codex sessions. (#192) Thanks @osolmaz.
|
||||
- Models: add generic model selection via ACP `session/set_model`. (#150) Thanks @ironerumi.
|
||||
- Output: add `--suppress-reads` to mask raw file-read bodies in text and JSON output while keeping normal tool activity visible. (#193) Thanks @osolmaz.
|
||||
- CLI/prompts: add `--prompt-retries` to retry transient prompt failures with exponential backoff while preserving strict JSON behavior and avoiding replay after prompt side effects. (#196) Thanks @osolmaz.
|
||||
- Docs/PR triage: add conflict gates and standard check validation guidance for maintenance PRs. (#180, #187) Thanks @osolmaz.
|
||||
- Dependencies: update ACP SDK, workflow actions, TypeScript-native tooling, and development dependencies. (#131, #133, #146, #154, #177)
|
||||
|
||||
### Breaking
|
||||
|
||||
### Fixes
|
||||
|
||||
- Sessions/reset: close the live backend session when discarding persistent state so reset flows start a fresh ACP session instead of silently reopening the old one.
|
||||
- Agents/kiro: use `kiro-cli-chat acp` for the built-in Kiro adapter command to avoid orphan child processes. (#129) Thanks @vokako.
|
||||
- Agents/cursor: recognize Cursor's `Session \"...\" not found` `session/load` error format so reconnects fall back to `session/new` instead of failing. (#162) Thanks @log-li.
|
||||
- Output/thinking: preserve line breaks in text-mode `[thinking]` output instead of flattening multi-line thought chunks into one line. (#144) Thanks @Huarong.
|
||||
- Sessions/load: fall back to a fresh ACP session when adapters reject `session/load` with JSON-RPC `-32601` or `-32602`, so persistent session reconnects do not crash on partial load support. (#174) Thanks @Bortlesboat.
|
||||
- Flows/runtime: finalize interrupted `flow run` bundles as failed instead of leaving them stuck at `running` when the process receives `SIGHUP`, `SIGINT`, or `SIGTERM`.
|
||||
- Flows/runtime: finalize interrupted `flow run` bundles as failed instead of leaving them stuck at `running` when the process receives `SIGHUP`, `SIGINT`, or `SIGTERM`. (#188) Thanks @osolmaz.
|
||||
- Windows/process spawning: enable shell mode for terminal spawn on Windows. (#173) Thanks @Bortlesboat.
|
||||
- Client/startup: add connection timeout and max buffer size limits. (#168) Thanks @Yuan-ManX.
|
||||
- Client/auth: cache derived auth env key lists per auth method to avoid repeated allocations during credential lookup. (#167) Thanks @Yuan-ManX.
|
||||
- Output/thinking: preserve line breaks in text-mode `[thinking]` output instead of flattening multi-line thought chunks into one line. (#194) Thanks @osolmaz.
|
||||
- Agents/cursor: recognize Cursor's `Session "..." not found` `session/load` error format so reconnects fall back to `session/new` instead of failing. (#195) Thanks @osolmaz.
|
||||
- Agents/kiro: use `kiro-cli-chat acp` for the built-in Kiro adapter command to avoid orphan child processes. (#129) Thanks @vokako.
|
||||
|
||||
## 2026.3.18 (v0.3.1)
|
||||
|
||||
### Changes
|
||||
|
||||
- Conformance/ACP: add a data-driven ACP core v1 conformance suite with CI smoke coverage, nightly coverage, and a hardened runner that reports startup failures cleanly and scopes filesystem checks to the session cwd. (#130) Thanks @lynnzc.
|
||||
- Agents/droid: add `factory-droid` and `factorydroid` aliases for the built-in Factory Droid adapter and sync the built-in docs. (#156) Thanks @vincentkoc.
|
||||
|
||||
### Breaking
|
||||
|
||||
### Fixes
|
||||
|
||||
## 2026.3.12 (v0.3.0)
|
||||
|
||||
### Changes
|
||||
|
||||
- Agents/built-ins: add Factory Droid and iFlow as built-in ACP agents and document their built-in commands. (#112, #109) Thanks @ironerumi and @gandli.
|
||||
- Dependencies: update TypeScript-native and tsdown development tooling. (#106, #107, #118, #125, #126)
|
||||
|
||||
### Breaking
|
||||
|
||||
### Fixes
|
||||
|
||||
- Codex/session config: treat `thought_level` as a compatibility alias for codex-acp `reasoning_effort` so `acpx codex set thought_level <value>` works on current codex-acp releases. Thanks @vincentkoc.
|
||||
- Session control/errors: surface actionable `set-mode` and `set` error messages when adapters reject unsupported session control params, and preserve wrapped adapter metadata in those failures. (#123) Thanks @manthan787 and @vincentkoc.
|
||||
- Sessions/load fallback: suppress recoverable `session/load` error payloads during first-run prompt recovery and keep the session record rotated to the fresh ACP session. (#122) Thanks @lynnzc and @vincentkoc.
|
||||
- Codex/session config: treat `thought_level` as a compatibility alias for codex-acp `reasoning_effort` so `acpx codex set thought_level <value>` works on current codex-acp releases. (#127) Thanks @vincentkoc.
|
||||
- Session control/errors: surface actionable `set-mode` and `set` error messages when adapters reject unsupported session control params, and preserve wrapped adapter metadata in those failures. (#123) Thanks @manthan787.
|
||||
- Sessions/load fallback: suppress recoverable `session/load` error payloads during first-run prompt recovery and keep the session record rotated to the fresh ACP session. (#122) Thanks @lynnzc.
|
||||
- Permissions/stats: track client permission denials in permission stats. (#120) Thanks @lynnzc.
|
||||
- Agents/gemini: default to `--acp` for Gemini CLI and fall back to `--experimental-acp` for pre-0.33 releases. (#113)
|
||||
- Agents/gemini: default to `--acp` for Gemini CLI and fall back to `--experimental-acp` for pre-0.33 releases. (#113) Thanks @imWildCat.
|
||||
- Images/prompt validation: validate structured image prompt block MIME types and base64 payloads, emit human-readable CLI usage errors, and add an explicit non-CI live Cursor ACP smoke test path. (#110) Thanks @vincentkoc.
|
||||
- Windows/process spawning: detect PATH-resolved batch wrappers such as `npx` on Windows and enable shell mode only for those commands. (#102) Thanks @lynnzc.
|
||||
|
||||
## 2026.3.10 (v0.2.0)
|
||||
|
||||
### Changes
|
||||
|
||||
- Docs/changelog: add missing changelog entries, align the changelog with OpenClaw style, and clean up duplicate ACP and queue helpers. (#104, #105, #108) Thanks @vincentkoc.
|
||||
|
||||
### Breaking
|
||||
|
||||
### Fixes
|
||||
|
||||
- ACP/prompt blocks: preserve structured ACP prompt blocks instead of flattening them during prompt handling to support images and non-text. (#103) Thanks @vincentkoc.
|
||||
- Images/prompt validation: validate structured image prompt block MIME types and base64 payloads, emit human-readable CLI usage errors, and add an explicit non-CI live Cursor ACP smoke test path. Thanks @vincentkoc.
|
||||
- Windows/process spawning: detect PATH-resolved batch wrappers such as `npx` on Windows and enable shell mode only for those commands. (#90) Thanks @lynnzc.
|
||||
|
||||
## 2026.3.10 (v0.1.16)
|
||||
|
||||
@ -70,6 +211,7 @@ Repo: https://github.com/openclaw/acpx
|
||||
- Runtime/perf: improve runtime performance and queue coordination, tighten perf capture, reuse warm queue-owner ACP clients, and lazy-load CLI startup modules. (#73, #84, #87, #86) Thanks @vincentkoc.
|
||||
- Repo/maintenance: add Dependabot configuration and pin ACP adapter package ranges. (#74, #99) Thanks @vincentkoc and @osolmaz.
|
||||
- Docs/alpha: refresh code and adapter alpha docs. (#75) Thanks @vincentkoc.
|
||||
- Dependencies: batch pending dependency upgrades. (#83) Thanks @vincentkoc.
|
||||
|
||||
### Breaking
|
||||
|
||||
@ -83,6 +225,10 @@ Repo: https://github.com/openclaw/acpx
|
||||
|
||||
## 2026.3.1 (v0.1.15)
|
||||
|
||||
### Changes
|
||||
|
||||
### Breaking
|
||||
|
||||
### Fixes
|
||||
|
||||
- CLI/version: restore `--version` behavior and staged adapter shutdown fallback. (#41) Thanks @dutifulbob.
|
||||
@ -105,6 +251,10 @@ Repo: https://github.com/openclaw/acpx
|
||||
|
||||
## 2026.2.26 (v0.1.13)
|
||||
|
||||
### Changes
|
||||
|
||||
### Breaking
|
||||
|
||||
### Fixes
|
||||
|
||||
- CLI/version env: ignore foreign `npm_package_version` values in `npx` contexts when resolving the CLI version. (#25) Thanks @dutifulbob.
|
||||
@ -115,14 +265,26 @@ Repo: https://github.com/openclaw/acpx
|
||||
|
||||
- CLI/version: add dynamic `--version` resolution at runtime. (#24) Thanks @dutifulbob.
|
||||
|
||||
### Breaking
|
||||
|
||||
### Fixes
|
||||
|
||||
## 2026.2.25 (v0.1.11)
|
||||
|
||||
### Changes
|
||||
|
||||
- Runtime/owners: detach warm session owners from prompt callers and run the `opencode` adapter in ACP mode. (#23) Thanks @dutifulbob.
|
||||
|
||||
### Breaking
|
||||
|
||||
### Fixes
|
||||
|
||||
## 2026.2.25 (v0.1.10)
|
||||
|
||||
### Changes
|
||||
|
||||
### Breaking
|
||||
|
||||
### Fixes
|
||||
|
||||
- ACP/reconnect: fall back cleanly when a persisted ACP session is no longer found. (#22) Thanks @dutifulbob.
|
||||
@ -131,55 +293,87 @@ Repo: https://github.com/openclaw/acpx
|
||||
|
||||
### Changes
|
||||
|
||||
- Docs/session identity: clarify the ACP session identity model and coverage status. (#21) Thanks @dutifulbob.
|
||||
|
||||
## 2026.2.24 (v0.1.8)
|
||||
|
||||
### Changes
|
||||
|
||||
- ACP/session identity: document runtime session ID passthrough from ACP metadata. (#18) Thanks @dutifulbob.
|
||||
- Repo/metadata: align repository metadata with `openclaw/acpx`. (#19) Thanks @osolmaz.
|
||||
|
||||
## 2026.2.23 (v0.1.7)
|
||||
|
||||
### Changes
|
||||
|
||||
- Runtime/CLI: add the initial OpenClaw ACP integration runtime and CLI primitives. (#17) Thanks @dutifulbob.
|
||||
- Docs/install: restore global install docs, badges, and `skillflag` setup guidance. (#14) Thanks @dutifulbob.
|
||||
|
||||
## 2026.2.20 (v0.1.6)
|
||||
|
||||
### Changes
|
||||
|
||||
- Docs/README: add the README banner, badges, and simplified setup guidance. (#12, #13) Thanks @dutifulbob.
|
||||
|
||||
## 2026.2.20 (v0.1.5)
|
||||
|
||||
### Changes
|
||||
|
||||
- Runtime/session UX: implement high-priority runtime, config, and session UX features. (#7) Thanks @dutifulbob.
|
||||
- Tests/integration: add a mock ACP agent and integration tests. (#9) Thanks @dutifulbob.
|
||||
- Docs/install: clarify `npx` usage and use `@latest` in install commands. (#5, #6) Thanks @dutifulbob.
|
||||
- Docs/session identity: clarify the ACP session identity model and current coverage status. (#21) Thanks @dutifulbob.
|
||||
|
||||
### Breaking
|
||||
|
||||
### Fixes
|
||||
|
||||
- Prompt/cancel: cancel prompts cleanly during startup. (#10) Thanks @dutifulbob.
|
||||
## 2026.2.24 (v0.1.8)
|
||||
|
||||
### Changes
|
||||
|
||||
- Docs/runtime: specify runtime session id passthrough from ACP metadata. (#18) Thanks @dutifulbob.
|
||||
- Metadata/repo: update repository metadata for `openclaw/acpx`. (#19) Thanks @osolmaz.
|
||||
|
||||
### Breaking
|
||||
|
||||
### Fixes
|
||||
|
||||
## 2026.2.23 (v0.1.7)
|
||||
|
||||
### Changes
|
||||
|
||||
- Docs/install: restore global install instructions, badges, and skillflag guidance. (#14) Thanks @dutifulbob.
|
||||
- Runtime/OpenClaw: add OpenClaw ACP integration runtime and CLI primitives. (#17) Thanks @dutifulbob.
|
||||
|
||||
### Breaking
|
||||
|
||||
### Fixes
|
||||
|
||||
## 2026.2.20 (v0.1.6)
|
||||
|
||||
### Changes
|
||||
|
||||
- Docs/readme: add banner, badges, skillflag 0.1.4 guidance, and simplified setup. (#12, #13) Thanks @dutifulbob.
|
||||
|
||||
### Breaking
|
||||
|
||||
### Fixes
|
||||
|
||||
## 2026.2.20 (v0.1.5)
|
||||
|
||||
### Changes
|
||||
|
||||
- Docs/install: clarify `npx` usage and use `@latest` in install commands. (#5, #6) Thanks @dutifulbob.
|
||||
- Runtime/session UX: implement high-priority runtime, config, and session UX features. (#7) Thanks @dutifulbob.
|
||||
- Tests/integration: add mock ACP agent and integration tests. (#9) Thanks @dutifulbob.
|
||||
|
||||
### Breaking
|
||||
|
||||
### Fixes
|
||||
|
||||
- Startup/cancel: cancel prompts during startup correctly. (#10) Thanks @dutifulbob.
|
||||
|
||||
## 2026.2.18 (v0.1.4)
|
||||
|
||||
### Changes
|
||||
|
||||
- Sessions/routing: require explicit sessions and route prompts by directory walk. (#4) Thanks @dutifulbob.
|
||||
- Docs/skills: add a quick-setup blurb for agent skill install. (#3) Thanks @dutifulbob.
|
||||
- Docs/setup: add quick-setup guidance for agent skill install. (#3) Thanks @dutifulbob.
|
||||
- Sessions/prompts: require explicit sessions and route prompts by directory walk. (#4) Thanks @dutifulbob.
|
||||
|
||||
### Breaking
|
||||
|
||||
### Fixes
|
||||
|
||||
## 2026.2.18 (v0.1.3)
|
||||
|
||||
### Changes
|
||||
|
||||
- CI/tests: align CI and test setup and expand coverage for the initial release line. (#1) Thanks @dutifulbob.
|
||||
- CI/tests: align CI and test setup with SimpleDoc and expand coverage. (#1) Thanks @dutifulbob.
|
||||
|
||||
### Breaking
|
||||
|
||||
### Fixes
|
||||
|
||||
- Release/versioning: align release version bumping with the `skillflag` in-memory bump pattern. (#2) Thanks @dutifulbob.
|
||||
- Release: align release workflow with the skillflag in-memory bump pattern. (#2) Thanks @dutifulbob.
|
||||
|
||||
## 2026.2.18 (v0.1.2)
|
||||
|
||||
### Changes
|
||||
|
||||
- Initial public release of the ACP CLI client, including npm-first docs, agent-first prompt/exec/session commands, async prompt queueing, the initial agent registry, CI, trusted publishing, and MIT licensing.
|
||||
|
||||
### Breaking
|
||||
|
||||
### Fixes
|
||||
|
||||
17
README.md
17
README.md
@ -33,7 +33,7 @@ One command surface for Pi, OpenClaw ACP, Codex, Claude, and other ACP-compatibl
|
||||
- **Prompt from file/stdin**: `--file <path>` or piped stdin for prompt content
|
||||
- **Config files**: global + project JSON config with `acpx config show|init`
|
||||
- **Session inspect/history**: `sessions show` and `sessions history --limit <n>`
|
||||
- **Local status checks**: `status` reports running/dead/no-session, pid, uptime, last prompt
|
||||
- **Local status checks**: `status` reports running/idle/dead/no-session, pid, uptime, last prompt
|
||||
- **Client methods**: stable `fs/*` and `terminal/*` handlers with permission controls and cwd sandboxing
|
||||
- **Auth handshake**: stable `authenticate` support via env/config credentials
|
||||
- **Structured output**: typed ACP messages (thinking, tool calls, diffs) instead of ANSI scraping
|
||||
@ -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
|
||||
@ -271,7 +272,7 @@ Supported keys:
|
||||
"timeout": null,
|
||||
"format": "text",
|
||||
"agents": {
|
||||
"my-custom": { "command": "./bin/my-acp-server" }
|
||||
"my-custom": { "command": "./bin/my-acp-server", "args": ["acp"] }
|
||||
},
|
||||
"auth": {
|
||||
"my_auth_method_id": "credential-value"
|
||||
@ -281,6 +282,11 @@ Supported keys:
|
||||
|
||||
Use `acpx config show` to inspect the resolved result and `acpx config init` to create the global template.
|
||||
|
||||
For ACP `authenticate` handshakes, use either config `auth` entries or explicit
|
||||
`ACPX_AUTH_<METHOD_ID>` environment variables such as `ACPX_AUTH_OPENAI_API_KEY`.
|
||||
Ambient provider env vars such as `OPENAI_API_KEY` are still passed through to
|
||||
child agents, but they do not trigger ACP auth-method selection on their own.
|
||||
|
||||
## Output formats
|
||||
|
||||
```bash
|
||||
@ -319,8 +325,11 @@ JSON events include a stable envelope for correlation:
|
||||
}
|
||||
```
|
||||
|
||||
Session-control JSON payloads (`sessions new|ensure`, `status`) may also include
|
||||
`runtimeSessionId` when the adapter exposes a provider-native session ID.
|
||||
Session-control JSON payloads (`sessions new|ensure`, `status`) always include
|
||||
`acpxRecordId` and `acpxSessionId`. They include `agentSessionId` only when the
|
||||
adapter exposes a provider-native session ID. The text/quiet session id is the
|
||||
local acpx record id; do not assume it can be passed to the native provider CLI
|
||||
unless `agentSessionId` is present.
|
||||
|
||||
## Built-in agents and custom servers
|
||||
|
||||
|
||||
6
agents/Claude.md
Normal file
6
agents/Claude.md
Normal file
@ -0,0 +1,6 @@
|
||||
# Claude
|
||||
|
||||
- Built-in name: `claude`
|
||||
- Default command: `npx -y @agentclientprotocol/claude-agent-acp`
|
||||
- Upstream: https://github.com/agentclientprotocol/claude-agent-acp
|
||||
- ACPX pins the built-in package range so fresh installs pick up Claude model and ACP adapter fixes without depending on a global adapter binary.
|
||||
@ -5,7 +5,7 @@ Built-in agents:
|
||||
- `pi -> npx pi-acp`
|
||||
- `openclaw -> openclaw acp`
|
||||
- `codex -> npx @zed-industries/codex-acp`
|
||||
- `claude -> npx -y @zed-industries/claude-agent-acp`
|
||||
- `claude -> npx -y @agentclientprotocol/claude-agent-acp`
|
||||
- `gemini -> gemini --acp`
|
||||
- `cursor -> cursor-agent acp`
|
||||
- `copilot -> copilot --acp --stdio`
|
||||
@ -22,6 +22,7 @@ Built-in agents:
|
||||
Harness-specific docs in this directory:
|
||||
|
||||
- [Codex](Codex.md): built-in `codex -> npx @zed-industries/codex-acp`
|
||||
- [Claude](Claude.md): built-in `claude -> npx -y @agentclientprotocol/claude-agent-acp`
|
||||
- [Copilot](Copilot.md): built-in `copilot -> copilot --acp --stdio`
|
||||
- [Droid](Droid.md): built-in `droid -> droid exec --output-format acp` with `factory-droid` and `factorydroid` aliases
|
||||
- [Cursor](Cursor.md): built-in `cursor -> cursor-agent acp`
|
||||
|
||||
51
conformance/cases/021-prompt-post-success-drain.json
Normal file
51
conformance/cases/021-prompt-post-success-drain.json
Normal file
@ -0,0 +1,51 @@
|
||||
{
|
||||
"id": "acp.v1.session.prompt.post_success_drain",
|
||||
"profile": "acp-core-v1",
|
||||
"title": "Late post-success tool updates remain observable",
|
||||
"description": "Verify the harness can still observe tool updates emitted shortly after a successful prompt response.",
|
||||
"steps": [
|
||||
{
|
||||
"action": "new_session",
|
||||
"save_as": "session_id"
|
||||
},
|
||||
{
|
||||
"action": "prompt",
|
||||
"session": "$session_id",
|
||||
"prompt": [
|
||||
{
|
||||
"type": "text",
|
||||
"text": "late-tool 40 follow-up"
|
||||
}
|
||||
],
|
||||
"save_as": "prompt_result"
|
||||
}
|
||||
],
|
||||
"checks": [
|
||||
{
|
||||
"type": "saved_stop_reason_in",
|
||||
"key": "prompt_result",
|
||||
"values": ["end_turn", "completed", "done"]
|
||||
},
|
||||
{
|
||||
"type": "updates_count_at_least",
|
||||
"min": 4
|
||||
},
|
||||
{
|
||||
"type": "updates_all_session",
|
||||
"session": "$session_id"
|
||||
},
|
||||
{
|
||||
"type": "updates_text_includes",
|
||||
"text": "writing now"
|
||||
},
|
||||
{
|
||||
"type": "updates_session_update_includes",
|
||||
"values": ["tool_call", "tool_call_update"]
|
||||
}
|
||||
],
|
||||
"timeouts": {
|
||||
"request_timeout_ms": 10000,
|
||||
"update_timeout_ms": 10000,
|
||||
"settle_timeout_ms": 160
|
||||
}
|
||||
}
|
||||
@ -23,7 +23,8 @@
|
||||
"acp.v1.permissions.read.approved",
|
||||
"acp.v1.permissions.write.approved",
|
||||
"acp.v1.session.prompt.background_completion",
|
||||
"acp.v1.session.cancel.followup_prompt"
|
||||
"acp.v1.session.cancel.followup_prompt",
|
||||
"acp.v1.session.prompt.post_success_drain"
|
||||
],
|
||||
"optional_cases": []
|
||||
}
|
||||
|
||||
@ -126,6 +126,10 @@ type CaseCheck =
|
||||
| {
|
||||
type: "updates_text_includes";
|
||||
text: string;
|
||||
}
|
||||
| {
|
||||
type: "updates_session_update_includes";
|
||||
values: string[];
|
||||
};
|
||||
|
||||
type CaseResult = {
|
||||
@ -431,6 +435,14 @@ function resolveTimeoutMs(
|
||||
return fallbackMs;
|
||||
}
|
||||
|
||||
function resolveSettleTimeoutMs(caseDefinition: CaseDefinition): number {
|
||||
const value = caseDefinition.timeouts?.settle_timeout_ms;
|
||||
if (typeof value === "number" && Number.isFinite(value) && value > 0) {
|
||||
return Math.round(value);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
function splitCommandLine(value: string): ParsedCommand {
|
||||
const parts: string[] = [];
|
||||
let current = "";
|
||||
@ -944,6 +956,22 @@ function evaluateCaseChecks(params: {
|
||||
assert.equal(matched, true, `expected at least one update text including "${check.text}"`);
|
||||
break;
|
||||
}
|
||||
case "updates_session_update_includes": {
|
||||
const seen = new Set(
|
||||
params.harness.client.updates
|
||||
.map((update) => update.update?.sessionUpdate)
|
||||
.filter((value): value is string => typeof value === "string"),
|
||||
);
|
||||
|
||||
for (const value of check.values) {
|
||||
assert.equal(
|
||||
seen.has(value),
|
||||
true,
|
||||
`expected at least one update with sessionUpdate="${value}"`,
|
||||
);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -954,6 +982,7 @@ async function runCase(
|
||||
): Promise<{ passed: true } | { passed: false; error: string }> {
|
||||
const requestTimeoutMs = resolveTimeoutMs(caseDefinition, "request", DEFAULT_REQUEST_TIMEOUT_MS);
|
||||
const updateTimeoutMs = resolveTimeoutMs(caseDefinition, "update", DEFAULT_UPDATE_TIMEOUT_MS);
|
||||
const settleTimeoutMs = resolveSettleTimeoutMs(caseDefinition);
|
||||
const effectiveOptions: CliOptions =
|
||||
caseDefinition.permission_mode && caseDefinition.permission_mode !== options.permissionMode
|
||||
? { ...options, permissionMode: caseDefinition.permission_mode }
|
||||
@ -977,6 +1006,12 @@ async function runCase(
|
||||
});
|
||||
}
|
||||
|
||||
if (settleTimeoutMs > 0) {
|
||||
await new Promise<void>((resolve) => {
|
||||
setTimeout(resolve, settleTimeoutMs);
|
||||
});
|
||||
}
|
||||
|
||||
evaluateCaseChecks({
|
||||
caseDefinition,
|
||||
harness: activeHarness,
|
||||
|
||||
@ -22,7 +22,7 @@ This document defines how `acpx` should represent errors across:
|
||||
- Keep ACP semantics intact when available.
|
||||
- Provide stable `acpx` codes for orchestrator logic.
|
||||
- Avoid parsing free-form message text.
|
||||
- Keep text mode UX unchanged.
|
||||
- Keep the JSON/machine contract stable; text mode may add additive remediation hints.
|
||||
- Make changes additive to preserve backward compatibility.
|
||||
|
||||
## Two-layer contract
|
||||
@ -108,7 +108,7 @@ Cancellation is a normal completion path, not an error path:
|
||||
## Rollout and compatibility
|
||||
|
||||
- Queue error parsing should accept both old (`message` only) and new (`code/detailCode/message`) payload shapes during migration.
|
||||
- Text mode output and existing exit codes stay unchanged.
|
||||
- Text mode may add additive remediation hints; existing exit codes and JSON fields stay unchanged.
|
||||
- New JSON fields remain additive.
|
||||
- `--json-strict` is the recommended mode for orchestrators that need JSON-only output channels.
|
||||
|
||||
|
||||
62
docs/CLI.md
62
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,21 +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. |
|
||||
| `--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.
|
||||
|
||||
@ -131,6 +132,7 @@ acpx --non-interactive-permissions fail codex 'fail fast when prompt cannot be s
|
||||
acpx --cwd ~/repos/api codex 'review auth middleware'
|
||||
acpx --format json codex exec 'summarize open TODO items'
|
||||
acpx --format json --json-strict codex exec 'machine-safe JSON output'
|
||||
acpx --no-terminal codex exec 'summarize without terminal capability'
|
||||
acpx --timeout 120 codex 'investigate flaky test failures'
|
||||
acpx --ttl 30 codex 'keep queue owner warm for quick follow-up'
|
||||
acpx --verbose codex 'debug adapter startup issues'
|
||||
@ -196,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
|
||||
|
||||
@ -305,6 +307,7 @@ acpx [global_options] <agent> sessions show
|
||||
acpx [global_options] <agent> sessions show <name>
|
||||
acpx [global_options] <agent> sessions history
|
||||
acpx [global_options] <agent> sessions history <name> [--limit <count>]
|
||||
acpx [global_options] <agent> sessions prune [--dry-run] [--before <date> | --older-than <days>] [--include-history]
|
||||
|
||||
acpx [global_options] sessions ... # defaults to codex
|
||||
```
|
||||
@ -316,12 +319,17 @@ Behavior:
|
||||
- `sessions new` creates a fresh cwd-scoped default session
|
||||
- `sessions new --name <name>` creates a fresh named session for cwd
|
||||
- creating a fresh session soft-closes the previous open session in that scope (if present)
|
||||
- text and quiet output print the local `acpxRecordId`; JSON output also includes
|
||||
`acpxSessionId` and, when the adapter exposes one, `agentSessionId`
|
||||
- `sessions ensure` returns the nearest matching active session or creates one for cwd
|
||||
- `sessions ensure --name <name>` does the same for named sessions
|
||||
- `sessions close` soft-closes the current cwd default session
|
||||
- `sessions close <name>` soft-closes current cwd named session
|
||||
- `sessions show [name]` displays stored session metadata
|
||||
- `sessions history [name]` displays stored turn history previews (default 20, configurable with `--limit`)
|
||||
- `sessions prune --dry-run` previews closed sessions that can be deleted
|
||||
- `sessions prune` deletes closed session records for the selected agent; add `--include-history` to delete event stream files too
|
||||
- `sessions prune --before <date>` and `--older-than <days>` filter by close time, falling back to last-used time for older records
|
||||
- close errors if the target session does not exist
|
||||
|
||||
## `status` command
|
||||
@ -335,12 +343,16 @@ acpx [global_options] status -s <name>
|
||||
|
||||
Shows local process status for the cwd-scoped session:
|
||||
|
||||
- `running`, `dead`, or `no-session`
|
||||
- `running`, `idle`, `dead`, or `no-session`
|
||||
- session id, agent command, pid
|
||||
- uptime when running
|
||||
- last prompt timestamp
|
||||
- last known exit code/signal when dead
|
||||
|
||||
`idle` means the persistent session is saved and resumable, but no queue owner is
|
||||
currently running. The next prompt starts a queue owner and reconnects the
|
||||
session.
|
||||
|
||||
Status checks are local and PID-based (`kill(pid, 0)` semantics).
|
||||
|
||||
## `config` command
|
||||
@ -370,7 +382,7 @@ Supported keys:
|
||||
"timeout": null,
|
||||
"format": "text",
|
||||
"agents": {
|
||||
"my-custom": { "command": "./bin/my-acp-server" }
|
||||
"my-custom": { "command": "./bin/my-acp-server", "args": ["acp"] }
|
||||
},
|
||||
"auth": {
|
||||
"my_auth_method_id": "credential-value"
|
||||
@ -380,6 +392,11 @@ Supported keys:
|
||||
|
||||
CLI flags always override config values.
|
||||
|
||||
For ACP `authenticate` handshakes, use either config `auth` entries or explicit
|
||||
`ACPX_AUTH_<METHOD_ID>` environment variables such as `ACPX_AUTH_OPENAI_API_KEY`.
|
||||
Ambient provider env vars such as `OPENAI_API_KEY` are still passed through to
|
||||
child agents, but they do not trigger ACP auth-method selection on their own.
|
||||
|
||||
## `--agent` escape hatch
|
||||
|
||||
`--agent <command>` sets a raw adapter command explicitly.
|
||||
@ -487,7 +504,7 @@ Hard rule for the ACP stream:
|
||||
When `--format json` is used:
|
||||
|
||||
- commands that talk to an ACP adapter emit raw ACP JSON-RPC messages.
|
||||
- local query commands (`sessions list/show/history`) emit local JSON documents (not ACP stream traffic).
|
||||
- local query commands (`sessions list/show/history/prune`) emit local JSON documents (not ACP stream traffic).
|
||||
|
||||
### Sessions/query command output behavior
|
||||
|
||||
@ -498,6 +515,9 @@ When `--format json` is used:
|
||||
- `sessions show` with `json`: full session record object
|
||||
- `sessions history` with `text`: tab-separated `timestamp role textPreview` entries
|
||||
- `sessions history` with `json`: object containing `entries` array
|
||||
- `sessions prune` with `text`: summary plus pruned ids and close/last-used time
|
||||
- `sessions prune` with `json`: object containing `action`, `dryRun`, `count`, `bytesFreed`, and `pruned`
|
||||
- `sessions prune` with `quiet`: one pruned session id per line
|
||||
- `status` with `text`: key/value process status lines
|
||||
|
||||
## Permission modes
|
||||
|
||||
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;
|
||||
|
||||
@ -51,10 +51,14 @@ export function resolveRunBundleFilePath(
|
||||
relativePath: string,
|
||||
): string {
|
||||
const normalizedRelativePath = normalizeRelativePath(relativePath);
|
||||
const runDir = path.resolve(runsDir, runId);
|
||||
const resolvedRunsDir = path.resolve(runsDir);
|
||||
const runDir = path.resolve(resolvedRunsDir, runId);
|
||||
if (!isPathInsideDirectory(resolvedRunsDir, runDir, { allowSamePath: false })) {
|
||||
throw new Error(`Refusing to read run bundle outside runs directory: ${runId}`);
|
||||
}
|
||||
const resolvedPath = path.resolve(runDir, normalizedRelativePath);
|
||||
|
||||
if (!resolvedPath.startsWith(`${runDir}${path.sep}`) && resolvedPath !== runDir) {
|
||||
if (!isPathInsideDirectory(runDir, resolvedPath)) {
|
||||
throw new Error(`Refusing to read outside run bundle: ${relativePath}`);
|
||||
}
|
||||
|
||||
@ -102,3 +106,20 @@ function normalizeRelativePath(relativePath: string): string {
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function isPathInsideDirectory(
|
||||
rootDir: string,
|
||||
candidatePath: string,
|
||||
options: { allowSamePath?: boolean } = {},
|
||||
): boolean {
|
||||
const relativePath = path.relative(rootDir, candidatePath);
|
||||
if (!options.allowSamePath && relativePath.length === 0) {
|
||||
return false;
|
||||
}
|
||||
return (
|
||||
relativePath.length === 0 ||
|
||||
(!relativePath.startsWith(`..${path.sep}`) &&
|
||||
relativePath !== ".." &&
|
||||
!path.isAbsolute(relativePath))
|
||||
);
|
||||
}
|
||||
|
||||
@ -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,12 +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|not allowed|required/.test(error.message)
|
||||
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;
|
||||
}
|
||||
@ -273,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
53
package.json
53
package.json
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "acpx",
|
||||
"version": "0.5.3",
|
||||
"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",
|
||||
@ -56,10 +57,10 @@
|
||||
"prepack": "pnpm run build",
|
||||
"prepare": "husky",
|
||||
"test": "pnpm run build:test && node --test dist-test/test/*.test.js",
|
||||
"test:coverage": "pnpm run build:test && node --experimental-test-coverage --test-coverage-lines=83 --test-coverage-branches=76 --test-coverage-functions=86 --test dist-test/test/*.test.js",
|
||||
"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,39 +68,39 @@
|
||||
"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.17.0",
|
||||
"@agentclientprotocol/sdk": "^0.21.0",
|
||||
"commander": "^14.0.3",
|
||||
"skillflag": "^0.1.4",
|
||||
"tsx": "^4.0.0",
|
||||
"zod": "^4.3.6"
|
||||
"tsx": "^4.21.0",
|
||||
"zod": "^4.4.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^25.3.5",
|
||||
"@types/node": "^25.6.0",
|
||||
"@types/react": "^19.2.14",
|
||||
"@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.20260328.1",
|
||||
"@typescript/native-preview": "7.0.0-dev.20260503.1",
|
||||
"@vitejs/plugin-react": "^6.0.1",
|
||||
"@xyflow/react": "^12.10.1",
|
||||
"@xyflow/react": "^12.10.2",
|
||||
"elkjs": "^0.11.1",
|
||||
"fast-json-patch": "^3.1.1",
|
||||
"husky": "^9.1.7",
|
||||
"lint-staged": "^16.3.2",
|
||||
"markdownlint-cli2": "^0.22.0",
|
||||
"oxfmt": "^0.42.0",
|
||||
"oxlint": "^1.51.0",
|
||||
"oxlint-tsgolint": "^0.18.1",
|
||||
"react": "^19.2.4",
|
||||
"react-dom": "^19.2.4",
|
||||
"react-test-renderer": "^19.2.0",
|
||||
"tsdown": "^0.21.0-beta.2",
|
||||
"typescript": "^6.0.2",
|
||||
"vite": "^8.0.7",
|
||||
"ws": "^8.18.3"
|
||||
"lint-staged": "^16.4.0",
|
||||
"markdownlint-cli2": "^0.22.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",
|
||||
"tsdown": "^0.21.10",
|
||||
"typescript": "^6.0.3",
|
||||
"vite": "^8.0.10",
|
||||
"ws": "^8.20.0"
|
||||
},
|
||||
"lint-staged": {
|
||||
"*.{js,ts}": [
|
||||
@ -113,5 +114,5 @@
|
||||
"engines": {
|
||||
"node": ">=22.12.0"
|
||||
},
|
||||
"packageManager": "pnpm@10.23.0"
|
||||
"packageManager": "pnpm@10.33.2"
|
||||
}
|
||||
|
||||
1500
pnpm-lock.yaml
generated
1500
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
|
||||
|
||||
@ -74,7 +75,7 @@ Friendly agent names resolve to commands:
|
||||
- `pi` -> `npx pi-acp`
|
||||
- `openclaw` -> `openclaw acp`
|
||||
- `codex` -> `npx @zed-industries/codex-acp`
|
||||
- `claude` -> `npx -y @agentclientprotocol/claude-agent-acp`
|
||||
- `claude` -> `npx -y @agentclientprotocol/claude-agent-acp` (ACPX-owned package range)
|
||||
- `gemini` -> `gemini --acp`
|
||||
- `cursor` -> `cursor-agent acp`
|
||||
- `copilot` -> `copilot --acp --stdio`
|
||||
@ -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.
|
||||
@ -221,11 +222,16 @@ Supported keys:
|
||||
- `ttl` (seconds)
|
||||
- `timeout` (seconds or `null`)
|
||||
- `format` (`text`, `json`, `quiet`)
|
||||
- `agents` map (`name -> { command }`)
|
||||
- `agents` map (`name -> { command, args? }`)
|
||||
- `auth` map (`authMethodId -> credential`)
|
||||
|
||||
Use `acpx config show` to inspect the resolved config and `acpx config init` to create the global template.
|
||||
|
||||
For ACP `authenticate` handshakes, use either config `auth` entries or explicit
|
||||
`ACPX_AUTH_<METHOD_ID>` environment variables such as `ACPX_AUTH_OPENAI_API_KEY`.
|
||||
Ambient provider env vars such as `OPENAI_API_KEY` are still passed through to
|
||||
child agents, but they do not trigger ACP auth-method selection on their own.
|
||||
|
||||
## Session behavior
|
||||
|
||||
Persistent prompt sessions are scoped by:
|
||||
|
||||
@ -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(
|
||||
@ -340,13 +303,43 @@ export function buildClaudeCodeOptionsMeta(
|
||||
claudeCodeOptions.maxTurns = options.maxTurns;
|
||||
}
|
||||
|
||||
if (Object.keys(claudeCodeOptions).length === 0) {
|
||||
const meta: Record<string, unknown> = {};
|
||||
if (Object.keys(claudeCodeOptions).length > 0) {
|
||||
meta.claudeCode = { options: claudeCodeOptions };
|
||||
}
|
||||
|
||||
const systemPrompt = options.systemPrompt;
|
||||
if (typeof systemPrompt === "string" && systemPrompt.length > 0) {
|
||||
meta.systemPrompt = systemPrompt;
|
||||
} else if (
|
||||
systemPrompt &&
|
||||
typeof systemPrompt === "object" &&
|
||||
typeof systemPrompt.append === "string" &&
|
||||
systemPrompt.append.length > 0
|
||||
) {
|
||||
meta.systemPrompt = { append: systemPrompt.append };
|
||||
}
|
||||
|
||||
if (Object.keys(meta).length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
claudeCode: {
|
||||
options: claudeCodeOptions,
|
||||
},
|
||||
};
|
||||
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);
|
||||
}
|
||||
|
||||
@ -1,5 +1,7 @@
|
||||
import type { AcpClientOptions } from "../types.js";
|
||||
|
||||
const AUTH_ENV_PREFIX = "ACPX_AUTH_";
|
||||
|
||||
function toEnvToken(value: string): string {
|
||||
return value
|
||||
.trim()
|
||||
@ -8,42 +10,58 @@ function toEnvToken(value: string): string {
|
||||
.toUpperCase();
|
||||
}
|
||||
|
||||
function buildAuthEnvKeys(methodId: string): string[] {
|
||||
function buildAuthEnvKey(methodId: string): string | undefined {
|
||||
const token = toEnvToken(methodId);
|
||||
const keys = new Set<string>([methodId]);
|
||||
if (token) {
|
||||
keys.add(token);
|
||||
keys.add(`ACPX_AUTH_${token}`);
|
||||
}
|
||||
return [...keys];
|
||||
return token.length > 0 ? `${AUTH_ENV_PREFIX}${token}` : undefined;
|
||||
}
|
||||
|
||||
const authEnvKeysCache = new Map<string, string[]>();
|
||||
const authEnvKeyCache = new Map<string, string | undefined>();
|
||||
|
||||
function authEnvKeys(methodId: string): string[] {
|
||||
const cached = authEnvKeysCache.get(methodId);
|
||||
if (cached) {
|
||||
function authEnvKey(methodId: string): string | undefined {
|
||||
const cached = authEnvKeyCache.get(methodId);
|
||||
if (cached !== undefined) {
|
||||
return cached;
|
||||
}
|
||||
const keys = buildAuthEnvKeys(methodId);
|
||||
authEnvKeysCache.set(methodId, keys);
|
||||
return keys;
|
||||
const key = buildAuthEnvKey(methodId);
|
||||
authEnvKeyCache.set(methodId, key);
|
||||
return key;
|
||||
}
|
||||
|
||||
export function readEnvCredential(methodId: string): string | undefined {
|
||||
for (const key of authEnvKeys(methodId)) {
|
||||
const value = process.env[key];
|
||||
if (typeof value === "string" && value.trim().length > 0) {
|
||||
return value;
|
||||
}
|
||||
const key = authEnvKey(methodId);
|
||||
if (!key) {
|
||||
return undefined;
|
||||
}
|
||||
const value = process.env[key];
|
||||
if (typeof value === "string" && value.trim().length > 0) {
|
||||
return value;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function promotePrefixedAuthEnvironment(env: NodeJS.ProcessEnv): void {
|
||||
for (const [key, value] of Object.entries(env)) {
|
||||
if (!key.startsWith(AUTH_ENV_PREFIX)) {
|
||||
continue;
|
||||
}
|
||||
if (typeof value !== "string" || value.trim().length === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const normalized = key.slice(AUTH_ENV_PREFIX.length);
|
||||
if (!normalized || env[normalized] != null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
env[normalized] = value;
|
||||
}
|
||||
}
|
||||
|
||||
function buildAgentEnvironment(
|
||||
authCredentials: Record<string, string> | undefined,
|
||||
): NodeJS.ProcessEnv {
|
||||
const env: NodeJS.ProcessEnv = { ...process.env };
|
||||
promotePrefixedAuthEnvironment(env);
|
||||
if (!authCredentials) {
|
||||
return env;
|
||||
}
|
||||
@ -59,7 +77,7 @@ function buildAgentEnvironment(
|
||||
|
||||
const normalized = toEnvToken(methodId);
|
||||
if (normalized) {
|
||||
const prefixed = `ACPX_AUTH_${normalized}`;
|
||||
const prefixed = `${AUTH_ENV_PREFIX}${normalized}`;
|
||||
if (env[prefixed] == null) {
|
||||
env[prefixed] = credential;
|
||||
}
|
||||
|
||||
@ -1,12 +1,22 @@
|
||||
import type { ChildProcess, ChildProcessByStdio } from "node:child_process";
|
||||
import { execFile, type ChildProcess, type ChildProcessByStdio } from "node:child_process";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { Readable, Writable } from "node:stream";
|
||||
import { promisify } from "node:util";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
export type CommandParts = {
|
||||
command: string;
|
||||
args: string[];
|
||||
};
|
||||
|
||||
type ResolveSessionCwdOptions = {
|
||||
platform?: NodeJS.Platform;
|
||||
existsSync?: (filePath: string) => boolean;
|
||||
runWslpath?: (cwd: string) => Promise<string>;
|
||||
};
|
||||
|
||||
export function isoNow(): string {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
@ -146,6 +156,63 @@ export function asAbsoluteCwd(cwd: string): string {
|
||||
return path.resolve(cwd);
|
||||
}
|
||||
|
||||
export async function resolveAgentSessionCwd(
|
||||
cwd: string,
|
||||
agentCommand: string,
|
||||
options: ResolveSessionCwdOptions = {},
|
||||
): Promise<string> {
|
||||
const resolved = asAbsoluteCwd(cwd);
|
||||
if (!shouldTranslateWslWindowsCwd(agentCommand, options)) {
|
||||
return resolved;
|
||||
}
|
||||
|
||||
const translated = (await (options.runWslpath ?? runWslpath)(resolved)).trim();
|
||||
if (!translated) {
|
||||
throw new Error(`wslpath returned an empty Windows path for cwd: ${resolved}`);
|
||||
}
|
||||
return translated;
|
||||
}
|
||||
|
||||
function shouldTranslateWslWindowsCwd(
|
||||
agentCommand: string,
|
||||
options: ResolveSessionCwdOptions,
|
||||
): boolean {
|
||||
if (!isWsl(options)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const { command } = splitCommandLine(agentCommand);
|
||||
return isWindowsExecutableCommand(command);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function isWsl(options: ResolveSessionCwdOptions): boolean {
|
||||
const platform = options.platform ?? process.platform;
|
||||
if (platform !== "linux") {
|
||||
return false;
|
||||
}
|
||||
|
||||
const existsSync = options.existsSync ?? fs.existsSync;
|
||||
return existsSync("/proc/sys/fs/binfmt_misc/WSLInterop");
|
||||
}
|
||||
|
||||
const WINDOWS_EXECUTABLE_EXTENSION_RE = /\.(?:exe|cmd|bat)$/u;
|
||||
|
||||
function isWindowsExecutableCommand(command: string): boolean {
|
||||
const normalized = command.toLowerCase();
|
||||
return WINDOWS_EXECUTABLE_EXTENSION_RE.test(normalized);
|
||||
}
|
||||
|
||||
async function runWslpath(cwd: string): Promise<string> {
|
||||
const { stdout } = await execFileAsync("wslpath", ["-w", cwd], {
|
||||
encoding: "utf8",
|
||||
});
|
||||
return stdout;
|
||||
}
|
||||
|
||||
export function basenameToken(value: string): string {
|
||||
return path
|
||||
.basename(value)
|
||||
|
||||
@ -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,
|
||||
@ -78,6 +80,7 @@ import {
|
||||
isoNow,
|
||||
isChildProcessRunning,
|
||||
requireAgentStdio,
|
||||
resolveAgentSessionCwd,
|
||||
splitCommandLine,
|
||||
waitForChildExit,
|
||||
waitForSpawn,
|
||||
@ -114,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;
|
||||
};
|
||||
|
||||
@ -351,6 +356,7 @@ export class AcpClient {
|
||||
updateRuntimeOptions(options: {
|
||||
permissionMode?: PermissionMode;
|
||||
nonInteractivePermissions?: NonInteractivePermissionPolicy;
|
||||
terminal?: boolean;
|
||||
suppressSdkConsoleErrors?: boolean;
|
||||
verbose?: boolean;
|
||||
}): void {
|
||||
@ -360,6 +366,9 @@ export class AcpClient {
|
||||
if (options.nonInteractivePermissions !== undefined) {
|
||||
this.options.nonInteractivePermissions = options.nonInteractivePermissions;
|
||||
}
|
||||
if (options.terminal !== undefined) {
|
||||
this.options.terminal = options.terminal;
|
||||
}
|
||||
if (options.permissionMode || options.nonInteractivePermissions !== undefined) {
|
||||
this.filesystem.updatePermissionPolicy(
|
||||
this.options.permissionMode,
|
||||
@ -431,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 {
|
||||
@ -524,7 +543,7 @@ export class AcpClient {
|
||||
readTextFile: true,
|
||||
writeTextFile: true,
|
||||
},
|
||||
terminal: true,
|
||||
terminal: this.options.terminal !== false,
|
||||
},
|
||||
clientInfo: {
|
||||
name: "acpx",
|
||||
@ -548,6 +567,7 @@ export class AcpClient {
|
||||
this.log(`initialized protocol version ${initResult.protocolVersion}`);
|
||||
} catch (error) {
|
||||
startupFailure.dispose();
|
||||
const normalizedError = await this.normalizeInitializeError(error, child, startupStderr);
|
||||
try {
|
||||
child.kill();
|
||||
} catch {
|
||||
@ -562,7 +582,7 @@ export class AcpClient {
|
||||
},
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
throw normalizedError;
|
||||
}
|
||||
}
|
||||
|
||||
@ -625,12 +645,13 @@ export class AcpClient {
|
||||
const connection = this.getConnection();
|
||||
const { command, args } = splitCommandLine(this.options.agentCommand);
|
||||
const claudeAcp = isClaudeAcpCommand(command, args);
|
||||
const sessionCwd = await resolveAgentSessionCwd(cwd, this.options.agentCommand);
|
||||
|
||||
let result: Awaited<ReturnType<typeof connection.newSession>>;
|
||||
try {
|
||||
const createPromise = this.runConnectionRequest(() =>
|
||||
connection.newSession({
|
||||
cwd: asAbsoluteCwd(cwd),
|
||||
cwd: sessionCwd,
|
||||
mcpServers: this.options.mcpServers ?? [],
|
||||
_meta: buildClaudeCodeOptionsMeta(this.options.sessionOptions),
|
||||
}),
|
||||
@ -653,6 +674,7 @@ export class AcpClient {
|
||||
return {
|
||||
sessionId: result.sessionId,
|
||||
agentSessionId: extractRuntimeSessionId(result._meta),
|
||||
configOptions: result.configOptions ?? undefined,
|
||||
models: result.models ?? undefined,
|
||||
};
|
||||
}
|
||||
@ -668,6 +690,7 @@ export class AcpClient {
|
||||
options: LoadSessionOptions = {},
|
||||
): Promise<SessionLoadResult> {
|
||||
const connection = this.getConnection();
|
||||
const sessionCwd = await resolveAgentSessionCwd(cwd, this.options.agentCommand);
|
||||
const previousSuppression = this.suppressSessionUpdates;
|
||||
const previousReplaySuppression = this.suppressReplaySessionUpdateMessages;
|
||||
this.suppressSessionUpdates = previousSuppression || Boolean(options.suppressReplayUpdates);
|
||||
@ -680,7 +703,7 @@ export class AcpClient {
|
||||
response = await this.runConnectionRequest(() =>
|
||||
connection.loadSession({
|
||||
sessionId,
|
||||
cwd: asAbsoluteCwd(cwd),
|
||||
cwd: sessionCwd,
|
||||
mcpServers: this.options.mcpServers ?? [],
|
||||
}),
|
||||
);
|
||||
@ -698,6 +721,7 @@ export class AcpClient {
|
||||
|
||||
return {
|
||||
agentSessionId: extractRuntimeSessionId(response?._meta),
|
||||
configOptions: response?.configOptions ?? undefined,
|
||||
models: response?.models ?? undefined,
|
||||
};
|
||||
}
|
||||
@ -834,7 +858,7 @@ export class AcpClient {
|
||||
async closeSession(sessionId: string): Promise<void> {
|
||||
const connection = this.getConnection();
|
||||
await this.runConnectionRequest(() =>
|
||||
connection.unstable_closeSession({
|
||||
connection.closeSession({
|
||||
sessionId,
|
||||
}),
|
||||
);
|
||||
@ -1087,6 +1111,32 @@ export class AcpClient {
|
||||
};
|
||||
}
|
||||
|
||||
private async normalizeInitializeError(
|
||||
error: unknown,
|
||||
child: ChildProcessByStdio<Writable, Readable, Readable>,
|
||||
startupStderr: string[],
|
||||
): Promise<unknown> {
|
||||
if (error instanceof AgentStartupError) {
|
||||
return error;
|
||||
}
|
||||
|
||||
const connectionClosedDuringInitialize =
|
||||
error instanceof Error && /acp connection closed/i.test(error.message);
|
||||
await waitForChildExit(child, 100);
|
||||
const childExited = child.exitCode !== null || child.signalCode !== null;
|
||||
if (!connectionClosedDuringInitialize && !childExited) {
|
||||
return error;
|
||||
}
|
||||
|
||||
return new AgentStartupError({
|
||||
agentCommand: this.options.agentCommand,
|
||||
exitCode: child.exitCode ?? null,
|
||||
signal: child.signalCode ?? null,
|
||||
stderrSummary: this.summarizeStartupStderr(startupStderr),
|
||||
cause: error,
|
||||
});
|
||||
}
|
||||
|
||||
private selectAuthMethod(methods: AuthMethod[]): AuthSelection | undefined {
|
||||
for (const method of methods) {
|
||||
const envCredential = readEnvCredential(method.id);
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -57,16 +57,34 @@ function buildFallbackData(params: BuildJsonRpcErrorParams): Record<string, unkn
|
||||
return data;
|
||||
}
|
||||
|
||||
function mergeAcpErrorData(acpData: unknown, fallbackData: Record<string, unknown>): unknown {
|
||||
if (Object.keys(fallbackData).length === 0) {
|
||||
return acpData;
|
||||
}
|
||||
if (acpData === undefined) {
|
||||
return fallbackData;
|
||||
}
|
||||
if (acpData && typeof acpData === "object" && !Array.isArray(acpData)) {
|
||||
return {
|
||||
...fallbackData,
|
||||
...acpData,
|
||||
};
|
||||
}
|
||||
return acpData;
|
||||
}
|
||||
|
||||
function buildErrorObject(params: BuildJsonRpcErrorParams): JsonRpcErrorObject {
|
||||
const fallbackData = buildFallbackData(params);
|
||||
if (hasValidAcpError(params.acp)) {
|
||||
const data = mergeAcpErrorData(params.acp.data, fallbackData);
|
||||
return {
|
||||
code: params.acp.code,
|
||||
message: params.acp.message,
|
||||
...(params.acp.data !== undefined ? { data: params.acp.data } : {}),
|
||||
...(data !== undefined ? { data } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
const data = buildFallbackData(params);
|
||||
const data = fallbackData;
|
||||
return {
|
||||
code: OUTPUT_ERROR_JSONRPC_CODES[params.outputCode] ?? -32603,
|
||||
message: params.message,
|
||||
|
||||
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)}.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -133,7 +133,7 @@ async function defaultConfirmExecute(commandLine: string): Promise<boolean> {
|
||||
}
|
||||
|
||||
function canPromptForPermission(): boolean {
|
||||
return Boolean(process.stdin.isTTY && process.stderr.isTTY);
|
||||
return process.stdin.isTTY && process.stderr.isTTY;
|
||||
}
|
||||
|
||||
function waitMs(ms: number): Promise<void> {
|
||||
|
||||
@ -3,9 +3,9 @@ import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const ACP_ADAPTER_PACKAGE_RANGES = {
|
||||
pi: "^0.0.22",
|
||||
codex: "^0.11.1",
|
||||
claude: "^0.25.0",
|
||||
pi: "^0.0.26",
|
||||
codex: "^0.12.0",
|
||||
claude: "^0.31.0",
|
||||
} as const;
|
||||
|
||||
type BuiltInAgentPackageSpec = {
|
||||
|
||||
@ -20,7 +20,7 @@ import {
|
||||
parseTtlSeconds,
|
||||
resolveOutputPolicy,
|
||||
} from "./cli/flags.js";
|
||||
import { createOutputFormatter } from "./cli/output/output.js";
|
||||
import { createOutputFormatter, getTextErrorRemediationHints } from "./cli/output/output.js";
|
||||
import { runQueueOwnerFromEnv } from "./cli/queue/owner-env.js";
|
||||
import { flushPerfMetricsCapture, installPerfMetricsCapture } from "./perf-metrics-capture.js";
|
||||
import { EXIT_CODES, OUTPUT_FORMATS, type OutputFormat, type OutputPolicy } from "./types.js";
|
||||
@ -245,6 +245,11 @@ async function emitRequestedError(
|
||||
|
||||
if (!outputPolicy.suppressNonJsonStderr) {
|
||||
process.stderr.write(`${normalized.message}\n`);
|
||||
if (outputPolicy.format === "text") {
|
||||
for (const hint of getTextErrorRemediationHints(normalized)) {
|
||||
process.stderr.write(`${hint}\n`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -30,9 +30,11 @@ import {
|
||||
resolvePermissionMode,
|
||||
resolveSessionNameFromFlags,
|
||||
type ExecFlags,
|
||||
type GlobalFlags,
|
||||
type PromptFlags,
|
||||
type SessionsHistoryFlags,
|
||||
type SessionsNewFlags,
|
||||
type SessionsPruneFlags,
|
||||
type StatusFlags,
|
||||
} from "./flags.js";
|
||||
import { emitJsonResult } from "./output/json-output.js";
|
||||
@ -155,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,
|
||||
@ -222,6 +288,7 @@ export async function handlePrompt(
|
||||
nonInteractivePermissions: globalFlags.nonInteractivePermissions,
|
||||
authCredentials: config.auth,
|
||||
authPolicy: globalFlags.authPolicy,
|
||||
terminal: globalFlags.terminal,
|
||||
outputFormatter,
|
||||
errorEmissionPolicy: {
|
||||
queueErrorAlreadyEmitted: outputPolicy.queueErrorAlreadyEmitted,
|
||||
@ -233,6 +300,12 @@ export async function handlePrompt(
|
||||
promptRetries: globalFlags.promptRetries,
|
||||
verbose: globalFlags.verbose,
|
||||
waitForCompletion: flags.wait !== false,
|
||||
sessionOptions: {
|
||||
model: globalFlags.model,
|
||||
allowedTools: globalFlags.allowedTools,
|
||||
maxTurns: globalFlags.maxTurns,
|
||||
systemPrompt: globalFlags.systemPrompt,
|
||||
},
|
||||
});
|
||||
|
||||
if ("queued" in result) {
|
||||
@ -299,6 +372,7 @@ export async function handleExec(
|
||||
nonInteractivePermissions: globalFlags.nonInteractivePermissions,
|
||||
authCredentials: config.auth,
|
||||
authPolicy: globalFlags.authPolicy,
|
||||
terminal: globalFlags.terminal,
|
||||
outputFormatter,
|
||||
suppressSdkConsoleErrors: outputPolicy.suppressSdkConsoleErrors,
|
||||
timeoutMs: globalFlags.timeout,
|
||||
@ -308,6 +382,7 @@ export async function handleExec(
|
||||
model: globalFlags.model,
|
||||
allowedTools: globalFlags.allowedTools,
|
||||
maxTurns: globalFlags.maxTurns,
|
||||
systemPrompt: globalFlags.systemPrompt,
|
||||
},
|
||||
});
|
||||
|
||||
@ -455,6 +530,7 @@ export async function handleSetMode(
|
||||
nonInteractivePermissions: globalFlags.nonInteractivePermissions,
|
||||
authCredentials: config.auth,
|
||||
authPolicy: globalFlags.authPolicy,
|
||||
terminal: globalFlags.terminal,
|
||||
timeoutMs: globalFlags.timeout,
|
||||
verbose: globalFlags.verbose,
|
||||
});
|
||||
@ -489,6 +565,7 @@ export async function handleSetModel(
|
||||
nonInteractivePermissions: globalFlags.nonInteractivePermissions,
|
||||
authCredentials: config.auth,
|
||||
authPolicy: globalFlags.authPolicy,
|
||||
terminal: globalFlags.terminal,
|
||||
timeoutMs: globalFlags.timeout,
|
||||
verbose: globalFlags.verbose,
|
||||
});
|
||||
@ -530,6 +607,7 @@ export async function handleSetConfigOption(
|
||||
nonInteractivePermissions: globalFlags.nonInteractivePermissions,
|
||||
authCredentials: config.auth,
|
||||
authPolicy: globalFlags.authPolicy,
|
||||
terminal: globalFlags.terminal,
|
||||
timeoutMs: globalFlags.timeout,
|
||||
verbose: globalFlags.verbose,
|
||||
});
|
||||
@ -576,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);
|
||||
@ -612,24 +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,
|
||||
timeoutMs: globalFlags.timeout,
|
||||
verbose: globalFlags.verbose,
|
||||
sessionOptions: {
|
||||
model: globalFlags.model,
|
||||
allowedTools: globalFlags.allowedTools,
|
||||
maxTurns: globalFlags.maxTurns,
|
||||
},
|
||||
});
|
||||
const created = await createSession(
|
||||
buildSessionStartOptions({ agent, flags, globalFlags, config, permissionMode }),
|
||||
);
|
||||
|
||||
printCreatedSessionBanner(created, agent.agentName, globalFlags.format, globalFlags.jsonStrict);
|
||||
|
||||
@ -652,24 +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,
|
||||
timeoutMs: globalFlags.timeout,
|
||||
verbose: globalFlags.verbose,
|
||||
sessionOptions: {
|
||||
model: globalFlags.model,
|
||||
allowedTools: globalFlags.allowedTools,
|
||||
maxTurns: globalFlags.maxTurns,
|
||||
},
|
||||
});
|
||||
const result = await ensureSession(
|
||||
buildSessionStartOptions({ agent, flags, globalFlags, config, permissionMode }),
|
||||
);
|
||||
|
||||
if (result.created) {
|
||||
printCreatedSessionBanner(
|
||||
@ -829,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);
|
||||
}
|
||||
@ -856,22 +887,35 @@ 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);
|
||||
}
|
||||
|
||||
export async function handleSessionsPrune(
|
||||
explicitAgentName: string | undefined,
|
||||
flags: SessionsPruneFlags,
|
||||
command: Command,
|
||||
config: ResolvedAcpxConfig,
|
||||
): Promise<void> {
|
||||
const globalFlags = resolveGlobalFlags(command, config);
|
||||
const agent = resolveAgentInvocation(explicitAgentName, globalFlags, config);
|
||||
const [{ pruneSessions }, { printPruneResultByFormat }] = await Promise.all([
|
||||
loadSessionModule(),
|
||||
loadOutputRenderModule(),
|
||||
]);
|
||||
|
||||
const olderThanMs = flags.olderThan != null ? flags.olderThan * 24 * 60 * 60 * 1000 : undefined;
|
||||
|
||||
const result = await pruneSessions({
|
||||
agentCommand: agent.agentCommand,
|
||||
before: flags.before,
|
||||
olderThanMs,
|
||||
includeHistory: flags.includeHistory,
|
||||
dryRun: flags.dryRun,
|
||||
});
|
||||
|
||||
printPruneResultByFormat(result, globalFlags.format);
|
||||
}
|
||||
|
||||
export { parseHistoryLimit, NoSessionError, loadSessionModule };
|
||||
|
||||
@ -9,6 +9,7 @@ import {
|
||||
handleSessionsHistory,
|
||||
handleSessionsList,
|
||||
handleSessionsNew,
|
||||
handleSessionsPrune,
|
||||
handleSessionsShow,
|
||||
handleSetConfigOption,
|
||||
handleSetMode,
|
||||
@ -20,11 +21,14 @@ import {
|
||||
addPromptInputOption,
|
||||
addSessionNameOption,
|
||||
addSessionOption,
|
||||
parseDaysOlderThan,
|
||||
parseNonEmptyValue,
|
||||
parsePruneBeforeDate,
|
||||
parseSessionName,
|
||||
type PromptFlags,
|
||||
type SessionsHistoryFlags,
|
||||
type SessionsNewFlags,
|
||||
type SessionsPruneFlags,
|
||||
type StatusFlags,
|
||||
} from "./flags.js";
|
||||
import { registerStatusCommand } from "./status-command.js";
|
||||
@ -134,6 +138,17 @@ export function registerSessionsCommand(
|
||||
config,
|
||||
);
|
||||
});
|
||||
|
||||
sessionsCommand
|
||||
.command("prune")
|
||||
.description("Delete closed sessions and free disk space")
|
||||
.option("--dry-run", "Preview what would be pruned without deleting anything")
|
||||
.option("--before <date>", "Prune sessions closed before this date", parsePruneBeforeDate)
|
||||
.option("--older-than <days>", "Prune sessions closed more than N days ago", parseDaysOlderThan)
|
||||
.option("--include-history", "Also delete event stream files (.stream.ndjson)")
|
||||
.action(async function (this: Command, flags: SessionsPruneFlags) {
|
||||
await handleSessionsPrune(explicitAgentName, flags, this, config);
|
||||
});
|
||||
}
|
||||
|
||||
export function registerSharedAgentSubcommands(
|
||||
|
||||
@ -13,6 +13,7 @@ import type {
|
||||
|
||||
type ConfigAgentEntry = {
|
||||
command: string;
|
||||
args?: string[];
|
||||
};
|
||||
|
||||
type ConfigFileShape = {
|
||||
@ -197,12 +198,37 @@ function parseAgents(value: unknown, sourcePath: string): Record<string, string>
|
||||
`Invalid config agents.${name}.command in ${sourcePath}: expected non-empty string`,
|
||||
);
|
||||
}
|
||||
parsed[normalizeAgentName(name)] = command.trim();
|
||||
const args = parseAgentArgs(raw.args, name, sourcePath);
|
||||
parsed[normalizeAgentName(name)] =
|
||||
args.length > 0 ? `${command.trim()} ${args.map(quoteCommandArg).join(" ")}` : command.trim();
|
||||
}
|
||||
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function parseAgentArgs(value: unknown, agentName: string, sourcePath: string): string[] {
|
||||
if (value == null) {
|
||||
return [];
|
||||
}
|
||||
if (!Array.isArray(value)) {
|
||||
throw new Error(
|
||||
`Invalid config agents.${agentName}.args in ${sourcePath}: expected array of strings`,
|
||||
);
|
||||
}
|
||||
return value.map((arg, index) => {
|
||||
if (typeof arg !== "string") {
|
||||
throw new Error(
|
||||
`Invalid config agents.${agentName}.args[${index}] in ${sourcePath}: expected string`,
|
||||
);
|
||||
}
|
||||
return arg;
|
||||
});
|
||||
}
|
||||
|
||||
function quoteCommandArg(value: string): string {
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
|
||||
function parseAuth(value: unknown, sourcePath: string): Record<string, string> | undefined {
|
||||
if (value == null) {
|
||||
return undefined;
|
||||
|
||||
@ -5,6 +5,7 @@ import {
|
||||
DEFAULT_AGENT_NAME,
|
||||
resolveAgentCommand as resolveAgentCommandFromRegistry,
|
||||
} from "../agent-registry.js";
|
||||
import type { SystemPromptOption } from "../runtime/engine/session-options.js";
|
||||
import { DEFAULT_QUEUE_OWNER_TTL_MS } from "../session/session.js";
|
||||
import {
|
||||
AUTH_POLICIES,
|
||||
@ -35,6 +36,7 @@ export type GlobalFlags = PermissionFlags & {
|
||||
nonInteractivePermissions: NonInteractivePermissionPolicy;
|
||||
jsonStrict?: boolean;
|
||||
suppressReads?: boolean;
|
||||
terminal?: boolean;
|
||||
timeout?: number;
|
||||
ttl: number;
|
||||
verbose?: boolean;
|
||||
@ -42,6 +44,7 @@ export type GlobalFlags = PermissionFlags & {
|
||||
model?: string;
|
||||
allowedTools?: string[];
|
||||
maxTurns?: number;
|
||||
systemPrompt?: SystemPromptOption;
|
||||
promptRetries?: number;
|
||||
};
|
||||
|
||||
@ -68,6 +71,13 @@ export type StatusFlags = {
|
||||
session?: string;
|
||||
};
|
||||
|
||||
export type SessionsPruneFlags = {
|
||||
dryRun?: boolean;
|
||||
before?: Date;
|
||||
olderThan?: number;
|
||||
includeHistory?: boolean;
|
||||
};
|
||||
|
||||
export function parseOutputFormat(value: string): OutputFormat {
|
||||
if (!OUTPUT_FORMATS.includes(value as OutputFormat)) {
|
||||
throw new InvalidArgumentError(
|
||||
@ -135,6 +145,24 @@ export function parseHistoryLimit(value: string): number {
|
||||
return parsed;
|
||||
}
|
||||
|
||||
export function parseDaysOlderThan(value: string): number {
|
||||
const parsed = Number(value);
|
||||
if (!Number.isInteger(parsed) || parsed <= 0) {
|
||||
throw new InvalidArgumentError("--older-than must be a positive integer number of days");
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
export function parsePruneBeforeDate(value: string): Date {
|
||||
const date = new Date(value);
|
||||
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)`,
|
||||
);
|
||||
}
|
||||
return date;
|
||||
}
|
||||
|
||||
export function parseAllowedTools(value: string): string[] {
|
||||
const trimmed = value.trim();
|
||||
if (trimmed.length === 0) {
|
||||
@ -159,6 +187,31 @@ export function parseMaxTurns(value: string): number {
|
||||
return parsed;
|
||||
}
|
||||
|
||||
export function resolveSystemPromptFlag(opts: {
|
||||
systemPrompt?: unknown;
|
||||
appendSystemPrompt?: unknown;
|
||||
}): SystemPromptOption | undefined {
|
||||
const replace =
|
||||
typeof opts.systemPrompt === "string" && opts.systemPrompt.length > 0
|
||||
? opts.systemPrompt
|
||||
: undefined;
|
||||
const append =
|
||||
typeof opts.appendSystemPrompt === "string" && opts.appendSystemPrompt.length > 0
|
||||
? opts.appendSystemPrompt
|
||||
: undefined;
|
||||
|
||||
if (replace !== undefined && append !== undefined) {
|
||||
throw new InvalidArgumentError("Use only one of --system-prompt or --append-system-prompt");
|
||||
}
|
||||
if (replace !== undefined) {
|
||||
return replace;
|
||||
}
|
||||
if (append !== undefined) {
|
||||
return { append };
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function parsePromptRetries(value: string): number {
|
||||
const parsed = Number(value);
|
||||
if (!Number.isInteger(parsed) || parsed < 0) {
|
||||
@ -218,6 +271,16 @@ export function addGlobalFlags(command: Command): Command {
|
||||
parseAllowedTools,
|
||||
)
|
||||
.option("--max-turns <count>", "Maximum turns for the session", parseMaxTurns)
|
||||
.option(
|
||||
"--system-prompt <text>",
|
||||
"Replace the agent system prompt (claude-agent-acp via ACP _meta.systemPrompt)",
|
||||
(value: string) => parseNonEmptyValue("System prompt", value),
|
||||
)
|
||||
.option(
|
||||
"--append-system-prompt <text>",
|
||||
"Append text to the agent system prompt (claude-agent-acp via ACP _meta.systemPrompt.append)",
|
||||
(value: string) => parseNonEmptyValue("Append system prompt", value),
|
||||
)
|
||||
.option(
|
||||
"--prompt-retries <count>",
|
||||
"Retry failed prompt turns on transient errors (default: 0)",
|
||||
@ -227,6 +290,7 @@ export function addGlobalFlags(command: Command): Command {
|
||||
"--json-strict",
|
||||
"Strict JSON mode: requires --format json and suppresses non-JSON stderr output",
|
||||
)
|
||||
.option("--no-terminal", "Do not advertise ACP terminal capability")
|
||||
.option("--timeout <seconds>", "Maximum time to wait for agent response", parseTimeoutSeconds)
|
||||
.option(
|
||||
"--ttl <seconds>",
|
||||
@ -302,6 +366,7 @@ export function resolveGlobalFlags(command: Command, config: ResolvedAcpxConfig)
|
||||
nonInteractivePermissions: opts.nonInteractivePermissions ?? config.nonInteractivePermissions,
|
||||
jsonStrict,
|
||||
suppressReads: opts.suppressReads === true,
|
||||
terminal: opts.terminal === false ? false : undefined,
|
||||
timeout: opts.timeout ?? config.timeoutMs,
|
||||
ttl: opts.ttl ?? config.ttlMs ?? DEFAULT_QUEUE_OWNER_TTL_MS,
|
||||
verbose,
|
||||
@ -309,6 +374,7 @@ export function resolveGlobalFlags(command: Command, config: ResolvedAcpxConfig)
|
||||
model: typeof opts.model === "string" ? parseNonEmptyValue("Model", opts.model) : undefined,
|
||||
allowedTools: Array.isArray(opts.allowedTools) ? opts.allowedTools : undefined,
|
||||
maxTurns: typeof opts.maxTurns === "number" ? opts.maxTurns : undefined,
|
||||
systemPrompt: resolveSystemPromptFlag(opts),
|
||||
promptRetries: typeof opts.promptRetries === "number" ? opts.promptRetries : undefined,
|
||||
approveAll: opts.approveAll ? true : undefined,
|
||||
approveReads: opts.approveReads ? true : undefined,
|
||||
|
||||
@ -31,8 +31,19 @@ type WritableLike = {
|
||||
isTTY?: boolean;
|
||||
};
|
||||
|
||||
type RenderableOutputError = {
|
||||
code: OutputErrorCode;
|
||||
detailCode?: string;
|
||||
origin?: OutputErrorOrigin;
|
||||
message: string;
|
||||
retryable?: boolean;
|
||||
acp?: OutputErrorAcpPayload;
|
||||
timestamp?: string;
|
||||
};
|
||||
|
||||
type OutputFormatterOptions = {
|
||||
stdout?: WritableLike;
|
||||
stderr?: WritableLike;
|
||||
jsonContext?: OutputFormatterContext;
|
||||
suppressReads?: boolean;
|
||||
};
|
||||
@ -192,6 +203,23 @@ function readFirstString(source: Record<string, unknown>, keys: string[]): strin
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function readFirstFiniteNumber(
|
||||
source: Record<string, unknown>,
|
||||
keys: string[],
|
||||
): number | undefined {
|
||||
for (const key of keys) {
|
||||
const value = source[key];
|
||||
if (typeof value === "number" && Number.isFinite(value)) {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function formatMetadataNumber(value: number): string {
|
||||
return Number.isInteger(value) ? String(value) : String(Number(value.toFixed(8)));
|
||||
}
|
||||
|
||||
function readFirstStringArray(
|
||||
source: Record<string, unknown>,
|
||||
keys: string[],
|
||||
@ -211,6 +239,160 @@ function readFirstStringArray(
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function formatDisjunction(values: string[]): string {
|
||||
if (values.length <= 1) {
|
||||
return values[0] ?? "";
|
||||
}
|
||||
if (values.length === 2) {
|
||||
return `${values[0]} or ${values[1]}`;
|
||||
}
|
||||
return `${values.slice(0, -1).join(", ")}, or ${values.at(-1)}`;
|
||||
}
|
||||
|
||||
function parseAuthMethodIdsFromMessage(message: string): string[] {
|
||||
const methods: string[] = [];
|
||||
const methodListMatch = message.match(/auth methods \[([^\]]+)\]/iu);
|
||||
if (methodListMatch) {
|
||||
methods.push(
|
||||
...methodListMatch[1]
|
||||
.split(",")
|
||||
.map((entry) => entry.trim())
|
||||
.filter((entry) => entry.length > 0),
|
||||
);
|
||||
}
|
||||
|
||||
const singleMethodMatch = message.match(/auth method ([\w.-]+)/iu);
|
||||
if (singleMethodMatch) {
|
||||
methods.push(singleMethodMatch[1]);
|
||||
}
|
||||
|
||||
return dedupeStrings(methods);
|
||||
}
|
||||
|
||||
function parseAuthMethodIdsFromAcpData(data: unknown): string[] {
|
||||
const record = asRecord(data);
|
||||
if (!record) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const methodIds: string[] = [];
|
||||
if (typeof record.methodId === "string" && record.methodId.trim().length > 0) {
|
||||
methodIds.push(record.methodId.trim());
|
||||
}
|
||||
|
||||
if (Array.isArray(record.methods)) {
|
||||
for (const entry of record.methods) {
|
||||
if (typeof entry === "string" && entry.trim().length > 0) {
|
||||
methodIds.push(entry.trim());
|
||||
continue;
|
||||
}
|
||||
|
||||
const id = asRecord(entry)?.id;
|
||||
if (typeof id === "string" && id.trim().length > 0) {
|
||||
methodIds.push(id.trim());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return dedupeStrings(methodIds);
|
||||
}
|
||||
|
||||
function renderAuthRequiredHint(params: RenderableOutputError): string {
|
||||
const methodIds = dedupeStrings([
|
||||
...parseAuthMethodIdsFromAcpData(params.acp?.data),
|
||||
...parseAuthMethodIdsFromMessage(params.message),
|
||||
]);
|
||||
|
||||
if (methodIds.length === 0) {
|
||||
return "hint: run `acpx config show` to locate the active config, then add the required credential under `auth` and retry.";
|
||||
}
|
||||
|
||||
const configKeys = methodIds.map((methodId) => `\`auth.${methodId}\``);
|
||||
return `hint: run \`acpx config show\` to locate the active config, then add ${formatDisjunction(configKeys)} and retry.`;
|
||||
}
|
||||
|
||||
export function getTextErrorRemediationHints(params: RenderableOutputError): string[] {
|
||||
const lowerMessage = params.message.toLowerCase();
|
||||
|
||||
if (params.detailCode === "AUTH_REQUIRED") {
|
||||
return [renderAuthRequiredHint(params)];
|
||||
}
|
||||
|
||||
if (params.code === "TIMEOUT") {
|
||||
return [
|
||||
"hint: increase `--timeout <seconds>` for long-running prompts, or check whether the agent/provider is stalled.",
|
||||
];
|
||||
}
|
||||
|
||||
if (params.code === "NO_SESSION") {
|
||||
if (lowerMessage.includes("create one:")) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [
|
||||
"hint: the saved ACP session is missing or stale; start a fresh session with `acpx <agent> sessions new`, then retry.",
|
||||
];
|
||||
}
|
||||
|
||||
if (lowerMessage.includes("does not support session/load")) {
|
||||
return [
|
||||
"hint: this adapter cannot resume saved ACP sessions; create a fresh one with `acpx <agent> sessions new` instead of reusing `--resume-session`.",
|
||||
];
|
||||
}
|
||||
|
||||
if (
|
||||
lowerMessage.includes("failed to resume acp session") ||
|
||||
lowerMessage.includes("session/load")
|
||||
) {
|
||||
return [
|
||||
"hint: rerun with `--verbose` to capture the ACP load failure details.",
|
||||
"hint: if you do not need the old backend session, start a fresh one with `acpx <agent> sessions new` and retry.",
|
||||
];
|
||||
}
|
||||
|
||||
if (
|
||||
/\b429\b/u.test(params.message) ||
|
||||
lowerMessage.includes("rate limit") ||
|
||||
lowerMessage.includes("quota exceeded")
|
||||
) {
|
||||
return [
|
||||
"hint: the provider appears rate-limited; retry later, switch model, or check provider quota/billing.",
|
||||
];
|
||||
}
|
||||
|
||||
if (
|
||||
lowerMessage.includes("model not found") ||
|
||||
lowerMessage.includes("unknown model") ||
|
||||
lowerMessage.includes("invalid model")
|
||||
) {
|
||||
return [
|
||||
"hint: check the configured model name for this agent, then retry with `--model <model>` or `sessions set-model <model>`.",
|
||||
];
|
||||
}
|
||||
|
||||
if (
|
||||
lowerMessage.includes("session/set_mode") ||
|
||||
lowerMessage.includes("session/set_model") ||
|
||||
lowerMessage.includes("session/set_config_option")
|
||||
) {
|
||||
return [
|
||||
"hint: rerun with `--verbose` to capture the ACP method/error details before retrying.",
|
||||
];
|
||||
}
|
||||
|
||||
if (
|
||||
params.origin === "acp" &&
|
||||
params.code === "RUNTIME" &&
|
||||
(params.acp?.code === -32602 ||
|
||||
params.acp?.code === -32603 ||
|
||||
lowerMessage.includes("internal error"))
|
||||
) {
|
||||
return ["hint: rerun with `--verbose` to capture the underlying ACP error details."];
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
function summarizeToolInput(rawInput: unknown): string | undefined {
|
||||
if (rawInput == null) {
|
||||
return undefined;
|
||||
@ -579,18 +761,13 @@ class TextOutputFormatter implements OutputFormatter {
|
||||
this.writeLine(this.dim(`[done] ${stopReason}`));
|
||||
}
|
||||
|
||||
onError(params: {
|
||||
code: OutputErrorCode;
|
||||
detailCode?: string;
|
||||
origin?: OutputErrorOrigin;
|
||||
message: string;
|
||||
retryable?: boolean;
|
||||
acp?: OutputErrorAcpPayload;
|
||||
timestamp?: string;
|
||||
}): void {
|
||||
onError(params: RenderableOutputError): void {
|
||||
this.flushThoughtBuffer();
|
||||
this.beginSection("done");
|
||||
this.writeLine(this.formatAnsi(`[error] ${params.code}: ${params.message}`, "31"));
|
||||
for (const hint of getTextErrorRemediationHints(params)) {
|
||||
this.writeLine(this.dim(hint));
|
||||
}
|
||||
}
|
||||
|
||||
onClientOperation(operation: ClientOperation): void {
|
||||
@ -824,11 +1001,14 @@ class TextOutputFormatter implements OutputFormatter {
|
||||
|
||||
class QuietOutputFormatter implements OutputFormatter {
|
||||
private readonly stdout: WritableLike;
|
||||
private readonly stderr: WritableLike;
|
||||
private chunks: string[] = [];
|
||||
private flushed = false;
|
||||
private metadataFlushed = false;
|
||||
|
||||
constructor(stdout: WritableLike) {
|
||||
constructor(stdout: WritableLike, stderr: WritableLike) {
|
||||
this.stdout = stdout;
|
||||
this.stderr = stderr;
|
||||
}
|
||||
|
||||
setContext(_context: OutputFormatterContext): void {
|
||||
@ -847,6 +1027,7 @@ class QuietOutputFormatter implements OutputFormatter {
|
||||
|
||||
if (parsePromptStopReason(message)) {
|
||||
this.flushBufferedOutput();
|
||||
this.flushMetadata(message);
|
||||
}
|
||||
}
|
||||
|
||||
@ -875,6 +1056,81 @@ class QuietOutputFormatter implements OutputFormatter {
|
||||
const text = this.chunks.join("");
|
||||
this.stdout.write(text.endsWith("\n") ? text : `${text}\n`);
|
||||
}
|
||||
|
||||
private flushMetadata(message: AcpJsonRpcMessage): void {
|
||||
if (this.metadataFlushed) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.metadataFlushed = true;
|
||||
const result = asRecord((message as { result?: unknown }).result);
|
||||
if (!result) {
|
||||
return;
|
||||
}
|
||||
|
||||
const usageLine = this.formatUsageLine(asRecord(result.usage));
|
||||
if (usageLine) {
|
||||
this.stderr.write(`${usageLine}\n`);
|
||||
}
|
||||
|
||||
const costLine = this.formatCostLine(result.cost);
|
||||
if (costLine) {
|
||||
this.stderr.write(`${costLine}\n`);
|
||||
}
|
||||
}
|
||||
|
||||
private formatUsageLine(usage: Record<string, unknown> | undefined): string | undefined {
|
||||
if (!usage) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const parts: string[] = [];
|
||||
const fields: Array<[string, string[]]> = [
|
||||
["input", ["inputTokens", "input_tokens"]],
|
||||
["output", ["outputTokens", "output_tokens"]],
|
||||
["cache_read", ["cachedReadTokens", "cacheReadInputTokens", "cache_read_input_tokens"]],
|
||||
[
|
||||
"cache_write",
|
||||
["cachedWriteTokens", "cacheCreationInputTokens", "cache_creation_input_tokens"],
|
||||
],
|
||||
["total", ["totalTokens", "total_tokens"]],
|
||||
];
|
||||
|
||||
for (const [label, keys] of fields) {
|
||||
const value = readFirstFiniteNumber(usage, keys);
|
||||
if (value !== undefined) {
|
||||
parts.push(`${label}=${formatMetadataNumber(value)}`);
|
||||
}
|
||||
}
|
||||
|
||||
return parts.length > 0 ? `[acpx] tokens: ${parts.join(" ")}` : undefined;
|
||||
}
|
||||
|
||||
private formatCostLine(cost: unknown): string | undefined {
|
||||
if (typeof cost === "number" && Number.isFinite(cost)) {
|
||||
return `[acpx] cost: ${formatMetadataNumber(cost)}`;
|
||||
}
|
||||
|
||||
if (typeof cost === "string" && cost.trim()) {
|
||||
return `[acpx] cost: ${cost.trim()}`;
|
||||
}
|
||||
|
||||
const record = asRecord(cost);
|
||||
if (!record) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const amount = readFirstFiniteNumber(record, ["amount", "value", "total"]);
|
||||
if (amount === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const currency =
|
||||
typeof record.currency === "string" && record.currency.trim()
|
||||
? ` ${record.currency.trim()}`
|
||||
: "";
|
||||
return `[acpx] cost: ${formatMetadataNumber(amount)}${currency}`;
|
||||
}
|
||||
}
|
||||
|
||||
export function createOutputFormatter(
|
||||
@ -882,6 +1138,7 @@ export function createOutputFormatter(
|
||||
options: OutputFormatterOptions = {},
|
||||
): OutputFormatter {
|
||||
const stdout = options.stdout ?? process.stdout;
|
||||
const stderr = options.stderr ?? process.stderr;
|
||||
const suppressReads = options.suppressReads === true;
|
||||
|
||||
switch (format) {
|
||||
@ -890,7 +1147,7 @@ export function createOutputFormatter(
|
||||
case "json":
|
||||
return createJsonOutputFormatter(stdout, suppressReads, options.jsonContext);
|
||||
case "quiet":
|
||||
return new QuietOutputFormatter(stdout);
|
||||
return new QuietOutputFormatter(stdout, stderr);
|
||||
default: {
|
||||
const exhaustive: never = format;
|
||||
void exhaustive;
|
||||
|
||||
@ -205,6 +205,64 @@ export function printCreatedSessionBanner(
|
||||
process.stderr.write(`[acpx] cwd: ${record.cwd}\n`);
|
||||
}
|
||||
|
||||
function formatBytes(bytes: number): string {
|
||||
if (bytes >= 1_073_741_824) {
|
||||
return `${(bytes / 1_073_741_824).toFixed(1)} GB`;
|
||||
}
|
||||
if (bytes >= 1_048_576) {
|
||||
return `${(bytes / 1_048_576).toFixed(1)} MB`;
|
||||
}
|
||||
if (bytes >= 1024) {
|
||||
return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
}
|
||||
return `${bytes} B`;
|
||||
}
|
||||
|
||||
export function printPruneResultByFormat(
|
||||
result: { pruned: SessionRecord[]; bytesFreed: number; dryRun: boolean },
|
||||
format: OutputFormat,
|
||||
): void {
|
||||
const count = result.pruned.length;
|
||||
|
||||
if (
|
||||
emitJsonResult(format, {
|
||||
action: result.dryRun ? "sessions_prune_dry_run" : "sessions_pruned",
|
||||
dryRun: result.dryRun,
|
||||
count,
|
||||
bytesFreed: result.bytesFreed,
|
||||
pruned: result.pruned.map((r) => r.acpxRecordId),
|
||||
})
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (format === "quiet") {
|
||||
for (const record of result.pruned) {
|
||||
process.stdout.write(`${record.acpxRecordId}\n`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (count === 0) {
|
||||
process.stdout.write(
|
||||
result.dryRun ? "[DRY RUN] No sessions to prune\n" : "No sessions pruned\n",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const prefix = result.dryRun ? "[DRY RUN] Would prune" : "Pruned";
|
||||
const bytesSuffix =
|
||||
!result.dryRun && result.bytesFreed > 0 ? `, freed ${formatBytes(result.bytesFreed)}` : "";
|
||||
process.stdout.write(`${prefix} ${count} session${count === 1 ? "" : "s"}${bytesSuffix}\n`);
|
||||
|
||||
for (const record of result.pruned) {
|
||||
const label = record.name ? ` (${record.name})` : "";
|
||||
process.stdout.write(
|
||||
` ${record.acpxRecordId}${label}\t${record.closedAt ?? record.lastUsedAt}\n`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function agentSessionIdPayload(agentSessionId: string | undefined): {
|
||||
agentSessionId?: string;
|
||||
} {
|
||||
|
||||
@ -4,6 +4,7 @@ import { normalizeOutputError } from "../../acp/error-normalization.js";
|
||||
import { recordPerfDuration } from "../../perf-metrics.js";
|
||||
import { textPrompt } from "../../prompt-content.js";
|
||||
import type {
|
||||
AcpClientOptions,
|
||||
NonInteractivePermissionPolicy,
|
||||
PermissionMode,
|
||||
PromptInput,
|
||||
@ -83,6 +84,7 @@ export type QueueTask = {
|
||||
nonInteractivePermissions?: NonInteractivePermissionPolicy;
|
||||
timeoutMs?: number;
|
||||
suppressSdkConsoleErrors?: boolean;
|
||||
sessionOptions?: NonNullable<AcpClientOptions["sessionOptions"]>;
|
||||
waitForCompletion: boolean;
|
||||
enqueuedAt: number;
|
||||
send: (message: QueueOwnerMessage) => void;
|
||||
@ -91,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: (
|
||||
@ -393,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,
|
||||
@ -451,6 +467,7 @@ export class SessionQueueOwner {
|
||||
nonInteractivePermissions: request.nonInteractivePermissions,
|
||||
timeoutMs: request.timeoutMs,
|
||||
suppressSdkConsoleErrors: request.suppressSdkConsoleErrors,
|
||||
sessionOptions: request.sessionOptions,
|
||||
waitForCompletion: request.waitForCompletion,
|
||||
enqueuedAt: Date.now(),
|
||||
send: (message) => {
|
||||
|
||||
@ -3,6 +3,7 @@ import type { SetSessionConfigOptionResponse } from "@agentclientprotocol/sdk";
|
||||
import { QueueConnectionError, QueueProtocolError } from "../../errors.js";
|
||||
import { incrementPerfCounter } from "../../perf-metrics.js";
|
||||
import type {
|
||||
AcpClientOptions,
|
||||
NonInteractivePermissionPolicy,
|
||||
OutputErrorEmissionPolicy,
|
||||
OutputFormatter,
|
||||
@ -22,7 +23,9 @@ import {
|
||||
import {
|
||||
parseQueueOwnerMessage,
|
||||
type QueueCancelRequest,
|
||||
type QueueCloseSessionRequest,
|
||||
type QueueOwnerCancelResultMessage,
|
||||
type QueueOwnerCloseSessionResultMessage,
|
||||
type QueueOwnerMessage,
|
||||
type QueueOwnerSetConfigOptionResultMessage,
|
||||
type QueueOwnerSetModelResultMessage,
|
||||
@ -269,6 +272,7 @@ export type SubmitToQueueOwnerOptions = {
|
||||
suppressSdkConsoleErrors?: boolean;
|
||||
waitForCompletion: boolean;
|
||||
verbose?: boolean;
|
||||
sessionOptions?: NonNullable<AcpClientOptions["sessionOptions"]>;
|
||||
};
|
||||
|
||||
async function submitToQueueOwner(
|
||||
@ -288,6 +292,7 @@ async function submitToQueueOwner(
|
||||
timeoutMs: options.timeoutMs,
|
||||
suppressSdkConsoleErrors: options.suppressSdkConsoleErrors,
|
||||
waitForCompletion: options.waitForCompletion,
|
||||
sessionOptions: options.sessionOptions,
|
||||
};
|
||||
|
||||
options.outputFormatter.setContext({
|
||||
@ -593,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> {
|
||||
@ -640,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,3 +1,4 @@
|
||||
import { randomInt } from "node:crypto";
|
||||
import fs from "node:fs/promises";
|
||||
import { queueBaseDir, queueLockFilePath, queueSocketBaseDir, queueSocketPath } from "./paths.js";
|
||||
|
||||
@ -66,7 +67,7 @@ function parseQueueOwnerRecord(raw: unknown): QueueOwnerRecord | null {
|
||||
}
|
||||
|
||||
function createOwnerGeneration(): number {
|
||||
return Date.now() * 1_000 + Math.floor(Math.random() * 1_000);
|
||||
return randomInt(1, 2 ** 48);
|
||||
}
|
||||
|
||||
function nowIso(): string {
|
||||
@ -82,10 +83,13 @@ function isQueueOwnerHeartbeatStale(owner: QueueOwnerRecord): boolean {
|
||||
}
|
||||
|
||||
async function ensureQueueDir(): Promise<void> {
|
||||
await fs.mkdir(queueBaseDir(), { recursive: true });
|
||||
const baseDir = queueBaseDir();
|
||||
await fs.mkdir(baseDir, { recursive: true, mode: 0o700 });
|
||||
await fs.chmod(baseDir, 0o700);
|
||||
const socketDir = queueSocketBaseDir();
|
||||
if (socketDir) {
|
||||
await fs.mkdir(socketDir, { recursive: true });
|
||||
await fs.mkdir(socketDir, { recursive: true, mode: 0o700 });
|
||||
await fs.chmod(socketDir, 0o700);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -1,9 +1,11 @@
|
||||
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 {
|
||||
OUTPUT_ERROR_CODES,
|
||||
OUTPUT_ERROR_ORIGINS,
|
||||
type AcpClientOptions,
|
||||
type OutputErrorAcpPayload,
|
||||
type OutputErrorCode,
|
||||
type OutputErrorOrigin,
|
||||
@ -17,6 +19,8 @@ import type {
|
||||
SessionSendResult,
|
||||
} from "../../types.js";
|
||||
|
||||
type QueueSessionOptions = NonNullable<AcpClientOptions["sessionOptions"]>;
|
||||
|
||||
export type QueueSubmitRequest = {
|
||||
type: "submit_prompt";
|
||||
requestId: string;
|
||||
@ -29,6 +33,7 @@ export type QueueSubmitRequest = {
|
||||
timeoutMs?: number;
|
||||
suppressSdkConsoleErrors?: boolean;
|
||||
waitForCompletion: boolean;
|
||||
sessionOptions?: QueueSessionOptions;
|
||||
};
|
||||
|
||||
export type QueueCancelRequest = {
|
||||
@ -62,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";
|
||||
@ -117,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;
|
||||
@ -138,6 +158,7 @@ export type QueueOwnerMessage =
|
||||
| QueueOwnerSetModeResultMessage
|
||||
| QueueOwnerSetModelResultMessage
|
||||
| QueueOwnerSetConfigOptionResultMessage
|
||||
| QueueOwnerCloseSessionResultMessage
|
||||
| QueueOwnerErrorMessage;
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> | undefined {
|
||||
@ -167,23 +188,53 @@ function isOutputErrorOrigin(value: unknown): value is OutputErrorOrigin {
|
||||
return typeof value === "string" && OUTPUT_ERROR_ORIGINS.includes(value as OutputErrorOrigin);
|
||||
}
|
||||
|
||||
function parseAcpError(value: unknown): OutputErrorAcpPayload | undefined {
|
||||
function parseSessionOptions(value: unknown): QueueSessionOptions | null | undefined {
|
||||
if (value == null) {
|
||||
return 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 null;
|
||||
}
|
||||
|
||||
return {
|
||||
code: record.code,
|
||||
message: record.message,
|
||||
data: record.data,
|
||||
};
|
||||
const sessionOptions: QueueSessionOptions = {};
|
||||
if (record.model != null) {
|
||||
if (typeof record.model !== "string" || record.model.trim().length === 0) {
|
||||
return null;
|
||||
}
|
||||
sessionOptions.model = record.model;
|
||||
}
|
||||
if (record.allowedTools != null) {
|
||||
if (!Array.isArray(record.allowedTools)) {
|
||||
return null;
|
||||
}
|
||||
const allowedTools = record.allowedTools.filter(
|
||||
(tool): tool is string => typeof tool === "string",
|
||||
);
|
||||
if (allowedTools.length !== record.allowedTools.length) {
|
||||
return null;
|
||||
}
|
||||
sessionOptions.allowedTools = allowedTools;
|
||||
}
|
||||
if (record.maxTurns != null) {
|
||||
if (typeof record.maxTurns !== "number" || !Number.isFinite(record.maxTurns)) {
|
||||
return null;
|
||||
}
|
||||
sessionOptions.maxTurns = Math.max(1, Math.round(record.maxTurns));
|
||||
}
|
||||
if (record.systemPrompt != null) {
|
||||
if (typeof record.systemPrompt === "string") {
|
||||
sessionOptions.systemPrompt = record.systemPrompt;
|
||||
} else {
|
||||
const systemPrompt = asRecord(record.systemPrompt);
|
||||
if (!systemPrompt || typeof systemPrompt.append !== "string") {
|
||||
return null;
|
||||
}
|
||||
sessionOptions.systemPrompt = { append: systemPrompt.append };
|
||||
}
|
||||
}
|
||||
|
||||
return sessionOptions;
|
||||
}
|
||||
|
||||
function parseOwnerGeneration(value: unknown): number | undefined | null {
|
||||
@ -235,6 +286,7 @@ export function parseQueueRequest(raw: unknown): QueueRequest | null {
|
||||
: typeof request.suppressSdkConsoleErrors === "boolean"
|
||||
? request.suppressSdkConsoleErrors
|
||||
: null;
|
||||
const sessionOptions = parseSessionOptions(request.sessionOptions);
|
||||
|
||||
const prompt =
|
||||
request.prompt == null ? undefined : isPromptInput(request.prompt) ? request.prompt : null;
|
||||
@ -245,6 +297,7 @@ export function parseQueueRequest(raw: unknown): QueueRequest | null {
|
||||
prompt === null ||
|
||||
nonInteractivePermissions === null ||
|
||||
suppressSdkConsoleErrors === null ||
|
||||
sessionOptions === null ||
|
||||
typeof request.waitForCompletion !== "boolean"
|
||||
) {
|
||||
return null;
|
||||
@ -262,6 +315,7 @@ export function parseQueueRequest(raw: unknown): QueueRequest | null {
|
||||
timeoutMs,
|
||||
...(suppressSdkConsoleErrors !== undefined ? { suppressSdkConsoleErrors } : {}),
|
||||
waitForCompletion: request.waitForCompletion,
|
||||
...(sessionOptions !== undefined ? { sessionOptions } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
@ -273,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;
|
||||
@ -481,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;
|
||||
|
||||
|
||||
@ -59,6 +59,10 @@ export function parseQueueOwnerPayload(raw: string): QueueOwnerRuntimeOptions {
|
||||
options.authPolicy = record.authPolicy;
|
||||
}
|
||||
|
||||
if (typeof record.terminal === "boolean") {
|
||||
options.terminal = record.terminal;
|
||||
}
|
||||
|
||||
if (typeof record.suppressSdkConsoleErrors === "boolean") {
|
||||
options.suppressSdkConsoleErrors = record.suppressSdkConsoleErrors;
|
||||
}
|
||||
@ -79,6 +83,30 @@ export function parseQueueOwnerPayload(raw: string): QueueOwnerRuntimeOptions {
|
||||
options.promptRetries = Math.max(0, Math.round(record.promptRetries));
|
||||
}
|
||||
|
||||
const sessionOpts = asRecord(record.sessionOptions);
|
||||
if (sessionOpts) {
|
||||
options.sessionOptions = {};
|
||||
if (typeof sessionOpts.model === "string" && sessionOpts.model.trim().length > 0) {
|
||||
options.sessionOptions.model = sessionOpts.model;
|
||||
}
|
||||
if (Array.isArray(sessionOpts.allowedTools)) {
|
||||
options.sessionOptions.allowedTools = sessionOpts.allowedTools.filter(
|
||||
(tool): tool is string => typeof tool === "string",
|
||||
);
|
||||
}
|
||||
if (typeof sessionOpts.maxTurns === "number" && Number.isFinite(sessionOpts.maxTurns)) {
|
||||
options.sessionOptions.maxTurns = Math.max(1, Math.round(sessionOpts.maxTurns));
|
||||
}
|
||||
if (typeof sessionOpts.systemPrompt === "string") {
|
||||
options.sessionOptions.systemPrompt = sessionOpts.systemPrompt;
|
||||
} else {
|
||||
const systemPrompt = asRecord(sessionOpts.systemPrompt);
|
||||
if (typeof systemPrompt?.append === "string") {
|
||||
options.sessionOptions.systemPrompt = { append: systemPrompt.append };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return options;
|
||||
}
|
||||
|
||||
|
||||
@ -44,6 +44,7 @@ export type RunOnceOptions = {
|
||||
nonInteractivePermissions?: NonInteractivePermissionPolicy;
|
||||
authCredentials?: Record<string, string>;
|
||||
authPolicy?: AuthPolicy;
|
||||
terminal?: boolean;
|
||||
outputFormatter: OutputFormatter;
|
||||
onAcpMessage?: (direction: AcpMessageDirection, message: AcpJsonRpcMessage) => void;
|
||||
onSessionUpdate?: (notification: SessionNotification) => void;
|
||||
@ -64,6 +65,7 @@ export type SessionCreateOptions = {
|
||||
nonInteractivePermissions?: NonInteractivePermissionPolicy;
|
||||
authCredentials?: Record<string, string>;
|
||||
authPolicy?: AuthPolicy;
|
||||
terminal?: boolean;
|
||||
verbose?: boolean;
|
||||
sessionOptions?: SessionAgentOptions;
|
||||
} & TimedRunOptions;
|
||||
@ -77,6 +79,7 @@ export type SessionSendOptions = {
|
||||
nonInteractivePermissions?: NonInteractivePermissionPolicy;
|
||||
authCredentials?: Record<string, string>;
|
||||
authPolicy?: AuthPolicy;
|
||||
terminal?: boolean;
|
||||
outputFormatter: OutputFormatter;
|
||||
onAcpMessage?: (direction: AcpMessageDirection, message: AcpJsonRpcMessage) => void;
|
||||
onSessionUpdate?: (notification: SessionNotification) => void;
|
||||
@ -89,6 +92,7 @@ export type SessionSendOptions = {
|
||||
maxQueueDepth?: number;
|
||||
client?: AcpClient;
|
||||
promptRetries?: number;
|
||||
sessionOptions?: SessionAgentOptions;
|
||||
} & TimedRunOptions;
|
||||
|
||||
export type SessionEnsureOptions = {
|
||||
@ -101,6 +105,7 @@ export type SessionEnsureOptions = {
|
||||
nonInteractivePermissions?: NonInteractivePermissionPolicy;
|
||||
authCredentials?: Record<string, string>;
|
||||
authPolicy?: AuthPolicy;
|
||||
terminal?: boolean;
|
||||
verbose?: boolean;
|
||||
walkBoundary?: string;
|
||||
sessionOptions?: SessionAgentOptions;
|
||||
@ -123,6 +128,7 @@ export type SessionSetModeOptions = {
|
||||
nonInteractivePermissions?: NonInteractivePermissionPolicy;
|
||||
authCredentials?: Record<string, string>;
|
||||
authPolicy?: AuthPolicy;
|
||||
terminal?: boolean;
|
||||
verbose?: boolean;
|
||||
} & TimedRunOptions;
|
||||
|
||||
@ -133,6 +139,7 @@ export type SessionSetModelOptions = {
|
||||
nonInteractivePermissions?: NonInteractivePermissionPolicy;
|
||||
authCredentials?: Record<string, string>;
|
||||
authPolicy?: AuthPolicy;
|
||||
terminal?: boolean;
|
||||
verbose?: boolean;
|
||||
} & TimedRunOptions;
|
||||
|
||||
@ -144,6 +151,7 @@ export type SessionSetConfigOptionOptions = {
|
||||
nonInteractivePermissions?: NonInteractivePermissionPolicy;
|
||||
authCredentials?: Record<string, string>;
|
||||
authPolicy?: AuthPolicy;
|
||||
terminal?: boolean;
|
||||
verbose?: boolean;
|
||||
} & TimedRunOptions;
|
||||
|
||||
|
||||
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,9 +2,12 @@ import { withTimeout } from "../../async-control.js";
|
||||
import {
|
||||
withConnectedSession,
|
||||
type FullConnectedSessionController,
|
||||
type WithConnectedSessionOptions,
|
||||
type WithConnectedSessionResult,
|
||||
} from "../../runtime/engine/connected-session.js";
|
||||
import {
|
||||
setCurrentModelId,
|
||||
setDesiredConfigOption,
|
||||
setDesiredModeId,
|
||||
setDesiredModelId,
|
||||
} from "../../session/mode-preference.js";
|
||||
@ -28,6 +31,7 @@ export type RunSessionSetModeDirectOptions = {
|
||||
nonInteractivePermissions?: NonInteractivePermissionPolicy;
|
||||
authCredentials?: Record<string, string>;
|
||||
authPolicy?: AuthPolicy;
|
||||
terminal?: boolean;
|
||||
timeoutMs?: number;
|
||||
verbose?: boolean;
|
||||
onClientAvailable?: (controller: ActiveSessionController) => void;
|
||||
@ -42,6 +46,7 @@ export type RunSessionSetConfigOptionDirectOptions = {
|
||||
nonInteractivePermissions?: NonInteractivePermissionPolicy;
|
||||
authCredentials?: Record<string, string>;
|
||||
authPolicy?: AuthPolicy;
|
||||
terminal?: boolean;
|
||||
timeoutMs?: number;
|
||||
verbose?: boolean;
|
||||
onClientAvailable?: (controller: ActiveSessionController) => void;
|
||||
@ -55,16 +60,31 @@ export type RunSessionSetModelDirectOptions = {
|
||||
nonInteractivePermissions?: NonInteractivePermissionPolicy;
|
||||
authCredentials?: Record<string, string>;
|
||||
authPolicy?: AuthPolicy;
|
||||
terminal?: boolean;
|
||||
timeoutMs?: number;
|
||||
verbose?: boolean;
|
||||
onClientAvailable?: (controller: ActiveSessionController) => void;
|
||||
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,
|
||||
@ -72,18 +92,20 @@ export async function runSessionSetModeDirect(
|
||||
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 }) => {
|
||||
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,
|
||||
@ -91,65 +113,50 @@ 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,
|
||||
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,
|
||||
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,
|
||||
);
|
||||
if (options.configId === "mode") {
|
||||
setDesiredModeId(record, options.value);
|
||||
} else {
|
||||
setDesiredConfigOption(record, options.configId, options.value);
|
||||
}
|
||||
return response;
|
||||
},
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
return {
|
||||
record: result.record,
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import { spawn } from "node:child_process";
|
||||
import { realpathSync } from "node:fs";
|
||||
import type { SessionAgentOptions } from "../../runtime/engine/session-options.js";
|
||||
import type {
|
||||
AuthPolicy,
|
||||
McpServer,
|
||||
@ -14,11 +15,13 @@ export type QueueOwnerRuntimeOptions = {
|
||||
nonInteractivePermissions?: NonInteractivePermissionPolicy;
|
||||
authCredentials?: Record<string, string>;
|
||||
authPolicy?: AuthPolicy;
|
||||
terminal?: boolean;
|
||||
suppressSdkConsoleErrors?: boolean;
|
||||
verbose?: boolean;
|
||||
ttlMs?: number;
|
||||
maxQueueDepth?: number;
|
||||
promptRetries?: number;
|
||||
sessionOptions?: SessionAgentOptions;
|
||||
};
|
||||
|
||||
type SessionSendLike = {
|
||||
@ -28,11 +31,13 @@ type SessionSendLike = {
|
||||
nonInteractivePermissions?: NonInteractivePermissionPolicy;
|
||||
authCredentials?: Record<string, string>;
|
||||
authPolicy?: AuthPolicy;
|
||||
terminal?: boolean;
|
||||
suppressSdkConsoleErrors?: boolean;
|
||||
verbose?: boolean;
|
||||
ttlMs?: number;
|
||||
maxQueueDepth?: number;
|
||||
promptRetries?: number;
|
||||
sessionOptions?: SessionAgentOptions;
|
||||
};
|
||||
|
||||
export function sanitizeQueueOwnerExecArgv(
|
||||
@ -126,11 +131,13 @@ export function queueOwnerRuntimeOptionsFromSend(
|
||||
nonInteractivePermissions: options.nonInteractivePermissions,
|
||||
authCredentials: options.authCredentials,
|
||||
authPolicy: options.authPolicy,
|
||||
terminal: options.terminal,
|
||||
suppressSdkConsoleErrors: options.suppressSdkConsoleErrors,
|
||||
verbose: options.verbose,
|
||||
ttlMs: options.ttlMs,
|
||||
maxQueueDepth: options.maxQueueDepth,
|
||||
promptRetries: options.promptRetries,
|
||||
sessionOptions: options.sessionOptions,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@ -5,7 +5,10 @@ import { checkpointPerfMetricsCapture } from "../../perf-metrics-capture.js";
|
||||
import { setPerfGauge } from "../../perf-metrics.js";
|
||||
import { promptToDisplayText } from "../../prompt-content.js";
|
||||
import { applyLifecycleSnapshotToRecord } from "../../runtime/engine/lifecycle.js";
|
||||
import { sessionOptionsFromRecord } from "../../runtime/engine/session-options.js";
|
||||
import {
|
||||
mergeSessionOptions,
|
||||
sessionOptionsFromRecord,
|
||||
} from "../../runtime/engine/session-options.js";
|
||||
import {
|
||||
absolutePath,
|
||||
resolveSessionRecord,
|
||||
@ -56,6 +59,7 @@ async function submitToRunningOwner(
|
||||
suppressSdkConsoleErrors: options.suppressSdkConsoleErrors,
|
||||
waitForCompletion,
|
||||
verbose: options.verbose,
|
||||
sessionOptions: options.sessionOptions,
|
||||
});
|
||||
}
|
||||
|
||||
@ -76,9 +80,13 @@ export async function runSessionQueueOwner(options: QueueOwnerRuntimeOptions): P
|
||||
nonInteractivePermissions: options.nonInteractivePermissions,
|
||||
authCredentials: options.authCredentials,
|
||||
authPolicy: options.authPolicy,
|
||||
terminal: options.terminal,
|
||||
suppressSdkConsoleErrors: options.suppressSdkConsoleErrors,
|
||||
verbose: options.verbose,
|
||||
sessionOptions: sessionOptionsFromRecord(sessionRecord),
|
||||
sessionOptions: mergeSessionOptions(
|
||||
options.sessionOptions,
|
||||
sessionOptionsFromRecord(sessionRecord),
|
||||
),
|
||||
});
|
||||
const ttlMs = normalizeQueueOwnerTtlMs(options.ttlMs);
|
||||
const maxQueueDepth = Math.max(1, Math.round(options.maxQueueDepth ?? 16));
|
||||
@ -95,6 +103,7 @@ export async function runSessionQueueOwner(options: QueueOwnerRuntimeOptions): P
|
||||
nonInteractivePermissions: options.nonInteractivePermissions,
|
||||
authCredentials: options.authCredentials,
|
||||
authPolicy: options.authPolicy,
|
||||
terminal: options.terminal,
|
||||
timeoutMs,
|
||||
verbose: options.verbose,
|
||||
});
|
||||
@ -107,6 +116,7 @@ export async function runSessionQueueOwner(options: QueueOwnerRuntimeOptions): P
|
||||
nonInteractivePermissions: options.nonInteractivePermissions,
|
||||
authCredentials: options.authCredentials,
|
||||
authPolicy: options.authPolicy,
|
||||
terminal: options.terminal,
|
||||
timeoutMs,
|
||||
verbose: options.verbose,
|
||||
});
|
||||
@ -120,6 +130,7 @@ export async function runSessionQueueOwner(options: QueueOwnerRuntimeOptions): P
|
||||
nonInteractivePermissions: options.nonInteractivePermissions,
|
||||
authCredentials: options.authCredentials,
|
||||
authPolicy: options.authPolicy,
|
||||
terminal: options.terminal,
|
||||
timeoutMs,
|
||||
verbose: options.verbose,
|
||||
});
|
||||
@ -150,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 {
|
||||
@ -171,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);
|
||||
},
|
||||
@ -226,6 +247,7 @@ export async function runSessionQueueOwner(options: QueueOwnerRuntimeOptions): P
|
||||
authPolicy: options.authPolicy,
|
||||
suppressSdkConsoleErrors: options.suppressSdkConsoleErrors,
|
||||
promptRetries: options.promptRetries,
|
||||
sessionOptions: options.sessionOptions,
|
||||
onClientAvailable: setActiveController,
|
||||
onClientClosed: clearActiveController,
|
||||
onPromptActive: async () => {
|
||||
|
||||
@ -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";
|
||||
@ -14,7 +15,11 @@ import {
|
||||
} from "../../runtime/engine/lifecycle.js";
|
||||
import { runPromptTurn } from "../../runtime/engine/prompt-turn.js";
|
||||
import { connectAndLoadSession } from "../../runtime/engine/reconnect.js";
|
||||
import { sessionOptionsFromRecord } from "../../runtime/engine/session-options.js";
|
||||
import {
|
||||
mergeSessionOptions,
|
||||
sessionOptionsFromRecord,
|
||||
type SessionAgentOptions,
|
||||
} from "../../runtime/engine/session-options.js";
|
||||
import {
|
||||
cloneSessionAcpxState,
|
||||
cloneSessionConversation,
|
||||
@ -24,52 +29,41 @@ import {
|
||||
trimConversationForRuntime,
|
||||
} from "../../session/conversation-model.js";
|
||||
import { SessionEventWriter } from "../../session/events.js";
|
||||
import { absolutePath, isoNow, resolveSessionRecord } from "../../session/persistence.js";
|
||||
import { setCurrentModelId, setDesiredModelId } from "../../session/mode-preference.js";
|
||||
import {
|
||||
absolutePath,
|
||||
isoNow,
|
||||
resolveSessionRecord,
|
||||
writeSessionRecord,
|
||||
} from "../../session/persistence.js";
|
||||
import type {
|
||||
AcpJsonRpcMessage,
|
||||
AcpMessageDirection,
|
||||
AuthPolicy,
|
||||
ClientOperation,
|
||||
McpServer,
|
||||
NonInteractivePermissionPolicy,
|
||||
OutputErrorAcpPayload,
|
||||
OutputErrorCode,
|
||||
OutputErrorOrigin,
|
||||
OutputFormatter,
|
||||
PermissionMode,
|
||||
PromptInput,
|
||||
RunPromptResult,
|
||||
SessionNotification,
|
||||
SessionResumePolicy,
|
||||
SessionRecord,
|
||||
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;
|
||||
outputFormatter: OutputFormatter;
|
||||
onAcpMessage?: (direction: AcpMessageDirection, message: AcpJsonRpcMessage) => void;
|
||||
onSessionUpdate?: (notification: SessionNotification) => void;
|
||||
onClientOperation?: (operation: ClientOperation) => void;
|
||||
timeoutMs?: number;
|
||||
suppressSdkConsoleErrors?: boolean;
|
||||
verbose?: boolean;
|
||||
promptRetries?: number;
|
||||
onClientAvailable?: (controller: ActiveSessionController) => void;
|
||||
onClientClosed?: () => void;
|
||||
onPromptActive?: () => Promise<void> | void;
|
||||
client?: AcpClient;
|
||||
};
|
||||
|
||||
type ActiveSessionController = QueueOwnerActiveSessionController;
|
||||
@ -136,27 +130,45 @@ function toPromptResult(
|
||||
};
|
||||
}
|
||||
|
||||
async function applyRequestedModelIfAdvertised(params: {
|
||||
async function applyPromptModelIfAdvertised(params: {
|
||||
client: AcpClient;
|
||||
sessionId: string;
|
||||
requestedModel: string | undefined;
|
||||
models: import("../../acp/client.js").SessionCreateResult["models"];
|
||||
record: SessionRecord;
|
||||
timeoutMs?: number;
|
||||
}): Promise<boolean> {
|
||||
}): Promise<void> {
|
||||
const requestedModel =
|
||||
typeof params.requestedModel === "string" ? params.requestedModel.trim() : "";
|
||||
if (!requestedModel || !params.models) {
|
||||
return false;
|
||||
if (!requestedModel) {
|
||||
return;
|
||||
}
|
||||
if (params.models.currentModelId === requestedModel) {
|
||||
return true;
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
await withTimeout(
|
||||
params.client.setSessionModel(params.sessionId, requestedModel),
|
||||
params.timeoutMs,
|
||||
);
|
||||
return true;
|
||||
setDesiredModelId(params.record, requestedModel);
|
||||
setCurrentModelId(params.record, requestedModel);
|
||||
}
|
||||
|
||||
function jsonRpcIdKey(value: unknown): string | undefined {
|
||||
@ -274,6 +286,7 @@ export async function runQueuedTask(
|
||||
authPolicy?: AuthPolicy;
|
||||
suppressSdkConsoleErrors?: boolean;
|
||||
promptRetries?: number;
|
||||
sessionOptions?: SessionAgentOptions;
|
||||
onClientAvailable?: (controller: ActiveSessionController) => void;
|
||||
onClientClosed?: () => void;
|
||||
onPromptActive?: () => Promise<void> | void;
|
||||
@ -299,6 +312,7 @@ export async function runQueuedTask(
|
||||
suppressSdkConsoleErrors: task.suppressSdkConsoleErrors ?? options.suppressSdkConsoleErrors,
|
||||
verbose: options.verbose,
|
||||
promptRetries: options.promptRetries,
|
||||
sessionOptions: mergeSessionOptions(task.sessionOptions, options.sessionOptions),
|
||||
onClientAvailable: options.onClientAvailable,
|
||||
onClientClosed: options.onClientClosed,
|
||||
onPromptActive: options.onPromptActive,
|
||||
@ -349,7 +363,13 @@ async function runSessionPrompt(options: RunSessionPromptOptions): Promise<Sessi
|
||||
});
|
||||
const conversation = cloneSessionConversation(record);
|
||||
let acpxState = cloneSessionAcpxState(record.acpx);
|
||||
const promptMessageId = recordPromptSubmission(conversation, options.prompt, isoNow());
|
||||
const promptStartedAt = isoNow();
|
||||
const promptMessageId = recordPromptSubmission(conversation, options.prompt, promptStartedAt);
|
||||
record.lastPromptAt = promptStartedAt;
|
||||
record.lastUsedAt = promptStartedAt;
|
||||
applyConversation(record, conversation);
|
||||
record.acpx = acpxState;
|
||||
await writeSessionRecord(record);
|
||||
|
||||
output.setContext({
|
||||
sessionId: record.acpxRecordId,
|
||||
@ -360,6 +380,10 @@ async function runSessionPrompt(options: RunSessionPromptOptions): Promise<Sessi
|
||||
});
|
||||
const pendingMessages: AcpJsonRpcMessage[] = [];
|
||||
const pendingConnectOutputMessages: AcpJsonRpcMessage[] = [];
|
||||
const sessionOptions = mergeSessionOptions(
|
||||
options.sessionOptions,
|
||||
sessionOptionsFromRecord(record),
|
||||
);
|
||||
let bufferingConnectOutput = true;
|
||||
let promptTurnActive = false;
|
||||
let promptTurnHadSideEffects = false;
|
||||
@ -379,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 });
|
||||
});
|
||||
@ -396,13 +420,15 @@ async function runSessionPrompt(options: RunSessionPromptOptions): Promise<Sessi
|
||||
nonInteractivePermissions: options.nonInteractivePermissions,
|
||||
authCredentials: options.authCredentials,
|
||||
authPolicy: options.authPolicy,
|
||||
terminal: options.terminal,
|
||||
suppressSdkConsoleErrors: options.suppressSdkConsoleErrors,
|
||||
verbose: options.verbose,
|
||||
sessionOptions: sessionOptionsFromRecord(record),
|
||||
sessionOptions,
|
||||
});
|
||||
client.updateRuntimeOptions({
|
||||
permissionMode: options.permissionMode,
|
||||
nonInteractivePermissions: options.nonInteractivePermissions,
|
||||
terminal: options.terminal,
|
||||
suppressSdkConsoleErrors: options.suppressSdkConsoleErrors,
|
||||
verbose: options.verbose,
|
||||
});
|
||||
@ -504,6 +530,14 @@ async function runSessionPrompt(options: RunSessionPromptOptions): Promise<Sessi
|
||||
);
|
||||
}
|
||||
|
||||
await applyPromptModelIfAdvertised({
|
||||
client,
|
||||
sessionId: activeSessionId,
|
||||
requestedModel: sessionOptions?.model,
|
||||
record,
|
||||
timeoutMs: options.timeoutMs,
|
||||
});
|
||||
|
||||
output.setContext({
|
||||
sessionId: record.acpxRecordId,
|
||||
});
|
||||
@ -682,6 +716,7 @@ export async function runOnce(options: RunOnceOptions): Promise<RunPromptResult>
|
||||
nonInteractivePermissions: options.nonInteractivePermissions,
|
||||
authCredentials: options.authCredentials,
|
||||
authPolicy: options.authPolicy,
|
||||
terminal: options.terminal,
|
||||
suppressSdkConsoleErrors: options.suppressSdkConsoleErrors,
|
||||
verbose: options.verbose,
|
||||
onAcpMessage: options.onAcpMessage,
|
||||
@ -719,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,
|
||||
});
|
||||
|
||||
@ -782,6 +818,7 @@ export async function sendSessionDirect(options: SessionSendOptions): Promise<Se
|
||||
nonInteractivePermissions: options.nonInteractivePermissions,
|
||||
authCredentials: options.authCredentials,
|
||||
authPolicy: options.authPolicy,
|
||||
terminal: options.terminal,
|
||||
outputFormatter: options.outputFormatter,
|
||||
onAcpMessage: options.onAcpMessage,
|
||||
onSessionUpdate: options.onSessionUpdate,
|
||||
|
||||
@ -2,6 +2,7 @@ import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import {
|
||||
setCurrentModelId,
|
||||
setDesiredConfigOption,
|
||||
setDesiredModeId,
|
||||
setDesiredModelId,
|
||||
} from "../../session/mode-preference.js";
|
||||
@ -17,6 +18,7 @@ import {
|
||||
terminateProcess,
|
||||
terminateQueueOwnerForSession,
|
||||
tryCancelOnRunningOwner,
|
||||
tryCloseSessionOnRunningOwner,
|
||||
trySetConfigOptionOnRunningOwner,
|
||||
trySetModelOnRunningOwner,
|
||||
trySetModeOnRunningOwner,
|
||||
@ -70,6 +72,7 @@ export async function setSessionMode(
|
||||
nonInteractivePermissions: options.nonInteractivePermissions,
|
||||
authCredentials: options.authCredentials,
|
||||
authPolicy: options.authPolicy,
|
||||
terminal: options.terminal,
|
||||
timeoutMs: options.timeoutMs,
|
||||
verbose: options.verbose,
|
||||
});
|
||||
@ -102,6 +105,7 @@ export async function setSessionModel(
|
||||
nonInteractivePermissions: options.nonInteractivePermissions,
|
||||
authCredentials: options.authCredentials,
|
||||
authPolicy: options.authPolicy,
|
||||
terminal: options.terminal,
|
||||
timeoutMs: options.timeoutMs,
|
||||
verbose: options.verbose,
|
||||
});
|
||||
@ -121,8 +125,10 @@ export async function setSessionConfigOption(
|
||||
const record = await resolveSessionRecord(options.sessionId);
|
||||
if (options.configId === "mode") {
|
||||
setDesiredModeId(record, options.value);
|
||||
await writeSessionRecord(record);
|
||||
} else {
|
||||
setDesiredConfigOption(record, options.configId, options.value);
|
||||
}
|
||||
await writeSessionRecord(record);
|
||||
return {
|
||||
record,
|
||||
response: ownerResponse,
|
||||
@ -138,6 +144,7 @@ export async function setSessionConfigOption(
|
||||
nonInteractivePermissions: options.nonInteractivePermissions,
|
||||
authCredentials: options.authCredentials,
|
||||
authPolicy: options.authPolicy,
|
||||
terminal: options.terminal,
|
||||
timeoutMs: options.timeoutMs,
|
||||
verbose: options.verbose,
|
||||
});
|
||||
@ -181,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,25 +22,39 @@ import type {
|
||||
SessionCreateWithClientResult,
|
||||
SessionEnsureOptions,
|
||||
} from "./contracts.js";
|
||||
import { applyRequestedModelIfAdvertised } from "./model-helpers.js";
|
||||
import { setSessionModel } from "./session-control.js";
|
||||
|
||||
function persistSessionOptions(
|
||||
record: SessionRecord,
|
||||
options: SessionAgentOptions | undefined,
|
||||
): void {
|
||||
const systemPromptOption = options?.systemPrompt;
|
||||
const normalizedSystemPrompt =
|
||||
typeof systemPromptOption === "string" && systemPromptOption.length > 0
|
||||
? systemPromptOption
|
||||
: systemPromptOption &&
|
||||
typeof systemPromptOption === "object" &&
|
||||
typeof systemPromptOption.append === "string" &&
|
||||
systemPromptOption.append.length > 0
|
||||
? { append: systemPromptOption.append }
|
||||
: undefined;
|
||||
|
||||
const next =
|
||||
options &&
|
||||
({
|
||||
model: typeof options.model === "string" ? options.model : undefined,
|
||||
allowed_tools: Array.isArray(options.allowedTools) ? [...options.allowedTools] : undefined,
|
||||
max_turns: typeof options.maxTurns === "number" ? options.maxTurns : undefined,
|
||||
system_prompt: normalizedSystemPrompt,
|
||||
} satisfies NonNullable<NonNullable<SessionRecord["acpx"]>["session_options"]>);
|
||||
|
||||
const hasValues = Boolean(
|
||||
next &&
|
||||
((typeof next.model === "string" && next.model.trim().length > 0) ||
|
||||
(Array.isArray(next.allowed_tools) && next.allowed_tools.length > 0) ||
|
||||
typeof next.max_turns === "number"),
|
||||
typeof next.max_turns === "number" ||
|
||||
next.system_prompt !== undefined),
|
||||
);
|
||||
|
||||
if (hasValues && next) {
|
||||
@ -57,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,
|
||||
@ -88,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;
|
||||
|
||||
@ -105,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) {
|
||||
@ -125,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,
|
||||
});
|
||||
}
|
||||
@ -161,6 +158,7 @@ async function createSessionRecordWithClient(
|
||||
};
|
||||
|
||||
persistSessionOptions(record, options.sessionOptions);
|
||||
applyConfigOptionsToRecord(record, sessionResult);
|
||||
syncAdvertisedModelState(record, sessionModels);
|
||||
if (requestedModelApplied) {
|
||||
setCurrentModelId(record, options.sessionOptions?.model);
|
||||
@ -181,6 +179,7 @@ export async function createSessionWithClient(
|
||||
nonInteractivePermissions: options.nonInteractivePermissions,
|
||||
authCredentials: options.authCredentials,
|
||||
authPolicy: options.authPolicy,
|
||||
terminal: options.terminal,
|
||||
verbose: options.verbose,
|
||||
sessionOptions: options.sessionOptions,
|
||||
});
|
||||
@ -232,6 +231,7 @@ export async function ensureSession(options: SessionEnsureOptions): Promise<Sess
|
||||
nonInteractivePermissions: options.nonInteractivePermissions,
|
||||
authCredentials: options.authCredentials,
|
||||
authPolicy: options.authPolicy,
|
||||
terminal: options.terminal,
|
||||
timeoutMs: options.timeoutMs,
|
||||
verbose: options.verbose,
|
||||
});
|
||||
@ -253,6 +253,7 @@ export async function ensureSession(options: SessionEnsureOptions): Promise<Sess
|
||||
nonInteractivePermissions: options.nonInteractivePermissions,
|
||||
authCredentials: options.authCredentials,
|
||||
authPolicy: options.authPolicy,
|
||||
terminal: options.terminal,
|
||||
timeoutMs: options.timeoutMs,
|
||||
verbose: options.verbose,
|
||||
sessionOptions: options.sessionOptions,
|
||||
|
||||
@ -12,6 +12,8 @@ import { emitJsonResult } from "./output/json-output.js";
|
||||
import { agentSessionIdPayload } from "./output/render.js";
|
||||
import { probeQueueOwnerHealth } from "./queue/ipc.js";
|
||||
|
||||
type SessionStatusState = "running" | "idle" | "dead";
|
||||
|
||||
function formatUptime(startedAt: string | undefined): string | undefined {
|
||||
if (!startedAt) {
|
||||
return undefined;
|
||||
@ -32,6 +34,37 @@ function formatUptime(startedAt: string | undefined): string | undefined {
|
||||
.padStart(2, "0")}:${remSeconds.toString().padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
function resolveStatusState(
|
||||
record: { lastAgentExitCode?: number | null; lastAgentExitSignal?: NodeJS.Signals | null },
|
||||
health: Awaited<ReturnType<typeof probeQueueOwnerHealth>>,
|
||||
): SessionStatusState {
|
||||
if (health.healthy) {
|
||||
return "running";
|
||||
}
|
||||
|
||||
if (health.hasLease) {
|
||||
return "dead";
|
||||
}
|
||||
|
||||
if (record.lastAgentExitSignal || (record.lastAgentExitCode ?? 0) !== 0) {
|
||||
return "dead";
|
||||
}
|
||||
|
||||
return "idle";
|
||||
}
|
||||
|
||||
function statusSummary(state: SessionStatusState): string {
|
||||
switch (state) {
|
||||
case "running":
|
||||
return "queue owner healthy";
|
||||
case "idle":
|
||||
return "session idle; queue owner will start on next prompt";
|
||||
case "dead":
|
||||
return "queue owner unavailable";
|
||||
}
|
||||
return "queue owner unavailable";
|
||||
}
|
||||
|
||||
export async function handleStatus(
|
||||
explicitAgentName: string | undefined,
|
||||
flags: StatusFlags,
|
||||
@ -74,12 +107,14 @@ export async function handleStatus(
|
||||
}
|
||||
|
||||
const health = await probeQueueOwnerHealth(record.acpxRecordId);
|
||||
const running = health.healthy;
|
||||
const statusState = resolveStatusState(record, health);
|
||||
const running = statusState === "running";
|
||||
const dead = statusState === "dead";
|
||||
const payload = {
|
||||
sessionId: record.acpxRecordId,
|
||||
agentCommand: record.agentCommand,
|
||||
pid: health.pid ?? record.pid ?? null,
|
||||
status: running ? "running" : "dead",
|
||||
status: statusState,
|
||||
model: record.acpx?.current_model_id ?? null,
|
||||
mode: record.acpx?.current_mode_id ?? null,
|
||||
availableModels: record.acpx?.available_models ?? null,
|
||||
@ -93,16 +128,16 @@ export async function handleStatus(
|
||||
if (
|
||||
emitJsonResult(globalFlags.format, {
|
||||
action: "status_snapshot",
|
||||
status: running ? "alive" : "dead",
|
||||
status: running ? "alive" : statusState,
|
||||
pid: payload.pid ?? undefined,
|
||||
summary: running ? "queue owner healthy" : "queue owner unavailable",
|
||||
summary: statusSummary(statusState),
|
||||
model: payload.model ?? undefined,
|
||||
mode: payload.mode ?? undefined,
|
||||
availableModels: payload.availableModels ?? undefined,
|
||||
uptime: payload.uptime ?? undefined,
|
||||
lastPromptTime: payload.lastPromptTime ?? undefined,
|
||||
exitCode: payload.exitCode ?? undefined,
|
||||
signal: payload.signal ?? undefined,
|
||||
exitCode: dead ? (payload.exitCode ?? undefined) : undefined,
|
||||
signal: dead ? (payload.signal ?? undefined) : undefined,
|
||||
acpxRecordId: record.acpxRecordId,
|
||||
acpxSessionId: record.acpSessionId,
|
||||
agentSessionId: record.agentSessionId,
|
||||
@ -127,7 +162,7 @@ export async function handleStatus(
|
||||
process.stdout.write(`mode: ${payload.mode ?? "-"}\n`);
|
||||
process.stdout.write(`uptime: ${payload.uptime ?? "-"}\n`);
|
||||
process.stdout.write(`lastPromptTime: ${payload.lastPromptTime ?? "-"}\n`);
|
||||
if (payload.status === "dead") {
|
||||
if (dead) {
|
||||
process.stdout.write(`exitCode: ${payload.exitCode ?? "-"}\n`);
|
||||
process.stdout.write(`signal: ${payload.signal ?? "-"}\n`);
|
||||
}
|
||||
|
||||
@ -153,6 +153,17 @@ export class SessionModelReplayError extends AcpxOperationalError {
|
||||
}
|
||||
}
|
||||
|
||||
export class SessionConfigOptionReplayError extends AcpxOperationalError {
|
||||
constructor(message: string, options?: AcpxErrorOptions) {
|
||||
super(message, {
|
||||
outputCode: "RUNTIME",
|
||||
detailCode: "SESSION_CONFIG_OPTION_REPLAY_FAILED",
|
||||
origin: "acp",
|
||||
...options,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export class ClaudeAcpSessionCreateTimeoutError extends AcpxOperationalError {
|
||||
constructor(message: string, options?: AcpxErrorOptions) {
|
||||
super(message, {
|
||||
|
||||
@ -56,7 +56,7 @@ async function defaultConfirmWrite(filePath: string, preview: string): Promise<b
|
||||
}
|
||||
|
||||
function canPromptForPermission(): boolean {
|
||||
return Boolean(process.stdin.isTTY && process.stderr.isTTY);
|
||||
return process.stdin.isTTY && process.stderr.isTTY;
|
||||
}
|
||||
|
||||
export class FileSystemHandlers {
|
||||
|
||||
@ -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");
|
||||
}
|
||||
@ -101,7 +101,7 @@ function getByPath(value: unknown, jsonPath: string): unknown {
|
||||
return jsonPath
|
||||
.slice(2)
|
||||
.split(".")
|
||||
.reduce<unknown>((current, key) => {
|
||||
.reduce((current: unknown, key) => {
|
||||
if (current == null || typeof current !== "object") {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
@ -1,5 +1,23 @@
|
||||
export type JsonObjectParseMode = "strict" | "fenced" | "compat";
|
||||
|
||||
function normalizeJsonText(text: unknown): string {
|
||||
if (typeof text === "string") {
|
||||
return text.trim();
|
||||
}
|
||||
if (text == null) {
|
||||
return "";
|
||||
}
|
||||
if (
|
||||
typeof text === "number" ||
|
||||
typeof text === "boolean" ||
|
||||
typeof text === "bigint" ||
|
||||
typeof text === "symbol"
|
||||
) {
|
||||
return String(text).trim();
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
// The generic entrypoint when a workflow wants to choose its tolerance level
|
||||
// explicitly. Most callers should still use one of the small helpers below.
|
||||
export function parseJsonObject(
|
||||
@ -8,7 +26,7 @@ export function parseJsonObject(
|
||||
mode?: JsonObjectParseMode;
|
||||
} = {},
|
||||
): unknown {
|
||||
const trimmed = String(text ?? "").trim();
|
||||
const trimmed = normalizeJsonText(text);
|
||||
if (!trimmed) {
|
||||
throw new Error("Expected JSON output, got empty text");
|
||||
}
|
||||
@ -20,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;
|
||||
}
|
||||
@ -66,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,
|
||||
|
||||
@ -410,7 +410,7 @@ function createFlowDefinitionSnapshot(flow: FlowDefinition): FlowDefinitionSnaps
|
||||
};
|
||||
}
|
||||
|
||||
function snapshotNode(node: FlowNodeDefinition) {
|
||||
function snapshotNode(node: FlowNodeDefinition): FlowDefinitionSnapshot["nodes"][string] {
|
||||
const common = {
|
||||
nodeType: node.nodeType,
|
||||
...(node.timeoutMs !== undefined ? { timeoutMs: node.timeoutMs } : {}),
|
||||
@ -453,6 +453,8 @@ function snapshotNode(node: FlowNodeDefinition) {
|
||||
hasRun: typeof node.run === "function",
|
||||
};
|
||||
}
|
||||
|
||||
throw new Error(`Unsupported flow node type: ${String(node satisfies never)}`);
|
||||
}
|
||||
|
||||
function snapshotCwd(cwd: AcpNodeDefinition["cwd"]): {
|
||||
|
||||
@ -92,7 +92,7 @@ async function promptForToolPermission(params: RequestPermissionRequest): Promis
|
||||
}
|
||||
|
||||
function canPromptForPermission(): boolean {
|
||||
return Boolean(process.stdin.isTTY && process.stderr.isTTY);
|
||||
return process.stdin.isTTY && process.stderr.isTTY;
|
||||
}
|
||||
|
||||
export function permissionModeSatisfies(actual: PermissionMode, required: PermissionMode): boolean {
|
||||
|
||||
@ -17,6 +17,7 @@ const MAP_OBJECT_PATHS = new Set(["request_token_usage", "messages.Agent.tool_re
|
||||
const OPAQUE_VALUE_PATHS = new Set([
|
||||
"agent_capabilities",
|
||||
"messages.Agent.content.ToolUse.input",
|
||||
"acpx.desired_config_options",
|
||||
"acpx.config_options",
|
||||
]);
|
||||
|
||||
|
||||
147
src/runtime.ts
147
src/runtime.ts
@ -6,15 +6,17 @@ import type {
|
||||
AcpRuntimeCapabilities,
|
||||
AcpRuntimeDoctorReport,
|
||||
AcpRuntimeEnsureInput,
|
||||
AcpRuntimeEvent,
|
||||
AcpRuntimeHandle,
|
||||
AcpRuntimeOptions,
|
||||
AcpRuntimeStatus,
|
||||
AcpRuntimeTurnInput,
|
||||
AcpSessionStore,
|
||||
} from "./runtime/public/contract.js";
|
||||
import { AcpRuntimeError } from "./runtime/public/errors.js";
|
||||
import { createFileSessionStore } from "./runtime/public/file-session-store.js";
|
||||
import { decodeAcpxRuntimeHandleState, writeHandleState } from "./runtime/public/handle-state.js";
|
||||
import { probeRuntime } from "./runtime/public/probe.js";
|
||||
import { normalizeRuntimeDetails, probeRuntime } from "./runtime/public/probe.js";
|
||||
import { deriveAgentFromSessionKey, type AcpxHandleState } from "./runtime/public/shared.js";
|
||||
|
||||
export { DEFAULT_AGENT_NAME, createFileSessionStore };
|
||||
@ -37,8 +39,11 @@ export type {
|
||||
AcpRuntimePromptMode,
|
||||
AcpRuntimeSessionMode,
|
||||
AcpRuntimeStatus,
|
||||
AcpRuntimeTurn,
|
||||
AcpRuntimeTurnAttachment,
|
||||
AcpRuntimeTurnInput,
|
||||
AcpRuntimeTurnResult,
|
||||
AcpRuntimeTurnResultError,
|
||||
AcpSessionRecord,
|
||||
AcpSessionStore,
|
||||
AcpSessionUpdateTag,
|
||||
@ -81,7 +86,7 @@ export class AcpxRuntime implements AcpxRuntimeLike {
|
||||
probeRunner?: (options: AcpRuntimeOptions) => Promise<{
|
||||
ok: boolean;
|
||||
message: string;
|
||||
details?: string[];
|
||||
details?: unknown[];
|
||||
}>;
|
||||
},
|
||||
) {}
|
||||
@ -102,7 +107,7 @@ export class AcpxRuntime implements AcpxRuntimeLike {
|
||||
ok: report.ok,
|
||||
code: report.ok ? undefined : "ACP_BACKEND_UNAVAILABLE",
|
||||
message: report.message,
|
||||
details: report.details,
|
||||
details: normalizeRuntimeDetails(report.details),
|
||||
};
|
||||
}
|
||||
|
||||
@ -146,16 +151,46 @@ export class AcpxRuntime implements AcpxRuntimeLike {
|
||||
return handle;
|
||||
}
|
||||
|
||||
async *runTurn(
|
||||
input: import("./runtime/public/contract.js").AcpRuntimeTurnInput,
|
||||
): AsyncIterable<import("./runtime/public/contract.js").AcpRuntimeEvent> {
|
||||
const state = this.resolveHandleState(input.handle);
|
||||
startTurn(input: AcpRuntimeTurnInput) {
|
||||
const { handle, state } = this.resolveManagerHandle(input.handle);
|
||||
const managerPromise = this.getManager();
|
||||
const turnPromise = managerPromise.then((manager) =>
|
||||
manager.startTurn({
|
||||
handle,
|
||||
text: input.text,
|
||||
attachments: input.attachments,
|
||||
mode: input.mode,
|
||||
sessionMode: state.mode,
|
||||
requestId: input.requestId,
|
||||
timeoutMs: input.timeoutMs,
|
||||
signal: input.signal,
|
||||
}),
|
||||
);
|
||||
return {
|
||||
requestId: input.requestId,
|
||||
events: {
|
||||
async *[Symbol.asyncIterator]() {
|
||||
const turn = await turnPromise;
|
||||
yield* turn.events;
|
||||
},
|
||||
},
|
||||
get result() {
|
||||
return turnPromise.then((turn) => turn.result);
|
||||
},
|
||||
cancel(inputArgs?: { reason?: string }) {
|
||||
return turnPromise.then((turn) => turn.cancel(inputArgs));
|
||||
},
|
||||
closeStream(inputArgs?: { reason?: string }) {
|
||||
return turnPromise.then((turn) => turn.closeStream(inputArgs));
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async *runTurn(input: AcpRuntimeTurnInput): AsyncIterable<AcpRuntimeEvent> {
|
||||
const { handle, state } = this.resolveManagerHandle(input.handle);
|
||||
const manager = await this.getManager();
|
||||
yield* manager.runTurn({
|
||||
handle: {
|
||||
...input.handle,
|
||||
acpxRecordId: state.acpxRecordId ?? input.handle.acpxRecordId ?? input.handle.sessionKey,
|
||||
},
|
||||
handle,
|
||||
text: input.text,
|
||||
attachments: input.attachments,
|
||||
mode: input.mode,
|
||||
@ -166,33 +201,44 @@ export class AcpxRuntime implements AcpxRuntimeLike {
|
||||
});
|
||||
}
|
||||
|
||||
getCapabilities(): 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: {
|
||||
handle: AcpRuntimeHandle;
|
||||
signal?: AbortSignal;
|
||||
}): Promise<AcpRuntimeStatus> {
|
||||
const state = this.resolveHandleState(input.handle);
|
||||
const { handle } = this.resolveManagerHandle(input.handle);
|
||||
const manager = await this.getManager();
|
||||
return await manager.getStatus({
|
||||
...input.handle,
|
||||
acpxRecordId: state.acpxRecordId ?? input.handle.acpxRecordId ?? input.handle.sessionKey,
|
||||
});
|
||||
return await manager.getStatus(handle);
|
||||
}
|
||||
|
||||
async setMode(input: { handle: AcpRuntimeHandle; mode: string }): Promise<void> {
|
||||
const state = this.resolveHandleState(input.handle);
|
||||
const { handle, state } = this.resolveManagerHandle(input.handle);
|
||||
const manager = await this.getManager();
|
||||
await manager.setMode(
|
||||
{
|
||||
...input.handle,
|
||||
acpxRecordId: state.acpxRecordId ?? input.handle.acpxRecordId ?? input.handle.sessionKey,
|
||||
},
|
||||
input.mode,
|
||||
state.mode,
|
||||
);
|
||||
await manager.setMode(handle, input.mode, state.mode);
|
||||
}
|
||||
|
||||
async setConfigOption(input: {
|
||||
@ -200,26 +246,15 @@ export class AcpxRuntime implements AcpxRuntimeLike {
|
||||
key: string;
|
||||
value: string;
|
||||
}): Promise<void> {
|
||||
const state = this.resolveHandleState(input.handle);
|
||||
const { handle, state } = this.resolveManagerHandle(input.handle);
|
||||
const manager = await this.getManager();
|
||||
await manager.setConfigOption(
|
||||
{
|
||||
...input.handle,
|
||||
acpxRecordId: state.acpxRecordId ?? input.handle.acpxRecordId ?? input.handle.sessionKey,
|
||||
},
|
||||
input.key,
|
||||
input.value,
|
||||
state.mode,
|
||||
);
|
||||
await manager.setConfigOption(handle, input.key, input.value, state.mode);
|
||||
}
|
||||
|
||||
async cancel(input: { handle: AcpRuntimeHandle; reason?: string }): Promise<void> {
|
||||
const state = this.resolveHandleState(input.handle);
|
||||
const { handle } = this.resolveManagerHandle(input.handle);
|
||||
const manager = await this.getManager();
|
||||
await manager.cancel({
|
||||
...input.handle,
|
||||
acpxRecordId: state.acpxRecordId ?? input.handle.acpxRecordId ?? input.handle.sessionKey,
|
||||
});
|
||||
await manager.cancel(handle);
|
||||
}
|
||||
|
||||
async close(input: {
|
||||
@ -227,17 +262,11 @@ export class AcpxRuntime implements AcpxRuntimeLike {
|
||||
reason: string;
|
||||
discardPersistentState?: boolean;
|
||||
}): Promise<void> {
|
||||
const state = this.resolveHandleState(input.handle);
|
||||
const { handle } = this.resolveManagerHandle(input.handle);
|
||||
const manager = await this.getManager();
|
||||
await manager.close(
|
||||
{
|
||||
...input.handle,
|
||||
acpxRecordId: state.acpxRecordId ?? input.handle.acpxRecordId ?? input.handle.sessionKey,
|
||||
},
|
||||
{
|
||||
discardPersistentState: input.discardPersistentState,
|
||||
},
|
||||
);
|
||||
await manager.close(handle, {
|
||||
discardPersistentState: input.discardPersistentState,
|
||||
});
|
||||
}
|
||||
|
||||
private async getManager(): Promise<AcpRuntimeManager> {
|
||||
@ -259,6 +288,20 @@ export class AcpxRuntime implements AcpxRuntimeLike {
|
||||
return await (this.testOptions?.probeRunner?.(this.options) ?? probeRuntime(this.options));
|
||||
}
|
||||
|
||||
private resolveManagerHandle(handle: AcpRuntimeHandle): {
|
||||
handle: AcpRuntimeHandle;
|
||||
state: AcpxHandleState;
|
||||
} {
|
||||
const state = this.resolveHandleState(handle);
|
||||
return {
|
||||
handle: {
|
||||
...handle,
|
||||
acpxRecordId: state.acpxRecordId ?? handle.acpxRecordId ?? handle.sessionKey,
|
||||
},
|
||||
state,
|
||||
};
|
||||
}
|
||||
|
||||
private resolveHandleState(handle: AcpRuntimeHandle): AcpxHandleState {
|
||||
const decoded = decodeAcpxRuntimeHandleState(handle.runtimeSessionName);
|
||||
if (decoded) {
|
||||
|
||||
@ -41,6 +41,7 @@ export type WithConnectedSessionOptions<T> = {
|
||||
nonInteractivePermissions?: NonInteractivePermissionPolicy;
|
||||
authCredentials?: Record<string, string>;
|
||||
authPolicy?: AuthPolicy;
|
||||
terminal?: boolean;
|
||||
resumePolicy?: SessionResumePolicy;
|
||||
timeoutMs?: number;
|
||||
verbose?: boolean;
|
||||
@ -91,6 +92,7 @@ export async function withConnectedSession<T>(
|
||||
nonInteractivePermissions: options.nonInteractivePermissions,
|
||||
authCredentials: options.authCredentials,
|
||||
authPolicy: options.authPolicy,
|
||||
terminal: options.terminal,
|
||||
verbose: options.verbose,
|
||||
sessionOptions: sessionOptionsFromRecord(record),
|
||||
}) ??
|
||||
@ -102,6 +104,7 @@ export async function withConnectedSession<T>(
|
||||
nonInteractivePermissions: options.nonInteractivePermissions,
|
||||
authCredentials: options.authCredentials,
|
||||
authPolicy: options.authPolicy,
|
||||
terminal: options.terminal,
|
||||
verbose: options.verbose,
|
||||
sessionOptions: sessionOptionsFromRecord(record),
|
||||
});
|
||||
|
||||
@ -5,6 +5,7 @@ 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,
|
||||
@ -15,7 +16,7 @@ import {
|
||||
trimConversationForRuntime,
|
||||
} from "../../session/conversation-model.js";
|
||||
import { defaultSessionEventLog } from "../../session/event-log.js";
|
||||
import { setDesiredModeId } from "../../session/mode-preference.js";
|
||||
import { setDesiredConfigOption, setDesiredModeId } from "../../session/mode-preference.js";
|
||||
import type { ClientOperation, SessionRecord, SessionResumePolicy } from "../../types.js";
|
||||
import type {
|
||||
AcpRuntimeEvent,
|
||||
@ -24,6 +25,8 @@ import type {
|
||||
AcpRuntimePromptMode,
|
||||
AcpRuntimeStatus,
|
||||
AcpRuntimeTurnAttachment,
|
||||
AcpRuntimeTurn,
|
||||
AcpRuntimeTurnResult,
|
||||
} from "../public/contract.js";
|
||||
import { AcpRuntimeError } from "../public/errors.js";
|
||||
import { parsePromptEventLine } from "../public/events.js";
|
||||
@ -95,6 +98,10 @@ class AsyncEventQueue {
|
||||
}
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.items.length = 0;
|
||||
}
|
||||
|
||||
async next(): Promise<AcpRuntimeEvent | null> {
|
||||
if (this.items.length > 0) {
|
||||
return this.items.shift() ?? null;
|
||||
@ -205,6 +212,22 @@ function resumePolicyForSessionMode(mode: "persistent" | "oneshot"): SessionResu
|
||||
return mode === "persistent" ? "same-session-only" : "allow-new";
|
||||
}
|
||||
|
||||
function legacyTerminalEventFromTurnResult(result: AcpRuntimeTurnResult): AcpRuntimeEvent {
|
||||
if (result.status === "failed") {
|
||||
return {
|
||||
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 }),
|
||||
};
|
||||
}
|
||||
return {
|
||||
type: "done",
|
||||
...(result.stopReason ? { stopReason: result.stopReason } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function statusSummary(record: SessionRecord): string {
|
||||
const parts = [
|
||||
`session=${record.acpxRecordId}`,
|
||||
@ -219,6 +242,7 @@ function statusSummary(record: SessionRecord): string {
|
||||
export class AcpRuntimeManager {
|
||||
private readonly activeControllers = new Map<string, ActiveSessionController>();
|
||||
private readonly pendingPersistentClients = new Map<string, AcpClient>();
|
||||
private readonly closingActiveRecords = new Set<string>();
|
||||
|
||||
constructor(
|
||||
private readonly options: AcpRuntimeOptions,
|
||||
@ -229,6 +253,106 @@ export class AcpRuntimeManager {
|
||||
return this.deps.clientFactory?.(options) ?? new AcpClient(options);
|
||||
}
|
||||
|
||||
private async readPendingPersistentClient(
|
||||
record: SessionRecord,
|
||||
options: { consume: boolean },
|
||||
): Promise<AcpClient | undefined> {
|
||||
const pendingClient = this.pendingPersistentClients.get(record.acpxRecordId);
|
||||
if (!pendingClient) {
|
||||
return undefined;
|
||||
}
|
||||
if (!pendingClient.hasReusableSession(record.acpSessionId)) {
|
||||
this.pendingPersistentClients.delete(record.acpxRecordId);
|
||||
await pendingClient.close().catch(() => {});
|
||||
return undefined;
|
||||
}
|
||||
if (options.consume) {
|
||||
this.pendingPersistentClients.delete(record.acpxRecordId);
|
||||
}
|
||||
return pendingClient;
|
||||
}
|
||||
|
||||
private async closePendingPersistentClient(recordId: string): Promise<void> {
|
||||
const pendingClient = this.pendingPersistentClients.get(recordId);
|
||||
if (!pendingClient) {
|
||||
return;
|
||||
}
|
||||
this.pendingPersistentClients.delete(recordId);
|
||||
await pendingClient.close().catch(() => {});
|
||||
}
|
||||
|
||||
private async refreshClosedState(record: SessionRecord): Promise<boolean> {
|
||||
if (!this.closingActiveRecords.has(record.acpxRecordId)) {
|
||||
return record.closed === true;
|
||||
}
|
||||
const latest = await this.options.sessionStore.load(record.acpxRecordId).catch(() => undefined);
|
||||
record.closed = true;
|
||||
record.closedAt = latest?.closedAt ?? record.closedAt ?? isoNow();
|
||||
if (latest?.acpx) {
|
||||
record.acpx = {
|
||||
...record.acpx,
|
||||
...latest.acpx,
|
||||
};
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private async retainPersistentClientAfterTurn(input: {
|
||||
record: SessionRecord;
|
||||
client: AcpClient;
|
||||
}): Promise<boolean> {
|
||||
const { record, client } = input;
|
||||
const isPersistentRecord = !record.acpxRecordId.includes(":oneshot:");
|
||||
if (!isPersistentRecord || record.closed || !client.hasReusableSession(record.acpSessionId)) {
|
||||
return false;
|
||||
}
|
||||
const previousClient = this.pendingPersistentClients.get(record.acpxRecordId);
|
||||
this.pendingPersistentClients.set(record.acpxRecordId, client);
|
||||
if (previousClient && previousClient !== client) {
|
||||
await previousClient.close().catch(() => {});
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private async withRuntimeControlSession<T>(
|
||||
record: SessionRecord,
|
||||
sessionMode: "persistent" | "oneshot",
|
||||
run: (context: { client: AcpClient; sessionId: string; record: SessionRecord }) => Promise<T>,
|
||||
): Promise<{ value: T; record: SessionRecord }> {
|
||||
const pendingClient = await this.readPendingPersistentClient(record, { consume: false });
|
||||
if (pendingClient) {
|
||||
const value = await run({
|
||||
client: pendingClient,
|
||||
sessionId: record.acpSessionId,
|
||||
record,
|
||||
});
|
||||
record.lastUsedAt = isoNow();
|
||||
record.closed = false;
|
||||
record.closedAt = undefined;
|
||||
record.protocolVersion = pendingClient.initializeResult?.protocolVersion;
|
||||
record.agentCapabilities = pendingClient.initializeResult?.agentCapabilities;
|
||||
applyLifecycleSnapshotToRecord(record, pendingClient.getAgentLifecycleSnapshot());
|
||||
return { value, record };
|
||||
}
|
||||
|
||||
const result = await withConnectedSession({
|
||||
sessionRecordId: record.acpxRecordId,
|
||||
loadRecord: async (sessionRecordId) => await this.requireRecord(sessionRecordId),
|
||||
saveRecord: async (connectedRecord) => await this.options.sessionStore.save(connectedRecord),
|
||||
createClient: (options) => this.createClient(options),
|
||||
mcpServers: [...(this.options.mcpServers ?? [])],
|
||||
permissionMode: this.options.permissionMode,
|
||||
nonInteractivePermissions: this.options.nonInteractivePermissions,
|
||||
verbose: this.options.verbose,
|
||||
timeoutMs: this.options.timeoutMs,
|
||||
resumePolicy: resumePolicyForSessionMode(sessionMode),
|
||||
run,
|
||||
});
|
||||
return {
|
||||
value: result.value,
|
||||
record: result.record,
|
||||
};
|
||||
}
|
||||
async ensureSession(input: {
|
||||
sessionKey: string;
|
||||
agent: string;
|
||||
@ -250,6 +374,7 @@ export class AcpRuntimeManager {
|
||||
) {
|
||||
existing.closed = false;
|
||||
existing.closedAt = undefined;
|
||||
this.closingActiveRecords.delete(existing.acpxRecordId);
|
||||
await this.options.sessionStore.save(existing);
|
||||
return existing;
|
||||
}
|
||||
@ -268,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),
|
||||
@ -285,8 +415,10 @@ export class AcpRuntimeManager {
|
||||
cwd,
|
||||
agentSessionId,
|
||||
});
|
||||
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") {
|
||||
@ -303,7 +435,7 @@ export class AcpRuntimeManager {
|
||||
}
|
||||
}
|
||||
|
||||
async *runTurn(input: {
|
||||
startTurn(input: {
|
||||
handle: AcpRuntimeHandle;
|
||||
text: string;
|
||||
attachments?: AcpRuntimeTurnAttachment[];
|
||||
@ -312,113 +444,185 @@ export class AcpRuntimeManager {
|
||||
requestId: string;
|
||||
timeoutMs?: number;
|
||||
signal?: AbortSignal;
|
||||
}): AsyncIterable<AcpRuntimeEvent> {
|
||||
const record = await this.requireRecord(input.handle.acpxRecordId ?? input.handle.sessionKey);
|
||||
const conversation = cloneSessionConversation(record);
|
||||
let acpxState = cloneSessionAcpxState(record.acpx);
|
||||
}): AcpRuntimeTurn {
|
||||
const promptInput = toPromptInput(input.text, input.attachments);
|
||||
const promptMessageId = recordPromptSubmission(conversation, promptInput, isoNow());
|
||||
trimConversationForRuntime(conversation);
|
||||
|
||||
const queue = new AsyncEventQueue();
|
||||
let pendingClient = this.pendingPersistentClients.get(record.acpxRecordId);
|
||||
if (pendingClient) {
|
||||
this.pendingPersistentClients.delete(record.acpxRecordId);
|
||||
if (!pendingClient.hasReusableSession(record.acpSessionId)) {
|
||||
await pendingClient.close().catch(() => {});
|
||||
pendingClient = undefined;
|
||||
}
|
||||
}
|
||||
const client =
|
||||
pendingClient ??
|
||||
this.createClient({
|
||||
agentCommand: record.agentCommand,
|
||||
cwd: record.cwd,
|
||||
mcpServers: [...(this.options.mcpServers ?? [])],
|
||||
permissionMode: this.options.permissionMode,
|
||||
nonInteractivePermissions: this.options.nonInteractivePermissions,
|
||||
verbose: this.options.verbose,
|
||||
});
|
||||
let activeSessionId = record.acpSessionId;
|
||||
let sawDone = false;
|
||||
let pendingCancel = false;
|
||||
let turnActive = true;
|
||||
const result = createDeferred<AcpRuntimeTurnResult>();
|
||||
const sessionReady = createDeferred<void>();
|
||||
void sessionReady.promise.catch(() => {});
|
||||
let resultSettled = false;
|
||||
let pendingCancel = false;
|
||||
let turnActive = true;
|
||||
let streamClosed = false;
|
||||
let activeController: ActiveSessionController | null = null;
|
||||
|
||||
const applyPendingCancel = async (): Promise<boolean> => {
|
||||
if (!pendingCancel || !client.hasActivePrompt()) {
|
||||
return false;
|
||||
}
|
||||
const cancelled = await client.requestCancelActivePrompt();
|
||||
if (cancelled) {
|
||||
pendingCancel = false;
|
||||
}
|
||||
return cancelled;
|
||||
};
|
||||
|
||||
const activeController: ActiveSessionController = {
|
||||
hasActivePrompt: () => client.hasActivePrompt(),
|
||||
requestCancelActivePrompt: async () => {
|
||||
if (client.hasActivePrompt()) {
|
||||
return await client.requestCancelActivePrompt();
|
||||
}
|
||||
if (!turnActive) {
|
||||
return false;
|
||||
}
|
||||
pendingCancel = true;
|
||||
return true;
|
||||
},
|
||||
setSessionMode: async (modeId: string) => {
|
||||
if (!client.hasActivePrompt()) {
|
||||
await sessionReady.promise;
|
||||
}
|
||||
await client.setSessionMode(activeSessionId, modeId);
|
||||
},
|
||||
setSessionModel: async (modelId: string) => {
|
||||
if (!client.hasActivePrompt()) {
|
||||
await sessionReady.promise;
|
||||
}
|
||||
await client.setSessionModel(activeSessionId, modelId);
|
||||
},
|
||||
setSessionConfigOption: async (configId: string, value: string) => {
|
||||
if (!client.hasActivePrompt()) {
|
||||
await sessionReady.promise;
|
||||
}
|
||||
return await client.setSessionConfigOption(activeSessionId, configId, value);
|
||||
},
|
||||
};
|
||||
|
||||
const emitParsed = (payload: Record<string, unknown>): void => {
|
||||
const parsed = parsePromptEventLine(JSON.stringify(payload));
|
||||
if (!parsed) {
|
||||
const settleResult = (next: AcpRuntimeTurnResult): void => {
|
||||
if (resultSettled) {
|
||||
return;
|
||||
}
|
||||
if (parsed.type === "done") {
|
||||
sawDone = true;
|
||||
resultSettled = true;
|
||||
result.resolve(next);
|
||||
};
|
||||
|
||||
const closeStream = (): void => {
|
||||
if (streamClosed) {
|
||||
return;
|
||||
}
|
||||
queue.push(parsed);
|
||||
streamClosed = true;
|
||||
queue.clear();
|
||||
queue.close();
|
||||
};
|
||||
|
||||
const requestCancel = async (): Promise<boolean> => {
|
||||
if (activeController) {
|
||||
return await activeController.requestCancelActivePrompt();
|
||||
}
|
||||
if (!turnActive) {
|
||||
return false;
|
||||
}
|
||||
pendingCancel = true;
|
||||
return true;
|
||||
};
|
||||
|
||||
const abortHandler = () => {
|
||||
void activeController.requestCancelActivePrompt();
|
||||
void requestCancel();
|
||||
};
|
||||
if (input.signal) {
|
||||
if (input.signal.aborted) {
|
||||
queue.close();
|
||||
return;
|
||||
closeStream();
|
||||
settleResult({
|
||||
status: "cancelled",
|
||||
stopReason: "cancelled",
|
||||
});
|
||||
return {
|
||||
requestId: input.requestId,
|
||||
events: queue.iterate(),
|
||||
result: result.promise,
|
||||
cancel: async () => {},
|
||||
closeStream: async () => {},
|
||||
};
|
||||
}
|
||||
input.signal.addEventListener("abort", abortHandler, { once: true });
|
||||
}
|
||||
|
||||
this.activeControllers.set(record.acpxRecordId, activeController);
|
||||
|
||||
void (async () => {
|
||||
let record: SessionRecord | null = null;
|
||||
let conversation: ReturnType<typeof cloneSessionConversation> | null = null;
|
||||
let acpxState: ReturnType<typeof cloneSessionAcpxState>;
|
||||
let client: AcpClient | null = null;
|
||||
try {
|
||||
client.setEventHandlers({
|
||||
record = await this.requireRecord(input.handle.acpxRecordId ?? input.handle.sessionKey);
|
||||
conversation = cloneSessionConversation(record);
|
||||
acpxState = cloneSessionAcpxState(record.acpx);
|
||||
const promptStartedAt = isoNow();
|
||||
const promptMessageId = recordPromptSubmission(conversation, promptInput, promptStartedAt);
|
||||
trimConversationForRuntime(conversation);
|
||||
record.lastPromptAt = promptStartedAt;
|
||||
record.lastUsedAt = promptStartedAt;
|
||||
record.acpx = acpxState;
|
||||
applyConversation(record, conversation);
|
||||
await this.options.sessionStore.save(record);
|
||||
|
||||
const pendingClient = await this.readPendingPersistentClient(record, { consume: true });
|
||||
client =
|
||||
pendingClient ??
|
||||
this.createClient({
|
||||
agentCommand: record.agentCommand,
|
||||
cwd: record.cwd,
|
||||
mcpServers: [...(this.options.mcpServers ?? [])],
|
||||
permissionMode: this.options.permissionMode,
|
||||
nonInteractivePermissions: this.options.nonInteractivePermissions,
|
||||
verbose: this.options.verbose,
|
||||
});
|
||||
const runtimeClient = client;
|
||||
const runtimeConversation = conversation;
|
||||
const runtimeRecord = record;
|
||||
let activeSessionId = record.acpSessionId;
|
||||
|
||||
const applyPendingCancel = async (): Promise<boolean> => {
|
||||
if (!pendingCancel || !runtimeClient.hasActivePrompt()) {
|
||||
return false;
|
||||
}
|
||||
const cancelled = await runtimeClient.requestCancelActivePrompt();
|
||||
if (cancelled) {
|
||||
pendingCancel = false;
|
||||
}
|
||||
return cancelled;
|
||||
};
|
||||
|
||||
activeController = {
|
||||
hasActivePrompt: () => runtimeClient.hasActivePrompt(),
|
||||
requestCancelActivePrompt: async () => {
|
||||
if (runtimeClient.hasActivePrompt()) {
|
||||
return await runtimeClient.requestCancelActivePrompt();
|
||||
}
|
||||
if (!turnActive) {
|
||||
return false;
|
||||
}
|
||||
pendingCancel = true;
|
||||
return true;
|
||||
},
|
||||
setSessionMode: async (modeId: string) => {
|
||||
if (!runtimeClient.hasActivePrompt()) {
|
||||
await sessionReady.promise;
|
||||
}
|
||||
await runtimeClient.setSessionMode(activeSessionId, modeId);
|
||||
const nextState = cloneSessionAcpxState(acpxState) ?? {};
|
||||
nextState.desired_mode_id = modeId;
|
||||
acpxState = nextState;
|
||||
},
|
||||
setSessionModel: async (modelId: string) => {
|
||||
if (!runtimeClient.hasActivePrompt()) {
|
||||
await sessionReady.promise;
|
||||
}
|
||||
await runtimeClient.setSessionModel(activeSessionId, modelId);
|
||||
},
|
||||
setSessionConfigOption: async (configId: string, value: string) => {
|
||||
if (!runtimeClient.hasActivePrompt()) {
|
||||
await sessionReady.promise;
|
||||
}
|
||||
const response = await runtimeClient.setSessionConfigOption(
|
||||
activeSessionId,
|
||||
configId,
|
||||
value,
|
||||
);
|
||||
if (response?.configOptions) {
|
||||
const nextState = cloneSessionAcpxState(acpxState) ?? {};
|
||||
nextState.config_options = structuredClone(response.configOptions);
|
||||
acpxState = nextState;
|
||||
}
|
||||
if (configId === "mode") {
|
||||
const nextState = cloneSessionAcpxState(acpxState) ?? {};
|
||||
nextState.desired_mode_id = value;
|
||||
acpxState = nextState;
|
||||
} else if (configId !== "model") {
|
||||
const nextState = cloneSessionAcpxState(acpxState) ?? {};
|
||||
nextState.desired_config_options = {
|
||||
...nextState.desired_config_options,
|
||||
[configId]: value,
|
||||
};
|
||||
acpxState = nextState;
|
||||
}
|
||||
return response;
|
||||
},
|
||||
};
|
||||
|
||||
const emitParsed = (payload: Record<string, unknown>): void => {
|
||||
if (streamClosed) {
|
||||
return;
|
||||
}
|
||||
const parsed = parsePromptEventLine(JSON.stringify(payload));
|
||||
if (!parsed) {
|
||||
return;
|
||||
}
|
||||
queue.push(parsed);
|
||||
};
|
||||
|
||||
this.activeControllers.set(runtimeRecord.acpxRecordId, activeController);
|
||||
runtimeClient.setEventHandlers({
|
||||
onSessionUpdate: (notification) => {
|
||||
acpxState = recordSessionUpdate(conversation, acpxState, notification);
|
||||
trimConversationForRuntime(conversation);
|
||||
acpxState = recordSessionUpdate(runtimeConversation, acpxState, notification);
|
||||
trimConversationForRuntime(runtimeConversation);
|
||||
emitParsed({
|
||||
jsonrpc: "2.0",
|
||||
method: "session/update",
|
||||
@ -426,8 +630,8 @@ export class AcpRuntimeManager {
|
||||
});
|
||||
},
|
||||
onClientOperation: (operation: ClientOperation) => {
|
||||
acpxState = recordClientOperation(conversation, acpxState, operation);
|
||||
trimConversationForRuntime(conversation);
|
||||
acpxState = recordClientOperation(runtimeConversation, acpxState, operation);
|
||||
trimConversationForRuntime(runtimeConversation);
|
||||
emitParsed({
|
||||
type: "client_operation",
|
||||
...operation,
|
||||
@ -442,13 +646,14 @@ export class AcpRuntimeManager {
|
||||
loadError: undefined,
|
||||
}
|
||||
: await connectAndLoadSession({
|
||||
client,
|
||||
record,
|
||||
client: runtimeClient,
|
||||
record: runtimeRecord,
|
||||
resumePolicy: resumePolicyForSessionMode(input.sessionMode),
|
||||
timeoutMs: this.options.timeoutMs,
|
||||
activeController,
|
||||
onClientAvailable: (controller) => {
|
||||
this.activeControllers.set(record.acpxRecordId, controller);
|
||||
activeController = controller;
|
||||
this.activeControllers.set(runtimeRecord.acpxRecordId, controller);
|
||||
},
|
||||
onConnectedRecord: (connectedRecord) => {
|
||||
connectedRecord.lastPromptAt = isoNow();
|
||||
@ -459,11 +664,11 @@ export class AcpRuntimeManager {
|
||||
});
|
||||
sessionReady.resolve();
|
||||
|
||||
record.lastRequestId = input.requestId;
|
||||
record.lastPromptAt = isoNow();
|
||||
record.closed = false;
|
||||
record.closedAt = undefined;
|
||||
record.lastUsedAt = isoNow();
|
||||
runtimeRecord.lastRequestId = input.requestId;
|
||||
runtimeRecord.lastPromptAt = isoNow();
|
||||
runtimeRecord.closed = false;
|
||||
runtimeRecord.closedAt = undefined;
|
||||
runtimeRecord.lastUsedAt = isoNow();
|
||||
if (resumed || loadError) {
|
||||
emitParsed({
|
||||
type: "status",
|
||||
@ -473,67 +678,106 @@ export class AcpRuntimeManager {
|
||||
|
||||
if (pendingCancel || input.signal?.aborted) {
|
||||
pendingCancel = false;
|
||||
if (!sawDone) {
|
||||
queue.push({
|
||||
type: "done",
|
||||
stopReason: "cancelled",
|
||||
});
|
||||
}
|
||||
settleResult({
|
||||
status: "cancelled",
|
||||
stopReason: "cancelled",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await applyPendingCancel();
|
||||
const response = await runPromptTurn({
|
||||
client,
|
||||
client: runtimeClient,
|
||||
sessionId,
|
||||
prompt: promptInput,
|
||||
timeoutMs: input.timeoutMs ?? this.options.timeoutMs,
|
||||
conversation,
|
||||
conversation: runtimeConversation,
|
||||
promptMessageId,
|
||||
});
|
||||
|
||||
record.acpSessionId = activeSessionId;
|
||||
reconcileAgentSessionId(record, record.agentSessionId);
|
||||
record.protocolVersion = client.initializeResult?.protocolVersion;
|
||||
record.agentCapabilities = client.initializeResult?.agentCapabilities;
|
||||
record.acpx = acpxState;
|
||||
applyConversation(record, conversation);
|
||||
applyLifecycleSnapshotToRecord(record, client.getAgentLifecycleSnapshot());
|
||||
await this.options.sessionStore.save(record);
|
||||
runtimeRecord.acpSessionId = activeSessionId;
|
||||
reconcileAgentSessionId(runtimeRecord, runtimeRecord.agentSessionId);
|
||||
runtimeRecord.protocolVersion = runtimeClient.initializeResult?.protocolVersion;
|
||||
runtimeRecord.agentCapabilities = runtimeClient.initializeResult?.agentCapabilities;
|
||||
runtimeRecord.acpx = acpxState;
|
||||
applyConversation(runtimeRecord, runtimeConversation);
|
||||
applyLifecycleSnapshotToRecord(runtimeRecord, runtimeClient.getAgentLifecycleSnapshot());
|
||||
await this.options.sessionStore.save(runtimeRecord);
|
||||
|
||||
if (!sawDone) {
|
||||
queue.push({
|
||||
type: "done",
|
||||
stopReason: response.stopReason,
|
||||
});
|
||||
}
|
||||
settleResult({
|
||||
status: response.stopReason === "cancelled" ? "cancelled" : "completed",
|
||||
...(response.stopReason ? { stopReason: response.stopReason } : {}),
|
||||
});
|
||||
} catch (error) {
|
||||
sessionReady.reject(error);
|
||||
const normalized = normalizeOutputError(error, { origin: "runtime" });
|
||||
queue.push({
|
||||
type: "error",
|
||||
message: normalized.message,
|
||||
code: normalized.code,
|
||||
retryable: normalized.retryable,
|
||||
settleResult({
|
||||
status: "failed",
|
||||
error: {
|
||||
message: normalized.message,
|
||||
...(normalized.code ? { code: normalized.code } : {}),
|
||||
...(normalized.detailCode ? { detailCode: normalized.detailCode } : {}),
|
||||
...(normalized.retryable !== undefined ? { retryable: normalized.retryable } : {}),
|
||||
},
|
||||
});
|
||||
} finally {
|
||||
turnActive = false;
|
||||
if (input.signal) {
|
||||
input.signal.removeEventListener("abort", abortHandler);
|
||||
}
|
||||
this.activeControllers.delete(record.acpxRecordId);
|
||||
client.clearEventHandlers();
|
||||
applyLifecycleSnapshotToRecord(record, client.getAgentLifecycleSnapshot());
|
||||
record.acpx = acpxState;
|
||||
applyConversation(record, conversation);
|
||||
record.lastUsedAt = isoNow();
|
||||
await this.options.sessionStore.save(record).catch(() => {});
|
||||
await client.close().catch(() => {});
|
||||
client?.clearEventHandlers();
|
||||
let pooled = false;
|
||||
if (record && conversation) {
|
||||
applyLifecycleSnapshotToRecord(
|
||||
record,
|
||||
client?.getAgentLifecycleSnapshot() ?? { running: false },
|
||||
);
|
||||
record.acpx = acpxState;
|
||||
applyConversation(record, conversation);
|
||||
record.lastUsedAt = isoNow();
|
||||
const closed = await this.refreshClosedState(record);
|
||||
await this.options.sessionStore.save(record).catch(() => {});
|
||||
if (!closed && client) {
|
||||
pooled = await this.retainPersistentClientAfterTurn({ record, client });
|
||||
}
|
||||
}
|
||||
if (!pooled) {
|
||||
await client?.close().catch(() => {});
|
||||
}
|
||||
if (record) {
|
||||
this.activeControllers.delete(record.acpxRecordId);
|
||||
this.closingActiveRecords.delete(record.acpxRecordId);
|
||||
}
|
||||
queue.close();
|
||||
}
|
||||
})();
|
||||
|
||||
yield* queue.iterate();
|
||||
return {
|
||||
requestId: input.requestId,
|
||||
events: queue.iterate(),
|
||||
result: result.promise,
|
||||
cancel: async () => {
|
||||
await requestCancel();
|
||||
},
|
||||
closeStream: async () => {
|
||||
closeStream();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async *runTurn(input: {
|
||||
handle: AcpRuntimeHandle;
|
||||
text: string;
|
||||
attachments?: AcpRuntimeTurnAttachment[];
|
||||
mode: AcpRuntimePromptMode;
|
||||
sessionMode: "persistent" | "oneshot";
|
||||
requestId: string;
|
||||
timeoutMs?: number;
|
||||
signal?: AbortSignal;
|
||||
}): AsyncIterable<AcpRuntimeEvent> {
|
||||
const turn = this.startTurn(input);
|
||||
yield* turn.events;
|
||||
yield legacyTerminalEventFromTurnResult(await turn.result);
|
||||
}
|
||||
|
||||
async getStatus(handle: AcpRuntimeHandle): Promise<AcpRuntimeStatus> {
|
||||
@ -547,6 +791,9 @@ export class AcpRuntimeManager {
|
||||
cwd: record.cwd,
|
||||
lastUsedAt: record.lastUsedAt,
|
||||
closed: record.closed === true,
|
||||
...(record.acpx?.config_options !== undefined
|
||||
? { configOptions: structuredClone(record.acpx.config_options) }
|
||||
: {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
@ -562,22 +809,13 @@ export class AcpRuntimeManager {
|
||||
if (controller) {
|
||||
await controller.setSessionMode(mode);
|
||||
} else {
|
||||
const result = await withConnectedSession({
|
||||
sessionRecordId: record.acpxRecordId,
|
||||
loadRecord: async (sessionRecordId) => await this.requireRecord(sessionRecordId),
|
||||
saveRecord: async (connectedRecord) =>
|
||||
await this.options.sessionStore.save(connectedRecord),
|
||||
createClient: (options) => this.createClient(options),
|
||||
mcpServers: [...(this.options.mcpServers ?? [])],
|
||||
permissionMode: this.options.permissionMode,
|
||||
nonInteractivePermissions: this.options.nonInteractivePermissions,
|
||||
verbose: this.options.verbose,
|
||||
timeoutMs: this.options.timeoutMs,
|
||||
resumePolicy: resumePolicyForSessionMode(sessionMode),
|
||||
run: async ({ client, sessionId }) => {
|
||||
const result = await this.withRuntimeControlSession(
|
||||
record,
|
||||
sessionMode,
|
||||
async ({ client, sessionId }) => {
|
||||
await client.setSessionMode(sessionId, mode);
|
||||
},
|
||||
});
|
||||
);
|
||||
targetRecord = result.record;
|
||||
}
|
||||
setDesiredModeId(targetRecord, mode);
|
||||
@ -594,31 +832,28 @@ export class AcpRuntimeManager {
|
||||
const controller = this.activeControllers.get(record.acpxRecordId);
|
||||
let targetRecord = record;
|
||||
if (controller) {
|
||||
await controller.setSessionConfigOption(key, value);
|
||||
const response = await controller.setSessionConfigOption(key, value);
|
||||
applyConfigOptionsToRecord(targetRecord, response);
|
||||
} else {
|
||||
const result = await withConnectedSession({
|
||||
sessionRecordId: record.acpxRecordId,
|
||||
loadRecord: async (sessionRecordId) => await this.requireRecord(sessionRecordId),
|
||||
saveRecord: async (connectedRecord) =>
|
||||
await this.options.sessionStore.save(connectedRecord),
|
||||
createClient: (options) => this.createClient(options),
|
||||
mcpServers: [...(this.options.mcpServers ?? [])],
|
||||
permissionMode: this.options.permissionMode,
|
||||
nonInteractivePermissions: this.options.nonInteractivePermissions,
|
||||
verbose: this.options.verbose,
|
||||
timeoutMs: this.options.timeoutMs,
|
||||
resumePolicy: resumePolicyForSessionMode(sessionMode),
|
||||
run: async ({ client, sessionId, record: connectedRecord }) => {
|
||||
await client.setSessionConfigOption(sessionId, key, value);
|
||||
const result = await this.withRuntimeControlSession(
|
||||
record,
|
||||
sessionMode,
|
||||
async ({ client, sessionId, record: connectedRecord }) => {
|
||||
const response = await client.setSessionConfigOption(sessionId, key, value);
|
||||
applyConfigOptionsToRecord(connectedRecord, response);
|
||||
if (key === "mode") {
|
||||
setDesiredModeId(connectedRecord, value);
|
||||
} else {
|
||||
setDesiredConfigOption(connectedRecord, key, value);
|
||||
}
|
||||
},
|
||||
});
|
||||
);
|
||||
targetRecord = result.record;
|
||||
}
|
||||
if (key === "mode") {
|
||||
setDesiredModeId(targetRecord, value);
|
||||
} else {
|
||||
setDesiredConfigOption(targetRecord, key, value);
|
||||
}
|
||||
await this.options.sessionStore.save(targetRecord);
|
||||
}
|
||||
@ -633,6 +868,9 @@ export class AcpRuntimeManager {
|
||||
options: { discardPersistentState?: boolean } = {},
|
||||
): Promise<void> {
|
||||
const record = await this.requireRecord(handle.acpxRecordId ?? handle.sessionKey);
|
||||
if (this.activeControllers.has(record.acpxRecordId)) {
|
||||
this.closingActiveRecords.add(record.acpxRecordId);
|
||||
}
|
||||
await this.cancel(handle);
|
||||
if (options.discardPersistentState) {
|
||||
await this.closeBackendSession(record);
|
||||
@ -640,6 +878,8 @@ export class AcpRuntimeManager {
|
||||
...record.acpx,
|
||||
reset_on_next_ensure: true,
|
||||
};
|
||||
} else {
|
||||
await this.closePendingPersistentClient(record.acpxRecordId);
|
||||
}
|
||||
record.closed = true;
|
||||
record.closedAt = isoNow();
|
||||
@ -647,18 +887,10 @@ export class AcpRuntimeManager {
|
||||
}
|
||||
|
||||
private async closeBackendSession(record: SessionRecord): Promise<void> {
|
||||
const pendingClient = this.pendingPersistentClients.get(record.acpxRecordId);
|
||||
if (pendingClient) {
|
||||
this.pendingPersistentClients.delete(record.acpxRecordId);
|
||||
}
|
||||
const reusablePendingClient =
|
||||
pendingClient?.hasReusableSession(record.acpSessionId) === true ? pendingClient : undefined;
|
||||
if (pendingClient && !reusablePendingClient) {
|
||||
await pendingClient.close().catch(() => {});
|
||||
}
|
||||
const pendingClient = await this.readPendingPersistentClient(record, { consume: true });
|
||||
|
||||
const client =
|
||||
reusablePendingClient ??
|
||||
pendingClient ??
|
||||
this.createClient({
|
||||
agentCommand: record.agentCommand,
|
||||
cwd: record.cwd,
|
||||
@ -669,7 +901,7 @@ export class AcpRuntimeManager {
|
||||
});
|
||||
|
||||
try {
|
||||
if (!reusablePendingClient) {
|
||||
if (!pendingClient) {
|
||||
await withTimeout(client.start(), this.options.timeoutMs);
|
||||
}
|
||||
if (!client.supportsCloseSession()) {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user