Compare commits

...

72 Commits
v0.5.1 ... main

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

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

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 20:28:05 +01:00
Peter Steinberger
fd67b109b3
Create CNAME 2026-05-05 20:25:47 +01:00
Peter Steinberger
43013ead1a
fix(runtime): preserve ACP session details and close active owners 2026-05-05 19:15:51 +01:00
Peter Steinberger
032bc40466
test: make Windows path assertions portable 2026-05-05 11:38:24 +01:00
Solomon Neas
c6bc937a29
fix: recognize .cmd/.bat as Windows-side commands for WSL cwd translation
Recognize Windows .cmd/.bat ACP agent wrappers when translating WSL cwd, including non-C drive installs.\n\nGate: pnpm run check locally on PR branch; rebased onto main after #289 and #291.\n\nThanks @solomonneas.
2026-05-05 10:51:02 +01:00
Jordan Baker
97f301dc69
fix: closeSession uses session/close not nes/close
Send session/close from AcpClient.closeSession instead of the experimental nes/close method.\n\nGate: pnpm run check locally on PR branch; rebased onto main after #289.\n\nThanks @hexsprite.
2026-05-05 10:50:08 +01:00
Mike Chong
436fd4fd66
fix(win32): resolve Claude Code executable path for ACP spawn
Resolve claude.exe from PATH before spawning Claude ACP sessions on native Windows.\n\nGate: pnpm run check; GitHub CI green on "3637bce325a32bcf083626a3e9d4ee6534b19594".\n\nThanks @MikeChongCan.
2026-05-05 10:48:54 +01:00
dependabot[bot]
a23310ee99
chore(deps): bump pnpm/action-setup to 6.0.5
Bump pnpm/action-setup from 6.0.3 to 6.0.5.
2026-05-05 10:33:07 +01:00
Peter Steinberger
03d87dd493
fix: satisfy unknown-session conformance
Some checks failed
CI / scope (push) Has been cancelled
CI / ${{ matrix.name }} (pnpm run build, Build) (push) Has been cancelled
CI / ${{ matrix.name }} (pnpm run conformance:run -- --case acp.v1.initialize.handshake, Conformance Smoke) (push) Has been cancelled
CI / ${{ matrix.name }} (pnpm run format:check, Format) (push) Has been cancelled
CI / ${{ matrix.name }} (pnpm run lint, Lint) (push) Has been cancelled
CI / ${{ matrix.name }} (pnpm run test:coverage, Test, 22) (push) Has been cancelled
CI / ${{ matrix.name }} (pnpm run typecheck, Typecheck) (push) Has been cancelled
CI / Docs (push) Has been cancelled
2026-05-03 16:18:04 +01:00
Vincent Koc
1a9fdabfd7
fix(security): avoid prototype setter in replay patches
Some checks failed
CI / scope (push) Has been cancelled
CI / Docs (push) Has been cancelled
CI / ${{ matrix.name }} (pnpm run build, Build) (push) Has been cancelled
CI / ${{ matrix.name }} (pnpm run conformance:run -- --case acp.v1.initialize.handshake, Conformance Smoke) (push) Has been cancelled
CI / ${{ matrix.name }} (pnpm run format:check, Format) (push) Has been cancelled
CI / ${{ matrix.name }} (pnpm run lint, Lint) (push) Has been cancelled
CI / ${{ matrix.name }} (pnpm run test:coverage, Test, 22) (push) Has been cancelled
CI / ${{ matrix.name }} (pnpm run typecheck, Typecheck) (push) Has been cancelled
2026-04-30 03:38:06 -07:00
Vincent Koc
541742f800
fix(security): harden flow sanitizer paths 2026-04-30 03:33:29 -07:00
Peter Steinberger
e1a3546669
chore: drop stale npm lockfile
Some checks failed
CI / scope (push) Has been cancelled
CI / ${{ matrix.name }} (pnpm run build, Build) (push) Has been cancelled
CI / ${{ matrix.name }} (pnpm run conformance:run -- --case acp.v1.initialize.handshake, Conformance Smoke) (push) Has been cancelled
CI / ${{ matrix.name }} (pnpm run format:check, Format) (push) Has been cancelled
CI / ${{ matrix.name }} (pnpm run lint, Lint) (push) Has been cancelled
CI / ${{ matrix.name }} (pnpm run test:coverage, Test, 22) (push) Has been cancelled
CI / ${{ matrix.name }} (pnpm run typecheck, Typecheck) (push) Has been cancelled
CI / Docs (push) Has been cancelled
2026-04-27 11:55:10 +01:00
Peter Steinberger
2480c48806
fix: require advertised ACP model support
Some checks are pending
CI / scope (push) Waiting to run
CI / ${{ matrix.name }} (pnpm run build, Build) (push) Blocked by required conditions
CI / ${{ matrix.name }} (pnpm run conformance:run -- --case acp.v1.initialize.handshake, Conformance Smoke) (push) Blocked by required conditions
CI / ${{ matrix.name }} (pnpm run format:check, Format) (push) Blocked by required conditions
CI / ${{ matrix.name }} (pnpm run lint, Lint) (push) Blocked by required conditions
CI / ${{ matrix.name }} (pnpm run test:coverage, Test, 22) (push) Blocked by required conditions
CI / ${{ matrix.name }} (pnpm run typecheck, Typecheck) (push) Blocked by required conditions
CI / Docs (push) Blocked by required conditions
2026-04-25 21:52:18 +01:00
Peter Steinberger
119b84ee31
test: dedupe session record fixtures
Some checks are pending
CI / scope (push) Waiting to run
CI / ${{ matrix.name }} (pnpm run build, Build) (push) Blocked by required conditions
CI / ${{ matrix.name }} (pnpm run conformance:run -- --case acp.v1.initialize.handshake, Conformance Smoke) (push) Blocked by required conditions
CI / ${{ matrix.name }} (pnpm run format:check, Format) (push) Blocked by required conditions
CI / ${{ matrix.name }} (pnpm run lint, Lint) (push) Blocked by required conditions
CI / ${{ matrix.name }} (pnpm run test:coverage, Test, 22) (push) Blocked by required conditions
CI / ${{ matrix.name }} (pnpm run typecheck, Typecheck) (push) Blocked by required conditions
CI / Docs (push) Blocked by required conditions
2026-04-25 11:32:53 +01:00
Peter Steinberger
7f3c177e7c
refactor: dedupe session and ACP helpers 2026-04-25 11:32:44 +01:00
Peter Steinberger
7ac8cf0bb3
chore: bump version to 0.6.1 2026-04-25 11:24:40 +01:00
Peter Steinberger
69f527646b
chore: strengthen lint rules 2026-04-25 11:23:44 +01:00
Peter Steinberger
0cc6ebad9b
docs: curate 0.6.0 changelog
Some checks failed
Release / release (push) Has been cancelled
2026-04-25 10:51:15 +01:00
Peter Steinberger
2b92de84e7
docs: clarify session identity output 2026-04-25 10:44:12 +01:00
Peter Steinberger
521fda38a5
fix: replay saved session config options 2026-04-25 10:40:46 +01:00
Peter Steinberger
013f2fd830
fix: use locked ACP SDK close API 2026-04-25 10:04:33 +01:00
Peter Steinberger
b7cc36c503
fix: improve live session history and error hints 2026-04-25 09:58:44 +01:00
Peter Steinberger
51123f8de5
fix: mark resumable sessions idle
Fixes #185
2026-04-25 09:47:51 +01:00
Peter Steinberger
4787ba3efa
fix: translate WSL cwd for Windows agents
Fixes #232
2026-04-25 09:40:39 +01:00
Peter Steinberger
957c60475e
chore(agents): bump built-in adapter ranges (#275) 2026-04-25 09:29:50 +01:00
Peter Steinberger
ea2c1cfb5a
fix(output): show quiet usage metadata (#274) 2026-04-25 09:25:12 +01:00
Peter Steinberger
3435b3f77c
test(queue): cover no-wait prompt drain (#273) 2026-04-25 09:20:28 +01:00
Peter Steinberger
6e0e1a70b2
fix(runtime): stringify doctor details
Normalize runtime probe/doctor detail values before returning `AcpRuntimeDoctorReport`, so consumers never receive object values that render as `[object Object]`.

Fixes #267.

Validation:
- fnm exec --using=24.15.0 corepack pnpm@10.33.2 run check
- CI: https://github.com/openclaw/acpx/actions/runs/24926487294
2026-04-25 09:14:26 +01:00
DMQ
3b94ac9c39
feat(cli): add terminal capability opt-out
Add --no-terminal so ACPX can initialize new agent clients with clientCapabilities.terminal=false.

Reworked on current main because the original branch targeted stale paths. Includes CLI/docs/changelog updates plus regression coverage for direct exec and queue-owner propagation.

Local verification:
- fnm exec --using=24.15.0 corepack pnpm@10.33.2 run check

CI: https://github.com/openclaw/acpx/actions/runs/24926378907

Co-authored-by: DMQ <6860287+DMQ@users.noreply.github.com>
2026-04-25 09:08:35 +01:00
Coder
0ad1577c1e
feat(sessions): add prune command
Add an explicit `sessions prune` command for deleting closed session records and optional event stream history.

- add `sessions prune` with `--dry-run`, `--before`, `--older-than`, and `--include-history`
- filter age by `closedAt`, falling back to `lastUsedAt` for older records
- avoid overmatching neighboring stream files when deleting history
- cover storage behavior plus CLI help, dry-run, JSON output, and include-history paths
- document the command and add the changelog entry

Validation:
- fnm exec --using=24.15.0 corepack pnpm@10.33.2 run build:test
- fnm exec --using=24.15.0 node --test dist-test/test/sessions-prune.test.js dist-test/test/cli.test.js --test-name-pattern "sessions prune|sessions new command is present"
- fnm exec --using=24.15.0 corepack pnpm@10.33.2 run check
- CI: https://github.com/openclaw/acpx/actions/runs/24926186590 on c263cd981d66bf66750a1ac73c4f46d089c780cc

Co-authored-by: coder999999999 <coder999999999@users.noreply.github.com>
2026-04-25 08:57:34 +01:00
Paul Bohm
cf895c8c6c
fix(runtime): reuse sessions for controls
Reuse the kept-open persistent runtime client for mode/config controls and keep config option state visible in runtime status.

- share runtime control session acquisition across active, pending, and reconnect paths
- reuse the pending persistent client for controls before the first prompt turn
- persist returned config option state into session status
- preserve active-turn config option state through the turn finalizer

Validation:
- fnm exec --using=24.15.0 corepack pnpm@10.33.2 run build:test
- fnm exec --using=24.15.0 node --test dist-test/test/runtime-manager.test.js
- fnm exec --using=24.15.0 corepack pnpm@10.33.2 run format:check
- CI: https://github.com/openclaw/acpx/actions/runs/24926069053 on 0683e507b9e109e101cd490173f5678df2234245

Co-authored-by: enki <29411+enki@users.noreply.github.com>
2026-04-25 08:50:25 +01:00
Paul Bohm
475a767169
feat(runtime): add turn handles
Add runtime turn handles for embedders that need live stream control without overloading terminal events.

- add startTurn(...) with live events, terminal result, per-turn cancel, and stream-only close
- keep runTurn(...) as the compatibility adapter that emits terminal done/error events
- retain reusable persistent clients across turns and close them on runtime close/active close
- make coverage exclude compiled test files explicitly and normalize early child-exit initialize failures

Validation:
- fnm exec --using=24.15.0 corepack pnpm@10.33.2 run check
- CI: https://github.com/openclaw/acpx/actions/runs/24925872112 on 343b0b957259e0adf720bded199c718f3bb8c057

Co-authored-by: enki <29411+enki@users.noreply.github.com>
2026-04-25 08:46:10 +01:00
Peter Steinberger
e42e921366
fix(prompt): honor model option on persistent prompts (#271)
fix(prompt): honor model option on persistent prompts

- carry prompt session options through persistent prompt and queue owner paths
- apply requested --model before sending the prompt to an existing session
- add regression coverage for persistent prompt model switches and queue payload parsing

Co-authored-by: william khoo <6055148+skywills@users.noreply.github.com>
2026-04-25 08:21:18 +01:00
Peter Steinberger
445d975b99
fix(claude): bump built-in adapter range
Bump the ACPX-owned Claude ACP adapter package range to `^0.31.0` so fresh built-in launches pick up Opus 4.7 support and later ACP compatibility fixes.

Also adds Claude built-in docs, fixes the stale docs package name, locks the range in tests, and hardens temp-home cleanup for a reproduced macOS `ENOTEMPTY` race in the CLI test suite.

Supersedes #253.
2026-04-25 08:06:16 +01:00
Miguel Salinas
1098b0d6ca
feat(claude): forward system prompt metadata
Adds Claude system prompt replace/append flags and persists the setting across acpx sessions.\n\nVerified locally with Node 24 / pnpm 10.33.2 full check and GitHub CI on 7453007.\n\nCo-authored-by: Miguel Salinas <mikeys@me.com>
2026-04-25 07:53:49 +01:00
Peter Steinberger
8afcc68c86
chore(deps): update dependencies
Updates all current dependencies and pnpm pin; adapts ACP SDK 0.20 session close API.\n\nVerified with local Node 24 pnpm 10.33.2 full check and CI.
2026-04-25 07:43:00 +01:00
Peter Steinberger
232999cd59
chore: prepare 0.6.0 release 2026-04-25 07:39:44 +01:00
Peter Steinberger
e0cb576534 ci: bump pnpm setup action to v6.0.3 (#266) 2026-04-25 07:29:49 +01:00
dependabot[bot]
9bd6421c29 chore(deps): bump @agentclientprotocol/sdk in the production group
Bumps the production group with 1 update: [@agentclientprotocol/sdk](https://github.com/agentclientprotocol/typescript-sdk).


Updates `@agentclientprotocol/sdk` from 0.18.1 to 0.18.2
- [Release notes](https://github.com/agentclientprotocol/typescript-sdk/releases)
- [Changelog](https://github.com/agentclientprotocol/typescript-sdk/blob/main/CHANGELOG.md)
- [Commits](https://github.com/agentclientprotocol/typescript-sdk/compare/v0.18.1...v0.18.2)

---
updated-dependencies:
- dependency-name: "@agentclientprotocol/sdk"
  dependency-version: 0.18.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: production
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-25 07:26:52 +01:00
Peter Steinberger
b03c475f1e fix(output): add text error remediation hints (#256) 2026-04-25 07:26:04 +01:00
Peter Steinberger
3a0007e99d fix(queue): randomize owner generations (#207) 2026-04-25 07:20:38 +01:00
Peter Steinberger
3d967954c1 fix(config): honor custom agent args (#199) 2026-04-25 07:14:20 +01:00
Peter Steinberger
b201fd13bb test(queue): cover owner-only queue dirs (#216) 2026-04-25 07:07:34 +01:00
gus
4aafd95551 fix: add explicit chmod after mkdir for existing directories
mkdir with recursive: true does not change permissions on directories
that already exist. An explicit chmod ensures the correct mode is
applied even if the directory was previously created with a more
permissive umask.
2026-04-25 07:07:34 +01:00
gus
98ae7f9f26 fix: restrict IPC socket directory permissions to owner-only
The queue socket directory at /tmp/acpx-<hash>/ and the queue base
directory at ~/.acpx/queues/ are created with mkdir without explicit
mode, inheriting the default umask (typically 0o755). This allows
other local users to list socket files and connect to them.

Set mode 0o700 on both directories so only the owning user can
access the IPC sockets.
2026-04-25 07:07:34 +01:00
Luke
351f1588c1 fix: require explicit ACPX auth envs for ACP auth selection 2026-04-25 07:03:19 +01:00
JunghwanNA
486f596acb Protect replay viewer file reads from run-id boundary escapes
The replay viewer already rejected relative-path traversal inside a selected
bundle, but it still trusted runId when resolving the bundle root. A crafted
runId could escape the configured runs directory and expose adjacent local
acpx files. This change validates the resolved run directory before serving
bundle files and adds unit plus HTTP regression coverage while preserving
normal in-root reads.

Constraint: Keep the fix narrow to replay-viewer server paths without changing bundle format or viewer semantics
Rejected: Sanitizing runId with a regex allowlist | would overfit naming conventions instead of enforcing the real filesystem boundary
Confidence: high
Scope-risk: narrow
Reversibility: clean
Directive: Preserve both checks: runDir must stay under runsDir and relativePath must stay under runDir
Tested: pnpm run build:test && node --test dist-test/test/replay-viewer-*.test.js; pnpm run viewer:typecheck; positive in-root HTTP read returned 200; out-of-root HTTP read returned 400
Not-tested: Full pnpm run check on Node >=22.12.0; public npm package build/install path outside local worktree
2026-04-25 06:58:43 +01:00
Peter Steinberger
9a540808bd fix(runtime): pool persistent ACP clients (#265) 2026-04-25 06:48:39 +01:00
Sway-Chan
03467406ec fix(runtime): retain persistent ACP client across turns
ensureSession places persistent clients into pendingPersistentClients
(keepClientOpen=true), but runTurn's finally unconditionally closes
the client, breaking lifecycle symmetry and causing per-send cold-start
for persistent sessions.

- runTurn finally: mirror the persistent branch symmetrically by
  returning the client to the pool when the record is persistent
  (recordId does not contain ":oneshot:"), the record is still open,
  and the backend reports a reusable session.
- close(): when invoked without discardPersistentState (e.g. via
  AcpxManager.evictIdleRuntimeHandles with reason="idle-evicted"),
  clean the pool so we do not leak pooled CLI processes.

Validated locally by applying equivalent edits to dist/register.runtime-*.js,
restarting the gateway, then running two turns against the same persistent
gemini-acp label: PID and agent_started_at remained stable across turns,
and the pooled process was reaped on the next unrelated spawn which
triggered evictIdleRuntimeHandles.
2026-04-25 06:48:39 +01:00
Peter Steinberger
051d5e25ff test(runtime): guard late ACP updates (#252) 2026-04-25 06:42:54 +01:00
logofet85-ai
c5dea03e96 test: add post-success drain guard coverage 2026-04-25 06:42:54 +01:00
Peter Steinberger
3f0c05dc2b fix(runtime): drain late ACP updates (#251) 2026-04-25 06:34:01 +01:00
Дмитрий
0b93bfedc0 fix(runtime): drain post-success session updates before closing prompt turn 2026-04-25 06:34:01 +01:00
dependabot[bot]
be5199464f
chore(deps-dev): bump @typescript/native-preview (#235)
Some checks failed
CI / scope (push) Has been cancelled
CI / ${{ matrix.name }} (pnpm run build, Build) (push) Has been cancelled
CI / ${{ matrix.name }} (pnpm run conformance:run -- --case acp.v1.initialize.handshake, Conformance Smoke) (push) Has been cancelled
CI / ${{ matrix.name }} (pnpm run format:check, Format) (push) Has been cancelled
CI / ${{ matrix.name }} (pnpm run lint, Lint) (push) Has been cancelled
CI / ${{ matrix.name }} (pnpm run test:coverage, Test, 22) (push) Has been cancelled
CI / ${{ matrix.name }} (pnpm run typecheck, Typecheck) (push) Has been cancelled
CI / Docs (push) Has been cancelled
Bumps [@typescript/native-preview](https://github.com/microsoft/typescript-go) from 7.0.0-dev.20260406.1 to 7.0.0-dev.20260407.1.
- [Changelog](https://github.com/microsoft/typescript-go/blob/main/CHANGES.md)
- [Commits](https://github.com/microsoft/typescript-go/commits)

---
updated-dependencies:
- dependency-name: "@typescript/native-preview"
  dependency-version: 7.0.0-dev.20260407.1
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-12 12:21:43 +02:00
dependabot[bot]
b9395bdf94
chore(deps-dev): bump the development group with 2 updates (#234)
Bumps the development group with 2 updates: [oxfmt](https://github.com/oxc-project/oxc/tree/HEAD/npm/oxfmt) and [oxlint](https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint).


Updates `oxfmt` from 0.43.0 to 0.44.0
- [Release notes](https://github.com/oxc-project/oxc/releases)
- [Changelog](https://github.com/oxc-project/oxc/blob/main/npm/oxfmt/CHANGELOG.md)
- [Commits](https://github.com/oxc-project/oxc/commits/oxfmt_v0.44.0/npm/oxfmt)

Updates `oxlint` from 1.58.0 to 1.59.0
- [Release notes](https://github.com/oxc-project/oxc/releases)
- [Changelog](https://github.com/oxc-project/oxc/blob/main/npm/oxlint/CHANGELOG.md)
- [Commits](https://github.com/oxc-project/oxc/commits/oxlint_v1.59.0/npm/oxlint)

---
updated-dependencies:
- dependency-name: oxfmt
  dependency-version: 0.44.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: development
- dependency-name: oxlint
  dependency-version: 1.59.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: development
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-12 12:19:07 +02:00
dependabot[bot]
63e561dcab
chore(deps): bump @agentclientprotocol/sdk in the production group (#233)
Bumps the production group with 1 update: [@agentclientprotocol/sdk](https://github.com/agentclientprotocol/typescript-sdk).


Updates `@agentclientprotocol/sdk` from 0.18.0 to 0.18.1
- [Release notes](https://github.com/agentclientprotocol/typescript-sdk/releases)
- [Changelog](https://github.com/agentclientprotocol/typescript-sdk/blob/main/CHANGELOG.md)
- [Commits](https://github.com/agentclientprotocol/typescript-sdk/compare/v0.18.0...v0.18.1)

---
updated-dependencies:
- dependency-name: "@agentclientprotocol/sdk"
  dependency-version: 0.18.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-12 12:16:38 +02:00
Onur
efdc6cc252
fix(ci): handle newer sdk and lint behavior (#237)
* fix(ci): handle newer sdk and lint behavior

* fix(ci): preserve error payload compatibility
2026-04-12 11:06:58 +02:00
dependabot[bot]
81ac6d3cc4
chore(deps-dev): bump the development group across 1 directory with 4 updates (#225)
Some checks failed
CI / scope (push) Has been cancelled
CI / ${{ matrix.name }} (pnpm run build, Build) (push) Has been cancelled
CI / ${{ matrix.name }} (pnpm run conformance:run -- --case acp.v1.initialize.handshake, Conformance Smoke) (push) Has been cancelled
CI / ${{ matrix.name }} (pnpm run format:check, Format) (push) Has been cancelled
CI / ${{ matrix.name }} (pnpm run lint, Lint) (push) Has been cancelled
CI / ${{ matrix.name }} (pnpm run test:coverage, Test, 22) (push) Has been cancelled
CI / ${{ matrix.name }} (pnpm run typecheck, Typecheck) (push) Has been cancelled
CI / Docs (push) Has been cancelled
Bumps the development group with 4 updates in the / directory: [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node), [oxfmt](https://github.com/oxc-project/oxc/tree/HEAD/npm/oxfmt), [oxlint](https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint) and [oxlint-tsgolint](https://github.com/oxc-project/tsgolint).


Updates `@types/node` from 25.5.0 to 25.5.2
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node)

Updates `oxfmt` from 0.42.0 to 0.43.0
- [Release notes](https://github.com/oxc-project/oxc/releases)
- [Changelog](https://github.com/oxc-project/oxc/blob/main/npm/oxfmt/CHANGELOG.md)
- [Commits](https://github.com/oxc-project/oxc/commits/oxfmt_v0.43.0/npm/oxfmt)

Updates `oxlint` from 1.57.0 to 1.58.0
- [Release notes](https://github.com/oxc-project/oxc/releases)
- [Changelog](https://github.com/oxc-project/oxc/blob/main/npm/oxlint/CHANGELOG.md)
- [Commits](https://github.com/oxc-project/oxc/commits/oxlint_v1.58.0/npm/oxlint)

Updates `oxlint-tsgolint` from 0.18.1 to 0.19.0
- [Release notes](https://github.com/oxc-project/tsgolint/releases)
- [Commits](https://github.com/oxc-project/tsgolint/compare/v0.18.1...v0.19.0)

---
updated-dependencies:
- dependency-name: "@types/node"
  dependency-version: 25.5.2
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: development
- dependency-name: oxfmt
  dependency-version: 0.43.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: development
- dependency-name: oxlint
  dependency-version: 1.58.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: development
- dependency-name: oxlint-tsgolint
  dependency-version: 0.19.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: development
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-09 00:41:15 +02:00
dependabot[bot]
7c5f5e4d13
chore(deps-dev): bump @typescript/native-preview (#213)
Bumps [@typescript/native-preview](https://github.com/microsoft/typescript-go) from 7.0.0-dev.20260328.1 to 7.0.0-dev.20260331.1.
- [Changelog](https://github.com/microsoft/typescript-go/blob/main/CHANGES.md)
- [Commits](https://github.com/microsoft/typescript-go/commits)

---
updated-dependencies:
- dependency-name: "@typescript/native-preview"
  dependency-version: 7.0.0-dev.20260331.1
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-09 00:40:47 +02:00
dependabot[bot]
6f2ac13dd7
chore(deps): bump @agentclientprotocol/sdk in the production group (#224)
Bumps the production group with 1 update: [@agentclientprotocol/sdk](https://github.com/agentclientprotocol/typescript-sdk).


Updates `@agentclientprotocol/sdk` from 0.17.1 to 0.18.0
- [Release notes](https://github.com/agentclientprotocol/typescript-sdk/releases)
- [Changelog](https://github.com/agentclientprotocol/typescript-sdk/blob/main/CHANGELOG.md)
- [Commits](https://github.com/agentclientprotocol/typescript-sdk/compare/v0.17.1...v0.18.0)

---
updated-dependencies:
- dependency-name: "@agentclientprotocol/sdk"
  dependency-version: 0.18.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-08 20:13:49 +02:00
Onur Solmaz
087f8207d5
chore: bump version to 0.5.3
Some checks failed
Release / release (push) Has been cancelled
2026-04-08 10:23:32 +01:00
Mason
edd9554468
chore: upgrade vite to 8.0.7 (#231) 2026-04-08 17:03:58 +08:00
Onur
bdd0f7ae16 Runtime: persist reset-on-next-ensure state
Some checks failed
Release / release (push) Has been cancelled
2026-04-07 11:33:37 +02:00
Onur
5031bf32f1 Runtime: harden backend reset close 2026-04-07 11:33:37 +02:00
Onur
2d05bc43e1 fix: close backend sessions on reset 2026-04-07 11:33:37 +02:00
158 changed files with 11136 additions and 6228 deletions

View File

@ -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 }}

View File

@ -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
View File

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

View File

@ -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 }}

View File

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

View File

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

View File

@ -1,55 +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
- 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)
@ -69,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
@ -82,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.
@ -104,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.
@ -114,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.
@ -130,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

1
CNAME Normal file
View File

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

View File

@ -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
View 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.

View File

@ -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`

View 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
}
}

View File

@ -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": []
}

View File

@ -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,

View File

@ -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.

View File

@ -43,11 +43,11 @@ acpx [global_options] <agent> sessions [list | new [--name <name>] | ensure [--n
`<agent>` can be:
- built-in friendly name from [../README.md](../README.md)
- built-in friendly name from [the README](https://github.com/openclaw/acpx/blob/main/README.md)
- unknown token (treated as raw command)
- overridden by `--agent <command>` escape hatch
Additional built-in agent docs live in [../agents/README.md](../agents/README.md).
Additional built-in agent docs live in [the Agents page](agents.md).
Prompt options:
@ -102,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
View File

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

198
docs/agents.md Normal file
View File

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

187
docs/config.md Normal file
View File

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

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

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

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

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

198
docs/flows.md Normal file
View File

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

55
docs/index.md Normal file
View File

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

108
docs/install.md Normal file
View File

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

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

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

137
docs/permissions.md Normal file
View File

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

181
docs/prompting.md Normal file
View File

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

136
docs/quickstart.md Normal file
View File

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

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

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

211
docs/sessions.md Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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))
);
}

View File

@ -84,7 +84,7 @@ export async function createReplayViewerServer(
response.setHeader("content-type", "application/json; charset=utf-8");
response.end(
JSON.stringify({
error: error instanceof Error ? error.message : String(error),
error: formatPublicError(error),
}),
);
return;
@ -189,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";

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

4347
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -1,6 +1,6 @@
{
"name": "acpx",
"version": "0.5.1",
"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.3",
"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"
}

1333
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

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

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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;
}

View File

@ -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)

View File

@ -26,6 +26,7 @@ import {
type WaitForTerminalExitResponse,
type WriteTextFileRequest,
type WriteTextFileResponse,
type SessionConfigOption,
type SessionModelState,
} from "@agentclientprotocol/sdk";
import { resolveBuiltInAgentLaunch } from "../agent-registry.js";
@ -64,6 +65,7 @@ import {
isQoderAcpCommand,
resolveAgentCloseAfterStdinEndMs,
resolveClaudeAcpSessionCreateTimeoutMs,
resolveClaudeCodeExecutable,
resolveGeminiAcpStartupTimeoutMs,
resolveGeminiCommandArgs,
shouldIgnoreNonJsonAgentOutputLine,
@ -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;
};
@ -331,6 +336,10 @@ export class AcpClient {
return Boolean(this.initResult?.agentCapabilities?.loadSession);
}
supportsCloseSession(): boolean {
return Boolean(this.initResult?.agentCapabilities?.sessionCapabilities?.close);
}
setEventHandlers(
handlers: Pick<
AcpClientOptions,
@ -347,6 +356,7 @@ export class AcpClient {
updateRuntimeOptions(options: {
permissionMode?: PermissionMode;
nonInteractivePermissions?: NonInteractivePermissionPolicy;
terminal?: boolean;
suppressSdkConsoleErrors?: boolean;
verbose?: boolean;
}): void {
@ -356,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,
@ -427,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 {
@ -520,7 +543,7 @@ export class AcpClient {
readTextFile: true,
writeTextFile: true,
},
terminal: true,
terminal: this.options.terminal !== false,
},
clientInfo: {
name: "acpx",
@ -544,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 {
@ -558,7 +582,7 @@ export class AcpClient {
},
);
}
throw error;
throw normalizedError;
}
}
@ -621,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),
}),
@ -649,6 +674,7 @@ export class AcpClient {
return {
sessionId: result.sessionId,
agentSessionId: extractRuntimeSessionId(result._meta),
configOptions: result.configOptions ?? undefined,
models: result.models ?? undefined,
};
}
@ -664,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);
@ -676,7 +703,7 @@ export class AcpClient {
response = await this.runConnectionRequest(() =>
connection.loadSession({
sessionId,
cwd: asAbsoluteCwd(cwd),
cwd: sessionCwd,
mcpServers: this.options.mcpServers ?? [],
}),
);
@ -694,6 +721,7 @@ export class AcpClient {
return {
agentSessionId: extractRuntimeSessionId(response?._meta),
configOptions: response?.configOptions ?? undefined,
models: response?.models ?? undefined,
};
}
@ -827,6 +855,18 @@ export class AcpClient {
);
}
async closeSession(sessionId: string): Promise<void> {
const connection = this.getConnection();
await this.runConnectionRequest(() =>
connection.closeSession({
sessionId,
}),
);
if (this.loadedSessionId === sessionId) {
this.loadedSessionId = undefined;
}
}
async requestCancelActivePrompt(): Promise<boolean> {
const active = this.activePrompt;
if (!active) {
@ -1071,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);

View File

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

View File

@ -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
View File

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

View File

@ -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> {

View File

@ -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 = {

View File

@ -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`);
}
}
}
}

View File

@ -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 };

View File

@ -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(

View File

@ -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;

View File

@ -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,

View File

@ -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;

View File

@ -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;
} {

View File

@ -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) => {

View File

@ -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;

View File

@ -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);
}
}

View File

@ -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;

View File

@ -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;
}

View File

@ -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;

View File

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

View File

@ -2,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,

View File

@ -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,
};
}

View File

@ -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 () => {

View File

@ -4,6 +4,7 @@ import {
isRetryablePromptError,
normalizeOutputError,
} from "../../acp/error-normalization.js";
import { assertRequestedModelSupported } from "../../acp/model-support.js";
import { InterruptedError, withInterrupt, withTimeout } from "../../async-control.js";
export { InterruptedError, TimeoutError } from "../../async-control.js";
import { formatPerfMetric, measurePerf, startPerfTimer } from "../../perf-metrics.js";
@ -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,

View File

@ -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 (

View File

@ -1,6 +1,7 @@
import { AcpClient, type SessionCreateResult } from "../../acp/client.js";
import { formatErrorMessage } from "../../acp/error-normalization.js";
import { withInterrupt, withTimeout } from "../../async-control.js";
import { applyConfigOptionsToRecord } from "../../session/config-options.js";
import { createSessionConversation } from "../../session/conversation-model.js";
import { defaultSessionEventLog } from "../../session/event-log.js";
import { setCurrentModelId, syncAdvertisedModelState } from "../../session/mode-preference.js";
@ -21,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,

View File

@ -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`);
}

View File

@ -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, {

View File

@ -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 {

View File

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

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

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

View File

@ -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;
}

View File

@ -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[] = [];

View File

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

View File

@ -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"]): {

View File

@ -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 {

View File

@ -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",
]);

View File

@ -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,34 +246,26 @@ 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: { handle: AcpRuntimeHandle; reason: string }): Promise<void> {
const state = this.resolveHandleState(input.handle);
async close(input: {
handle: AcpRuntimeHandle;
reason: string;
discardPersistentState?: boolean;
}): Promise<void> {
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,
await manager.close(handle, {
discardPersistentState: input.discardPersistentState,
});
}
@ -250,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) {

View File

@ -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),
});

View File

@ -2,7 +2,10 @@ import { randomUUID } from "node:crypto";
import path from "node:path";
import { AcpClient } from "../../acp/client.js";
import { normalizeOutputError } from "../../acp/error-normalization.js";
import { extractAcpError, isAcpResourceNotFoundError } from "../../acp/error-shapes.js";
import { withTimeout } from "../../async-control.js";
import { textPrompt, type PromptInput } from "../../prompt-content.js";
import { applyConfigOptionsToRecord } from "../../session/config-options.js";
import {
cloneSessionAcpxState,
cloneSessionConversation,
@ -13,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,
@ -22,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";
@ -93,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;
@ -120,6 +129,21 @@ function isoNow(): string {
return new Date().toISOString();
}
function isUnsupportedSessionCloseError(error: unknown): boolean {
const acp = extractAcpError(error);
if (!acp) {
return false;
}
if (acp.code === -32601 || acp.code === -32602) {
return true;
}
if (acp.code !== -32603 || !acp.data || typeof acp.data !== "object") {
return false;
}
const details = (acp.data as { details?: unknown }).details;
return typeof details === "string" && details.toLowerCase().includes("invalid params");
}
function toPromptInput(
text: string,
attachments?: AcpRuntimeTurnAttachment[],
@ -188,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}`,
@ -202,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,
@ -212,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;
@ -233,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;
}
@ -251,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),
@ -268,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") {
@ -286,7 +435,7 @@ export class AcpRuntimeManager {
}
}
async *runTurn(input: {
startTurn(input: {
handle: AcpRuntimeHandle;
text: string;
attachments?: AcpRuntimeTurnAttachment[];
@ -295,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",
@ -409,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,
@ -425,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();
@ -442,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",
@ -456,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> {
@ -530,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) }
: {}),
},
};
}
@ -545,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);
@ -577,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);
}
@ -611,14 +863,71 @@ export class AcpRuntimeManager {
await controller?.requestCancelActivePrompt();
}
async close(handle: AcpRuntimeHandle): Promise<void> {
async close(
handle: AcpRuntimeHandle,
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);
record.acpx = {
...record.acpx,
reset_on_next_ensure: true,
};
} else {
await this.closePendingPersistentClient(record.acpxRecordId);
}
record.closed = true;
record.closedAt = isoNow();
await this.options.sessionStore.save(record);
}
private async closeBackendSession(record: SessionRecord): Promise<void> {
const pendingClient = await this.readPendingPersistentClient(record, { consume: true });
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,
});
try {
if (!pendingClient) {
await withTimeout(client.start(), this.options.timeoutMs);
}
if (!client.supportsCloseSession()) {
throw new AcpRuntimeError(
"ACP_BACKEND_UNSUPPORTED_CONTROL",
`Agent does not support session/close for ${record.acpxRecordId}.`,
);
}
await withTimeout(client.closeSession(record.acpSessionId), this.options.timeoutMs);
} catch (error) {
if (isUnsupportedSessionCloseError(error)) {
throw new AcpRuntimeError(
"ACP_BACKEND_UNSUPPORTED_CONTROL",
`Agent does not support session/close for ${record.acpxRecordId}.`,
{ cause: error },
);
}
if (isAcpResourceNotFoundError(error)) {
return;
}
throw error;
} finally {
await client.close().catch(() => {});
}
}
private async requireRecord(sessionId: string): Promise<SessionRecord> {
const record = await this.options.sessionStore.load(sessionId);
if (!record) {

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