From 0476eb269fdb60354e546705dee15e45af613bcc Mon Sep 17 00:00:00 2001 From: kelvinschen Date: Tue, 21 Jul 2026 17:05:00 +0800 Subject: [PATCH 01/57] chore(agents): bump codex-acp adapter range --- CHANGELOG.md | 2 ++ README.md | 3 ++- agents/Codex.md | 7 ++++--- docs/session-control.md | 5 ++++- skills/acpx/SKILL.md | 8 ++++---- src/agent-registry.ts | 2 +- test/agent-registry.test.ts | 4 ++-- 7 files changed, 19 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 17146379..7ac53708 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,8 @@ Repo: https://github.com/openclaw/acpx ### Changes +- Agents/built-ins: bump the default `@agentclientprotocol/codex-acp` package range to `^1.1.4` so fresh built-in Codex launches use the current stable adapter line. + ### Breaking ### Fixes diff --git a/README.md b/README.md index d67105d9..11e1af66 100644 --- a/README.md +++ b/README.md @@ -148,7 +148,8 @@ acpx codex --file - "extra context" # explicit stdin + appended args acpx codex --no-wait 'draft test migration plan' # enqueue without waiting if session is busy acpx codex cancel # cooperative cancel of in-flight prompt acpx codex set-mode auto # session/set_mode (adapter-defined mode id) -acpx codex set model 'gpt-5.2[high]' # adapter-advertised model control +acpx codex set model gpt-5.6-sol # select the advertised base model +acpx codex set reasoning_effort max # set the advertised reasoning effort acpx exec 'summarize this repo' # default agent shortcut (codex) acpx codex exec 'what does this repo do?' # one-shot, no saved session diff --git a/agents/Codex.md b/agents/Codex.md index 5968bf5e..064deebb 100644 --- a/agents/Codex.md +++ b/agents/Codex.md @@ -3,6 +3,7 @@ - Built-in name: `codex` - Default command: `npx -y @agentclientprotocol/codex-acp` - Upstream: https://github.com/agentclientprotocol/codex-acp -- Runtime controls exposed by current codex-acp releases include ACP modes and an advertised model session config option. -- Reasoning effort is encoded in advertised Codex model ids such as `gpt-5.2[high]` when the adapter reports those variants. -- `acpx --model codex ...` and `acpx codex set model ` apply the requested model through the advertised config option; legacy adapters that advertise `models` use `session/set_model`. +- ACPX owns the built-in package range so fresh launches use the repository-selected stable adapter line without requiring a global install. +- Runtime controls exposed by current codex-acp releases include ACP modes plus separate `model` and `reasoning_effort` session config options. +- Use the advertised base model id with `acpx --model codex ...` or `acpx codex set model `, then set reasoning effort separately with `acpx codex set reasoning_effort `. +- Legacy `models` metadata may encode both values in a combined id such as `gpt-5.6-sol[max]`; ACPX uses that form only when the adapter does not advertise the newer model config option. diff --git a/docs/session-control.md b/docs/session-control.md index 3a34ee71..543df5a8 100644 --- a/docs/session-control.md +++ b/docs/session-control.md @@ -54,8 +54,11 @@ Calls ACP `session/set_config_option` with the literal `` and ``. No `set model ` is a special-case interception. `acpx` prefers an advertised model session config option and updates it through `session/set_config_option`. If an adapter explicitly advertises legacy `models` metadata instead, `acpx` preserves compatibility through `session/set_model`. +Current codex-acp releases advertise the base model and reasoning effort as separate config options. + ```bash -acpx codex set model 'gpt-5.2[high]' +acpx codex set model gpt-5.6-sol +acpx codex set reasoning_effort max acpx claude set model claude-sonnet-4-6 ``` diff --git a/skills/acpx/SKILL.md b/skills/acpx/SKILL.md index 6daa6cd8..c8b01072 100644 --- a/skills/acpx/SKILL.md +++ b/skills/acpx/SKILL.md @@ -83,7 +83,7 @@ Friendly agent names resolve to commands: - `pi` -> `npx pi-acp` - `openclaw` -> `openclaw acp` -- `codex` -> `npx -y @agentclientprotocol/codex-acp` +- `codex` -> `npx -y @agentclientprotocol/codex-acp` (ACPX-owned package range) - `claude` -> `npx -y @agentclientprotocol/claude-agent-acp` (ACPX-owned package range) - `gemini` -> `gemini --acp` - `cursor` -> `cursor-agent acp` @@ -174,8 +174,8 @@ Behavior: ```bash acpx codex cancel acpx codex set-mode auto -acpx codex set model gpt-5.2[high] -acpx codex set model gpt-5.4 +acpx codex set model gpt-5.6-sol +acpx codex set reasoning_effort max ``` Behavior: @@ -184,7 +184,7 @@ Behavior: - `set-mode`: calls ACP `session/set_mode`. - `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, reasoning effort is selected through advertised ACP model ids when the adapter reports model variants. +- Current codex-acp releases expose `model` and `reasoning_effort` as separate config options. - `--model `: Claude-compatible adapters may consume session creation metadata; other agents must advertise a model config option or legacy `models` metadata. - `set model `: uses `session/set_config_option` for advertised model config options and preserves `session/set_model` for explicitly advertised legacy models. - `set-mode`/`set` route through queue-owner IPC when active, otherwise reconnect directly. diff --git a/src/agent-registry.ts b/src/agent-registry.ts index 279aa24e..5029b977 100644 --- a/src/agent-registry.ts +++ b/src/agent-registry.ts @@ -4,7 +4,7 @@ import { fileURLToPath } from "node:url"; const ACP_ADAPTER_PACKAGE_RANGES = { pi: "^0.0.26", - codex: "^0.0.44", + codex: "^1.1.4", claude: "^0.37.0", mux: "^0.27.0", } as const; diff --git a/test/agent-registry.test.ts b/test/agent-registry.test.ts index 6434673e..9178d9d9 100644 --- a/test/agent-registry.test.ts +++ b/test/agent-registry.test.ts @@ -102,8 +102,8 @@ test("claude built-in uses the current ACP adapter package range", () => { }); test("npm-backed built-ins use current adapter package ranges", () => { - assert.equal(BUILT_IN_AGENT_PACKAGES.codex.packageRange, "^0.0.44"); - assert.equal(AGENT_REGISTRY.codex, "npx -y @agentclientprotocol/codex-acp@^0.0.44"); + assert.equal(BUILT_IN_AGENT_PACKAGES.codex.packageRange, "^1.1.4"); + assert.equal(AGENT_REGISTRY.codex, "npx -y @agentclientprotocol/codex-acp@^1.1.4"); assert.equal(AGENT_REGISTRY.pi, "npx pi-acp@^0.0.26"); }); From c5a97232e1da7822b04469488929cd25dc9b6b95 Mon Sep 17 00:00:00 2001 From: Vitor Cepeda Lopes Date: Tue, 21 Jul 2026 17:48:15 +0100 Subject: [PATCH 02/57] chore(agents): refresh built-in adapter ranges --- CHANGELOG.md | 2 +- README.md | 2 +- agents/Mux.md | 2 +- agents/README.md | 4 ++-- docs/agents.md | 4 ++-- skills/acpx/SKILL.md | 2 +- src/agent-registry.ts | 8 ++++---- test/agent-registry.test.ts | 14 +++++++------- test/model-support.test.ts | 2 +- 9 files changed, 20 insertions(+), 20 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7ac53708..037ef4d7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,7 @@ Repo: https://github.com/openclaw/acpx ### Changes -- Agents/built-ins: bump the default `@agentclientprotocol/codex-acp` package range to `^1.1.4` so fresh built-in Codex launches use the current stable adapter line. +- Agents/built-ins: refresh the default Pi, Codex, Claude, and Mux adapter ranges. Thanks @kelvinschen. ### Breaking diff --git a/README.md b/README.md index 11e1af66..5cd47c79 100644 --- a/README.md +++ b/README.md @@ -362,7 +362,7 @@ Built-ins: | `kilocode` | `npx -y @kilocode/cli acp` | [Kilocode](https://kilocode.ai) | | `kimi` | native (`kimi acp`) | [Kimi CLI](https://github.com/MoonshotAI/kimi-cli) | | `kiro` | native (`kiro-cli-chat acp`) | [Kiro CLI](https://kiro.dev) | -| `mux` | `npx -y mux@^0.27.0 acp` | [Mux](https://mux.coder.com) | +| `mux` | `mux acp` via an ACPX-owned npm range | [Mux](https://mux.coder.com) | | `opencode` | `npx -y opencode-ai acp` | [OpenCode](https://opencode.ai) | | `qoder` | native (`qodercli --acp`) | [Qoder CLI](https://docs.qoder.com/cli/acp) | | `qwen` | native (`qwen --acp`) | [Qwen Code](https://github.com/QwenLM/qwen-code) | diff --git a/agents/Mux.md b/agents/Mux.md index 233a37f3..343bffc7 100644 --- a/agents/Mux.md +++ b/agents/Mux.md @@ -1,7 +1,7 @@ # Mux - Built-in name: `mux` -- Default command: `npx -y mux@^0.27.0 acp` +- Default entrypoint: `mux acp` via an ACPX-owned npm range - Upstream: https://mux.coder.com/integrations/acp `acpx mux` starts coder/mux through its ACP stdio bridge (`mux acp`). `mux acp` auto-starts an in-process mux server, so a separate `mux server` is not required. diff --git a/agents/README.md b/agents/README.md index 42cdcee8..d834b9ae 100644 --- a/agents/README.md +++ b/agents/README.md @@ -16,7 +16,7 @@ Built-in agents: - `kilocode -> npx -y @kilocode/cli acp` - `kimi -> kimi acp` - `kiro -> kiro-cli-chat acp` -- `mux -> npx -y mux@^0.27.0 acp` +- `mux -> mux acp` via an ACPX-owned npm range - `opencode -> npx -y opencode-ai acp` - `qoder -> qodercli --acp` - `qwen -> qwen --acp` @@ -36,7 +36,7 @@ Harness-specific docs in this directory: - [Kilocode](Kilocode.md): built-in `kilocode -> npx -y @kilocode/cli acp` - [Kimi](Kimi.md): built-in `kimi -> kimi acp` - [Kiro](Kiro.md): built-in `kiro -> kiro-cli-chat acp` -- [Mux](Mux.md): built-in `mux -> npx -y mux@^0.27.0 acp` +- [Mux](Mux.md): built-in `mux -> mux acp` via an ACPX-owned npm range - [OpenCode](OpenCode.md): built-in `opencode -> npx -y opencode-ai acp` - [Qoder](Qoder.md): built-in `qoder -> qodercli --acp` - [Qwen](Qwen.md): built-in `qwen -> qwen --acp` diff --git a/docs/agents.md b/docs/agents.md index 4990f51e..2cae34ac 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -25,7 +25,7 @@ The default agent for top-level commands like `acpx exec …` and `acpx prompt | `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) | -| `mux` | `npx -y mux@^0.27.0 acp` | [Mux](https://mux.coder.com) | +| `mux` | `mux acp` via an ACPX-owned npm range | [Mux](https://mux.coder.com) | | `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) | @@ -179,7 +179,7 @@ Configure model/provider settings through fast-agent environment variables, fast ### Mux - Built-in name: `mux` -- Default command: `npx -y mux@^0.27.0 acp` +- Default entrypoint: `mux acp` via an ACPX-owned npm range - Upstream: https://mux.coder.com/integrations/acp `acpx mux` starts coder/mux through its ACP stdio bridge (`mux acp`). `mux acp` auto-starts an in-process mux server, so a separate `mux server` is not required. diff --git a/skills/acpx/SKILL.md b/skills/acpx/SKILL.md index c8b01072..7bc029bd 100644 --- a/skills/acpx/SKILL.md +++ b/skills/acpx/SKILL.md @@ -95,7 +95,7 @@ Friendly agent names resolve to commands: - `kilocode` -> `npx -y @kilocode/cli acp` - `kimi` -> `kimi acp` - `kiro` -> `kiro-cli-chat acp` -- `mux` -> `npx -y mux@^0.27.0 acp` +- `mux` -> `mux acp` via an ACPX-owned npm range - `opencode` -> `npx -y opencode-ai acp` - `qoder` -> `qodercli --acp` Forwards Qoder-native `--allowed-tools` and `--max-turns` startup flags from `acpx` session options. diff --git a/src/agent-registry.ts b/src/agent-registry.ts index 5029b977..ca42fa1c 100644 --- a/src/agent-registry.ts +++ b/src/agent-registry.ts @@ -3,10 +3,10 @@ import path from "node:path"; import { fileURLToPath } from "node:url"; const ACP_ADAPTER_PACKAGE_RANGES = { - pi: "^0.0.26", - codex: "^1.1.4", - claude: "^0.37.0", - mux: "^0.27.0", + pi: "^0.0.31", + codex: "^1.1.5", + claude: "^0.60.0", + mux: "^0.28.0", } as const; type BuiltInAgentPackageSpec = { diff --git a/test/agent-registry.test.ts b/test/agent-registry.test.ts index 9178d9d9..f8f564d5 100644 --- a/test/agent-registry.test.ts +++ b/test/agent-registry.test.ts @@ -60,8 +60,8 @@ test("grok-build built-in runs the Grok Build ACP entrypoint", () => { }); test("mux built-in runs the coder/mux ACP stdio bridge through npx", () => { - assert.equal(AGENT_REGISTRY.mux, "npx -y mux@^0.27.0 acp"); - assert.equal(resolveAgentCommand("mux"), "npx -y mux@^0.27.0 acp"); + assert.equal(AGENT_REGISTRY.mux, "npx -y mux@^0.28.0 acp"); + assert.equal(resolveAgentCommand("mux"), "npx -y mux@^0.28.0 acp"); }); test("listBuiltInAgents preserves the required example prefix and alphabetical tail", () => { @@ -97,14 +97,14 @@ test("default agent is codex", () => { }); test("claude built-in uses the current ACP adapter package range", () => { - assert.equal(BUILT_IN_AGENT_PACKAGES.claude.packageRange, "^0.37.0"); - assert.equal(AGENT_REGISTRY.claude, "npx -y @agentclientprotocol/claude-agent-acp@^0.37.0"); + assert.equal(BUILT_IN_AGENT_PACKAGES.claude.packageRange, "^0.60.0"); + assert.equal(AGENT_REGISTRY.claude, "npx -y @agentclientprotocol/claude-agent-acp@^0.60.0"); }); test("npm-backed built-ins use current adapter package ranges", () => { - assert.equal(BUILT_IN_AGENT_PACKAGES.codex.packageRange, "^1.1.4"); - assert.equal(AGENT_REGISTRY.codex, "npx -y @agentclientprotocol/codex-acp@^1.1.4"); - assert.equal(AGENT_REGISTRY.pi, "npx pi-acp@^0.0.26"); + assert.equal(BUILT_IN_AGENT_PACKAGES.codex.packageRange, "^1.1.5"); + assert.equal(AGENT_REGISTRY.codex, "npx -y @agentclientprotocol/codex-acp@^1.1.5"); + assert.equal(AGENT_REGISTRY.pi, "npx pi-acp@^0.0.31"); }); test("resolveInstalledBuiltInAgentLaunch uses a locally installed adapter when available", (t) => { diff --git a/test/model-support.test.ts b/test/model-support.test.ts index 2e1cb0b2..72cfe7a4 100644 --- a/test/model-support.test.ts +++ b/test/model-support.test.ts @@ -18,7 +18,7 @@ test("Claude ACP model validation warns for unadvertised selectors", () => { { modelId: "sonnet", name: "Sonnet" }, ], }, - agentCommand: "npx -y @agentclientprotocol/claude-agent-acp@^0.37.0", + agentCommand: "npx -y @agentclientprotocol/claude-agent-acp@^0.60.0", context: "apply", }); From e8681c9c8758862f764c061d81bac7df6f892727 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 21 Jul 2026 23:07:55 -0700 Subject: [PATCH 03/57] docs(changelog): credit adapter refresh contributors --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 037ef4d7..871a501f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,7 @@ Repo: https://github.com/openclaw/acpx ### Changes -- Agents/built-ins: refresh the default Pi, Codex, Claude, and Mux adapter ranges. Thanks @kelvinschen. +- Agents/built-ins: refresh the default Pi, Codex, Claude, and Mux adapter ranges. Thanks @kelvinschen and @TheAngryPit. ### Breaking From 26b740e810389428a7091cdb1bdd2b66090fe901 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 23 Jul 2026 06:03:31 -0700 Subject: [PATCH 04/57] chore(release): prepare acpx 0.12.1 --- CHANGELOG.md | 8 ++++++++ package.json | 2 +- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 871a501f..9c2f7458 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,14 @@ Repo: https://github.com/openclaw/acpx ### Changes +### Breaking + +### Fixes + +## 2026.7.23 (v0.12.1) + +### Changes + - Agents/built-ins: refresh the default Pi, Codex, Claude, and Mux adapter ranges. Thanks @kelvinschen and @TheAngryPit. ### Breaking diff --git a/package.json b/package.json index 3a5bd528..42b4902f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "acpx", - "version": "0.12.0", + "version": "0.12.1", "description": "Headless CLI client for the Agent Client Protocol (ACP) — talk to coding agents from the command line", "keywords": [ "acp", From bbd551912df1c1603f8b0ad98de607ebe95ee44b Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 26 Jul 2026 18:52:47 -0700 Subject: [PATCH 05/57] ci: add ClawSweeper dispatch workflow --- .github/workflows/clawsweeper-dispatch.yml | 202 +++++++++++++++++++++ 1 file changed, 202 insertions(+) create mode 100644 .github/workflows/clawsweeper-dispatch.yml diff --git a/.github/workflows/clawsweeper-dispatch.yml b/.github/workflows/clawsweeper-dispatch.yml new file mode 100644 index 00000000..5af378a9 --- /dev/null +++ b/.github/workflows/clawsweeper-dispatch.yml @@ -0,0 +1,202 @@ +name: ClawSweeper Dispatch + +on: + issues: + types: [opened, reopened, edited, labeled, unlabeled] + issue_comment: + types: [created, edited] + pull_request_target: # zizmor: ignore[dangerous-triggers] maintainer-owned external dispatch; no checkout or untrusted PR code execution + types: [opened, reopened, synchronize, ready_for_review, edited, labeled, unlabeled] + +permissions: + contents: read + +concurrency: + group: clawsweeper-dispatch-${{ github.repository }}-${{ github.event.issue.number || github.event.pull_request.number || github.run_id }} + cancel-in-progress: ${{ github.event.action == 'edited' || github.event.action == 'synchronize' || github.event.action == 'ready_for_review' }} + +jobs: + dispatch: + runs-on: ubuntu-latest + if: ${{ !(endsWith(github.actor, '[bot]') && (github.event.action == 'labeled' || github.event.action == 'unlabeled')) }} + env: + HAS_CLAWSWEEPER_APP_PRIVATE_KEY: ${{ secrets.CLAWSWEEPER_APP_PRIVATE_KEY != '' }} + CLAWSWEEPER_APP_CLIENT_ID: Iv23liOECG0slfuhz093 + SUPERSEDES_IN_PROGRESS: ${{ (github.event.action == 'edited' || github.event.action == 'synchronize' || github.event.action == 'ready_for_review') && 'true' || 'false' }} + steps: + - name: Debounce bursty metadata events + if: ${{ github.event.action == 'labeled' || github.event.action == 'unlabeled' }} + run: sleep 20 + + - name: Create ClawSweeper dispatch token + id: token + if: ${{ env.HAS_CLAWSWEEPER_APP_PRIVATE_KEY == 'true' }} + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 + with: + client-id: ${{ env.CLAWSWEEPER_APP_CLIENT_ID }} + private-key: ${{ secrets.CLAWSWEEPER_APP_PRIVATE_KEY }} + owner: openclaw + repositories: clawsweeper + permission-contents: write + + - name: Pre-filter ClawSweeper comment + id: comment_filter + if: ${{ github.event_name == 'issue_comment' }} + env: + COMMENT_BODY: ${{ github.event.comment.body }} + run: | + set -euo pipefail + if grep -Eiq '(^|[[:space:]])@(clawsweeper|openclaw-clawsweeper)\b(\[bot\])?|(^|[[:space:]])/(clawsweeper|review|autoclose|auto([[:space:]]+|-)?merge)\b' <<< "$COMMENT_BODY"; then + echo "is_command=true" >> "$GITHUB_OUTPUT" + else + echo "is_command=false" >> "$GITHUB_OUTPUT" + fi + + - name: Create target comment token + id: target_token + if: >- + ${{ + github.event_name == 'issue_comment' && + steps.comment_filter.outputs.is_command == 'true' && + env.HAS_CLAWSWEEPER_APP_PRIVATE_KEY == 'true' + }} + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 + with: + client-id: ${{ env.CLAWSWEEPER_APP_CLIENT_ID }} + private-key: ${{ secrets.CLAWSWEEPER_APP_PRIVATE_KEY }} + owner: ${{ github.repository_owner }} + repositories: ${{ github.event.repository.name }} + permission-issues: write + permission-pull-requests: read + + - name: Dispatch exact ClawSweeper review + if: ${{ github.event_name != 'issue_comment' }} + env: + GH_TOKEN: ${{ steps.token.outputs.token }} + TARGET_REPO: ${{ github.repository }} + ITEM_NUMBER: ${{ github.event.issue.number || github.event.pull_request.number }} + ITEM_KIND: ${{ github.event_name == 'pull_request_target' && 'pull_request' || 'issue' }} + SOURCE_EVENT: ${{ github.event_name }} + SOURCE_ACTION: ${{ github.event.action }} + run: | + if [ -z "$GH_TOKEN" ]; then + echo "::notice::Skipping ClawSweeper dispatch because no dispatch credential is configured." + exit 0 + fi + ingress_fingerprint="$(node <<'NODE' + const crypto = require("node:crypto"); + const fs = require("node:fs"); + const event = JSON.parse(fs.readFileSync(process.env.GITHUB_EVENT_PATH, "utf8")); + const pullRequest = event.pull_request && typeof event.pull_request === "object" + ? event.pull_request + : {}; + const headSha = String(pullRequest.head?.sha || "").trim().toLowerCase(); + const updatedAt = String(pullRequest.updated_at || "").trim(); + if ( + process.env.ITEM_KIND !== "pull_request" || + !/^[0-9a-f]{40}$/.test(headSha) || + !updatedAt + ) { + process.stdout.write(""); + } else { + process.stdout.write( + crypto + .createHash("sha256") + .update( + JSON.stringify({ + version: 1, + target_repo: String(process.env.TARGET_REPO || "").toLowerCase(), + item_number: Number(process.env.ITEM_NUMBER), + action: String(process.env.SOURCE_ACTION || ""), + head_sha: headSha, + updated_at: updatedAt, + body: typeof pullRequest.body === "string" ? pullRequest.body : "", + label: String(event.label?.name || ""), + }), + ) + .digest("hex"), + ); + } + NODE + )" + payload="$(jq -nc \ + --arg target_repo "$TARGET_REPO" \ + --argjson item_number "$ITEM_NUMBER" \ + --arg item_kind "$ITEM_KIND" \ + --arg source_event "$SOURCE_EVENT" \ + --arg source_action "$SOURCE_ACTION" \ + --arg ingress_fingerprint "$ingress_fingerprint" \ + --argjson supersedes_in_progress "$SUPERSEDES_IN_PROGRESS" \ + '{event_type:"clawsweeper_item",client_payload:({target_repo:$target_repo,item_number:$item_number,item_kind:$item_kind,source_event:$source_event,source_action:$source_action,supersedes_in_progress:$supersedes_in_progress} + (if $ingress_fingerprint != "" then {ingress_route:"target_dispatcher",ingress_fingerprint:$ingress_fingerprint} else {} end))}')" + gh api repos/openclaw/clawsweeper/dispatches \ + --method POST \ + --input - <<< "$payload" + + - name: Acknowledge and dispatch ClawSweeper comment + if: >- + ${{ + github.event_name == 'issue_comment' && + steps.comment_filter.outputs.is_command == 'true' + }} + env: + DISPATCH_TOKEN: ${{ steps.token.outputs.token }} + TARGET_TOKEN: ${{ steps.target_token.outputs.token }} + TARGET_REPO: ${{ github.repository }} + ITEM_NUMBER: ${{ github.event.issue.number }} + COMMENT_ID: ${{ github.event.comment.id }} + COMMENT_BODY: ${{ github.event.comment.body }} + AUTHOR_ASSOCIATION: ${{ github.event.comment.author_association }} + SOURCE_ACTION: ${{ github.event.action }} + run: | + if [ -z "$DISPATCH_TOKEN" ]; then + echo "::notice::Skipping ClawSweeper dispatch because no dispatch credential is configured." + exit 0 + fi + body_file="$RUNNER_TEMP/clawsweeper-comment-body.txt" + printf '%s\n' "$COMMENT_BODY" > "$body_file" + if grep -Eiq ')' "$body_file"; then + echo "Ignoring ClawSweeper proof-nudge comment." + exit 0 + fi + if [ -n "$TARGET_TOKEN" ]; then + GH_TOKEN="$TARGET_TOKEN" gh api -X POST \ + -H "Accept: application/vnd.github+json" \ + "repos/$TARGET_REPO/issues/comments/$COMMENT_ID/reactions" \ + -f content="eyes" >/dev/null || true + fi + status_comment_id="" + if [ -n "$TARGET_TOKEN" ]; then + case "$AUTHOR_ASSOCIATION" in + OWNER|MEMBER|COLLABORATOR) + status_body="$(printf '%s\n' \ + "" \ + "🦞👀" \ + "ClawSweeper picked this up." \ + "" \ + "Command router queued. I will update this comment with the next step.")" + status_payload="$(jq -nc --arg body "$status_body" '{body:$body}')" + status_err="$(mktemp)" + if status_response="$(GH_TOKEN="$TARGET_TOKEN" gh api \ + "repos/$TARGET_REPO/issues/$ITEM_NUMBER/comments" \ + --method POST \ + --input - <<< "$status_payload" 2>"$status_err")"; then + status_comment_id="$(jq -r '.id // empty' <<< "$status_response")" + else + cat "$status_err" >&2 + echo "::warning::Could not create ClawSweeper queued status comment; dispatching command router without one." + fi + rm -f "$status_err" + ;; + esac + fi + payload="$(jq -nc \ + --arg target_repo "$TARGET_REPO" \ + --argjson item_number "$ITEM_NUMBER" \ + --argjson comment_id "$COMMENT_ID" \ + --arg status_comment_id "$status_comment_id" \ + --arg source_event "issue_comment" \ + --arg source_action "$SOURCE_ACTION" \ + '{event_type:"clawsweeper_comment",client_payload:({target_repo:$target_repo,item_number:$item_number,comment_id:$comment_id,source_event:$source_event,source_action:$source_action,max_comments:"1"} + (if $status_comment_id != "" then {status_comment_id:($status_comment_id|tonumber)} else {} end))}')" + GH_TOKEN="$DISPATCH_TOKEN" gh api repos/openclaw/clawsweeper/dispatches \ + --method POST \ + --input - <<< "$payload" From f64e2ca4e4cc1d56d09ecd46186dd57192a920dc Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 27 Jul 2026 07:08:44 -0400 Subject: [PATCH 06/57] fix(windows): require structured agent argv (#473) * fix(windows): require structured agent argv * refactor(acp): own built-in command migration --- CHANGELOG.md | 2 + README.md | 5 +- docs/CLI.md | 9 +- docs/config.md | 19 ++-- skills/acpx/SKILL.md | 2 +- src/acp/builtin-command-migration.ts | 56 +++++++++++ src/acp/client-process.ts | 57 +++++++++++- src/acp/client.ts | 30 ++++-- src/agent-registry.ts | 42 ++++++++- src/cli/command-handlers.ts | 3 + src/cli/compare-command.ts | 1 + src/cli/config.ts | 119 +++++++++++++++++++----- src/cli/flags.ts | 35 ++++++- src/cli/session/contracts.ts | 4 + src/cli/session/queue-owner-runtime.ts | 1 + src/cli/session/runtime.ts | 2 + src/cli/session/session-management.ts | 4 + src/flows/runtime-support.ts | 2 + src/flows/runtime.ts | 3 + src/flows/types.ts | 2 + src/runtime.ts | 43 ++++++++- src/runtime/engine/connected-session.ts | 2 + src/runtime/engine/manager.ts | 15 ++- src/runtime/public/contract.ts | 2 +- src/runtime/public/probe.ts | 11 ++- src/session/persistence/parse.ts | 15 +++ src/session/persistence/serialize.ts | 1 + src/spawn-command-options.ts | 45 +++++++++ src/types.ts | 2 + test/agent-registry.test.ts | 10 ++ test/cli-flags.test.ts | 28 ++++++ test/config.test.ts | 115 +++++++++++++++++++++-- test/flows.test.ts | 20 +++- test/runtime-test-helpers.ts | 1 + test/runtime.test.ts | 20 ++++ test/session-persistence.test.ts | 65 +++++++++++++ test/spawn-options.test.ts | 36 +++++++ 37 files changed, 749 insertions(+), 80 deletions(-) create mode 100644 src/acp/builtin-command-migration.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 9c2f7458..4a1a289a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,8 @@ Repo: https://github.com/openclaw/acpx ### Breaking +- Windows agent launches now require structured `agents..argv`; unambiguous legacy `command` plus `args` entries migrate automatically, while raw, ambiguous, or directly executable `.sh` commands fail with explicit migration guidance instead of lossy parsing or CreateProcess ENOENT. Existing custom-agent sessions without saved argv must be recreated. Fixes #466. Thanks @MarcelCFritsche. + ### Fixes ## 2026.7.23 (v0.12.1) diff --git a/README.md b/README.md index 5cd47c79..c59e31cc 100644 --- a/README.md +++ b/README.md @@ -283,7 +283,7 @@ Supported keys: "timeout": null, "format": "text", "agents": { - "my-custom": { "command": "./bin/my-acp-server", "args": ["acp"] } + "my-custom": { "argv": ["./bin/my-acp-server", "acp"] } }, "auth": { "my_auth_method_id": "credential-value" @@ -292,6 +292,9 @@ Supported keys: ``` Use `acpx config show` to inspect the resolved result and `acpx config init` to create the global template. +Use structured `agents..argv` for custom launches; it is required on Windows. Legacy +`command` plus `args` entries migrate when `command` is an unquoted executable with no whitespace, +while raw command strings remain Unix-only. For ACP `authenticate` handshakes, use either config `auth` entries or explicit `ACPX_AUTH_` environment variables such as `ACPX_AUTH_OPENAI_API_KEY`. diff --git a/docs/CLI.md b/docs/CLI.md index 0984384c..3ce18f00 100644 --- a/docs/CLI.md +++ b/docs/CLI.md @@ -429,7 +429,7 @@ Supported keys: "timeout": null, "format": "text", "agents": { - "my-custom": { "command": "./bin/my-acp-server", "args": ["acp"] } + "my-custom": { "argv": ["./bin/my-acp-server", "acp"] } }, "auth": { "my_auth_method_id": "credential-value" @@ -439,6 +439,10 @@ Supported keys: CLI flags always override config values. +Custom agents should use structured `agents..argv`, which is required on Windows. Legacy +`command` plus `args` entries migrate when `command` is an unquoted executable with no whitespace. +Raw command strings, including `--agent`, are supported only on Unix. + For ACP `authenticate` handshakes, use either config `auth` entries or explicit `ACPX_AUTH_` environment variables such as `ACPX_AUTH_OPENAI_API_KEY`. Ambient provider env vars such as `OPENAI_API_KEY` are still passed through to @@ -446,7 +450,8 @@ child agents, but they do not trigger ACP auth-method selection on their own. ## `--agent` escape hatch -`--agent ` sets a raw adapter command explicitly. +`--agent ` sets a raw adapter command explicitly on Unix. On Windows, define a named +agent with `agents..argv` so executable and argument boundaries remain unambiguous. Examples: diff --git a/docs/config.md b/docs/config.md index be57b4e9..571cfa2a 100644 --- a/docs/config.md +++ b/docs/config.md @@ -46,7 +46,7 @@ acpx config init } ], "agents": { - "my-custom": { "command": "./bin/my-acp-server", "args": ["acp"] } + "my-custom": { "argv": ["./bin/my-acp-server", "acp"] } }, "auth": { "openai_api_key": "sk-…" @@ -88,12 +88,10 @@ Custom agents and overrides live here: { "agents": { "my-agent": { - "command": "./bin/my-acp-server", - "args": ["acp", "--profile", "ci"] + "argv": ["./bin/my-acp-server", "acp", "--profile", "ci"] }, "codex": { - "command": "/usr/local/bin/codex-acp", - "args": ["--mode", "stable"] + "argv": ["/usr/local/bin/codex-acp", "--mode", "stable"] } } } @@ -102,15 +100,18 @@ Custom agents and overrides live here: Rules: - Keys are friendly names you would type at `acpx …`. -- `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. +- `argv` is the preferred form and is required for custom agent launches on Windows. Its first item is the executable and every remaining item is passed literally as one argument. +- Legacy `{ "command": "…", "args": […] }` entries migrate when `command` is an unquoted executable with no whitespace. Quoted executables and inline arguments are rejected as ambiguous; move the complete launch to `argv`. +- A legacy `command` without `args` remains a raw command string for Unix compatibility. Windows rejects it with migration guidance because inferring argv would corrupt paths and quoting. +- The raw `--agent ` escape hatch is likewise Unix-only. +- Windows cannot execute `.sh` files directly. Name the interpreter explicitly, for example `"argv": ["bash", "C:\\tools\\bin\\agent.sh"]`; acpx does not discover or infer an interpreter. +- On Windows, close and recreate custom-agent sessions created by an older acpx release so their records persist structured argv. Known built-in commands migrate automatically. - 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" } } } +{ "agents": { "codex": { "argv": ["/usr/local/bin/codex-acp"] } } } ``` Use this to point a particular repo at a vendored or pinned adapter. diff --git a/skills/acpx/SKILL.md b/skills/acpx/SKILL.md index 7bc029bd..c91260c0 100644 --- a/skills/acpx/SKILL.md +++ b/skills/acpx/SKILL.md @@ -332,7 +332,7 @@ Supported keys: - `ttl` (seconds) - `timeout` (seconds or `null`) - `format` (`text`, `json`, `quiet`) -- `agents` map (`name -> { command, args? }`) +- `agents` map (`name -> { argv: [executable, ...args] }`); structured argv is required on Windows, and legacy `{ command, args }` entries migrate automatically - `auth` map (`authMethodId -> credential`) Use `acpx config show` to inspect the resolved config and `acpx config init` to create the global template. diff --git a/src/acp/builtin-command-migration.ts b/src/acp/builtin-command-migration.ts new file mode 100644 index 00000000..8675b6b9 --- /dev/null +++ b/src/acp/builtin-command-migration.ts @@ -0,0 +1,56 @@ +import { AGENT_ARGV_REGISTRY, AGENT_REGISTRY, BUILT_IN_AGENT_PACKAGES } from "../agent-registry.js"; + +const LEGACY_AGENT_COMMANDS: Record = { + pi: ["npx pi-acp", "npx pi-acp@^0.0.22", "npx pi-acp@^0.0.26"], + codex: [ + "npx @zed-industries/codex-acp", + "npx @zed-industries/codex-acp@^0.9.5", + "npx @zed-industries/codex-acp@^0.10.0", + "npx @zed-industries/codex-acp@^0.11.1", + "npx @zed-industries/codex-acp@^0.12.0", + "npx -y @agentclientprotocol/codex-acp@^0.0.44", + "npx -y @agentclientprotocol/codex-acp@^1.1.4", + ], + claude: [ + "npx @zed-industries/claude-agent-acp", + "npx -y @zed-industries/claude-agent-acp", + "npx -y @zed-industries/claude-agent-acp@^0.21.0", + "npx -y @zed-industries/claude-agent-acp@^0.23.1", + "npx -y @zed-industries/claude-agent-acp@^0.24.2", + "npx -y @zed-industries/claude-agent-acp@^0.25.0", + "npx -y @zed-industries/claude-agent-acp@^0.31.0", + "npx -y @agentclientprotocol/claude-agent-acp@^0.36.1", + "npx -y @agentclientprotocol/claude-agent-acp@^0.37.0", + "npm exec @agentclientprotocol/claude-agent-acp@^0.36.1", + "npm exec @agentclientprotocol/claude-agent-acp@^0.37.0", + ], + gemini: ["gemini", "gemini --experimental-acp"], + kiro: ["kiro-cli acp"], + mux: ["npx -y mux@^0.27.0 acp"], + opencode: ["npx opencode-ai"], +}; + +function currentArgv(name: string): string[] | undefined { + const argv = AGENT_ARGV_REGISTRY[name]; + return argv ? [...argv] : undefined; +} + +export function resolveAgentArgvForCommand(agentCommand: string): string[] | undefined { + for (const [name, command] of Object.entries(AGENT_REGISTRY)) { + if (command === agentCommand) { + return currentArgv(name); + } + } + for (const [name, spec] of Object.entries(BUILT_IN_AGENT_PACKAGES)) { + const legacyCommands: readonly string[] = spec.legacyFallbackCommands ?? []; + if (legacyCommands.includes(agentCommand)) { + return currentArgv(name); + } + } + for (const [name, commands] of Object.entries(LEGACY_AGENT_COMMANDS)) { + if (commands.includes(agentCommand)) { + return currentArgv(name); + } + } + return undefined; +} diff --git a/src/acp/client-process.ts b/src/acp/client-process.ts index a1d639a8..efb81de1 100644 --- a/src/acp/client-process.ts +++ b/src/acp/client-process.ts @@ -11,6 +11,27 @@ export type CommandParts = { args: string[]; }; +export function normalizeAgentCommandInput(value: string | readonly string[]): { + agentCommand: string; + agentArgv?: string[]; +} { + if (typeof value === "string") { + return { agentCommand: value }; + } + const parts = toCommandParts([...value]); + const argv = [parts.command, ...parts.args]; + return { + agentCommand: renderArgvIdentity(argv), + agentArgv: argv, + }; +} + +const IDENTITY_SAFE_ARG_RE = /^[A-Za-z0-9_@%+=:,./^~-]+$/u; + +export function renderArgvIdentity(argv: readonly string[]): string { + return argv.map((arg) => (IDENTITY_SAFE_ARG_RE.test(arg) ? arg : JSON.stringify(arg))).join(" "); +} + type ResolveSessionCwdOptions = { platform?: NodeJS.Platform; existsSync?: (filePath: string) => boolean; @@ -87,6 +108,32 @@ export function waitForChildExit( }); } +export function resolveAgentCommandParts( + value: string, + argv: readonly string[] | undefined, + platform: NodeJS.Platform = process.platform, +): CommandParts { + if (argv) { + const parts = toCommandParts([...argv]); + assertWindowsLaunchableCommand(parts.command, platform); + return parts; + } + if (platform === "win32") { + throw new Error( + 'Raw agent command strings are not supported on Windows. Configure the agent with an argv array, for example: "argv": ["agent.exe", "--acp"]. Legacy agents..args arrays are migrated automatically. Existing sessions without saved argv must be closed and recreated.', + ); + } + return splitCommandLine(value); +} + +function assertWindowsLaunchableCommand(command: string, platform: NodeJS.Platform): void { + if (platform === "win32" && path.extname(command).toLowerCase() === ".sh") { + throw new Error( + `Windows cannot launch shell script executable "${command}" directly. Configure an explicit interpreter argv, for example: "argv": ["bash", "${command}"]. acpx does not infer interpreters.`, + ); + } +} + export function splitCommandLine(value: string): CommandParts { const parts: string[] = []; let current = ""; @@ -115,13 +162,13 @@ export function splitCommandLine(value: string): CommandParts { parts.push(current); } - if (parts.length === 0) { - throw new Error("Invalid --agent command: empty command"); - } - if (parts[0] === "") { + return toCommandParts(parts); +} + +function toCommandParts(parts: string[]): CommandParts { + if (parts.length === 0 || parts[0] === "") { throw new Error("Invalid --agent command: empty command"); } - return { command: parts[0], args: parts.slice(1), diff --git a/src/acp/client.ts b/src/acp/client.ts index b9830229..3666ee72 100644 --- a/src/acp/client.ts +++ b/src/acp/client.ts @@ -55,7 +55,7 @@ import { } from "../permissions.js"; import { getUnsupportedPromptContentMessage, textPrompt } from "../prompt-content.js"; import { extractRuntimeSessionId } from "../session/runtime-session-id.js"; -import { buildSpawnCommandOptions } from "../spawn-command-options.js"; +import { buildAgentSpawnCommand, buildSpawnCommandOptions } from "../spawn-command-options.js"; import type { AcpClientOptions, NonInteractivePermissionPolicy, @@ -93,8 +93,8 @@ import { isoNow, isChildProcessRunning, requireAgentStdio, + resolveAgentCommandParts, resolveAgentSessionCwd, - splitCommandLine, waitForChildExit, waitForSpawn, } from "./client-process.js"; @@ -652,7 +652,10 @@ export class AcpClient { } private async resolveAgentLaunchPlan(): Promise { - const configuredCommand = splitCommandLine(this.options.agentCommand); + const configuredCommand = resolveAgentCommandParts( + this.options.agentCommand, + this.options.agentArgv, + ); const resolvedBuiltInLaunch = resolveBuiltInAgentLaunch(this.options.agentCommand); const spawnCommand = resolvedBuiltInLaunch?.command ?? configuredCommand.command; let args = resolvedBuiltInLaunch?.args ?? configuredCommand.args; @@ -710,11 +713,16 @@ export class AcpClient { private async spawnAgentProcess( plan: AgentLaunchPlan, ): Promise> { - const spawnedChild = spawn( + const spawnCommand = buildAgentSpawnCommand( plan.spawnCommand, plan.args, - buildSpawnCommandOptions(plan.spawnCommand, plan.spawnOptions), - ) as ChildProcessByStdio; + process.platform, + plan.spawnOptions.env, + ); + const spawnedChild = spawn(spawnCommand.command, spawnCommand.args, { + ...plan.spawnOptions, + windowsVerbatimArguments: spawnCommand.windowsVerbatimArguments, + }) as ChildProcessByStdio; try { await waitForSpawn(spawnedChild); } catch (error) { @@ -911,7 +919,10 @@ export class AcpClient { async createSession(cwd = this.options.cwd): Promise { const connection = this.getConnection(); - const { command, args } = splitCommandLine(this.options.agentCommand); + const { command, args } = resolveAgentCommandParts( + this.options.agentCommand, + this.options.agentArgv, + ); const claudeAcp = isClaudeAcpCommand(command, args); const sessionCwd = await resolveAgentSessionCwd(cwd, this.options.agentCommand); @@ -1621,7 +1632,10 @@ export class AcpClient { } private isGrokBuildAcpCommand(): boolean { - const { command, args } = splitCommandLine(this.options.agentCommand); + const { command, args } = resolveAgentCommandParts( + this.options.agentCommand, + this.options.agentArgv, + ); const executable = command .replace(/\\/g, "/") .split("/") diff --git a/src/agent-registry.ts b/src/agent-registry.ts index ca42fa1c..1e20fe43 100644 --- a/src/agent-registry.ts +++ b/src/agent-registry.ts @@ -58,6 +58,32 @@ export const AGENT_REGISTRY: Record = { trae: "traecli acp serve", }; +export const AGENT_ARGV_REGISTRY: Record = { + pi: ["npx", `pi-acp@${ACP_ADAPTER_PACKAGE_RANGES.pi}`], + openclaw: ["openclaw", "acp"], + codex: ["npx", "-y", `@agentclientprotocol/codex-acp@${ACP_ADAPTER_PACKAGE_RANGES.codex}`], + claude: [ + "npx", + "-y", + `@agentclientprotocol/claude-agent-acp@${ACP_ADAPTER_PACKAGE_RANGES.claude}`, + ], + gemini: ["gemini", "--acp"], + cursor: ["cursor-agent", "acp"], + copilot: ["copilot", "--acp", "--stdio"], + droid: ["droid", "exec", "--output-format", "acp"], + "fast-agent": ["uvx", "fast-agent-mcp", "acp"], + "grok-build": ["grok", "agent", "stdio"], + iflow: ["iflow", "--experimental-acp"], + kilocode: ["npx", "-y", "@kilocode/cli", "acp"], + kimi: ["kimi", "acp"], + kiro: ["kiro-cli-chat", "acp"], + mux: ["npx", "-y", `mux@${ACP_ADAPTER_PACKAGE_RANGES.mux}`, "acp"], + opencode: ["npx", "-y", "opencode-ai", "acp"], + qoder: ["qodercli", "--acp"], + qwen: ["qwen", "--acp"], + trae: ["traecli", "acp", "serve"], +}; + export const BUILT_IN_AGENT_PACKAGES = { codex: { packageName: "@agentclientprotocol/codex-acp", @@ -88,6 +114,11 @@ export function normalizeAgentName(value: string): string { return value.trim().toLowerCase(); } +export function resolveCanonicalAgentName(value: string): string { + const normalized = normalizeAgentName(value); + return AGENT_ALIASES[normalized] ?? normalized; +} + export function mergeAgentRegistry(overrides?: Record): Record { if (!overrides) { return { ...AGENT_REGISTRY }; @@ -110,6 +141,13 @@ export function resolveAgentCommand(agentName: string, overrides?: Record): string[] { - return Object.keys(mergeAgentRegistry(overrides)); +export function listBuiltInAgents(overrides?: Record): string[] { + return [...new Set([...Object.keys(AGENT_REGISTRY), ...Object.keys(overrides ?? {})])]; } diff --git a/src/cli/command-handlers.ts b/src/cli/command-handlers.ts index fed1a341..10e9ccc7 100644 --- a/src/cli/command-handlers.ts +++ b/src/cli/command-handlers.ts @@ -215,6 +215,7 @@ function buildSessionStartOptions(params: { }): Parameters[0] { return { agentCommand: params.agent.agentCommand, + agentArgv: params.agent.agentArgv, cwd: params.agent.cwd, name: params.flags.name, resumeSessionId: params.flags.resumeSession, @@ -433,6 +434,7 @@ export async function handleExec( const result = await runOnce({ agentCommand: agent.agentCommand, + agentArgv: agent.agentArgv, cwd: agent.cwd, prompt, mcpServers: config.mcpServers, @@ -709,6 +711,7 @@ async function tryListAgentSessions( try { return await listAgentSessions({ agentCommand: agent.agentCommand, + agentArgv: agent.agentArgv, cwd: agent.cwd, cursor: flags.cursor, filterCwd: resolveSessionListFilterCwd(flags, agent.cwd), diff --git a/src/cli/compare-command.ts b/src/cli/compare-command.ts index ebef1a13..dfed8a4e 100644 --- a/src/cli/compare-command.ts +++ b/src/cli/compare-command.ts @@ -358,6 +358,7 @@ async function runAgentForCompare(params: { const agent = resolveAgentInvocation(params.agentName, params.globalFlags, params.config); const result = await runOnce({ agentCommand: agent.agentCommand, + agentArgv: agent.agentArgv, cwd: agent.cwd, prompt: params.prompt, mcpServers: params.config.mcpServers, diff --git a/src/cli/config.ts b/src/cli/config.ts index fda58210..846beb54 100644 --- a/src/cli/config.ts +++ b/src/cli/config.ts @@ -2,6 +2,7 @@ import { createHash } from "node:crypto"; import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; +import { renderArgvIdentity } from "../acp/client-process.js"; import { DEFAULT_AGENT_NAME, normalizeAgentName } from "../agent-registry.js"; import { parseMcpServers } from "../mcp-servers.js"; import type { @@ -12,11 +13,13 @@ import type { PermissionMode, } from "../types.js"; -type ConfigAgentEntry = { +export type ResolvedAgentConfig = { command: string; - args?: string[]; + argv?: string[]; }; +type ConfigAgentEntry = { command: string } | { argv: string[] }; + type ConfigFileShape = { defaultAgent?: unknown; defaultPermissions?: unknown; @@ -41,7 +44,7 @@ export type ResolvedAcpxConfig = { timeoutMs?: number; queueMaxDepth: number; format: OutputFormat; - agents: Record; + agents: Record; auth: Record; disableExec: boolean; mcpServers: McpServer[]; @@ -189,7 +192,10 @@ function parseDefaultAgent(value: unknown, sourcePath: string): string | undefin return normalizeAgentName(value); } -function parseAgents(value: unknown, sourcePath: string): Record | undefined { +function parseAgents( + value: unknown, + sourcePath: string, +): Record | undefined { if (value == null) { return undefined; } @@ -197,25 +203,88 @@ function parseAgents(value: unknown, sourcePath: string): Record throw new Error(`Invalid config agents in ${sourcePath}: expected object`); } - const parsed: Record = {}; + const parsed: Record = {}; for (const [name, raw] of Object.entries(value)) { - if (!isObject(raw)) { - throw new Error( - `Invalid config agents.${name} in ${sourcePath}: expected object with command`, - ); - } - const command = raw.command; - if (typeof command !== "string" || command.trim().length === 0) { + parsed[normalizeAgentName(name)] = parseAgentEntry(raw, name, sourcePath); + } + + return parsed; +} + +function parseAgentEntry(raw: unknown, name: string, sourcePath: string): ResolvedAgentConfig { + if (!isObject(raw)) { + throw new Error(`Invalid config agents.${name} in ${sourcePath}: expected object with command`); + } + return Object.prototype.hasOwnProperty.call(raw, "argv") + ? parseArgvAgentEntry(raw, name, sourcePath) + : parseLegacyAgentEntry(raw, name, sourcePath); +} + +function parseArgvAgentEntry( + raw: Record, + name: string, + sourcePath: string, +): ResolvedAgentConfig { + if ( + Object.prototype.hasOwnProperty.call(raw, "command") || + Object.prototype.hasOwnProperty.call(raw, "args") + ) { + throw new Error( + `Invalid config agents.${name} in ${sourcePath}: use argv alone, not command or args`, + ); + } + const argv = parseAgentArgv(raw.argv, name, sourcePath); + return { command: renderArgv(argv), argv }; +} + +function parseLegacyAgentEntry( + raw: Record, + name: string, + sourcePath: string, +): ResolvedAgentConfig { + const command = raw.command; + if (typeof command !== "string" || command.trim().length === 0) { + throw new Error( + `Invalid config agents.${name}.command in ${sourcePath}: expected non-empty string`, + ); + } + const trimmedCommand = command.trim(); + if (!Object.prototype.hasOwnProperty.call(raw, "args")) { + return { command: trimmedCommand }; + } + if (/[\s'"]/u.test(trimmedCommand)) { + throw new Error( + `Invalid config agents.${name}.command in ${sourcePath}: command must be an unquoted executable with no whitespace when args is present; migrate the complete launch to argv`, + ); + } + const args = parseAgentArgs(raw.args, name, sourcePath); + return { + command: + args.length > 0 ? `${trimmedCommand} ${args.map(quoteCommandArg).join(" ")}` : trimmedCommand, + argv: [trimmedCommand, ...args], + }; +} + +function parseAgentArgv(value: unknown, agentName: string, sourcePath: string): string[] { + if (!Array.isArray(value) || value.length === 0) { + throw new Error( + `Invalid config agents.${agentName}.argv in ${sourcePath}: expected non-empty array of strings`, + ); + } + const argv = value.map((arg, index) => { + if (typeof arg !== "string") { throw new Error( - `Invalid config agents.${name}.command in ${sourcePath}: expected non-empty string`, + `Invalid config agents.${agentName}.argv[${index}] in ${sourcePath}: expected string`, ); } - const args = parseAgentArgs(raw.args, name, sourcePath); - parsed[normalizeAgentName(name)] = - args.length > 0 ? `${command.trim()} ${args.map(quoteCommandArg).join(" ")}` : command.trim(); + return arg; + }); + if (argv[0]?.length === 0) { + throw new Error( + `Invalid config agents.${agentName}.argv[0] in ${sourcePath}: expected non-empty executable`, + ); } - - return parsed; + return argv; } function parseAgentArgs(value: unknown, agentName: string, sourcePath: string): string[] { @@ -241,6 +310,10 @@ function quoteCommandArg(value: string): string { return JSON.stringify(value); } +function renderArgv(argv: readonly string[]): string { + return renderArgvIdentity(argv); +} + function parseAuth(value: unknown, sourcePath: string): Record | undefined { if (value == null) { return undefined; @@ -319,9 +392,9 @@ async function loadExplicitMcpConfig( } function mergeAgents( - globalAgents: Record | undefined, - projectAgents: Record | undefined, -): Record { + globalAgents: Record | undefined, + projectAgents: Record | undefined, +): Record { return { ...globalAgents, ...projectAgents, @@ -587,8 +660,8 @@ export function toConfigDisplay(config: ResolvedAcpxConfig): { disableExec: boolean; } { const agents: Record = {}; - for (const [name, command] of Object.entries(config.agents)) { - agents[name] = { command }; + for (const [name, agent] of Object.entries(config.agents)) { + agents[name] = agent.argv ? { argv: [...agent.argv] } : { command: agent.command }; } return { diff --git a/src/cli/flags.ts b/src/cli/flags.ts index 20491aa9..cbf9ba8f 100644 --- a/src/cli/flags.ts +++ b/src/cli/flags.ts @@ -3,6 +3,9 @@ import { InvalidArgumentError } from "commander"; import type { Command } from "commander"; import { DEFAULT_AGENT_NAME, + normalizeAgentName, + resolveCanonicalAgentName, + resolveAgentArgv, resolveAgentCommand as resolveAgentCommandFromRegistry, } from "../agent-registry.js"; import type { SystemPromptOption } from "../runtime/engine/session-options.js"; @@ -511,6 +514,7 @@ export function resolveAgentInvocation( ): { agentName: string; agentCommand: string; + agentArgv?: string[]; cwd: string; } { const override = globalFlags.agent?.trim(); @@ -519,14 +523,35 @@ export function resolveAgentInvocation( } const agentName = explicitAgentName ?? config.defaultAgent ?? DEFAULT_AGENT_NAME; - const agentCommand = - override && override.length > 0 - ? override - : resolveAgentCommandFromRegistry(agentName, config.agents); + const command = resolveInvocationCommand(agentName, override, config); return { agentName, - agentCommand, + ...command, cwd: path.resolve(globalFlags.cwd), }; } + +function resolveInvocationCommand( + agentName: string, + override: string | undefined, + config: ResolvedAcpxConfig, +): { agentCommand: string; agentArgv?: string[] } { + if (override) { + return { agentCommand: override }; + } + const normalizedAgentName = normalizeAgentName(agentName); + const configuredAgent = + config.agents[normalizedAgentName] ?? config.agents[resolveCanonicalAgentName(agentName)]; + if (configuredAgent) { + return { + agentCommand: configuredAgent.command, + ...(configuredAgent.argv ? { agentArgv: [...configuredAgent.argv] } : {}), + }; + } + const agentArgv = resolveAgentArgv(agentName); + return { + agentCommand: resolveAgentCommandFromRegistry(agentName), + ...(agentArgv ? { agentArgv } : {}), + }; +} diff --git a/src/cli/session/contracts.ts b/src/cli/session/contracts.ts index 3c6dec32..6c7cbc14 100644 --- a/src/cli/session/contracts.ts +++ b/src/cli/session/contracts.ts @@ -40,6 +40,7 @@ export function normalizeQueueOwnerTtlMs(ttlMs: number | undefined): number { export type RunOnceOptions = { agentCommand: string; + agentArgv?: string[]; cwd: string; prompt: PromptInput; mcpServers?: McpServer[]; @@ -63,6 +64,7 @@ export type RunOnceOptions = { export type SessionCreateOptions = { agentCommand: string; + agentArgv?: string[]; cwd: string; name?: string; resumeSessionId?: string; @@ -109,6 +111,7 @@ export type SessionSendOptions = { export type SessionEnsureOptions = { agentCommand: string; + agentArgv?: string[]; cwd: string; name?: string; resumeSessionId?: string; @@ -127,6 +130,7 @@ export type SessionEnsureOptions = { export type SessionListOptions = { agentCommand: string; + agentArgv?: string[]; cwd: string; cursor?: string; filterCwd?: string; diff --git a/src/cli/session/queue-owner-runtime.ts b/src/cli/session/queue-owner-runtime.ts index 62f5788f..980881de 100644 --- a/src/cli/session/queue-owner-runtime.ts +++ b/src/cli/session/queue-owner-runtime.ts @@ -84,6 +84,7 @@ function createQueueOwnerSharedClient( ): AcpClient { return new AcpClient({ agentCommand: sessionRecord.agentCommand, + agentArgv: sessionRecord.agentArgv, cwd: absolutePath(sessionRecord.cwd), mcpServers: options.mcpServers, permissionMode: options.permissionMode, diff --git a/src/cli/session/runtime.ts b/src/cli/session/runtime.ts index dba80c95..c6d0a66a 100644 --- a/src/cli/session/runtime.ts +++ b/src/cli/session/runtime.ts @@ -759,6 +759,7 @@ async function runSessionPrompt(options: RunSessionPromptOptions): Promise const acpErrors = new AcpErrorTracker(); const client = new AcpClient({ agentCommand: options.agentCommand, + agentArgv: options.agentArgv, cwd: absolutePath(options.cwd), mcpServers: options.mcpServers, permissionMode: options.permissionMode, diff --git a/src/cli/session/session-management.ts b/src/cli/session/session-management.ts index 454ddbc7..9d75cc0d 100644 --- a/src/cli/session/session-management.ts +++ b/src/cli/session/session-management.ts @@ -60,6 +60,7 @@ async function createSessionRecordWithClient( acpSessionId: sessionId, agentSessionId, agentCommand: options.agentCommand, + agentArgv: options.agentArgv, cwd, name: normalizeName(options.name), createdAt: now, @@ -189,6 +190,7 @@ export async function createSessionWithClient( ): Promise { const client = new AcpClient({ agentCommand: options.agentCommand, + agentArgv: options.agentArgv, cwd: absolutePath(options.cwd), mcpServers: options.mcpServers, permissionMode: options.permissionMode, @@ -233,6 +235,7 @@ export async function createSession(options: SessionCreateOptions): Promise { const client = new AcpClient({ agentCommand: options.agentCommand, + agentArgv: options.agentArgv, cwd: absolutePath(options.cwd), mcpServers: options.mcpServers, permissionMode: options.permissionMode, @@ -313,6 +316,7 @@ export async function ensureSession(options: SessionEnsureOptions): Promise[] = []; const result = await runOnce({ agentCommand: agent.agentCommand, + agentArgv: agent.agentArgv, cwd: agent.cwd, prompt, mcpServers: this.mcpServers, diff --git a/src/flows/types.ts b/src/flows/types.ts index dafb5b4d..d751b903 100644 --- a/src/flows/types.ts +++ b/src/flows/types.ts @@ -240,6 +240,7 @@ export type FlowSessionBinding = { profile?: string; agentName: string; agentCommand: string; + agentArgv?: string[]; cwd: string; acpxRecordId: string; acpSessionId: string; @@ -335,6 +336,7 @@ export type FlowSessionBundleSnapshot = { export type ResolvedFlowAgent = { agentName: string; agentCommand: string; + agentArgv?: string[]; cwd: string; }; diff --git a/src/runtime.ts b/src/runtime.ts index d4562de0..8861802a 100644 --- a/src/runtime.ts +++ b/src/runtime.ts @@ -1,4 +1,11 @@ -import { DEFAULT_AGENT_NAME, listBuiltInAgents, resolveAgentCommand } from "./agent-registry.js"; +import { + DEFAULT_AGENT_NAME, + listBuiltInAgents, + normalizeAgentName, + resolveCanonicalAgentName, + resolveAgentArgv, + resolveAgentCommand, +} from "./agent-registry.js"; import { AcpRuntimeManager } from "./runtime/engine/manager.js"; import type { AcpAgentRegistry, @@ -81,18 +88,46 @@ type AcpxRuntimeLike = AcpRuntime & { }; export function createAgentRegistry(params?: { - overrides?: Record; + overrides?: Record; }): AcpAgentRegistry { + const overrides = normalizeRegistryOverrides(params?.overrides); return { resolve(agentName: string) { - return resolveAgentCommand(agentName, params?.overrides); + const normalizedAgentName = normalizeAgentName(agentName); + const override = + overrides[normalizedAgentName] ?? overrides[resolveCanonicalAgentName(agentName)]; + return override ?? resolveAgentArgv(agentName) ?? resolveAgentCommand(agentName); }, list() { - return listBuiltInAgents(params?.overrides); + return listBuiltInAgents(overrides); }, }; } +function normalizeRegistryOverrides( + values: Record | undefined, +): Record { + const normalized: Record = {}; + for (const [name, value] of Object.entries(values ?? {})) { + const normalizedName = normalizeAgentName(name); + if (!normalizedName) { + continue; + } + const normalizedValue = normalizeRegistryOverride(value); + if (normalizedValue) { + normalized[normalizedName] = normalizedValue; + } + } + return normalized; +} + +function normalizeRegistryOverride(value: string | string[]): string | string[] | undefined { + if (typeof value === "string") { + return value.trim() || undefined; + } + return value.length > 0 && value[0]?.length ? [...value] : undefined; +} + export class AcpxRuntime implements AcpxRuntimeLike { private healthy = false; private manager: AcpRuntimeManager | null = null; diff --git a/src/runtime/engine/connected-session.ts b/src/runtime/engine/connected-session.ts index 372bbd92..b1dcf439 100644 --- a/src/runtime/engine/connected-session.ts +++ b/src/runtime/engine/connected-session.ts @@ -98,6 +98,7 @@ export async function withConnectedSession( const client = options.createClient?.({ agentCommand: record.agentCommand, + agentArgv: record.agentArgv, cwd: absolutePath(record.cwd), mcpServers: options.mcpServers, permissionMode: options.permissionMode ?? "approve-reads", @@ -111,6 +112,7 @@ export async function withConnectedSession( }) ?? new AcpClient({ agentCommand: record.agentCommand, + agentArgv: record.agentArgv, cwd: absolutePath(record.cwd), mcpServers: options.mcpServers, permissionMode: options.permissionMode ?? "approve-reads", diff --git a/src/runtime/engine/manager.ts b/src/runtime/engine/manager.ts index bb574087..a8f892f7 100644 --- a/src/runtime/engine/manager.ts +++ b/src/runtime/engine/manager.ts @@ -1,5 +1,6 @@ import { randomUUID } from "node:crypto"; import path from "node:path"; +import { normalizeAgentCommandInput } from "../../acp/client-process.js"; import { AcpClient } from "../../acp/client.js"; import { normalizeOutputError } from "../../acp/error-normalization.js"; import { extractAcpError, isAcpResourceNotFoundError } from "../../acp/error-shapes.js"; @@ -223,6 +224,7 @@ function createInitialRecord(params: { sessionName: string; sessionId: string; agentCommand: string; + agentArgv?: string[]; cwd: string; agentSessionId?: string; }): SessionRecord { @@ -233,6 +235,7 @@ function createInitialRecord(params: { acpSessionId: params.sessionId, agentSessionId: params.agentSessionId, agentCommand: params.agentCommand, + agentArgv: params.agentArgv, cwd: params.cwd, name: params.sessionName, createdAt: now, @@ -684,7 +687,9 @@ export class AcpRuntimeManager { sessionOptions?: SessionAgentOptions; }): Promise { const cwd = path.resolve(input.cwd?.trim() || this.options.cwd); - const agentCommand = this.options.agentRegistry.resolve(input.agent); + const { agentCommand, agentArgv } = normalizeAgentCommandInput( + this.options.agentRegistry.resolve(input.agent), + ); const existing = await this.options.sessionStore.load(input.sessionKey); if ( input.mode === "persistent" && @@ -707,6 +712,7 @@ export class AcpRuntimeManager { const client = this.createClient({ agentCommand, + agentArgv, cwd, mcpServers: [...(this.options.mcpServers ?? [])], permissionMode: this.options.permissionMode, @@ -724,6 +730,7 @@ export class AcpRuntimeManager { input, client, agentCommand, + agentArgv, cwd, session, }); @@ -744,15 +751,17 @@ export class AcpRuntimeManager { }; client: AcpClient; agentCommand: string; + agentArgv?: string[]; cwd: string; session: CreatedRuntimeSession; }): Promise { - const { input, client, agentCommand, cwd, session } = params; + const { input, client, agentCommand, agentArgv, cwd, session } = params; const record = createInitialRecord({ recordId: createRecordId(input.sessionKey, input.mode), sessionName: input.sessionKey, sessionId: session.sessionId, agentCommand, + agentArgv, cwd, agentSessionId: session.agentSessionId, }); @@ -962,6 +971,7 @@ export class AcpRuntimeManager { private createTurnClient(record: SessionRecord): AcpClient { return this.createClient({ agentCommand: record.agentCommand, + agentArgv: record.agentArgv, cwd: record.cwd, mcpServers: [...(this.options.mcpServers ?? [])], permissionMode: this.options.permissionMode, @@ -1399,6 +1409,7 @@ export class AcpRuntimeManager { pendingClient ?? this.createClient({ agentCommand: record.agentCommand, + agentArgv: record.agentArgv, cwd: record.cwd, mcpServers: [...(this.options.mcpServers ?? [])], permissionMode: this.options.permissionMode, diff --git a/src/runtime/public/contract.ts b/src/runtime/public/contract.ts index ea4c0967..a8b6052e 100644 --- a/src/runtime/public/contract.ts +++ b/src/runtime/public/contract.ts @@ -287,7 +287,7 @@ export interface AcpSessionStore { } export interface AcpAgentRegistry { - resolve(agentName: string): string; + resolve(agentName: string): string | string[]; list(): string[]; } diff --git a/src/runtime/public/probe.ts b/src/runtime/public/probe.ts index 43dea3f1..97d934e8 100644 --- a/src/runtime/public/probe.ts +++ b/src/runtime/public/probe.ts @@ -1,3 +1,4 @@ +import { normalizeAgentCommandInput } from "../../acp/client-process.js"; import { AcpClient } from "../../acp/client.js"; import { DEFAULT_AGENT_NAME } from "../../agent-registry.js"; import type { AcpRuntimeOptions } from "./contract.js"; @@ -75,7 +76,7 @@ export async function probeRuntime( deps: ProbeRuntimeDeps = {}, ): Promise { const agentName = options.probeAgent?.trim() || DEFAULT_AGENT_NAME; - const agentCommand = options.agentRegistry.resolve(agentName); + const agentCommand = normalizeAgentCommandInput(options.agentRegistry.resolve(agentName)); const client = createProbeClient(options, agentCommand, deps); try { @@ -85,7 +86,7 @@ export async function probeRuntime( message: "embedded ACP runtime ready", details: [ `agent=${agentName}`, - `command=${agentCommand}`, + `command=${agentCommand.agentCommand}`, `cwd=${options.cwd}`, ...(client.initializeResult?.protocolVersion ? [`protocolVersion=${client.initializeResult.protocolVersion}`] @@ -98,7 +99,7 @@ export async function probeRuntime( message: "embedded ACP runtime probe failed", details: [ `agent=${agentName}`, - `command=${agentCommand}`, + `command=${agentCommand.agentCommand}`, `cwd=${options.cwd}`, formatRuntimeDetail(error), ], @@ -110,11 +111,11 @@ export async function probeRuntime( function createProbeClient( options: AcpRuntimeOptions, - agentCommand: string, + agentCommand: ReturnType, deps: ProbeRuntimeDeps, ): AcpClient { const clientOptions = { - agentCommand, + ...agentCommand, cwd: options.cwd, mcpServers: [...(options.mcpServers ?? [])], permissionMode: options.permissionMode, diff --git a/src/session/persistence/parse.ts b/src/session/persistence/parse.ts index 103342df..8c1efdbb 100644 --- a/src/session/persistence/parse.ts +++ b/src/session/persistence/parse.ts @@ -1,3 +1,4 @@ +import { resolveAgentArgvForCommand } from "../../acp/builtin-command-migration.js"; import type { SessionAcpxState, SessionEventLog, @@ -23,6 +24,19 @@ function isStringArray(value: unknown): value is string[] { return Array.isArray(value) && value.every((entry) => typeof entry === "string"); } +function parseOptionalAgentArgv(value: unknown): string[] | undefined { + return isStringArray(value) && value.length > 0 && value[0]?.length > 0 ? value : undefined; +} + +function parsePersistedAgentArgv(record: Record): string[] | undefined { + return ( + parseOptionalAgentArgv(record.agent_argv) ?? + (typeof record.agent_command === "string" + ? resolveAgentArgvForCommand(record.agent_command) + : undefined) + ); +} + function hasModelConfigOption(options: unknown): boolean { if (!Array.isArray(options)) { return false; @@ -768,6 +782,7 @@ export function parseSessionRecord(raw: unknown): SessionRecord | null { acpSessionId: record.acp_session_id, agentSessionId: normalizeRuntimeSessionId(record.agent_session_id), agentCommand: record.agent_command, + agentArgv: parsePersistedAgentArgv(record), cwd: record.cwd, name: optionals.name, createdAt: record.created_at, diff --git a/src/session/persistence/serialize.ts b/src/session/persistence/serialize.ts index be28dbdb..df618faf 100644 --- a/src/session/persistence/serialize.ts +++ b/src/session/persistence/serialize.ts @@ -14,6 +14,7 @@ export function serializeSessionRecordForDisk(record: SessionRecord): Record&|;, *?])/gu; +const CMD_BACKSLASH_QUOTE_RE = /(?=(\\+?)?)\1"/gu; +const CMD_TRAILING_BACKSLASH_RE = /(?=(\\+?)?)\1$/gu; +const CMD_SHIM_RE = /node_modules[\\/].bin[\\/][^\\/]+\.cmd$/iu; + +function escapeCmdCommand(value: string): string { + return value.replace(CMD_META_CHAR_RE, "^$1"); +} + +function escapeCmdArgument(value: string, doubleEscapeMeta: boolean): string { + const quoted = `"${value + .replace(CMD_BACKSLASH_QUOTE_RE, '$1$1\\"') + .replace(CMD_TRAILING_BACKSLASH_RE, "$1$1")}"`; + const escaped = quoted.replace(CMD_META_CHAR_RE, "^$1"); + return doubleEscapeMeta ? escaped.replace(CMD_META_CHAR_RE, "^$1") : escaped; +} + +export function buildAgentSpawnCommand( + command: string, + args: readonly string[], + platform: NodeJS.Platform = process.platform, + env: NodeJS.ProcessEnv = process.env, +): AgentSpawnCommand { + if (!shouldUseWindowsBatchShell(command, platform, env)) { + return { command, args: [...args] }; + } + const resolvedCommand = path.win32.normalize(resolveWindowsCommand(command, env) ?? command); + const doubleEscapeMeta = CMD_SHIM_RE.test(resolvedCommand); + const shellCommand = [ + escapeCmdCommand(resolvedCommand), + ...args.map((arg) => escapeCmdArgument(arg, doubleEscapeMeta)), + ].join(" "); + return { + command: readWindowsEnvValue(env, "COMSPEC") ?? "cmd.exe", + args: ["/d", "/s", "/c", `"${shellCommand}"`], + windowsVerbatimArguments: true, + }; +} + export function buildSpawnCommandOptions( command: string, options: Parameters[2], diff --git a/src/types.ts b/src/types.ts index c3aef314..bf6d7a5d 100644 --- a/src/types.ts +++ b/src/types.ts @@ -202,6 +202,7 @@ export interface OutputFormatter { export type AcpClientOptions = { agentCommand: string; + agentArgv?: string[]; cwd: string; mcpServers?: McpServer[]; permissionMode: PermissionMode; @@ -384,6 +385,7 @@ export type SessionRecord = { acpSessionId: string; agentSessionId?: string; agentCommand: string; + agentArgv?: string[]; cwd: string; name?: string; createdAt: string; diff --git a/test/agent-registry.test.ts b/test/agent-registry.test.ts index f8f564d5..6db29b8d 100644 --- a/test/agent-registry.test.ts +++ b/test/agent-registry.test.ts @@ -3,7 +3,9 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import test from "node:test"; +import { normalizeAgentCommandInput } from "../src/acp/client-process.js"; import { + AGENT_ARGV_REGISTRY, AGENT_REGISTRY, BUILT_IN_AGENT_PACKAGES, DEFAULT_AGENT_NAME, @@ -14,6 +16,14 @@ import { resolveAgentCommand, } from "../src/agent-registry.js"; +test("built-in command displays stay synchronized with structured argv", () => { + assert.deepEqual(Object.keys(AGENT_ARGV_REGISTRY), Object.keys(AGENT_REGISTRY)); + for (const [name, argv] of Object.entries(AGENT_ARGV_REGISTRY)) { + assert.equal(argv.join(" "), AGENT_REGISTRY[name]); + assert.equal(normalizeAgentCommandInput(argv).agentCommand, AGENT_REGISTRY[name]); + } +}); + test("resolveAgentCommand maps known agents to commands", () => { for (const [name, command] of Object.entries(AGENT_REGISTRY)) { assert.equal(resolveAgentCommand(name), command); diff --git a/test/cli-flags.test.ts b/test/cli-flags.test.ts index cb4eff4a..e08fb33c 100644 --- a/test/cli-flags.test.ts +++ b/test/cli-flags.test.ts @@ -518,3 +518,31 @@ test("resolveAgentInvocation rejects conflicting positional and override agents" /Do not combine positional agent with --agent override/, ); }); + +test("resolveAgentInvocation applies canonical config overrides through aliases", () => { + assert.deepEqual( + resolveAgentInvocation( + "factory-droid", + { + cwd: "/repo", + nonInteractivePermissions: "deny", + ttl: 300_000, + format: "text", + }, + config({ + agents: { + droid: { + command: '"C:\\\\tools\\\\droid.exe" "--acp"', + argv: ["C:\\tools\\droid.exe", "--acp"], + }, + }, + }), + ), + { + agentName: "factory-droid", + agentCommand: '"C:\\\\tools\\\\droid.exe" "--acp"', + agentArgv: ["C:\\tools\\droid.exe", "--acp"], + cwd: "/repo", + }, + ); +}); diff --git a/test/config.test.ts b/test/config.test.ts index 9a145cb6..e83246c9 100644 --- a/test/config.test.ts +++ b/test/config.test.ts @@ -3,7 +3,7 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import test from "node:test"; -import { splitCommandLine } from "../src/acp/client-process.js"; +import { resolveAgentCommandParts, splitCommandLine } from "../src/acp/client-process.js"; import { initGlobalConfigFile, loadResolvedConfig } from "../src/cli/config.js"; test("loadResolvedConfig merges global and project config with project priority", async () => { @@ -89,8 +89,8 @@ test("loadResolvedConfig merges global and project config with project priority" assert.equal(config.queueMaxDepth, 5); assert.equal(config.format, "quiet"); assert.deepEqual(config.agents, { - custom: "project-custom", - extra: "./bin/extra", + custom: { command: "project-custom" }, + extra: { command: "./bin/extra" }, }); assert.deepEqual(config.auth, { global_method: "project-override", @@ -316,7 +316,13 @@ test("loadResolvedConfig merges agent args into the command safely", async () => agents: { custom: { command: "node", - args: ["/usr/local/bin/my agent", "--profile", "with spaces", 'quote"me'], + args: [ + "/usr/local/bin/my agent", + "--profile", + "with spaces", + 'quote"me', + "C:\\Program Files\\Agent", + ], }, }, }, @@ -328,11 +334,18 @@ test("loadResolvedConfig merges agent args into the command safely", async () => const config = await loadResolvedConfig(cwd); assert.deepEqual(config.agents, { - custom: 'node "/usr/local/bin/my agent" "--profile" "with spaces" "quote\\"me"', - }); - assert.deepEqual(splitCommandLine(config.agents.custom), { - command: "node", - args: ["/usr/local/bin/my agent", "--profile", "with spaces", 'quote"me'], + custom: { + command: + 'node "/usr/local/bin/my agent" "--profile" "with spaces" "quote\\"me" "C:\\\\Program Files\\\\Agent"', + argv: [ + "node", + "/usr/local/bin/my agent", + "--profile", + "with spaces", + 'quote"me', + "C:\\Program Files\\Agent", + ], + }, }); }); }); @@ -344,6 +357,70 @@ test("splitCommandLine preserves empty quoted arguments", () => { }); }); +test("loadResolvedConfig migrates JSON-doubled legacy args to literal argv", async () => { + await withTempEnv(async ({ homeDir }) => { + const cwd = path.join(homeDir, "workspace"); + await fs.mkdir(cwd, { recursive: true }); + await fs.mkdir(path.join(homeDir, ".acpx"), { recursive: true }); + await fs.writeFile( + path.join(homeDir, ".acpx", "config.json"), + '{"agents":{"custom":{"command":"C:\\\\tools\\\\bin\\\\agent.sh","args":["\\\\\\\\.\\\\pipe\\\\acpx-agent"]}}}\n', + "utf8", + ); + + const config = await loadResolvedConfig(cwd); + assert.deepEqual(config.agents.custom?.argv, [ + "C:\\tools\\bin\\agent.sh", + "\\\\.\\pipe\\acpx-agent", + ]); + }); +}); + +test("structured argv rejects the Windows .sh ENOENT repro with explicit guidance", async () => { + await withTempEnv(async ({ homeDir }) => { + const cwd = path.join(homeDir, "workspace"); + await fs.mkdir(cwd, { recursive: true }); + await fs.mkdir(path.join(homeDir, ".acpx"), { recursive: true }); + await fs.writeFile( + path.join(homeDir, ".acpx", "config.json"), + `${JSON.stringify({ + agents: { + custom: { + argv: ["C:\\tools\\bin\\agent.sh", "--pipe", "\\\\.\\pipe\\acpx-agent"], + }, + }, + })}\n`, + "utf8", + ); + + const config = await loadResolvedConfig(cwd); + const custom = config.agents.custom; + assert.ok(custom); + assert.throws( + () => resolveAgentCommandParts(custom.command, custom.argv, "win32"), + /Windows cannot launch shell script executable "C:\\tools\\bin\\agent\.sh" directly.*explicit interpreter argv.*acpx does not infer interpreters/su, + ); + assert.deepEqual( + resolveAgentCommandParts( + 'bash "C:\\\\tools\\\\bin\\\\agent.sh"', + ["bash", "C:\\tools\\bin\\agent.sh", "--pipe", "\\\\.\\pipe\\acpx-agent"], + "win32", + ), + { + command: "bash", + args: ["C:\\tools\\bin\\agent.sh", "--pipe", "\\\\.\\pipe\\acpx-agent"], + }, + ); + }); +}); + +test("raw Windows command strings fail with argv migration guidance", () => { + assert.throws( + () => resolveAgentCommandParts("C:\\tools\\bin\\agent.sh --profile ci", undefined, "win32"), + /Raw agent command strings are not supported on Windows.*argv array.*agents\.\.args/su, + ); +}); + test("splitCommandLine rejects empty quoted commands", () => { assert.throws(() => splitCommandLine('""'), { message: "Invalid --agent command: empty command", @@ -380,6 +457,26 @@ test("loadResolvedConfig rejects invalid agent args", async () => { }); }); +test("loadResolvedConfig rejects ambiguous or quoted legacy command plus args", async () => { + await withTempEnv(async ({ homeDir }) => { + const cwd = path.join(homeDir, "workspace"); + await fs.mkdir(cwd, { recursive: true }); + await fs.mkdir(path.join(homeDir, ".acpx"), { recursive: true }); + for (const command of ["node ./server.js", '"C:\\Program Files\\agent.exe"', '"node"']) { + await fs.writeFile( + path.join(homeDir, ".acpx", "config.json"), + `${JSON.stringify({ agents: { custom: { command, args: ["acp"] } } })}\n`, + "utf8", + ); + + await assert.rejects( + () => loadResolvedConfig(cwd), + /command must be an unquoted executable with no whitespace when args is present; migrate the complete launch to argv/u, + ); + } + }); +}); + async function withTempEnv(run: (ctx: { homeDir: string }) => Promise): Promise { const originalHome = process.env.HOME; diff --git a/test/flows.test.ts b/test/flows.test.ts index ad3e69cc..8928455b 100644 --- a/test/flows.test.ts +++ b/test/flows.test.ts @@ -609,7 +609,8 @@ test("FlowRunner executes isolated ACP nodes and branches deterministically", as const runner = new FlowRunner({ resolveAgent: () => ({ agentName: "mock", - agentCommand: MOCK_AGENT_COMMAND, + agentCommand: "display-only-invalid-command", + agentArgv: ["node", MOCK_AGENT_PATH], cwd, }), permissionMode: "approve-all", @@ -671,6 +672,11 @@ test("FlowRunner executes isolated ACP nodes and branches deterministically", as assert.equal(result.state.status, "completed"); assert.deepEqual(result.state.outputs.yes, { ok: true }); assert.equal(result.state.outputs.no, undefined); + assert.ok( + Object.values(result.state.sessionBindings).every( + (binding) => binding.agentArgv?.[1] === MOCK_AGENT_PATH, + ), + ); assert.match(result.runDir, new RegExp(escapeRegExp(flowRunsBaseDir(homeDir)))); } finally { await fs.rm(cwd, { recursive: true, force: true }); @@ -745,6 +751,7 @@ test("FlowRunner writes isolated ACP bundle traces and artifacts", async () => { resolveAgent: () => ({ agentName: "mock", agentCommand: `${MOCK_AGENT_COMMAND} --supports-load-session`, + agentArgv: ["node", MOCK_AGENT_PATH, "--supports-load-session"], cwd, }), permissionMode: "approve-all", @@ -838,7 +845,8 @@ test("FlowRunner writes persistent ACP bundle traces and session bindings", asyn const runner = new FlowRunner({ resolveAgent: () => ({ agentName: "mock", - agentCommand: `${MOCK_AGENT_COMMAND} --supports-load-session`, + agentCommand: "display-only-invalid-command --supports-load-session", + agentArgv: ["node", MOCK_AGENT_PATH, "--supports-load-session"], cwd, }), permissionMode: "approve-all", @@ -885,6 +893,11 @@ test("FlowRunner writes persistent ACP bundle traces and session bindings", asyn assert.equal(result.state.status, "completed"); assert.equal(manifest.sessions.length, 1); assert.equal(Object.values(result.state.sessionBindings).length, 1); + assert.deepEqual(Object.values(result.state.sessionBindings)[0]?.agentArgv, [ + "node", + MOCK_AGENT_PATH, + "--supports-load-session", + ]); assert.equal(steps[0]?.session?.bundleId, manifest.sessions[0]?.id); assert.equal(steps[0]?.trace?.sessionId, manifest.sessions[0]?.id); assert.ok(steps[0]?.trace?.conversation?.eventEndSeq); @@ -893,7 +906,7 @@ test("FlowRunner writes persistent ACP bundle traces and session bindings", asyn const record = JSON.parse( await fs.readFile(path.join(result.runDir, manifest.sessions[0].recordPath), "utf8"), - ) as { messages: unknown[]; lastSeq: number }; + ) as { messages: unknown[]; lastSeq: number; agentArgv?: string[] }; const bundledEvents = ( await fs.readFile(path.join(result.runDir, manifest.sessions[0].eventsPath), "utf8") ) @@ -902,6 +915,7 @@ test("FlowRunner writes persistent ACP bundle traces and session bindings", asyn .map((line) => JSON.parse(line) as { seq?: number }); assert.ok(record.messages.length >= 2); + assert.deepEqual(record.agentArgv, ["node", MOCK_AGENT_PATH, "--supports-load-session"]); assert.equal(record.lastSeq, bundledEvents.length); assert.equal(bundledEvents[0]?.seq, 1); } finally { diff --git a/test/runtime-test-helpers.ts b/test/runtime-test-helpers.ts index 87467fa8..33604b1f 100644 --- a/test/runtime-test-helpers.ts +++ b/test/runtime-test-helpers.ts @@ -29,6 +29,7 @@ export function makeSessionRecord( acpSessionId: overrides.acpSessionId, agentSessionId: overrides.agentSessionId, agentCommand: overrides.agentCommand, + agentArgv: overrides.agentArgv, cwd: options.resolveCwd === false ? overrides.cwd : path.resolve(overrides.cwd), name: overrides.name ?? (defaultName ? overrides.acpxRecordId : undefined), createdAt: overrides.createdAt ?? timestamp, diff --git a/test/runtime.test.ts b/test/runtime.test.ts index 4cc3a688..91a6c11a 100644 --- a/test/runtime.test.ts +++ b/test/runtime.test.ts @@ -293,6 +293,26 @@ test("doctor reports backend unavailable probe failures and agent registry honor assert.deepEqual(report.details, ["agent=codex", "command=codex-override --acp"]); }); +test("agent registry preserves structured argv overrides", () => { + const registry = createAgentRegistry({ + overrides: { + Custom: ["C:\\tools\\bin\\agent.sh", "--pipe", "\\\\.\\pipe\\acpx-agent"], + droid: ["C:\\tools\\droid.exe", "--acp"], + blank: " ", + }, + }); + + assert.deepEqual(registry.resolve("custom"), [ + "C:\\tools\\bin\\agent.sh", + "--pipe", + "\\\\.\\pipe\\acpx-agent", + ]); + assert.equal(registry.resolve("blank"), "blank"); + assert.deepEqual(registry.resolve("factorydroid"), ["C:\\tools\\droid.exe", "--acp"]); + assert.equal(registry.list().includes("Custom"), false); + assert.equal(registry.list().includes("custom"), true); +}); + test("doctor coerces probe detail values to strings", async () => { const circular: Record = { code: "BROKEN" }; circular.self = circular; diff --git a/test/session-persistence.test.ts b/test/session-persistence.test.ts index cb1c231b..ca97030a 100644 --- a/test/session-persistence.test.ts +++ b/test/session-persistence.test.ts @@ -4,6 +4,7 @@ import { once } from "node:events"; import fs from "node:fs/promises"; import path from "node:path"; import test from "node:test"; +import { AGENT_ARGV_REGISTRY, AGENT_REGISTRY } from "../src/agent-registry.js"; import { parseSessionRecord, serializeSessionRecordForDisk } from "../src/session/persistence.js"; import { fileExists, @@ -29,6 +30,70 @@ test("SessionRecord allows optional closed and closedAt fields", () => { assert.equal(record.closedAt, undefined); }); +test("parseSessionRecord preserves structured agent argv", () => { + const serialized = serializeSessionRecordForDisk( + makeSessionRecord({ + acpxRecordId: "structured-agent-argv", + acpSessionId: "structured-agent-argv", + agentCommand: '"C:\\\\tools\\\\bin\\\\agent.sh"', + agentArgv: ["C:\\tools\\bin\\agent.sh", "--pipe", "\\\\.\\pipe\\acpx-agent"], + cwd: "/tmp/structured-agent-argv", + }), + ); + + const parsed = parseSessionRecord(serialized); + + assert.ok(parsed); + assert.deepEqual(parsed.agentArgv, [ + "C:\\tools\\bin\\agent.sh", + "--pipe", + "\\\\.\\pipe\\acpx-agent", + ]); +}); + +test("parseSessionRecord backfills argv for legacy built-in records", () => { + const serialized = serializeSessionRecordForDisk( + makeSessionRecord({ + acpxRecordId: "legacy-built-in-argv", + acpSessionId: "legacy-built-in-argv", + agentCommand: AGENT_REGISTRY.codex, + cwd: "/tmp/legacy-built-in-argv", + }), + ); + delete serialized.agent_argv; + + const parsed = parseSessionRecord(serialized); + + assert.ok(parsed); + assert.deepEqual(parsed.agentArgv, AGENT_ARGV_REGISTRY.codex); +}); + +test("parseSessionRecord backfills argv for historical built-in commands", () => { + for (const [agentCommand, expectedArgv] of [ + ["npx @zed-industries/codex-acp@^0.12.0", AGENT_ARGV_REGISTRY.codex], + ["npm exec @agentclientprotocol/claude-agent-acp@^0.37.0", AGENT_ARGV_REGISTRY.claude], + ["npx -y mux@^0.27.0 acp", AGENT_ARGV_REGISTRY.mux], + ["gemini --experimental-acp", AGENT_ARGV_REGISTRY.gemini], + ["kiro-cli acp", AGENT_ARGV_REGISTRY.kiro], + ["npx opencode-ai", AGENT_ARGV_REGISTRY.opencode], + ] as const) { + const serialized = serializeSessionRecordForDisk( + makeSessionRecord({ + acpxRecordId: agentCommand, + acpSessionId: agentCommand, + agentCommand, + cwd: "/tmp/historical-built-in-argv", + }), + ); + delete serialized.agent_argv; + + const parsed = parseSessionRecord(serialized); + + assert.ok(parsed); + assert.deepEqual(parsed.agentArgv, expectedArgv); + } +}); + test("parseSessionRecord preserves persisted session env", () => { const serialized = serializeSessionRecordForDisk( makeSessionRecord({ diff --git a/test/spawn-options.test.ts b/test/spawn-options.test.ts index 93c781b7..76cda351 100644 --- a/test/spawn-options.test.ts +++ b/test/spawn-options.test.ts @@ -10,6 +10,7 @@ import { buildAgentSpawnOptions, buildSpawnCommandOptions } from "../src/acp/cli import { buildTerminalSpawnOptions } from "../src/acp/terminal-manager.js"; import { buildQueueOwnerSpawnOptions } from "../src/cli/session/queue-owner-process.js"; import { + buildAgentSpawnCommand, buildTerminalShellSpawnCommand, buildTerminalSpawnCommand, resolveWindowsExecutablePath, @@ -260,6 +261,41 @@ test("buildSpawnCommandOptions enables shell for .cmd/.bat on Windows", () => { assert.equal(cmdOptions.windowsHide, true); }); +test("buildAgentSpawnCommand preserves argv boundaries through cmd.exe", () => { + const command = buildAgentSpawnCommand( + "C:\\Program Files\\agent.cmd", + ["with spaces", "a&b", "C:\\trailing\\"], + "win32", + { COMSPEC: "C:\\Windows\\System32\\cmd.exe" }, + ); + + assert.deepEqual(command, { + command: "C:\\Windows\\System32\\cmd.exe", + args: [ + "/d", + "/s", + "/c", + '"C:\\Program^ Files\\agent.cmd ^"with^ spaces^" ^"a^&b^" ^"C:\\trailing\\\\^""', + ], + windowsVerbatimArguments: true, + }); +}); + +test("buildAgentSpawnCommand normalizes forward-slash batch paths for cmd.exe", () => { + const command = buildAgentSpawnCommand( + "C:/tools/agent.cmd", + ["--profile", "with spaces"], + "win32", + {}, + ); + + assert.deepEqual(command, { + command: "cmd.exe", + args: ["/d", "/s", "/c", '"C:\\tools\\agent.cmd ^"--profile^" ^"with^ spaces^""'], + windowsVerbatimArguments: true, + }); +}); + test("buildSpawnCommandOptions enables shell for PATH-resolved .cmd wrappers on Windows", async () => { const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "acpx-windows-spawn-")); const env = { From 3405b0ef882b009296ead03ccee84a22d7b08f95 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 27 Jul 2026 09:04:07 -0400 Subject: [PATCH 07/57] chore(deps): bump actions/checkout (#462) Bumps the actions group with 1 update in the / directory: [actions/checkout](https://github.com/actions/checkout). Updates `actions/checkout` from 7.0.0 to 7.0.1 - [Release notes](https://github.com/actions/checkout/releases) - [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md) - [Commits](https://github.com/actions/checkout/compare/v7.0.0...v7.0.1) --- updated-dependencies: - dependency-name: actions/checkout dependency-version: 7.0.1 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: actions ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/ci.yml | 6 +++--- .github/workflows/conformance-nightly.yml | 4 ++-- .github/workflows/crabbox-hydrate.yml | 2 +- .github/workflows/pages.yml | 2 +- .github/workflows/release.yml | 2 +- 5 files changed, 8 insertions(+), 8 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4fa369fc..edbccc54 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -25,7 +25,7 @@ jobs: docs_changed: ${{ steps.scope.outputs.docs_changed }} steps: - name: Checkout - uses: actions/checkout@v7.0.0 + uses: actions/checkout@v7.0.1 with: fetch-depth: 0 fetch-tags: false @@ -123,7 +123,7 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v7.0.0 + uses: actions/checkout@v7.0.1 with: fetch-depth: 1 fetch-tags: false @@ -156,7 +156,7 @@ jobs: contents: read steps: - name: Checkout - uses: actions/checkout@v7.0.0 + uses: actions/checkout@v7.0.1 with: fetch-depth: 1 fetch-tags: false diff --git a/.github/workflows/conformance-nightly.yml b/.github/workflows/conformance-nightly.yml index af5278bd..05a5389f 100644 --- a/.github/workflows/conformance-nightly.yml +++ b/.github/workflows/conformance-nightly.yml @@ -32,7 +32,7 @@ jobs: contents: read steps: - name: Checkout - uses: actions/checkout@v7.0.0 + uses: actions/checkout@v7.0.1 with: fetch-depth: 1 fetch-tags: false @@ -107,7 +107,7 @@ jobs: command: "npx -y @zed-industries/claude-agent-acp" steps: - name: Checkout - uses: actions/checkout@v7.0.0 + uses: actions/checkout@v7.0.1 with: fetch-depth: 1 fetch-tags: false diff --git a/.github/workflows/crabbox-hydrate.yml b/.github/workflows/crabbox-hydrate.yml index d26afc74..5627d088 100644 --- a/.github/workflows/crabbox-hydrate.yml +++ b/.github/workflows/crabbox-hydrate.yml @@ -38,7 +38,7 @@ jobs: runs-on: [self-hosted, crabbox, openclaw, acpx, "${{ inputs.crabbox_runner_label }}"] timeout-minutes: 120 steps: - - uses: actions/checkout@v7.0.0 + - uses: actions/checkout@v7.0.1 with: ref: ${{ inputs.ref || github.ref }} diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml index fd327299..a6c5f94f 100644 --- a/.github/workflows/pages.yml +++ b/.github/workflows/pages.yml @@ -32,7 +32,7 @@ jobs: url: ${{ steps.deployment.outputs.page_url }} steps: - name: Check out - uses: actions/checkout@v7.0.0 + uses: actions/checkout@v7.0.1 - name: Set up Node uses: actions/setup-node@v4 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6722b8a2..2bfb71ef 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -25,7 +25,7 @@ jobs: contents: read id-token: write steps: - - uses: actions/checkout@v7.0.0 + - uses: actions/checkout@v7.0.1 with: fetch-depth: 0 From 6eba58422dffe0b913fb9896e0ac745eae0a0239 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 27 Jul 2026 09:13:26 -0400 Subject: [PATCH 08/57] chore(deps): bump actions/setup-node from 4 to 7 (#452) Bumps [actions/setup-node](https://github.com/actions/setup-node) from 4 to 7. - [Release notes](https://github.com/actions/setup-node/releases) - [Commits](https://github.com/actions/setup-node/compare/v4...v7) --- updated-dependencies: - dependency-name: actions/setup-node dependency-version: '7' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/ci.yml | 4 ++-- .github/workflows/conformance-nightly.yml | 4 ++-- .github/workflows/crabbox-hydrate.yml | 2 +- .github/workflows/pages.yml | 2 +- .github/workflows/release.yml | 2 +- 5 files changed, 7 insertions(+), 7 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index edbccc54..4e0b9257 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -135,7 +135,7 @@ jobs: version: ${{ env.PNPM_VERSION }} - name: Setup Node.js - uses: actions/setup-node@v6 + uses: actions/setup-node@v7 with: node-version: ${{ matrix.node_version || env.NODE_VERSION }} check-latest: true @@ -168,7 +168,7 @@ jobs: version: ${{ env.PNPM_VERSION }} - name: Setup Node.js - uses: actions/setup-node@v6 + uses: actions/setup-node@v7 with: node-version: ${{ env.NODE_VERSION }} check-latest: true diff --git a/.github/workflows/conformance-nightly.yml b/.github/workflows/conformance-nightly.yml index 05a5389f..cec1978d 100644 --- a/.github/workflows/conformance-nightly.yml +++ b/.github/workflows/conformance-nightly.yml @@ -44,7 +44,7 @@ jobs: version: ${{ env.PNPM_VERSION }} - name: Setup Node.js - uses: actions/setup-node@v6 + uses: actions/setup-node@v7 with: node-version: ${{ env.NODE_VERSION }} check-latest: true @@ -119,7 +119,7 @@ jobs: version: ${{ env.PNPM_VERSION }} - name: Setup Node.js - uses: actions/setup-node@v6 + uses: actions/setup-node@v7 with: node-version: ${{ env.NODE_VERSION }} check-latest: true diff --git a/.github/workflows/crabbox-hydrate.yml b/.github/workflows/crabbox-hydrate.yml index 5627d088..42e63d66 100644 --- a/.github/workflows/crabbox-hydrate.yml +++ b/.github/workflows/crabbox-hydrate.yml @@ -46,7 +46,7 @@ jobs: with: version: ${{ env.PNPM_VERSION }} - - uses: actions/setup-node@v6 + - uses: actions/setup-node@v7 with: node-version: 24 cache: pnpm diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml index a6c5f94f..221139ee 100644 --- a/.github/workflows/pages.yml +++ b/.github/workflows/pages.yml @@ -35,7 +35,7 @@ jobs: uses: actions/checkout@v7.0.1 - name: Set up Node - uses: actions/setup-node@v4 + uses: actions/setup-node@v7 with: node-version: "22" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 2bfb71ef..8c9c525f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -33,7 +33,7 @@ jobs: with: version: ${{ env.PNPM_VERSION }} - - uses: actions/setup-node@v6 + - uses: actions/setup-node@v7 with: node-version: ${{ env.NODE_VERSION }} check-latest: true From 272d93409a97dd24887bb0bc8b125f17fabf8623 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 27 Jul 2026 09:41:17 -0400 Subject: [PATCH 09/57] chore(deps): refresh dependencies and security fixes (#475) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/ci.yml | 2 +- .github/workflows/conformance-nightly.yml | 2 +- .github/workflows/crabbox-hydrate.yml | 2 +- .github/workflows/release.yml | 2 +- AGENTS.md | 4 +- CHANGELOG.md | 2 + docs/install.md | 6 +- package.json | 36 +- pnpm-lock.yaml | 1479 ++++++++++----------- pnpm-workspace.yaml | 5 +- 10 files changed, 768 insertions(+), 772 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4e0b9257..d7a73b7f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -12,7 +12,7 @@ concurrency: env: NODE_VERSION: 24 - PNPM_VERSION: 10.33.2 + PNPM_VERSION: 10.34.5 jobs: scope: diff --git a/.github/workflows/conformance-nightly.yml b/.github/workflows/conformance-nightly.yml index cec1978d..ade4818c 100644 --- a/.github/workflows/conformance-nightly.yml +++ b/.github/workflows/conformance-nightly.yml @@ -22,7 +22,7 @@ concurrency: env: NODE_VERSION: 24 - PNPM_VERSION: 10.33.2 + PNPM_VERSION: 10.34.5 jobs: conformance-mock: diff --git a/.github/workflows/crabbox-hydrate.yml b/.github/workflows/crabbox-hydrate.yml index 42e63d66..6013c777 100644 --- a/.github/workflows/crabbox-hydrate.yml +++ b/.github/workflows/crabbox-hydrate.yml @@ -30,7 +30,7 @@ permissions: contents: read env: - PNPM_VERSION: 10.33.2 + PNPM_VERSION: 10.34.5 jobs: hydrate: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8c9c525f..4e7b410d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -11,7 +11,7 @@ concurrency: env: NODE_VERSION: 24 - PNPM_VERSION: 10.33.2 + PNPM_VERSION: 10.34.5 jobs: release: diff --git a/AGENTS.md b/AGENTS.md index eb19e1c2..c249da41 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -22,9 +22,9 @@ instead of expanding this file into a full technical spec. - npm: `https://www.npmjs.com/package/acpx` - Default branch: `main` - Runtime: Node.js `>=22.13.0` -- Package manager: `pnpm@10.33.2` +- Package manager: `pnpm@10.34.5` - Clean Node 22.13 setups can have stale Corepack signing keys; install pnpm - with `npm install -g pnpm@10.33.2` if `corepack prepare` fails. + with `npm install -g pnpm@10.34.5` if `corepack prepare` fails. ## Product Direction diff --git a/CHANGELOG.md b/CHANGELOG.md index 4a1a289a..465e63b2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,8 @@ Repo: https://github.com/openclaw/acpx ### Changes +- Dependencies/tooling: refresh the ACP SDK, runtime and development toolchain, update pnpm to 10.34.5, and resolve the PostCSS, fast-uri, js-yaml, and brace-expansion advisories. + ### Breaking - Windows agent launches now require structured `agents..argv`; unambiguous legacy `command` plus `args` entries migrate automatically, while raw, ambiguous, or directly executable `.sh` commands fail with explicit migration guidance instead of lossy parsing or CreateProcess ENOENT. Existing custom-agent sessions without saved argv must be recreated. Fixes #466. Thanks @MarcelCFritsche. diff --git a/docs/install.md b/docs/install.md index 67733a57..ceae8144 100644 --- a/docs/install.md +++ b/docs/install.md @@ -8,13 +8,13 @@ description: Install acpx globally with npm, run it ad-hoc with npx, or build fr ## Requirements - Node.js **22.13 or newer** (see `engines.node` in `package.json`) -- pnpm **10.33.2** for source builds +- pnpm **10.34.5** for source builds - The underlying coding agent CLI you plan to talk to (Codex, Claude, etc.) If pnpm is not installed yet, use npm: ```bash -npm install -g pnpm@10.33.2 +npm install -g pnpm@10.34.5 ``` Some older Corepack builds bundled with supported Node.js versions have stale @@ -74,7 +74,7 @@ For development or to test an unreleased branch: ```bash git clone https://github.com/openclaw/acpx.git cd acpx -npm install -g pnpm@10.33.2 # if pnpm is not already installed +npm install -g pnpm@10.34.5 # if pnpm is not already installed pnpm install pnpm run build node dist/cli.js --help diff --git a/package.json b/package.json index 42b4902f..03e4e84e 100644 --- a/package.json +++ b/package.json @@ -80,10 +80,10 @@ "viewer:typecheck": "tsc -p examples/flows/replay-viewer/tsconfig.json --noEmit && tsc -p examples/flows/replay-viewer/tsconfig.server.json --noEmit" }, "dependencies": { - "@agentclientprotocol/sdk": "^1.2.1", + "@agentclientprotocol/sdk": "^1.3.0", "commander": "^15.0.0", - "skillflag": "^0.2.0", - "tsx": "^4.23.0", + "skillflag": "^0.2.1", + "tsx": "^4.23.1", "zod": "^4.4.3" }, "devDependencies": { @@ -94,24 +94,24 @@ "@types/react-test-renderer": "^19.1.0", "@types/ws": "^8.18.1", "@typescript/native": "npm:typescript@^7.0.2", - "@vitejs/plugin-react": "^6.0.3", + "@vitejs/plugin-react": "^6.0.4", "@xyflow/react": "^12.11.2", - "c8": "^11.0.0", - "elkjs": "^0.11.1", + "c8": "^12.0.0", + "elkjs": "^0.12.0", "fast-json-patch": "^3.1.1", "husky": "^9.1.7", - "lint-staged": "^17.0.8", - "markdownlint-cli2": "^0.23.0", - "oxfmt": "^0.58.0", - "oxlint": "^1.73.0", - "oxlint-tsgolint": "^0.24.0", - "react": "^19.2.7", - "react-dom": "^19.2.7", - "react-test-renderer": "^19.2.7", - "tsdown": "^0.22.4", + "lint-staged": "^17.2.0", + "markdownlint-cli2": "^0.23.1", + "oxfmt": "^0.60.0", + "oxlint": "^1.75.0", + "oxlint-tsgolint": "^7.0.2001", + "react": "^19.2.8", + "react-dom": "^19.2.8", + "react-test-renderer": "^19.2.8", + "tsdown": "^0.22.14", "typescript": "npm:@typescript/typescript6@^6.0.2", - "vite": "^8.1.4", - "ws": "^8.21.0" + "vite": "^8.1.5", + "ws": "^8.21.1" }, "lint-staged": { "*.{js,ts}": [ @@ -125,5 +125,5 @@ "engines": { "node": ">=22.13.0" }, - "packageManager": "pnpm@10.33.2" + "packageManager": "pnpm@10.34.5" } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 47e9c489..e31a7f95 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -5,8 +5,9 @@ settings: excludeLinksFromLockfile: false overrides: - c8>yargs: 18.0.0 - js-yaml: 4.2.0 + brace-expansion: 5.0.8 + fast-uri: 3.1.4 + js-yaml: 4.3.0 markdown-it: 14.2.0 qs: 6.15.2 @@ -15,17 +16,17 @@ importers: .: dependencies: '@agentclientprotocol/sdk': - specifier: ^1.2.1 - version: 1.2.1(zod@4.4.3) + specifier: ^1.3.0 + version: 1.3.0(zod@4.4.3) commander: specifier: ^15.0.0 version: 15.0.0 skillflag: - specifier: ^0.2.0 - version: 0.2.0 + specifier: ^0.2.1 + version: 0.2.1 tsx: - specifier: ^4.23.0 - version: 4.23.0 + specifier: ^4.23.1 + version: 4.23.1 zod: specifier: ^4.4.3 version: 4.4.3 @@ -52,17 +53,17 @@ importers: specifier: npm:typescript@^7.0.2 version: typescript@7.0.2 '@vitejs/plugin-react': - specifier: ^6.0.3 - version: 6.0.3(vite@8.1.4(@types/node@26.1.1)(esbuild@0.28.1)(tsx@4.23.0)(yaml@2.9.0)) + specifier: ^6.0.4 + version: 6.0.4(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.1)(tsx@4.23.1)(yaml@2.9.0)) '@xyflow/react': specifier: ^12.11.2 - version: 12.11.2(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + version: 12.11.2(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) c8: - specifier: ^11.0.0 - version: 11.0.0 + specifier: ^12.0.0 + version: 12.0.0 elkjs: - specifier: ^0.11.1 - version: 0.11.1 + specifier: ^0.12.0 + version: 0.12.0 fast-json-patch: specifier: ^3.1.1 version: 3.1.1 @@ -70,46 +71,46 @@ importers: specifier: ^9.1.7 version: 9.1.7 lint-staged: - specifier: ^17.0.8 - version: 17.0.8 + specifier: ^17.2.0 + version: 17.2.0 markdownlint-cli2: - specifier: ^0.23.0 - version: 0.23.0 + specifier: ^0.23.1 + version: 0.23.1 oxfmt: - specifier: ^0.58.0 - version: 0.58.0 + specifier: ^0.60.0 + version: 0.60.0 oxlint: - specifier: ^1.73.0 - version: 1.73.0(oxlint-tsgolint@0.24.0) + specifier: ^1.75.0 + version: 1.75.0(oxlint-tsgolint@7.0.2001) oxlint-tsgolint: - specifier: ^0.24.0 - version: 0.24.0 + specifier: ^7.0.2001 + version: 7.0.2001 react: - specifier: ^19.2.7 - version: 19.2.7 + specifier: ^19.2.8 + version: 19.2.8 react-dom: - specifier: ^19.2.7 - version: 19.2.7(react@19.2.7) + specifier: ^19.2.8 + version: 19.2.8(react@19.2.8) react-test-renderer: - specifier: ^19.2.7 - version: 19.2.7(react@19.2.7) + specifier: ^19.2.8 + version: 19.2.8(react@19.2.8) tsdown: - specifier: ^0.22.4 - version: 0.22.4(@typescript/native-preview@7.0.0-dev.20260707.2)(@typescript/typescript6@6.0.2)(tsx@4.23.0)(unrun@0.2.37) + specifier: ^0.22.14 + version: 0.22.14(@typescript/typescript6@6.0.2)(tsx@4.23.1)(unrun@0.2.37) typescript: specifier: npm:@typescript/typescript6@^6.0.2 version: '@typescript/typescript6@6.0.2' vite: - specifier: ^8.1.4 - version: 8.1.4(@types/node@26.1.1)(esbuild@0.28.1)(tsx@4.23.0)(yaml@2.9.0) + specifier: ^8.1.5 + version: 8.1.5(@types/node@26.1.1)(esbuild@0.28.1)(tsx@4.23.1)(yaml@2.9.0) ws: - specifier: ^8.21.0 - version: 8.21.0 + specifier: ^8.21.1 + version: 8.21.1 packages: - '@agentclientprotocol/sdk@1.2.1': - resolution: {integrity: sha512-jwYUdOQR7tc+Zfch53VL4JJyUNK/46q03uUTYb+PjECsmnNl94XFXOfYLJ8RBpMNidXd1rpOAVgb0vqD98xImA==} + '@agentclientprotocol/sdk@1.3.0': + resolution: {integrity: sha512-i3h/efaeuMUFAO1HSfo97QZQnnvMd7wWBYtBsdL6UMZg3a78sk3Ffya5Xu7C7tYsXomXoDXJBAzQF2PcFKAhIQ==} peerDependencies: zod: ^3.25.0 || ^4.0.0 @@ -284,12 +285,18 @@ packages: '@emnapi/core@1.11.1': resolution: {integrity: sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==} + '@emnapi/core@1.11.2': + resolution: {integrity: sha512-TC8MkTuZUtcTSiFeuC0ksCh9QIJ5+F21MvZ4Wn4ORfYaFJ/0dsiudv5tVkejgwZlwQ39jL9WWDe2lz8x0WglOA==} + '@emnapi/runtime@1.10.0': resolution: {integrity: sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==} '@emnapi/runtime@1.11.1': resolution: {integrity: sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==} + '@emnapi/runtime@1.11.2': + resolution: {integrity: sha512-kyOl3X0DuTiT1h2ft8r2fYO8JYtU9a9Xis/zBSiGArNaagCOWx90N1k2wxp18czFDH+OgcWGb5ZP/XMt3dcyPA==} + '@emnapi/wasi-threads@1.2.1': resolution: {integrity: sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==} @@ -630,276 +637,279 @@ packages: '@oxc-project/types@0.139.0': resolution: {integrity: sha512-r9gHphtCs+1M7J0pw6Sn/hh/Wpa/iQrOOkrNAlVLF/gHq+/CJmHIWKKUUhdWjcD6CIa8idarspCsASiXCXvFUw==} - '@oxfmt/binding-android-arm-eabi@0.58.0': - resolution: {integrity: sha512-Uz62sHduGGPftXtILGyxdSW4PX82rUg+rfdNqhsgxe881g4rIoXlIqmZQ6HVKcF4f+F8qMhdD03Bx5u7gmeTdg==} + '@oxc-project/types@0.140.0': + resolution: {integrity: sha512-h5LUOzGArYemnW1NMz/DuuQhBi96J6JL2Bk8zE4kvqxB5Sg3jxmCiH4uyOWHDkiKSt5vWlG4FIwCR/DbstcNRQ==} + + '@oxfmt/binding-android-arm-eabi@0.60.0': + resolution: {integrity: sha512-1q4q4Jc8FlOMVojEisyFAVyl8h1yawNv6phjgmhGVEDeyeOdsSnSr9x0+D4mOnEKvpO5L4mxKZ/DP9X6U3A/Mw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [android] - '@oxfmt/binding-android-arm64@0.58.0': - resolution: {integrity: sha512-rD0lRaJp1b+9vw6X4A2dJWKukd6X8yxiicN4JxXcXayolmUypRZxk+lKR+fVOu5q/iYc0fh5fR4bgmfOfVlbaA==} + '@oxfmt/binding-android-arm64@0.60.0': + resolution: {integrity: sha512-tD41I6nCt9k8SQXft0CSjjU9jg6SwG7uMu7PxodSEHXl+GDW0868oy6tTtoJkyUze8YKFgTpz/k5LuPUnFiGLw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [android] - '@oxfmt/binding-darwin-arm64@0.58.0': - resolution: {integrity: sha512-uzbPPk7O6M+w2K65vcQ1woga3wgP8zghjL1KOG5b6qJ8dvYHZJ1VShaslg2KOK6yQIwCQtcMCXqLBM6sqXUNTg==} + '@oxfmt/binding-darwin-arm64@0.60.0': + resolution: {integrity: sha512-TTpzPug96Zxdyb46KvTyIUQDdsqbumXh2TKG9C23PCT0kF7JkW56Z/quPuG9rqOFKQIi1gpRNZ7DX18LwxXPnw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [darwin] - '@oxfmt/binding-darwin-x64@0.58.0': - resolution: {integrity: sha512-L0nKYDxU32oxeQqJj21W9SlIMnf81VZEhyah6iDvFhf5q0oynq498Fopth7blErUJVBpVtxQ98RMCfMPqpJX6w==} + '@oxfmt/binding-darwin-x64@0.60.0': + resolution: {integrity: sha512-CnOoWgQ7L+JL/YQaRJ+NyATciSfcftncm7y3kqyte1cGtFEGnStaCd1TAyrinkfQ7nRBfHrTs1/vTwUJr3WF2Q==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [darwin] - '@oxfmt/binding-freebsd-x64@0.58.0': - resolution: {integrity: sha512-woNwfD58dC5PGS9LSLSD5JYfo/EFK5iG9vhDWkcCg3q78ag7KC8bpDqgvPHrMoXpx83OLXxoSOhu6z8FsVTHlg==} + '@oxfmt/binding-freebsd-x64@0.60.0': + resolution: {integrity: sha512-ychJo7S3hZxdO6eDZ9zM6F2lM9fpJS3EKS5CAUSWyprdLYxTu4gbaUKV/VBPTcMJwQa2Bpo+643y3OJ537pihA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [freebsd] - '@oxfmt/binding-linux-arm-gnueabihf@0.58.0': - resolution: {integrity: sha512-Sqs8nMLxuQpY21NKJ1u4stPDmO5hskBCNNh2E3AdCfI1QqWtf4m+Qn4mGEIUO4KGmuq3SWc/SZ80uy5IiwTCDw==} + '@oxfmt/binding-linux-arm-gnueabihf@0.60.0': + resolution: {integrity: sha512-36IH5o55T2Fx7E0feDttt+mifxN6yk9pWv4KfhAIsP0dFnUq27331OwbpOsZdoXF9soOLWm7mQUz5+UUmyec4g==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [linux] - '@oxfmt/binding-linux-arm-musleabihf@0.58.0': - resolution: {integrity: sha512-Vd4exzBI5B5hB9m22JiTQzIL23WvHo/Pe+sNXPNeBLXSP9swCBPKCEBRwKpmpQzYhlgYaCgfPcGXPKAJBRIiZQ==} + '@oxfmt/binding-linux-arm-musleabihf@0.60.0': + resolution: {integrity: sha512-G1Ve7lAa6sFBolVI2LWHfEAqy0YKh4vnioH8uYO9kAEdgM7mR40IksIx9/Zk4+vbYew/sGa4J9Q4tZ3n9gXDHA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [linux] - '@oxfmt/binding-linux-arm64-gnu@0.58.0': - resolution: {integrity: sha512-bUWi5mHV+4Vi56RLHE1h6q/HHfwAIT3XoB9vJAVeRzfu5NriXM8y6eeJu0vlKa0C9kq2rq1sOWRClhdLHPocrg==} + '@oxfmt/binding-linux-arm64-gnu@0.60.0': + resolution: {integrity: sha512-LTQdRBf6uzj/h7Xk6lKzbGD2hrF/fK4YI9LIN1c0509tPUn8wRa3mCmrFQpEWJPLYGFrLFFMTYW1Ljj6VqW2Hw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] libc: [glibc] - '@oxfmt/binding-linux-arm64-musl@0.58.0': - resolution: {integrity: sha512-2ZHxemzgHcjtktAuVUwSoyXmGo/t+aF5tS1ciPpPei4rhSyrz3JOqDosXXrmhN/yLUSzJjtuW7ToTWqfQpCj2w==} + '@oxfmt/binding-linux-arm64-musl@0.60.0': + resolution: {integrity: sha512-2JMo3XPxMPx3hiqddSZYyaH+fKJm6cz0u8n1naYjP/CdOQOZW34i8lKBUfmbWiuFvd6KoYXLmhAyBuvojsYS7Q==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] libc: [musl] - '@oxfmt/binding-linux-ppc64-gnu@0.58.0': - resolution: {integrity: sha512-AwKkVwjVmFQ3bcO7j0McGYAqCKH2a326fswfofng/E8VewCT/raeeGQr4huVhY704deK8AWASSTlxzMj0eZc6Q==} + '@oxfmt/binding-linux-ppc64-gnu@0.60.0': + resolution: {integrity: sha512-L3C+nBD13lr306tr/PjM3RMll+BVqgFrIgUyoeHuai5oueJrRLgO3j+GO5/Cbhtkf5PSlHYTI1JY7iqBd1qa6A==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ppc64] os: [linux] libc: [glibc] - '@oxfmt/binding-linux-riscv64-gnu@0.58.0': - resolution: {integrity: sha512-xsRpTxfUnJF8D3AUKko/qyWdjw4GZVHlCVFuGlzSCTeewLmykKINW8em1+wx+axsDVtJJcMtvsiaXggXxrlHgw==} + '@oxfmt/binding-linux-riscv64-gnu@0.60.0': + resolution: {integrity: sha512-M4MsmvqlxFiPtSRGyBYQSZxchEf463AOyd+Dh4/9xDpjWBsRtDUTDMFN5EdHinjVK1/eDJQ8MLpcYjpYayaCnA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [riscv64] os: [linux] libc: [glibc] - '@oxfmt/binding-linux-riscv64-musl@0.58.0': - resolution: {integrity: sha512-Z4AYOTcy7nYEIiXwD62PlerimyYRcfJOgUbQAEBjXz098kxKuERBlRntofGy69HHhe9E0TLVNMl1yspVNu+efw==} + '@oxfmt/binding-linux-riscv64-musl@0.60.0': + resolution: {integrity: sha512-OH+9UskYuxRB+GxqdGkVN8f5UpwhqG8YscNo1wl8+KJ62cd7wZdGga6iGLJIf8kibF1WBwvlfDUx3cez/VXwFg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [riscv64] os: [linux] libc: [musl] - '@oxfmt/binding-linux-s390x-gnu@0.58.0': - resolution: {integrity: sha512-A3nhhtZPC/TKVWOPj9q/H3p2znJDCcHWYlJBhWL8hGq/bFmBaNBHC8Np6E581yVq1w9Mi3rMDNzDalWvtUfJtQ==} + '@oxfmt/binding-linux-s390x-gnu@0.60.0': + resolution: {integrity: sha512-y7AAFutt9wFWBFOAn6+BHaV39usZmcr3YYH2385f+NHgPNpIF9HpqKp0jgUxPaUOCyG3oaX5VhJduL1Nw164rw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [s390x] os: [linux] libc: [glibc] - '@oxfmt/binding-linux-x64-gnu@0.58.0': - resolution: {integrity: sha512-2g+tVkgwqphw8R4hgo+kF4oz8+P5RwVOtr9+irsC7uwEp0e9j7Crw8kDGKL20uYlLPD7g02DqA61mC/UNYx98A==} + '@oxfmt/binding-linux-x64-gnu@0.60.0': + resolution: {integrity: sha512-yKZ9+CXAI+1RO5nH/4Z/9M6DAsfOzd5bw/gtWk81KB4mpalMaRRSXfouc5/tHxazDmBek55HNPepNYBgaCew0Q==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] libc: [glibc] - '@oxfmt/binding-linux-x64-musl@0.58.0': - resolution: {integrity: sha512-rc15P6AbyyB7426aN8AakLd02Trb3a6ML/mmfAQeVHJEfVofWLcWIrBdy6zDEY+DIaL/s8E4GGPboVw+oP3+EA==} + '@oxfmt/binding-linux-x64-musl@0.60.0': + resolution: {integrity: sha512-bCUGaF6hJOYnQzLJdHLZbvGsOd5oSvGAyJhPAKum2uyLYUuXmP8vqg690DWi2hqcnIoYpqSqCrjzE5aiUAgwQg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] libc: [musl] - '@oxfmt/binding-openharmony-arm64@0.58.0': - resolution: {integrity: sha512-ZWoTM27/HYPOh9iq86DAbhPu9nXb8qKvvGU/h8OfliyVUFAMMNTLDkGsWDKKnDqIkqvZ9+dXlgUOsH1LYO3O7g==} + '@oxfmt/binding-openharmony-arm64@0.60.0': + resolution: {integrity: sha512-GrUeZOvzP30ExxfCuQiyofuUGI+OmvAgFwOO5w5p9mGPlxcyuqI+6Sy9fAKFFfLQrqKYWFgc5sYA2Unj/29nPg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [openharmony] - '@oxfmt/binding-win32-arm64-msvc@0.58.0': - resolution: {integrity: sha512-LHZnqFXe2dEfkRI4XdZS/57nEOT/I4UCRX5IyM9v4GYW9XwQCjGe1IUK59SuKw3POwvcgWQ4pme2cYXmNqTNPg==} + '@oxfmt/binding-win32-arm64-msvc@0.60.0': + resolution: {integrity: sha512-WD4Q954kUl2TDJV/6q7UnE2rlKk047kXLJsr4bJ2mXRaAqNXcmV3nwKUsGCc3mz/jYDBnXtJEaBErJEybK8iQQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [win32] - '@oxfmt/binding-win32-ia32-msvc@0.58.0': - resolution: {integrity: sha512-mZKpg20TpheCJym1rarcZCUJeW1sSruw8zAAaCYWvuVfwIUDN1CXdrPU/JgCWReXTCTrEfCB8Wyo3hh9jSZ2EA==} + '@oxfmt/binding-win32-ia32-msvc@0.60.0': + resolution: {integrity: sha512-HqDekjr8JXzVDUP1YthDZ1Y3CBEcuZT4WX3B+1kaxj8CvZA8Y2YhcEsXqoSop3tVsgjACxjnFQFDkBo0r/jq1Q==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ia32] os: [win32] - '@oxfmt/binding-win32-x64-msvc@0.58.0': - resolution: {integrity: sha512-N/wUU4N5PZ2orBtI+Ko7MnMfYLfE7K91UrGMY/c/pYyHR3lA9kwst1XugkZx+92YcRh/Eo+iv2eTESSWXfiZPA==} + '@oxfmt/binding-win32-x64-msvc@0.60.0': + resolution: {integrity: sha512-tz78yhmGPKboTMHCHSaUqXK8JrmoSejgDcWeqAtg2s07ZGKQ3rH5Jn8NuXPGNG33CDbY2e9NoQWXIVEmKO21Rw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [win32] - '@oxlint-tsgolint/darwin-arm64@0.24.0': - resolution: {integrity: sha512-C2uMmwK5Bc4ri4ysZ6sA8Rcu+A5zBQTp6ml2u0CLLbRZp4kMFPV3yWk8B5DK9Aw7y9bbjogIm75tUwGLFzlsYQ==} + '@oxlint-tsgolint/darwin-arm64@7.0.2001': + resolution: {integrity: sha512-CUJEdbSZ54+Xy9OXqOhWLTKZKV0BBiV7C2i/ygyVmXtkUNXx5YCzN8DpSSshTAKktoL7S+tnQ/ftFG/i7X896w==} cpu: [arm64] os: [darwin] - '@oxlint-tsgolint/darwin-x64@0.24.0': - resolution: {integrity: sha512-Wgvt/1lRbDxmoNqWQKKcL+UIiqLmdJ+EWLpQa1qzoNVAfNB0PJpa82/8dH1twT/3rSs4zrP5TXPWl4juB71WuQ==} + '@oxlint-tsgolint/darwin-x64@7.0.2001': + resolution: {integrity: sha512-pXfBb5BqONCcgrXQNUZWXgiYmRSWJzd97S8i41VVOh6ut0tyo+cJ5FKFpczDHxiVNfj/3e7c9B4MtztNdpIVCw==} cpu: [x64] os: [darwin] - '@oxlint-tsgolint/linux-arm64@0.24.0': - resolution: {integrity: sha512-PB1rxII7KV83+ASY4sSkXtqvpij6ME66+QCRL49uksi/ofs2Rf/UVboYr095n0Rkbl2wgvlsHGl6DHC361jQUQ==} + '@oxlint-tsgolint/linux-arm64@7.0.2001': + resolution: {integrity: sha512-roP7zujb/QDPzDwEKsFFpzNHHy91/Y7oX9vQXk78ekyZtcQj1QXDIMH33gjDdHBfRl4K9pZ36xhRgrP4Zr+R8A==} cpu: [arm64] os: [linux] - '@oxlint-tsgolint/linux-x64@0.24.0': - resolution: {integrity: sha512-xcz3CxKmjTQLREtE/UShh+ruWmm9nAb7UM9zKcD65BStiuYgOakAKkPHl4YS5DztpVcDrE0+HqbOolTlRKYWmw==} + '@oxlint-tsgolint/linux-x64@7.0.2001': + resolution: {integrity: sha512-UDezNqdECVmngu2TPnjaS1YoAmcTaBoI5lV9vk3VahBxoi+I5r9k3iJTT7qZoYWOXTD/7T7bNcwRgrocR6BscQ==} cpu: [x64] os: [linux] - '@oxlint-tsgolint/win32-arm64@0.24.0': - resolution: {integrity: sha512-A2i6ZGBec3i20S7RaxkgHc6r3HYtD5Mn7j/mb22NkTz14u0JuudvTu6JggAnbGMcv8+dBKQI//EasxSPJLD8pw==} + '@oxlint-tsgolint/win32-arm64@7.0.2001': + resolution: {integrity: sha512-uJZhqB6pdXLuN+AD1F5082byyQti/NPmJA77GtcFlmT2HzRelqbNls3SaIqxpjdFgvSBF9g0yOKGBkGFg7kX8Q==} cpu: [arm64] os: [win32] - '@oxlint-tsgolint/win32-x64@0.24.0': - resolution: {integrity: sha512-0ZbGd9qRB6zs82moekaKdEvncRANq49EAwfNX62JpTS46feXUhKAuoyVDvZMj6Rywejylrmmu79Wo6faYCo4Ew==} + '@oxlint-tsgolint/win32-x64@7.0.2001': + resolution: {integrity: sha512-FkDRm8hx9OwzGQqyWG1tO5QrTLRApff9DzSgpz9QZau37BR8d1VYKOxMLGf6shPZntJFoTwIIJYT68VndYDCog==} cpu: [x64] os: [win32] - '@oxlint/binding-android-arm-eabi@1.73.0': - resolution: {integrity: sha512-HZQRN/UMBu+Ut+/9MiAChkbP4qZqrNOWBcNI45vOT40GVhbGR0JgHB87L48D4iAqFQIdVmeQYtV9RF89AjTKkg==} + '@oxlint/binding-android-arm-eabi@1.75.0': + resolution: {integrity: sha512-lutovtFzJqlRaqpZrCqSSGaHZzl9nIxxpjLzhSRLunN6dCLylj0uzlCyQGaQDIys7rrv8kVXiFO+R4Zpn0bX7g==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [android] - '@oxlint/binding-android-arm64@1.73.0': - resolution: {integrity: sha512-Gp+KJRylv2aW7thRpG5p1KTxZq4ZJFbWowrKzufNq9d3ssl3r3JviYV45/+p+7CN1Nv0zDd1e8Ex0b/HUDq4TQ==} + '@oxlint/binding-android-arm64@1.75.0': + resolution: {integrity: sha512-hXI0hDgHkw4w5nfru72aG7y+2iQJmC4waH/KV6H/hbgA6yAP5jYNx0P9yug15Hs0tWl/+mda3Jjn/2gmDT48tw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [android] - '@oxlint/binding-darwin-arm64@1.73.0': - resolution: {integrity: sha512-3de96NdtXhxERMjIz7wsp2HYMY6pMQycGxFWac2mFecAx6VeARF/IqFb1QIaqiCRIdfzBwzTed+pCTCoiS+CYA==} + '@oxlint/binding-darwin-arm64@1.75.0': + resolution: {integrity: sha512-D91BWbK/dMYfCcrghspPIuKs2D9LF4Z/OabVSQjw1AO6PWxArD7teDA48bm0ySFqWDaPVqmQRl5GMWNglTXyrQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [darwin] - '@oxlint/binding-darwin-x64@1.73.0': - resolution: {integrity: sha512-5zx/uPW32TiaOeVY1dQ/H5iOf0K1HOdFKOJhLqGl4o63+i1fpzoqqu/mKtd7OFgFjNCdhlyTGgjVkQTZm1ELcg==} + '@oxlint/binding-darwin-x64@1.75.0': + resolution: {integrity: sha512-02mpwzf12BonZ6PT0TuQoomvEh2kVl2WGBIKWezCyToIS+rYkQZ6GXnARBAl9A4Ovm2V+Xe7M4KretyqmmcnJQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [darwin] - '@oxlint/binding-freebsd-x64@1.73.0': - resolution: {integrity: sha512-qNe4gKHaGnLuZJ8toUg90JAa0S2vTVvDw+0bRi3q1avXZXDT4u5mMeECf3nD4HYrbdn1O7dXqWut4onY/yx/Xg==} + '@oxlint/binding-freebsd-x64@1.75.0': + resolution: {integrity: sha512-qZJgLnDaBsiL5YESx2t/TZ8eXkL9fEkKoXEdzegROhlz9A0lgyGnZ0dAzJrh7LJAHQl2K9RdRueN2s/9N7+odg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [freebsd] - '@oxlint/binding-linux-arm-gnueabihf@1.73.0': - resolution: {integrity: sha512-cCehYh5hTbfShm/fxTD6wwrGUWIpvX+N5OxmAMhFhDeTGXvw+BeNj889tpxsFQ9ZLatQ6wImuY8tsKLZ+FMz7w==} + '@oxlint/binding-linux-arm-gnueabihf@1.75.0': + resolution: {integrity: sha512-7XlaWA5BJD3XpCfrEqjEe6Zseeb14S7QGa304XfwKignRaKQ+eIj775BQ7nIslggWickl4IsPUFqJ+/gAyNHVg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [linux] - '@oxlint/binding-linux-arm-musleabihf@1.73.0': - resolution: {integrity: sha512-d5j5GDU/2dMgjVhw7TQT9ITrsIr1Y02KEXKyVGIXUkD+KiaxE9TP65FS2ZdgTBemQvoRL+gSBdbrIm3cQIeacg==} + '@oxlint/binding-linux-arm-musleabihf@1.75.0': + resolution: {integrity: sha512-av6Tpv8yrcMMMOadOqENBhlsLRcGFXXwoQ0hzHhsmS9FJ4Wioy8we427GbcMe2XTxmL2e60T67H1Dyr3up+tAA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [linux] - '@oxlint/binding-linux-arm64-gnu@1.73.0': - resolution: {integrity: sha512-Eyf1SrP3+yR1DI3OJgOY2Pvrr9dWP9TK37xPaDYycwTtlGlI45erJAVIfH5/m/xosDt6BupJYEFi47bvbTuuyw==} + '@oxlint/binding-linux-arm64-gnu@1.75.0': + resolution: {integrity: sha512-WcUhd8fHT5plrA14lANevl+hOl815mVI5t2hU21oFWrZKFXIVV/Sr4rWQV0NzSvzBupbMLNc5ErEA6Ehxh5jMg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] libc: [glibc] - '@oxlint/binding-linux-arm64-musl@1.73.0': - resolution: {integrity: sha512-IlT/OJApEDKaMmCooHuncgJZbbCe7T5QIWmTZBEtYscWvzPQuuEinVcid6kwQRVQOUdb7PUCz4jQHnaYXdfJXw==} + '@oxlint/binding-linux-arm64-musl@1.75.0': + resolution: {integrity: sha512-UWzp5wRHFe/ESO3+eEaxXsTkYTGLYjnTsi/I5neEacXSItQ6WNleapfOAeA4x2b8nyhJ4uQxqvtv9pHv8kWJtQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] libc: [musl] - '@oxlint/binding-linux-ppc64-gnu@1.73.0': - resolution: {integrity: sha512-L+JYcb/vdg5fmcH08V6o0YYLU28cTH1SPNulwJdvK9NK49aXSkYy6oNpKBmddArVOXYqNepriDGiZ04G54kh1Q==} + '@oxlint/binding-linux-ppc64-gnu@1.75.0': + resolution: {integrity: sha512-XEVRwGMLKCUKrvhLAz4F6AIh8MJrQVdSZtAmPpRZt9tGPsUnamPOcl3dS/ZQzJnar/Ymgc//+xho0L60Emzuxg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ppc64] os: [linux] libc: [glibc] - '@oxlint/binding-linux-riscv64-gnu@1.73.0': - resolution: {integrity: sha512-Qtk0g3bKV6OwWjIm7R8kQN1uOZRKQt/MODK2a8QfkwhTpXBD53ozx5XLVWLGDQAVyp2otLW4D2wB98XfAfMPGA==} + '@oxlint/binding-linux-riscv64-gnu@1.75.0': + resolution: {integrity: sha512-mAG4DUXqfLC8cTjMD2kt3jDmVzFREYtDyeLNdLdsCcBc4Zbl2EMuiFektGBilQwkNjYnMvCqJs55U+Hyb+b+jw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [riscv64] os: [linux] libc: [glibc] - '@oxlint/binding-linux-riscv64-musl@1.73.0': - resolution: {integrity: sha512-wX0NQKZVxltkAOVmzFcpOaMpdaUvsq1Eqpx9tkAfl71UdkTlSo1R4AdAnGccR1Fm2+TzFgZ22CyyGuZ41RDr/A==} + '@oxlint/binding-linux-riscv64-musl@1.75.0': + resolution: {integrity: sha512-95hrAvriAlI+pekSomTFIn0+bawMDlDwTNVmdjsFusTHyL2JWh7TWvRNG/Lkim72uN8OiCcO9wcaC6omLP5E3w==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [riscv64] os: [linux] libc: [musl] - '@oxlint/binding-linux-s390x-gnu@1.73.0': - resolution: {integrity: sha512-vPe7UGBMWyiLTtnqS4xxgMQFSFGmtQwhwCxuiw6lXygaO6bVt0D8dFVg8Xv05eaiN3ybC0HXXHUAohFMFvqoCQ==} + '@oxlint/binding-linux-s390x-gnu@1.75.0': + resolution: {integrity: sha512-4b6f2+FrtruAESrCqIKcrarzfrSx+wk2QNcp+RT91/Prc+pMQMAfyZ1rG1c3tFQNl8Bc616tx40uNXyxNBRPbQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [s390x] os: [linux] libc: [glibc] - '@oxlint/binding-linux-x64-gnu@1.73.0': - resolution: {integrity: sha512-2CwIWr9cemFC/CbRBWZvuk5mffz6ObmfFkfcC/9rTQ7f+icNhYr2kOjf9Rt8lLvugvkdGDOmkoVoFFHh6ClCTw==} + '@oxlint/binding-linux-x64-gnu@1.75.0': + resolution: {integrity: sha512-nshAhrUvXFUWOvqQ2soIw7HFNWvpvEV4o0cYSqPtzLiPF5gKyYTDOOTJ6Rn8g8K/iGvPIrbDA4v8+5MvnjJrrg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] libc: [glibc] - '@oxlint/binding-linux-x64-musl@1.73.0': - resolution: {integrity: sha512-nDadfJgg7NBBxG0N560wOe7LLX5QiYp6qBaI7viuk5EUORFBktU/NfV0MbTqU3gTqQDCh4VyxKdo5VADxk9w8Q==} + '@oxlint/binding-linux-x64-musl@1.75.0': + resolution: {integrity: sha512-e4jNxLKnxLC6sYBQRxrI2pgIIxnmMtF8U/VwNYcjTT/CLS+spH624cYVnj07bTKwaEWT37/e025isOs6j/0xqA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] libc: [musl] - '@oxlint/binding-openharmony-arm64@1.73.0': - resolution: {integrity: sha512-wGjJC+NLH9xP+IKGn9RDW94ojJR/wPbg5WCnQjj/oReaOtCQthr8ws1zICe77JFmo4ouUdeTHHZL/ESGiF6Pmw==} + '@oxlint/binding-openharmony-arm64@1.75.0': + resolution: {integrity: sha512-hZ2lH+1qLf/DiEP9UWuQTK2JWj/BgvMB4jhIV4SmNU1wfEiYYX4TynQyAZXx0j9X4qRYizAL042SKaV+8ynh4w==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [openharmony] - '@oxlint/binding-win32-arm64-msvc@1.73.0': - resolution: {integrity: sha512-I7X47GPGljw225YUQ5SbC/rb1Kkdrd0yQf0x+hYxeKS6DpfjMbo9ccQPQ6LNY6BoJQ1sHhgDUGuMn5Vg5gHT6w==} + '@oxlint/binding-win32-arm64-msvc@1.75.0': + resolution: {integrity: sha512-Ilj6PNzGDS3bCU0MSJH7Msh0NhH+T/mRp2shwg+q+GHeVlPwP5LEboW96aW+3kVKFk6zYZy1Xi5pZkqZh6X8KQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [win32] - '@oxlint/binding-win32-ia32-msvc@1.73.0': - resolution: {integrity: sha512-5lWj+3h+74Fm1jYOO9qkJA4xkAlZA099DkXppuXsk7UpnpZLttsefrZU469vChGaG6hcSqrkKXQOvMTZtbjeNg==} + '@oxlint/binding-win32-ia32-msvc@1.75.0': + resolution: {integrity: sha512-QVit2nOEOiPhkmsrksPSkoGCdnZRNkspt8fwoYyP09te1VEbnSj4LAxua4rc8FKTmWkySVe05j8iz9GXYfF1AQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ia32] os: [win32] - '@oxlint/binding-win32-x64-msvc@1.73.0': - resolution: {integrity: sha512-WaNRvh4f6zY9CvUQk2YoA1O90ieWrIklI84+HXFr9Isjz9CSESrdqo/RtIYt4Dll/cAchqGDMehfaZd0vqEFZw==} + '@oxlint/binding-win32-x64-msvc@1.75.0': + resolution: {integrity: sha512-DSxnNkBUAYARPwJtR12Ig3deWr8w0H997xP6jy33i+e0SyYJw8FKuz4+cZtpmPEhQmvlPJE3X/2vNxDmLkd/rA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [win32] @@ -919,6 +929,12 @@ packages: cpu: [arm64] os: [android] + '@rolldown/binding-android-arm64@1.2.0': + resolution: {integrity: sha512-9yB1l95IrJuNGDFdOYe79vdApdz6WWBCObE+rQ2LUliYUlcyFwSYIb2xb5/Ifw7dAtMy2ZqNyd8QTSOc7duAKw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + '@rolldown/binding-darwin-arm64@1.0.0-rc.17': resolution: {integrity: sha512-4ksWc9n0mhlZpZ9PMZgTGjeOPRu8MB1Z3Tz0Mo02eWfWCHMW1zN82Qz/pL/rC+yQa+8ZnutMF0JjJe7PjwasYw==} engines: {node: ^20.19.0 || >=22.12.0} @@ -931,6 +947,12 @@ packages: cpu: [arm64] os: [darwin] + '@rolldown/binding-darwin-arm64@1.2.0': + resolution: {integrity: sha512-pexNaW9ACLUOaBITOpU6qVu4VrsOFIjTv6bzgu0YUATo4eUJx0V605PxwZfndpPOn0ilqGqvGQ0M8UW0IE24jg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + '@rolldown/binding-darwin-x64@1.0.0-rc.17': resolution: {integrity: sha512-SUSDOI6WwUVNcWxd02QEBjLdY1VPHvlEkw6T/8nYG322iYWCTxRb1vzk4E+mWWYehTp7ERibq54LSJGjmouOsw==} engines: {node: ^20.19.0 || >=22.12.0} @@ -943,6 +965,12 @@ packages: cpu: [x64] os: [darwin] + '@rolldown/binding-darwin-x64@1.2.0': + resolution: {integrity: sha512-NqKYaq0355ZmNMG4QGpxtEDxsc7tGDhjhCm4PpE0cwnBW+5Il95LJyq414niEiaKLVjnVHBEjSo1wngKxJNiFw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + '@rolldown/binding-freebsd-x64@1.0.0-rc.17': resolution: {integrity: sha512-hwnz3nw9dbJ05EDO/PvcjaaewqqDy7Y1rn1UO81l8iIK1GjenME75dl16ajbvSSMfv66WXSRCYKIqfgq2KCfxw==} engines: {node: ^20.19.0 || >=22.12.0} @@ -955,6 +983,12 @@ packages: cpu: [x64] os: [freebsd] + '@rolldown/binding-freebsd-x64@1.2.0': + resolution: {integrity: sha512-3vPoHzh6eBTz9IbB0/qZdSr0Qeks2echn+I4cHu2joV74VriPDdldswksEDzrl1mBB+oPRi+67+3Ib59paxIPQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + '@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.17': resolution: {integrity: sha512-IS+W7epTcwANmFSQFrS1SivEXHtl1JtuQA9wlxrZTcNi6mx+FDOYrakGevvvTwgj2JvWiK8B29/qD9BELZPyXQ==} engines: {node: ^20.19.0 || >=22.12.0} @@ -967,6 +1001,12 @@ packages: cpu: [arm] os: [linux] + '@rolldown/binding-linux-arm-gnueabihf@1.2.0': + resolution: {integrity: sha512-E6NNefZ1bUVmKJq2tJkf45J4Zyczj7qm9rUT7NY+Xo2474Y13qWAwc2tvBt0BAVbmtXR1llkxXg0Ou1jbDf2SQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + '@rolldown/binding-linux-arm64-gnu@1.0.0-rc.17': resolution: {integrity: sha512-e6usGaHKW5BMNZOymS1UcEYGowQMWcgZ71Z17Sl/h2+ZziNJ1a9n3Zvcz6LdRyIW5572wBCTH/Z+bKuZouGk9Q==} engines: {node: ^20.19.0 || >=22.12.0} @@ -981,6 +1021,13 @@ packages: os: [linux] libc: [glibc] + '@rolldown/binding-linux-arm64-gnu@1.2.0': + resolution: {integrity: sha512-D+TgkdgM1vu+7/Fpf8+v0ARW+RXEP9Ccazgm8zQ4JFFd9Q7SrYQ2TakU5S5ihazQDgpKyAgZDOcIFsvoHmTZ8w==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [glibc] + '@rolldown/binding-linux-arm64-musl@1.0.0-rc.17': resolution: {integrity: sha512-b/CgbwAJpmrRLp02RPfhbudf5tZnN9nsPWK82znefso832etkem8H7FSZwxrOI9djcdTP7U6YfNhbRnh7djErg==} engines: {node: ^20.19.0 || >=22.12.0} @@ -995,6 +1042,13 @@ packages: os: [linux] libc: [musl] + '@rolldown/binding-linux-arm64-musl@1.2.0': + resolution: {integrity: sha512-wUqdwJBbAv0APN87GecstdMUtLjjNTs0hBALpxETD73mccFxdmt/XeizXDtN5RAlBwNKmI+Tg+blect2G+8IeQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [musl] + '@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.17': resolution: {integrity: sha512-4EII1iNGRUN5WwGbF/kOh/EIkoDN9HsupgLQoXfY+D1oyJm7/F4t5PYU5n8SWZgG0FEwakyM8pGgwcBYruGTlA==} engines: {node: ^20.19.0 || >=22.12.0} @@ -1009,6 +1063,13 @@ packages: os: [linux] libc: [glibc] + '@rolldown/binding-linux-ppc64-gnu@1.2.0': + resolution: {integrity: sha512-9DtF35qR9/NrfhM4oxLplCzVVjE+KKm8Pjemi0i/sdhAWkUasjmSo8WTTubNJClhSHCfyk2yeyoXDQEDPtDAAw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + libc: [glibc] + '@rolldown/binding-linux-s390x-gnu@1.0.0-rc.17': resolution: {integrity: sha512-AH8oq3XqQo4IibpVXvPeLDI5pzkpYn0WiZAfT05kFzoJ6tQNzwRdDYQ45M8I/gslbodRZwW8uxLhbSBbkv96rA==} engines: {node: ^20.19.0 || >=22.12.0} @@ -1023,6 +1084,13 @@ packages: os: [linux] libc: [glibc] + '@rolldown/binding-linux-s390x-gnu@1.2.0': + resolution: {integrity: sha512-RzuHrBh8X8Hntd2N4VR02QGEciq/9JhcZoTpR/Cee6otRrlILGCf3cg2ygHuih+ZebUnWmMrDX6ITI85btO6rQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + libc: [glibc] + '@rolldown/binding-linux-x64-gnu@1.0.0-rc.17': resolution: {integrity: sha512-cLnjV3xfo7KslbU41Z7z8BH/E1y5mzUYzAqih1d1MDaIGZRCMqTijqLv76/P7fyHuvUcfGsIpqCdddbxLLK9rA==} engines: {node: ^20.19.0 || >=22.12.0} @@ -1037,6 +1105,13 @@ packages: os: [linux] libc: [glibc] + '@rolldown/binding-linux-x64-gnu@1.2.0': + resolution: {integrity: sha512-MK7L0018jjh1jR3mh21G2j1zAVcpscJBlPo2z19pRjv2XOYGRhaV4LyiD8HO6nCDdZln9IFgCMIV5yt4E3klGQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [glibc] + '@rolldown/binding-linux-x64-musl@1.0.0-rc.17': resolution: {integrity: sha512-0phclDw1spsL7dUB37sIARuis2tAgomCJXAHZlpt8PXZ4Ba0dRP1e+66lsRqrfhISeN9bEGNjQs+T/Fbd7oYGw==} engines: {node: ^20.19.0 || >=22.12.0} @@ -1051,6 +1126,13 @@ packages: os: [linux] libc: [musl] + '@rolldown/binding-linux-x64-musl@1.2.0': + resolution: {integrity: sha512-gyrxLQ9NfGb/9LoVnC4kb9miUghw1mghnkfYvNHSnVIXriabnfgGPUP4RLcJm87q3KgYz4FYUG8IDiWUT+CpSw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [musl] + '@rolldown/binding-openharmony-arm64@1.0.0-rc.17': resolution: {integrity: sha512-0ag/hEgXOwgw4t8QyQvUCxvEg+V0KBcA6YuOx9g0r02MprutRF5dyljgm3EmR02O292UX7UeS6HzWHAl6KgyhA==} engines: {node: ^20.19.0 || >=22.12.0} @@ -1063,6 +1145,12 @@ packages: cpu: [arm64] os: [openharmony] + '@rolldown/binding-openharmony-arm64@1.2.0': + resolution: {integrity: sha512-/6VFMQGRmrhP77KXDC+StIxGzcNp5JOIyYtw0CQ8gPlzhpiIRucYfoM5FaFamHd5BJYIdH86yfP46l1p3WdrFA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + '@rolldown/binding-wasm32-wasi@1.0.0-rc.17': resolution: {integrity: sha512-LEXei6vo0E5wTGwpkJ4KoT3OZJRnglwldt5ziLzOlc6qqb55z4tWNq2A+PFqCJuvWWdP53CVhG1Z9NtToDPJrA==} engines: {node: ^20.19.0 || >=22.12.0} @@ -1073,6 +1161,11 @@ packages: engines: {node: ^20.19.0 || >=22.12.0} cpu: [wasm32] + '@rolldown/binding-wasm32-wasi@1.2.0': + resolution: {integrity: sha512-rwdbUL465kisF24WEJLvP3JrEG6E5GRuIHt5wpMwHGERtHe4Wm2CIvtf5gTBgr2tGOHKh5NdKEAFS2VkOPE91g==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [wasm32] + '@rolldown/binding-win32-arm64-msvc@1.0.0-rc.17': resolution: {integrity: sha512-gUmyzBl3SPMa6hrqFUth9sVfcLBlYsbMzBx5PlexMroZStgzGqlZ26pYG89rBb45Mnia+oil6YAIFeEWGWhoZA==} engines: {node: ^20.19.0 || >=22.12.0} @@ -1085,6 +1178,12 @@ packages: cpu: [arm64] os: [win32] + '@rolldown/binding-win32-arm64-msvc@1.2.0': + resolution: {integrity: sha512-+5suHwRiKGmhwyUaNT8a5QbrBvLFh2DbO910TEmGRH1aSxwrCezodvGQnulv4uiWEIv1Kq4ypRsJ5+O+ry1DiA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + '@rolldown/binding-win32-x64-msvc@1.0.0-rc.17': resolution: {integrity: sha512-3hkiolcUAvPB9FLb3UZdfjVVNWherN1f/skkGWJP/fgSQhYUZpSIRr0/I8ZK9TkF3F7kxvJAk0+IcKvPHk9qQg==} engines: {node: ^20.19.0 || >=22.12.0} @@ -1097,6 +1196,12 @@ packages: cpu: [x64] os: [win32] + '@rolldown/binding-win32-x64-msvc@1.2.0': + resolution: {integrity: sha512-WfFv6/qGufotqBSBzBYwgpCkJBk8Nj7697LL9vTz/XWc67e0r3oewu8iMRwQj3AUL45GVD7wVsPjCsAAtW66Wg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + '@rolldown/pluginutils@1.0.0-rc.17': resolution: {integrity: sha512-n8iosDOt6Ig1UhJ2AYqoIhHWh/isz0xpicHTzpKBeotdVsTEcxsSA/i3EVM7gQAj0rU27OLAxCjzlj15IWY7bg==} @@ -1179,53 +1284,6 @@ packages: '@types/ws@8.18.1': resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==} - '@typescript/native-preview-darwin-arm64@7.0.0-dev.20260707.2': - resolution: {integrity: sha512-wny2pgKjGbiZtnOIHVa3tXC1UfDqxNEFzyPGmiqybedG8hipG2Nfp0l5UxbaKCjkLacUpH/W5bP2hBOMVhCOzg==} - engines: {node: '>=16.20.0'} - cpu: [arm64] - os: [darwin] - - '@typescript/native-preview-darwin-x64@7.0.0-dev.20260707.2': - resolution: {integrity: sha512-Afc7M5zOwo+GpfcYwz5Z8HMB2tPVsui7nNIqEuuFB73MPdVqNn/Wmpe4tP4MRri0AtJnJknoHBaTJ/VDAp/Jhw==} - engines: {node: '>=16.20.0'} - cpu: [x64] - os: [darwin] - - '@typescript/native-preview-linux-arm64@7.0.0-dev.20260707.2': - resolution: {integrity: sha512-iITBa2WjjTI5N9t5l7Z4KoOSI+2zBlhbvFzsD/f8qX8QoKjz/Y4DPyBDgezYi8nkqjjksbgSOJ3/ykzhwrB9cg==} - engines: {node: '>=16.20.0'} - cpu: [arm64] - os: [linux] - - '@typescript/native-preview-linux-arm@7.0.0-dev.20260707.2': - resolution: {integrity: sha512-hJm/UOqZTr9FHmR7uNm8VGX4oKtfWk0Jem0zPeJFNC8ckGUfSBueyiEYMZB+XmRc1aG4x1E46y3CplP4CLHvGQ==} - engines: {node: '>=16.20.0'} - cpu: [arm] - os: [linux] - - '@typescript/native-preview-linux-x64@7.0.0-dev.20260707.2': - resolution: {integrity: sha512-du0dzi6y97Po5vDNdPJTyyijHCpaS22JLRnKZEJXBDaO9gCIymOv/5QQokFRuOlQm0bWl3i9PF4OVdGP6uAOQA==} - engines: {node: '>=16.20.0'} - cpu: [x64] - os: [linux] - - '@typescript/native-preview-win32-arm64@7.0.0-dev.20260707.2': - resolution: {integrity: sha512-SsAwfhyHJ1akgBc+99z4+hwdbHsdWaKB8EwCNIMA6JfSLMeUjffrYvxu+vfMyxVtOVOz7RrRXRoiDiu4a2sCtg==} - engines: {node: '>=16.20.0'} - cpu: [arm64] - os: [win32] - - '@typescript/native-preview-win32-x64@7.0.0-dev.20260707.2': - resolution: {integrity: sha512-DL4u27stv0fo71sVhOzHSwE+YMZsbBijVI+kg5dLDLilSH79WFTJ8RSQ46vJrCMt+Gjlv/JOZP1PuLJDfioYeQ==} - engines: {node: '>=16.20.0'} - cpu: [x64] - os: [win32] - - '@typescript/native-preview@7.0.0-dev.20260707.2': - resolution: {integrity: sha512-oUGp+Rep/hqMhPunyinsALUwSlzHINSxitifPiSaeqoKOKD2OlR9NE3TaPqwsl4NlGslsOSUXI1JotWQzpYCPg==} - engines: {node: '>=16.20.0'} - hasBin: true - '@typescript/typescript-aix-ppc64@7.0.2': resolution: {integrity: sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==} engines: {node: '>=16.20.0'} @@ -1350,8 +1408,8 @@ packages: resolution: {integrity: sha512-mbCddXd+jm7hfx7w2YU64/Av4/NqqeG3GoRZgxPcgoTxYjhrcfJRw9ULch71SS4G+Q3bOXFhRvPqjguN0Hyp5w==} hasBin: true - '@vitejs/plugin-react@6.0.3': - resolution: {integrity: sha512-vmFvco5/QuC2f9Oj+wTk0+9XeDFkHxSamwZKYc7MxYwKICfvUvlMhqKI0VuICPltGqh1neqBKDvO4kes1ya8vg==} + '@vitejs/plugin-react@6.0.4': + resolution: {integrity: sha512-XcCQz0TBpBgljhj0gMuuDj49i6Ytqh5q1osT/Gp5uAVJUCTWxyskk/l1jwYYiu2xcNHHipdMz40EGfM1VdamVg==} engines: {node: ^20.19.0 || >=22.12.0} peerDependencies: '@rolldown/plugin-babel': ^0.1.7 || ^0.2.0 @@ -1379,130 +1437,130 @@ packages: '@xyflow/system@0.0.79': resolution: {integrity: sha512-czLyOh91NF0hIzbNzwi8I6GlqG23BHh2435OddfI6uiaLH3xdrdygO93gqgH1Bv9mhy8XPFQJOBn1FTq4LvEWA==} - '@yuku-codegen/binding-darwin-arm64@0.5.44': - resolution: {integrity: sha512-mpZc7hrjl/qxcbEMS6vVLE0lO4DjiXCvrp3gYbZ58bbmsSxSGCTlOCA0lpeLXAKOZxe0ZrVoqKteaewFF2Vbuw==} + '@yuku-codegen/binding-darwin-arm64@0.8.0': + resolution: {integrity: sha512-7cSJH6PaKLRBdCfiB4pM6EukvgOk5xV4tyuLOIOEqrHsbnV7brtyff7CjhZbeGozdIHoOnKOi5R7rrmCWN3QSw==} cpu: [arm64] os: [darwin] - '@yuku-codegen/binding-darwin-x64@0.5.44': - resolution: {integrity: sha512-PCt776PnGtnQYimxk18IyvPf/BQ6CNU95W+6eaoQfeME8ra+7v3pNNoTAmLfSveUC9KZPaoajR9NqkKfEVjA2Q==} + '@yuku-codegen/binding-darwin-x64@0.8.0': + resolution: {integrity: sha512-mhooLL+L5ytMxgz4ueXCIirU796X2xj97d4KSQW1HxZGzX6h8wOk5bIAhGqcmOL2bqAmMZ+UkBTbPC9VpzKb/g==} cpu: [x64] os: [darwin] - '@yuku-codegen/binding-freebsd-x64@0.5.44': - resolution: {integrity: sha512-5MNu1R4ysytPLMdl1UlGyjgAbUdT8fguW/QRo+pyEymbuWRN5n8JEHCFpm4AsWdP61fStagSpPDJcwN+mwC9Lg==} + '@yuku-codegen/binding-freebsd-x64@0.8.0': + resolution: {integrity: sha512-MHLOAlgGhdOh0ZfnWWnno4ljlFB04/Lox/7MIGYIvISMKFvejfC9Atb1t9G7pTVBvy+l5uqxzHGBSJUsDOOkTA==} cpu: [x64] os: [freebsd] - '@yuku-codegen/binding-linux-arm-gnu@0.5.44': - resolution: {integrity: sha512-xbP2qQ7/h6ZZKeckCiI2osB5SE5PBfLb4uzIfO1BSTX+9rlBKTqomxDaCib7aPf2X1CXMiIq+i7AK1EhBa5a7A==} + '@yuku-codegen/binding-linux-arm-gnu@0.8.0': + resolution: {integrity: sha512-Ur3Awo45Sc5/Fglr8WN5XIf4IwAsq8wLd917Du8ow6mStxsBTHqFiH+tT7d5jV0FqcJnwy8EHVczmgde0zTo1A==} cpu: [arm] os: [linux] libc: [glibc] - '@yuku-codegen/binding-linux-arm-musl@0.5.44': - resolution: {integrity: sha512-gmXKMCpgkUm5PllGMKBMoBmqgbTQkx+S85eE46LctuwTSKS/K7u65NE6t4zk6cLDvZpV67enycKXBORWn6q7xA==} + '@yuku-codegen/binding-linux-arm-musl@0.8.0': + resolution: {integrity: sha512-FIy7Ttx8oeUCd/8Y6IjnOsu+lRc6En+V/H67BlVphOeCySZAo5LU8VWrb4tv0DvjaSOzdm3DmdmRzmzN7NCqWQ==} cpu: [arm] os: [linux] libc: [musl] - '@yuku-codegen/binding-linux-arm64-gnu@0.5.44': - resolution: {integrity: sha512-eOoejNUtnHs1/TMn4wryOAG/TarvlOqSZtRPeS56liBKp/GVQSirqTM9AITSGPLSdK1u7fZWMdj5IOEoJp3ruw==} + '@yuku-codegen/binding-linux-arm64-gnu@0.8.0': + resolution: {integrity: sha512-s+25wl1TLvf+7LzasPEi1RR1sDfVAU0i1QH601mn3vj+HudFYBYNZtUBKFaZvl645QR6vcaDaGHnOaMZhBR3ig==} cpu: [arm64] os: [linux] libc: [glibc] - '@yuku-codegen/binding-linux-arm64-musl@0.5.44': - resolution: {integrity: sha512-xTzrhEMy1mdv5+SbJPPlwqlMrhXGPntE2f12TLi+dNPXhif4iXCGJpoh8pV9nwZFE/cq1ITuTnjIfTFj/PifzA==} + '@yuku-codegen/binding-linux-arm64-musl@0.8.0': + resolution: {integrity: sha512-pRha3Cjm4AnA5wEuhpg+8XXoGfwz5X61/9a/VNxeau47+kH6xjJspkEloIy/HbHgUnvVRqVHoEHczdGZT2J9NA==} cpu: [arm64] os: [linux] libc: [musl] - '@yuku-codegen/binding-linux-x64-gnu@0.5.44': - resolution: {integrity: sha512-2bvgIU4P+f4q18grdHoGnt9L6XlAciq/8GWpM06fmwj8qqruS8qwwwZXBk3/0U2+IHGv9pXRR8GtSXzl5n/blg==} + '@yuku-codegen/binding-linux-x64-gnu@0.8.0': + resolution: {integrity: sha512-GySXmiw5Dw99Ba3GMV2ExQVckihuangnrIpSJagPx8RFUNtwfmzpr2ibf8k31eEAE4YUofV7mEfJN5tN6+POnQ==} cpu: [x64] os: [linux] libc: [glibc] - '@yuku-codegen/binding-linux-x64-musl@0.5.44': - resolution: {integrity: sha512-EzX5hWK0MmU4fbqY9coc5PJ0y0LYjrBdAF7mjSyetdK/yWRGBfHi9nh0dUJ0lqb47653hxB8/r9byfYgg6ecbA==} + '@yuku-codegen/binding-linux-x64-musl@0.8.0': + resolution: {integrity: sha512-8YFfcPZz44v8YyekWVKkNz/cD4t6DW62/dr03OVtMfwUtHfmo/8wpby0JKMleAOXbgWItSGw81LtwqM4I9oS0A==} cpu: [x64] os: [linux] libc: [musl] - '@yuku-codegen/binding-win32-arm64@0.5.44': - resolution: {integrity: sha512-/ajGFWcOLGtmVUdxWKXDW6Ixtez68xxtmd2l95xNg8Lzs4aqbV2cy0Ns2Bzg9vjHsgDIooQlLXHpWh6HsHcy6w==} + '@yuku-codegen/binding-win32-arm64@0.8.0': + resolution: {integrity: sha512-YhENbgkuzjsil+zDNV35oU3PQMDg2RXh8BPt915WCNAbIIHcqgYupHJF3564206+DbPkZtcDvcoa34Wb5B8tbQ==} cpu: [arm64] os: [win32] - '@yuku-codegen/binding-win32-x64@0.5.44': - resolution: {integrity: sha512-geuJQ7FKI8YUtBgW8w72ChMfMvYy3gSytACxB4HOkylsoutrhH6lUNYHk1ny/p8u8t47NGxktpnzVJzI8IzJkA==} + '@yuku-codegen/binding-win32-x64@0.8.0': + resolution: {integrity: sha512-qvzSRABXe6/ndubx+RNwgbFVbs7Pqroz/q/UR6vm++xsmfcpkMF43B23jgNl2xi2IUrEt8C/L1bBslXp+LtgnA==} cpu: [x64] os: [win32] - '@yuku-parser/binding-darwin-arm64@0.5.44': - resolution: {integrity: sha512-EXSW5w1YOIMyxu53MFxP43gTDeHlQueOjk8AEI9tuLF5976bDq3MXuIgzQvZKPVAirxGZu8+ANVXH1WcAH1G6A==} + '@yuku-parser/binding-darwin-arm64@0.8.0': + resolution: {integrity: sha512-04AakSJhI4mPrqhZzXdFyaEDh0YkfeqbnyYY3aCrmxeWfR/Xr8+kFn5sh+wZYN/5HatPniELKHixJuUCUyfBvg==} cpu: [arm64] os: [darwin] - '@yuku-parser/binding-darwin-x64@0.5.44': - resolution: {integrity: sha512-pJBHsKxqR/nPwwaXgkkYTVo3G9dJ/AXhWsYyKyadU1zLg9gGfVwVTMehqwwutMCONDsLqOmEv/UqItmhpVsQJw==} + '@yuku-parser/binding-darwin-x64@0.8.0': + resolution: {integrity: sha512-BQJGI9bDeyb/X2rwhtXoBTQ9EtbkJtteX6C7cZ9jow0pqqmoOufgHPP2+m65GLk2eVNCBcfl3xlTGpJ7RFcviw==} cpu: [x64] os: [darwin] - '@yuku-parser/binding-freebsd-x64@0.5.44': - resolution: {integrity: sha512-fOqYX5BQML94uj9hd3a+LlBNz54OoDm6l+wgUIZ8UUkOBfPV+w2lux1jj9FKXT85wwjDOFhgZ/XQYSEDK/N9mA==} + '@yuku-parser/binding-freebsd-x64@0.8.0': + resolution: {integrity: sha512-04hmgnU152wya88raI+RQhxZPgwXcHbfdNZLu3x5ggKnJVHLD8xZZLcWIKSs59CiEXL6PKVX1/cx8GoTYlaCaQ==} cpu: [x64] os: [freebsd] - '@yuku-parser/binding-linux-arm-gnu@0.5.44': - resolution: {integrity: sha512-kA2jRNh2cbbHDbhBN9IR8CnYI4UZHNQg0OVz6+V16Ph5VlLYpfv/6GdCmvQQoTGAnhFlbQiRoQys03ey91FTew==} + '@yuku-parser/binding-linux-arm-gnu@0.8.0': + resolution: {integrity: sha512-+cuJWUK13lwce721XGRjz7izSr2Q1U0RlHkDzdkohWpRWlttTqRdxNnaW13nDc47GBDrNsXMHx+KLcebeOeeGg==} cpu: [arm] os: [linux] libc: [glibc] - '@yuku-parser/binding-linux-arm-musl@0.5.44': - resolution: {integrity: sha512-TQ82L8c5Lxl3HBOmw0uGfCcsyw0mp7DVGwCuW24OdDWak6BKaumvB8/Ep1llPOvhk5xgSFB7fsqgh6sIwkNRew==} + '@yuku-parser/binding-linux-arm-musl@0.8.0': + resolution: {integrity: sha512-rOVPRqt9cm1YP/wPV+yyZh0FYr6UR/wm/BXslvLuge0LDewVvDrm/AxcDrwWuPAu61Nzdg2rMVfcNnrplCbC1w==} cpu: [arm] os: [linux] libc: [musl] - '@yuku-parser/binding-linux-arm64-gnu@0.5.44': - resolution: {integrity: sha512-o6up+m/MsoMEtq6h4JTzmzKOvH9o3o41vK8oiok0LOZoPOFOqOSqC+N5V2ArD1O54m5LUq6ErghAbGWiPsMsqg==} + '@yuku-parser/binding-linux-arm64-gnu@0.8.0': + resolution: {integrity: sha512-pAoIozKr6E+ptpaQz4CZv1O7cay2f4m7kbd+DSQug5MKdUBTZZ18GdWSPvq0fwQYraH9hZlyLqrMaeP5W/ncrA==} cpu: [arm64] os: [linux] libc: [glibc] - '@yuku-parser/binding-linux-arm64-musl@0.5.44': - resolution: {integrity: sha512-3XZoiHhtrjWKgk7CJC/sGzk1TE57SDPYzOFXUg6CZrigRZ+c0Q5ItzJpyAvhzlxv5ruNRYiuGvFbaRSIDnp2BA==} + '@yuku-parser/binding-linux-arm64-musl@0.8.0': + resolution: {integrity: sha512-JCAg50ahXuYlrsIi1jmymX9X/9B0JYBRroAHnYttN44tAvCo1PFqukHrw1up6HvEOoLA0OjlM0Zwh60u3Gc/Zg==} cpu: [arm64] os: [linux] libc: [musl] - '@yuku-parser/binding-linux-x64-gnu@0.5.44': - resolution: {integrity: sha512-7KYCySZI6cjsdeiiIwvviDfJFd4Xj5gjNBzm9akUpwxRXNwaHHuEq2yGkVdGqj3BT6dJsiNhJIhtS6k7/HJdFA==} + '@yuku-parser/binding-linux-x64-gnu@0.8.0': + resolution: {integrity: sha512-tEeVQ14etp7lpUqXzq+X5AlQzFH+m3TVDiCKIq17zxnxJ117DOVvoveWSkSMt/lj68z48SvZUMHe7xLvqtO1lg==} cpu: [x64] os: [linux] libc: [glibc] - '@yuku-parser/binding-linux-x64-musl@0.5.44': - resolution: {integrity: sha512-SLU/nXwOjET2XlOiO984sAzCloiWw0+JdcWpGzXLz3JQWbdiblxWC9cIvB0fCtKdDhX1mIutKNEH+RRRJADYcQ==} + '@yuku-parser/binding-linux-x64-musl@0.8.0': + resolution: {integrity: sha512-+UGRYnF37nnbZNMsMjSGDXKUTIxYUmbbk3Lzib88sLK7kg2y80vjchYYoYsDUK9kAgqPWyA9hKGURHy/Kk9Few==} cpu: [x64] os: [linux] libc: [musl] - '@yuku-parser/binding-win32-arm64@0.5.44': - resolution: {integrity: sha512-LGmaJtB2mVqPD7Gu/RKAu9aIVxlaKPOgKB8RPHeFFD5Tf/apCiWZix1tkBjdOAo9cVEAoR9qnVHE6zJ+OG0drQ==} + '@yuku-parser/binding-win32-arm64@0.8.0': + resolution: {integrity: sha512-l+7Va9/sX1ccRjzjJj6MR0arKsvHB5b419+pKbwzn+/18A/xfccWqmxXrn0C0NyVLlbEb3AV9Is+AbF72O85yA==} cpu: [arm64] os: [win32] - '@yuku-parser/binding-win32-x64@0.5.44': - resolution: {integrity: sha512-2i0FTklLuhBIgILvtEQ3IN3J4qaTMKlxptseWNrz4F5mimL9UpQFUniVju9OOInwP2O1vgLdZn8EBjEXGNCHmg==} + '@yuku-parser/binding-win32-x64@0.8.0': + resolution: {integrity: sha512-dolKDTJv2xrWowDBEkvSaDvCsVFZOA7DCUuD+7DatglS68WbuxS4LudU5bzSeAOL5vgPpyGm82469cMLnL+6vQ==} cpu: [x64] os: [win32] - '@yuku-toolchain/types@0.5.43': - resolution: {integrity: sha512-kSpvPntnXw5+lYjO71ffBEnQ5ycQ74KGIYknh0TS4xeyCuBkOqxyJumxZkMhLBBUCLjDAbx2+Icnr3Zh4ftjpQ==} + '@yuku-toolchain/types@0.8.0': + resolution: {integrity: sha512-hL/raFM5V9UT2lVE/lIWDTWvANqSB8TvMVp+PgICehBa96KhTl/UpGm370JXaacukR+QnaHM2Yv6O/UcrzAFGg==} ajv@8.18.0: resolution: {integrity: sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==} @@ -1511,10 +1569,6 @@ packages: resolution: {integrity: sha512-++nLNyZwRfHqFh7akH5Gw/JYizoFlMRz0KRigfwfsLqV8ZqlcVRb1LkPEWdYvEKDnbktknM2J4BXaYUGrQZPww==} engines: {node: '>= 14'} - ansi-escapes@7.3.0: - resolution: {integrity: sha512-BvU8nYgGQBxcmMuEeUEmNTvrMVjJNSH7RgW24vXexN4Ven6qCvy4TntnvlnwnMLTVlcRQQdbRY8NKnaIoeWDNg==} - engines: {node: '>=18'} - ansi-regex@6.2.2: resolution: {integrity: sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==} engines: {node: '>=12'} @@ -1576,30 +1630,30 @@ packages: bare-events: optional: true - bare-url@2.4.5: - resolution: {integrity: sha512-K+y9xF1tN+CdPu4qWwr0QiK1Al07eFPGYK5M2pDXcmHdMdgC/tT/bpmMe1hrmRHaidKLkXrC+cRNYf3XVDUhSQ==} + bare-url@2.4.6: + resolution: {integrity: sha512-iQxPClE07hETVpbRoX7JXX3v/ZQViCxe/SYCxylRLzdEx1xJAufPptfiOqR8tqiCtmbtMDANKWszzjLu1PMAZQ==} - baseline-browser-mapping@2.10.42: - resolution: {integrity: sha512-c/jurFrDLyui7o1J86yLkRu4LMsTYcBohveus7/I2Hzdn9KIP2bdJPTue/lR1KH46enoPbD77GKeSYNdyPoD3Q==} + baseline-browser-mapping@2.11.1: + resolution: {integrity: sha512-HYXq73DDpCtNzOmrFsm9eSwCvWCql0RzqjpDzXN9EadiLJ4DNat0nsZ/Bzmy+Ud12mb4/zKDY0cQ805ZzN+i0A==} engines: {node: '>=6.0.0'} hasBin: true - brace-expansion@5.0.7: - resolution: {integrity: sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==} - engines: {node: 18 || 20 || >=22} + brace-expansion@5.0.8: + resolution: {integrity: sha512-JZyDyq3D4AUifKTPOB7DELf6XsB3WdPuNxCtob1vFXPsSXhdAiHBWJ/tJ8HAc9aH84BK+5JFZLNkJKx3G9kzQg==} + engines: {node: 20 || >=22} braces@3.0.3: resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} engines: {node: '>=8'} - browserslist@4.28.5: - resolution: {integrity: sha512-Cu2E6QejHWzuDMTkuwgpABFgDfZrXLQq5V13YOACZx4mFAG4IwGTbTfHPMr4WtxlHoXSM8FIuRwYYCz5XiabaQ==} + browserslist@4.28.7: + resolution: {integrity: sha512-JxV13hNrFxqjOc8alRbq9dK1MM79NEXYpma2B2J4wAtpWS5zIEIKqWPGCl7N4o7Uc7B7itylh7SuDujATRyyTw==} engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} hasBin: true - c8@11.0.0: - resolution: {integrity: sha512-e/uRViGHSVIJv7zsaDKM7VRn2390TgHXqUSvYwPHBQaU6L7E9L0n9JbdkwdYPvshDT0KymBmmlwSpms3yBaMNg==} - engines: {node: 20 || >=22} + c8@12.0.0: + resolution: {integrity: sha512-4zpJvrd1nKWutnnKC2pXkFmb6iM1l+ffN//o1CzlTNwW7GSOs9a1xrLqkC48nU8oEkjmPZLPiwMsIaOvoF4Pqg==} + engines: {node: ^20.19.0 || ^22.12.0 || >=23} hasBin: true peerDependencies: monocart-coverage-reports: ^2 @@ -1619,8 +1673,8 @@ packages: resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==} engines: {node: '>= 0.4'} - caniuse-lite@1.0.30001803: - resolution: {integrity: sha512-g/uHREV2ZpK9qMalCsWaxmA6ol+DX8GYhuf3T40RKoP+oL7vhRJh8LNt73PCjpnR6l14FzfPrB5Yux4PKm2meg==} + caniuse-lite@1.0.30001806: + resolution: {integrity: sha512-72Cuvd95zbSYPKq6Fhg8eDJRlzgWDf7/mtoZv6Qe/DYNCEBdNxoA3+rZAU2ZhGCpZlns3EssFavaZomckT5Uuw==} chalk@5.6.2: resolution: {integrity: sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==} @@ -1641,14 +1695,6 @@ packages: classcat@5.0.5: resolution: {integrity: sha512-JhZUT7JFcQy/EzW605k/ktHtncoo9vnyW/2GspNYwFlN1C/WmjuV/xtS04e9SOkL2sTdw0VAZ2UGCcQ9lR6p6w==} - cli-cursor@5.0.0: - resolution: {integrity: sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==} - engines: {node: '>=18'} - - cli-truncate@5.2.0: - resolution: {integrity: sha512-xRwvIOMGrfOAnM1JYtqQImuaNtDEv9v6oIYAs4LIHwTiKee8uwvIi363igssOC0O5U04i4AlENs79LQLu9tEMw==} - engines: {node: '>=20'} - cli-width@4.1.0: resolution: {integrity: sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==} engines: {node: '>= 12'} @@ -1762,11 +1808,11 @@ packages: resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} engines: {node: '>= 0.4'} - electron-to-chromium@1.5.389: - resolution: {integrity: sha512-cEto7aeOqBfU1D+c5py5pE+ooscKE75JifxLBdFUZsqAxRS6y7kebtxAZvICszSl05gPjYHDTjY+lXpyGvpJbg==} + electron-to-chromium@1.5.396: + resolution: {integrity: sha512-yHiw2Y3C3H9U6TMbOfoWK/BPreiOPXRfTWPBwQBoZG6/8TB6eOPnsy5oaRYuatR7Fw2SJ4kKforgufeo7fq0EQ==} - elkjs@0.11.1: - resolution: {integrity: sha512-zxxR9k+rx5ktMwT/FwyLdPCrq7xN6e4VGGHH8hA01vVYKjTFik7nHOxBnAYtrgYUB1RpAiLvA1/U2YraWxyKKg==} + elkjs@0.12.0: + resolution: {integrity: sha512-YZcKynxVxYoKIOEpywEPwCFdg+BTbxQRNf3pbwdDCvc8O3kQD8bmIwSxKU1eOTVc4Xo+VG9Te+575mlfvOrhEQ==} emoji-regex@10.6.0: resolution: {integrity: sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==} @@ -1779,10 +1825,6 @@ packages: resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==} engines: {node: '>=0.12'} - environment@1.1.0: - resolution: {integrity: sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q==} - engines: {node: '>=18'} - es-define-property@1.0.1: resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} engines: {node: '>= 0.4'} @@ -1804,9 +1846,6 @@ packages: resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} engines: {node: '>=6'} - eventemitter3@5.0.4: - resolution: {integrity: sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==} - events-universal@1.0.1: resolution: {integrity: sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw==} @@ -1833,8 +1872,8 @@ packages: fast-string-width@3.0.2: resolution: {integrity: sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg==} - fast-uri@3.1.3: - resolution: {integrity: sha512-i70LwGWUduXqzicKXWshooq+sWL1K3WUU5rKZNG/0i3a1OSoX3HqhH5WbWwTmqWfor4urUakGPiRQcleRZTwOg==} + fast-uri@3.1.4: + resolution: {integrity: sha512-8JnbkQ4juDyvYs4mgFGQqg4yCYtFDtUtmp2QIQq11ZZe5CFQ5wcqm1rqDgAh/QdMySuBnPzMUiJUNZG5N/AiQw==} fast-wrap-ansi@0.2.2: resolution: {integrity: sha512-7F2Fl+TjRSenLqlU3UjSH0iyqopqoZIu7eZVpEirP2g1GtWa2G/ecEmBdgz31+Mxr+ELclgg6sokpSFIQiZ02Q==} @@ -1911,8 +1950,8 @@ packages: resolution: {integrity: sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==} engines: {node: 18 || 20 || >=22} - globby@16.2.0: - resolution: {integrity: sha512-QrJia2qDf5BB/V6HYlDTs0I0lBahyjLzpGQg3KT7FnCdTonAyPy2RtY802m2k4ALx6Dp752f82WsOczEVr3l6Q==} + globby@16.2.1: + resolution: {integrity: sha512-JmsqJalahxxgW8V2ecSQ2G7UjPlI9cpKdrkG9KoNiXhd/YslXOTEB0cViENWUznuovIuNT+FkMbraDGjr4FCUg==} engines: {node: '>=20'} gopd@1.2.0: @@ -1950,8 +1989,8 @@ packages: resolution: {integrity: sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==} engines: {node: '>=0.10.0'} - ignore@7.0.5: - resolution: {integrity: sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==} + ignore@7.0.6: + resolution: {integrity: sha512-BAg6QkE8W+TuQLrrw0Ugr7HegXduRuuj8/ti2kSOc+jz1dmx8/WNcjr6XGnq5YpDWxFwwaavqD0+jIUOKelTsw==} engines: {node: '>= 4'} import-without-cache@0.4.0: @@ -1974,10 +2013,6 @@ packages: resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} engines: {node: '>=0.10.0'} - is-fullwidth-code-point@5.1.0: - resolution: {integrity: sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ==} - engines: {node: '>=18'} - is-glob@4.0.3: resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} engines: {node: '>=0.10.0'} @@ -2026,8 +2061,8 @@ packages: js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} - js-yaml@4.2.0: - resolution: {integrity: sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==} + js-yaml@4.3.0: + resolution: {integrity: sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==} hasBin: true jsesc@3.1.0: @@ -2057,92 +2092,88 @@ packages: resolution: {integrity: sha512-Eeo8Ys1doU1z+x8AZsPpQu+p/QcZBI5PeOo7QGQdy2x2m0MU/hYagBbGOmXwr5KVbEfVuWv9LpnQWeehogurjg==} hasBin: true - lightningcss-android-arm64@1.32.0: - resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==} + lightningcss-android-arm64@1.33.0: + resolution: {integrity: sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [android] - lightningcss-darwin-arm64@1.32.0: - resolution: {integrity: sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==} + lightningcss-darwin-arm64@1.33.0: + resolution: {integrity: sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [darwin] - lightningcss-darwin-x64@1.32.0: - resolution: {integrity: sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==} + lightningcss-darwin-x64@1.33.0: + resolution: {integrity: sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [darwin] - lightningcss-freebsd-x64@1.32.0: - resolution: {integrity: sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==} + lightningcss-freebsd-x64@1.33.0: + resolution: {integrity: sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [freebsd] - lightningcss-linux-arm-gnueabihf@1.32.0: - resolution: {integrity: sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==} + lightningcss-linux-arm-gnueabihf@1.33.0: + resolution: {integrity: sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==} engines: {node: '>= 12.0.0'} cpu: [arm] os: [linux] - lightningcss-linux-arm64-gnu@1.32.0: - resolution: {integrity: sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==} + lightningcss-linux-arm64-gnu@1.33.0: + resolution: {integrity: sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] libc: [glibc] - lightningcss-linux-arm64-musl@1.32.0: - resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==} + lightningcss-linux-arm64-musl@1.33.0: + resolution: {integrity: sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] libc: [musl] - lightningcss-linux-x64-gnu@1.32.0: - resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==} + lightningcss-linux-x64-gnu@1.33.0: + resolution: {integrity: sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] libc: [glibc] - lightningcss-linux-x64-musl@1.32.0: - resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==} + lightningcss-linux-x64-musl@1.33.0: + resolution: {integrity: sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] libc: [musl] - lightningcss-win32-arm64-msvc@1.32.0: - resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==} + lightningcss-win32-arm64-msvc@1.33.0: + resolution: {integrity: sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [win32] - lightningcss-win32-x64-msvc@1.32.0: - resolution: {integrity: sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==} + lightningcss-win32-x64-msvc@1.33.0: + resolution: {integrity: sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [win32] - lightningcss@1.32.0: - resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==} + lightningcss@1.33.0: + resolution: {integrity: sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==} engines: {node: '>= 12.0.0'} linkify-it@5.0.2: resolution: {integrity: sha512-ONTm2jCMAVZjgQa/Fy1kScXsuOoF5NPTsoFBdE1KVIZ2vAh/r9+Bqo+0jINCBYnavTPQZz38QzFTme79ENoN3Q==} - lint-staged@17.0.8: - resolution: {integrity: sha512-B2P/d+jVW0UXOQ0MVMLrB/9ydA1P+zz6jYfdrbbEd9ur3S2rcbduFWKiUCC02Sm5hbC8nrm7y24WuYMG54HfxA==} + lint-staged@17.2.0: + resolution: {integrity: sha512-FchGnFe4i4B1C/a35SPU9bNGPEHSC1+1iV0plLjzBmKVe9klZrlRfSgK6Cw4VeHyqOXbJUXP0vON61uRftNQ0A==} engines: {node: '>=22.22.1'} hasBin: true - listr2@10.2.2: - resolution: {integrity: sha512-JtNtbZj8q5BnDMR7trpwvwk3RIrANtIVzEUm8w7amp6xelLgyuq+4WZoTH913XaQAoH/cNdYhaNzBPA2U3xbDw==} - engines: {node: '>=22.13.0'} - locate-path@6.0.0: resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} engines: {node: '>=10'} @@ -2150,10 +2181,6 @@ packages: lodash.groupby@4.6.0: resolution: {integrity: sha512-5dcWxm23+VAoz+awKmBaiBvzox8+RqMgFhi7UvX9DHZr2HdxHXM/Wrf8cfKpsW37RNrvtPn6hSwNqurSILbmJw==} - log-update@6.1.0: - resolution: {integrity: sha512-9ie8ItPR6tjY5uYJh8K/Zrv/RMZ5VOlOWvtZdEHYSTFKZfIBPQa9tOAEeAWhd+AnIneLJ22w5fjOYtoutpWq5w==} - engines: {node: '>=18'} - lru-cache@11.5.2: resolution: {integrity: sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==} engines: {node: 20 || >=22} @@ -2174,21 +2201,21 @@ packages: peerDependencies: markdownlint-cli2: '>=0.0.4' - markdownlint-cli2@0.23.0: - resolution: {integrity: sha512-1nmgQmU/ZTMRVwYCDs7i1HI3zfBISnT2NNRv+9V01oOLZbAtqL+a7tldpPhBWBVBten3FqhMCGV6EUh9McqutQ==} + markdownlint-cli2@0.23.1: + resolution: {integrity: sha512-20JPI5W+HpV1OA+pUM712wgvL4GzYNUvbmhLU8KlEYJ1kCDx4soZ4/Xqd+WkLrPTOKMAn8SfO3zYFrK8GLlwQg==} engines: {node: '>=22'} hasBin: true - markdownlint@0.41.0: - resolution: {integrity: sha512-xMUI3ChBuRuxuLF4ENvCZyS8z/+Jly1coUcZwErKLIB3sDj7ojpaTBa1e9YVPhSN4jGEIjYGQCldbTJS/hqS+A==} + markdownlint@0.41.1: + resolution: {integrity: sha512-qHKeU2E1bdyNAT077go2FVTNXvYcktN5IHtF6XyeD1l0PClxzSp2tUApAV14ORI8DGX4H9bNKZEzelZp4qn8IA==} engines: {node: '>=22'} math-intrinsics@1.1.0: resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} engines: {node: '>= 0.4'} - mdurl@2.0.0: - resolution: {integrity: sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==} + mdurl@2.1.0: + resolution: {integrity: sha512-1+HBaOx0zi/dQWht8rNv9MYf9qqpqL/kxI0hXImU6Y547zM6Sni8BQibt7ifgMcYtQg41ao3Ivd6cnSM86inpg==} merge2@1.4.1: resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} @@ -2273,10 +2300,6 @@ packages: resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} engines: {node: '>=8.6'} - mimic-function@5.0.1: - resolution: {integrity: sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==} - engines: {node: '>=18'} - minimalistic-assert@1.0.1: resolution: {integrity: sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==} @@ -2308,8 +2331,8 @@ packages: resolution: {integrity: sha512-dkEJPVvun4FryqBmZ5KhDo0K9iDXAwn08tMLDinNdRBNPcYEDiWYysLcc6k3mjTMlbP9KyylvRpd4wFtwrT9rw==} engines: {node: ^20.17.0 || >=22.9.0} - nanoid@3.3.15: - resolution: {integrity: sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==} + nanoid@3.3.16: + resolution: {integrity: sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true @@ -2325,16 +2348,12 @@ packages: resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} engines: {node: '>= 0.4'} - obug@2.1.3: - resolution: {integrity: sha512-9miFgM2OFba7hB+pRgvtV84pYTBaoTHohvmIgiRt6dRIzbwEOIaNaP+dIlGs2fNFoB0SeISs0Jz5WFVRid6Xyg==} + obug@2.1.4: + resolution: {integrity: sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==} engines: {node: '>=12.20.0'} - onetime@7.0.0: - resolution: {integrity: sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==} - engines: {node: '>=18'} - - oxfmt@0.58.0: - resolution: {integrity: sha512-8feG/7NVEHDVwc1OUpP6Pks+TnaDFUw2jLLFIMi5bcmmwxAX2wBQvjSzj62RRTYBf2Op1Wt8xbkmagmPTR5ETg==} + oxfmt@0.60.0: + resolution: {integrity: sha512-fViX6i+gJuZWY+jI/fnR6WRbRj70GZ9RlCd30MygJrHTUNc4DxvKHWw8vBjMjffv3PgU5qWDR0AzmojQByqaZA==} engines: {node: ^20.19.0 || >=22.12.0} hasBin: true peerDependencies: @@ -2346,16 +2365,16 @@ packages: vite-plus: optional: true - oxlint-tsgolint@0.24.0: - resolution: {integrity: sha512-giCk5sEvG02d5tzPmFMX3hem8ndzEEu1xvGYS5OwNfO2WGl6ZVxt5LjE0yiMDoz94INI7XkXwgFAQiydPvVHDw==} + oxlint-tsgolint@7.0.2001: + resolution: {integrity: sha512-KjK/XLcXr1DSyonKhsuFqJRiuKqcyG9j3LJ8nkOsrLzGvodBPqzHOKauy10asLMDI0sUpvb+1sxlzff3udZvfg==} hasBin: true - oxlint@1.73.0: - resolution: {integrity: sha512-u91G9TJzU6yqKWNZUYprQB07W7YvntZXaRxQ6CkoytepYhLWUXWsr1M8zUJ34VatNPuUAr3Z8GH+O2A331CluQ==} + oxlint@1.75.0: + resolution: {integrity: sha512-m9WzjRcRYA/uqIZDa9tclrieoPJ/ln1QYTKdFx6NUOs8uY5DiHlIwRQoCrHT6OM6O3ww3l2skY5gO7G7ZphE7g==} engines: {node: ^20.19.0 || >=22.12.0} hasBin: true peerDependencies: - oxlint-tsgolint: '>=0.24.0' + oxlint-tsgolint: '>=7.0.2001' vite-plus: '*' peerDependenciesMeta: oxlint-tsgolint: @@ -2405,8 +2424,8 @@ packages: resolution: {integrity: sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==} engines: {node: '>=12'} - postcss@8.5.16: - resolution: {integrity: sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==} + postcss@8.5.23: + resolution: {integrity: sha512-g50586zr4bZmwFiTlflMu8E0bDTb5I5gertgwAKmsdUlTQIhZtunzUlD1WSzwcVWPoAVpsrA6vlfCD7oXvRwgg==} engines: {node: ^10 || ^12 || >=14} pretty-ms@9.3.0: @@ -2431,21 +2450,21 @@ packages: queue-microtask@1.2.3: resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} - react-dom@19.2.7: - resolution: {integrity: sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==} + react-dom@19.2.8: + resolution: {integrity: sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ==} peerDependencies: - react: ^19.2.7 + react: ^19.2.8 - react-is@19.2.7: - resolution: {integrity: sha512-kZFnouyVv7eP/Phmrlo9FK+zcAdriZJvzxXHF1Sl1P377WSGe2G/JxVolhTrB/jeV47lKImhNUsijjHAAbcl/A==} + react-is@19.2.8: + resolution: {integrity: sha512-s5un28nYxKJw5gvUHyW5PCC28CvBqLu9r3cWgzHT4Vo/5fqqkFcdRYsGcKf50WMPpjjFZS5d76fn3YCo2njKwQ==} - react-test-renderer@19.2.7: - resolution: {integrity: sha512-U4TyPDJ9MsC8rFimXuJum8w40aPc9kbOZYO8Pc2/4A884i8hwJsMNA/JNyuOc/f2/37wHvk7HjpVl1V4re7Dig==} + react-test-renderer@19.2.8: + resolution: {integrity: sha512-GHKPaDRaNYU24PHTLG8Bx8VMY9t+qNfxQbt/Yjp7aMWBkKU6766SR0n6TnYu7P5I1MfEuAMUadqiyDHyI4Yy9Q==} peerDependencies: - react: ^19.2.7 + react: ^19.2.8 - react@19.2.7: - resolution: {integrity: sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==} + react@19.2.8: + resolution: {integrity: sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==} engines: {node: '>=0.10.0'} require-from-string@2.0.2: @@ -2455,31 +2474,24 @@ packages: resolve-pkg-maps@1.0.0: resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} - restore-cursor@5.1.0: - resolution: {integrity: sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==} - engines: {node: '>=18'} - reusify@1.1.0: resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} engines: {iojs: '>=1.0.0', node: '>=0.10.0'} - rfdc@1.4.1: - resolution: {integrity: sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==} - - rolldown-plugin-dts@0.27.3: - resolution: {integrity: sha512-J57nfkYu6dd6s/Pt4LwGHipbUcm969tyXbQ/kBz9+7m+oeVzD90AynO2u133I61LISwbCUh9l0bt0/tvhOdd7w==} + rolldown-plugin-dts@0.27.14: + resolution: {integrity: sha512-ZvuDDwoIpRK9RPxDXratCpklFO9QZZWndf/sd0VBFb4LEj0jj07UcHK9OCh7V4XiFz2Z89ziyBC2K6tJiDjrbw==} engines: {node: ^22.18.0 || >=24.11.0} peerDependencies: - '@ts-macro/tsc': ^0.3.6 - '@typescript/native-preview': '>=7.0.0-dev.20260325.1' + '@typescript/native-preview': '*' + '@volar/typescript': ~2.4.0 rolldown: ^1.0.0 - typescript: ^5.0.0 || ^6.0.0 || ^7.0.0 + typescript: ^5.0.0 || ^6.0.0 || ~7.0.0 vue-tsc: ~3.2.0 || ~3.3.0 peerDependenciesMeta: - '@ts-macro/tsc': - optional: true '@typescript/native-preview': optional: true + '@volar/typescript': + optional: true typescript: optional: true vue-tsc: @@ -2495,6 +2507,11 @@ packages: engines: {node: ^20.19.0 || >=22.12.0} hasBin: true + rolldown@1.2.0: + resolution: {integrity: sha512-u7tgm5l4Yw1iTqUL4EcYOAt7fFvCgQMLeidrnD4GALlC6aOznCjezYajgxeyKw27u0Q5N7fwgCzjVyPIWzwuBA==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + run-parallel@1.2.0: resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} @@ -2552,8 +2569,8 @@ packages: sisteransi@1.0.5: resolution: {integrity: sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==} - skillflag@0.2.0: - resolution: {integrity: sha512-7ZmEpBeEoPLc+hqZ/StAnCO/hulgEPANzPyZgOM/CZ5zc3b0ApSp3URavY5POM/OKyi5d9+UC/Q21OoiYC2kJw==} + skillflag@0.2.1: + resolution: {integrity: sha512-47lfgUr6xzgljtTD5XfJrS+6muNb9R44OEr+o31Nco2R65W1xOA8Pi6QSbLa90tDYE6TqW5Hrh3N1egserGrhQ==} engines: {node: '>=18'} hasBin: true @@ -2561,14 +2578,6 @@ packages: resolution: {integrity: sha512-ZA6oR3T/pEyuqwMgAKT0/hAv8oAXckzbkmR0UkUosQ+Mc4RxGoJkRmwHgHufaenlyAgE1Mxgpdcrf75y6XcnDg==} engines: {node: '>=14.16'} - slice-ansi@7.1.2: - resolution: {integrity: sha512-iOBWFgUX7caIZiuutICxVgX1SdxwAVFFKwt1EvMYYec/NWO5meOJ6K5uQxhrYBdQJne4KxiqZc+KptFOWFSI9w==} - engines: {node: '>=18'} - - slice-ansi@8.0.0: - resolution: {integrity: sha512-stxByr12oeeOyY2BlviTNQlYV5xOj47GirPr4yA1hE9JCtxfQN0+tVbkxwCtYDQWhEKWFHsEK48ORg5jrouCAg==} - engines: {node: '>=20'} - smol-toml@1.7.0: resolution: {integrity: sha512-aqVvWoyO21L23mb+drl4RmMXbf6N7FdHjAhTRA9ZBL7apWBgfWC16KjrASI+1p9GAroljyMHj6fK67i0UiTNvQ==} engines: {node: '>= 18'} @@ -2596,10 +2605,6 @@ packages: resolution: {integrity: sha512-IIaP0g3iy9Cyy18w3M9YcaDudujEAVHKt3a3QJg1+sr/oX96TbaGUubG0hJyCjCBThFH+tFpcIyoUHUn1ogaLA==} engines: {node: '>=20'} - string-width@8.2.2: - resolution: {integrity: sha512-GaPUh5gfdrYzqeVNZvUfT23vYYxXzKYidUcnMtJg/3rxRV63EFZy3k6xfKlmfeJD0176lnUV/Usr3XcwSvFzpg==} - engines: {node: '>=20'} - strip-ansi@7.2.0: resolution: {integrity: sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==} engines: {node: '>=12'} @@ -2645,18 +2650,18 @@ packages: resolution: {integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==} hasBin: true - tsdown@0.22.4: - resolution: {integrity: sha512-3a5FsNL2fH2jw3ozvFUuPMBgS0xXjX9wpZShHyB4klXelVhyaNw5Q5WA9TPCNeoGYpRZEc4OZdMx5wT4Fkma3A==} + tsdown@0.22.14: + resolution: {integrity: sha512-ule7Y+fsAN2iZbLDoo7C4KYljFJNJJ+fLshyn+9gozeTspVersWHxwdGB+Dm2hzA38s6muFnUTl0jK3vJm9ifQ==} engines: {node: ^22.18.0 || >=24.11.0} hasBin: true peerDependencies: '@arethetypeswrong/core': ^0.18.1 - '@tsdown/css': 0.22.4 - '@tsdown/exe': 0.22.4 + '@tsdown/css': 0.22.14 + '@tsdown/exe': 0.22.14 '@vitejs/devtools': '*' publint: ^0.3.8 tsx: '*' - typescript: ^5.0.0 || ^6.0.0 + typescript: ^5.0.0 || ^6.0.0 || ^7.0.0 unplugin-unused: ^0.5.0 unrun: '*' peerDependenciesMeta: @@ -2682,8 +2687,8 @@ packages: tslib@2.8.1: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} - tsx@4.23.0: - resolution: {integrity: sha512-eUdUIaCr963q2h5u3+QwvYp0+eqPvn+egeqZUm0hwERCqqx1E3kK5ehbGCvqSE5MQAULr67ww0cA3jKc3YkM1w==} + tsx@4.23.1: + resolution: {integrity: sha512-GQHnkIfxyx1wYCOS/wonik5MVRZU9hi1TEZmzGZSCJB1y9YgoZ8H6itNE/u4suE+yLmOzuE4E5S4TZ/ZX2wcWQ==} engines: {node: '>=18.0.0'} hasBin: true @@ -2754,8 +2759,12 @@ packages: resolution: {integrity: sha512-kiGUalWN+rgBJ/1OHZsBtU4rXZOfj/7rKQxULKlIzwzQSvMJUUNgPwJEEh7gU6xEVxC0ahoOBvN2YI8GH6FNgA==} engines: {node: '>=10.12.0'} - vite@8.1.4: - resolution: {integrity: sha512-bTT9PsdWO+MQMNG9ZXIP/qM9wGh37DFxTV/sPq9cFpHr3w4jkgef032PkAL9jAqhk3Nz8NQw3O8n6/xFkqO4QQ==} + verkit@0.3.0: + resolution: {integrity: sha512-Njrh4U8UODGajoZ44QS2C/BsoEM9DTI/aCqY5swsizb+/ap0FamvnCMcZAxrR5+aoC0ZqkawEfpC/N2SBc+xeA==} + engines: {node: '>=18.12.0'} + + vite@8.1.5: + resolution: {integrity: sha512-7ULLwsCdYx/nRyrpiEwvqb5TFHrMVZyBt+rg/OAXT7rgj/z+DtTDyKFeLAdDkubDVDKD8jOsndmy7m55XcfUsw==} engines: {node: ^20.19.0 || >=22.12.0} hasBin: true peerDependencies: @@ -2805,16 +2814,12 @@ packages: engines: {node: '>= 8'} hasBin: true - wrap-ansi@10.0.0: - resolution: {integrity: sha512-SGcvg80f0wUy2/fXES19feHMz8E0JoXv2uNgHOu4Dgi2OrCy1lqwFYEJz1BLbDI0exjPMe/ZdzZ/YpGECBG/aQ==} - engines: {node: '>=20'} - wrap-ansi@9.0.2: resolution: {integrity: sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==} engines: {node: '>=18'} - ws@8.21.0: - resolution: {integrity: sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==} + ws@8.21.1: + resolution: {integrity: sha512-+0NTnW77fFN/DjQi6k/Sq/Yvk4Sgajw7urW8V+asjXnRgDs9gyGkdb7EzgfhA4goXsRIZKE28fzIXBHEzhuiWw==} engines: {node: '>=10.0.0'} peerDependencies: bufferutil: ^4.0.1 @@ -2857,14 +2862,14 @@ packages: resolution: {integrity: sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug==} engines: {node: '>=18'} - yuku-ast@0.1.7: - resolution: {integrity: sha512-2RiMEWv500TixY5rJy6OZd4fSy9WYZKWh6gGbIJ7y7vAGcuCugWOWwOLGaQcRZrXcPUfqtLtvpaJ3SdXtWlhKA==} + yuku-ast@0.8.0: + resolution: {integrity: sha512-trBzFsSa6k32vzNUCH6pFhAoTzWD/NifSYOIQ/6v14vXKh7TRhd2vDNIwRguGdXvcyfBNEEGcsfxFHrnJbS+FQ==} - yuku-codegen@0.5.44: - resolution: {integrity: sha512-0rhtgWGz+bR3Pe7xqJ5E4VqxI1vqNtkeJRkeXM0Qd3tgldYClQipxa9bRZyuNOOfk0Ri02scrjnkoAHM16/f2g==} + yuku-codegen@0.8.0: + resolution: {integrity: sha512-f82SDo8moLRymtdYN7/cz2yRbWE6Pmbmph+mLj24QkIR8ASG/c2nETdHzBhV/3rR/r8K+qmy7+JqQV3thHAm8g==} - yuku-parser@0.5.44: - resolution: {integrity: sha512-mAhpQZ/bXjxZmKiGUqEWskC9mZTcTBv6/fdzVdzdjM6XuD1DP3IavdLVWdM39L9ewK9vS9OtJmaKNeWgRzpy0w==} + yuku-parser@0.8.0: + resolution: {integrity: sha512-obrazyE8Cyh79xTQS9wv44khnhvkXG1CDSSy0bg+Pjjla+iXkKXK2b6+LTYXSN3qvVfQxc0MN5qhNKYr9sl3Zw==} zod@4.4.3: resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==} @@ -2886,7 +2891,7 @@ packages: snapshots: - '@agentclientprotocol/sdk@1.2.1(zod@4.4.3)': + '@agentclientprotocol/sdk@1.3.0(zod@4.4.3)': dependencies: zod: 4.4.3 @@ -2934,7 +2939,7 @@ snapshots: dependencies: '@babel/compat-data': 7.29.7 '@babel/helper-validator-option': 7.29.7 - browserslist: 4.28.5 + browserslist: 4.28.7 lru-cache: 5.1.1 semver: 6.3.1 @@ -3132,6 +3137,12 @@ snapshots: tslib: 2.8.1 optional: true + '@emnapi/core@1.11.2': + dependencies: + '@emnapi/wasi-threads': 1.2.2 + tslib: 2.8.1 + optional: true + '@emnapi/runtime@1.10.0': dependencies: tslib: 2.8.1 @@ -3142,6 +3153,11 @@ snapshots: tslib: 2.8.1 optional: true + '@emnapi/runtime@1.11.2': + dependencies: + tslib: 2.8.1 + optional: true + '@emnapi/wasi-threads@1.2.1': dependencies: tslib: 2.8.1 @@ -3384,6 +3400,13 @@ snapshots: '@tybys/wasm-util': 0.10.3 optional: true + '@napi-rs/wasm-runtime@1.1.6(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)': + dependencies: + '@emnapi/core': 1.11.2 + '@emnapi/runtime': 1.11.2 + '@tybys/wasm-util': 0.10.3 + optional: true + '@nodelib/fs.scandir@2.1.5': dependencies: '@nodelib/fs.stat': 2.0.5 @@ -3401,136 +3424,138 @@ snapshots: '@oxc-project/types@0.139.0': {} - '@oxfmt/binding-android-arm-eabi@0.58.0': + '@oxc-project/types@0.140.0': {} + + '@oxfmt/binding-android-arm-eabi@0.60.0': optional: true - '@oxfmt/binding-android-arm64@0.58.0': + '@oxfmt/binding-android-arm64@0.60.0': optional: true - '@oxfmt/binding-darwin-arm64@0.58.0': + '@oxfmt/binding-darwin-arm64@0.60.0': optional: true - '@oxfmt/binding-darwin-x64@0.58.0': + '@oxfmt/binding-darwin-x64@0.60.0': optional: true - '@oxfmt/binding-freebsd-x64@0.58.0': + '@oxfmt/binding-freebsd-x64@0.60.0': optional: true - '@oxfmt/binding-linux-arm-gnueabihf@0.58.0': + '@oxfmt/binding-linux-arm-gnueabihf@0.60.0': optional: true - '@oxfmt/binding-linux-arm-musleabihf@0.58.0': + '@oxfmt/binding-linux-arm-musleabihf@0.60.0': optional: true - '@oxfmt/binding-linux-arm64-gnu@0.58.0': + '@oxfmt/binding-linux-arm64-gnu@0.60.0': optional: true - '@oxfmt/binding-linux-arm64-musl@0.58.0': + '@oxfmt/binding-linux-arm64-musl@0.60.0': optional: true - '@oxfmt/binding-linux-ppc64-gnu@0.58.0': + '@oxfmt/binding-linux-ppc64-gnu@0.60.0': optional: true - '@oxfmt/binding-linux-riscv64-gnu@0.58.0': + '@oxfmt/binding-linux-riscv64-gnu@0.60.0': optional: true - '@oxfmt/binding-linux-riscv64-musl@0.58.0': + '@oxfmt/binding-linux-riscv64-musl@0.60.0': optional: true - '@oxfmt/binding-linux-s390x-gnu@0.58.0': + '@oxfmt/binding-linux-s390x-gnu@0.60.0': optional: true - '@oxfmt/binding-linux-x64-gnu@0.58.0': + '@oxfmt/binding-linux-x64-gnu@0.60.0': optional: true - '@oxfmt/binding-linux-x64-musl@0.58.0': + '@oxfmt/binding-linux-x64-musl@0.60.0': optional: true - '@oxfmt/binding-openharmony-arm64@0.58.0': + '@oxfmt/binding-openharmony-arm64@0.60.0': optional: true - '@oxfmt/binding-win32-arm64-msvc@0.58.0': + '@oxfmt/binding-win32-arm64-msvc@0.60.0': optional: true - '@oxfmt/binding-win32-ia32-msvc@0.58.0': + '@oxfmt/binding-win32-ia32-msvc@0.60.0': optional: true - '@oxfmt/binding-win32-x64-msvc@0.58.0': + '@oxfmt/binding-win32-x64-msvc@0.60.0': optional: true - '@oxlint-tsgolint/darwin-arm64@0.24.0': + '@oxlint-tsgolint/darwin-arm64@7.0.2001': optional: true - '@oxlint-tsgolint/darwin-x64@0.24.0': + '@oxlint-tsgolint/darwin-x64@7.0.2001': optional: true - '@oxlint-tsgolint/linux-arm64@0.24.0': + '@oxlint-tsgolint/linux-arm64@7.0.2001': optional: true - '@oxlint-tsgolint/linux-x64@0.24.0': + '@oxlint-tsgolint/linux-x64@7.0.2001': optional: true - '@oxlint-tsgolint/win32-arm64@0.24.0': + '@oxlint-tsgolint/win32-arm64@7.0.2001': optional: true - '@oxlint-tsgolint/win32-x64@0.24.0': + '@oxlint-tsgolint/win32-x64@7.0.2001': optional: true - '@oxlint/binding-android-arm-eabi@1.73.0': + '@oxlint/binding-android-arm-eabi@1.75.0': optional: true - '@oxlint/binding-android-arm64@1.73.0': + '@oxlint/binding-android-arm64@1.75.0': optional: true - '@oxlint/binding-darwin-arm64@1.73.0': + '@oxlint/binding-darwin-arm64@1.75.0': optional: true - '@oxlint/binding-darwin-x64@1.73.0': + '@oxlint/binding-darwin-x64@1.75.0': optional: true - '@oxlint/binding-freebsd-x64@1.73.0': + '@oxlint/binding-freebsd-x64@1.75.0': optional: true - '@oxlint/binding-linux-arm-gnueabihf@1.73.0': + '@oxlint/binding-linux-arm-gnueabihf@1.75.0': optional: true - '@oxlint/binding-linux-arm-musleabihf@1.73.0': + '@oxlint/binding-linux-arm-musleabihf@1.75.0': optional: true - '@oxlint/binding-linux-arm64-gnu@1.73.0': + '@oxlint/binding-linux-arm64-gnu@1.75.0': optional: true - '@oxlint/binding-linux-arm64-musl@1.73.0': + '@oxlint/binding-linux-arm64-musl@1.75.0': optional: true - '@oxlint/binding-linux-ppc64-gnu@1.73.0': + '@oxlint/binding-linux-ppc64-gnu@1.75.0': optional: true - '@oxlint/binding-linux-riscv64-gnu@1.73.0': + '@oxlint/binding-linux-riscv64-gnu@1.75.0': optional: true - '@oxlint/binding-linux-riscv64-musl@1.73.0': + '@oxlint/binding-linux-riscv64-musl@1.75.0': optional: true - '@oxlint/binding-linux-s390x-gnu@1.73.0': + '@oxlint/binding-linux-s390x-gnu@1.75.0': optional: true - '@oxlint/binding-linux-x64-gnu@1.73.0': + '@oxlint/binding-linux-x64-gnu@1.75.0': optional: true - '@oxlint/binding-linux-x64-musl@1.73.0': + '@oxlint/binding-linux-x64-musl@1.75.0': optional: true - '@oxlint/binding-openharmony-arm64@1.73.0': + '@oxlint/binding-openharmony-arm64@1.75.0': optional: true - '@oxlint/binding-win32-arm64-msvc@1.73.0': + '@oxlint/binding-win32-arm64-msvc@1.75.0': optional: true - '@oxlint/binding-win32-ia32-msvc@1.73.0': + '@oxlint/binding-win32-ia32-msvc@1.75.0': optional: true - '@oxlint/binding-win32-x64-msvc@1.73.0': + '@oxlint/binding-win32-x64-msvc@1.75.0': optional: true '@quansync/fs@1.0.0': @@ -3543,72 +3568,108 @@ snapshots: '@rolldown/binding-android-arm64@1.1.5': optional: true + '@rolldown/binding-android-arm64@1.2.0': + optional: true + '@rolldown/binding-darwin-arm64@1.0.0-rc.17': optional: true '@rolldown/binding-darwin-arm64@1.1.5': optional: true + '@rolldown/binding-darwin-arm64@1.2.0': + optional: true + '@rolldown/binding-darwin-x64@1.0.0-rc.17': optional: true '@rolldown/binding-darwin-x64@1.1.5': optional: true + '@rolldown/binding-darwin-x64@1.2.0': + optional: true + '@rolldown/binding-freebsd-x64@1.0.0-rc.17': optional: true '@rolldown/binding-freebsd-x64@1.1.5': optional: true + '@rolldown/binding-freebsd-x64@1.2.0': + optional: true + '@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.17': optional: true '@rolldown/binding-linux-arm-gnueabihf@1.1.5': optional: true + '@rolldown/binding-linux-arm-gnueabihf@1.2.0': + optional: true + '@rolldown/binding-linux-arm64-gnu@1.0.0-rc.17': optional: true '@rolldown/binding-linux-arm64-gnu@1.1.5': optional: true + '@rolldown/binding-linux-arm64-gnu@1.2.0': + optional: true + '@rolldown/binding-linux-arm64-musl@1.0.0-rc.17': optional: true '@rolldown/binding-linux-arm64-musl@1.1.5': optional: true + '@rolldown/binding-linux-arm64-musl@1.2.0': + optional: true + '@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.17': optional: true '@rolldown/binding-linux-ppc64-gnu@1.1.5': optional: true + '@rolldown/binding-linux-ppc64-gnu@1.2.0': + optional: true + '@rolldown/binding-linux-s390x-gnu@1.0.0-rc.17': optional: true '@rolldown/binding-linux-s390x-gnu@1.1.5': optional: true + '@rolldown/binding-linux-s390x-gnu@1.2.0': + optional: true + '@rolldown/binding-linux-x64-gnu@1.0.0-rc.17': optional: true '@rolldown/binding-linux-x64-gnu@1.1.5': optional: true + '@rolldown/binding-linux-x64-gnu@1.2.0': + optional: true + '@rolldown/binding-linux-x64-musl@1.0.0-rc.17': optional: true '@rolldown/binding-linux-x64-musl@1.1.5': optional: true + '@rolldown/binding-linux-x64-musl@1.2.0': + optional: true + '@rolldown/binding-openharmony-arm64@1.0.0-rc.17': optional: true '@rolldown/binding-openharmony-arm64@1.1.5': optional: true + '@rolldown/binding-openharmony-arm64@1.2.0': + optional: true + '@rolldown/binding-wasm32-wasi@1.0.0-rc.17': dependencies: '@emnapi/core': 1.10.0 @@ -3623,18 +3684,31 @@ snapshots: '@napi-rs/wasm-runtime': 1.1.6(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1) optional: true + '@rolldown/binding-wasm32-wasi@1.2.0': + dependencies: + '@emnapi/core': 1.11.2 + '@emnapi/runtime': 1.11.2 + '@napi-rs/wasm-runtime': 1.1.6(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2) + optional: true + '@rolldown/binding-win32-arm64-msvc@1.0.0-rc.17': optional: true '@rolldown/binding-win32-arm64-msvc@1.1.5': optional: true + '@rolldown/binding-win32-arm64-msvc@1.2.0': + optional: true + '@rolldown/binding-win32-x64-msvc@1.0.0-rc.17': optional: true '@rolldown/binding-win32-x64-msvc@1.1.5': optional: true + '@rolldown/binding-win32-x64-msvc@1.2.0': + optional: true + '@rolldown/pluginutils@1.0.0-rc.17': optional: true @@ -3760,38 +3834,6 @@ snapshots: dependencies: '@types/node': 26.1.1 - '@typescript/native-preview-darwin-arm64@7.0.0-dev.20260707.2': - optional: true - - '@typescript/native-preview-darwin-x64@7.0.0-dev.20260707.2': - optional: true - - '@typescript/native-preview-linux-arm64@7.0.0-dev.20260707.2': - optional: true - - '@typescript/native-preview-linux-arm@7.0.0-dev.20260707.2': - optional: true - - '@typescript/native-preview-linux-x64@7.0.0-dev.20260707.2': - optional: true - - '@typescript/native-preview-win32-arm64@7.0.0-dev.20260707.2': - optional: true - - '@typescript/native-preview-win32-x64@7.0.0-dev.20260707.2': - optional: true - - '@typescript/native-preview@7.0.0-dev.20260707.2': - optionalDependencies: - '@typescript/native-preview-darwin-arm64': 7.0.0-dev.20260707.2 - '@typescript/native-preview-darwin-x64': 7.0.0-dev.20260707.2 - '@typescript/native-preview-linux-arm': 7.0.0-dev.20260707.2 - '@typescript/native-preview-linux-arm64': 7.0.0-dev.20260707.2 - '@typescript/native-preview-linux-x64': 7.0.0-dev.20260707.2 - '@typescript/native-preview-win32-arm64': 7.0.0-dev.20260707.2 - '@typescript/native-preview-win32-x64': 7.0.0-dev.20260707.2 - optional: true - '@typescript/typescript-aix-ppc64@7.0.2': optional: true @@ -3856,18 +3898,18 @@ snapshots: dependencies: '@typescript/old': typescript@6.0.3 - '@vitejs/plugin-react@6.0.3(vite@8.1.4(@types/node@26.1.1)(esbuild@0.28.1)(tsx@4.23.0)(yaml@2.9.0))': + '@vitejs/plugin-react@6.0.4(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.1)(tsx@4.23.1)(yaml@2.9.0))': dependencies: '@rolldown/pluginutils': 1.0.1 - vite: 8.1.4(@types/node@26.1.1)(esbuild@0.28.1)(tsx@4.23.0)(yaml@2.9.0) + vite: 8.1.5(@types/node@26.1.1)(esbuild@0.28.1)(tsx@4.23.1)(yaml@2.9.0) - '@xyflow/react@12.11.2(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + '@xyflow/react@12.11.2(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': dependencies: '@xyflow/system': 0.0.79 classcat: 5.0.5 - react: 19.2.7 - react-dom: 19.2.7(react@19.2.7) - zustand: 4.5.7(@types/react@19.2.17)(react@19.2.7) + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + zustand: 4.5.7(@types/react@19.2.17)(react@19.2.8) optionalDependencies: '@types/react': 19.2.17 '@types/react-dom': 19.2.3(@types/react@19.2.17) @@ -3886,87 +3928,83 @@ snapshots: d3-selection: 3.0.0 d3-zoom: 3.0.0 - '@yuku-codegen/binding-darwin-arm64@0.5.44': + '@yuku-codegen/binding-darwin-arm64@0.8.0': optional: true - '@yuku-codegen/binding-darwin-x64@0.5.44': + '@yuku-codegen/binding-darwin-x64@0.8.0': optional: true - '@yuku-codegen/binding-freebsd-x64@0.5.44': + '@yuku-codegen/binding-freebsd-x64@0.8.0': optional: true - '@yuku-codegen/binding-linux-arm-gnu@0.5.44': + '@yuku-codegen/binding-linux-arm-gnu@0.8.0': optional: true - '@yuku-codegen/binding-linux-arm-musl@0.5.44': + '@yuku-codegen/binding-linux-arm-musl@0.8.0': optional: true - '@yuku-codegen/binding-linux-arm64-gnu@0.5.44': + '@yuku-codegen/binding-linux-arm64-gnu@0.8.0': optional: true - '@yuku-codegen/binding-linux-arm64-musl@0.5.44': + '@yuku-codegen/binding-linux-arm64-musl@0.8.0': optional: true - '@yuku-codegen/binding-linux-x64-gnu@0.5.44': + '@yuku-codegen/binding-linux-x64-gnu@0.8.0': optional: true - '@yuku-codegen/binding-linux-x64-musl@0.5.44': + '@yuku-codegen/binding-linux-x64-musl@0.8.0': optional: true - '@yuku-codegen/binding-win32-arm64@0.5.44': + '@yuku-codegen/binding-win32-arm64@0.8.0': optional: true - '@yuku-codegen/binding-win32-x64@0.5.44': + '@yuku-codegen/binding-win32-x64@0.8.0': optional: true - '@yuku-parser/binding-darwin-arm64@0.5.44': + '@yuku-parser/binding-darwin-arm64@0.8.0': optional: true - '@yuku-parser/binding-darwin-x64@0.5.44': + '@yuku-parser/binding-darwin-x64@0.8.0': optional: true - '@yuku-parser/binding-freebsd-x64@0.5.44': + '@yuku-parser/binding-freebsd-x64@0.8.0': optional: true - '@yuku-parser/binding-linux-arm-gnu@0.5.44': + '@yuku-parser/binding-linux-arm-gnu@0.8.0': optional: true - '@yuku-parser/binding-linux-arm-musl@0.5.44': + '@yuku-parser/binding-linux-arm-musl@0.8.0': optional: true - '@yuku-parser/binding-linux-arm64-gnu@0.5.44': + '@yuku-parser/binding-linux-arm64-gnu@0.8.0': optional: true - '@yuku-parser/binding-linux-arm64-musl@0.5.44': + '@yuku-parser/binding-linux-arm64-musl@0.8.0': optional: true - '@yuku-parser/binding-linux-x64-gnu@0.5.44': + '@yuku-parser/binding-linux-x64-gnu@0.8.0': optional: true - '@yuku-parser/binding-linux-x64-musl@0.5.44': + '@yuku-parser/binding-linux-x64-musl@0.8.0': optional: true - '@yuku-parser/binding-win32-arm64@0.5.44': + '@yuku-parser/binding-win32-arm64@0.8.0': optional: true - '@yuku-parser/binding-win32-x64@0.5.44': + '@yuku-parser/binding-win32-x64@0.8.0': optional: true - '@yuku-toolchain/types@0.5.43': {} + '@yuku-toolchain/types@0.8.0': {} ajv@8.18.0: dependencies: fast-deep-equal: 3.1.3 - fast-uri: 3.1.3 + fast-uri: 3.1.4 json-schema-traverse: 1.0.0 require-from-string: 2.0.2 angular-html-parser@10.4.0: {} - ansi-escapes@7.3.0: - dependencies: - environment: 1.1.0 - ansi-regex@6.2.2: {} ansi-styles@6.2.3: {} @@ -3986,7 +4024,7 @@ snapshots: bare-events: 2.9.1 bare-path: 3.1.1 bare-stream: 2.13.3(bare-events@2.9.1) - bare-url: 2.4.5 + bare-url: 2.4.6 fast-fifo: 1.3.2 transitivePeerDependencies: - bare-abort-controller @@ -4004,13 +4042,13 @@ snapshots: transitivePeerDependencies: - react-native-b4a - bare-url@2.4.5: + bare-url@2.4.6: dependencies: bare-path: 3.1.1 - baseline-browser-mapping@2.10.42: {} + baseline-browser-mapping@2.11.1: {} - brace-expansion@5.0.7: + brace-expansion@5.0.8: dependencies: balanced-match: 4.0.4 @@ -4018,15 +4056,15 @@ snapshots: dependencies: fill-range: 7.1.1 - browserslist@4.28.5: + browserslist@4.28.7: dependencies: - baseline-browser-mapping: 2.10.42 - caniuse-lite: 1.0.30001803 - electron-to-chromium: 1.5.389 + baseline-browser-mapping: 2.11.1 + caniuse-lite: 1.0.30001806 + electron-to-chromium: 1.5.396 node-releases: 2.0.51 - update-browserslist-db: 1.2.3(browserslist@4.28.5) + update-browserslist-db: 1.2.3(browserslist@4.28.7) - c8@11.0.0: + c8@12.0.0: dependencies: '@bcoe/v8-coverage': 1.0.2 '@istanbuljs/schema': 0.1.6 @@ -4052,7 +4090,7 @@ snapshots: call-bind-apply-helpers: 1.0.2 get-intrinsic: 1.3.0 - caniuse-lite@1.0.30001803: {} + caniuse-lite@1.0.30001806: {} chalk@5.6.2: {} @@ -4066,15 +4104,6 @@ snapshots: classcat@5.0.5: {} - cli-cursor@5.0.0: - dependencies: - restore-cursor: 5.1.0 - - cli-truncate@5.2.0: - dependencies: - slice-ansi: 8.0.0 - string-width: 8.2.2 - cli-width@4.1.0: {} cliui@9.0.1: @@ -4168,9 +4197,9 @@ snapshots: es-errors: 1.3.0 gopd: 1.2.0 - electron-to-chromium@1.5.389: {} + electron-to-chromium@1.5.396: {} - elkjs@0.11.1: {} + elkjs@0.12.0: {} emoji-regex@10.6.0: {} @@ -4178,8 +4207,6 @@ snapshots: entities@4.5.0: {} - environment@1.1.0: {} - es-define-property@1.0.1: {} es-errors@1.3.0: {} @@ -4219,8 +4246,6 @@ snapshots: escalade@3.2.0: {} - eventemitter3@5.0.4: {} - events-universal@1.0.1: dependencies: bare-events: 2.9.1 @@ -4262,7 +4287,7 @@ snapshots: dependencies: fast-string-truncated-width: 3.0.3 - fast-uri@3.1.3: {} + fast-uri@3.1.4: {} fast-wrap-ansi@0.2.2: dependencies: @@ -4342,11 +4367,11 @@ snapshots: minipass: 7.1.3 path-scurry: 2.0.2 - globby@16.2.0: + globby@16.2.1: dependencies: '@sindresorhus/merge-streams': 4.0.0 fast-glob: 3.3.3 - ignore: 7.0.5 + ignore: 7.0.6 is-path-inside: 4.0.0 slash: 5.1.0 unicorn-magic: 0.4.0 @@ -4373,7 +4398,7 @@ snapshots: dependencies: safer-buffer: 2.1.2 - ignore@7.0.5: {} + ignore@7.0.6: {} import-without-cache@0.4.0: {} @@ -4390,10 +4415,6 @@ snapshots: is-extglob@2.1.1: {} - is-fullwidth-code-point@5.1.0: - dependencies: - get-east-asian-width: 1.6.0 - is-glob@4.0.3: dependencies: is-extglob: 2.1.1 @@ -4429,7 +4450,7 @@ snapshots: js-tokens@4.0.0: {} - js-yaml@4.2.0: + js-yaml@4.3.0: dependencies: argparse: 2.0.1 @@ -4449,90 +4470,73 @@ snapshots: dependencies: commander: 8.3.0 - lightningcss-android-arm64@1.32.0: + lightningcss-android-arm64@1.33.0: optional: true - lightningcss-darwin-arm64@1.32.0: + lightningcss-darwin-arm64@1.33.0: optional: true - lightningcss-darwin-x64@1.32.0: + lightningcss-darwin-x64@1.33.0: optional: true - lightningcss-freebsd-x64@1.32.0: + lightningcss-freebsd-x64@1.33.0: optional: true - lightningcss-linux-arm-gnueabihf@1.32.0: + lightningcss-linux-arm-gnueabihf@1.33.0: optional: true - lightningcss-linux-arm64-gnu@1.32.0: + lightningcss-linux-arm64-gnu@1.33.0: optional: true - lightningcss-linux-arm64-musl@1.32.0: + lightningcss-linux-arm64-musl@1.33.0: optional: true - lightningcss-linux-x64-gnu@1.32.0: + lightningcss-linux-x64-gnu@1.33.0: optional: true - lightningcss-linux-x64-musl@1.32.0: + lightningcss-linux-x64-musl@1.33.0: optional: true - lightningcss-win32-arm64-msvc@1.32.0: + lightningcss-win32-arm64-msvc@1.33.0: optional: true - lightningcss-win32-x64-msvc@1.32.0: + lightningcss-win32-x64-msvc@1.33.0: optional: true - lightningcss@1.32.0: + lightningcss@1.33.0: dependencies: detect-libc: 2.1.2 optionalDependencies: - lightningcss-android-arm64: 1.32.0 - lightningcss-darwin-arm64: 1.32.0 - lightningcss-darwin-x64: 1.32.0 - lightningcss-freebsd-x64: 1.32.0 - lightningcss-linux-arm-gnueabihf: 1.32.0 - lightningcss-linux-arm64-gnu: 1.32.0 - lightningcss-linux-arm64-musl: 1.32.0 - lightningcss-linux-x64-gnu: 1.32.0 - lightningcss-linux-x64-musl: 1.32.0 - lightningcss-win32-arm64-msvc: 1.32.0 - lightningcss-win32-x64-msvc: 1.32.0 + lightningcss-android-arm64: 1.33.0 + lightningcss-darwin-arm64: 1.33.0 + lightningcss-darwin-x64: 1.33.0 + lightningcss-freebsd-x64: 1.33.0 + lightningcss-linux-arm-gnueabihf: 1.33.0 + lightningcss-linux-arm64-gnu: 1.33.0 + lightningcss-linux-arm64-musl: 1.33.0 + lightningcss-linux-x64-gnu: 1.33.0 + lightningcss-linux-x64-musl: 1.33.0 + lightningcss-win32-arm64-msvc: 1.33.0 + lightningcss-win32-x64-msvc: 1.33.0 linkify-it@5.0.2: dependencies: uc.micro: 2.1.0 - lint-staged@17.0.8: + lint-staged@17.2.0: dependencies: - listr2: 10.2.2 picomatch: 4.0.5 string-argv: 0.3.2 tinyexec: 1.2.4 optionalDependencies: yaml: 2.9.0 - listr2@10.2.2: - dependencies: - cli-truncate: 5.2.0 - eventemitter3: 5.0.4 - log-update: 6.1.0 - rfdc: 1.4.1 - wrap-ansi: 10.0.0 - locate-path@6.0.0: dependencies: p-locate: 5.0.0 lodash.groupby@4.6.0: {} - log-update@6.1.0: - dependencies: - ansi-escapes: 7.3.0 - cli-cursor: 5.0.0 - slice-ansi: 7.1.2 - strip-ansi: 7.2.0 - wrap-ansi: 9.0.2 - lru-cache@11.5.2: {} lru-cache@5.1.1: @@ -4548,29 +4552,29 @@ snapshots: argparse: 2.0.1 entities: 4.5.0 linkify-it: 5.0.2 - mdurl: 2.0.0 + mdurl: 2.1.0 punycode.js: 2.3.1 uc.micro: 2.1.0 - markdownlint-cli2-formatter-default@0.0.6(markdownlint-cli2@0.23.0): + markdownlint-cli2-formatter-default@0.0.6(markdownlint-cli2@0.23.1): dependencies: - markdownlint-cli2: 0.23.0 + markdownlint-cli2: 0.23.1 - markdownlint-cli2@0.23.0: + markdownlint-cli2@0.23.1: dependencies: - globby: 16.2.0 - js-yaml: 4.2.0 + globby: 16.2.1 + js-yaml: 4.3.0 jsonc-parser: 3.3.1 jsonpointer: 5.0.1 markdown-it: 14.2.0 - markdownlint: 0.41.0 - markdownlint-cli2-formatter-default: 0.0.6(markdownlint-cli2@0.23.0) + markdownlint: 0.41.1 + markdownlint-cli2-formatter-default: 0.0.6(markdownlint-cli2@0.23.1) micromatch: 4.0.8 smol-toml: 1.7.0 transitivePeerDependencies: - supports-color - markdownlint@0.41.0: + markdownlint@0.41.1: dependencies: micromark: 4.0.2 micromark-core-commonmark: 2.0.3 @@ -4586,7 +4590,7 @@ snapshots: math-intrinsics@1.1.0: {} - mdurl@2.0.0: {} + mdurl@2.1.0: {} merge2@1.4.1: {} @@ -4767,13 +4771,11 @@ snapshots: braces: 3.0.3 picomatch: 2.3.2 - mimic-function@5.0.1: {} - minimalistic-assert@1.0.1: {} minimatch@10.2.5: dependencies: - brace-expansion: 5.0.7 + brace-expansion: 5.0.8 minipass@7.1.3: {} @@ -4793,7 +4795,7 @@ snapshots: mute-stream@3.0.0: {} - nanoid@3.3.15: {} + nanoid@3.3.16: {} node-releases@2.0.51: {} @@ -4804,67 +4806,63 @@ snapshots: object-inspect@1.13.4: {} - obug@2.1.3: {} - - onetime@7.0.0: - dependencies: - mimic-function: 5.0.1 + obug@2.1.4: {} - oxfmt@0.58.0: + oxfmt@0.60.0: dependencies: tinypool: 2.1.0 optionalDependencies: - '@oxfmt/binding-android-arm-eabi': 0.58.0 - '@oxfmt/binding-android-arm64': 0.58.0 - '@oxfmt/binding-darwin-arm64': 0.58.0 - '@oxfmt/binding-darwin-x64': 0.58.0 - '@oxfmt/binding-freebsd-x64': 0.58.0 - '@oxfmt/binding-linux-arm-gnueabihf': 0.58.0 - '@oxfmt/binding-linux-arm-musleabihf': 0.58.0 - '@oxfmt/binding-linux-arm64-gnu': 0.58.0 - '@oxfmt/binding-linux-arm64-musl': 0.58.0 - '@oxfmt/binding-linux-ppc64-gnu': 0.58.0 - '@oxfmt/binding-linux-riscv64-gnu': 0.58.0 - '@oxfmt/binding-linux-riscv64-musl': 0.58.0 - '@oxfmt/binding-linux-s390x-gnu': 0.58.0 - '@oxfmt/binding-linux-x64-gnu': 0.58.0 - '@oxfmt/binding-linux-x64-musl': 0.58.0 - '@oxfmt/binding-openharmony-arm64': 0.58.0 - '@oxfmt/binding-win32-arm64-msvc': 0.58.0 - '@oxfmt/binding-win32-ia32-msvc': 0.58.0 - '@oxfmt/binding-win32-x64-msvc': 0.58.0 - - oxlint-tsgolint@0.24.0: + '@oxfmt/binding-android-arm-eabi': 0.60.0 + '@oxfmt/binding-android-arm64': 0.60.0 + '@oxfmt/binding-darwin-arm64': 0.60.0 + '@oxfmt/binding-darwin-x64': 0.60.0 + '@oxfmt/binding-freebsd-x64': 0.60.0 + '@oxfmt/binding-linux-arm-gnueabihf': 0.60.0 + '@oxfmt/binding-linux-arm-musleabihf': 0.60.0 + '@oxfmt/binding-linux-arm64-gnu': 0.60.0 + '@oxfmt/binding-linux-arm64-musl': 0.60.0 + '@oxfmt/binding-linux-ppc64-gnu': 0.60.0 + '@oxfmt/binding-linux-riscv64-gnu': 0.60.0 + '@oxfmt/binding-linux-riscv64-musl': 0.60.0 + '@oxfmt/binding-linux-s390x-gnu': 0.60.0 + '@oxfmt/binding-linux-x64-gnu': 0.60.0 + '@oxfmt/binding-linux-x64-musl': 0.60.0 + '@oxfmt/binding-openharmony-arm64': 0.60.0 + '@oxfmt/binding-win32-arm64-msvc': 0.60.0 + '@oxfmt/binding-win32-ia32-msvc': 0.60.0 + '@oxfmt/binding-win32-x64-msvc': 0.60.0 + + oxlint-tsgolint@7.0.2001: optionalDependencies: - '@oxlint-tsgolint/darwin-arm64': 0.24.0 - '@oxlint-tsgolint/darwin-x64': 0.24.0 - '@oxlint-tsgolint/linux-arm64': 0.24.0 - '@oxlint-tsgolint/linux-x64': 0.24.0 - '@oxlint-tsgolint/win32-arm64': 0.24.0 - '@oxlint-tsgolint/win32-x64': 0.24.0 - - oxlint@1.73.0(oxlint-tsgolint@0.24.0): + '@oxlint-tsgolint/darwin-arm64': 7.0.2001 + '@oxlint-tsgolint/darwin-x64': 7.0.2001 + '@oxlint-tsgolint/linux-arm64': 7.0.2001 + '@oxlint-tsgolint/linux-x64': 7.0.2001 + '@oxlint-tsgolint/win32-arm64': 7.0.2001 + '@oxlint-tsgolint/win32-x64': 7.0.2001 + + oxlint@1.75.0(oxlint-tsgolint@7.0.2001): optionalDependencies: - '@oxlint/binding-android-arm-eabi': 1.73.0 - '@oxlint/binding-android-arm64': 1.73.0 - '@oxlint/binding-darwin-arm64': 1.73.0 - '@oxlint/binding-darwin-x64': 1.73.0 - '@oxlint/binding-freebsd-x64': 1.73.0 - '@oxlint/binding-linux-arm-gnueabihf': 1.73.0 - '@oxlint/binding-linux-arm-musleabihf': 1.73.0 - '@oxlint/binding-linux-arm64-gnu': 1.73.0 - '@oxlint/binding-linux-arm64-musl': 1.73.0 - '@oxlint/binding-linux-ppc64-gnu': 1.73.0 - '@oxlint/binding-linux-riscv64-gnu': 1.73.0 - '@oxlint/binding-linux-riscv64-musl': 1.73.0 - '@oxlint/binding-linux-s390x-gnu': 1.73.0 - '@oxlint/binding-linux-x64-gnu': 1.73.0 - '@oxlint/binding-linux-x64-musl': 1.73.0 - '@oxlint/binding-openharmony-arm64': 1.73.0 - '@oxlint/binding-win32-arm64-msvc': 1.73.0 - '@oxlint/binding-win32-ia32-msvc': 1.73.0 - '@oxlint/binding-win32-x64-msvc': 1.73.0 - oxlint-tsgolint: 0.24.0 + '@oxlint/binding-android-arm-eabi': 1.75.0 + '@oxlint/binding-android-arm64': 1.75.0 + '@oxlint/binding-darwin-arm64': 1.75.0 + '@oxlint/binding-darwin-x64': 1.75.0 + '@oxlint/binding-freebsd-x64': 1.75.0 + '@oxlint/binding-linux-arm-gnueabihf': 1.75.0 + '@oxlint/binding-linux-arm-musleabihf': 1.75.0 + '@oxlint/binding-linux-arm64-gnu': 1.75.0 + '@oxlint/binding-linux-arm64-musl': 1.75.0 + '@oxlint/binding-linux-ppc64-gnu': 1.75.0 + '@oxlint/binding-linux-riscv64-gnu': 1.75.0 + '@oxlint/binding-linux-riscv64-musl': 1.75.0 + '@oxlint/binding-linux-s390x-gnu': 1.75.0 + '@oxlint/binding-linux-x64-gnu': 1.75.0 + '@oxlint/binding-linux-x64-musl': 1.75.0 + '@oxlint/binding-openharmony-arm64': 1.75.0 + '@oxlint/binding-win32-arm64-msvc': 1.75.0 + '@oxlint/binding-win32-ia32-msvc': 1.75.0 + '@oxlint/binding-win32-x64-msvc': 1.75.0 + oxlint-tsgolint: 7.0.2001 p-limit@3.1.0: dependencies: @@ -4903,9 +4901,9 @@ snapshots: picomatch@4.0.5: {} - postcss@8.5.16: + postcss@8.5.23: dependencies: - nanoid: 3.3.15 + nanoid: 3.3.16 picocolors: 1.1.1 source-map-js: 1.2.1 @@ -4925,45 +4923,37 @@ snapshots: queue-microtask@1.2.3: {} - react-dom@19.2.7(react@19.2.7): + react-dom@19.2.8(react@19.2.8): dependencies: - react: 19.2.7 + react: 19.2.8 scheduler: 0.27.0 - react-is@19.2.7: {} + react-is@19.2.8: {} - react-test-renderer@19.2.7(react@19.2.7): + react-test-renderer@19.2.8(react@19.2.8): dependencies: - react: 19.2.7 - react-is: 19.2.7 + react: 19.2.8 + react-is: 19.2.8 scheduler: 0.27.0 - react@19.2.7: {} + react@19.2.8: {} require-from-string@2.0.2: {} resolve-pkg-maps@1.0.0: {} - restore-cursor@5.1.0: - dependencies: - onetime: 7.0.0 - signal-exit: 4.1.0 - reusify@1.1.0: {} - rfdc@1.4.1: {} - - rolldown-plugin-dts@0.27.3(@typescript/native-preview@7.0.0-dev.20260707.2)(@typescript/typescript6@6.0.2)(rolldown@1.1.5): + rolldown-plugin-dts@0.27.14(@typescript/typescript6@6.0.2)(rolldown@1.2.0): dependencies: dts-resolver: 3.0.0 get-tsconfig: 5.0.0-beta.5 - obug: 2.1.3 - rolldown: 1.1.5 - yuku-ast: 0.1.7 - yuku-codegen: 0.5.44 - yuku-parser: 0.5.44 + obug: 2.1.4 + rolldown: 1.2.0 + yuku-ast: 0.8.0 + yuku-codegen: 0.8.0 + yuku-parser: 0.8.0 optionalDependencies: - '@typescript/native-preview': 7.0.0-dev.20260707.2 typescript: '@typescript/typescript6@6.0.2' transitivePeerDependencies: - oxc-resolver @@ -5011,6 +5001,27 @@ snapshots: '@rolldown/binding-win32-arm64-msvc': 1.1.5 '@rolldown/binding-win32-x64-msvc': 1.1.5 + rolldown@1.2.0: + dependencies: + '@oxc-project/types': 0.140.0 + '@rolldown/pluginutils': 1.0.1 + optionalDependencies: + '@rolldown/binding-android-arm64': 1.2.0 + '@rolldown/binding-darwin-arm64': 1.2.0 + '@rolldown/binding-darwin-x64': 1.2.0 + '@rolldown/binding-freebsd-x64': 1.2.0 + '@rolldown/binding-linux-arm-gnueabihf': 1.2.0 + '@rolldown/binding-linux-arm64-gnu': 1.2.0 + '@rolldown/binding-linux-arm64-musl': 1.2.0 + '@rolldown/binding-linux-ppc64-gnu': 1.2.0 + '@rolldown/binding-linux-s390x-gnu': 1.2.0 + '@rolldown/binding-linux-x64-gnu': 1.2.0 + '@rolldown/binding-linux-x64-musl': 1.2.0 + '@rolldown/binding-openharmony-arm64': 1.2.0 + '@rolldown/binding-wasm32-wasi': 1.2.0 + '@rolldown/binding-win32-arm64-msvc': 1.2.0 + '@rolldown/binding-win32-x64-msvc': 1.2.0 + run-parallel@1.2.0: dependencies: queue-microtask: 1.2.3 @@ -5067,7 +5078,7 @@ snapshots: sisteransi@1.0.5: {} - skillflag@0.2.0: + skillflag@0.2.1: dependencies: '@clack/prompts': 1.7.0 tar-stream: 3.2.0 @@ -5078,16 +5089,6 @@ snapshots: slash@5.1.0: {} - slice-ansi@7.1.2: - dependencies: - ansi-styles: 6.2.3 - is-fullwidth-code-point: 5.1.0 - - slice-ansi@8.0.0: - dependencies: - ansi-styles: 6.2.3 - is-fullwidth-code-point: 5.1.0 - smol-toml@1.7.0: {} source-map-js@1.2.1: {} @@ -5116,11 +5117,6 @@ snapshots: get-east-asian-width: 1.6.0 strip-ansi: 7.2.0 - string-width@8.2.2: - dependencies: - get-east-asian-width: 1.6.0 - strip-ansi: 7.2.0 - strip-ansi@7.2.0: dependencies: ansi-regex: 6.2.2 @@ -5176,7 +5172,7 @@ snapshots: tree-kill@1.2.2: {} - tsdown@0.22.4(@typescript/native-preview@7.0.0-dev.20260707.2)(@typescript/typescript6@6.0.2)(tsx@4.23.0)(unrun@0.2.37): + tsdown@0.22.14(@typescript/typescript6@6.0.2)(tsx@4.23.1)(unrun@0.2.37): dependencies: ansis: 4.3.1 cac: 7.0.0 @@ -5184,28 +5180,28 @@ snapshots: empathic: 2.0.1 hookable: 6.1.1 import-without-cache: 0.4.0 - obug: 2.1.3 + obug: 2.1.4 picomatch: 4.0.5 - rolldown: 1.1.5 - rolldown-plugin-dts: 0.27.3(@typescript/native-preview@7.0.0-dev.20260707.2)(@typescript/typescript6@6.0.2)(rolldown@1.1.5) - semver: 7.8.5 + rolldown: 1.2.0 + rolldown-plugin-dts: 0.27.14(@typescript/typescript6@6.0.2)(rolldown@1.2.0) tinyexec: 1.2.4 tinyglobby: 0.2.17 tree-kill: 1.2.2 unconfig-core: 7.5.0 + verkit: 0.3.0 optionalDependencies: - tsx: 4.23.0 + tsx: 4.23.1 typescript: '@typescript/typescript6@6.0.2' unrun: 0.2.37 transitivePeerDependencies: - - '@ts-macro/tsc' - '@typescript/native-preview' + - '@volar/typescript' - oxc-resolver - vue-tsc tslib@2.8.1: {} - tsx@4.23.0: + tsx@4.23.1: dependencies: esbuild: 0.28.1 optionalDependencies: @@ -5268,15 +5264,15 @@ snapshots: rolldown: 1.0.0-rc.17 optional: true - update-browserslist-db@1.2.3(browserslist@4.28.5): + update-browserslist-db@1.2.3(browserslist@4.28.7): dependencies: - browserslist: 4.28.5 + browserslist: 4.28.7 escalade: 3.2.0 picocolors: 1.1.1 - use-sync-external-store@1.6.0(react@19.2.7): + use-sync-external-store@1.6.0(react@19.2.8): dependencies: - react: 19.2.7 + react: 19.2.8 v8-to-istanbul@9.3.0: dependencies: @@ -5284,18 +5280,20 @@ snapshots: '@types/istanbul-lib-coverage': 2.0.6 convert-source-map: 2.0.0 - vite@8.1.4(@types/node@26.1.1)(esbuild@0.28.1)(tsx@4.23.0)(yaml@2.9.0): + verkit@0.3.0: {} + + vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.1)(tsx@4.23.1)(yaml@2.9.0): dependencies: - lightningcss: 1.32.0 + lightningcss: 1.33.0 picomatch: 4.0.5 - postcss: 8.5.16 + postcss: 8.5.23 rolldown: 1.1.5 tinyglobby: 0.2.17 optionalDependencies: '@types/node': 26.1.1 esbuild: 0.28.1 fsevents: 2.3.3 - tsx: 4.23.0 + tsx: 4.23.1 yaml: 2.9.0 weapon-regex@1.3.6: {} @@ -5304,19 +5302,13 @@ snapshots: dependencies: isexe: 2.0.0 - wrap-ansi@10.0.0: - dependencies: - ansi-styles: 6.2.3 - string-width: 8.2.2 - strip-ansi: 7.2.0 - wrap-ansi@9.0.2: dependencies: ansi-styles: 6.2.3 string-width: 7.2.0 strip-ansi: 7.2.0 - ws@8.21.0: {} + ws@8.21.1: {} y18n@5.0.8: {} @@ -5342,47 +5334,48 @@ snapshots: yoctocolors@2.1.2: {} - yuku-ast@0.1.7: + yuku-ast@0.8.0: dependencies: - '@yuku-toolchain/types': 0.5.43 + '@yuku-toolchain/types': 0.8.0 - yuku-codegen@0.5.44: + yuku-codegen@0.8.0: dependencies: - '@yuku-toolchain/types': 0.5.43 + '@yuku-toolchain/types': 0.8.0 optionalDependencies: - '@yuku-codegen/binding-darwin-arm64': 0.5.44 - '@yuku-codegen/binding-darwin-x64': 0.5.44 - '@yuku-codegen/binding-freebsd-x64': 0.5.44 - '@yuku-codegen/binding-linux-arm-gnu': 0.5.44 - '@yuku-codegen/binding-linux-arm-musl': 0.5.44 - '@yuku-codegen/binding-linux-arm64-gnu': 0.5.44 - '@yuku-codegen/binding-linux-arm64-musl': 0.5.44 - '@yuku-codegen/binding-linux-x64-gnu': 0.5.44 - '@yuku-codegen/binding-linux-x64-musl': 0.5.44 - '@yuku-codegen/binding-win32-arm64': 0.5.44 - '@yuku-codegen/binding-win32-x64': 0.5.44 - - yuku-parser@0.5.44: - dependencies: - '@yuku-toolchain/types': 0.5.43 + '@yuku-codegen/binding-darwin-arm64': 0.8.0 + '@yuku-codegen/binding-darwin-x64': 0.8.0 + '@yuku-codegen/binding-freebsd-x64': 0.8.0 + '@yuku-codegen/binding-linux-arm-gnu': 0.8.0 + '@yuku-codegen/binding-linux-arm-musl': 0.8.0 + '@yuku-codegen/binding-linux-arm64-gnu': 0.8.0 + '@yuku-codegen/binding-linux-arm64-musl': 0.8.0 + '@yuku-codegen/binding-linux-x64-gnu': 0.8.0 + '@yuku-codegen/binding-linux-x64-musl': 0.8.0 + '@yuku-codegen/binding-win32-arm64': 0.8.0 + '@yuku-codegen/binding-win32-x64': 0.8.0 + + yuku-parser@0.8.0: + dependencies: + '@yuku-toolchain/types': 0.8.0 + yuku-ast: 0.8.0 optionalDependencies: - '@yuku-parser/binding-darwin-arm64': 0.5.44 - '@yuku-parser/binding-darwin-x64': 0.5.44 - '@yuku-parser/binding-freebsd-x64': 0.5.44 - '@yuku-parser/binding-linux-arm-gnu': 0.5.44 - '@yuku-parser/binding-linux-arm-musl': 0.5.44 - '@yuku-parser/binding-linux-arm64-gnu': 0.5.44 - '@yuku-parser/binding-linux-arm64-musl': 0.5.44 - '@yuku-parser/binding-linux-x64-gnu': 0.5.44 - '@yuku-parser/binding-linux-x64-musl': 0.5.44 - '@yuku-parser/binding-win32-arm64': 0.5.44 - '@yuku-parser/binding-win32-x64': 0.5.44 + '@yuku-parser/binding-darwin-arm64': 0.8.0 + '@yuku-parser/binding-darwin-x64': 0.8.0 + '@yuku-parser/binding-freebsd-x64': 0.8.0 + '@yuku-parser/binding-linux-arm-gnu': 0.8.0 + '@yuku-parser/binding-linux-arm-musl': 0.8.0 + '@yuku-parser/binding-linux-arm64-gnu': 0.8.0 + '@yuku-parser/binding-linux-arm64-musl': 0.8.0 + '@yuku-parser/binding-linux-x64-gnu': 0.8.0 + '@yuku-parser/binding-linux-x64-musl': 0.8.0 + '@yuku-parser/binding-win32-arm64': 0.8.0 + '@yuku-parser/binding-win32-x64': 0.8.0 zod@4.4.3: {} - zustand@4.5.7(@types/react@19.2.17)(react@19.2.7): + zustand@4.5.7(@types/react@19.2.17)(react@19.2.8): dependencies: - use-sync-external-store: 1.6.0(react@19.2.7) + use-sync-external-store: 1.6.0(react@19.2.8) optionalDependencies: '@types/react': 19.2.17 - react: 19.2.7 + react: 19.2.8 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index a2b7b692..0f82fdb4 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,7 +1,8 @@ minimumReleaseAge: 2880 overrides: - c8>yargs: 18.0.0 - js-yaml: 4.2.0 + brace-expansion: 5.0.8 + fast-uri: 3.1.4 + js-yaml: 4.3.0 markdown-it: 14.2.0 qs: 6.15.2 From 9866c7608764c47142420721932946cec8d9d6de Mon Sep 17 00:00:00 2001 From: Sebastien Tardif Date: Mon, 27 Jul 2026 09:56:39 -0400 Subject: [PATCH 10/57] fix(flows): swallow heartbeat write rejections from setInterval (#442) * fix(flows): swallow heartbeat write rejections from setInterval void heartbeat() inside setInterval can reject when store.writeLive fails, producing unhandledRejection noise and process flags. Catch rejections as best-effort live-state updates, matching other best-effort .catch() sites in the runtime. Signed-off-by: Sebastien Tardif * test(flows): prove heartbeat timer swallows writeLive rejections Add a focused regression that forces best-effort heartbeat writes to reject and asserts zero unhandledRejection events, matching the setInterval .catch() boundary in FlowRunner.runWithHeartbeat. Signed-off-by: Sebastien Tardif * test(flows): exercise FlowRunner heartbeat writeLive rejection path Replace the duplicated timer harness with a real FlowRunner shell node that forces interval writeLive failures and asserts zero unhandledRejection. Signed-off-by: Sebastien Tardif * retrigger proof check Signed-off-by: Sebastien Tardif * fix(flows): harden heartbeat rejection coverage (#442) Co-authored-by: Sebastien Tardif --------- Signed-off-by: Sebastien Tardif Co-authored-by: Peter Steinberger --- CHANGELOG.md | 2 + src/flows/runtime.ts | 6 +- test/flows-heartbeat-rejection.test.ts | 106 +++++++++++++++++++++++++ 3 files changed, 113 insertions(+), 1 deletion(-) create mode 100644 test/flows-heartbeat-rejection.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 465e63b2..5bc3d72d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,8 @@ Repo: https://github.com/openclaw/acpx ### Fixes +- Flows: swallow best-effort heartbeat write failures at the timer boundary so storage errors do not become unhandled promise rejections. Thanks @SebTardif. + ## 2026.7.23 (v0.12.1) ### Changes diff --git a/src/flows/runtime.ts b/src/flows/runtime.ts index af17aab9..fe92fd19 100644 --- a/src/flows/runtime.ts +++ b/src/flows/runtime.ts @@ -1014,7 +1014,11 @@ export class FlowRunner { if (heartbeatMs > 0) { timer = setInterval(() => { - void heartbeat(); + // Heartbeat writes are best-effort; never leave a rejected promise + // from setInterval (unhandledRejection noise / process flags). + void heartbeat().catch(() => { + // ignore + }); }, heartbeatMs); } diff --git a/test/flows-heartbeat-rejection.test.ts b/test/flows-heartbeat-rejection.test.ts new file mode 100644 index 00000000..9c53581a --- /dev/null +++ b/test/flows-heartbeat-rejection.test.ts @@ -0,0 +1,106 @@ +import assert from "node:assert/strict"; +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import test from "node:test"; +import { extractJsonObject } from "../src/flows/json.js"; +import { FlowRunner, defineFlow, shell } from "../src/flows/runtime.js"; + +type WriteLiveArgs = [string, unknown, { type?: string }?]; + +/** + * Production-path regression: FlowRunner.runWithHeartbeat must swallow + * writeLive rejections from the setInterval boundary so a failing live + * store cannot surface unhandledRejection while a shell node runs. + */ +test("FlowRunner heartbeat writeLive rejections do not become unhandledRejection", async () => { + const rejections: unknown[] = []; + const onUnhandled = (reason: unknown) => { + rejections.push(reason); + }; + process.on("unhandledRejection", onUnhandled); + + const homeDir = await fs.mkdtemp(path.join(os.tmpdir(), "acpx-hb-reject-home-")); + const outputRoot = await fs.mkdtemp(path.join(os.tmpdir(), "acpx-hb-reject-runs-")); + const previousHome = process.env.HOME; + process.env.HOME = homeDir; + + try { + const runner = new FlowRunner({ + resolveAgent: () => ({ + agentName: "unused", + agentCommand: "unused", + cwd: process.cwd(), + }), + permissionMode: "approve-all", + outputRoot, + }); + + const store = ( + runner as unknown as { + store: { + writeLive: (...args: WriteLiveArgs) => Promise; + }; + } + ).store; + const originalWriteLive = store.writeLive.bind(store); + let heartbeatAttempts = 0; + let rejectedIntervalHeartbeats = 0; + store.writeLive = async (...args: WriteLiveArgs) => { + const event = args[2]; + if (event?.type === "node_heartbeat") { + heartbeatAttempts += 1; + // First call is awaited during shell prepare (must succeed so the + // node can run). Interval ticks from runWithHeartbeat must be + // swallowed so they never become unhandledRejection. + if (heartbeatAttempts > 1) { + rejectedIntervalHeartbeats += 1; + throw new Error("disk full (proof)"); + } + } + return originalWriteLive(...args); + }; + + const flow = defineFlow({ + name: "heartbeat-reject-test", + startAt: "slow", + nodes: { + slow: shell({ + heartbeatMs: 20, + exec: () => ({ + command: process.execPath, + args: [ + "-e", + "setTimeout(() => process.stdout.write(JSON.stringify({done:true})), 120)", + ], + }), + parse: (result) => extractJsonObject(result.stdout), + }), + }, + edges: [], + }); + + const result = await runner.run(flow, {}); + // Allow any late timer ticks to surface + await new Promise((resolve) => { + setTimeout(resolve, 50); + }); + + assert.equal(result.state.status, "completed"); + assert.deepEqual(result.state.outputs.slow, { done: true }); + assert.ok( + rejectedIntervalHeartbeats >= 1, + `expected at least one rejected interval heartbeat, got attempts=${heartbeatAttempts} rejected=${rejectedIntervalHeartbeats}`, + ); + assert.deepEqual(rejections, []); + } finally { + process.off("unhandledRejection", onUnhandled); + if (previousHome === undefined) { + delete process.env.HOME; + } else { + process.env.HOME = previousHome; + } + await fs.rm(homeDir, { recursive: true, force: true, maxRetries: 3, retryDelay: 50 }); + await fs.rm(outputRoot, { recursive: true, force: true, maxRetries: 3, retryDelay: 50 }); + } +}); From 1d17a61f7eb3feda6c66370d1f990c6f4216db51 Mon Sep 17 00:00:00 2001 From: Henk ter Harmsel Date: Mon, 27 Jul 2026 16:13:00 +0200 Subject: [PATCH 11/57] fix(session): prevent concurrent save failures (#447) * fix(session): avoid atomic write temp collisions * test(session): make temp-path regression deterministic (#447) Co-authored-by: Henk ter Harmsel --------- Co-authored-by: Peter Steinberger --- CHANGELOG.md | 2 ++ src/runtime/public/file-session-store.ts | 3 ++- src/session/persistence/atomic-write.ts | 8 +++++++ src/session/persistence/index.ts | 3 ++- src/session/persistence/repository.ts | 3 ++- test/atomic-write.test.ts | 23 +++++++++++++++++++ test/runtime.test.ts | 28 ++++++++++++++++++++++++ 7 files changed, 67 insertions(+), 3 deletions(-) create mode 100644 src/session/persistence/atomic-write.ts create mode 100644 test/atomic-write.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 5bc3d72d..55b71d48 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,8 @@ Repo: https://github.com/openclaw/acpx - Flows: swallow best-effort heartbeat write failures at the timer boundary so storage errors do not become unhandled promise rejections. Thanks @SebTardif. +- Runtime/sessions: use collision-resistant temporary paths for concurrent atomic session and index writes. Thanks @henkterharmsel. + ## 2026.7.23 (v0.12.1) ### Changes diff --git a/src/runtime/public/file-session-store.ts b/src/runtime/public/file-session-store.ts index 342e1c29..cf4ba451 100644 --- a/src/runtime/public/file-session-store.ts +++ b/src/runtime/public/file-session-store.ts @@ -1,6 +1,7 @@ import fs from "node:fs/promises"; import path from "node:path"; import { assertPersistedKeyPolicy } from "../../persisted-key-policy.js"; +import { createAtomicWriteTempPath } from "../../session/persistence/atomic-write.js"; import { parseSessionRecord } from "../../session/persistence/parse.js"; import { serializeSessionRecordForDisk } from "../../session/persistence/serialize.js"; import type { AcpFileSessionStoreOptions, AcpSessionRecord, AcpSessionStore } from "./contract.js"; @@ -50,7 +51,7 @@ class FileSessionStore implements AcpSessionStore { assertPersistedKeyPolicy(persisted); const file = this.filePath(record.acpxRecordId); - const tempFile = `${file}.${process.pid}.${Date.now()}.tmp`; + const tempFile = createAtomicWriteTempPath(file); const payload = JSON.stringify(persisted, null, 2); await fs.writeFile(tempFile, `${payload}\n`, "utf8"); await fs.rename(tempFile, file); diff --git a/src/session/persistence/atomic-write.ts b/src/session/persistence/atomic-write.ts new file mode 100644 index 00000000..f39e95ab --- /dev/null +++ b/src/session/persistence/atomic-write.ts @@ -0,0 +1,8 @@ +import { randomUUID } from "node:crypto"; + +export function createAtomicWriteTempPath( + filePath: string, + createUniqueId: () => string = randomUUID, +): string { + return `${filePath}.${process.pid}.${Date.now()}.${createUniqueId()}.tmp`; +} diff --git a/src/session/persistence/index.ts b/src/session/persistence/index.ts index d66fad7b..eca8decf 100644 --- a/src/session/persistence/index.ts +++ b/src/session/persistence/index.ts @@ -1,6 +1,7 @@ import fs from "node:fs/promises"; import path from "node:path"; import type { SessionRecord } from "../../types.js"; +import { createAtomicWriteTempPath } from "./atomic-write.js"; import { parseSessionRecord } from "./parse.js"; const SESSION_INDEX_SCHEMA = "acpx.session-index.v1"; @@ -125,7 +126,7 @@ export async function writeSessionIndex( }, ): Promise { const filePath = sessionIndexPath(sessionDir); - const tempFile = `${filePath}.${process.pid}.${Date.now()}.tmp`; + const tempFile = createAtomicWriteTempPath(filePath); const payload = JSON.stringify( { schema: SESSION_INDEX_SCHEMA, diff --git a/src/session/persistence/repository.ts b/src/session/persistence/repository.ts index 0dc8ef69..86a490e7 100644 --- a/src/session/persistence/repository.ts +++ b/src/session/persistence/repository.ts @@ -6,6 +6,7 @@ import { SessionNotFoundError, SessionResolutionError } from "../../errors.js"; import { incrementPerfCounter, measurePerf } from "../../perf-metrics.js"; import { assertPersistedKeyPolicy } from "../../persisted-key-policy.js"; import type { SessionRecord } from "../../types.js"; +import { createAtomicWriteTempPath } from "./atomic-write.js"; import { loadOrRebuildSessionIndex, rebuildSessionIndex, @@ -90,7 +91,7 @@ export async function writeSessionRecord(record: SessionRecord): Promise { assertPersistedKeyPolicy(persisted); const file = sessionFilePath(record.acpxRecordId); - const tempFile = `${file}.${process.pid}.${Date.now()}.tmp`; + const tempFile = createAtomicWriteTempPath(file); const payload = JSON.stringify(persisted, null, 2); await fs.writeFile(tempFile, `${payload}\n`, "utf8"); await fs.rename(tempFile, file); diff --git a/test/atomic-write.test.ts b/test/atomic-write.test.ts new file mode 100644 index 00000000..a16bfda6 --- /dev/null +++ b/test/atomic-write.test.ts @@ -0,0 +1,23 @@ +import assert from "node:assert/strict"; +import path from "node:path"; +import test from "node:test"; +import { createAtomicWriteTempPath } from "../src/session/persistence/atomic-write.js"; + +test("atomic write temp paths stay distinct within the same millisecond", () => { + const originalNow = Date.now; + Date.now = () => 1_750_000_000_000; + + try { + const destination = path.join("/tmp", "session.json"); + const first = createAtomicWriteTempPath(destination, () => "first-write"); + const second = createAtomicWriteTempPath(destination, () => "second-write"); + + assert.notEqual(first, second); + assert.equal(path.dirname(first), path.dirname(destination)); + assert.equal(path.dirname(second), path.dirname(destination)); + assert.match(first, /\.first-write\.tmp$/u); + assert.match(second, /\.second-write\.tmp$/u); + } finally { + Date.now = originalNow; + } +}); diff --git a/test/runtime.test.ts b/test/runtime.test.ts index 91a6c11a..7415a3eb 100644 --- a/test/runtime.test.ts +++ b/test/runtime.test.ts @@ -234,6 +234,34 @@ test("createFileSessionStore persists records inside the provided state director ); }); +test("createFileSessionStore supports concurrent saves in the same millisecond", async (t) => { + const stateDir = await fs.mkdtemp(path.join(os.tmpdir(), "acpx-runtime-store-concurrent-")); + t.after(async () => { + await fs.rm(stateDir, { recursive: true, force: true }); + }); + + const originalNow = Date.now; + Date.now = () => 1_750_000_000_000; + t.after(() => { + Date.now = originalNow; + }); + + const store = createFileSessionStore({ stateDir }); + const record = createSessionRecord({ + acpxRecordId: "agent:codex:acp:concurrent", + acpSessionId: "sid-concurrent", + }); + + await Promise.all(Array.from({ length: 8 }, () => store.save(record))); + + const loaded = await store.load(record.acpxRecordId); + assert.equal(loaded?.acpSessionId, "sid-concurrent"); + assert.deepEqual( + (await fs.readdir(path.join(stateDir, "sessions"))).filter((file) => file.endsWith(".tmp")), + [], + ); +}); + test("createFileSessionStore.load() returns undefined for a corrupt session file (#378)", async (t) => { const stateDir = await fs.mkdtemp(path.join(os.tmpdir(), "acpx-runtime-store-corrupt-")); t.after(async () => { From 2f9fd039463ab255e349ee3b718c1186bfbaa0c5 Mon Sep 17 00:00:00 2001 From: zgxkbtl <489693132@qq.com> Date: Mon, 27 Jul 2026 22:32:56 +0800 Subject: [PATCH 12/57] feat: add filesystem capability opt-out (#469) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(cli): add filesystem capability opt-out * docs(cli): complete no-fs coverage (#469) Co-authored-by: 甄新 --------- Co-authored-by: Peter Steinberger --- CHANGELOG.md | 2 + README.md | 2 + agents/Qwen.md | 15 ++++ docs/CLI.md | 2 + docs/compare.md | 1 + skills/acpx/SKILL.md | 3 +- src/acp/client.ts | 20 ++++-- src/cli-core.ts | 1 + src/cli/command-handlers.ts | 7 ++ src/cli/compare-command.ts | 1 + src/cli/flags.ts | 9 ++- src/cli/queue/owner-env.ts | 3 +- src/cli/session/contracts.ts | 8 +++ src/cli/session/prompt-runner.ts | 5 ++ src/cli/session/queue-owner-process.ts | 3 + src/cli/session/queue-owner-runtime.ts | 4 ++ src/cli/session/runtime.ts | 4 ++ src/cli/session/session-control.ts | 3 + src/cli/session/session-management.ts | 4 ++ src/flows/cli.ts | 1 + src/flows/runtime.ts | 5 ++ src/flows/types.ts | 1 + src/runtime/engine/connected-session.ts | 3 + src/types.ts | 1 + test/cli-flags.test.ts | 4 ++ test/cli.test.ts | 2 + test/integration.test.ts | 91 +++++++++++++++++++++++++ test/queue-owner-env.test.ts | 2 + test/queue-owner-process.test.ts | 4 +- 29 files changed, 202 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 55b71d48..ac6d4aa1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,8 @@ Repo: https://github.com/openclaw/acpx - Dependencies/tooling: refresh the ACP SDK, runtime and development toolchain, update pnpm to 10.34.5, and resolve the PostCSS, fast-uri, js-yaml, and brace-expansion advisories. +- CLI/ACP: add `--no-fs` to disable advertised ACP file read/write capabilities so compatible agents can use their native filesystem implementation. Thanks @zgxkbtl. + ### Breaking - Windows agent launches now require structured `agents..argv`; unambiguous legacy `command` plus `args` entries migrate automatically, while raw, ambiguous, or directly executable `.sh` commands fail with explicit migration guidance instead of lossy parsing or CreateProcess ENOENT. Existing custom-agent sessions without saved argv must be recreated. Fixes #466. Thanks @MarcelCFritsche. diff --git a/README.md b/README.md index c59e31cc..65a8c238 100644 --- a/README.md +++ b/README.md @@ -36,6 +36,7 @@ One command surface for Pi, OpenClaw ACP, Codex, Claude, and other ACP-compatibl - **Session export/import**: move portable session archives between machines - **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 +- **Capability opt-outs**: let adapters use their native filesystem or terminal tools with `--no-fs` and `--no-terminal` - **Auth handshake**: stable `authenticate` support via env/config credentials - **Structured output**: typed ACP messages (thinking, tool calls, diffs) instead of ANSI scraping - **Any ACP agent**: built-in registry + `--agent` escape hatch for custom servers @@ -219,6 +220,7 @@ acpx flow run ./my-flow.ts --input-file ./flow-input.json acpx --timeout 1800 flow run ./my-flow.ts acpx --format quiet codex 'final recommendation only' acpx --suppress-reads codex exec 'show tool activity without dumping file bodies' +acpx --no-fs codex exec 'read files without ACP client filesystem delegation' acpx --timeout 90 codex 'investigate intermittent test timeout' acpx --ttl 30 codex 'keep queue owner alive for quick follow-ups' diff --git a/agents/Qwen.md b/agents/Qwen.md index 530bbd94..0784218a 100644 --- a/agents/Qwen.md +++ b/agents/Qwen.md @@ -3,3 +3,18 @@ - Built-in name: `qwen` - Default command: `qwen --acp` - Upstream: https://github.com/QwenLM/qwen-code + +## Filesystem delegation + +By default, acpx advertises ACP `fs/read_text_file` and `fs/write_text_file`, so +Qwen delegates its file tools to the acpx filesystem proxy. Use `--no-fs` when +Qwen must use its native filesystem service instead, such as when it needs to +read or write files in its own runtime temporary directories: + +```bash +acpx --no-fs qwen exec 'inspect the runtime artifact and summarize it' +``` + +The flag advertises both filesystem capabilities as `false` for the new ACP +client connection. It is independent of `--no-terminal`; combine both flags +when Qwen should use neither acpx filesystem nor terminal delegation. diff --git a/docs/CLI.md b/docs/CLI.md index 3ce18f00..30e545da 100644 --- a/docs/CLI.md +++ b/docs/CLI.md @@ -118,6 +118,7 @@ All global options: | `--format ` | 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-fs` | Disable ACP filesystem capabilities | Advertises `clientCapabilities.fs.readTextFile` and `writeTextFile` as `false` during ACP initialize for new agent clients. | | `--no-terminal` | Disable ACP terminal capability | Advertises `clientCapabilities.terminal: false` during ACP initialize for new agent clients. | | `--non-interactive-permissions ` | Non-TTY prompt policy | `deny` (default) or `fail` when approval prompt cannot be shown. | | `--permission-policy ` | Per-tool permission policy | JSON object or file path with `autoApprove`, `autoDeny`, `escalate`, and optional `defaultAction` (`approve`, `deny`, `escalate`). Alias: `--policy`. | @@ -140,6 +141,7 @@ acpx --policy '{"escalate":["execute"],"defaultAction":"deny"}' --format json co 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-fs codex exec 'use agent-native file operations' 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' diff --git a/docs/compare.md b/docs/compare.md index b87423e3..27ff7f4b 100644 --- a/docs/compare.md +++ b/docs/compare.md @@ -36,6 +36,7 @@ prompt. | `--timeout ` | Per-agent timeout in seconds. | | `--non-interactive-permissions ` | Non-TTY prompt behavior. | | `--auth-policy ` | ACP authentication behavior. | +| `--no-fs` | Do not advertise filesystem support to agents. | | `--no-terminal` | Do not advertise terminal support to agents. | | `--prompt-retries ` | Retry failed prompt turns before any side effects are observed. | | `--model`, `--allowed-tools`, `--max-turns`, `--system-prompt` | Session creation options forwarded to compatible agents. | diff --git a/skills/acpx/SKILL.md b/skills/acpx/SKILL.md index c91260c0..d9c5c634 100644 --- a/skills/acpx/SKILL.md +++ b/skills/acpx/SKILL.md @@ -35,7 +35,7 @@ Core capabilities: - Structured streaming output (`text`, `json`, `quiet`) with optional `--suppress-reads` - Built-in agent registry plus raw `--agent` escape hatch - Claude system prompt override via `--system-prompt` / `--append-system-prompt` -- Optional terminal capability disable via `--no-terminal` for review-only flows +- Optional ACP filesystem and terminal capability opt-outs via `--no-fs` and `--no-terminal` - Tool whitelist (`--allowed-tools`), turn cap (`--max-turns`), retry on transient failures (`--prompt-retries`) - Multi-agent flows via `acpx flow run` and the `acpx/flows` authoring API (`defineFlow`, `decision`, `decisionEdge`, `acp`, `action`, `compute`, `checkpoint`) @@ -268,6 +268,7 @@ Behavior: - `--allowed-tools `: comma-separated tool whitelist (use `""` for no tools) - `--max-turns `: cap session turn count - `--prompt-retries `: retry failed prompt turns on transient errors (default `0`) +- `--no-fs`: advertise both ACP filesystem capabilities as disabled so compatible agents use their native file operations - `--no-terminal`: do not advertise the ACP terminal capability — useful for review-only or sandboxed agent invocations - `--verbose`: verbose ACP/debug logs to stderr diff --git a/src/acp/client.ts b/src/acp/client.ts index 3666ee72..f142c4c2 100644 --- a/src/acp/client.ts +++ b/src/acp/client.ts @@ -151,12 +151,13 @@ function resolveClientInfo(devinAcp: boolean): { name: string; version: string } function resolveClientCapabilities(params: { devinAcp: boolean; + fs: boolean; terminal: boolean; }): ClientCapabilities { const baseCapabilities: ClientCapabilities = { fs: { - readTextFile: true, - writeTextFile: true, + readTextFile: params.fs, + writeTextFile: params.fs, }, terminal: params.terminal, }; @@ -539,6 +540,7 @@ export class AcpClient { permissionMode?: PermissionMode; nonInteractivePermissions?: NonInteractivePermissionPolicy; permissionPolicy?: AcpClientOptions["permissionPolicy"]; + fs?: boolean; terminal?: boolean; suppressSdkConsoleErrors?: boolean; verbose?: boolean; @@ -554,9 +556,7 @@ export class AcpClient { if (Object.prototype.hasOwnProperty.call(options, "permissionPolicy")) { this.options.permissionPolicy = options.permissionPolicy; } - if (options.terminal !== undefined) { - this.options.terminal = options.terminal; - } + this.updateClientCapabilityPreferences(options); this.refreshRuntimePermissionPolicy(shouldRefreshPermissionPolicy); if (options.suppressSdkConsoleErrors !== undefined) { this.options.suppressSdkConsoleErrors = options.suppressSdkConsoleErrors; @@ -566,6 +566,15 @@ export class AcpClient { } } + private updateClientCapabilityPreferences(options: { fs?: boolean; terminal?: boolean }): void { + if (options.fs !== undefined) { + this.options.fs = options.fs; + } + if (options.terminal !== undefined) { + this.options.terminal = options.terminal; + } + } + private refreshRuntimePermissionPolicy(enabled: boolean): void { if (!enabled) { return; @@ -819,6 +828,7 @@ export class AcpClient { protocolVersion: PROTOCOL_VERSION, clientCapabilities: resolveClientCapabilities({ devinAcp: launch.devinAcp, + fs: this.options.fs !== false, terminal: this.options.terminal !== false, }), clientInfo: resolveClientInfo(launch.devinAcp), diff --git a/src/cli-core.ts b/src/cli-core.ts index 38d6445e..f1663eb4 100644 --- a/src/cli-core.ts +++ b/src/cli-core.ts @@ -72,6 +72,7 @@ const TOP_LEVEL_VERSION_BOOLEAN_FLAGS = new Set([ "--deny-all", "--suppress-reads", "--json-strict", + "--no-fs", "--no-terminal", "--verbose", ]); diff --git a/src/cli/command-handlers.ts b/src/cli/command-handlers.ts index 10e9ccc7..2aba8a74 100644 --- a/src/cli/command-handlers.ts +++ b/src/cli/command-handlers.ts @@ -225,6 +225,7 @@ function buildSessionStartOptions(params: { permissionPolicy: params.permissionPolicy, authCredentials: params.config.auth, authPolicy: params.globalFlags.authPolicy, + fs: params.globalFlags.fs, terminal: params.globalFlags.terminal, timeoutMs: params.globalFlags.timeout, verbose: params.globalFlags.verbose, @@ -354,6 +355,7 @@ export async function handlePrompt( permissionPolicy, authCredentials: config.auth, authPolicy: globalFlags.authPolicy, + fs: globalFlags.fs, terminal: globalFlags.terminal, outputFormatter, errorEmissionPolicy: { @@ -443,6 +445,7 @@ export async function handleExec( permissionPolicy, authCredentials: config.auth, authPolicy: globalFlags.authPolicy, + fs: globalFlags.fs, terminal: globalFlags.terminal, outputFormatter, errorEmissionPolicy: { @@ -604,6 +607,7 @@ export async function handleSetMode( nonInteractivePermissions: globalFlags.nonInteractivePermissions, authCredentials: config.auth, authPolicy: globalFlags.authPolicy, + fs: globalFlags.fs, terminal: globalFlags.terminal, timeoutMs: globalFlags.timeout, verbose: globalFlags.verbose, @@ -641,6 +645,7 @@ export async function handleSetModel( nonInteractivePermissions: globalFlags.nonInteractivePermissions, authCredentials: config.auth, authPolicy: globalFlags.authPolicy, + fs: globalFlags.fs, terminal: globalFlags.terminal, timeoutMs: globalFlags.timeout, verbose: globalFlags.verbose, @@ -685,6 +690,7 @@ export async function handleSetConfigOption( nonInteractivePermissions: globalFlags.nonInteractivePermissions, authCredentials: config.auth, authPolicy: globalFlags.authPolicy, + fs: globalFlags.fs, terminal: globalFlags.terminal, timeoutMs: globalFlags.timeout, verbose: globalFlags.verbose, @@ -721,6 +727,7 @@ async function tryListAgentSessions( permissionPolicy, authCredentials: config.auth, authPolicy: globalFlags.authPolicy, + fs: globalFlags.fs, terminal: globalFlags.terminal, timeoutMs: globalFlags.timeout, verbose: globalFlags.verbose, diff --git a/src/cli/compare-command.ts b/src/cli/compare-command.ts index dfed8a4e..4555359e 100644 --- a/src/cli/compare-command.ts +++ b/src/cli/compare-command.ts @@ -367,6 +367,7 @@ async function runAgentForCompare(params: { permissionPolicy: params.permissionPolicy, authCredentials: params.config.auth, authPolicy: params.globalFlags.authPolicy, + fs: params.globalFlags.fs, terminal: params.globalFlags.terminal, outputFormatter: formatter, suppressSdkConsoleErrors: true, diff --git a/src/cli/flags.ts b/src/cli/flags.ts index cbf9ba8f..fff7bfbb 100644 --- a/src/cli/flags.ts +++ b/src/cli/flags.ts @@ -39,6 +39,7 @@ export type GlobalFlags = PermissionFlags & { nonInteractivePermissions: NonInteractivePermissionPolicy; jsonStrict?: boolean; suppressReads?: boolean; + fs?: boolean; terminal?: boolean; timeout?: number; ttl: number; @@ -337,6 +338,7 @@ export function addGlobalFlags(command: Command): Command { "--json-strict", "Strict JSON mode: requires --format json and suppresses non-JSON stderr output", ) + .option("--no-fs", "Do not advertise ACP filesystem capabilities") .option("--no-terminal", "Do not advertise ACP terminal capability") .option("--timeout ", "Maximum time to wait for agent response", parseTimeoutSeconds) .option( @@ -419,6 +421,7 @@ export function resolveGlobalFlags(command: Command, config: ResolvedAcpxConfig) permissionPolicy: resolvePermissionPolicyOption(opts), jsonStrict, suppressReads: opts.suppressReads === true, + fs: resolveCapabilityOption(opts.fs), terminal: resolveTerminalOption(opts.terminal), timeout: resolveTimeoutOption(opts.timeout, config), ttl: resolveTtlOption(opts.ttl, config), @@ -465,10 +468,14 @@ function resolvePermissionPolicyOption(opts: Record): string | return primary ?? alias; } -function resolveTerminalOption(value: unknown): boolean | undefined { +function resolveCapabilityOption(value: unknown): boolean | undefined { return value === false ? false : undefined; } +function resolveTerminalOption(value: unknown): boolean | undefined { + return resolveCapabilityOption(value); +} + function resolveTimeoutOption(value: unknown, config: ResolvedAcpxConfig): number | undefined { return numberOption(value) ?? config.timeoutMs; } diff --git a/src/cli/queue/owner-env.ts b/src/cli/queue/owner-env.ts index 161cb311..a00147f8 100644 --- a/src/cli/queue/owner-env.ts +++ b/src/cli/queue/owner-env.ts @@ -82,6 +82,7 @@ function assignQueueOwnerScalarOptions( if (record.authPolicy === "skip" || record.authPolicy === "fail") { options.authPolicy = record.authPolicy; } + assignBooleanOption(options, "fs", record.fs); assignBooleanOption(options, "terminal", record.terminal); assignBooleanOption(options, "suppressSdkConsoleErrors", record.suppressSdkConsoleErrors); assignBooleanOption(options, "verbose", record.verbose); @@ -92,7 +93,7 @@ function assignQueueOwnerScalarOptions( function assignBooleanOption( options: QueueOwnerRuntimeOptions, - key: "terminal" | "suppressSdkConsoleErrors" | "verbose", + key: "fs" | "terminal" | "suppressSdkConsoleErrors" | "verbose", value: unknown, ): void { if (typeof value === "boolean") { diff --git a/src/cli/session/contracts.ts b/src/cli/session/contracts.ts index 6c7cbc14..03417bef 100644 --- a/src/cli/session/contracts.ts +++ b/src/cli/session/contracts.ts @@ -49,6 +49,7 @@ export type RunOnceOptions = { permissionPolicy?: PermissionPolicy; authCredentials?: Record; authPolicy?: AuthPolicy; + fs?: boolean; terminal?: boolean; outputFormatter: OutputFormatter; errorEmissionPolicy?: OutputErrorEmissionPolicy; @@ -74,6 +75,7 @@ export type SessionCreateOptions = { permissionPolicy?: PermissionPolicy; authCredentials?: Record; authPolicy?: AuthPolicy; + fs?: boolean; terminal?: boolean; verbose?: boolean; sessionOptions?: SessionAgentOptions; @@ -92,6 +94,7 @@ export type SessionSendOptions = { permissionPolicy?: PermissionPolicy; authCredentials?: Record; authPolicy?: AuthPolicy; + fs?: boolean; terminal?: boolean; outputFormatter: OutputFormatter; onAcpMessage?: (direction: AcpMessageDirection, message: AcpJsonRpcMessage) => void; @@ -121,6 +124,7 @@ export type SessionEnsureOptions = { permissionPolicy?: PermissionPolicy; authCredentials?: Record; authPolicy?: AuthPolicy; + fs?: boolean; terminal?: boolean; verbose?: boolean; walkBoundary?: string; @@ -140,6 +144,7 @@ export type SessionListOptions = { permissionPolicy?: PermissionPolicy; authCredentials?: Record; authPolicy?: AuthPolicy; + fs?: boolean; terminal?: boolean; verbose?: boolean; } & TimedRunOptions; @@ -163,6 +168,7 @@ export type SessionSetModeOptions = { nonInteractivePermissions?: NonInteractivePermissionPolicy; authCredentials?: Record; authPolicy?: AuthPolicy; + fs?: boolean; terminal?: boolean; verbose?: boolean; } & TimedRunOptions; @@ -174,6 +180,7 @@ export type SessionSetModelOptions = { nonInteractivePermissions?: NonInteractivePermissionPolicy; authCredentials?: Record; authPolicy?: AuthPolicy; + fs?: boolean; terminal?: boolean; verbose?: boolean; } & TimedRunOptions; @@ -186,6 +193,7 @@ export type SessionSetConfigOptionOptions = { nonInteractivePermissions?: NonInteractivePermissionPolicy; authCredentials?: Record; authPolicy?: AuthPolicy; + fs?: boolean; terminal?: boolean; verbose?: boolean; } & TimedRunOptions; diff --git a/src/cli/session/prompt-runner.ts b/src/cli/session/prompt-runner.ts index d0a1e9db..51cb757b 100644 --- a/src/cli/session/prompt-runner.ts +++ b/src/cli/session/prompt-runner.ts @@ -34,6 +34,7 @@ export type RunSessionSetModeDirectOptions = { nonInteractivePermissions?: NonInteractivePermissionPolicy; authCredentials?: Record; authPolicy?: AuthPolicy; + fs?: boolean; terminal?: boolean; timeoutMs?: number; verbose?: boolean; @@ -49,6 +50,7 @@ export type RunSessionSetConfigOptionDirectOptions = { nonInteractivePermissions?: NonInteractivePermissionPolicy; authCredentials?: Record; authPolicy?: AuthPolicy; + fs?: boolean; terminal?: boolean; timeoutMs?: number; verbose?: boolean; @@ -63,6 +65,7 @@ export type RunSessionSetModelDirectOptions = { nonInteractivePermissions?: NonInteractivePermissionPolicy; authCredentials?: Record; authPolicy?: AuthPolicy; + fs?: boolean; terminal?: boolean; timeoutMs?: number; verbose?: boolean; @@ -76,6 +79,7 @@ type DirectConnectedSessionOptions = { nonInteractivePermissions?: NonInteractivePermissionPolicy; authCredentials?: Record; authPolicy?: AuthPolicy; + fs?: boolean; terminal?: boolean; timeoutMs?: number; verbose?: boolean; @@ -95,6 +99,7 @@ function buildDirectConnectedSessionOptions( nonInteractivePermissions: options.nonInteractivePermissions, authCredentials: options.authCredentials, authPolicy: options.authPolicy, + fs: options.fs, terminal: options.terminal, timeoutMs: options.timeoutMs, verbose: options.verbose, diff --git a/src/cli/session/queue-owner-process.ts b/src/cli/session/queue-owner-process.ts index 42c787df..2c848bab 100644 --- a/src/cli/session/queue-owner-process.ts +++ b/src/cli/session/queue-owner-process.ts @@ -22,6 +22,7 @@ export type QueueOwnerRuntimeOptions = { nonInteractivePermissions?: NonInteractivePermissionPolicy; authCredentials?: Record; authPolicy?: AuthPolicy; + fs?: boolean; terminal?: boolean; suppressSdkConsoleErrors?: boolean; verbose?: boolean; @@ -40,6 +41,7 @@ type SessionSendLike = { nonInteractivePermissions?: NonInteractivePermissionPolicy; authCredentials?: Record; authPolicy?: AuthPolicy; + fs?: boolean; terminal?: boolean; suppressSdkConsoleErrors?: boolean; verbose?: boolean; @@ -167,6 +169,7 @@ export function queueOwnerRuntimeOptionsFromSend( nonInteractivePermissions: options.nonInteractivePermissions, authCredentials: options.authCredentials, authPolicy: options.authPolicy, + fs: options.fs, terminal: options.terminal, suppressSdkConsoleErrors: options.suppressSdkConsoleErrors, verbose: options.verbose, diff --git a/src/cli/session/queue-owner-runtime.ts b/src/cli/session/queue-owner-runtime.ts index 980881de..cbcb0f8b 100644 --- a/src/cli/session/queue-owner-runtime.ts +++ b/src/cli/session/queue-owner-runtime.ts @@ -91,6 +91,7 @@ function createQueueOwnerSharedClient( nonInteractivePermissions: options.nonInteractivePermissions, authCredentials: options.authCredentials, authPolicy: options.authPolicy, + fs: options.fs, terminal: options.terminal, suppressSdkConsoleErrors: options.suppressSdkConsoleErrors, verbose: options.verbose, @@ -114,6 +115,7 @@ function createQueueOwnerTurnController( nonInteractivePermissions: options.nonInteractivePermissions, authCredentials: options.authCredentials, authPolicy: options.authPolicy, + fs: options.fs, terminal: options.terminal, timeoutMs, verbose: options.verbose, @@ -127,6 +129,7 @@ function createQueueOwnerTurnController( nonInteractivePermissions: options.nonInteractivePermissions, authCredentials: options.authCredentials, authPolicy: options.authPolicy, + fs: options.fs, terminal: options.terminal, timeoutMs, verbose: options.verbose, @@ -142,6 +145,7 @@ function createQueueOwnerTurnController( nonInteractivePermissions: options.nonInteractivePermissions, authCredentials: options.authCredentials, authPolicy: options.authPolicy, + fs: options.fs, terminal: options.terminal, timeoutMs, verbose: options.verbose, diff --git a/src/cli/session/runtime.ts b/src/cli/session/runtime.ts index c6d0a66a..e03340e2 100644 --- a/src/cli/session/runtime.ts +++ b/src/cli/session/runtime.ts @@ -767,6 +767,7 @@ async function runSessionPrompt(options: RunSessionPromptOptions): Promise permissionPolicy: options.permissionPolicy, authCredentials: options.authCredentials, authPolicy: options.authPolicy, + fs: options.fs, terminal: options.terminal, suppressSdkConsoleErrors: options.suppressSdkConsoleErrors, verbose: options.verbose, @@ -1225,6 +1228,7 @@ export async function sendSessionDirect(options: SessionSendOptions): Promise { const pending = this.store @@ -1221,6 +1225,7 @@ export class FlowRunner { permissionPolicy: this.permissionPolicy, authCredentials: this.authCredentials, authPolicy: this.authPolicy, + fs: this.fs, outputFormatter: capture.formatter, onAcpMessage: (direction, message) => { const pending = this.store diff --git a/src/flows/types.ts b/src/flows/types.ts index d751b903..54079552 100644 --- a/src/flows/types.ts +++ b/src/flows/types.ts @@ -348,6 +348,7 @@ export type FlowRunnerOptions = { permissionPolicy?: PermissionPolicy; authCredentials?: Record; authPolicy?: AuthPolicy; + fs?: boolean; timeoutMs?: number; defaultNodeTimeoutMs?: number; ttlMs?: number; diff --git a/src/runtime/engine/connected-session.ts b/src/runtime/engine/connected-session.ts index b1dcf439..9aeb48af 100644 --- a/src/runtime/engine/connected-session.ts +++ b/src/runtime/engine/connected-session.ts @@ -49,6 +49,7 @@ export type WithConnectedSessionOptions = { ) => Promise; authCredentials?: Record; authPolicy?: AuthPolicy; + fs?: boolean; terminal?: boolean; resumePolicy?: SessionResumePolicy; timeoutMs?: number; @@ -106,6 +107,7 @@ export async function withConnectedSession( onPermissionRequest: options.onPermissionRequest, authCredentials: options.authCredentials, authPolicy: options.authPolicy, + fs: options.fs, terminal: options.terminal, verbose: options.verbose, sessionOptions: sessionOptionsFromRecord(record), @@ -120,6 +122,7 @@ export async function withConnectedSession( onPermissionRequest: options.onPermissionRequest, authCredentials: options.authCredentials, authPolicy: options.authPolicy, + fs: options.fs, terminal: options.terminal, verbose: options.verbose, sessionOptions: sessionOptionsFromRecord(record), diff --git a/src/types.ts b/src/types.ts index bf6d7a5d..40e8edca 100644 --- a/src/types.ts +++ b/src/types.ts @@ -210,6 +210,7 @@ export type AcpClientOptions = { permissionPolicy?: PermissionPolicy; authCredentials?: Record; authPolicy?: AuthPolicy; + fs?: boolean; terminal?: boolean; suppressSdkConsoleErrors?: boolean; verbose?: boolean; diff --git a/test/cli-flags.test.ts b/test/cli-flags.test.ts index e08fb33c..f97401ed 100644 --- a/test/cli-flags.test.ts +++ b/test/cli-flags.test.ts @@ -181,6 +181,7 @@ test("resolveGlobalFlags validates and normalizes dynamic Commander options", () permissionPolicy: '{"defaultAction":"deny"}', jsonStrict: true, suppressReads: true, + fs: false, terminal: false, timeout: 12_000, ttl: 34_000, @@ -204,6 +205,7 @@ test("resolveGlobalFlags validates and normalizes dynamic Commander options", () permissionPolicy: '{"defaultAction":"deny"}', jsonStrict: true, suppressReads: true, + fs: false, terminal: false, timeout: 12_000, ttl: 34_000, @@ -361,6 +363,7 @@ test("global flag registration parses each supported option", () => { "--prompt-retries", "2", "--json-strict", + "--no-fs", "--no-terminal", "--timeout", "1.5", @@ -384,6 +387,7 @@ test("global flag registration parses each supported option", () => { systemPrompt: "be precise", promptRetries: 2, jsonStrict: true, + fs: false, terminal: false, timeout: 1500, ttl: 0, diff --git a/test/cli.test.ts b/test/cli.test.ts index a1c04656..3672f35d 100644 --- a/test/cli.test.ts +++ b/test/cli.test.ts @@ -306,6 +306,7 @@ test("CLI resolves unknown raw agent commands after newer global flags", async ( ["--system-prompt", "be precise"], ["--append-system-prompt", "be concise"], ["--prompt-retries", "1"], + ["--no-fs"], ["--no-terminal"], ]; @@ -330,6 +331,7 @@ test("global passthrough flags are present in help output", async () => { assert.match(result.stdout, /--max-turns /); assert.match(result.stdout, /text, json, quiet/); assert.match(result.stdout, /--suppress-reads/); + assert.match(result.stdout, /--no-fs/); assert.match(result.stdout, /--no-terminal/); }); }); diff --git a/test/integration.test.ts b/test/integration.test.ts index 4c2e8eaa..f765c93a 100644 --- a/test/integration.test.ts +++ b/test/integration.test.ts @@ -103,6 +103,65 @@ test("integration: built-in cursor agent resolves to cursor-agent acp", async () }); }); +test("integration: flow run --no-fs disables advertised filesystem capabilities", async () => { + await withTempHome(async (homeDir) => { + const cwd = await fs.mkdtemp(path.join(os.tmpdir(), "acpx-integration-cwd-")); + + try { + const result = await runCli( + [ + ...baseLoadCapableAgentArgs(cwd), + "--format", + "json", + "--no-fs", + "flow", + "run", + FLOW_FIXTURE_PATH, + "--input-json", + JSON.stringify({ next: "yes_path" }), + ], + homeDir, + ); + + assert.equal(result.code, 0, result.stderr); + const payload = JSON.parse(result.stdout.trim()) as { runDir?: string }; + assert.equal(typeof payload.runDir, "string", result.stdout); + + const manifest = JSON.parse( + await fs.readFile(path.join(payload.runDir ?? "", "manifest.json"), "utf8"), + ) as { sessions?: Array<{ eventsPath?: string }> }; + const eventsPath = manifest.sessions?.[0]?.eventsPath; + assert.equal(typeof eventsPath, "string"); + + const events = (await fs.readFile(path.join(payload.runDir ?? "", eventsPath ?? ""), "utf8")) + .trim() + .split("\n") + .map( + (line) => + JSON.parse(line) as { + message?: { + method?: string; + params?: { + clientCapabilities?: { + fs?: { readTextFile?: unknown; writeTextFile?: unknown }; + }; + }; + }; + }, + ); + const initializeRequest = events.find((event) => event.message?.method === "initialize"); + + assert(initializeRequest, JSON.stringify(events, null, 2)); + assert.deepEqual(initializeRequest.message?.params?.clientCapabilities?.fs, { + readTextFile: false, + writeTextFile: false, + }); + } finally { + await fs.rm(cwd, { recursive: true, force: true }); + } + }); +}); + test("integration: flow run executes multiple ACP steps in one session and branches", async () => { await withTempHome(async (homeDir) => { const cwd = await fs.mkdtemp(path.join(os.tmpdir(), "acpx-integration-cwd-")); @@ -1085,6 +1144,38 @@ test("integration: exec --no-terminal disables advertised terminal capability", }); }); +test("integration: exec --no-fs disables advertised filesystem capabilities", async () => { + await withTempHome(async (homeDir) => { + const cwd = await fs.mkdtemp(path.join(os.tmpdir(), "acpx-integration-cwd-")); + + try { + const result = await runCli( + [...baseAgentArgs(cwd), "--format", "json", "--no-fs", "exec", "echo hello"], + homeDir, + ); + assert.equal(result.code, 0, result.stderr); + + const payloads = parseJsonRpcOutputLines(result.stdout); + const initializeRequest = payloads.find((payload) => payload.method === "initialize") as + | { + params?: { + clientCapabilities?: { + fs?: { readTextFile?: unknown; writeTextFile?: unknown }; + }; + }; + } + | undefined; + assert(initializeRequest, result.stdout); + assert.deepEqual(initializeRequest.params?.clientCapabilities?.fs, { + readTextFile: false, + writeTextFile: false, + }); + } finally { + await fs.rm(cwd, { recursive: true, force: true }); + } + }); +}); + test("integration: non-Devin ACP launch advertises standard acpx client capabilities", async () => { await withTempHome(async (homeDir) => { const cwd = await fs.mkdtemp(path.join(os.tmpdir(), "acpx-integration-cwd-")); diff --git a/test/queue-owner-env.test.ts b/test/queue-owner-env.test.ts index da4c6725..ac8a5b5d 100644 --- a/test/queue-owner-env.test.ts +++ b/test/queue-owner-env.test.ts @@ -29,6 +29,7 @@ describe("parseQueueOwnerPayload", () => { ], ttlMs: 1234, maxQueueDepth: 7, + fs: false, terminal: false, sessionOptions: { model: "fast-model", @@ -47,6 +48,7 @@ describe("parseQueueOwnerPayload", () => { assert.equal(parsed.mcpConfigFingerprint, "fingerprint-v1"); assert.equal(parsed.ttlMs, 1234); assert.equal(parsed.maxQueueDepth, 7); + assert.equal(parsed.fs, false); assert.equal(parsed.terminal, false); assert.deepEqual(parsed.mcpServers, [ { diff --git a/test/queue-owner-process.test.ts b/test/queue-owner-process.test.ts index 9207b783..bc769acb 100644 --- a/test/queue-owner-process.test.ts +++ b/test/queue-owner-process.test.ts @@ -148,13 +148,15 @@ describe("writeQueueOwnerPayloadFile", () => { }); describe("queueOwnerRuntimeOptionsFromSend", () => { - it("preserves terminal capability preference", () => { + it("preserves client capability preferences", () => { const options = queueOwnerRuntimeOptionsFromSend({ sessionId: "session-1", permissionMode: "approve-reads", + fs: false, terminal: false, }); + assert.equal(options.fs, false); assert.equal(options.terminal, false); }); }); From 865709b3d7963fe6540167e02dac0a32cdbf662e Mon Sep 17 00:00:00 2001 From: dan-roberts-poolside Date: Mon, 27 Jul 2026 15:48:14 +0100 Subject: [PATCH 13/57] feat: add Pool ACP agent * feat: add Pool ACP agent Register Poolside as a built-in agent via `pool acp`. * fix: add structured Pool agent launch --------- Co-authored-by: Onur Solmaz <2453968+osolmaz@users.noreply.github.com> --- README.md | 1 + agents/Pool.md | 27 ++++++++++++++ agents/README.md | 2 ++ docs/2026-02-17-agent-registry.md | 1 + docs/agents.md | 9 +++++ skills/acpx/SKILL.md | 1 + src/agent-registry.ts | 2 ++ test/agent-registry.test.ts | 7 ++++ test/integration.test.ts | 60 +++++++++++++++++++++++++++++++ 9 files changed, 110 insertions(+) create mode 100644 agents/Pool.md diff --git a/README.md b/README.md index 65a8c238..1dcb0863 100644 --- a/README.md +++ b/README.md @@ -369,6 +369,7 @@ Built-ins: | `kiro` | native (`kiro-cli-chat acp`) | [Kiro CLI](https://kiro.dev) | | `mux` | `mux acp` via an ACPX-owned npm range | [Mux](https://mux.coder.com) | | `opencode` | `npx -y opencode-ai acp` | [OpenCode](https://opencode.ai) | +| `pool` | native (`pool acp`) | [Poolside](https://poolside.ai) | | `qoder` | native (`qodercli --acp`) | [Qoder CLI](https://docs.qoder.com/cli/acp) | | `qwen` | native (`qwen --acp`) | [Qwen Code](https://github.com/QwenLM/qwen-code) | | `trae` | native (`traecli acp serve`) | [Trae CLI](https://docs.trae.cn/cli) | diff --git a/agents/Pool.md b/agents/Pool.md new file mode 100644 index 00000000..4718be15 --- /dev/null +++ b/agents/Pool.md @@ -0,0 +1,27 @@ +# Pool + +- Built-in name: `pool` +- Default command: `pool acp` +- Upstream: [Poolside pool](https://github.com/poolsideai/pool) + +`acpx pool` launches the installed `pool` CLI through its ACP stdio entrypoint. Install `pool` using the [official instructions](https://github.com/poolsideai/pool#install), then run `pool login` before using it through `acpx`. Poolside stores its configuration and credentials under `~/.config/poolside/`. + +Examples: + +```bash +acpx pool sessions new +acpx pool 'review this branch' +acpx pool exec 'summarize this repository' +``` + +If your Poolside install exposes ACP through a different command, override the built-in in `~/.acpx/config.json`: + +```json +{ + "agents": { + "pool": { + "argv": ["pool", "acp"] + } + } +} +``` diff --git a/agents/README.md b/agents/README.md index d834b9ae..7e34595c 100644 --- a/agents/README.md +++ b/agents/README.md @@ -18,6 +18,7 @@ Built-in agents: - `kiro -> kiro-cli-chat acp` - `mux -> mux acp` via an ACPX-owned npm range - `opencode -> npx -y opencode-ai acp` +- `pool -> pool acp` - `qoder -> qodercli --acp` - `qwen -> qwen --acp` - `trae -> traecli acp serve` @@ -38,6 +39,7 @@ Harness-specific docs in this directory: - [Kiro](Kiro.md): built-in `kiro -> kiro-cli-chat acp` - [Mux](Mux.md): built-in `mux -> mux acp` via an ACPX-owned npm range - [OpenCode](OpenCode.md): built-in `opencode -> npx -y opencode-ai acp` +- [Pool](Pool.md): built-in `pool -> pool acp` - [Qoder](Qoder.md): built-in `qoder -> qodercli --acp` - [Qwen](Qwen.md): built-in `qwen -> qwen --acp` - [Trae](Trae.md): built-in `trae -> traecli acp serve` diff --git a/docs/2026-02-17-agent-registry.md b/docs/2026-02-17-agent-registry.md index 5dec90fd..6fbb9d54 100644 --- a/docs/2026-02-17-agent-registry.md +++ b/docs/2026-02-17-agent-registry.md @@ -14,6 +14,7 @@ date: 2026-02-17 - `codex -> npx @zed-industries/codex-acp` - `claude -> npx -y @agentclientprotocol/claude-agent-acp` - `grok-build -> grok agent stdio` +- `pool -> pool acp` The built-in agents table lives in [../README.md](../README.md). Additional built-in agent docs live under [../agents/README.md](../agents/README.md). diff --git a/docs/agents.md b/docs/agents.md index 2cae34ac..e0784231 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -27,6 +27,7 @@ The default agent for top-level commands like `acpx exec …` and `acpx prompt | `kiro` | `kiro-cli-chat acp` | [Kiro CLI](https://kiro.dev) | | `mux` | `mux acp` via an ACPX-owned npm range | [Mux](https://mux.coder.com) | | `opencode` | `npx -y opencode-ai acp` | [OpenCode](https://opencode.ai) | +| `pool` | `pool acp` | [Poolside](https://poolside.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) | @@ -192,6 +193,14 @@ Configure at least one model provider before prompting (for example `ANTHROPIC_A - Default command: `npx -y opencode-ai acp` - Upstream: [opencode.ai](https://opencode.ai) +### Pool + +- Built-in name: `pool` +- Default command: `pool acp` +- Upstream: [Poolside](https://poolside.ai) + +`acpx pool` uses the installed `pool` CLI ACP server (`pool acp`). Install `pool` and complete its authentication flow with `pool login` before using it through `acpx`; credentials are stored under `~/.config/poolside/`. + ### Qwen - Built-in name: `qwen` diff --git a/skills/acpx/SKILL.md b/skills/acpx/SKILL.md index d9c5c634..e2a240ae 100644 --- a/skills/acpx/SKILL.md +++ b/skills/acpx/SKILL.md @@ -97,6 +97,7 @@ Friendly agent names resolve to commands: - `kiro` -> `kiro-cli-chat acp` - `mux` -> `mux acp` via an ACPX-owned npm range - `opencode` -> `npx -y opencode-ai acp` +- `pool` -> `pool acp` - `qoder` -> `qodercli --acp` Forwards Qoder-native `--allowed-tools` and `--max-turns` startup flags from `acpx` session options. - `qwen` -> `qwen --acp` diff --git a/src/agent-registry.ts b/src/agent-registry.ts index 1e20fe43..1269e080 100644 --- a/src/agent-registry.ts +++ b/src/agent-registry.ts @@ -53,6 +53,7 @@ export const AGENT_REGISTRY: Record = { kiro: "kiro-cli-chat acp", mux: `npx -y mux@${ACP_ADAPTER_PACKAGE_RANGES.mux} acp`, opencode: "npx -y opencode-ai acp", + pool: "pool acp", qoder: "qodercli --acp", qwen: "qwen --acp", trae: "traecli acp serve", @@ -79,6 +80,7 @@ export const AGENT_ARGV_REGISTRY: Record = { kiro: ["kiro-cli-chat", "acp"], mux: ["npx", "-y", `mux@${ACP_ADAPTER_PACKAGE_RANGES.mux}`, "acp"], opencode: ["npx", "-y", "opencode-ai", "acp"], + pool: ["pool", "acp"], qoder: ["qodercli", "--acp"], qwen: ["qwen", "--acp"], trae: ["traecli", "acp", "serve"], diff --git a/test/agent-registry.test.ts b/test/agent-registry.test.ts index 6db29b8d..44bc33e5 100644 --- a/test/agent-registry.test.ts +++ b/test/agent-registry.test.ts @@ -74,6 +74,12 @@ test("mux built-in runs the coder/mux ACP stdio bridge through npx", () => { assert.equal(resolveAgentCommand("mux"), "npx -y mux@^0.28.0 acp"); }); +test("pool built-in runs the Poolside ACP entrypoint", () => { + assert.equal(AGENT_REGISTRY.pool, "pool acp"); + assert.deepEqual(AGENT_ARGV_REGISTRY.pool, ["pool", "acp"]); + assert.equal(resolveAgentCommand("pool"), "pool acp"); +}); + test("listBuiltInAgents preserves the required example prefix and alphabetical tail", () => { const agents = listBuiltInAgents(); assert.deepEqual(agents, Object.keys(AGENT_REGISTRY)); @@ -96,6 +102,7 @@ test("listBuiltInAgents preserves the required example prefix and alphabetical t "kiro", "mux", "opencode", + "pool", "qoder", "qwen", "trae", diff --git a/test/integration.test.ts b/test/integration.test.ts index f765c93a..da639205 100644 --- a/test/integration.test.ts +++ b/test/integration.test.ts @@ -934,6 +934,33 @@ test("integration: built-in grok-build agent resolves to grok agent stdio", asyn }); }); +test("integration: built-in pool agent resolves to pool acp", async () => { + await withTempHome(async (homeDir) => { + const cwd = await fs.mkdtemp(path.join(os.tmpdir(), "acpx-integration-cwd-")); + const fakeBinDir = await fs.mkdtemp(path.join(os.tmpdir(), "acpx-fake-pool-")); + + try { + await writeFakePoolAgent(fakeBinDir); + + const result = await runCli( + ["--approve-all", "--cwd", cwd, "--format", "quiet", "pool", "exec", "echo hello"], + homeDir, + { + env: { + PATH: `${fakeBinDir}${path.delimiter}${process.env.PATH ?? ""}`, + }, + }, + ); + + assert.equal(result.code, 0, result.stderr); + assert.match(result.stdout, /hello/); + } finally { + await fs.rm(fakeBinDir, { recursive: true, force: true }); + await fs.rm(cwd, { recursive: true, force: true }); + } + }); +}); + test("integration: built-in iflow agent resolves to iflow --experimental-acp", async () => { await withTempHome(async (homeDir) => { const cwd = await fs.mkdtemp(path.join(os.tmpdir(), "acpx-integration-cwd-")); @@ -4646,6 +4673,39 @@ async function writeFakeGrokBuildAgent(binDir: string): Promise { ); } +async function writeFakePoolAgent(binDir: string): Promise { + if (process.platform === "win32") { + await fs.writeFile( + path.join(binDir, "pool.cmd"), + [ + "@echo off", + "setlocal", + 'if not "%~1"=="acp" exit /b 2', + `"${process.execPath}" "${MOCK_AGENT_PATH}" %2 %3 %4 %5 %6 %7 %8 %9`, + "", + ].join("\r\n"), + { encoding: "utf8" }, + ); + return; + } + + await fs.writeFile( + path.join(binDir, "pool"), + [ + "#!/bin/sh", + 'if [ "$1" = "acp" ]; then', + " shift", + "else", + ' echo "unexpected pool command: $*" 1>&2', + " exit 2", + "fi", + `exec "${process.execPath}" "${MOCK_AGENT_PATH}" "$@"`, + "", + ].join("\n"), + { encoding: "utf8", mode: 0o755 }, + ); +} + async function writeFakeQoderAgent(binDir: string, argLogPath?: string): Promise { if (process.platform === "win32") { await fs.writeFile( From 69c20c2bb179cf5ced4376eac1c18bdefd580f91 Mon Sep 17 00:00:00 2001 From: mehmetali <36207866+realmehmetali@users.noreply.github.com> Date: Mon, 27 Jul 2026 07:57:03 -0700 Subject: [PATCH 14/57] fix(cli): preserve positive timer durations (#459) * fix(cli): preserve positive timer durations * fix(cli): share timer conversion with config (#459) Co-authored-by: mehmetali <36207866+realmehmetali@users.noreply.github.com> --------- Co-authored-by: Peter Steinberger --- CHANGELOG.md | 2 ++ src/cli/config.ts | 17 ++++++++++++++--- src/cli/flags.ts | 13 +++++++++++-- src/cli/timer-duration.ts | 9 +++++++++ test/cli-flags.test.ts | 4 ++++ test/config.test.ts | 24 +++++++++++++++++++++++- test/integration.test.ts | 15 ++++++++++++--- 7 files changed, 75 insertions(+), 9 deletions(-) create mode 100644 src/cli/timer-duration.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index ac6d4aa1..159f5161 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,8 @@ Repo: https://github.com/openclaw/acpx - CLI/ACP: add `--no-fs` to disable advertised ACP file read/write capabilities so compatible agents can use their native filesystem implementation. Thanks @zgxkbtl. +- CLI/timers: preserve tiny positive timeout and TTL values from flags or config as 1 ms instead of disabling timers, and reject delays beyond Node's supported timer range. Thanks @realmehmetali. + ### Breaking - Windows agent launches now require structured `agents..argv`; unambiguous legacy `command` plus `args` entries migrate automatically, while raw, ambiguous, or directly executable `.sh` commands fail with explicit migration guidance instead of lossy parsing or CreateProcess ENOENT. Existing custom-agent sessions without saved argv must be recreated. Fixes #466. Thanks @MarcelCFritsche. diff --git a/src/cli/config.ts b/src/cli/config.ts index 846beb54..b951e59d 100644 --- a/src/cli/config.ts +++ b/src/cli/config.ts @@ -12,6 +12,7 @@ import type { OutputFormat, PermissionMode, } from "../types.js"; +import { toTimerMilliseconds } from "./timer-duration.js"; export type ResolvedAgentConfig = { command: string; @@ -109,7 +110,11 @@ function parseTtlMs(value: unknown, sourcePath: string): number | undefined { if (typeof value !== "number" || !Number.isFinite(value) || value < 0) { throw new Error(`Invalid config ttl in ${sourcePath}: expected non-negative seconds`); } - return Math.round(value * 1_000); + const milliseconds = toTimerMilliseconds(value, true); + if (milliseconds === undefined) { + throw new Error(`Invalid config ttl in ${sourcePath}: exceeds maximum supported timer delay`); + } + return milliseconds; } function parseTimeoutMs(value: unknown, sourcePath: string): number | undefined { @@ -119,7 +124,13 @@ function parseTimeoutMs(value: unknown, sourcePath: string): number | undefined if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) { throw new Error(`Invalid config timeout in ${sourcePath}: expected positive seconds or null`); } - return Math.round(value * 1_000); + const milliseconds = toTimerMilliseconds(value, false); + if (milliseconds === undefined) { + throw new Error( + `Invalid config timeout in ${sourcePath}: exceeds maximum supported timer delay`, + ); + } + return milliseconds; } function parseQueueMaxDepth(value: unknown, sourcePath: string): number | undefined { @@ -669,7 +680,7 @@ export function toConfigDisplay(config: ResolvedAcpxConfig): { defaultPermissions: config.defaultPermissions, nonInteractivePermissions: config.nonInteractivePermissions, authPolicy: config.authPolicy, - ttl: Math.round(config.ttlMs / 1_000), + ttl: config.ttlMs / 1_000, timeout: config.timeoutMs == null ? null : config.timeoutMs / 1_000, queueMaxDepth: config.queueMaxDepth, format: config.format, diff --git a/src/cli/flags.ts b/src/cli/flags.ts index fff7bfbb..d8b2ac6e 100644 --- a/src/cli/flags.ts +++ b/src/cli/flags.ts @@ -21,6 +21,7 @@ import { type PermissionMode, } from "../types.js"; import type { ResolvedAcpxConfig } from "./config.js"; +import { toTimerMilliseconds } from "./timer-duration.js"; export type PermissionFlags = { approveAll?: boolean; @@ -159,7 +160,11 @@ export function parseTimeoutSeconds(value: string): number { if (!Number.isFinite(parsed) || parsed <= 0) { throw new InvalidArgumentError("Timeout must be a positive number of seconds"); } - return Math.round(parsed * 1000); + const milliseconds = toTimerMilliseconds(parsed, false); + if (milliseconds === undefined) { + throw new InvalidArgumentError("Timeout exceeds the maximum supported timer delay"); + } + return milliseconds; } export function parseTtlSeconds(value: string): number { @@ -167,7 +172,11 @@ export function parseTtlSeconds(value: string): number { if (!Number.isFinite(parsed) || parsed < 0) { throw new InvalidArgumentError("TTL must be a non-negative number of seconds"); } - return Math.round(parsed * 1000); + const milliseconds = toTimerMilliseconds(parsed, true); + if (milliseconds === undefined) { + throw new InvalidArgumentError("TTL exceeds the maximum supported timer delay"); + } + return milliseconds; } export function parseSessionName(value: string): string { diff --git a/src/cli/timer-duration.ts b/src/cli/timer-duration.ts new file mode 100644 index 00000000..556ba26c --- /dev/null +++ b/src/cli/timer-duration.ts @@ -0,0 +1,9 @@ +export const MAX_TIMER_DELAY_MS = 2_147_483_647; + +export function toTimerMilliseconds(seconds: number, allowZero: boolean): number | undefined { + if (allowZero && seconds === 0) { + return 0; + } + const milliseconds = Math.max(1, Math.round(seconds * 1000)); + return milliseconds <= MAX_TIMER_DELAY_MS ? milliseconds : undefined; +} diff --git a/test/cli-flags.test.ts b/test/cli-flags.test.ts index f97401ed..c17252ac 100644 --- a/test/cli-flags.test.ts +++ b/test/cli-flags.test.ts @@ -121,12 +121,16 @@ test("flag parsers reject invalid enum values with actionable messages", () => { test("numeric flag parsers reject non-finite and out-of-range values", () => { assert.equal(parseTimeoutSeconds("1.5"), 1500); + assert.equal(parseTimeoutSeconds("0.0001"), 1); assert.throws(() => parseTimeoutSeconds("0"), /positive number/); assert.throws(() => parseTimeoutSeconds("abc"), /positive number/); + assert.throws(() => parseTimeoutSeconds("2147483.648"), /maximum supported timer delay/); assert.equal(parseTtlSeconds("0"), 0); + assert.equal(parseTtlSeconds("0.0001"), 1); assert.equal(parseTtlSeconds("2.25"), 2250); assert.throws(() => parseTtlSeconds("-1"), /non-negative/); + assert.throws(() => parseTtlSeconds("2147483.648"), /maximum supported timer delay/); assert.equal(parseMaxTurns("2"), 2); assert.throws(() => parseMaxTurns("0"), /positive integer/); diff --git a/test/config.test.ts b/test/config.test.ts index e83246c9..a23c08b1 100644 --- a/test/config.test.ts +++ b/test/config.test.ts @@ -4,7 +4,7 @@ import os from "node:os"; import path from "node:path"; import test from "node:test"; import { resolveAgentCommandParts, splitCommandLine } from "../src/acp/client-process.js"; -import { initGlobalConfigFile, loadResolvedConfig } from "../src/cli/config.js"; +import { initGlobalConfigFile, loadResolvedConfig, toConfigDisplay } from "../src/cli/config.js"; test("loadResolvedConfig merges global and project config with project priority", async () => { await withTempEnv(async ({ homeDir }) => { @@ -110,6 +110,28 @@ test("loadResolvedConfig merges global and project config with project priority" }); }); +test("loadResolvedConfig normalizes timer values through the CLI timer boundary", async () => { + await withTempEnv(async ({ homeDir }) => { + const cwd = path.join(homeDir, "workspace"); + const configPath = path.join(homeDir, ".acpx", "config.json"); + await fs.mkdir(cwd, { recursive: true }); + await fs.mkdir(path.dirname(configPath), { recursive: true }); + + await fs.writeFile(configPath, `${JSON.stringify({ ttl: 0.0001, timeout: 0.0001 })}\n`); + const config = await loadResolvedConfig(cwd); + assert.equal(config.ttlMs, 1); + assert.equal(config.timeoutMs, 1); + assert.equal(toConfigDisplay(config).ttl, 0.001); + assert.equal(toConfigDisplay(config).timeout, 0.001); + + await fs.writeFile(configPath, `${JSON.stringify({ ttl: 2_147_483.648 })}\n`); + await assert.rejects(loadResolvedConfig(cwd), /ttl.*maximum supported timer delay/u); + + await fs.writeFile(configPath, `${JSON.stringify({ timeout: 2_147_483.648 })}\n`); + await assert.rejects(loadResolvedConfig(cwd), /timeout.*maximum supported timer delay/u); + }); +}); + test("loadResolvedConfig rejects invalid mcpServers config", async () => { await withTempEnv(async ({ homeDir }) => { const cwd = path.join(homeDir, "workspace"); diff --git a/test/integration.test.ts b/test/integration.test.ts index da639205..e95b95b4 100644 --- a/test/integration.test.ts +++ b/test/integration.test.ts @@ -4359,15 +4359,24 @@ test("integration: session remains resumable after queue owner exits and agent h const sessionId = createdPayload.acpxRecordId; assert.equal(typeof sessionId, "string"); - // 2. Send a prompt with a very short TTL so the queue owner exits quickly + // 2. Use a positive sub-millisecond TTL. It must floor to 1 ms rather than + // round to the zero sentinel, which would keep the owner alive forever. const prompt = await runCli( - [...baseAgentArgs(cwd), "--format", "quiet", "--ttl", "1", "prompt", "echo oneshot-done"], + [ + ...baseAgentArgs(cwd), + "--format", + "quiet", + "--ttl", + "0.0001", + "prompt", + "echo oneshot-done", + ], homeDir, ); assert.equal(prompt.code, 0, prompt.stderr); assert.match(prompt.stdout, /oneshot-done/); - // 3. Wait for the queue owner to exit (it should exit after 1s TTL) + // 3. Wait for the queue owner to exit after its 1 ms floored TTL. const { lockPath } = queuePaths(homeDir, sessionId as string); let ownerPid: number | undefined; try { From ac8753bf7f0975d78df349cb0f1417c6631ecbb8 Mon Sep 17 00:00:00 2001 From: guettlibot Date: Mon, 27 Jul 2026 17:18:41 +0200 Subject: [PATCH 15/57] fix(cli): report a cold-start session as "agent starting", not "needs reconnect" (#444) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(cli): distinguish starting and reconnect states (#444) Co-authored-by: Thomas Güttler * fix(cli): preserve reconnect status for live leases (#444) --------- Co-authored-by: Peter Steinberger Co-authored-by: Thomas Güttler --- CHANGELOG.md | 2 ++ src/cli/output/render.ts | 16 +++++++++++++--- test/cli.test.ts | 29 ++++++++++++++++++++++++++--- 3 files changed, 41 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 159f5161..2746b1ae 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,8 @@ Repo: https://github.com/openclaw/acpx - Runtime/sessions: use collision-resistant temporary paths for concurrent atomic session and index writes. Thanks @henkterharmsel. +- CLI/status: report a normal cold-start session as `agent starting` while preserving `needs reconnect` for an unreachable live owner. Thanks @guettli. + ## 2026.7.23 (v0.12.1) ### Changes diff --git a/src/cli/output/render.ts b/src/cli/output/render.ts index 5a3c8776..78e26929 100644 --- a/src/cli/output/render.ts +++ b/src/cli/output/render.ts @@ -16,13 +16,23 @@ function formatRoutedFrom(sessionCwd: string, currentCwd: string): string | unde return relative.startsWith(".") ? relative : `.${path.sep}${relative}`; } -type SessionConnectionStatus = "connected" | "needs reconnect"; +type SessionConnectionStatus = "connected" | "starting" | "needs reconnect"; + +export function classifySessionConnectionStatus(health: { + healthy: boolean; + hasLease: boolean; +}): SessionConnectionStatus { + if (health.healthy) { + return "connected"; + } + return health.hasLease ? "needs reconnect" : "starting"; +} async function resolveSessionConnectionStatus( record: SessionRecord, ): Promise { const health = await probeQueueOwnerHealth(record.acpxRecordId); - return health.healthy ? "connected" : "needs reconnect"; + return classifySessionConnectionStatus(health); } export function printSessionsByFormat(sessions: SessionRecord[], format: OutputFormat): void { @@ -203,7 +213,7 @@ export function printQueuedPromptByFormat( export function formatPromptSessionBannerLine( record: SessionRecord, currentCwd: string, - connectionStatus: SessionConnectionStatus = "needs reconnect", + connectionStatus: SessionConnectionStatus = "starting", ): string { const label = formatSessionLabel(record); const normalizedSessionCwd = path.resolve(record.cwd); diff --git a/test/cli.test.ts b/test/cli.test.ts index 3672f35d..16092748 100644 --- a/test/cli.test.ts +++ b/test/cli.test.ts @@ -15,6 +15,7 @@ import { parseMaxTurns, parseTtlSeconds, } from "../src/cli.js"; +import { classifySessionConnectionStatus } from "../src/cli/output/render.js"; import { serializeSessionRecordForDisk } from "../src/session/persistence.js"; import type { SessionRecord } from "../src/types.js"; import { @@ -235,9 +236,31 @@ test("formatPromptSessionBannerLine prints single-line prompt banner for matchin }); const line = formatPromptSessionBannerLine(record, "/home/user/project"); + assert.equal(line, "[acpx] session calm-forest (abc123) · /home/user/project · agent starting"); +}); + +test("formatPromptSessionBannerLine reports a live queue owner as connected", () => { + const record = makeSessionRecord({ + acpxRecordId: "abc123", + acpSessionId: "abc123", + agentCommand: "agent-a", + cwd: "/home/user/project", + name: "calm-forest", + createdAt: "2026-01-01T00:00:00.000Z", + lastUsedAt: "2026-01-01T00:00:00.000Z", + closed: false, + }); + + const line = formatPromptSessionBannerLine(record, "/home/user/project", "connected"); + assert.equal(line, "[acpx] session calm-forest (abc123) · /home/user/project · agent connected"); +}); + +test("session banner status distinguishes cold start from an unreachable owner", () => { + assert.equal(classifySessionConnectionStatus({ healthy: true, hasLease: true }), "connected"); + assert.equal(classifySessionConnectionStatus({ healthy: false, hasLease: false }), "starting"); assert.equal( - line, - "[acpx] session calm-forest (abc123) · /home/user/project · agent needs reconnect", + classifySessionConnectionStatus({ healthy: false, hasLease: true }), + "needs reconnect", ); }); @@ -256,7 +279,7 @@ test("formatPromptSessionBannerLine includes routed-from path when cwd differs", const line = formatPromptSessionBannerLine(record, "/home/user/project/src/auth"); assert.equal( line, - "[acpx] session calm-forest (abc123) · /home/user/project (routed from ./src/auth) · agent needs reconnect", + "[acpx] session calm-forest (abc123) · /home/user/project (routed from ./src/auth) · agent starting", ); }); From b34d7be64799d9c83c426877c5f5cd91078678d8 Mon Sep 17 00:00:00 2001 From: JordanTheJet Date: Mon, 27 Jul 2026 11:49:11 -0400 Subject: [PATCH 16/57] feat: add ZeroClaw ACP agent (#440) * feat: add ZeroClaw ACP agent (#440) Co-authored-by: jordanthejet * fix(agents): register ZeroClaw structured argv (#440) --------- Co-authored-by: Peter Steinberger --- CHANGELOG.md | 2 ++ README.md | 1 + agents/README.md | 2 ++ agents/ZeroClaw.md | 57 +++++++++++++++++++++++++++++++++++ docs/agents.md | 10 +++++++ skills/acpx/SKILL.md | 1 + src/agent-registry.ts | 2 ++ test/agent-registry.test.ts | 7 +++++ test/integration.test.ts | 60 +++++++++++++++++++++++++++++++++++++ 9 files changed, 142 insertions(+) create mode 100644 agents/ZeroClaw.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 2746b1ae..973ffd23 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,8 @@ Repo: https://github.com/openclaw/acpx - CLI/timers: preserve tiny positive timeout and TTL values from flags or config as 1 ms instead of disabling timers, and reject delays beyond Node's supported timer range. Thanks @realmehmetali. +- Agents/built-ins: add ZeroClaw via `zeroclaw acp`, ZeroClaw's native ACP v1 stdio server. Thanks @JordanTheJet. + ### Breaking - Windows agent launches now require structured `agents..argv`; unambiguous legacy `command` plus `args` entries migrate automatically, while raw, ambiguous, or directly executable `.sh` commands fail with explicit migration guidance instead of lossy parsing or CreateProcess ENOENT. Existing custom-agent sessions without saved argv must be recreated. Fixes #466. Thanks @MarcelCFritsche. diff --git a/README.md b/README.md index 1dcb0863..9492bf31 100644 --- a/README.md +++ b/README.md @@ -373,6 +373,7 @@ Built-ins: | `qoder` | native (`qodercli --acp`) | [Qoder CLI](https://docs.qoder.com/cli/acp) | | `qwen` | native (`qwen --acp`) | [Qwen Code](https://github.com/QwenLM/qwen-code) | | `trae` | native (`traecli acp serve`) | [Trae CLI](https://docs.trae.cn/cli) | +| `zeroclaw` | native (`zeroclaw acp`) | [ZeroClaw](https://github.com/zeroclaw-labs/zeroclaw) | `factory-droid` and `factorydroid` also resolve to the built-in `droid` adapter. diff --git a/agents/README.md b/agents/README.md index 7e34595c..520676a1 100644 --- a/agents/README.md +++ b/agents/README.md @@ -22,6 +22,7 @@ Built-in agents: - `qoder -> qodercli --acp` - `qwen -> qwen --acp` - `trae -> traecli acp serve` +- `zeroclaw -> zeroclaw acp` Harness-specific docs in this directory: @@ -43,3 +44,4 @@ Harness-specific docs in this directory: - [Qoder](Qoder.md): built-in `qoder -> qodercli --acp` - [Qwen](Qwen.md): built-in `qwen -> qwen --acp` - [Trae](Trae.md): built-in `trae -> traecli acp serve` +- [ZeroClaw](ZeroClaw.md): built-in `zeroclaw -> zeroclaw acp` diff --git a/agents/ZeroClaw.md b/agents/ZeroClaw.md new file mode 100644 index 00000000..2620f99b --- /dev/null +++ b/agents/ZeroClaw.md @@ -0,0 +1,57 @@ +# ZeroClaw + +- Built-in name: `zeroclaw` +- Default command: `zeroclaw acp` +- Upstream: https://github.com/zeroclaw-labs/zeroclaw + +`acpx zeroclaw` starts ZeroClaw's built-in ACP server (`zeroclaw acp`), a JSON-RPC 2.0 +stdio server that implements Agent Client Protocol v1 (`protocolVersion: 1`, no auth +methods). The `channel-acp-server` feature it needs is included in ZeroClaw's default +build, so a standard `zeroclaw` install works out of the box; a binary compiled without +it exits, reporting that the `channel-acp-server` feature is required. + +## Agent selection + +acpx's `session/new` does not pass a ZeroClaw agent alias, so the server picks the +session's agent from your ZeroClaw config in this order: + +1. `acp.default_agent`, when set. +2. The single `[agents.]` entry, when exactly one agent is configured. + +Single-agent setups therefore need no extra configuration. Multi-agent configs must set +`acp.default_agent`, otherwise `session/new` fails, reporting that it requires an +`agentAlias`. + +## Notes + +- Text prompts only — the server advertises no image, audio, or embedded-context prompt + capability. +- Session resume/close (acpx `ensure` and reuse) is advertised when ZeroClaw's ACP + session store is available. +- MCP tools are opt-in per agent via `[agents.].acp_enable_mcp` (off by default); + the `mcpServers` acpx sends on `session/new` are ignored. +- `zeroclaw acp --max-sessions ` and `--session-timeout ` bound concurrency. + Override the built-in command in acpx config to pass them. + +## Connecting to a running gateway + +To route acpx to an already-running ZeroClaw gateway/daemon instead of spawning a fresh +in-process server, override the built-in command to use `zeroclaw-acp-bridge`, which +bridges stdio to the gateway's ACP-over-WebSocket endpoint: + +```json +{ + "agents": { + "zeroclaw": { + "command": "zeroclaw-acp-bridge" + } + } +} +``` + +The bridge is a separate Cargo binary target and is not included in ZeroClaw's normal +prebuilt archives or installer, so build/install `zeroclaw-acp-bridge` from the ZeroClaw +source tree before using this override. It reads the gateway URL from the local ZeroClaw +config. When gateway pairing is active, run `zeroclaw gateway get-paircode --new`, then +start the bridge once with `zeroclaw-acp-bridge --pair-code ` to cache a token; an +existing token can instead be supplied through `ZEROCLAW_ACP_BRIDGE_TOKEN`. diff --git a/docs/agents.md b/docs/agents.md index e0784231..c9387f62 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -31,6 +31,7 @@ The default agent for top-level commands like `acpx exec …` and `acpx prompt | `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) | +| `zeroclaw` | `zeroclaw acp` | [ZeroClaw](https://github.com/zeroclaw-labs/zeroclaw) | `factory-droid` and `factorydroid` also resolve to the built-in `droid` adapter. @@ -213,6 +214,15 @@ Configure at least one model provider before prompting (for example `ANTHROPIC_A - Default command: `traecli acp serve` - Upstream: [docs.trae.cn](https://docs.trae.cn/cli) +### ZeroClaw + +- Built-in name: `zeroclaw` +- Default command: `zeroclaw acp` +- Upstream: [zeroclaw-labs/zeroclaw](https://github.com/zeroclaw-labs/zeroclaw) +- `zeroclaw acp` is ZeroClaw's native JSON-RPC 2.0 stdio server for ACP v1 (`protocolVersion: 1`, no auth methods). The `channel-acp-server` feature it needs ships in ZeroClaw's default build. +- `session/new` does not carry a ZeroClaw agent alias, so the server binds the session to `acp.default_agent` when set, otherwise to the sole `[agents.]` entry. Single-agent configs work as-is; multi-agent configs must set `acp.default_agent` or `session/new` fails. +- Text prompts only (no image, audio, or embedded-context capability). Session resume/close is advertised when the ZeroClaw ACP session store is available. MCP tools are opt-in per agent via `[agents.].acp_enable_mcp`. + ## Overriding a built-in Any built-in can be replaced wholesale through config, including `args` for adapter sub-commands: diff --git a/skills/acpx/SKILL.md b/skills/acpx/SKILL.md index e2a240ae..70ff8781 100644 --- a/skills/acpx/SKILL.md +++ b/skills/acpx/SKILL.md @@ -102,6 +102,7 @@ Friendly agent names resolve to commands: Forwards Qoder-native `--allowed-tools` and `--max-turns` startup flags from `acpx` session options. - `qwen` -> `qwen --acp` - `trae` -> `traecli acp serve` +- `zeroclaw` -> `zeroclaw acp` Rules: diff --git a/src/agent-registry.ts b/src/agent-registry.ts index 1269e080..6fc0337d 100644 --- a/src/agent-registry.ts +++ b/src/agent-registry.ts @@ -57,6 +57,7 @@ export const AGENT_REGISTRY: Record = { qoder: "qodercli --acp", qwen: "qwen --acp", trae: "traecli acp serve", + zeroclaw: "zeroclaw acp", }; export const AGENT_ARGV_REGISTRY: Record = { @@ -84,6 +85,7 @@ export const AGENT_ARGV_REGISTRY: Record = { qoder: ["qodercli", "--acp"], qwen: ["qwen", "--acp"], trae: ["traecli", "acp", "serve"], + zeroclaw: ["zeroclaw", "acp"], }; export const BUILT_IN_AGENT_PACKAGES = { diff --git a/test/agent-registry.test.ts b/test/agent-registry.test.ts index 44bc33e5..d4531400 100644 --- a/test/agent-registry.test.ts +++ b/test/agent-registry.test.ts @@ -80,6 +80,12 @@ test("pool built-in runs the Poolside ACP entrypoint", () => { assert.equal(resolveAgentCommand("pool"), "pool acp"); }); +test("zeroclaw built-in launches the native ZeroClaw ACP server", () => { + assert.equal(AGENT_REGISTRY.zeroclaw, "zeroclaw acp"); + assert.deepEqual(AGENT_ARGV_REGISTRY.zeroclaw, ["zeroclaw", "acp"]); + assert.equal(resolveAgentCommand("zeroclaw"), "zeroclaw acp"); +}); + test("listBuiltInAgents preserves the required example prefix and alphabetical tail", () => { const agents = listBuiltInAgents(); assert.deepEqual(agents, Object.keys(AGENT_REGISTRY)); @@ -106,6 +112,7 @@ test("listBuiltInAgents preserves the required example prefix and alphabetical t "qoder", "qwen", "trae", + "zeroclaw", ]); }); diff --git a/test/integration.test.ts b/test/integration.test.ts index e95b95b4..48eb41ac 100644 --- a/test/integration.test.ts +++ b/test/integration.test.ts @@ -961,6 +961,33 @@ test("integration: built-in pool agent resolves to pool acp", async () => { }); }); +test("integration: built-in zeroclaw agent resolves to zeroclaw acp", async () => { + await withTempHome(async (homeDir) => { + const cwd = await fs.mkdtemp(path.join(os.tmpdir(), "acpx-integration-cwd-")); + const fakeBinDir = await fs.mkdtemp(path.join(os.tmpdir(), "acpx-fake-zeroclaw-")); + + try { + await writeFakeZeroClawAgent(fakeBinDir); + + const result = await runCli( + ["--approve-all", "--cwd", cwd, "--format", "quiet", "zeroclaw", "exec", "echo hello"], + homeDir, + { + env: { + PATH: `${fakeBinDir}${path.delimiter}${process.env.PATH ?? ""}`, + }, + }, + ); + + assert.equal(result.code, 0, result.stderr); + assert.match(result.stdout, /hello/); + } finally { + await fs.rm(fakeBinDir, { recursive: true, force: true }); + await fs.rm(cwd, { recursive: true, force: true }); + } + }); +}); + test("integration: built-in iflow agent resolves to iflow --experimental-acp", async () => { await withTempHome(async (homeDir) => { const cwd = await fs.mkdtemp(path.join(os.tmpdir(), "acpx-integration-cwd-")); @@ -4715,6 +4742,39 @@ async function writeFakePoolAgent(binDir: string): Promise { ); } +async function writeFakeZeroClawAgent(binDir: string): Promise { + if (process.platform === "win32") { + await fs.writeFile( + path.join(binDir, "zeroclaw.cmd"), + [ + "@echo off", + "setlocal", + 'if not "%~1"=="acp" exit /b 2', + `"${process.execPath}" "${MOCK_AGENT_PATH}" %2 %3 %4 %5 %6 %7 %8 %9`, + "", + ].join("\r\n"), + { encoding: "utf8" }, + ); + return; + } + + await fs.writeFile( + path.join(binDir, "zeroclaw"), + [ + "#!/bin/sh", + 'if [ "$1" = "acp" ]; then', + " shift", + "else", + ' echo "unexpected zeroclaw command: $*" 1>&2', + " exit 2", + "fi", + `exec "${process.execPath}" "${MOCK_AGENT_PATH}" "$@"`, + "", + ].join("\n"), + { encoding: "utf8", mode: 0o755 }, + ); +} + async function writeFakeQoderAgent(binDir: string, argLogPath?: string): Promise { if (process.platform === "win32") { await fs.writeFile( From 47dc1c56b20da3c248a4a1b5c5106f52e65e6594 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 27 Jul 2026 09:54:55 -0700 Subject: [PATCH 17/57] chore(release): prepare acpx 0.13.0 --- CHANGELOG.md | 15 ++++++++++++--- agents/Pool.md | 6 +++--- docs/2026-02-17-agent-registry.md | 1 - docs/agents.md | 2 +- package.json | 2 +- 5 files changed, 17 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 973ffd23..e67c1639 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,17 +4,26 @@ Repo: https://github.com/openclaw/acpx -## Unreleased +## 2026.7.27 (v0.13.0) + +### Highlights + +- Windows agent launches now use structured argv end to end. Unambiguous legacy `command` plus `args` entries migrate automatically; ambiguous/raw commands and `.sh` wrappers must move to `agents..argv`, and existing saved custom-agent sessions without argv must be recreated. +- Built-in Pool and ZeroClaw support makes both native ACP stdio servers available without custom registry configuration. +- The new `--no-fs` flag lets compatible agents use their native filesystem implementation instead of ACP client filesystem methods. +- The dependency and pnpm refresh resolves all four known PostCSS, fast-uri, js-yaml, and brace-expansion advisories. ### Changes -- Dependencies/tooling: refresh the ACP SDK, runtime and development toolchain, update pnpm to 10.34.5, and resolve the PostCSS, fast-uri, js-yaml, and brace-expansion advisories. +- Agents/built-ins: add Pool via `pool acp`. Thanks @dan-roberts-poolside and @osolmaz. + +- Agents/built-ins: add ZeroClaw via `zeroclaw acp`, ZeroClaw's native ACP v1 stdio server. Thanks @JordanTheJet. - CLI/ACP: add `--no-fs` to disable advertised ACP file read/write capabilities so compatible agents can use their native filesystem implementation. Thanks @zgxkbtl. - CLI/timers: preserve tiny positive timeout and TTL values from flags or config as 1 ms instead of disabling timers, and reject delays beyond Node's supported timer range. Thanks @realmehmetali. -- Agents/built-ins: add ZeroClaw via `zeroclaw acp`, ZeroClaw's native ACP v1 stdio server. Thanks @JordanTheJet. +- Dependencies/tooling: refresh the ACP SDK, runtime and development toolchain, update pnpm to 10.34.5, and resolve the PostCSS, fast-uri, js-yaml, and brace-expansion advisories. ### Breaking diff --git a/agents/Pool.md b/agents/Pool.md index 4718be15..37de7bff 100644 --- a/agents/Pool.md +++ b/agents/Pool.md @@ -4,7 +4,7 @@ - Default command: `pool acp` - Upstream: [Poolside pool](https://github.com/poolsideai/pool) -`acpx pool` launches the installed `pool` CLI through its ACP stdio entrypoint. Install `pool` using the [official instructions](https://github.com/poolsideai/pool#install), then run `pool login` before using it through `acpx`. Poolside stores its configuration and credentials under `~/.config/poolside/`. +`acpx pool` launches the installed `pool` CLI through its ACP stdio entrypoint. Install `pool` using the [official instructions](https://github.com/poolsideai/pool#install), then authenticate the CLI before using it through `acpx`; `pool login` is the normal interactive path. Pool reads its configuration from `~/.config/poolside/`. Examples: @@ -14,13 +14,13 @@ acpx pool 'review this branch' acpx pool exec 'summarize this repository' ``` -If your Poolside install exposes ACP through a different command, override the built-in in `~/.acpx/config.json`: +If the binary lives outside `PATH` or needs extra startup arguments, override the built-in argv in `~/.acpx/config.json`: ```json { "agents": { "pool": { - "argv": ["pool", "acp"] + "argv": ["/opt/pool/bin/pool", "acp"] } } } diff --git a/docs/2026-02-17-agent-registry.md b/docs/2026-02-17-agent-registry.md index 6fbb9d54..5dec90fd 100644 --- a/docs/2026-02-17-agent-registry.md +++ b/docs/2026-02-17-agent-registry.md @@ -14,7 +14,6 @@ date: 2026-02-17 - `codex -> npx @zed-industries/codex-acp` - `claude -> npx -y @agentclientprotocol/claude-agent-acp` - `grok-build -> grok agent stdio` -- `pool -> pool acp` The built-in agents table lives in [../README.md](../README.md). Additional built-in agent docs live under [../agents/README.md](../agents/README.md). diff --git a/docs/agents.md b/docs/agents.md index c9387f62..b03c8573 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -200,7 +200,7 @@ Configure at least one model provider before prompting (for example `ANTHROPIC_A - Default command: `pool acp` - Upstream: [Poolside](https://poolside.ai) -`acpx pool` uses the installed `pool` CLI ACP server (`pool acp`). Install `pool` and complete its authentication flow with `pool login` before using it through `acpx`; credentials are stored under `~/.config/poolside/`. +`acpx pool` uses the installed `pool` CLI ACP server (`pool acp`). Install and authenticate the CLI first; `pool login` is the normal interactive path. Focused setup notes live in `agents/Pool.md`. ### Qwen diff --git a/package.json b/package.json index 03e4e84e..78bc9232 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "acpx", - "version": "0.12.1", + "version": "0.13.0", "description": "Headless CLI client for the Agent Client Protocol (ACP) — talk to coding agents from the command line", "keywords": [ "acp", From 504040facb1992453cf16a2a096a1094fc4e48d4 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 27 Jul 2026 10:09:17 -0700 Subject: [PATCH 18/57] chore(release): open 0.13.1 development --- CHANGELOG.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index e67c1639..c1bdfad8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,14 @@ Repo: https://github.com/openclaw/acpx +## Unreleased + +### Changes + +### Breaking + +### Fixes + ## 2026.7.27 (v0.13.0) ### Highlights From e7efc145203d602046208062eb9b803c6cdc3c4a Mon Sep 17 00:00:00 2001 From: trumpyla Date: Tue, 28 Jul 2026 08:43:20 -0400 Subject: [PATCH 19/57] fix: terminate ACP adapter process groups --- CHANGELOG.md | 2 + src/acp/auth-env.ts | 2 + src/acp/client.ts | 50 +++- src/acp/process-tree.ts | 290 +++++++++++++++++++++++ src/acp/terminal-manager.ts | 369 ++--------------------------- test/client.test.ts | 92 +++++++ test/queue-owner-lifecycle.test.ts | 151 +++++++++++- test/spawn-options.test.ts | 5 + 8 files changed, 606 insertions(+), 355 deletions(-) create mode 100644 src/acp/process-tree.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index c1bdfad8..10362c0f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -45,6 +45,8 @@ Repo: https://github.com/openclaw/acpx - CLI/status: report a normal cold-start session as `agent starting` while preserving `needs reconnect` for an unreachable live owner. Thanks @guettli. +- Runtime/agents: terminate the owned adapter process group/tree during normal and failed startup cleanup so package-exec wrappers cannot leave descendants running after ACPX exits. + ## 2026.7.23 (v0.12.1) ### Changes diff --git a/src/acp/auth-env.ts b/src/acp/auth-env.ts index 35d20301..928997af 100644 --- a/src/acp/auth-env.ts +++ b/src/acp/auth-env.ts @@ -174,12 +174,14 @@ export function buildAgentSpawnOptions( sessionEnv?: Record, ): { cwd: string; + detached: true; env: NodeJS.ProcessEnv; stdio: ["pipe", "pipe", "pipe"]; windowsHide: true; } { return { cwd, + detached: true, env: buildAgentEnvironment(authCredentials, sessionEnv), stdio: ["pipe", "pipe", "pipe"], windowsHide: true, diff --git a/src/acp/client.ts b/src/acp/client.ts index f142c4c2..2ceef054 100644 --- a/src/acp/client.ts +++ b/src/acp/client.ts @@ -107,6 +107,14 @@ import { resolveRequestedModelId, type SessionModelState, } from "./model-support.js"; +import { + captureProcessTreePids, + createManagedProcessTree, + rememberProcessTreePids, + signalProcessTree, + waitForProcessTreeExit, + type ManagedProcessTree, +} from "./process-tree.js"; import { formatSessionControlAcpSummary, maybeWrapSessionControlError, @@ -410,6 +418,7 @@ export class AcpClient { private options: AcpClientOptions; private connection?: ClientSideConnection; private agent?: ChildProcessByStdio; + private agentProcessTree?: ManagedProcessTree; private initResult?: InitializeResponse; private loadedSessionId?: string; private eventHandlers: Pick< @@ -732,6 +741,11 @@ export class AcpClient { ...plan.spawnOptions, windowsVerbatimArguments: spawnCommand.windowsVerbatimArguments, }) as ChildProcessByStdio; + const processTree = createManagedProcessTree(spawnedChild.pid, true); + this.agentProcessTree = processTree; + spawnedChild.once("exit", () => { + rememberProcessTreePids(processTree); + }); try { await waitForSpawn(spawnedChild); } catch (error) { @@ -856,7 +870,7 @@ export class AcpClient { params.startupStderr, ); try { - params.child.kill(); + await this.terminateAgentProcess(params.child); } catch { // best effort } @@ -1390,18 +1404,37 @@ export class AcpClient { this.initResult = undefined; this.connection = undefined; this.agent = undefined; + this.agentProcessTree = undefined; } private async terminateAgentProcess( child: ChildProcessByStdio, ): Promise { + const processTree = this.agentProcessTree ?? createManagedProcessTree(child.pid, true); const stdinCloseGraceMs = resolveAgentCloseAfterStdinEndMs(this.options.agentCommand); + await captureProcessTreePids(processTree, isChildProcessRunning(child)); this.endAgentStdin(child); - let exited = await waitForChildExit(child, stdinCloseGraceMs); - exited = await this.killAgentIfRunning(child, exited, "SIGTERM", AGENT_CLOSE_TERM_GRACE_MS); + let exited = await waitForProcessTreeExit( + processTree, + () => isChildProcessRunning(child), + stdinCloseGraceMs, + ); + exited = await this.killAgentIfRunning( + child, + processTree, + exited, + "SIGTERM", + AGENT_CLOSE_TERM_GRACE_MS, + ); if (!exited) { this.log(`agent did not exit after ${AGENT_CLOSE_TERM_GRACE_MS}ms; forcing SIGKILL`); - exited = await this.killAgentIfRunning(child, exited, "SIGKILL", AGENT_CLOSE_KILL_GRACE_MS); + exited = await this.killAgentIfRunning( + child, + processTree, + exited, + "SIGKILL", + AGENT_CLOSE_KILL_GRACE_MS, + ); } // Ensure stdio handles don't keep this process alive after close() returns. @@ -1422,19 +1455,20 @@ export class AcpClient { private async killAgentIfRunning( child: ChildProcessByStdio, + processTree: ManagedProcessTree, alreadyExited: boolean, signal: NodeJS.Signals, waitMs: number, ): Promise { - if (alreadyExited || !isChildProcessRunning(child)) { - return alreadyExited; + if (alreadyExited) { + return true; } try { - child.kill(signal); + await signalProcessTree(processTree, isChildProcessRunning(child), signal); } catch { // best effort } - return await waitForChildExit(child, waitMs); + return await waitForProcessTreeExit(processTree, () => isChildProcessRunning(child), waitMs); } private detachAgentHandles(agent: ChildProcess, unref: boolean): void { diff --git a/src/acp/process-tree.ts b/src/acp/process-tree.ts new file mode 100644 index 00000000..7cecb59b --- /dev/null +++ b/src/acp/process-tree.ts @@ -0,0 +1,290 @@ +import { spawn } from "node:child_process"; + +const PROCESS_TREE_POLL_MS = 25; + +export type ManagedProcessTree = { + rootPid: number | undefined; + killProcessGroup: boolean; + descendantPids: Set; + snapshotPromise?: Promise; +}; + +export function createManagedProcessTree( + rootPid: number | undefined, + killProcessGroup: boolean, +): ManagedProcessTree { + return { + rootPid, + killProcessGroup, + descendantPids: new Set(), + }; +} + +export function rememberProcessTreePids(tree: ManagedProcessTree): void { + tree.snapshotPromise = captureProcessTreePids(tree, false); +} + +export async function captureProcessTreePids( + tree: ManagedProcessTree, + rootRunning: boolean, +): Promise { + const rootPid = tree.rootPid; + // POSIX ownership is the process group created at spawn. Descendants that + // deliberately create another session are outside that ownership boundary. + if (!tree.killProcessGroup || !rootPid || process.platform !== "win32") { + return; + } + await waitForPriorSnapshot(tree, rootRunning); + + recordProcessTreePids(tree, await listDescendantPids(rootPid)); +} + +async function waitForPriorSnapshot(tree: ManagedProcessTree, rootRunning: boolean): Promise { + if (rootRunning) { + return; + } + await tree.snapshotPromise?.catch(() => { + // Process tree snapshots are best-effort because the root may already be gone. + }); +} + +function recordProcessTreePids(tree: ManagedProcessTree, pids: number[]): void { + for (const pid of pids) { + if (pid === tree.rootPid) { + continue; + } + tree.descendantPids.add(pid); + } +} + +export async function signalProcessTree( + tree: ManagedProcessTree, + rootRunning: boolean, + signal: NodeJS.Signals, +): Promise { + const rootPid = tree.rootPid; + if (!tree.killProcessGroup || !rootPid) { + if (rootPid) { + sendSignal(rootPid, signal); + } + return; + } + + await captureProcessTreePids(tree, rootRunning); + if (process.platform === "win32") { + await signalWindowsProcessTree(tree, rootRunning, signal); + return; + } + signalPosixProcessTree(tree, signal); +} + +export async function waitForProcessTreeExit( + tree: ManagedProcessTree, + rootRunning: () => boolean, + timeoutMs: number, +): Promise { + const deadline = Date.now() + Math.max(0, timeoutMs); + while (rootRunning() || hasLiveManagedProcessTree(tree)) { + if (Date.now() >= deadline) { + return false; + } + await waitMs(Math.min(PROCESS_TREE_POLL_MS, Math.max(0, deadline - Date.now()))); + } + return true; +} + +async function signalWindowsProcessTree( + tree: ManagedProcessTree, + rootRunning: boolean, + signal: NodeJS.Signals, +): Promise { + const rootPid = tree.rootPid; + if (rootRunning && rootPid) { + await killWindowsProcessTree(rootPid, signal); + return; + } + for (const descendantPid of tree.descendantPids) { + await killWindowsProcessTree(descendantPid, signal); + } +} + +function signalPosixProcessTree(tree: ManagedProcessTree, signal: NodeJS.Signals): void { + const rootPid = tree.rootPid; + if (rootPid && hasLiveProcessGroup(rootPid)) { + sendSignal(-rootPid, signal); + } +} + +function hasLiveManagedProcessTree(tree: ManagedProcessTree): boolean { + const rootPid = tree.rootPid; + if ( + tree.killProcessGroup && + rootPid && + process.platform !== "win32" && + hasLiveProcessGroup(rootPid) + ) { + return true; + } + return process.platform === "win32" && hasLivePid(tree.descendantPids); +} + +async function listDescendantPids(rootPid: number): Promise { + let output: string; + try { + output = await runProcessListCommand(); + } catch { + return []; + } + + const childrenByParent = new Map(); + for (const line of output.split("\n")) { + addProcessListLine(childrenByParent, line); + } + + const descendants: number[] = []; + const queue = [...(childrenByParent.get(rootPid) ?? [])]; + for (let index = 0; index < queue.length; index += 1) { + const pid = queue[index]; + descendants.push(pid); + queue.push(...(childrenByParent.get(pid) ?? [])); + } + return descendants; +} + +function addProcessListLine(childrenByParent: Map, line: string): void { + const parsed = parseProcessListLine(line); + if (!parsed) { + return; + } + + const children = childrenByParent.get(parsed.parentPid); + if (children) { + children.push(parsed.pid); + } else { + childrenByParent.set(parsed.parentPid, [parsed.pid]); + } +} + +function parseProcessListLine(line: string): { pid: number; parentPid: number } | undefined { + const match = line.trim().match(/^(\d+)\s+(\d+)$/); + if (!match) { + return undefined; + } + + const pid = Number(match[1]); + const parentPid = Number(match[2]); + if (!Number.isInteger(pid) || !Number.isInteger(parentPid) || pid <= 0 || parentPid <= 0) { + return undefined; + } + return { pid, parentPid }; +} + +async function runProcessListCommand(): Promise { + if (process.platform === "win32") { + return await runWindowsProcessListCommand(); + } + return await runPsCommand(["-eo", "pid=,ppid="]); +} + +async function runPsCommand(args: string[]): Promise { + return await runCapturedCommand("ps", args, "ps"); +} + +async function runWindowsProcessListCommand(): Promise { + const command = [ + "Get-CimInstance Win32_Process |", + 'ForEach-Object { "$($_.ProcessId) $($_.ParentProcessId)" }', + ].join(" "); + return await runCapturedCommand( + "powershell.exe", + ["-NoProfile", "-NonInteractive", "-Command", command], + "powershell process list", + ); +} + +async function runCapturedCommand( + command: string, + args: string[], + description: string, +): Promise { + return await new Promise((resolve, reject) => { + const child = spawn(command, args, { + stdio: ["ignore", "pipe", "pipe"], + windowsHide: true, + }); + let stdout = ""; + let stderr = ""; + + 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", reject); + child.once("close", (code, signal) => { + if (code === 0) { + resolve(stdout); + return; + } + reject( + new Error( + `${description} exited with code ${code ?? "null"} signal ${signal ?? "null"}: ${stderr}`, + ), + ); + }); + }); +} + +async function killWindowsProcessTree(pid: number, signal: NodeJS.Signals): Promise { + const args = ["/pid", String(pid), "/t"]; + if (signal === "SIGKILL") { + args.push("/f"); + } + await new Promise((resolve) => { + const child = spawn("taskkill", args, { + stdio: ["ignore", "ignore", "ignore"], + windowsHide: true, + }); + child.once("error", () => resolve()); + child.once("close", () => resolve()); + }); +} + +function sendSignal(pid: number, signal: NodeJS.Signals): void { + try { + process.kill(pid, signal); + } catch { + // Processes can exit between discovery and signaling. + } +} + +function hasLiveProcessGroup(processGroupId: number): boolean { + try { + process.kill(-processGroupId, 0); + return true; + } catch { + return false; + } +} + +function hasLivePid(pids: Set): boolean { + let live = false; + for (const pid of pids) { + try { + process.kill(pid, 0); + live = true; + } catch { + pids.delete(pid); + } + } + return live; +} + +function waitMs(ms: number): Promise { + return new Promise((resolve) => { + setTimeout(resolve, Math.max(0, ms)); + }); +} diff --git a/src/acp/terminal-manager.ts b/src/acp/terminal-manager.ts index 00c14a41..cfa5aeca 100644 --- a/src/acp/terminal-manager.ts +++ b/src/acp/terminal-manager.ts @@ -24,15 +24,20 @@ import { type TerminalSpawnCommand, } from "../spawn-command-options.js"; import type { ClientOperation, NonInteractivePermissionPolicy, PermissionMode } from "../types.js"; +import { + createManagedProcessTree, + rememberProcessTreePids, + signalProcessTree, + waitForProcessTreeExit, + type ManagedProcessTree, +} from "./process-tree.js"; const DEFAULT_TERMINAL_OUTPUT_LIMIT_BYTES = 64 * 1024; const DEFAULT_KILL_GRACE_MS = 1_500; type ManagedTerminal = { process: ChildProcessByStdio; - killProcessGroup: boolean; - descendantPids: Set; - processGroupSnapshotPromise?: Promise; + processTree: ManagedProcessTree; output: Buffer; truncated: boolean; outputByteLimit: number; @@ -147,12 +152,6 @@ function canPromptForPermission(): boolean { return process.stdin.isTTY && process.stderr.isTTY; } -function waitMs(ms: number): Promise { - return new Promise((resolve) => { - setTimeout(resolve, Math.max(0, ms)); - }); -} - export class TerminalManager { private readonly cwd: string; private permissionMode: PermissionMode; @@ -210,8 +209,7 @@ export class TerminalManager { const terminal: ManagedTerminal = { process: proc, - killProcessGroup: spawnCommand.killProcessGroup, - descendantPids: new Set(), + processTree: createManagedProcessTree(proc.pid, spawnCommand.killProcessGroup), output: Buffer.alloc(0), truncated: false, outputByteLimit, @@ -239,14 +237,11 @@ export class TerminalManager { proc.once("exit", (exitCode, signal) => { terminal.exitCode = exitCode; terminal.signal = signal; - terminal.processGroupSnapshotPromise = rememberProcessGroupPids(terminal); - void (async () => { - await terminal.processGroupSnapshotPromise; - terminal.resolveExit({ - exitCode: exitCode ?? null, - signal: signal ?? null, - }); - })(); + rememberProcessTreePids(terminal.processTree); + terminal.resolveExit({ + exitCode: exitCode ?? null, + signal: signal ?? null, + }); }); const terminalId = randomUUID(); @@ -440,23 +435,23 @@ export class TerminalManager { } private async killProcess(terminal: ManagedTerminal): Promise { - if (!this.isRunning(terminal) && !terminal.killProcessGroup) { + if (!this.isRunning(terminal) && !terminal.processTree.killProcessGroup) { return; } try { - await this.signalProcess(terminal, "SIGTERM"); + await signalProcessTree(terminal.processTree, this.isRunning(terminal), "SIGTERM"); } catch { return; } const exitedAfterTerm = await this.waitForCleanupAfterSignal(terminal); - if (exitedAfterTerm && !terminal.killProcessGroup) { + if (exitedAfterTerm) { return; } try { - await this.signalProcess(terminal, "SIGKILL"); + await signalProcessTree(terminal.processTree, this.isRunning(terminal), "SIGKILL"); } catch { return; } @@ -464,75 +459,12 @@ export class TerminalManager { await this.waitForCleanupAfterSignal(terminal); } - private async signalProcess(terminal: ManagedTerminal, signal: NodeJS.Signals): Promise { - const pid = terminal.process.pid; - if (terminal.killProcessGroup && pid && process.platform === "win32") { - await this.signalWindowsProcessGroup(terminal, pid, signal); - return; - } - if (terminal.killProcessGroup && pid) { - await this.signalPosixProcessGroup(terminal, pid, signal); - return; - } - terminal.process.kill(signal); - } - - private async signalWindowsProcessGroup( - terminal: ManagedTerminal, - pid: number, - signal: NodeJS.Signals, - ): Promise { - await this.captureDescendantPids(terminal, pid); - if (this.isRunning(terminal)) { - await killWindowsProcessTree(pid, signal); - return; - } - for (const descendantPid of terminal.descendantPids) { - await killWindowsProcessTree(descendantPid, signal); - } - } - - private async signalPosixProcessGroup( - terminal: ManagedTerminal, - pid: number, - signal: NodeJS.Signals, - ): Promise { - await this.captureDescendantPids(terminal, pid); - if (hasLiveProcessGroup(pid)) { - sendSignal(-pid, signal); - return; - } - for (const descendantPid of terminal.descendantPids) { - sendSignal(descendantPid, signal); - } - } - - private async captureDescendantPids(terminal: ManagedTerminal, pid: number): Promise { - if (!this.isRunning(terminal)) { - await terminal.processGroupSnapshotPromise?.catch(() => { - // ignore best-effort process group snapshot failures - }); - } - for (const descendantPid of await listDescendantPids(pid)) { - terminal.descendantPids.add(descendantPid); - } - } - private async waitForCleanupAfterSignal(terminal: ManagedTerminal): Promise { - return await Promise.race([ - this.waitForTerminalAndTrackedDescendants(terminal).then(() => true), - waitMs(this.killGraceMs).then(() => false), - ]); - } - - private async waitForTerminalAndTrackedDescendants(terminal: ManagedTerminal): Promise { - await terminal.exitPromise; - while (hasLiveTerminalProcessGroup(terminal)) { - await waitMs(25); - } - while (hasLivePid(terminal.descendantPids)) { - await waitMs(25); - } + return await waitForProcessTreeExit( + terminal.processTree, + () => this.isRunning(terminal), + this.killGraceMs, + ); } } @@ -627,258 +559,3 @@ function commandPathExists(command: string, cwd: string): boolean { const resolvedPath = path.isAbsolute(command) ? command : path.resolve(cwd, command); return fs.existsSync(resolvedPath); } - -async function listDescendantPids(rootPid: number): Promise { - let output: string; - try { - output = await runProcessListCommand(); - } catch { - return []; - } - - const childrenByParent = new Map(); - for (const line of output.split("\n")) { - addProcessListLine(childrenByParent, line); - } - - const descendants: number[] = []; - const queue = [...(childrenByParent.get(rootPid) ?? [])]; - for (let index = 0; index < queue.length; index += 1) { - const pid = queue[index]; - descendants.push(pid); - queue.push(...(childrenByParent.get(pid) ?? [])); - } - return descendants; -} - -function addProcessListLine(childrenByParent: Map, line: string): void { - const parsed = parseProcessListLine(line); - if (!parsed) { - return; - } - - const children = childrenByParent.get(parsed.parentPid); - if (children) { - children.push(parsed.pid); - } else { - childrenByParent.set(parsed.parentPid, [parsed.pid]); - } -} - -function parseProcessListLine(line: string): { pid: number; parentPid: number } | undefined { - const match = line.trim().match(/^(\d+)\s+(\d+)$/); - if (!match) { - return undefined; - } - - const pid = Number(match[1]); - const parentPid = Number(match[2]); - if (!Number.isInteger(pid) || !Number.isInteger(parentPid) || pid <= 0 || parentPid <= 0) { - return undefined; - } - return { pid, parentPid }; -} - -async function runProcessListCommand(): Promise { - if (process.platform === "win32") { - return await runWindowsProcessListCommand(); - } - - return await new Promise((resolve, reject) => { - const child = spawn("ps", ["-eo", "pid=,ppid="], { - stdio: ["ignore", "pipe", "pipe"], - }); - - let stdout = ""; - let stderr = ""; - - 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", reject); - child.once("close", (code, signal) => { - if (code === 0) { - resolve(stdout); - return; - } - reject( - new Error(`ps exited with code ${code ?? "null"} signal ${signal ?? "null"}: ${stderr}`), - ); - }); - }); -} - -async function rememberProcessGroupPids(terminal: ManagedTerminal): Promise { - const processGroupId = terminal.process.pid; - if (!terminal.killProcessGroup || !processGroupId) { - return; - } - - if (process.platform === "win32") { - for (const pid of await listDescendantPids(processGroupId)) { - terminal.descendantPids.add(pid); - } - return; - } - - for (const pid of await listProcessGroupPids(processGroupId)) { - if (pid !== processGroupId) { - terminal.descendantPids.add(pid); - } - } -} - -async function listProcessGroupPids(processGroupId: number): Promise { - let output: string; - try { - output = await runProcessGroupListCommand(); - } catch { - return []; - } - - const pids: number[] = []; - for (const line of output.split("\n")) { - const match = line.trim().match(/^(\d+)\s+(\d+)$/); - if (!match) { - continue; - } - - const pid = Number(match[1]); - const pgid = Number(match[2]); - if (Number.isInteger(pid) && Number.isInteger(pgid) && pid > 0 && pgid === processGroupId) { - pids.push(pid); - } - } - return pids; -} - -async function runProcessGroupListCommand(): Promise { - return await new Promise((resolve, reject) => { - const child = spawn("ps", ["-eo", "pid=,pgid="], { - stdio: ["ignore", "pipe", "pipe"], - }); - - let stdout = ""; - let stderr = ""; - - 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", reject); - child.once("close", (code, signal) => { - if (code === 0) { - resolve(stdout); - return; - } - reject( - new Error(`ps exited with code ${code ?? "null"} signal ${signal ?? "null"}: ${stderr}`), - ); - }); - }); -} - -async function runWindowsProcessListCommand(): Promise { - return await new Promise((resolve, reject) => { - const command = [ - "Get-CimInstance Win32_Process |", - 'ForEach-Object { "$($_.ProcessId) $($_.ParentProcessId)" }', - ].join(" "); - const child = spawn("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", command], { - stdio: ["ignore", "pipe", "pipe"], - windowsHide: true, - }); - - let stdout = ""; - let stderr = ""; - - 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", reject); - child.once("close", (code, signal) => { - if (code === 0) { - resolve(stdout); - return; - } - reject( - new Error( - `powershell process list exited with code ${code ?? "null"} signal ${ - signal ?? "null" - }: ${stderr}`, - ), - ); - }); - }); -} - -async function killWindowsProcessTree(pid: number, signal: NodeJS.Signals): Promise { - const args = ["/pid", String(pid), "/t"]; - if (signal === "SIGKILL") { - args.push("/f"); - } - await new Promise((resolve) => { - const child = spawn("taskkill", args, { - stdio: ["ignore", "ignore", "ignore"], - windowsHide: true, - }); - child.once("error", () => resolve()); - child.once("close", () => resolve()); - }); -} - -function sendSignal(pid: number, signal: NodeJS.Signals): void { - try { - process.kill(pid, signal); - } catch { - // Process tree cleanup is best-effort because descendants can exit between ps and kill. - } -} - -function hasLiveProcessGroup(processGroupId: number): boolean { - try { - process.kill(-processGroupId, 0); - return true; - } catch { - return false; - } -} - -function hasLiveTerminalProcessGroup(terminal: ManagedTerminal): boolean { - const pid = terminal.process.pid; - return Boolean( - terminal.killProcessGroup && pid && process.platform !== "win32" && hasLiveProcessGroup(pid), - ); -} - -function hasLivePid(pids: Set): boolean { - for (const pid of pids) { - try { - process.kill(pid, 0); - return true; - } catch { - pids.delete(pid); - } - } - return false; -} diff --git a/test/client.test.ts b/test/client.test.ts index 3483c39f..629217f5 100644 --- a/test/client.test.ts +++ b/test/client.test.ts @@ -1,4 +1,6 @@ import assert from "node:assert/strict"; +import fs from "node:fs/promises"; +import os from "node:os"; import path from "node:path"; import { PassThrough } from "node:stream"; import test from "node:test"; @@ -1276,6 +1278,76 @@ test("AcpClient start fails fast when the agent exits during initialize", async assert(Date.now() - startedAt < 2_000); }); +test("AcpClient startup failure kills descendants left by an exited npx wrapper", async () => { + if (process.platform === "win32") { + return; + } + + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "acpx-startup-tree-")); + const packageDir = path.join(tempDir, "adapter-package"); + const processInfoPath = path.join(tempDir, "process-info.json"); + let descendantPid: number | undefined; + + try { + await fs.mkdir(packageDir, { recursive: true }); + await fs.writeFile( + path.join(packageDir, "package.json"), + `${JSON.stringify({ + name: "acpx-startup-tree-fixture", + version: "1.0.0", + bin: { "acpx-startup-tree-fixture": "adapter.cjs" }, + })}\n`, + ); + const adapterPath = path.join(packageDir, "adapter.cjs"); + await fs.writeFile( + adapterPath, + [ + "#!/usr/bin/env node", + 'const { spawn } = require("node:child_process");', + 'const fs = require("node:fs");', + 'const child = spawn(process.execPath, ["-e", "process.on(\\"SIGTERM\\", () => {}); setInterval(() => {}, 1000);"], { stdio: "ignore" });', + `fs.writeFileSync(${JSON.stringify(processInfoPath)}, JSON.stringify({ adapterPid: process.pid, descendantPid: child.pid }));`, + "setTimeout(() => process.exit(17), 100);", + "", + ].join("\n"), + ); + await fs.chmod(adapterPath, 0o755); + + const client = makeClient({ + agentCommand: `npx --yes --offline --package ${JSON.stringify(packageDir)} -- acpx-startup-tree-fixture`, + cwd: tempDir, + sessionOptions: { + env: { + HOME: tempDir, + npm_config_cache: path.join(tempDir, "npm-cache"), + }, + }, + }); + + await assert.rejects(() => client.start(), AgentStartupError); + const processInfo = JSON.parse(await fs.readFile(processInfoPath, "utf8")) as { + adapterPid: number; + descendantPid: number; + }; + descendantPid = processInfo.descendantPid; + assert.notEqual( + processInfo.adapterPid, + descendantPid, + "fixture must create a separate stubborn descendant", + ); + assert.equal( + isPidAlive(descendantPid), + false, + "startup cleanup must not leave the adapter descendant alive", + ); + } finally { + if (descendantPid) { + await terminateTestPid(descendantPid); + } + await fs.rm(tempDir, { recursive: true, force: true }); + } +}); + test("AcpClient close resets in-memory state and shuts down terminal manager", async () => { const client = makeClient(); const internals = asInternals(client); @@ -1354,6 +1426,26 @@ function makeClient( }); } +function isPidAlive(pid: number): boolean { + try { + process.kill(pid, 0); + return true; + } catch { + return false; + } +} + +async function terminateTestPid(pid: number): Promise { + if (!isPidAlive(pid)) { + return; + } + process.kill(pid, "SIGKILL"); + const deadline = Date.now() + 2_000; + while (isPidAlive(pid) && Date.now() < deadline) { + await new Promise((resolve) => setTimeout(resolve, 25)); + } +} + function asInternals(client: AcpClient): ClientInternals { return client as unknown as ClientInternals; } diff --git a/test/queue-owner-lifecycle.test.ts b/test/queue-owner-lifecycle.test.ts index 67963b56..e2b152fd 100644 --- a/test/queue-owner-lifecycle.test.ts +++ b/test/queue-owner-lifecycle.test.ts @@ -15,7 +15,7 @@ import net from "node:net"; import path from "node:path"; import readline from "node:readline"; import { describe, it } from "node:test"; -import { fileURLToPath } from "node:url"; +import { fileURLToPath, pathToFileURL } from "node:url"; import { isProcessAlive } from "../src/cli/queue/lease-store.js"; import { queueLockFilePath, queueSocketPath } from "../src/cli/queue/paths.js"; import { makeSessionRecord, withTempHome, writeSessionRecordFile } from "./runtime-test-helpers.js"; @@ -96,6 +96,45 @@ function waitForProcessExit( }); } +async function writeLocalNpxAdapter( + homeDir: string, + processInfoPath: string, +): Promise<{ binName: string; packageDir: string }> { + const packageDir = path.join(homeDir, "adapter-package"); + const binName = "acpx-process-tree-fixture"; + const adapterPath = path.join(packageDir, "adapter.cjs"); + await fs.mkdir(packageDir, { recursive: true }); + await fs.writeFile( + path.join(packageDir, "package.json"), + `${JSON.stringify({ + name: binName, + version: "1.0.0", + bin: { [binName]: "adapter.cjs" }, + })}\n`, + ); + await fs.writeFile( + adapterPath, + [ + "#!/usr/bin/env node", + 'const fs = require("node:fs");', + `fs.writeFileSync(${JSON.stringify(processInfoPath)}, JSON.stringify({ pid: process.pid, parentPid: process.ppid }));`, + "setInterval(() => {}, 1_000);", + `void import(${JSON.stringify(pathToFileURL(MOCK_AGENT_PATH).href)});`, + "", + ].join("\n"), + ); + await fs.chmod(adapterPath, 0o755); + return { binName, packageDir }; +} + +async function terminateFixturePid(pid: number): Promise { + if (!isProcessAlive(pid)) { + return; + } + process.kill(pid, "SIGKILL"); + await waitUntil(async () => !isProcessAlive(pid), 2_000); +} + describe("queue owner lifecycle — graceful SIGTERM shutdown", () => { it("exits with code 0 and releases its lease when it receives SIGTERM", async () => { if (process.platform === "win32") { @@ -612,4 +651,114 @@ describe("queue owner lifecycle — bridge process death on SIGTERM", () => { } }); }); + + it("kills a SIGTERM-resistant adapter grandchild launched through npx", async () => { + if (process.platform === "win32") { + return; + } + + await withTempHome("acpx-lifecycle-npx-tree-", async (homeDir) => { + const cwd = path.join(homeDir, "workspace"); + await fs.mkdir(cwd, { recursive: true }); + + const processInfoPath = path.join(homeDir, "adapter-process.json"); + const { binName: adapterBin, packageDir } = await writeLocalNpxAdapter( + homeDir, + processInfoPath, + ); + const record = makeSessionRecord({ + acpxRecordId: "lifecycle-npx-tree-test", + acpSessionId: "lifecycle-npx-tree-session", + agentCommand: `npx --yes --offline --package ${JSON.stringify(packageDir)} -- ${adapterBin} --ignore-sigterm`, + cwd, + }); + await writeSessionRecordFile(homeDir, record); + + const socketPath = queueSocketPath(record.acpxRecordId, homeDir); + const lockPath = queueLockFilePath(record.acpxRecordId, homeDir); + const child = spawn(process.execPath, [CLI_PATH, "__queue-owner"], { + env: { + ...process.env, + HOME: homeDir, + ACPX_QUEUE_OWNER_PAYLOAD: JSON.stringify({ + sessionId: record.acpxRecordId, + permissionMode: "approve-reads", + }), + }, + stdio: ["ignore", "ignore", "pipe"], + }); + const stderrChunks: Buffer[] = []; + child.stderr?.on("data", (chunk: Buffer) => stderrChunks.push(chunk)); + + let queueSocket: net.Socket | undefined; + let adapterPid: number | undefined; + let wrapperPid: number | undefined; + + try { + await waitUntil( + async () => + (await fileExists(socketPath)) || child.exitCode != null || child.signalCode != null, + ); + assert.equal( + await fileExists(socketPath), + true, + `queue owner must open its socket; stderr=${Buffer.concat(stderrChunks).toString("utf8")}`, + ); + queueSocket = await new Promise((resolve, reject) => { + const socket = net.createConnection(socketPath); + socket.once("connect", () => resolve(socket)); + socket.once("error", reject); + }); + queueSocket.write( + `${JSON.stringify({ + type: "submit_prompt", + requestId: "req-npx-tree-test", + message: "sleep 10000", + permissionMode: "approve-reads", + waitForCompletion: true, + })}\n`, + ); + + await waitUntil(() => fileExists(processInfoPath), 8_000); + const processInfo = JSON.parse(await fs.readFile(processInfoPath, "utf8")) as { + pid: number; + parentPid: number; + }; + adapterPid = processInfo.pid; + wrapperPid = processInfo.parentPid; + assert.notEqual( + wrapperPid, + child.pid, + "fixture must launch the adapter as an npx grandchild, not the queue owner's direct child", + ); + assert.equal(isProcessAlive(adapterPid), true, "adapter grandchild must be alive"); + assert.equal(isProcessAlive(wrapperPid), true, "npx wrapper must be alive"); + + child.kill("SIGTERM"); + const { code, signal } = await waitForProcessExit(child, 10_000); + const stderr = Buffer.concat(stderrChunks).toString("utf8"); + + assert.equal(signal, null, `queue owner should exit gracefully; stderr=${stderr}`); + assert.equal(code, 0, `expected queue owner exit code 0; stderr=${stderr}`); + assert.equal( + isProcessAlive(adapterPid), + false, + "SIGTERM-resistant adapter grandchild must not survive npx wrapper teardown", + ); + assert.equal(isProcessAlive(wrapperPid), false, "npx wrapper must be gone"); + assert.equal(await fileExists(lockPath), false, "queue owner lease must be released"); + } finally { + queueSocket?.destroy(); + if (child.exitCode == null && child.signalCode == null) { + child.kill("SIGKILL"); + } + if (adapterPid) { + await terminateFixturePid(adapterPid); + } + if (wrapperPid) { + await terminateFixturePid(wrapperPid); + } + } + }); + }); }); diff --git a/test/spawn-options.test.ts b/test/spawn-options.test.ts index 76cda351..516d8d27 100644 --- a/test/spawn-options.test.ts +++ b/test/spawn-options.test.ts @@ -112,6 +112,11 @@ test("buildAgentSpawnOptions hides Windows console windows and preserves auth en }); assert.equal(options.cwd, "/tmp/acpx-agent"); + assert.equal( + (options as { detached?: boolean }).detached, + true, + "agent wrappers must lead an owned process group/tree for descendant cleanup", + ); assert.deepEqual(options.stdio, ["pipe", "pipe", "pipe"]); assert.equal(options.windowsHide, true); assert.equal(options.env.ACPX_AUTH_TOKEN, "secret-token"); From 93b6be19894d96403b8cadb07a872f622ce03b92 Mon Sep 17 00:00:00 2001 From: trumpyla Date: Tue, 28 Jul 2026 17:08:15 -0400 Subject: [PATCH 20/57] fix: retry queue owner lease-before-bind races --- src/cli/queue/ipc.ts | 15 +++++++++++++-- src/cli/session/queue-owner-runtime.ts | 1 + test/client.test.ts | 12 ++++++++++-- 3 files changed, 24 insertions(+), 4 deletions(-) diff --git a/src/cli/queue/ipc.ts b/src/cli/queue/ipc.ts index ab49ccdf..95bf7b16 100644 --- a/src/cli/queue/ipc.ts +++ b/src/cli/queue/ipc.ts @@ -201,11 +201,12 @@ function parseQueueOwnerResponseLine( async function runQueueOwnerRequest(options: { owner: QueueOwnerRecord; request: QueueRequest; + connectAttempts?: number; onAccepted?: (controls: QueueOwnerRequestControls) => void; onMessage: (message: QueueOwnerMessage, controls: QueueOwnerRequestControls) => void; onClose: (controls: QueueOwnerRequestControls) => void; }): Promise { - const socket = await connectToQueueOwner(options.owner); + const socket = await connectToQueueOwner(options.owner, options.connectAttempts); if (!socket) { return undefined; } @@ -322,6 +323,8 @@ export type SubmitToQueueOwnerOptions = { waitForCompletion: boolean; verbose?: boolean; sessionOptions?: NonNullable; + /** Use a single connection probe and treat lease-before-bind as a startup miss. */ + startupProbe?: boolean; /** Fires when the queue owner acknowledges the request (IPC accept), before completion. */ onQueueAccepted?: () => void; }; @@ -415,6 +418,7 @@ async function submitToQueueOwner( return await runQueueOwnerRequest({ owner, request, + connectAttempts: options.startupProbe ? 1 : undefined, onAccepted: ({ resolve }) => { options.onQueueAccepted?.(); options.outputFormatter.setContext({ @@ -707,6 +711,13 @@ function assertQueueOwnerMcpConfigMatches( ); } +function unavailableOwnerCountsAsMissing( + health: QueueOwnerHealth, + startupProbe: boolean | undefined, +): boolean { + return !health.hasLease || startupProbe === true; +} + export async function trySubmitToRunningOwner( options: SubmitToQueueOwnerOptions, ): Promise { @@ -744,7 +755,7 @@ export async function trySubmitToRunningOwner( } const health = await probeQueueOwnerHealth(options.sessionId); - if (!health.hasLease) { + if (unavailableOwnerCountsAsMissing(health, options.startupProbe)) { return undefined; } diff --git a/src/cli/session/queue-owner-runtime.ts b/src/cli/session/queue-owner-runtime.ts index cbcb0f8b..85aa1d10 100644 --- a/src/cli/session/queue-owner-runtime.ts +++ b/src/cli/session/queue-owner-runtime.ts @@ -74,6 +74,7 @@ async function submitToRunningOwner( waitForCompletion, verbose: options.verbose, sessionOptions: options.sessionOptions, + startupProbe: true, onQueueAccepted: extras?.onQueueAccepted, }); } diff --git a/test/client.test.ts b/test/client.test.ts index 629217f5..04cb94a4 100644 --- a/test/client.test.ts +++ b/test/client.test.ts @@ -1336,8 +1336,8 @@ test("AcpClient startup failure kills descendants left by an exited npx wrapper" "fixture must create a separate stubborn descendant", ); assert.equal( - isPidAlive(descendantPid), - false, + await waitForPidExit(descendantPid), + true, "startup cleanup must not leave the adapter descendant alive", ); } finally { @@ -1446,6 +1446,14 @@ async function terminateTestPid(pid: number): Promise { } } +async function waitForPidExit(pid: number, timeoutMs = 2_000): Promise { + const deadline = Date.now() + timeoutMs; + while (isPidAlive(pid) && Date.now() < deadline) { + await new Promise((resolve) => setTimeout(resolve, 25)); + } + return !isPidAlive(pid); +} + function asInternals(client: AcpClient): ClientInternals { return client as unknown as ClientInternals; } From f68abe0d006e59d4ea8e80c1e800cf5ff123b477 Mon Sep 17 00:00:00 2001 From: trumpyla Date: Tue, 28 Jul 2026 18:14:03 -0400 Subject: [PATCH 21/57] fix: harden lifecycle race handling --- CHANGELOG.md | 4 +- src/acp/client.ts | 5 +- src/acp/process-tree.ts | 151 ++++++++++++++++++++++--------- src/acp/terminal-manager.ts | 2 + src/cli/queue/ipc-transport.ts | 4 +- src/cli/queue/ipc.ts | 15 +-- test/process-tree.test.ts | 43 +++++++++ test/queue-ipc-errors.test.ts | 33 +++++++ test/queue-owner-process.test.ts | 8 +- 9 files changed, 210 insertions(+), 55 deletions(-) create mode 100644 test/process-tree.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 10362c0f..11e7c878 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -45,7 +45,9 @@ Repo: https://github.com/openclaw/acpx - CLI/status: report a normal cold-start session as `agent starting` while preserving `needs reconnect` for an unreachable live owner. Thanks @guettli. -- Runtime/agents: terminate the owned adapter process group/tree during normal and failed startup cleanup so package-exec wrappers cannot leave descendants running after ACPX exits. +- Runtime/agents: terminate owned adapter process groups on POSIX and best-effort tracked process trees on Windows during normal and failed startup cleanup so package-exec wrappers do not leave captured descendants running after ACPX exits. + +- Runtime/queue: treat a cold-start owner lease that has not bound its socket yet as a startup miss without a second health probe, allowing the caller to recover instead of reporting `QUEUE_NOT_ACCEPTING_REQUESTS`. ## 2026.7.23 (v0.12.1) diff --git a/src/acp/client.ts b/src/acp/client.ts index 2ceef054..9b2feede 100644 --- a/src/acp/client.ts +++ b/src/acp/client.ts @@ -108,6 +108,7 @@ import { type SessionModelState, } from "./model-support.js"; import { + beginProcessTreeTracking, captureProcessTreePids, createManagedProcessTree, rememberProcessTreePids, @@ -751,6 +752,7 @@ export class AcpClient { } catch (error) { throw new AgentSpawnError(this.options.agentCommand, error); } + beginProcessTreeTracking(processTree); return requireAgentStdio(spawnedChild); } @@ -1412,8 +1414,9 @@ export class AcpClient { ): Promise { const processTree = this.agentProcessTree ?? createManagedProcessTree(child.pid, true); const stdinCloseGraceMs = resolveAgentCloseAfterStdinEndMs(this.options.agentCommand); - await captureProcessTreePids(processTree, isChildProcessRunning(child)); + const processTreeSnapshot = captureProcessTreePids(processTree, isChildProcessRunning(child)); this.endAgentStdin(child); + await processTreeSnapshot; let exited = await waitForProcessTreeExit( processTree, () => isChildProcessRunning(child), diff --git a/src/acp/process-tree.ts b/src/acp/process-tree.ts index 7cecb59b..351e1935 100644 --- a/src/acp/process-tree.ts +++ b/src/acp/process-tree.ts @@ -5,6 +5,7 @@ const PROCESS_TREE_POLL_MS = 25; export type ManagedProcessTree = { rootPid: number | undefined; killProcessGroup: boolean; + platform: NodeJS.Platform; descendantPids: Set; snapshotPromise?: Promise; }; @@ -12,16 +13,24 @@ export type ManagedProcessTree = { export function createManagedProcessTree( rootPid: number | undefined, killProcessGroup: boolean, + platform: NodeJS.Platform = process.platform, ): ManagedProcessTree { return { rootPid, killProcessGroup, + platform, descendantPids: new Set(), }; } export function rememberProcessTreePids(tree: ManagedProcessTree): void { - tree.snapshotPromise = captureProcessTreePids(tree, false); + queueProcessTreeSnapshot(tree); +} + +export function beginProcessTreeTracking(tree: ManagedProcessTree): void { + if (tree.platform === "win32") { + queueProcessTreeSnapshot(tree); + } } export async function captureProcessTreePids( @@ -29,25 +38,46 @@ export async function captureProcessTreePids( rootRunning: boolean, ): Promise { const rootPid = tree.rootPid; - // POSIX ownership is the process group created at spawn. Descendants that - // deliberately create another session are outside that ownership boundary. - if (!tree.killProcessGroup || !rootPid || process.platform !== "win32") { + if (!tree.killProcessGroup || !rootPid) { return; } - await waitForPriorSnapshot(tree, rootRunning); - recordProcessTreePids(tree, await listDescendantPids(rootPid)); -} - -async function waitForPriorSnapshot(tree: ManagedProcessTree, rootRunning: boolean): Promise { - if (rootRunning) { + if (!rootRunning) { + await waitForPriorSnapshot(tree); return; } + + await recordCurrentProcessTreePids(tree); +} + +async function waitForPriorSnapshot(tree: ManagedProcessTree): Promise { await tree.snapshotPromise?.catch(() => { // Process tree snapshots are best-effort because the root may already be gone. }); } +function queueProcessTreeSnapshot(tree: ManagedProcessTree): void { + const priorSnapshot = tree.snapshotPromise; + tree.snapshotPromise = (async () => { + await priorSnapshot?.catch(() => { + // A later snapshot can still succeed after an earlier best-effort failure. + }); + await recordCurrentProcessTreePids(tree); + })(); +} + +async function recordCurrentProcessTreePids(tree: ManagedProcessTree): Promise { + const rootPid = tree.rootPid; + if (!tree.killProcessGroup || !rootPid) { + return; + } + const pids = + tree.platform === "win32" + ? await listDescendantPids(rootPid, tree.platform) + : await listProcessGroupPids(rootPid); + recordProcessTreePids(tree, pids); +} + function recordProcessTreePids(tree: ManagedProcessTree, pids: number[]): void { for (const pid of pids) { if (pid === tree.rootPid) { @@ -71,11 +101,42 @@ export async function signalProcessTree( } await captureProcessTreePids(tree, rootRunning); - if (process.platform === "win32") { - await signalWindowsProcessTree(tree, rootRunning, signal); - return; + for (const target of resolveProcessTreeSignalTargets(tree, rootRunning)) { + if (target.tree) { + await killWindowsProcessTree(target.pid, signal); + } else { + sendSignal(target.pid, signal); + } } - signalPosixProcessTree(tree, signal); +} + +export type ProcessTreeSignalTarget = { + pid: number; + tree: boolean; +}; + +export function resolveProcessTreeSignalTargets( + tree: ManagedProcessTree, + rootRunning: boolean, +): ProcessTreeSignalTarget[] { + const rootPid = tree.rootPid; + if (!rootPid) { + return []; + } + if (!tree.killProcessGroup) { + return [{ pid: rootPid, tree: false }]; + } + if (tree.platform === "win32") { + return rootRunning + ? [{ pid: rootPid, tree: true }] + : Array.from(tree.descendantPids, (pid) => ({ pid, tree: true })); + } + if (rootRunning) { + return [{ pid: -rootPid, tree: false }]; + } + // Once the root exits, its numeric PID/PGID can be recycled. Signal only + // members captured while the owned group still existed. + return Array.from(tree.descendantPids, (pid) => ({ pid, tree: false })); } export async function waitForProcessTreeExit( @@ -84,54 +145,38 @@ export async function waitForProcessTreeExit( timeoutMs: number, ): Promise { const deadline = Date.now() + Math.max(0, timeoutMs); - while (rootRunning() || hasLiveManagedProcessTree(tree)) { + let rootIsRunning = rootRunning(); + while (rootIsRunning || hasLiveManagedProcessTree(tree, rootIsRunning)) { if (Date.now() >= deadline) { return false; } await waitMs(Math.min(PROCESS_TREE_POLL_MS, Math.max(0, deadline - Date.now()))); + rootIsRunning = rootRunning(); } return true; } -async function signalWindowsProcessTree( - tree: ManagedProcessTree, - rootRunning: boolean, - signal: NodeJS.Signals, -): Promise { - const rootPid = tree.rootPid; - if (rootRunning && rootPid) { - await killWindowsProcessTree(rootPid, signal); - return; - } - for (const descendantPid of tree.descendantPids) { - await killWindowsProcessTree(descendantPid, signal); - } -} - -function signalPosixProcessTree(tree: ManagedProcessTree, signal: NodeJS.Signals): void { - const rootPid = tree.rootPid; - if (rootPid && hasLiveProcessGroup(rootPid)) { - sendSignal(-rootPid, signal); - } -} - -function hasLiveManagedProcessTree(tree: ManagedProcessTree): boolean { +function hasLiveManagedProcessTree(tree: ManagedProcessTree, rootRunning: boolean): boolean { const rootPid = tree.rootPid; if ( + rootRunning && tree.killProcessGroup && rootPid && - process.platform !== "win32" && + tree.platform !== "win32" && hasLiveProcessGroup(rootPid) ) { return true; } - return process.platform === "win32" && hasLivePid(tree.descendantPids); + return hasLivePid(tree.descendantPids); } -async function listDescendantPids(rootPid: number): Promise { +async function listDescendantPids( + rootPid: number, + platform: NodeJS.Platform = process.platform, +): Promise { let output: string; try { - output = await runProcessListCommand(); + output = await runProcessListCommand(platform); } catch { return []; } @@ -179,13 +224,31 @@ function parseProcessListLine(line: string): { pid: number; parentPid: number } return { pid, parentPid }; } -async function runProcessListCommand(): Promise { - if (process.platform === "win32") { +async function runProcessListCommand(platform: NodeJS.Platform): Promise { + if (platform === "win32") { return await runWindowsProcessListCommand(); } return await runPsCommand(["-eo", "pid=,ppid="]); } +async function listProcessGroupPids(processGroupId: number): Promise { + let output: string; + try { + output = await runPsCommand(["-eo", "pid=,pgid="]); + } catch { + return []; + } + + const pids: number[] = []; + for (const line of output.split("\n")) { + const parsed = parseProcessListLine(line); + if (parsed?.parentPid === processGroupId) { + pids.push(parsed.pid); + } + } + return pids; +} + async function runPsCommand(args: string[]): Promise { return await runCapturedCommand("ps", args, "ps"); } diff --git a/src/acp/terminal-manager.ts b/src/acp/terminal-manager.ts index cfa5aeca..cf656cd0 100644 --- a/src/acp/terminal-manager.ts +++ b/src/acp/terminal-manager.ts @@ -25,6 +25,7 @@ import { } from "../spawn-command-options.js"; import type { ClientOperation, NonInteractivePermissionPolicy, PermissionMode } from "../types.js"; import { + beginProcessTreeTracking, createManagedProcessTree, rememberProcessTreePids, signalProcessTree, @@ -218,6 +219,7 @@ export class TerminalManager { exitPromise, resolveExit, }; + beginProcessTreeTracking(terminal.processTree); const appendOutput = (chunk: Buffer | string): void => { const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk); diff --git a/src/cli/queue/ipc-transport.ts b/src/cli/queue/ipc-transport.ts index df8c02b0..5a3242ca 100644 --- a/src/cli/queue/ipc-transport.ts +++ b/src/cli/queue/ipc-transport.ts @@ -70,7 +70,9 @@ export async function connectToQueueOwner( if (!shouldRetryQueueConnect(error)) { throw error; } - await waitMs(QUEUE_CONNECT_RETRY_MS); + if (attempt + 1 < attempts) { + await waitMs(QUEUE_CONNECT_RETRY_MS); + } } } diff --git a/src/cli/queue/ipc.ts b/src/cli/queue/ipc.ts index 95bf7b16..443061cd 100644 --- a/src/cli/queue/ipc.ts +++ b/src/cli/queue/ipc.ts @@ -711,11 +711,15 @@ function assertQueueOwnerMcpConfigMatches( ); } -function unavailableOwnerCountsAsMissing( - health: QueueOwnerHealth, +async function unavailableOwnerCountsAsMissing( + sessionId: string, startupProbe: boolean | undefined, -): boolean { - return !health.hasLease || startupProbe === true; +): Promise { + if (startupProbe) { + return true; + } + const health = await probeQueueOwnerHealth(sessionId); + return !health.hasLease; } export async function trySubmitToRunningOwner( @@ -754,8 +758,7 @@ export async function trySubmitToRunningOwner( return submitted; } - const health = await probeQueueOwnerHealth(options.sessionId); - if (unavailableOwnerCountsAsMissing(health, options.startupProbe)) { + if (await unavailableOwnerCountsAsMissing(options.sessionId, options.startupProbe)) { return undefined; } diff --git a/test/process-tree.test.ts b/test/process-tree.test.ts new file mode 100644 index 00000000..16821951 --- /dev/null +++ b/test/process-tree.test.ts @@ -0,0 +1,43 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { + createManagedProcessTree, + resolveProcessTreeSignalTargets, +} from "../src/acp/process-tree.js"; + +test("running POSIX trees signal the owned process group", () => { + const tree = createManagedProcessTree(4100, true, "darwin"); + tree.descendantPids.add(4101); + + assert.deepEqual(resolveProcessTreeSignalTargets(tree, true), [{ pid: -4100, tree: false }]); +}); + +test("exited POSIX roots never signal a potentially recycled process group", () => { + const tree = createManagedProcessTree(4200, true, "linux"); + tree.descendantPids.add(4201); + tree.descendantPids.add(4202); + + assert.deepEqual(resolveProcessTreeSignalTargets(tree, false), [ + { pid: 4201, tree: false }, + { pid: 4202, tree: false }, + ]); +}); + +test("Windows uses taskkill trees for a running root and remembered descendants after exit", () => { + const tree = createManagedProcessTree(4300, true, "win32"); + tree.descendantPids.add(4301); + tree.descendantPids.add(4302); + + assert.deepEqual(resolveProcessTreeSignalTargets(tree, true), [{ pid: 4300, tree: true }]); + assert.deepEqual(resolveProcessTreeSignalTargets(tree, false), [ + { pid: 4301, tree: true }, + { pid: 4302, tree: true }, + ]); +}); + +test("non-group processes signal only their root", () => { + const tree = createManagedProcessTree(4400, false, "linux"); + tree.descendantPids.add(4401); + + assert.deepEqual(resolveProcessTreeSignalTargets(tree, true), [{ pid: 4400, tree: false }]); +}); diff --git a/test/queue-ipc-errors.test.ts b/test/queue-ipc-errors.test.ts index 059dd5f8..d464209e 100644 --- a/test/queue-ipc-errors.test.ts +++ b/test/queue-ipc-errors.test.ts @@ -12,6 +12,7 @@ import { trySubmitToRunningOwner, } from "../src/cli/queue/ipc.js"; import { QueueConnectionError, QueueProtocolError } from "../src/errors.js"; +import { getPerfMetricsSnapshot, resetPerfMetrics } from "../src/perf-metrics.js"; import type { OutputFormatter } from "../src/types.js"; import { cleanupOwnerArtifacts, @@ -806,6 +807,38 @@ test("trySubmitToRunningOwner clears stale owner lock on protocol mismatch", asy }); }); +test("startup probe treats lease-before-bind as a miss without a health reconnect", async () => { + await withTempHome(async (homeDir) => { + const sessionId = "startup-lease-before-bind"; + const keeper = await startKeeperProcess(); + const { lockPath, socketPath } = queuePaths(homeDir, sessionId); + await writeQueueOwnerLock({ + lockPath, + pid: keeper.pid, + sessionId, + socketPath, + }); + + resetPerfMetrics(); + try { + const outcome = await trySubmitToRunningOwner({ + sessionId, + message: "hello", + permissionMode: "approve-reads", + outputFormatter: NOOP_OUTPUT_FORMATTER, + waitForCompletion: true, + startupProbe: true, + }); + assert.equal(outcome, undefined); + assert.equal(getPerfMetricsSnapshot().timings["queue.connect"]?.count, 1); + } finally { + resetPerfMetrics(); + await cleanupOwnerArtifacts({ socketPath, lockPath }); + stopProcess(keeper); + } + }); +}); + test("trySubmitToRunningOwner rejects MCP config changes for a live owner", async () => { await withTempHome(async (homeDir) => { const sessionId = "submit-mcp-config-owner-mismatch"; diff --git a/test/queue-owner-process.test.ts b/test/queue-owner-process.test.ts index bc769acb..cc1dc13f 100644 --- a/test/queue-owner-process.test.ts +++ b/test/queue-owner-process.test.ts @@ -274,6 +274,7 @@ describe("spawnQueueOwnerProcess startup capture lifecycle", () => { `; const ownerArgs = JSON.stringify(["--input-type=module", "-e", ownerCode]); const probe = ` + import { writeSync } from "node:fs"; import { spawnQueueOwnerProcess } from ${JSON.stringify(moduleUrl)}; process.env.ACPX_QUEUE_OWNER_ARGS = ${JSON.stringify(ownerArgs)}; const handle = spawnQueueOwnerProcess({ @@ -281,7 +282,7 @@ describe("spawnQueueOwnerProcess startup capture lifecycle", () => { permissionMode: "approve-reads", }); handle.stopStartupCapture(); - console.log(handle.pid); + writeSync(1, String(handle.pid)); `; const result = spawnSync(process.execPath, ["--input-type=module", "--eval", probe], { @@ -295,7 +296,10 @@ describe("spawnQueueOwnerProcess startup capture lifecycle", () => { 0, `submitter did not exit independently: ${result.stderr || String(result.signal)}`, ); - assert.ok(Number.isInteger(ownerPid) && ownerPid > 0, "expected detached owner pid"); + assert.ok( + Number.isInteger(ownerPid) && ownerPid > 0, + `expected detached owner pid; stdout=${JSON.stringify(result.stdout)} stderr=${JSON.stringify(result.stderr)}`, + ); assert.doesNotThrow(() => process.kill(ownerPid, 0), "owner should still be running"); } finally { if (Number.isInteger(ownerPid) && ownerPid > 0) { From f158b26b7ef43c362f8edee17097e8fe1e977c47 Mon Sep 17 00:00:00 2001 From: trumpyla Date: Tue, 28 Jul 2026 18:36:40 -0400 Subject: [PATCH 22/57] fix: wait for terminal output drain --- src/acp/terminal-manager.ts | 45 +++++++++++++++++++++++++++++++++---- 1 file changed, 41 insertions(+), 4 deletions(-) diff --git a/src/acp/terminal-manager.ts b/src/acp/terminal-manager.ts index cf656cd0..fd69dfbc 100644 --- a/src/acp/terminal-manager.ts +++ b/src/acp/terminal-manager.ts @@ -26,6 +26,7 @@ import { import type { ClientOperation, NonInteractivePermissionPolicy, PermissionMode } from "../types.js"; import { beginProcessTreeTracking, + captureProcessTreePids, createManagedProcessTree, rememberProcessTreePids, signalProcessTree, @@ -35,6 +36,7 @@ import { const DEFAULT_TERMINAL_OUTPUT_LIMIT_BYTES = 64 * 1024; const DEFAULT_KILL_GRACE_MS = 1_500; +const TERMINAL_OUTPUT_DRAIN_GRACE_MS = 50; type ManagedTerminal = { process: ChildProcessByStdio; @@ -236,14 +238,24 @@ export class TerminalManager { proc.stdout.on("data", appendOutput); proc.stderr.on("data", appendOutput); + const outputDrained = Promise.all([ + waitForReadableCompletion(proc.stdout), + waitForReadableCompletion(proc.stderr), + ]).then(() => {}); proc.once("exit", (exitCode, signal) => { terminal.exitCode = exitCode; terminal.signal = signal; rememberProcessTreePids(terminal.processTree); - terminal.resolveExit({ - exitCode: exitCode ?? null, - signal: signal ?? null, - }); + void (async () => { + await Promise.all([ + captureProcessTreePids(terminal.processTree, false), + waitForTerminalOutputDrain(outputDrained), + ]); + terminal.resolveExit({ + exitCode: exitCode ?? null, + signal: signal ?? null, + }); + })(); }); const terminalId = randomUUID(); @@ -470,6 +482,31 @@ export class TerminalManager { } } +function waitForReadableCompletion(stream: Readable): Promise { + if (stream.readableEnded || stream.closed) { + return Promise.resolve(); + } + return new Promise((resolve) => { + const finish = (): void => { + stream.off("end", finish); + stream.off("close", finish); + resolve(); + }; + stream.once("end", finish); + stream.once("close", finish); + }); +} + +async function waitForTerminalOutputDrain(outputDrained: Promise): Promise { + await new Promise((resolve) => { + const timeout = setTimeout(resolve, TERMINAL_OUTPUT_DRAIN_GRACE_MS); + void outputDrained.then(() => { + clearTimeout(timeout); + resolve(); + }); + }); +} + async function spawnTerminalProcess( params: CreateTerminalRequest, defaultCwd: string, From fdf12ea621f81e2e850a99ec44b3ac60f4a1b858 Mon Sep 17 00:00:00 2001 From: trumpyla Date: Wed, 29 Jul 2026 12:28:52 -0400 Subject: [PATCH 23/57] fix: publish queue owner leases atomically --- CHANGELOG.md | 14 +++++-- src/cli/queue/ipc.ts | 16 ++++++- src/cli/queue/lease-store.ts | 54 ++++++++++++++++++++---- test/queue-ipc-errors.test.ts | 45 ++++++++++++++++++++ test/queue-lease-store.test.ts | 76 ++++++++++++++++++++++++++++++++++ 5 files changed, 190 insertions(+), 15 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 11e7c878..79328972 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,16 @@ Repo: https://github.com/openclaw/acpx ### Fixes +- Runtime/agents: terminate owned adapter process groups on POSIX and + best-effort tracked process trees on Windows during normal and failed startup + cleanup so package-exec wrappers do not leave captured descendants running + after ACPX exits. + +- Runtime/queue: publish queue-owner leases atomically, preserve fresh malformed + locks during collisions, and treat only a fresh lease-before-bind owner as a + fast startup miss, preventing premature `QUEUE_NOT_ACCEPTING_REQUESTS` errors + without masking older unreachable owners. + ## 2026.7.27 (v0.13.0) ### Highlights @@ -45,10 +55,6 @@ Repo: https://github.com/openclaw/acpx - CLI/status: report a normal cold-start session as `agent starting` while preserving `needs reconnect` for an unreachable live owner. Thanks @guettli. -- Runtime/agents: terminate owned adapter process groups on POSIX and best-effort tracked process trees on Windows during normal and failed startup cleanup so package-exec wrappers do not leave captured descendants running after ACPX exits. - -- Runtime/queue: treat a cold-start owner lease that has not bound its socket yet as a startup miss without a second health probe, allowing the caller to recover instead of reporting `QUEUE_NOT_ACCEPTING_REQUESTS`. - ## 2026.7.23 (v0.12.1) ### Changes diff --git a/src/cli/queue/ipc.ts b/src/cli/queue/ipc.ts index 443061cd..f5d01c11 100644 --- a/src/cli/queue/ipc.ts +++ b/src/cli/queue/ipc.ts @@ -51,11 +51,17 @@ export { } from "./lease-store.js"; export type { QueueOwnerLease } from "./lease-store.js"; +const QUEUE_OWNER_STARTUP_GRACE_MS = 10_000; const STALE_OWNER_PROTOCOL_DETAIL_CODES = new Set([ "QUEUE_PROTOCOL_MALFORMED_MESSAGE", "QUEUE_PROTOCOL_UNEXPECTED_RESPONSE", ]); +function queueOwnerIsWithinStartupGrace(owner: QueueOwnerRecord): boolean { + const createdAt = Date.parse(owner.createdAt); + return Number.isFinite(createdAt) && Date.now() - createdAt < QUEUE_OWNER_STARTUP_GRACE_MS; +} + async function maybeRecoverStaleOwnerAfterProtocolMismatch(params: { sessionId: string; owner: QueueOwnerRecord; @@ -713,10 +719,16 @@ function assertQueueOwnerMcpConfigMatches( async function unavailableOwnerCountsAsMissing( sessionId: string, + owner: QueueOwnerRecord, startupProbe: boolean | undefined, ): Promise { if (startupProbe) { - return true; + const latestOwner = await readQueueOwnerRecord(sessionId); + return ( + !latestOwner || + latestOwner.ownerGeneration !== owner.ownerGeneration || + queueOwnerIsWithinStartupGrace(latestOwner) + ); } const health = await probeQueueOwnerHealth(sessionId); return !health.hasLease; @@ -758,7 +770,7 @@ export async function trySubmitToRunningOwner( return submitted; } - if (await unavailableOwnerCountsAsMissing(options.sessionId, options.startupProbe)) { + if (await unavailableOwnerCountsAsMissing(options.sessionId, owner, options.startupProbe)) { return undefined; } diff --git a/src/cli/queue/lease-store.ts b/src/cli/queue/lease-store.ts index a32487f8..c14a3182 100644 --- a/src/cli/queue/lease-store.ts +++ b/src/cli/queue/lease-store.ts @@ -1,4 +1,4 @@ -import { randomInt } from "node:crypto"; +import { randomInt, randomUUID } from "node:crypto"; import fs from "node:fs/promises"; import { isProcessAlive } from "../../process-liveness.js"; import { queueBaseDir, queueLockFilePath, queueSocketBaseDir, queueSocketPath } from "./paths.js"; @@ -15,6 +15,7 @@ const PROCESS_SIGTERM_GRACE_MS = 4_000; const PROCESS_SIGKILL_GRACE_MS = 1_500; const PROCESS_POLL_MS = 50; const QUEUE_OWNER_STALE_HEARTBEAT_MS = 15_000; +const QUEUE_OWNER_MALFORMED_LOCK_STALE_MS = QUEUE_OWNER_STALE_HEARTBEAT_MS; export type QueueOwnerRecord = { pid: number; @@ -189,6 +190,43 @@ async function cleanupStaleQueueOwner( }); } +function queueOwnerLockTempPath(lockPath: string): string { + return `${lockPath}.${process.pid}.${randomUUID()}.tmp`; +} + +async function writeQueueOwnerFileAtomically( + lockPath: string, + payload: string, + operation: "create" | "replace", +): Promise { + const tempPath = queueOwnerLockTempPath(lockPath); + try { + await fs.writeFile(tempPath, payload, { + encoding: "utf8", + flag: "wx", + mode: 0o600, + }); + if (operation === "create") { + await fs.link(tempPath, lockPath); + } else { + await fs.rename(tempPath, lockPath); + } + } finally { + await fs.rm(tempPath, { force: true }).catch(() => { + // best-effort cleanup after publication or a failed collision + }); + } +} + +async function malformedQueueOwnerLockIsStale(lockPath: string): Promise { + try { + const stat = await fs.stat(lockPath); + return Date.now() - stat.mtimeMs > QUEUE_OWNER_MALFORMED_LOCK_STALE_MS; + } catch { + return false; + } +} + async function retireStaleQueueOwner( sessionId: string, owner: QueueOwnerRecord | undefined, @@ -311,10 +349,7 @@ export async function tryAcquireQueueOwnerLease( ); try { - await fs.writeFile(lockPath, `${payload}\n`, { - encoding: "utf8", - flag: "wx", - }); + await writeQueueOwnerFileAtomically(lockPath, `${payload}\n`, "create"); await removeSocketFile(socketPath).catch(() => { // best-effort stale socket cleanup after ownership is acquired }); @@ -363,7 +398,10 @@ async function handleLeaseCollision(sessionId: string, error: unknown): Promise< const owner = await readQueueOwnerRecord(sessionId); if (!owner) { - await cleanupStaleQueueOwner(sessionId, owner); + const lockPath = queueLockFilePath(sessionId); + if (await malformedQueueOwnerLockIsStale(lockPath)) { + await cleanupStaleQueueOwner(sessionId, owner); + } return undefined; } @@ -418,9 +456,7 @@ export async function refreshQueueOwnerLease( null, 2, ); - await fs.writeFile(lease.lockPath, `${payload}\n`, { - encoding: "utf8", - }); + await writeQueueOwnerFileAtomically(lease.lockPath, `${payload}\n`, "replace"); } export async function releaseQueueOwnerLease(lease: QueueOwnerLease): Promise { diff --git a/test/queue-ipc-errors.test.ts b/test/queue-ipc-errors.test.ts index d464209e..9206de17 100644 --- a/test/queue-ipc-errors.test.ts +++ b/test/queue-ipc-errors.test.ts @@ -839,6 +839,51 @@ test("startup probe treats lease-before-bind as a miss without a health reconnec }); }); +test("startup probe fails closed for an older live owner without a socket", async () => { + await withTempHome(async (homeDir) => { + const sessionId = "startup-owner-not-accepting"; + const keeper = await startKeeperProcess(); + const { lockPath, socketPath } = queuePaths(homeDir, sessionId); + await writeQueueOwnerLock({ + lockPath, + pid: keeper.pid, + sessionId, + socketPath, + createdAt: "2000-01-01T00:00:00.000Z", + heartbeatAt: new Date().toISOString(), + }); + + resetPerfMetrics(); + try { + await assert.rejects( + async () => + await trySubmitToRunningOwner({ + sessionId, + message: "hello", + permissionMode: "approve-reads", + outputFormatter: NOOP_OUTPUT_FORMATTER, + waitForCompletion: true, + startupProbe: true, + }), + (error: unknown) => { + assert(error instanceof QueueConnectionError); + assert.equal(error.detailCode, "QUEUE_NOT_ACCEPTING_REQUESTS"); + assert.equal(error.retryable, true); + return true; + }, + ); + assert.equal(getPerfMetricsSnapshot().timings["queue.connect"]?.count, 1); + await fs.access(lockPath); + assert.equal(keeper.exitCode, null); + assert.equal(keeper.signalCode, null); + } finally { + resetPerfMetrics(); + await cleanupOwnerArtifacts({ socketPath, lockPath }); + stopProcess(keeper); + } + }); +}); + test("trySubmitToRunningOwner rejects MCP config changes for a live owner", async () => { await withTempHome(async (homeDir) => { const sessionId = "submit-mcp-config-owner-mismatch"; diff --git a/test/queue-lease-store.test.ts b/test/queue-lease-store.test.ts index 8ccd92a8..fc1d781a 100644 --- a/test/queue-lease-store.test.ts +++ b/test/queue-lease-store.test.ts @@ -178,6 +178,82 @@ test("tryAcquireQueueOwnerLease clears stale dead owners and can acquire on retr }); }); +test("tryAcquireQueueOwnerLease preserves a fresh malformed lock during collision", async () => { + await withTempHome(async (homeDir) => { + const sessionId = "fresh-malformed-owner"; + const { lockPath, socketPath } = queuePaths(homeDir, sessionId); + const malformedPayload = "{incomplete"; + await fs.mkdir(path.dirname(lockPath), { recursive: true }); + await fs.writeFile(lockPath, malformedPayload, "utf8"); + if (process.platform !== "win32") { + await fs.mkdir(path.dirname(socketPath), { recursive: true }); + await fs.writeFile(socketPath, "live-socket-placeholder", "utf8"); + } + + assert.equal(await tryAcquireQueueOwnerLease(sessionId), undefined); + assert.equal(await fs.readFile(lockPath, "utf8"), malformedPayload); + if (process.platform !== "win32") { + assert.equal(await fs.readFile(socketPath, "utf8"), "live-socket-placeholder"); + } + + await fs.rm(lockPath, { force: true }); + if (process.platform !== "win32") { + await fs.rm(socketPath, { force: true }); + } + }); +}); + +test("tryAcquireQueueOwnerLease removes a malformed lock only after it is stale", async () => { + await withTempHome(async (homeDir) => { + const sessionId = "stale-malformed-owner"; + const { lockPath, socketPath } = queuePaths(homeDir, sessionId); + await fs.mkdir(path.dirname(lockPath), { recursive: true }); + await fs.writeFile(lockPath, "{incomplete", "utf8"); + await fs.utimes(lockPath, new Date(0), new Date(0)); + if (process.platform !== "win32") { + await fs.mkdir(path.dirname(socketPath), { recursive: true }); + await fs.writeFile(socketPath, "stale-socket-placeholder", "utf8"); + } + + assert.equal(await tryAcquireQueueOwnerLease(sessionId), undefined); + await assert.rejects(fs.access(lockPath)); + if (process.platform !== "win32") { + await assert.rejects(fs.access(socketPath)); + } + + const lease = await tryAcquireQueueOwnerLease(sessionId); + assert(lease); + await releaseQueueOwnerLease(lease); + }); +}); + +test("refreshQueueOwnerLease never exposes a partial record to concurrent readers", async () => { + await withTempHome(async (homeDir) => { + const sessionId = "atomic-refresh"; + const lease = await tryAcquireQueueOwnerLease(sessionId); + assert(lease); + + try { + const writers = Array.from({ length: 200 }, async (_, index) => { + await refreshQueueOwnerLease(lease, { queueDepth: index % 5 }); + }); + const observations = await Promise.all( + Array.from({ length: 200 }, async () => await readQueueOwnerRecord(sessionId)), + ); + await Promise.all(writers); + assert.equal( + observations.every((record) => record !== undefined), + true, + ); + + const files = await fs.readdir(path.dirname(queueLockFilePath(sessionId, homeDir))); + assert.deepEqual(files, [path.basename(queueLockFilePath(sessionId, homeDir))]); + } finally { + await releaseQueueOwnerLease(lease); + } + }); +}); + test("readQueueOwnerStatus returns live owner details for a healthy owner", async () => { await withTempHome(async (homeDir) => { const sessionId = "healthy-owner"; From 006e333abae067dd3def612ea7fd8589865dcfdb Mon Sep 17 00:00:00 2001 From: trumpyla Date: Wed, 29 Jul 2026 12:51:53 -0400 Subject: [PATCH 24/57] fix: close lifecycle review races --- CHANGELOG.md | 10 +-- src/acp/process-tree.ts | 74 ++++++++++++++++++++--- src/cli/queue/lease-store.ts | 107 +++++++++++++++++++++++++-------- test/process-tree.test.ts | 41 +++++++++++++ test/queue-lease-store.test.ts | 23 +++++++ 5 files changed, 217 insertions(+), 38 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 79328972..6263a2d5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,12 +15,14 @@ Repo: https://github.com/openclaw/acpx - Runtime/agents: terminate owned adapter process groups on POSIX and best-effort tracked process trees on Windows during normal and failed startup cleanup so package-exec wrappers do not leave captured descendants running - after ACPX exits. + after ACPX exits; exit-triggered snapshots are observed before cleanup + completes, and external process-list discovery is bounded. - Runtime/queue: publish queue-owner leases atomically, preserve fresh malformed - locks during collisions, and treat only a fresh lease-before-bind owner as a - fast startup miss, preventing premature `QUEUE_NOT_ACCEPTING_REQUESTS` errors - without masking older unreachable owners. + locks during collisions, serialize generation-checked refresh and release, + and treat only a fresh lease-before-bind owner as a fast startup miss, + preventing premature `QUEUE_NOT_ACCEPTING_REQUESTS` errors without masking + older unreachable owners or letting released owners overwrite successors. ## 2026.7.27 (v0.13.0) diff --git a/src/acp/process-tree.ts b/src/acp/process-tree.ts index 351e1935..421dc200 100644 --- a/src/acp/process-tree.ts +++ b/src/acp/process-tree.ts @@ -1,6 +1,7 @@ import { spawn } from "node:child_process"; const PROCESS_TREE_POLL_MS = 25; +const PROCESS_LIST_COMMAND_TIMEOUT_MS = 1_000; export type ManagedProcessTree = { rootPid: number | undefined; @@ -145,15 +146,41 @@ export async function waitForProcessTreeExit( timeoutMs: number, ): Promise { const deadline = Date.now() + Math.max(0, timeoutMs); - let rootIsRunning = rootRunning(); - while (rootIsRunning || hasLiveManagedProcessTree(tree, rootIsRunning)) { + while (true) { + let rootIsRunning = rootRunning(); + if (!rootIsRunning) { + const snapshotCompleted = await waitForPriorSnapshotBeforeDeadline(tree, deadline); + if (!snapshotCompleted) { + return false; + } + rootIsRunning = rootRunning(); + } + if (!rootIsRunning && !hasLiveManagedProcessTree(tree, rootIsRunning)) { + return true; + } if (Date.now() >= deadline) { return false; } await waitMs(Math.min(PROCESS_TREE_POLL_MS, Math.max(0, deadline - Date.now()))); - rootIsRunning = rootRunning(); } - return true; +} + +async function waitForPriorSnapshotBeforeDeadline( + tree: ManagedProcessTree, + deadline: number, +): Promise { + const snapshot = tree.snapshotPromise; + if (!snapshot) { + return true; + } + const remainingMs = Math.max(0, deadline - Date.now()); + return await Promise.race([ + snapshot.then( + () => true, + () => true, + ), + waitMs(remainingMs).then(() => false), + ]); } function hasLiveManagedProcessTree(tree: ManagedProcessTree, rootRunning: boolean): boolean { @@ -277,6 +304,33 @@ async function runCapturedCommand( }); let stdout = ""; let stderr = ""; + let settled = false; + + const finish = (result: { output: string } | { error: Error }): void => { + if (settled) { + return; + } + settled = true; + clearTimeout(timeout); + if ("output" in result) { + resolve(result.output); + } else { + reject(result.error); + } + }; + const timeout = setTimeout(() => { + child.stdout.destroy(); + child.stderr.destroy(); + try { + child.kill("SIGKILL"); + } catch { + // best-effort cleanup for a stalled process-list command + } + child.unref(); + finish({ + error: new Error(`${description} did not exit within ${PROCESS_LIST_COMMAND_TIMEOUT_MS}ms`), + }); + }, PROCESS_LIST_COMMAND_TIMEOUT_MS); child.stdout.setEncoding("utf8"); child.stderr.setEncoding("utf8"); @@ -286,17 +340,19 @@ async function runCapturedCommand( child.stderr.on("data", (chunk: string) => { stderr += chunk; }); - child.once("error", reject); + child.once("error", (error) => { + finish({ error }); + }); child.once("close", (code, signal) => { if (code === 0) { - resolve(stdout); + finish({ output: stdout }); return; } - reject( - new Error( + finish({ + error: new Error( `${description} exited with code ${code ?? "null"} signal ${signal ?? "null"}: ${stderr}`, ), - ); + }); }); }); } diff --git a/src/cli/queue/lease-store.ts b/src/cli/queue/lease-store.ts index c14a3182..48914c16 100644 --- a/src/cli/queue/lease-store.ts +++ b/src/cli/queue/lease-store.ts @@ -39,6 +39,14 @@ export type QueueOwnerLease = { mcpConfigFingerprint?: string; }; +type QueueOwnerLeaseState = { + pendingRefresh: Promise; + released: boolean; + releasePromise?: Promise; +}; + +const queueOwnerLeaseStates = new WeakMap(); + export type QueueOwnerStatus = { pid: number; socketPath: string; @@ -353,7 +361,7 @@ export async function tryAcquireQueueOwnerLease( await removeSocketFile(socketPath).catch(() => { // best-effort stale socket cleanup after ownership is acquired }); - return { + const lease = { sessionId, lockPath, socketPath, @@ -361,6 +369,11 @@ export async function tryAcquireQueueOwnerLease( ownerGeneration, ...mcpConfigMetadata, }; + queueOwnerLeaseStates.set(lease, { + pendingRefresh: Promise.resolve(), + released: false, + }); + return lease; } catch (error) { return await handleLeaseCollision(sessionId, error); } @@ -441,34 +454,78 @@ export async function refreshQueueOwnerLease( }, nowIsoFactory: () => string = nowIso, ): Promise { - const payload = JSON.stringify( - { - pid: process.pid, - sessionId: lease.sessionId, - socketPath: lease.socketPath, - createdAt: lease.createdAt, - heartbeatAt: nowIsoFactory(), - ownerGeneration: lease.ownerGeneration, - queueDepth: Math.max(0, Math.round(options.queueDepth)), - ...(lease.mcpConfigPath ? { mcpConfigPath: lease.mcpConfigPath } : {}), - ...(lease.mcpConfigFingerprint ? { mcpConfigFingerprint: lease.mcpConfigFingerprint } : {}), - }, - null, - 2, - ); - await writeQueueOwnerFileAtomically(lease.lockPath, `${payload}\n`, "replace"); + const state = queueOwnerLeaseState(lease); + if (state.released) { + return; + } + const refresh = state.pendingRefresh.then(async () => { + if (state.released || !(await queueOwnerLeaseStillOwnsLock(lease))) { + return; + } + const payload = JSON.stringify( + { + pid: process.pid, + sessionId: lease.sessionId, + socketPath: lease.socketPath, + createdAt: lease.createdAt, + heartbeatAt: nowIsoFactory(), + ownerGeneration: lease.ownerGeneration, + queueDepth: Math.max(0, Math.round(options.queueDepth)), + ...(lease.mcpConfigPath ? { mcpConfigPath: lease.mcpConfigPath } : {}), + ...(lease.mcpConfigFingerprint ? { mcpConfigFingerprint: lease.mcpConfigFingerprint } : {}), + }, + null, + 2, + ); + await writeQueueOwnerFileAtomically(lease.lockPath, `${payload}\n`, "replace"); + }); + state.pendingRefresh = refresh.catch(() => { + // Keep the serialization chain usable after a best-effort refresh failure. + }); + await refresh; } export async function releaseQueueOwnerLease(lease: QueueOwnerLease): Promise { - await removeSocketFile(lease.socketPath).catch(() => { - // ignore best-effort cleanup failures - }); + const state = queueOwnerLeaseState(lease); + if (!state.releasePromise) { + state.released = true; + state.releasePromise = (async () => { + await state.pendingRefresh; + if (!(await queueOwnerLeaseStillOwnsLock(lease))) { + return; + } + await removeSocketFile(lease.socketPath).catch(() => { + // ignore best-effort cleanup failures + }); - await fs.unlink(lease.lockPath).catch((error) => { - if ((error as NodeJS.ErrnoException).code !== "ENOENT") { - throw error; - } - }); + if (!(await queueOwnerLeaseStillOwnsLock(lease))) { + return; + } + await fs.unlink(lease.lockPath).catch((error) => { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") { + throw error; + } + }); + })(); + } + await state.releasePromise; +} + +function queueOwnerLeaseState(lease: QueueOwnerLease): QueueOwnerLeaseState { + let state = queueOwnerLeaseStates.get(lease); + if (!state) { + state = { + pendingRefresh: Promise.resolve(), + released: false, + }; + queueOwnerLeaseStates.set(lease, state); + } + return state; +} + +async function queueOwnerLeaseStillOwnsLock(lease: QueueOwnerLease): Promise { + const owner = await readQueueOwnerRecord(lease.sessionId); + return owner?.ownerGeneration === lease.ownerGeneration; } export async function terminateQueueOwnerForSession(sessionId: string): Promise { diff --git a/test/process-tree.test.ts b/test/process-tree.test.ts index 16821951..0b37f67c 100644 --- a/test/process-tree.test.ts +++ b/test/process-tree.test.ts @@ -1,8 +1,13 @@ import assert from "node:assert/strict"; +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; import test from "node:test"; import { + captureProcessTreePids, createManagedProcessTree, resolveProcessTreeSignalTargets, + waitForProcessTreeExit, } from "../src/acp/process-tree.js"; test("running POSIX trees signal the owned process group", () => { @@ -41,3 +46,39 @@ test("non-group processes signal only their root", () => { assert.deepEqual(resolveProcessTreeSignalTargets(tree, true), [{ pid: 4400, tree: false }]); }); + +test("waitForProcessTreeExit waits for the exit-triggered process snapshot", async () => { + const tree = createManagedProcessTree(4500, true, "linux"); + let resolveSnapshot: (() => void) | undefined; + tree.snapshotPromise = new Promise((resolve) => { + resolveSnapshot = resolve; + }); + + const exitResult = waitForProcessTreeExit(tree, () => false, 50); + tree.descendantPids.add(process.pid); + resolveSnapshot?.(); + + assert.equal(await exitResult, false); +}); + +test( + "process-tree snapshots bound stalled process-list commands", + { skip: process.platform === "win32" }, + async () => { + const fixtureDir = await fs.mkdtemp(path.join(os.tmpdir(), "acpx-stalled-ps-")); + const psPath = path.join(fixtureDir, "ps"); + const originalPath = process.env.PATH; + await fs.writeFile(psPath, "#!/bin/sh\nwhile :; do :; done\n", { mode: 0o755 }); + process.env.PATH = `${fixtureDir}${path.delimiter}${originalPath ?? ""}`; + + try { + const tree = createManagedProcessTree(process.pid, true, "linux"); + const startedAt = Date.now(); + await captureProcessTreePids(tree, true); + assert(Date.now() - startedAt < 3_000); + } finally { + process.env.PATH = originalPath; + await fs.rm(fixtureDir, { recursive: true, force: true }); + } + }, +); diff --git a/test/queue-lease-store.test.ts b/test/queue-lease-store.test.ts index fc1d781a..1890cf51 100644 --- a/test/queue-lease-store.test.ts +++ b/test/queue-lease-store.test.ts @@ -254,6 +254,29 @@ test("refreshQueueOwnerLease never exposes a partial record to concurrent reader }); }); +test("released owners cannot overwrite or remove a successor lease", async () => { + await withTempHome(async () => { + const sessionId = "released-owner-refresh"; + const releasedLease = await tryAcquireQueueOwnerLease(sessionId); + assert(releasedLease); + await releaseQueueOwnerLease(releasedLease); + + const successorLease = await tryAcquireQueueOwnerLease(sessionId); + assert(successorLease); + try { + await refreshQueueOwnerLease(releasedLease, { queueDepth: 9 }); + await releaseQueueOwnerLease(releasedLease); + + const record = await readQueueOwnerRecord(sessionId); + assert(record); + assert.equal(record.ownerGeneration, successorLease.ownerGeneration); + assert.equal(record.queueDepth, 0); + } finally { + await releaseQueueOwnerLease(successorLease); + } + }); +}); + test("readQueueOwnerStatus returns live owner details for a healthy owner", async () => { await withTempHome(async (homeDir) => { const sessionId = "healthy-owner"; From 2a4e074a0acb473ff6aa4b3334b59c57a7969e43 Mon Sep 17 00:00:00 2001 From: trumpyla Date: Wed, 29 Jul 2026 13:14:18 -0400 Subject: [PATCH 25/57] fix: validate remembered process identities --- CHANGELOG.md | 6 +- src/acp/process-tree.ts | 143 ++++++++++++++++++++------------- src/cli/queue/lease-store.ts | 7 +- test/process-tree.test.ts | 52 ++++++++++++ test/queue-lease-store.test.ts | 21 +++++ 5 files changed, 166 insertions(+), 63 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6263a2d5..012e9b91 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,13 +16,15 @@ Repo: https://github.com/openclaw/acpx best-effort tracked process trees on Windows during normal and failed startup cleanup so package-exec wrappers do not leave captured descendants running after ACPX exits; exit-triggered snapshots are observed before cleanup - completes, and external process-list discovery is bounded. + completes, external process-list discovery is bounded, and remembered + descendants are identity-checked before later signaling. - Runtime/queue: publish queue-owner leases atomically, preserve fresh malformed locks during collisions, serialize generation-checked refresh and release, and treat only a fresh lease-before-bind owner as a fast startup miss, preventing premature `QUEUE_NOT_ACCEPTING_REQUESTS` errors without masking - older unreachable owners or letting released owners overwrite successors. + older unreachable owners, letting released owners overwrite successors, or + leaving stale dangling-symlink locks unrecoverable. ## 2026.7.27 (v0.13.0) diff --git a/src/acp/process-tree.ts b/src/acp/process-tree.ts index 421dc200..f8f26c25 100644 --- a/src/acp/process-tree.ts +++ b/src/acp/process-tree.ts @@ -8,9 +8,16 @@ export type ManagedProcessTree = { killProcessGroup: boolean; platform: NodeJS.Platform; descendantPids: Set; + descendantIdentities: Map; snapshotPromise?: Promise; }; +type ProcessListEntry = { + pid: number; + parentPid: number; + identity: string; +}; + export function createManagedProcessTree( rootPid: number | undefined, killProcessGroup: boolean, @@ -21,6 +28,7 @@ export function createManagedProcessTree( killProcessGroup, platform, descendantPids: new Set(), + descendantIdentities: new Map(), }; } @@ -72,19 +80,20 @@ async function recordCurrentProcessTreePids(tree: ManagedProcessTree): Promise { - let output: string; - try { - output = await runProcessListCommand(platform); - } catch { +): Promise { + const processList = await readProcessList(platform); + if (!processList) { return []; } - const childrenByParent = new Map(); - for (const line of output.split("\n")) { - addProcessListLine(childrenByParent, line); + const childrenByParent = new Map(); + for (const processEntry of processList) { + const children = childrenByParent.get(processEntry.parentPid); + if (children) { + children.push(processEntry); + } else { + childrenByParent.set(processEntry.parentPid, [processEntry]); + } } - const descendants: number[] = []; + const descendants: ProcessListEntry[] = []; const queue = [...(childrenByParent.get(rootPid) ?? [])]; for (let index = 0; index < queue.length; index += 1) { - const pid = queue[index]; - descendants.push(pid); - queue.push(...(childrenByParent.get(pid) ?? [])); + const processEntry = queue[index]; + descendants.push(processEntry); + queue.push(...(childrenByParent.get(processEntry.pid) ?? [])); } return descendants; } -function addProcessListLine(childrenByParent: Map, line: string): void { - const parsed = parseProcessListLine(line); - if (!parsed) { - return; - } - - const children = childrenByParent.get(parsed.parentPid); - if (children) { - children.push(parsed.pid); - } else { - childrenByParent.set(parsed.parentPid, [parsed.pid]); - } -} - -function parseProcessListLine(line: string): { pid: number; parentPid: number } | undefined { - const match = line.trim().match(/^(\d+)\s+(\d+)$/); +function parseProcessListLine(line: string): ProcessListEntry | undefined { + const match = line.trim().match(/^(\d+)\s+(\d+)\s+(.+)$/); if (!match) { return undefined; } const pid = Number(match[1]); const parentPid = Number(match[2]); + const identity = match[3].trim(); if (!Number.isInteger(pid) || !Number.isInteger(parentPid) || pid <= 0 || parentPid <= 0) { return undefined; } - return { pid, parentPid }; -} - -async function runProcessListCommand(platform: NodeJS.Platform): Promise { - if (platform === "win32") { - return await runWindowsProcessListCommand(); + if (!identity) { + return undefined; } - return await runPsCommand(["-eo", "pid=,ppid="]); + return { pid, parentPid, identity }; } -async function listProcessGroupPids(processGroupId: number): Promise { +async function readProcessList(platform: NodeJS.Platform): Promise { let output: string; try { - output = await runPsCommand(["-eo", "pid=,pgid="]); + output = + platform === "win32" + ? await runWindowsProcessListCommand() + : await runPsCommand(["-eo", "pid=,pgid=,lstart="]); } catch { + return undefined; + } + return output + .split("\n") + .map((line) => parseProcessListLine(line)) + .filter((entry): entry is ProcessListEntry => entry !== undefined); +} + +async function listProcessGroupEntries( + processGroupId: number, + platform: NodeJS.Platform, +): Promise { + const processList = await readProcessList(platform); + if (!processList) { return []; } + return processList.filter((entry) => entry.parentPid === processGroupId); +} - const pids: number[] = []; - for (const line of output.split("\n")) { - const parsed = parseProcessListLine(line); - if (parsed?.parentPid === processGroupId) { - pids.push(parsed.pid); +async function retainOwnedProcessTreePids(tree: ManagedProcessTree): Promise { + const processList = await readProcessList(tree.platform); + if (!processList) { + tree.descendantPids.clear(); + tree.descendantIdentities.clear(); + return; + } + const currentByPid = new Map(processList.map((entry) => [entry.pid, entry])); + for (const pid of tree.descendantPids) { + const current = currentByPid.get(pid); + const capturedIdentity = tree.descendantIdentities.get(pid); + const remainsInOwnedGroup = tree.platform === "win32" || current?.parentPid === tree.rootPid; + if (!current || current.identity !== capturedIdentity || !remainsInOwnedGroup) { + tree.descendantPids.delete(pid); + tree.descendantIdentities.delete(pid); } } - return pids; } async function runPsCommand(args: string[]): Promise { @@ -283,7 +309,7 @@ async function runPsCommand(args: string[]): Promise { async function runWindowsProcessListCommand(): Promise { const command = [ "Get-CimInstance Win32_Process |", - 'ForEach-Object { "$($_.ProcessId) $($_.ParentProcessId)" }', + 'ForEach-Object { "$($_.ProcessId) $($_.ParentProcessId) $($_.CreationDate.ToUniversalTime().Ticks)" }', ].join(" "); return await runCapturedCommand( "powershell.exe", @@ -389,14 +415,15 @@ function hasLiveProcessGroup(processGroupId: number): boolean { } } -function hasLivePid(pids: Set): boolean { +function hasLivePid(tree: ManagedProcessTree): boolean { let live = false; - for (const pid of pids) { + for (const pid of tree.descendantPids) { try { process.kill(pid, 0); live = true; } catch { - pids.delete(pid); + tree.descendantPids.delete(pid); + tree.descendantIdentities.delete(pid); } } return live; diff --git a/src/cli/queue/lease-store.ts b/src/cli/queue/lease-store.ts index 48914c16..7d109204 100644 --- a/src/cli/queue/lease-store.ts +++ b/src/cli/queue/lease-store.ts @@ -8,9 +8,10 @@ export { isProcessAlive } from "../../process-liveness.js"; // Budget for graceful SIGTERM shutdown of a queue-owner process. // The owner runs AcpClient.close() during shutdown: // stdin-close grace (100 ms) + SIGTERM wait (1 500 ms) + SIGKILL wait (1 000 ms) = 2 600 ms worst case. -// We add ~1 400 ms of headroom for event-loop latency and process startup overhead → 4 000 ms. +// Process identity validation can add two bounded 1 000 ms process-list calls. +// Add ~1 900 ms of headroom for those calls, event-loop latency, and process startup overhead. // If the owner does not exit within this window we escalate to SIGKILL. -const PROCESS_SIGTERM_GRACE_MS = 4_000; +const PROCESS_SIGTERM_GRACE_MS = 6_500; // After SIGKILL the OS terminates the process almost immediately; 1 500 ms is generous. const PROCESS_SIGKILL_GRACE_MS = 1_500; const PROCESS_POLL_MS = 50; @@ -228,7 +229,7 @@ async function writeQueueOwnerFileAtomically( async function malformedQueueOwnerLockIsStale(lockPath: string): Promise { try { - const stat = await fs.stat(lockPath); + const stat = await fs.lstat(lockPath); return Date.now() - stat.mtimeMs > QUEUE_OWNER_MALFORMED_LOCK_STALE_MS; } catch { return false; diff --git a/test/process-tree.test.ts b/test/process-tree.test.ts index 0b37f67c..27536e49 100644 --- a/test/process-tree.test.ts +++ b/test/process-tree.test.ts @@ -1,15 +1,20 @@ import assert from "node:assert/strict"; +import { execFile } from "node:child_process"; import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import test from "node:test"; +import { promisify } from "node:util"; import { captureProcessTreePids, createManagedProcessTree, resolveProcessTreeSignalTargets, + signalProcessTree, waitForProcessTreeExit, } from "../src/acp/process-tree.js"; +const execFileAsync = promisify(execFile); + test("running POSIX trees signal the owned process group", () => { const tree = createManagedProcessTree(4100, true, "darwin"); tree.descendantPids.add(4101); @@ -61,6 +66,12 @@ test("waitForProcessTreeExit waits for the exit-triggered process snapshot", asy assert.equal(await exitResult, false); }); +test("waitForProcessTreeExit keeps a live non-group root in the cleanup loop", async () => { + const tree = createManagedProcessTree(process.pid, false, process.platform); + + assert.equal(await waitForProcessTreeExit(tree, () => true, 10), false); +}); + test( "process-tree snapshots bound stalled process-list commands", { skip: process.platform === "win32" }, @@ -82,3 +93,44 @@ test( } }, ); + +test( + "signaling a running POSIX group does not wait for another process-list snapshot", + { skip: process.platform === "win32" }, + async () => { + const fixtureDir = await fs.mkdtemp(path.join(os.tmpdir(), "acpx-stalled-ps-signal-")); + const psPath = path.join(fixtureDir, "ps"); + const originalPath = process.env.PATH; + await fs.writeFile(psPath, "#!/bin/sh\nwhile :; do :; done\n", { mode: 0o755 }); + process.env.PATH = `${fixtureDir}${path.delimiter}${originalPath ?? ""}`; + + try { + const tree = createManagedProcessTree(process.pid, true, process.platform); + const startedAt = Date.now(); + await signalProcessTree(tree, true, "SIGCONT"); + assert(Date.now() - startedAt < 500); + } finally { + process.env.PATH = originalPath; + await fs.rm(fixtureDir, { recursive: true, force: true }); + } + }, +); + +test( + "exited POSIX trees discard remembered PIDs whose identity changed", + { skip: process.platform === "win32" }, + async () => { + const { stdout } = await execFileAsync("ps", ["-o", "pgid=", "-p", String(process.pid)]); + const processGroupId = Number(stdout.trim()); + assert(Number.isInteger(processGroupId)); + + const tree = createManagedProcessTree(processGroupId, true, process.platform); + tree.descendantPids.add(process.pid); + tree.descendantIdentities.set(process.pid, "not-this-process"); + + await signalProcessTree(tree, false, "SIGCONT"); + + assert.equal(tree.descendantPids.has(process.pid), false); + assert.equal(tree.descendantIdentities.has(process.pid), false); + }, +); diff --git a/test/queue-lease-store.test.ts b/test/queue-lease-store.test.ts index 1890cf51..cf67cc90 100644 --- a/test/queue-lease-store.test.ts +++ b/test/queue-lease-store.test.ts @@ -227,6 +227,27 @@ test("tryAcquireQueueOwnerLease removes a malformed lock only after it is stale" }); }); +test( + "tryAcquireQueueOwnerLease ages out a stale dangling symlink lock", + { skip: process.platform === "win32" }, + async () => { + await withTempHome(async (homeDir) => { + const sessionId = "stale-dangling-symlink-owner"; + const { lockPath } = queuePaths(homeDir, sessionId); + await fs.mkdir(path.dirname(lockPath), { recursive: true }); + await fs.symlink("missing-owner-record", lockPath); + await fs.lutimes(lockPath, new Date(0), new Date(0)); + + assert.equal(await tryAcquireQueueOwnerLease(sessionId), undefined); + await assert.rejects(fs.lstat(lockPath)); + + const lease = await tryAcquireQueueOwnerLease(sessionId); + assert(lease); + await releaseQueueOwnerLease(lease); + }); + }, +); + test("refreshQueueOwnerLease never exposes a partial record to concurrent readers", async () => { await withTempHome(async (homeDir) => { const sessionId = "atomic-refresh"; From 2537ffbc54097d922e2eecdba40d83af880a6b5a Mon Sep 17 00:00:00 2001 From: trumpyla Date: Wed, 29 Jul 2026 13:31:41 -0400 Subject: [PATCH 26/57] fix: rescan process groups during shutdown --- CHANGELOG.md | 5 ++-- src/acp/process-tree.ts | 51 ++++++++++++++++++++++++++++++++++----- test/process-tree.test.ts | 42 +++++++++++++++++++++++++++++++- 3 files changed, 89 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 012e9b91..6a4da8d1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,8 +16,9 @@ Repo: https://github.com/openclaw/acpx best-effort tracked process trees on Windows during normal and failed startup cleanup so package-exec wrappers do not leave captured descendants running after ACPX exits; exit-triggered snapshots are observed before cleanup - completes, external process-list discovery is bounded, and remembered - descendants are identity-checked before later signaling. + completes, owned POSIX group members created during shutdown are discovered, + external process-list discovery is bounded, and remembered descendants are + identity-checked before later signaling. - Runtime/queue: publish queue-owner leases atomically, preserve fresh malformed locks during collisions, serialize generation-checked refresh and release, diff --git a/src/acp/process-tree.ts b/src/acp/process-tree.ts index f8f26c25..40758cee 100644 --- a/src/acp/process-tree.ts +++ b/src/acp/process-tree.ts @@ -112,7 +112,11 @@ export async function signalProcessTree( if (!rootRunning) { await captureProcessTreePids(tree, false); - await retainOwnedProcessTreePids(tree); + const refreshed = await refreshExitedProcessTreePids(tree); + if (!refreshed) { + tree.descendantPids.clear(); + tree.descendantIdentities.clear(); + } } for (const target of resolveProcessTreeSignalTargets(tree, rootRunning)) { if (target.tree) { @@ -168,7 +172,9 @@ export async function waitForProcessTreeExit( rootIsRunning = rootRunning(); } if (!rootIsRunning && !hasLiveManagedProcessTree(tree, rootIsRunning)) { - return true; + if (await exitedTreeRemainsEmptyAfterRefresh(tree)) { + return true; + } } if (Date.now() >= deadline) { return false; @@ -195,6 +201,10 @@ async function waitForPriorSnapshotBeforeDeadline( ]); } +async function exitedTreeRemainsEmptyAfterRefresh(tree: ManagedProcessTree): Promise { + return (await refreshExitedProcessTreePids(tree)) && !hasLiveManagedProcessTree(tree, false); +} + function hasLiveManagedProcessTree(tree: ManagedProcessTree, rootRunning: boolean): boolean { const rootPid = tree.rootPid; if ( @@ -283,14 +293,27 @@ async function listProcessGroupEntries( return processList.filter((entry) => entry.parentPid === processGroupId); } -async function retainOwnedProcessTreePids(tree: ManagedProcessTree): Promise { +async function refreshExitedProcessTreePids(tree: ManagedProcessTree): Promise { + const rootPid = tree.rootPid; + if (!tree.killProcessGroup || !rootPid) { + return true; + } const processList = await readProcessList(tree.platform); if (!processList) { - tree.descendantPids.clear(); - tree.descendantIdentities.clear(); - return; + return false; } const currentByPid = new Map(processList.map((entry) => [entry.pid, entry])); + retainCurrentProcessTreePids(tree, currentByPid); + if (tree.platform !== "win32") { + discoverCurrentPosixGroupMembers(tree, rootPid, processList); + } + return true; +} + +function retainCurrentProcessTreePids( + tree: ManagedProcessTree, + currentByPid: Map, +): void { for (const pid of tree.descendantPids) { const current = currentByPid.get(pid); const capturedIdentity = tree.descendantIdentities.get(pid); @@ -302,6 +325,22 @@ async function retainOwnedProcessTreePids(tree: ManagedProcessTree): Promise entry.parentPid === rootPid); + if (currentGroup.some((entry) => entry.pid === rootPid)) { + // A live process whose PID equals the old group leader means the numeric + // PID/PGID was recycled after the owned group became empty. + tree.descendantPids.clear(); + tree.descendantIdentities.clear(); + return; + } + recordProcessTreePids(tree, currentGroup); +} + async function runPsCommand(args: string[]): Promise { return await runCapturedCommand("ps", args, "ps"); } diff --git a/test/process-tree.test.ts b/test/process-tree.test.ts index 27536e49..1a2e926c 100644 --- a/test/process-tree.test.ts +++ b/test/process-tree.test.ts @@ -1,5 +1,6 @@ import assert from "node:assert/strict"; -import { execFile } from "node:child_process"; +import { execFile, spawn } from "node:child_process"; +import { once } from "node:events"; import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; @@ -134,3 +135,42 @@ test( assert.equal(tree.descendantIdentities.has(process.pid), false); }, ); + +test( + "exited POSIX trees discover descendants spawned after the exit snapshot", + { skip: process.platform === "win32" }, + async () => { + const child = spawn( + "sh", + ["-c", "trap 'sleep 30 & exit 0' TERM; echo ready; while :; do sleep 1; done"], + { + detached: true, + stdio: ["ignore", "pipe", "ignore"], + }, + ); + const rootPid = child.pid; + assert(rootPid); + + try { + await once(child.stdout, "data"); + const tree = createManagedProcessTree(rootPid, true, process.platform); + await captureProcessTreePids(tree, true); + await signalProcessTree(tree, true, "SIGTERM"); + if (child.exitCode === null && child.signalCode === null) { + await once(child, "exit"); + } + + assert.equal(await waitForProcessTreeExit(tree, () => false, 100), false); + + await signalProcessTree(tree, false, "SIGKILL"); + assert.equal(await waitForProcessTreeExit(tree, () => false, 2_000), true); + } finally { + try { + process.kill(-rootPid, "SIGKILL"); + } catch { + // The process group was already cleaned up. + } + child.stdout.destroy(); + } + }, +); From 79457aeb6c463aa1fca8388ad5bc6f30ec8af3a9 Mon Sep 17 00:00:00 2001 From: trumpyla Date: Wed, 29 Jul 2026 14:24:51 -0400 Subject: [PATCH 27/57] fix: close remaining process tree races --- CHANGELOG.md | 5 +- src/acp/client.ts | 7 ++- src/acp/process-tree.ts | 107 +++++++++++++++++++++++++++++++----- src/acp/terminal-manager.ts | 8 ++- test/process-tree.test.ts | 49 +++++++++++++++++ 5 files changed, 157 insertions(+), 19 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6a4da8d1..33ccbbde 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,8 +16,9 @@ Repo: https://github.com/openclaw/acpx best-effort tracked process trees on Windows during normal and failed startup cleanup so package-exec wrappers do not leave captured descendants running after ACPX exits; exit-triggered snapshots are observed before cleanup - completes, owned POSIX group members created during shutdown are discovered, - external process-list discovery is bounded, and remembered descendants are + completes, owned POSIX group members created during shutdown are discovered + and re-signaled during forced cleanup, external process-list discovery is + bounded, and Windows parent edges plus remembered descendants are identity-checked before later signaling. - Runtime/queue: publish queue-owner leases atomically, preserve fresh malformed diff --git a/src/acp/client.ts b/src/acp/client.ts index 9b2feede..24d5e390 100644 --- a/src/acp/client.ts +++ b/src/acp/client.ts @@ -1471,7 +1471,12 @@ export class AcpClient { } catch { // best effort } - return await waitForProcessTreeExit(processTree, () => isChildProcessRunning(child), waitMs); + return await waitForProcessTreeExit( + processTree, + () => isChildProcessRunning(child), + waitMs, + signal === "SIGKILL" ? signal : undefined, + ); } private detachAgentHandles(agent: ChildProcess, unref: boolean): void { diff --git a/src/acp/process-tree.ts b/src/acp/process-tree.ts index 40758cee..f5df3e05 100644 --- a/src/acp/process-tree.ts +++ b/src/acp/process-tree.ts @@ -9,10 +9,11 @@ export type ManagedProcessTree = { platform: NodeJS.Platform; descendantPids: Set; descendantIdentities: Map; + rootIdentity?: string; snapshotPromise?: Promise; }; -type ProcessListEntry = { +export type ProcessListEntry = { pid: number; parentPid: number; identity: string; @@ -82,7 +83,7 @@ async function recordCurrentProcessTreePids(tree: ManagedProcessTree): Promise boolean, timeoutMs: number, + finalSignal?: NodeJS.Signals, ): Promise { const deadline = Date.now() + Math.max(0, timeoutMs); + const signaledIdentities = new Set(); while (true) { let rootIsRunning = rootRunning(); if (!rootIsRunning) { @@ -172,7 +175,7 @@ export async function waitForProcessTreeExit( rootIsRunning = rootRunning(); } if (!rootIsRunning && !hasLiveManagedProcessTree(tree, rootIsRunning)) { - if (await exitedTreeRemainsEmptyAfterRefresh(tree)) { + if (await exitedTreeRemainsEmptyAfterRefresh(tree, finalSignal, signaledIdentities)) { return true; } } @@ -201,8 +204,36 @@ async function waitForPriorSnapshotBeforeDeadline( ]); } -async function exitedTreeRemainsEmptyAfterRefresh(tree: ManagedProcessTree): Promise { - return (await refreshExitedProcessTreePids(tree)) && !hasLiveManagedProcessTree(tree, false); +async function exitedTreeRemainsEmptyAfterRefresh( + tree: ManagedProcessTree, + finalSignal: NodeJS.Signals | undefined, + signaledIdentities: Set, +): Promise { + if (!(await refreshExitedProcessTreePids(tree))) { + return false; + } + if (!hasLiveManagedProcessTree(tree, false)) { + return true; + } + if (finalSignal) { + signalNewlyDiscoveredProcessPids(tree, finalSignal, signaledIdentities); + } + return false; +} + +function signalNewlyDiscoveredProcessPids( + tree: ManagedProcessTree, + signal: NodeJS.Signals, + signaledIdentities: Set, +): void { + for (const pid of tree.descendantPids) { + const identity = tree.descendantIdentities.get(pid); + const signalKey = `${pid}:${identity ?? ""}`; + if (!signaledIdentities.has(signalKey)) { + sendSignal(pid, signal); + signaledIdentities.add(signalKey); + } + } } function hasLiveManagedProcessTree(tree: ManagedProcessTree, rootRunning: boolean): boolean { @@ -219,15 +250,42 @@ function hasLiveManagedProcessTree(tree: ManagedProcessTree, rootRunning: boolea return hasLivePid(tree); } -async function listDescendantProcesses( - rootPid: number, - platform: NodeJS.Platform = process.platform, -): Promise { - const processList = await readProcessList(platform); +async function listDescendantProcesses(tree: ManagedProcessTree): Promise { + const rootPid = tree.rootPid; + if (!rootPid) { + return []; + } + const processList = await readProcessList(tree.platform); if (!processList) { return []; } + const snapshot = collectWindowsDescendantProcesses(rootPid, tree.rootIdentity, processList); + if (!snapshot) { + return []; + } + tree.rootIdentity = snapshot.rootIdentity; + return snapshot.descendants; +} + +export function collectWindowsDescendantProcesses( + rootPid: number, + expectedRootIdentity: string | undefined, + processList: ProcessListEntry[], +): { rootIdentity: string; descendants: ProcessListEntry[] } | undefined { + const root = processList.find((entry) => entry.pid === rootPid); + if (!root || (expectedRootIdentity && expectedRootIdentity !== root.identity)) { + return undefined; + } + + const childrenByParent = indexProcessesByParent(processList); + return { + rootIdentity: root.identity, + descendants: walkValidatedDescendants(root, childrenByParent), + }; +} + +function indexProcessesByParent(processList: ProcessListEntry[]): Map { const childrenByParent = new Map(); for (const processEntry of processList) { const children = childrenByParent.get(processEntry.parentPid); @@ -237,17 +295,38 @@ async function listDescendantProcesses( childrenByParent.set(processEntry.parentPid, [processEntry]); } } + return childrenByParent; +} +function walkValidatedDescendants( + root: ProcessListEntry, + childrenByParent: Map, +): ProcessListEntry[] { const descendants: ProcessListEntry[] = []; - const queue = [...(childrenByParent.get(rootPid) ?? [])]; + const visited = new Set([root.pid]); + const queue = [root]; for (let index = 0; index < queue.length; index += 1) { - const processEntry = queue[index]; - descendants.push(processEntry); - queue.push(...(childrenByParent.get(processEntry.pid) ?? [])); + const parent = queue[index]; + for (const child of childrenByParent.get(parent.pid) ?? []) { + if (visited.has(child.pid) || !isCreatedAtOrAfter(child.identity, parent.identity)) { + continue; + } + visited.add(child.pid); + descendants.push(child); + queue.push(child); + } } return descendants; } +function isCreatedAtOrAfter(childIdentity: string, parentIdentity: string): boolean { + try { + return BigInt(childIdentity) >= BigInt(parentIdentity); + } catch { + return false; + } +} + function parseProcessListLine(line: string): ProcessListEntry | undefined { const match = line.trim().match(/^(\d+)\s+(\d+)\s+(.+)$/); if (!match) { diff --git a/src/acp/terminal-manager.ts b/src/acp/terminal-manager.ts index fd69dfbc..c2288b0d 100644 --- a/src/acp/terminal-manager.ts +++ b/src/acp/terminal-manager.ts @@ -470,14 +470,18 @@ export class TerminalManager { return; } - await this.waitForCleanupAfterSignal(terminal); + await this.waitForCleanupAfterSignal(terminal, "SIGKILL"); } - private async waitForCleanupAfterSignal(terminal: ManagedTerminal): Promise { + private async waitForCleanupAfterSignal( + terminal: ManagedTerminal, + finalSignal?: NodeJS.Signals, + ): Promise { return await waitForProcessTreeExit( terminal.processTree, () => this.isRunning(terminal), this.killGraceMs, + finalSignal, ); } } diff --git a/test/process-tree.test.ts b/test/process-tree.test.ts index 1a2e926c..cf6c3a54 100644 --- a/test/process-tree.test.ts +++ b/test/process-tree.test.ts @@ -8,6 +8,7 @@ import test from "node:test"; import { promisify } from "node:util"; import { captureProcessTreePids, + collectWindowsDescendantProcesses, createManagedProcessTree, resolveProcessTreeSignalTargets, signalProcessTree, @@ -16,6 +17,21 @@ import { const execFileAsync = promisify(execFile); +test("Windows descendant tracking validates creation order and terminates cycles", () => { + const processList = [ + { pid: 100, parentPid: 102, identity: "1000" }, + { pid: 101, parentPid: 100, identity: "1100" }, + { pid: 102, parentPid: 101, identity: "1200" }, + { pid: 103, parentPid: 100, identity: "900" }, + ]; + + assert.deepEqual(collectWindowsDescendantProcesses(100, "1000", processList), { + rootIdentity: "1000", + descendants: [processList[1], processList[2]], + }); + assert.equal(collectWindowsDescendantProcesses(100, "different-root", processList), undefined); +}); + test("running POSIX trees signal the owned process group", () => { const tree = createManagedProcessTree(4100, true, "darwin"); tree.descendantPids.add(4101); @@ -174,3 +190,36 @@ test( } }, ); + +test( + "final POSIX cleanup signals descendants discovered after the prior SIGKILL snapshot", + { skip: process.platform === "win32" }, + async () => { + const child = spawn("sh", ["-c", "sleep 30 & echo $!"], { + detached: true, + stdio: ["ignore", "pipe", "ignore"], + }); + const rootPid = child.pid; + assert(rootPid); + + try { + const [output] = await once(child.stdout, "data"); + const descendantPid = Number(String(output).trim()); + assert(Number.isInteger(descendantPid)); + if (child.exitCode === null && child.signalCode === null) { + await once(child, "exit"); + } + + const tree = createManagedProcessTree(rootPid, true, process.platform); + assert.equal(await waitForProcessTreeExit(tree, () => false, 2_000, "SIGKILL"), true); + assert.throws(() => process.kill(descendantPid, 0)); + } finally { + try { + process.kill(-rootPid, "SIGKILL"); + } catch { + // The process group was already cleaned up. + } + child.stdout.destroy(); + } + }, +); From a85da81d0f0211c77fa65178ac109458da75fda0 Mon Sep 17 00:00:00 2001 From: trumpyla Date: Wed, 29 Jul 2026 14:46:26 -0400 Subject: [PATCH 28/57] fix: serialize queue lease mutations --- CHANGELOG.md | 26 +- src/acp/client.ts | 10 +- src/acp/process-tree.ts | 213 +++++++++++++-- src/acp/terminal-manager.ts | 13 +- src/cli/queue/ipc.ts | 3 +- src/cli/queue/lease-store.ts | 459 +++++++++++++++++++++++++++------ test/process-tree.test.ts | 141 +++++++++- test/queue-ipc-errors.test.ts | 25 ++ test/queue-lease-store.test.ts | 237 +++++++++++++++-- test/terminal.test.ts | 39 +++ 10 files changed, 1033 insertions(+), 133 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 33ccbbde..1ccd0d02 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,17 +16,25 @@ Repo: https://github.com/openclaw/acpx best-effort tracked process trees on Windows during normal and failed startup cleanup so package-exec wrappers do not leave captured descendants running after ACPX exits; exit-triggered snapshots are observed before cleanup - completes, owned POSIX group members created during shutdown are discovered - and re-signaled during forced cleanup, external process-list discovery is - bounded, and Windows parent edges plus remembered descendants are - identity-checked before later signaling. + completes, POSIX descendants are captured before signaling so later session + or group changes cannot escape forced cleanup, owned POSIX group members + created during shutdown are discovered and re-signaled, external process-list + discovery is bounded, Linux process identities come from procfs, other POSIX + snapshots use a fixed locale, Windows wrappers are sampled with bounded + exponential backoff through multi-stage launches, transient discovery + failures preserve and still signal captured targets, and Windows parent edges + plus remembered descendants are identity-checked before later signaling. - Runtime/queue: publish queue-owner leases atomically, preserve fresh malformed - locks during collisions, serialize generation-checked refresh and release, - and treat only a fresh lease-before-bind owner as a fast startup miss, - preventing premature `QUEUE_NOT_ACCEPTING_REQUESTS` errors without masking - older unreachable owners, letting released owners overwrite successors, or - leaving stale dangling-symlink locks unrecoverable. + locks during collisions, serialize cross-process refresh, release, and stale + cleanup through inode-qualified, crash-recoverable claims, retry explicit + cleanup across heartbeat contention while making unresolved explicit cleanup + fail visibly, reacquire atomically with fresh timestamps after retiring a + stale collision, and treat only a fresh, live lease-before-bind owner as a + fast startup miss, preventing premature + `QUEUE_NOT_ACCEPTING_REQUESTS` errors without masking older unreachable + owners, letting released owners overwrite successors, or leaving stale + dangling-symlink locks unrecoverable. ## 2026.7.27 (v0.13.0) diff --git a/src/acp/client.ts b/src/acp/client.ts index 24d5e390..d6f8c334 100644 --- a/src/acp/client.ts +++ b/src/acp/client.ts @@ -738,21 +738,27 @@ export class AcpClient { process.platform, plan.spawnOptions.env, ); + const rootCreatedAfterMs = Date.now(); const spawnedChild = spawn(spawnCommand.command, spawnCommand.args, { ...plan.spawnOptions, windowsVerbatimArguments: spawnCommand.windowsVerbatimArguments, }) as ChildProcessByStdio; - const processTree = createManagedProcessTree(spawnedChild.pid, true); + const processTree = createManagedProcessTree( + spawnedChild.pid, + true, + process.platform, + rootCreatedAfterMs, + ); this.agentProcessTree = processTree; spawnedChild.once("exit", () => { rememberProcessTreePids(processTree); }); + beginProcessTreeTracking(processTree, () => isChildProcessRunning(spawnedChild)); try { await waitForSpawn(spawnedChild); } catch (error) { throw new AgentSpawnError(this.options.agentCommand, error); } - beginProcessTreeTracking(processTree); return requireAgentStdio(spawnedChild); } diff --git a/src/acp/process-tree.ts b/src/acp/process-tree.ts index f5df3e05..88631627 100644 --- a/src/acp/process-tree.ts +++ b/src/acp/process-tree.ts @@ -1,7 +1,12 @@ import { spawn } from "node:child_process"; +import fs from "node:fs/promises"; const PROCESS_TREE_POLL_MS = 25; const PROCESS_LIST_COMMAND_TIMEOUT_MS = 1_000; +const WINDOWS_TRACKING_INITIAL_POLL_MS = 25; +const WINDOWS_TRACKING_MAX_POLL_MS = 1_000; +const WINDOWS_TRACKING_MAX_DURATION_MS = 10_000; +const WINDOWS_EPOCH_OFFSET_TICKS = 621_355_968_000_000_000n; export type ManagedProcessTree = { rootPid: number | undefined; @@ -10,19 +15,30 @@ export type ManagedProcessTree = { descendantPids: Set; descendantIdentities: Map; rootIdentity?: string; + rootIdentityFloor?: string; snapshotPromise?: Promise; }; export type ProcessListEntry = { pid: number; parentPid: number; + processGroupId?: number; identity: string; }; +export async function readProcessIdentity( + pid: number, + platform: NodeJS.Platform = process.platform, +): Promise { + const processList = await readProcessList(platform); + return processList?.find((entry) => entry.pid === pid)?.identity; +} + export function createManagedProcessTree( rootPid: number | undefined, killProcessGroup: boolean, platform: NodeJS.Platform = process.platform, + rootCreatedAfterMs?: number, ): ManagedProcessTree { return { rootPid, @@ -30,6 +46,10 @@ export function createManagedProcessTree( platform, descendantPids: new Set(), descendantIdentities: new Map(), + rootIdentityFloor: + platform === "win32" && rootCreatedAfterMs !== undefined + ? windowsTicksFromUnixMs(rootCreatedAfterMs) + : undefined, }; } @@ -37,9 +57,12 @@ export function rememberProcessTreePids(tree: ManagedProcessTree): void { queueProcessTreeSnapshot(tree); } -export function beginProcessTreeTracking(tree: ManagedProcessTree): void { +export function beginProcessTreeTracking( + tree: ManagedProcessTree, + rootRunning: () => boolean, +): void { if (tree.platform === "win32") { - queueProcessTreeSnapshot(tree); + queueWindowsProcessTreeTracking(tree, rootRunning); } } @@ -76,6 +99,31 @@ function queueProcessTreeSnapshot(tree: ManagedProcessTree): void { })(); } +function queueWindowsProcessTreeTracking( + tree: ManagedProcessTree, + rootRunning: () => boolean, +): void { + const priorSnapshot = tree.snapshotPromise; + tree.snapshotPromise = (async () => { + await priorSnapshot?.catch(() => { + // Tracking can continue after an earlier best-effort snapshot failure. + }); + const deadline = Date.now() + WINDOWS_TRACKING_MAX_DURATION_MS; + let pollMs = WINDOWS_TRACKING_INITIAL_POLL_MS; + while (rootRunning() && Date.now() < deadline) { + const descendantCount = tree.descendantPids.size; + await recordCurrentProcessTreePids(tree); + if (rootRunning() && Date.now() < deadline) { + await waitMs(pollMs); + pollMs = + tree.descendantPids.size > descendantCount + ? WINDOWS_TRACKING_INITIAL_POLL_MS + : Math.min(pollMs * 2, WINDOWS_TRACKING_MAX_POLL_MS); + } + } + })(); +} + async function recordCurrentProcessTreePids(tree: ManagedProcessTree): Promise { const rootPid = tree.rootPid; if (!tree.killProcessGroup || !rootPid) { @@ -84,7 +132,7 @@ async function recordCurrentProcessTreePids(tree: ManagedProcessTree): Promise entry.pid === rootPid); - if (!root || (expectedRootIdentity && expectedRootIdentity !== root.identity)) { + if (!root) { + return undefined; + } + if ( + (expectedRootIdentity && expectedRootIdentity !== root.identity) || + (rootIdentityFloor && !isCreatedAtOrAfter(root.identity, rootIdentityFloor)) + ) { return undefined; } @@ -285,6 +343,10 @@ export function collectWindowsDescendantProcesses( }; } +function windowsTicksFromUnixMs(unixMs: number): string { + return (BigInt(Math.floor(unixMs)) * 10_000n + WINDOWS_EPOCH_OFFSET_TICKS).toString(); +} + function indexProcessesByParent(processList: ProcessListEntry[]): Map { const childrenByParent = new Map(); for (const processEntry of processList) { @@ -323,11 +385,13 @@ function isCreatedAtOrAfter(childIdentity: string, parentIdentity: string): bool try { return BigInt(childIdentity) >= BigInt(parentIdentity); } catch { - return false; + const childTime = Date.parse(childIdentity); + const parentTime = Date.parse(parentIdentity); + return Number.isFinite(childTime) && Number.isFinite(parentTime) && childTime >= parentTime; } } -function parseProcessListLine(line: string): ProcessListEntry | undefined { +function parseWindowsProcessListLine(line: string): ProcessListEntry | undefined { const match = line.trim().match(/^(\d+)\s+(\d+)\s+(.+)$/); if (!match) { return undefined; @@ -345,31 +409,127 @@ function parseProcessListLine(line: string): ProcessListEntry | undefined { return { pid, parentPid, identity }; } +function parsePosixProcessListLine(line: string): ProcessListEntry | undefined { + const match = line.trim().match(/^(\d+)\s+(\d+)\s+(\d+)\s+(.+)$/); + if (!match) { + return undefined; + } + const pid = Number(match[1]); + const parentPid = Number(match[2]); + const processGroupId = Number(match[3]); + const identity = match[4].trim(); + if ( + !isPositiveInteger(pid) || + !isPositiveInteger(parentPid) || + !isPositiveInteger(processGroupId) || + !identity + ) { + return undefined; + } + return { pid, parentPid, processGroupId, identity }; +} + async function readProcessList(platform: NodeJS.Platform): Promise { + if (platform === "linux") { + return await readLinuxProcProcessList(); + } let output: string; try { output = platform === "win32" ? await runWindowsProcessListCommand() - : await runPsCommand(["-eo", "pid=,pgid=,lstart="]); + : await runPsCommand(["-eo", "pid=,ppid=,pgid=,lstart="]); } catch { return undefined; } + const parseLine = platform === "win32" ? parseWindowsProcessListLine : parsePosixProcessListLine; return output .split("\n") - .map((line) => parseProcessListLine(line)) + .map((line) => parseLine(line)) .filter((entry): entry is ProcessListEntry => entry !== undefined); } -async function listProcessGroupEntries( - processGroupId: number, - platform: NodeJS.Platform, -): Promise { - const processList = await readProcessList(platform); +async function readLinuxProcProcessList(): Promise { + let entries; + try { + entries = await fs.readdir("/proc", { withFileTypes: true }); + } catch { + return undefined; + } + const processList = await Promise.all( + entries + .filter((entry) => entry.isDirectory() && /^\d+$/.test(entry.name)) + .map(async (entry) => { + try { + const stat = await fs.readFile(`/proc/${entry.name}/stat`, "utf8"); + return parseLinuxProcStat(stat); + } catch { + return undefined; + } + }), + ); + return processList.filter((entry): entry is ProcessListEntry => entry !== undefined); +} + +function parseLinuxProcStat(stat: string): ProcessListEntry | undefined { + const commandEnd = stat.lastIndexOf(")"); + const commandStart = stat.indexOf("("); + if (commandStart <= 0 || commandEnd <= commandStart) { + return undefined; + } + const pid = Number(stat.slice(0, commandStart).trim()); + const fields = stat + .slice(commandEnd + 1) + .trim() + .split(/\s+/); + const parentPid = Number(fields[1]); + const processGroupId = Number(fields[2]); + const startTime = fields[19]; + if ( + !isPositiveInteger(pid) || + !isPositiveInteger(parentPid) || + !isPositiveInteger(processGroupId) || + !isNumericIdentity(startTime) + ) { + return undefined; + } + return { pid, parentPid, processGroupId, identity: startTime }; +} + +function isPositiveInteger(value: number): boolean { + return Number.isInteger(value) && value > 0; +} + +function isNumericIdentity(value: string | undefined): value is string { + return value !== undefined && /^\d+$/.test(value); +} + +async function listOwnedPosixProcesses(tree: ManagedProcessTree): Promise { + const rootPid = tree.rootPid; + if (!rootPid) { + return []; + } + const processList = await readProcessList(tree.platform); if (!processList) { return []; } - return processList.filter((entry) => entry.parentPid === processGroupId); + const ownedByPid = new Map( + processList + .filter((entry) => entry.processGroupId === rootPid) + .map((entry) => [entry.pid, entry]), + ); + const root = processList.find((entry) => entry.pid === rootPid); + if ( + root && + (!tree.rootIdentity || tree.rootIdentity === root.identity) && + root.processGroupId === rootPid + ) { + tree.rootIdentity = root.identity; + for (const descendant of walkValidatedDescendants(root, indexProcessesByParent(processList))) { + ownedByPid.set(descendant.pid, descendant); + } + } + return [...ownedByPid.values()]; } async function refreshExitedProcessTreePids(tree: ManagedProcessTree): Promise { @@ -396,8 +556,7 @@ function retainCurrentProcessTreePids( for (const pid of tree.descendantPids) { const current = currentByPid.get(pid); const capturedIdentity = tree.descendantIdentities.get(pid); - const remainsInOwnedGroup = tree.platform === "win32" || current?.parentPid === tree.rootPid; - if (!current || current.identity !== capturedIdentity || !remainsInOwnedGroup) { + if (!current || current.identity !== capturedIdentity) { tree.descendantPids.delete(pid); tree.descendantIdentities.delete(pid); } @@ -409,7 +568,7 @@ function discoverCurrentPosixGroupMembers( rootPid: number, processList: ProcessListEntry[], ): void { - const currentGroup = processList.filter((entry) => entry.parentPid === rootPid); + const currentGroup = processList.filter((entry) => entry.processGroupId === rootPid); if (currentGroup.some((entry) => entry.pid === rootPid)) { // A live process whose PID equals the old group leader means the numeric // PID/PGID was recycled after the owned group became empty. @@ -421,7 +580,11 @@ function discoverCurrentPosixGroupMembers( } async function runPsCommand(args: string[]): Promise { - return await runCapturedCommand("ps", args, "ps"); + return await runCapturedCommand("ps", args, "ps", { + ...process.env, + LANG: "C", + LC_ALL: "C", + }); } async function runWindowsProcessListCommand(): Promise { @@ -440,9 +603,11 @@ async function runCapturedCommand( command: string, args: string[], description: string, + env?: NodeJS.ProcessEnv, ): Promise { return await new Promise((resolve, reject) => { const child = spawn(command, args, { + env, stdio: ["ignore", "pipe", "pipe"], windowsHide: true, }); diff --git a/src/acp/terminal-manager.ts b/src/acp/terminal-manager.ts index c2288b0d..8b886fa4 100644 --- a/src/acp/terminal-manager.ts +++ b/src/acp/terminal-manager.ts @@ -203,6 +203,7 @@ export class TerminalManager { 0, Math.round(params.outputByteLimit ?? DEFAULT_TERMINAL_OUTPUT_LIMIT_BYTES), ); + const rootCreatedAfterMs = Date.now(); const { proc, spawnCommand } = await spawnTerminalProcess(params, this.cwd); let resolveExit: (response: WaitForTerminalExitResponse) => void = () => {}; @@ -212,7 +213,12 @@ export class TerminalManager { const terminal: ManagedTerminal = { process: proc, - processTree: createManagedProcessTree(proc.pid, spawnCommand.killProcessGroup), + processTree: createManagedProcessTree( + proc.pid, + spawnCommand.killProcessGroup, + process.platform, + rootCreatedAfterMs, + ), output: Buffer.alloc(0), truncated: false, outputByteLimit, @@ -221,7 +227,10 @@ export class TerminalManager { exitPromise, resolveExit, }; - beginProcessTreeTracking(terminal.processTree); + beginProcessTreeTracking( + terminal.processTree, + () => proc.exitCode === null && proc.signalCode === null, + ); const appendOutput = (chunk: Buffer | string): void => { const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk); diff --git a/src/cli/queue/ipc.ts b/src/cli/queue/ipc.ts index f5d01c11..2231d3c1 100644 --- a/src/cli/queue/ipc.ts +++ b/src/cli/queue/ipc.ts @@ -18,6 +18,7 @@ import { probeQueueOwnerHealth, type QueueOwnerHealth } from "./ipc-health.js"; import { connectToQueueOwner } from "./ipc-transport.js"; import { ensureOwnerIsUsable, + isProcessAlive, type QueueOwnerRecord, readQueueOwnerRecord, terminateQueueOwnerForSession, @@ -727,7 +728,7 @@ async function unavailableOwnerCountsAsMissing( return ( !latestOwner || latestOwner.ownerGeneration !== owner.ownerGeneration || - queueOwnerIsWithinStartupGrace(latestOwner) + (queueOwnerIsWithinStartupGrace(latestOwner) && isProcessAlive(latestOwner.pid)) ); } const health = await probeQueueOwnerHealth(sessionId); diff --git a/src/cli/queue/lease-store.ts b/src/cli/queue/lease-store.ts index 7d109204..0cb50509 100644 --- a/src/cli/queue/lease-store.ts +++ b/src/cli/queue/lease-store.ts @@ -1,5 +1,7 @@ import { randomInt, randomUUID } from "node:crypto"; +import type { Stats } from "node:fs"; import fs from "node:fs/promises"; +import { readProcessIdentity } from "../../acp/process-tree.js"; import { isProcessAlive } from "../../process-liveness.js"; import { queueBaseDir, queueLockFilePath, queueSocketBaseDir, queueSocketPath } from "./paths.js"; @@ -15,6 +17,8 @@ const PROCESS_SIGTERM_GRACE_MS = 6_500; // After SIGKILL the OS terminates the process almost immediately; 1 500 ms is generous. const PROCESS_SIGKILL_GRACE_MS = 1_500; const PROCESS_POLL_MS = 50; +const QUEUE_OWNER_CLEANUP_CLAIM_WAIT_MS = 1_000; +const QUEUE_OWNER_CLEANUP_CLAIM_POLL_MS = 10; const QUEUE_OWNER_STALE_HEARTBEAT_MS = 15_000; const QUEUE_OWNER_MALFORMED_LOCK_STALE_MS = QUEUE_OWNER_STALE_HEARTBEAT_MS; @@ -184,19 +188,60 @@ async function waitForProcessExit(pid: number, timeoutMs: number): Promise { + expectedLockStat?: Stats, +): Promise { const lockPath = queueLockFilePath(sessionId); const socketPath = owner?.socketPath ?? queueSocketPath(sessionId); + const claim = await claimQueueOwnerLockForCleanup( + lockPath, + owner?.ownerGeneration, + expectedLockStat, + ); + if (!claim) { + return false; + } - await removeSocketFile(socketPath).catch(() => { - // ignore stale socket cleanup failures - }); + try { + if (owner && isProcessAlive(owner.pid)) { + await terminateProcess(owner.pid); + } + await removeClaimedQueueOwnerFiles(claim, socketPath, lockPath); + return true; + } finally { + await claim.release(); + } +} - await fs.unlink(lockPath).catch((error) => { - if ((error as NodeJS.ErrnoException).code !== "ENOENT") { - throw error; +async function claimQueueOwnerLockForCleanup( + lockPath: string, + expectedGeneration?: number, + expectedStat?: Stats, +): Promise { + const deadline = Date.now() + QUEUE_OWNER_CLEANUP_CLAIM_WAIT_MS; + do { + const claim = await claimQueueOwnerLock(lockPath, expectedGeneration, expectedStat); + if (claim) { + return claim; } + await waitMs(QUEUE_OWNER_CLEANUP_CLAIM_POLL_MS); + } while (Date.now() < deadline); + return undefined; +} + +async function removeClaimedQueueOwnerFiles( + claim: QueueOwnerLockClaim, + socketPath: string, + lockPath: string, +): Promise { + if (!(await claim.isHeld())) { + return; + } + await removeSocketFile(socketPath).catch(() => { + // ignore stale socket cleanup failures }); + if (await claim.isHeld()) { + await unlinkIfPresent(lockPath); + } } function queueOwnerLockTempPath(lockPath: string): string { @@ -227,30 +272,268 @@ async function writeQueueOwnerFileAtomically( } } -async function malformedQueueOwnerLockIsStale(lockPath: string): Promise { +async function staleMalformedQueueOwnerLockStat(lockPath: string): Promise { try { const stat = await fs.lstat(lockPath); - return Date.now() - stat.mtimeMs > QUEUE_OWNER_MALFORMED_LOCK_STALE_MS; + return Date.now() - stat.mtimeMs > QUEUE_OWNER_MALFORMED_LOCK_STALE_MS ? stat : undefined; } catch { - return false; + return undefined; } } async function retireStaleQueueOwner( sessionId: string, owner: QueueOwnerRecord | undefined, +): Promise { + return await cleanupStaleQueueOwner(sessionId, owner); +} + +export type QueueOwnerLockClaim = { + isHeld: () => Promise; + release: () => Promise; +}; + +type QueueOwnerLockClaimRecord = { + claimId: string; + pid: number; + processIdentity?: string; +}; + +let currentProcessIdentityPromise: Promise | undefined; + +function queueOwnerLockClaimPath(lockPath: string, stat: Stats): string { + return `${lockPath}.claim-${stat.dev}-${stat.ino}`; +} + +export async function claimQueueOwnerLock( + lockPath: string, + expectedGeneration?: number, + expectedStat?: Stats, +): Promise { + const observedStat = expectedStat ?? (await lstatIfPresent(lockPath)); + if (!observedStat) { + return undefined; + } + const claimPath = queueOwnerLockClaimPath(lockPath, observedStat); + const claimRecord = await currentQueueOwnerLockClaimRecord(); + if (!(await createOrRecoverQueueOwnerLockClaim(claimPath, claimRecord))) { + return undefined; + } + const claim = { + isHeld: async (): Promise => { + const current = await readQueueOwnerLockClaimRecord(claimPath); + return current?.claimId === claimRecord.claimId; + }, + release: async (): Promise => { + if ((await readQueueOwnerLockClaimRecord(claimPath))?.claimId === claimRecord.claimId) { + await unlinkIfPresent(claimPath); + } + }, + }; + if (!(await validateQueueOwnerLockClaim(lockPath, observedStat, expectedGeneration, claim))) { + return undefined; + } + return claim; +} + +async function currentQueueOwnerLockClaimRecord(): Promise { + currentProcessIdentityPromise ??= readProcessIdentity(process.pid); + return { + claimId: randomUUID(), + pid: process.pid, + processIdentity: await currentProcessIdentityPromise, + }; +} + +async function createOrRecoverQueueOwnerLockClaim( + claimPath: string, + claimRecord: QueueOwnerLockClaimRecord, +): Promise { + if (await tryCreateQueueOwnerLockClaim(claimPath, claimRecord)) { + return true; + } + if (!(await recoverStaleQueueOwnerLockClaim(claimPath))) { + return false; + } + return await tryCreateQueueOwnerLockClaim(claimPath, claimRecord); +} + +async function tryCreateQueueOwnerLockClaim( + claimPath: string, + claimRecord: QueueOwnerLockClaimRecord, +): Promise { + try { + await fs.writeFile(claimPath, `${JSON.stringify(claimRecord)}\n`, { + encoding: "utf8", + flag: "wx", + mode: 0o600, + }); + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code === "EEXIST" || code === "ENOENT") { + return false; + } + throw error; + } + return true; +} + +async function recoverStaleQueueOwnerLockClaim(claimPath: string): Promise { + const observedStat = await lstatIfPresent(claimPath); + if (!observedStat || !(await queueOwnerLockClaimIsStale(claimPath, observedStat))) { + return false; + } + + const quarantinePath = `${claimPath}.reap-${process.pid}-${randomUUID()}`; + try { + await fs.rename(claimPath, quarantinePath); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") { + return true; + } + throw error; + } + + const movedStat = await lstatIfPresent(quarantinePath); + if (!movedStat || !sameFileIdentity(observedStat, movedStat)) { + await restoreDisplacedQueueOwnerLockClaim(quarantinePath, claimPath); + return false; + } + if (!(await queueOwnerLockClaimIsStale(quarantinePath, movedStat))) { + await restoreDisplacedQueueOwnerLockClaim(quarantinePath, claimPath); + return false; + } + await unlinkIfPresent(quarantinePath); + return true; +} + +async function restoreDisplacedQueueOwnerLockClaim( + quarantinePath: string, + claimPath: string, ): Promise { - if (owner && isProcessAlive(owner.pid)) { - await terminateProcess(owner.pid); + try { + await fs.rename(quarantinePath, claimPath); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "EEXIST") { + throw error; + } + await unlinkIfPresent(quarantinePath); } +} - await cleanupStaleQueueOwner(sessionId, owner); +async function queueOwnerLockClaimIsStale(claimPath: string, stat: Stats): Promise { + const claimRecord = await readQueueOwnerLockClaimRecord(claimPath); + if (!claimRecord) { + return Date.now() - stat.mtimeMs > QUEUE_OWNER_MALFORMED_LOCK_STALE_MS; + } + if (!claimantProcessIsAlive(claimRecord.pid)) { + return true; + } + if (!claimRecord.processIdentity) { + return Date.now() - stat.mtimeMs > QUEUE_OWNER_MALFORMED_LOCK_STALE_MS; + } + const currentIdentity = await readProcessIdentity(claimRecord.pid); + return currentIdentity !== undefined && currentIdentity !== claimRecord.processIdentity; +} + +function claimantProcessIsAlive(pid: number): boolean { + return pid === process.pid || isProcessAlive(pid); +} + +async function readQueueOwnerLockClaimRecord( + claimPath: string, +): Promise { + try { + const parsed: unknown = JSON.parse(await fs.readFile(claimPath, "utf8")); + return parseQueueOwnerLockClaimRecord(parsed); + } catch { + return undefined; + } +} + +function parseQueueOwnerLockClaimRecord(value: unknown): QueueOwnerLockClaimRecord | undefined { + if (!isQueueOwnerLockClaimRecord(value)) { + return undefined; + } + return { + claimId: value.claimId, + pid: value.pid, + processIdentity: value.processIdentity, + }; +} + +function isQueueOwnerLockClaimRecord(value: unknown): value is QueueOwnerLockClaimRecord { + if (typeof value !== "object" || value === null) { + return false; + } + const candidate = value as Record; + return ( + isNonEmptyString(candidate.claimId) && + isPositiveInteger(candidate.pid) && + isOptionalString(candidate.processIdentity) + ); +} + +function isNonEmptyString(value: unknown): value is string { + return typeof value === "string" && value.length > 0; +} + +function isOptionalString(value: unknown): value is string | undefined { + return value === undefined || typeof value === "string"; +} + +async function validateQueueOwnerLockClaim( + lockPath: string, + observedStat: Stats, + expectedGeneration: number | undefined, + claim: QueueOwnerLockClaim, +): Promise { + let valid = false; + try { + const currentStat = await lstatIfPresent(lockPath); + valid = Boolean(currentStat && sameFileIdentity(observedStat, currentStat)); + if (valid && expectedGeneration !== undefined) { + const claimedOwner = await readQueueOwnerRecordAtPath(lockPath); + valid = claimedOwner?.ownerGeneration === expectedGeneration; + } + return valid; + } finally { + if (!valid) { + await claim.release(); + } + } +} + +async function lstatIfPresent(filePath: string): Promise { + try { + return await fs.lstat(filePath); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") { + return undefined; + } + throw error; + } +} + +function sameFileIdentity(left: Stats, right: Stats): boolean { + return left.dev === right.dev && left.ino === right.ino; +} + +async function unlinkIfPresent(filePath: string): Promise { + await fs.unlink(filePath).catch((error) => { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") { + throw error; + } + }); } export async function readQueueOwnerRecord( sessionId: string, ): Promise { - const lockPath = queueLockFilePath(sessionId); + return await readQueueOwnerRecordAtPath(queueLockFilePath(sessionId)); +} + +async function readQueueOwnerRecordAtPath(lockPath: string): Promise { try { const payload = await fs.readFile(lockPath, "utf8"); const parsed = parseQueueOwnerRecord(JSON.parse(payload)); @@ -340,44 +623,62 @@ export async function tryAcquireQueueOwnerLease( await ensureQueueDir(); const lockPath = queueLockFilePath(sessionId); const socketPath = queueSocketPath(sessionId); - const createdAt = clock(); + let createdAt = clock(); const ownerGeneration = createOwnerGeneration(); - const payload = JSON.stringify( - { - pid: process.pid, - sessionId, - socketPath, - createdAt, - heartbeatAt: createdAt, - ownerGeneration, - queueDepth: 0, - ...mcpConfigMetadata, - }, - null, - 2, - ); + const buildPayload = () => + JSON.stringify( + { + pid: process.pid, + sessionId, + socketPath, + createdAt, + heartbeatAt: createdAt, + ownerGeneration, + queueDepth: 0, + ...mcpConfigMetadata, + }, + null, + 2, + ); + let payload = buildPayload(); + let acquired = false; try { await writeQueueOwnerFileAtomically(lockPath, `${payload}\n`, "create"); - await removeSocketFile(socketPath).catch(() => { - // best-effort stale socket cleanup after ownership is acquired - }); - const lease = { - sessionId, - lockPath, - socketPath, - createdAt, - ownerGeneration, - ...mcpConfigMetadata, - }; - queueOwnerLeaseStates.set(lease, { - pendingRefresh: Promise.resolve(), - released: false, - }); - return lease; + acquired = true; } catch (error) { - return await handleLeaseCollision(sessionId, error); + if (!(await handleLeaseCollision(sessionId, error))) { + return undefined; + } + } + if (!acquired) { + createdAt = clock(); + payload = buildPayload(); + try { + await writeQueueOwnerFileAtomically(lockPath, `${payload}\n`, "create"); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "EEXIST") { + return undefined; + } + throw error; + } } + await removeSocketFile(socketPath).catch(() => { + // best-effort stale socket cleanup after ownership is acquired + }); + const lease = { + sessionId, + lockPath, + socketPath, + createdAt, + ownerGeneration, + ...mcpConfigMetadata, + }; + queueOwnerLeaseStates.set(lease, { + pendingRefresh: Promise.resolve(), + released: false, + }); + return lease; } function readMcpConfigFingerprint( @@ -405,7 +706,7 @@ function createMcpConfigMetadata( }; } -async function handleLeaseCollision(sessionId: string, error: unknown): Promise { +async function handleLeaseCollision(sessionId: string, error: unknown): Promise { if ((error as NodeJS.ErrnoException).code !== "EEXIST") { throw error; } @@ -413,16 +714,17 @@ async function handleLeaseCollision(sessionId: string, error: unknown): Promise< const owner = await readQueueOwnerRecord(sessionId); if (!owner) { const lockPath = queueLockFilePath(sessionId); - if (await malformedQueueOwnerLockIsStale(lockPath)) { - await cleanupStaleQueueOwner(sessionId, owner); + const staleLockStat = await staleMalformedQueueOwnerLockStat(lockPath); + if (staleLockStat) { + return await cleanupStaleQueueOwner(sessionId, owner, staleLockStat); } - return undefined; + return false; } if (!isProcessAlive(owner.pid) || isQueueOwnerHeartbeatStale(owner)) { - await retireStaleQueueOwner(sessionId, owner); + return await retireStaleQueueOwner(sessionId, owner); } - return undefined; + return false; } function resolveLeaseArguments( @@ -460,7 +762,11 @@ export async function refreshQueueOwnerLease( return; } const refresh = state.pendingRefresh.then(async () => { - if (state.released || !(await queueOwnerLeaseStillOwnsLock(lease))) { + if (state.released) { + return; + } + const claim = await claimQueueOwnerLock(lease.lockPath, lease.ownerGeneration); + if (!claim) { return; } const payload = JSON.stringify( @@ -478,7 +784,14 @@ export async function refreshQueueOwnerLease( null, 2, ); - await writeQueueOwnerFileAtomically(lease.lockPath, `${payload}\n`, "replace"); + try { + if (!(await claim.isHeld())) { + return; + } + await writeQueueOwnerFileAtomically(lease.lockPath, `${payload}\n`, "replace"); + } finally { + await claim.release(); + } }); state.pendingRefresh = refresh.catch(() => { // Keep the serialization chain usable after a best-effort refresh failure. @@ -492,21 +805,24 @@ export async function releaseQueueOwnerLease(lease: QueueOwnerLease): Promise { await state.pendingRefresh; - if (!(await queueOwnerLeaseStillOwnsLock(lease))) { - return; - } - await removeSocketFile(lease.socketPath).catch(() => { - // ignore best-effort cleanup failures - }); - - if (!(await queueOwnerLeaseStillOwnsLock(lease))) { + const claim = await claimQueueOwnerLock(lease.lockPath, lease.ownerGeneration); + if (!claim) { return; } - await fs.unlink(lease.lockPath).catch((error) => { - if ((error as NodeJS.ErrnoException).code !== "ENOENT") { - throw error; + try { + if (!(await claim.isHeld())) { + return; } - }); + await removeSocketFile(lease.socketPath).catch(() => { + // ignore best-effort cleanup failures + }); + if (!(await claim.isHeld())) { + return; + } + await unlinkIfPresent(lease.lockPath); + } finally { + await claim.release(); + } })(); } await state.releasePromise; @@ -524,22 +840,15 @@ function queueOwnerLeaseState(lease: QueueOwnerLease): QueueOwnerLeaseState { return state; } -async function queueOwnerLeaseStillOwnsLock(lease: QueueOwnerLease): Promise { - const owner = await readQueueOwnerRecord(lease.sessionId); - return owner?.ownerGeneration === lease.ownerGeneration; -} - export async function terminateQueueOwnerForSession(sessionId: string): Promise { const owner = await readQueueOwnerRecord(sessionId); if (!owner) { return; } - if (isProcessAlive(owner.pid)) { - await terminateProcess(owner.pid); + if (!(await cleanupStaleQueueOwner(sessionId, owner))) { + throw new Error(`Queue owner cleanup is busy for session ${sessionId}; retry the operation`); } - - await cleanupStaleQueueOwner(sessionId, owner); } export async function waitMs(ms: number): Promise { diff --git a/test/process-tree.test.ts b/test/process-tree.test.ts index cf6c3a54..49a9d8b2 100644 --- a/test/process-tree.test.ts +++ b/test/process-tree.test.ts @@ -7,9 +7,11 @@ import path from "node:path"; import test from "node:test"; import { promisify } from "node:util"; import { + beginProcessTreeTracking, captureProcessTreePids, collectWindowsDescendantProcesses, createManagedProcessTree, + readProcessIdentity, resolveProcessTreeSignalTargets, signalProcessTree, waitForProcessTreeExit, @@ -30,8 +32,115 @@ test("Windows descendant tracking validates creation order and terminates cycles descendants: [processList[1], processList[2]], }); assert.equal(collectWindowsDescendantProcesses(100, "different-root", processList), undefined); + + const afterRootExit = processList.slice(1); + assert.equal(collectWindowsDescendantProcesses(100, undefined, afterRootExit, "1000"), undefined); }); +test( + "continuous Windows tracking captures children spawned after the initial snapshot", + { skip: process.platform === "win32" }, + async () => { + const fixtureDir = await fs.mkdtemp(path.join(os.tmpdir(), "acpx-windows-tracking-")); + const powershellPath = path.join(fixtureDir, "powershell.exe"); + const counterPath = path.join(fixtureDir, "counter"); + const originalPath = process.env.PATH; + await fs.writeFile( + powershellPath, + [ + "#!/bin/sh", + `counter=${JSON.stringify(counterPath)}`, + 'count=$(cat "$counter" 2>/dev/null || true)', + "count=${count:-0}", + "count=$((count + 1))", + 'printf "%s" "$count" > "$counter"', + 'printf "500 1 1000\\n"', + 'if [ "$count" -ge 2 ]; then', + ' printf "600 500 1100\\n"', + "fi", + 'if [ "$count" -ge 3 ]; then', + ` printf "${process.pid} 600 1200\\n"`, + "fi", + ].join("\n"), + { mode: 0o755 }, + ); + process.env.PATH = `${fixtureDir}${path.delimiter}${originalPath ?? ""}`; + + let running = true; + try { + const tree = createManagedProcessTree(500, true, "win32"); + beginProcessTreeTracking(tree, () => running); + for (let attempt = 0; attempt < 100 && !tree.descendantPids.has(process.pid); attempt += 1) { + await new Promise((resolve) => { + setTimeout(resolve, 25); + }); + } + running = false; + await tree.snapshotPromise; + const completedSamples = await fs.readFile(counterPath, "utf8"); + await new Promise((resolve) => { + setTimeout(resolve, 100); + }); + assert.equal(await fs.readFile(counterPath, "utf8"), completedSamples); + await captureProcessTreePids(tree, false); + assert.equal(tree.descendantPids.has(process.pid), true); + } finally { + running = false; + process.env.PATH = originalPath; + await fs.rm(fixtureDir, { recursive: true, force: true }); + } + }, +); + +test( + "Linux process identities do not depend on unsupported BusyBox ps columns", + { skip: process.platform !== "linux" }, + async () => { + const fixtureDir = await fs.mkdtemp(path.join(os.tmpdir(), "acpx-busybox-ps-")); + const psPath = path.join(fixtureDir, "ps"); + const originalPath = process.env.PATH; + await fs.writeFile(psPath, "#!/bin/sh\nexit 1\n", { mode: 0o755 }); + process.env.PATH = `${fixtureDir}${path.delimiter}${originalPath ?? ""}`; + + try { + assert.match((await readProcessIdentity(process.pid, "linux")) ?? "", /^\d+$/); + } finally { + process.env.PATH = originalPath; + await fs.rm(fixtureDir, { recursive: true, force: true }); + } + }, +); + +test( + "non-Linux POSIX snapshots force a locale-independent process identity", + { skip: process.platform === "win32" }, + async () => { + const fixtureDir = await fs.mkdtemp(path.join(os.tmpdir(), "acpx-posix-locale-")); + const psPath = path.join(fixtureDir, "ps"); + const originalPath = process.env.PATH; + await fs.writeFile( + psPath, + [ + "#!/bin/sh", + 'test "$LC_ALL" = C || exit 9', + 'printf "500 1 500 Wed Jul 29 12:00:00 2026\\n"', + `printf "${process.pid} 500 999 Wed Jul 29 12:00:01 2026\\n"`, + ].join("\n"), + { mode: 0o755 }, + ); + process.env.PATH = `${fixtureDir}${path.delimiter}${originalPath ?? ""}`; + + try { + const tree = createManagedProcessTree(500, true, "darwin"); + await captureProcessTreePids(tree, true); + assert.equal(tree.descendantPids.has(process.pid), true); + } finally { + process.env.PATH = originalPath; + await fs.rm(fixtureDir, { recursive: true, force: true }); + } + }, +); + test("running POSIX trees signal the owned process group", () => { const tree = createManagedProcessTree(4100, true, "darwin"); tree.descendantPids.add(4101); @@ -100,7 +209,7 @@ test( process.env.PATH = `${fixtureDir}${path.delimiter}${originalPath ?? ""}`; try { - const tree = createManagedProcessTree(process.pid, true, "linux"); + const tree = createManagedProcessTree(process.pid, true, "darwin"); const startedAt = Date.now(); await captureProcessTreePids(tree, true); assert(Date.now() - startedAt < 3_000); @@ -112,7 +221,33 @@ test( ); test( - "signaling a running POSIX group does not wait for another process-list snapshot", + "transient Windows process-list failures preserve remembered descendants", + { skip: process.platform === "win32" }, + async () => { + const fixtureDir = await fs.mkdtemp(path.join(os.tmpdir(), "acpx-stalled-powershell-")); + const powershellPath = path.join(fixtureDir, "powershell.exe"); + const originalPath = process.env.PATH; + await fs.writeFile(powershellPath, "#!/bin/sh\nwhile :; do :; done\n", { mode: 0o755 }); + process.env.PATH = `${fixtureDir}${path.delimiter}${originalPath ?? ""}`; + + try { + const tree = createManagedProcessTree(999_999, true, "win32"); + tree.descendantPids.add(process.pid); + tree.descendantIdentities.set(process.pid, "remembered"); + + await signalProcessTree(tree, false, "SIGCONT"); + + assert.equal(tree.descendantPids.has(process.pid), true); + assert.equal(tree.descendantIdentities.get(process.pid), "remembered"); + } finally { + process.env.PATH = originalPath; + await fs.rm(fixtureDir, { recursive: true, force: true }); + } + }, +); + +test( + "signaling a running POSIX group bounds its pre-signal descendant snapshot", { skip: process.platform === "win32" }, async () => { const fixtureDir = await fs.mkdtemp(path.join(os.tmpdir(), "acpx-stalled-ps-signal-")); @@ -125,7 +260,7 @@ test( const tree = createManagedProcessTree(process.pid, true, process.platform); const startedAt = Date.now(); await signalProcessTree(tree, true, "SIGCONT"); - assert(Date.now() - startedAt < 500); + assert(Date.now() - startedAt < 1_500); } finally { process.env.PATH = originalPath; await fs.rm(fixtureDir, { recursive: true, force: true }); diff --git a/test/queue-ipc-errors.test.ts b/test/queue-ipc-errors.test.ts index 9206de17..858a2c6a 100644 --- a/test/queue-ipc-errors.test.ts +++ b/test/queue-ipc-errors.test.ts @@ -839,6 +839,31 @@ test("startup probe treats lease-before-bind as a miss without a health reconnec }); }); +test("startup probe retires a fresh dead owner instead of treating it as lease-before-bind", async () => { + await withTempHome(async (homeDir) => { + const sessionId = "startup-dead-before-bind"; + const { lockPath, socketPath } = queuePaths(homeDir, sessionId); + await writeQueueOwnerLock({ + lockPath, + pid: 999_999, + sessionId, + socketPath, + }); + + const outcome = await trySubmitToRunningOwner({ + sessionId, + message: "hello", + permissionMode: "approve-reads", + outputFormatter: NOOP_OUTPUT_FORMATTER, + waitForCompletion: true, + startupProbe: true, + }); + + assert.equal(outcome, undefined); + await assert.rejects(fs.access(lockPath)); + }); +}); + test("startup probe fails closed for an older live owner without a socket", async () => { await withTempHome(async (homeDir) => { const sessionId = "startup-owner-not-accepting"; diff --git a/test/queue-lease-store.test.ts b/test/queue-lease-store.test.ts index cf67cc90..72ba7e02 100644 --- a/test/queue-lease-store.test.ts +++ b/test/queue-lease-store.test.ts @@ -4,7 +4,9 @@ import { once } from "node:events"; import fs from "node:fs/promises"; import path from "node:path"; import test from "node:test"; +import { fileURLToPath } from "node:url"; import { + claimQueueOwnerLock, ensureOwnerIsUsable, isProcessAlive, readQueueOwnerRecord, @@ -156,7 +158,7 @@ test("tryAcquireQueueOwnerLease tightens queue directory permissions", async () }); }); -test("tryAcquireQueueOwnerLease clears stale dead owners and can acquire on retry", async () => { +test("tryAcquireQueueOwnerLease replaces a stale dead owner in the same attempt", async () => { await withTempHome(async (homeDir) => { const sessionId = "stale-dead-owner"; const { lockPath, socketPath } = queuePaths(homeDir, sessionId); @@ -169,11 +171,37 @@ test("tryAcquireQueueOwnerLease clears stale dead owners and can acquire on retr heartbeatAt: "2000-01-01T00:00:00.000Z", }); - assert.equal(await tryAcquireQueueOwnerLease(sessionId), undefined); - assert.equal(await readQueueOwnerRecord(sessionId), undefined); - const lease = await tryAcquireQueueOwnerLease(sessionId); assert(lease); + assert.equal((await readQueueOwnerRecord(sessionId))?.ownerGeneration, lease.ownerGeneration); + await releaseQueueOwnerLease(lease); + }); +}); + +test("retry acquisition timestamps the replacement after stale-owner cleanup", async () => { + await withTempHome(async (homeDir) => { + const sessionId = "stale-owner-fresh-replacement-time"; + const { lockPath, socketPath } = queuePaths(homeDir, sessionId); + await writeQueueOwnerLock({ + lockPath, + pid: 999_999, + sessionId, + socketPath, + heartbeatAt: "2000-01-01T00:00:00.000Z", + }); + const timestamps = ["2026-07-29T12:00:00.000Z", "2026-07-29T12:00:07.000Z"]; + let clockIndex = 0; + + const lease = await tryAcquireQueueOwnerLease( + sessionId, + () => timestamps[clockIndex++] ?? timestamps[1], + ); + assert(lease); + assert.equal(lease.createdAt, timestamps[1]); + const owner = await readQueueOwnerRecord(sessionId); + assert(owner); + assert.equal(owner.createdAt, timestamps[1]); + assert.equal(owner.heartbeatAt, timestamps[1]); await releaseQueueOwnerLease(lease); }); }); @@ -215,14 +243,12 @@ test("tryAcquireQueueOwnerLease removes a malformed lock only after it is stale" await fs.writeFile(socketPath, "stale-socket-placeholder", "utf8"); } - assert.equal(await tryAcquireQueueOwnerLease(sessionId), undefined); - await assert.rejects(fs.access(lockPath)); + const lease = await tryAcquireQueueOwnerLease(sessionId); + assert(lease); + assert.equal((await readQueueOwnerRecord(sessionId))?.ownerGeneration, lease.ownerGeneration); if (process.platform !== "win32") { await assert.rejects(fs.access(socketPath)); } - - const lease = await tryAcquireQueueOwnerLease(sessionId); - assert(lease); await releaseQueueOwnerLease(lease); }); }); @@ -238,11 +264,9 @@ test( await fs.symlink("missing-owner-record", lockPath); await fs.lutimes(lockPath, new Date(0), new Date(0)); - assert.equal(await tryAcquireQueueOwnerLease(sessionId), undefined); - await assert.rejects(fs.lstat(lockPath)); - const lease = await tryAcquireQueueOwnerLease(sessionId); assert(lease); + assert.equal((await readQueueOwnerRecord(sessionId))?.ownerGeneration, lease.ownerGeneration); await releaseQueueOwnerLease(lease); }); }, @@ -275,6 +299,187 @@ test("refreshQueueOwnerLease never exposes a partial record to concurrent reader }); }); +test("lock claims serialize cross-process refresh and release mutations", async () => { + await withTempHome(async () => { + const sessionId = "claimed-owner-mutation"; + const lease = await tryAcquireQueueOwnerLease(sessionId); + assert(lease); + const claim = await claimQueueOwnerLock(lease.lockPath, lease.ownerGeneration); + assert(claim); + assert.equal(await claim.isHeld(), true); + + await refreshQueueOwnerLease(lease, { queueDepth: 7 }); + assert.equal(await claim.isHeld(), true); + await releaseQueueOwnerLease({ ...lease }); + assert.equal(await claim.isHeld(), true); + const blockedRecord = await readQueueOwnerRecord(sessionId); + assert(blockedRecord); + assert.equal(blockedRecord.queueDepth, 0); + + await claim.release(); + await refreshQueueOwnerLease(lease, { queueDepth: 7 }); + const refreshedRecord = await readQueueOwnerRecord(sessionId); + assert(refreshedRecord); + assert.equal(refreshedRecord.queueDepth, 7); + await releaseQueueOwnerLease(lease); + }); +}); + +test("lock claims recover after a claimant crashes", async () => { + await withTempHome(async () => { + const sessionId = "crashed-lock-claimant"; + const lease = await tryAcquireQueueOwnerLease(sessionId); + assert(lease); + const modulePath = fileURLToPath(new URL("../src/cli/queue/lease-store.js", import.meta.url)); + const script = ` + const { claimQueueOwnerLock } = await import(${JSON.stringify(modulePath)}); + const claim = await claimQueueOwnerLock( + ${JSON.stringify(lease.lockPath)}, + ${lease.ownerGeneration}, + ); + if (!claim) process.exit(2); + process.stdout.write("claimed\\n"); + setInterval(() => {}, 60_000); + `; + const claimant = spawn( + process.execPath, + ["--import", "tsx", "--input-type=module", "-e", script], + { stdio: ["ignore", "pipe", "inherit"] }, + ); + + try { + assert(claimant.stdout); + const output = await waitForChildOutput(claimant); + assert.equal(output.toString(), "claimed\n"); + assert.equal(await claimQueueOwnerLock(lease.lockPath, lease.ownerGeneration), undefined); + + claimant.kill("SIGKILL"); + await once(claimant, "close"); + + const recovered = await claimQueueOwnerLock(lease.lockPath, lease.ownerGeneration); + assert(recovered); + await recovered.release(); + await releaseQueueOwnerLease(lease); + } finally { + if (claimant.exitCode == null && claimant.signalCode == null) { + claimant.kill("SIGKILL"); + } + } + }); +}); + +test("explicit cleanup waits for an active lease claim", async () => { + await withTempHome(async () => { + const sessionId = "cleanup-claim-contention"; + const lease = await tryAcquireQueueOwnerLease(sessionId); + assert(lease); + const claim = await claimQueueOwnerLock(lease.lockPath, lease.ownerGeneration); + assert(claim); + + const cleanup = terminateQueueOwnerForSession(sessionId); + await new Promise((resolve) => { + setTimeout(resolve, 50); + }); + assert(await readQueueOwnerRecord(sessionId)); + await claim.release(); + await cleanup; + + assert.equal(await readQueueOwnerRecord(sessionId), undefined); + }); +}); + +test("explicit cleanup fails visibly after prolonged lease claim contention", async () => { + await withTempHome(async () => { + const sessionId = "cleanup-prolonged-claim-contention"; + const lease = await tryAcquireQueueOwnerLease(sessionId); + assert(lease); + const claim = await claimQueueOwnerLock(lease.lockPath, lease.ownerGeneration); + assert(claim); + + await assert.rejects( + terminateQueueOwnerForSession(sessionId), + /cleanup is busy.*retry the operation/, + ); + assert(await readQueueOwnerRecord(sessionId)); + + await claim.release(); + await terminateQueueOwnerForSession(sessionId); + assert.equal(await readQueueOwnerRecord(sessionId), undefined); + }); +}); + +async function waitForChildOutput(child: ReturnType): Promise { + const stdout = child.stdout; + assert(stdout); + return await new Promise((resolve, reject) => { + const cleanup = () => { + stdout.off("data", onData); + child.off("exit", onExit); + child.off("error", onError); + }; + const onData = (chunk: Buffer) => { + cleanup(); + resolve(chunk); + }; + const onExit = (code: number | null, signal: NodeJS.Signals | null) => { + cleanup(); + reject(new Error(`claimant exited before readiness: code=${code} signal=${signal}`)); + }; + const onError = (error: Error) => { + cleanup(); + reject(error); + }; + stdout.once("data", onData); + child.once("exit", onExit); + child.once("error", onError); + }); +} + +test("lock claims reject a replacement published after stale identity inspection", async () => { + await withTempHome(async () => { + const sessionId = "stale-identity-replacement"; + const lease = await tryAcquireQueueOwnerLease(sessionId); + assert(lease); + const oldStat = await fs.lstat(lease.lockPath); + const oldPath = `${lease.lockPath}.old`; + await fs.rename(lease.lockPath, oldPath); + const successorGeneration = lease.ownerGeneration + 1; + const successor = JSON.parse(await fs.readFile(oldPath, "utf8")) as Record; + successor.ownerGeneration = successorGeneration; + await fs.writeFile(lease.lockPath, `${JSON.stringify(successor, null, 2)}\n`, "utf8"); + + assert.equal(await claimQueueOwnerLock(lease.lockPath, undefined, oldStat), undefined); + const record = await readQueueOwnerRecord(sessionId); + assert(record); + assert.equal(record.ownerGeneration, successorGeneration); + + await fs.rm(oldPath, { force: true }); + await fs.rm(lease.lockPath, { force: true }); + }); +}); + +test("identity-less lock claims age out after claimant PID reuse cannot be excluded", async () => { + await withTempHome(async () => { + const sessionId = "identity-less-claim"; + const lease = await tryAcquireQueueOwnerLease(sessionId); + assert(lease); + const lockStat = await fs.lstat(lease.lockPath); + const claimPath = `${lease.lockPath}.claim-${lockStat.dev}-${lockStat.ino}`; + await fs.writeFile( + claimPath, + `${JSON.stringify({ claimId: "missing-identity", pid: process.pid })}\n`, + "utf8", + ); + const staleTime = new Date(Date.now() - 20_000); + await fs.utimes(claimPath, staleTime, staleTime); + + const recovered = await claimQueueOwnerLock(lease.lockPath, lease.ownerGeneration); + assert(recovered); + await recovered.release(); + await releaseQueueOwnerLease(lease); + }); +}); + test("released owners cannot overwrite or remove a successor lease", async () => { await withTempHome(async () => { const sessionId = "released-owner-refresh"; @@ -355,7 +560,7 @@ test("ensureOwnerIsUsable cleans up stale live owners", async () => { }); }); -test("tryAcquireQueueOwnerLease terminates stale live owners before retry acquisition", async () => { +test("tryAcquireQueueOwnerLease terminates stale live owners and acquires in the same attempt", async () => { await withTempHome(async (homeDir) => { const sessionId = "stale-live-owner-acquire"; const keeper = await startKeeperProcess(); @@ -370,12 +575,10 @@ test("tryAcquireQueueOwnerLease terminates stale live owners before retry acquis heartbeatAt: "2000-01-01T00:00:00.000Z", }); - assert.equal(await tryAcquireQueueOwnerLease(sessionId), undefined); - assert.equal(await readQueueOwnerRecord(sessionId), undefined); - assert.equal(isProcessAlive(keeper.pid), false); - const lease = await tryAcquireQueueOwnerLease(sessionId); assert(lease); + assert.equal((await readQueueOwnerRecord(sessionId))?.ownerGeneration, lease.ownerGeneration); + assert.equal(isProcessAlive(keeper.pid), false); await releaseQueueOwnerLease(lease); } finally { stopProcess(keeper); diff --git a/test/terminal.test.ts b/test/terminal.test.ts index 54cac437..571dab0f 100644 --- a/test/terminal.test.ts +++ b/test/terminal.test.ts @@ -493,6 +493,45 @@ test("terminal manager kills descendants of no-arg shell command lines", async ( } }); +test("terminal manager kills descendants that detach into a new process group", async (t) => { + if (process.platform === "win32") { + t.skip("POSIX process group assertion"); + return; + } + const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "acpx-terminal-test-")); + const childPidPath = path.join(tmp, "detached-child.pid"); + + try { + const manager = new TerminalManager({ + cwd: tmp, + permissionMode: "approve-all", + killGraceMs: 200, + }); + const detachedScript = "process.on('SIGTERM', () => {}); setInterval(() => {}, 1000);"; + const launcherScript = [ + "const { spawn } = require('node:child_process');", + "const fs = require('node:fs');", + `const child = spawn(process.execPath, ['-e', ${JSON.stringify(detachedScript)}], { detached: true, stdio: 'ignore' });`, + `fs.writeFileSync(${JSON.stringify(childPidPath)}, String(child.pid));`, + "setInterval(() => {}, 1000);", + ].join(""); + const created = await manager.createTerminal({ + sessionId: "session-1", + command: `${JSON.stringify(process.execPath)} -e ${JSON.stringify(launcherScript)} & wait`, + }); + + const childPid = await waitForPidFile(childPidPath); + await manager.killTerminal({ + sessionId: "session-1", + terminalId: created.terminalId, + }); + + await assertPidExits(childPid); + } finally { + await fs.rm(tmp, { recursive: true, force: true }); + } +}); + test("terminal manager releases shell command groups after wrapper exit", async (t) => { if (process.platform === "win32") { t.skip("POSIX process group assertion"); From ec048e503c1cb9221562e7b048c1aeacd95ff142 Mon Sep 17 00:00:00 2001 From: trumpyla Date: Wed, 29 Jul 2026 17:23:04 -0400 Subject: [PATCH 29/57] fix: close adapter lifecycle races --- CHANGELOG.md | 22 ++--- src/acp/process-tree.ts | 53 ++++++++---- src/cli/queue/ipc.ts | 2 +- src/cli/queue/lease-store.ts | 34 +++++++- src/cli/session/queue-owner-runtime.ts | 11 ++- test/process-tree.test.ts | 109 +++++++++++++++++++++++++ test/queue-lease-store.test.ts | 47 +++++++++++ test/queue-owner-process.test.ts | 8 ++ 8 files changed, 253 insertions(+), 33 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1ccd0d02..b7b808a6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,21 +20,23 @@ Repo: https://github.com/openclaw/acpx or group changes cannot escape forced cleanup, owned POSIX group members created during shutdown are discovered and re-signaled, external process-list discovery is bounded, Linux process identities come from procfs, other POSIX - snapshots use a fixed locale, Windows wrappers are sampled with bounded - exponential backoff through multi-stage launches, transient discovery + snapshots use a fixed locale, adapter wrappers are sampled with bounded + exponential backoff through multi-stage launches, escaped descendants are + extended from identity-validated parents after root exit, transient discovery failures preserve and still signal captured targets, and Windows parent edges plus remembered descendants are identity-checked before later signaling. - Runtime/queue: publish queue-owner leases atomically, preserve fresh malformed locks during collisions, serialize cross-process refresh, release, and stale - cleanup through inode-qualified, crash-recoverable claims, retry explicit - cleanup across heartbeat contention while making unresolved explicit cleanup - fail visibly, reacquire atomically with fresh timestamps after retiring a - stale collision, and treat only a fresh, live lease-before-bind owner as a - fast startup miss, preventing premature - `QUEUE_NOT_ACCEPTING_REQUESTS` errors without masking older unreachable - owners, letting released owners overwrite successors, or leaving stale - dangling-symlink locks unrecoverable. + cleanup through inode-qualified, crash-recoverable claims, revalidate stale + owners while holding cleanup claims, retry explicit cleanup across heartbeat + contention while making unresolved explicit cleanup fail visibly, reacquire + atomically with fresh timestamps after retiring a stale collision, and treat + only a fresh, live lease-before-bind owner as a fast startup miss while + retaining a retry window that covers the full startup grace period, preventing + premature `QUEUE_NOT_ACCEPTING_REQUESTS` errors without masking older + unreachable owners, letting released owners overwrite successors, or leaving + stale dangling-symlink locks unrecoverable. ## 2026.7.27 (v0.13.0) diff --git a/src/acp/process-tree.ts b/src/acp/process-tree.ts index 88631627..1e9d42dd 100644 --- a/src/acp/process-tree.ts +++ b/src/acp/process-tree.ts @@ -3,9 +3,9 @@ import fs from "node:fs/promises"; const PROCESS_TREE_POLL_MS = 25; const PROCESS_LIST_COMMAND_TIMEOUT_MS = 1_000; -const WINDOWS_TRACKING_INITIAL_POLL_MS = 25; -const WINDOWS_TRACKING_MAX_POLL_MS = 1_000; -const WINDOWS_TRACKING_MAX_DURATION_MS = 10_000; +const PROCESS_TREE_TRACKING_INITIAL_POLL_MS = 25; +const PROCESS_TREE_TRACKING_MAX_POLL_MS = 1_000; +const PROCESS_TREE_TRACKING_MAX_DURATION_MS = 10_000; const WINDOWS_EPOCH_OFFSET_TICKS = 621_355_968_000_000_000n; export type ManagedProcessTree = { @@ -61,9 +61,10 @@ export function beginProcessTreeTracking( tree: ManagedProcessTree, rootRunning: () => boolean, ): void { - if (tree.platform === "win32") { - queueWindowsProcessTreeTracking(tree, rootRunning); + if (!tree.killProcessGroup || !tree.rootPid) { + return; } + queueProcessTreeTracking(tree, rootRunning); } export async function captureProcessTreePids( @@ -99,17 +100,14 @@ function queueProcessTreeSnapshot(tree: ManagedProcessTree): void { })(); } -function queueWindowsProcessTreeTracking( - tree: ManagedProcessTree, - rootRunning: () => boolean, -): void { +function queueProcessTreeTracking(tree: ManagedProcessTree, rootRunning: () => boolean): void { const priorSnapshot = tree.snapshotPromise; tree.snapshotPromise = (async () => { await priorSnapshot?.catch(() => { // Tracking can continue after an earlier best-effort snapshot failure. }); - const deadline = Date.now() + WINDOWS_TRACKING_MAX_DURATION_MS; - let pollMs = WINDOWS_TRACKING_INITIAL_POLL_MS; + const deadline = Date.now() + PROCESS_TREE_TRACKING_MAX_DURATION_MS; + let pollMs = PROCESS_TREE_TRACKING_INITIAL_POLL_MS; while (rootRunning() && Date.now() < deadline) { const descendantCount = tree.descendantPids.size; await recordCurrentProcessTreePids(tree); @@ -117,8 +115,8 @@ function queueWindowsProcessTreeTracking( await waitMs(pollMs); pollMs = tree.descendantPids.size > descendantCount - ? WINDOWS_TRACKING_INITIAL_POLL_MS - : Math.min(pollMs * 2, WINDOWS_TRACKING_MAX_POLL_MS); + ? PROCESS_TREE_TRACKING_INITIAL_POLL_MS + : Math.min(pollMs * 2, PROCESS_TREE_TRACKING_MAX_POLL_MS); } } })(); @@ -220,7 +218,7 @@ export async function waitForProcessTreeExit( } rootIsRunning = rootRunning(); } - if (!rootIsRunning && !hasLiveManagedProcessTree(tree, rootIsRunning)) { + if (shouldRefreshExitedTree(tree, rootIsRunning, finalSignal)) { if (await exitedTreeRemainsEmptyAfterRefresh(tree, finalSignal, signaledIdentities)) { return true; } @@ -232,6 +230,14 @@ export async function waitForProcessTreeExit( } } +function shouldRefreshExitedTree( + tree: ManagedProcessTree, + rootIsRunning: boolean, + finalSignal: NodeJS.Signals | undefined, +): boolean { + return !rootIsRunning && Boolean(finalSignal || !hasLiveManagedProcessTree(tree, false)); +} + async function waitForPriorSnapshotBeforeDeadline( tree: ManagedProcessTree, deadline: number, @@ -262,12 +268,12 @@ async function exitedTreeRemainsEmptyAfterRefresh( return true; } if (finalSignal) { - signalNewlyDiscoveredProcessPids(tree, finalSignal, signaledIdentities); + signalUnsignaledProcessPids(tree, finalSignal, signaledIdentities); } return false; } -function signalNewlyDiscoveredProcessPids( +function signalUnsignaledProcessPids( tree: ManagedProcessTree, signal: NodeJS.Signals, signaledIdentities: Set, @@ -544,6 +550,7 @@ async function refreshExitedProcessTreePids(tree: ManagedProcessTree): Promise [entry.pid, entry])); retainCurrentProcessTreePids(tree, currentByPid); if (tree.platform !== "win32") { + discoverRememberedPosixDescendants(tree, currentByPid, processList); discoverCurrentPosixGroupMembers(tree, rootPid, processList); } return true; @@ -563,6 +570,20 @@ function retainCurrentProcessTreePids( } } +function discoverRememberedPosixDescendants( + tree: ManagedProcessTree, + currentByPid: Map, + processList: ProcessListEntry[], +): void { + const childrenByParent = indexProcessesByParent(processList); + const rememberedRoots = [...tree.descendantPids] + .map((pid) => currentByPid.get(pid)) + .filter((entry): entry is ProcessListEntry => entry !== undefined); + for (const rememberedRoot of rememberedRoots) { + recordProcessTreePids(tree, walkValidatedDescendants(rememberedRoot, childrenByParent)); + } +} + function discoverCurrentPosixGroupMembers( tree: ManagedProcessTree, rootPid: number, diff --git a/src/cli/queue/ipc.ts b/src/cli/queue/ipc.ts index 2231d3c1..93b8fdd7 100644 --- a/src/cli/queue/ipc.ts +++ b/src/cli/queue/ipc.ts @@ -52,7 +52,7 @@ export { } from "./lease-store.js"; export type { QueueOwnerLease } from "./lease-store.js"; -const QUEUE_OWNER_STARTUP_GRACE_MS = 10_000; +export const QUEUE_OWNER_STARTUP_GRACE_MS = 10_000; const STALE_OWNER_PROTOCOL_DETAIL_CODES = new Set([ "QUEUE_PROTOCOL_MALFORMED_MESSAGE", "QUEUE_PROTOCOL_UNEXPECTED_RESPONSE", diff --git a/src/cli/queue/lease-store.ts b/src/cli/queue/lease-store.ts index 0cb50509..a3cf6fb1 100644 --- a/src/cli/queue/lease-store.ts +++ b/src/cli/queue/lease-store.ts @@ -189,9 +189,9 @@ async function cleanupStaleQueueOwner( sessionId: string, owner: QueueOwnerRecord | undefined, expectedLockStat?: Stats, + revalidateStaleness = false, ): Promise { const lockPath = queueLockFilePath(sessionId); - const socketPath = owner?.socketPath ?? queueSocketPath(sessionId); const claim = await claimQueueOwnerLockForCleanup( lockPath, owner?.ownerGeneration, @@ -202,9 +202,12 @@ async function cleanupStaleQueueOwner( } try { - if (owner && isProcessAlive(owner.pid)) { - await terminateProcess(owner.pid); + const claimedOwner = await resolveQueueOwnerForCleanup(lockPath, owner, revalidateStaleness); + if (claimedOwner === false) { + return false; } + const socketPath = claimedOwner?.socketPath ?? queueSocketPath(sessionId); + await terminateClaimedQueueOwner(claimedOwner); await removeClaimedQueueOwnerFiles(claim, socketPath, lockPath); return true; } finally { @@ -212,6 +215,29 @@ async function cleanupStaleQueueOwner( } } +async function terminateClaimedQueueOwner(owner: QueueOwnerRecord | undefined): Promise { + if (owner && isProcessAlive(owner.pid)) { + await terminateProcess(owner.pid); + } +} + +async function resolveQueueOwnerForCleanup( + lockPath: string, + owner: QueueOwnerRecord | undefined, + revalidateStaleness: boolean, +): Promise { + if (!revalidateStaleness || !owner) { + return owner; + } + const currentOwner = await readQueueOwnerRecordAtPath(lockPath); + if (currentOwner?.ownerGeneration !== owner.ownerGeneration) { + return false; + } + return isProcessAlive(currentOwner.pid) && !isQueueOwnerHeartbeatStale(currentOwner) + ? false + : currentOwner; +} + async function claimQueueOwnerLockForCleanup( lockPath: string, expectedGeneration?: number, @@ -285,7 +311,7 @@ async function retireStaleQueueOwner( sessionId: string, owner: QueueOwnerRecord | undefined, ): Promise { - return await cleanupStaleQueueOwner(sessionId, owner); + return await cleanupStaleQueueOwner(sessionId, owner, undefined, true); } export type QueueOwnerLockClaim = { diff --git a/src/cli/session/queue-owner-runtime.ts b/src/cli/session/queue-owner-runtime.ts index 85aa1d10..140548a9 100644 --- a/src/cli/session/queue-owner-runtime.ts +++ b/src/cli/session/queue-owner-runtime.ts @@ -17,6 +17,7 @@ import { import type { SessionSendOutcome } from "../../types.js"; import { QUEUE_CONNECT_RETRY_MS, + QUEUE_OWNER_STARTUP_GRACE_MS, SessionQueueOwner, releaseQueueOwnerLease, tryAcquireQueueOwnerLease, @@ -48,7 +49,8 @@ import { } from "./queue-owner-process.js"; import { runQueuedTask } from "./runtime.js"; -const QUEUE_OWNER_STARTUP_MAX_ATTEMPTS = 120; +const QUEUE_OWNER_STARTUP_MAX_ATTEMPTS = + Math.ceil(QUEUE_OWNER_STARTUP_GRACE_MS / QUEUE_CONNECT_RETRY_MS) + 1; const QUEUE_OWNER_HEARTBEAT_INTERVAL_MS = 5_000; const QUEUE_OWNER_ACTIVE_TURN_CANCEL_GRACE_MS = 750; @@ -573,4 +575,9 @@ export async function sendSession(options: SessionSendOptions): Promise { + const fixtureDir = await fs.mkdtemp(path.join(os.tmpdir(), "acpx-posix-tracking-")); + const psPath = path.join(fixtureDir, "ps"); + const counterPath = path.join(fixtureDir, "counter"); + const originalPath = process.env.PATH; + await fs.writeFile( + psPath, + [ + "#!/bin/sh", + `counter=${JSON.stringify(counterPath)}`, + 'count=$(cat "$counter" 2>/dev/null || true)', + "count=${count:-0}", + "count=$((count + 1))", + 'printf "%s" "$count" > "$counter"', + 'printf "500 1 500 Wed Jul 29 12:00:00 2026\\n"', + 'if [ "$count" -ge 2 ]; then', + ` printf "${process.pid} 500 999 Wed Jul 29 12:00:01 2026\\n"`, + "fi", + ].join("\n"), + { mode: 0o755 }, + ); + process.env.PATH = `${fixtureDir}${path.delimiter}${originalPath ?? ""}`; + + let running = true; + try { + const tree = createManagedProcessTree(500, true, "darwin"); + beginProcessTreeTracking(tree, () => running); + for (let attempt = 0; attempt < 100 && !tree.descendantPids.has(process.pid); attempt += 1) { + await new Promise((resolve) => { + setTimeout(resolve, 25); + }); + } + running = false; + await tree.snapshotPromise; + + assert.equal(tree.descendantPids.has(process.pid), true); + } finally { + running = false; + process.env.PATH = originalPath; + await fs.rm(fixtureDir, { recursive: true, force: true }); + } + }, +); + test( "Linux process identities do not depend on unsupported BusyBox ps columns", { skip: process.platform !== "linux" }, @@ -287,6 +334,40 @@ test( }, ); +test( + "exited POSIX trees discover children of identity-validated escaped descendants", + { skip: process.platform === "win32" }, + async () => { + const fixtureDir = await fs.mkdtemp(path.join(os.tmpdir(), "acpx-posix-escaped-")); + const psPath = path.join(fixtureDir, "ps"); + const originalPath = process.env.PATH; + await fs.writeFile( + psPath, + [ + "#!/bin/sh", + 'printf "600 1 700 Wed Jul 29 12:00:00 2026\\n"', + `printf "${process.pid} 600 999 Wed Jul 29 12:00:01 2026\\n"`, + ].join("\n"), + { mode: 0o755 }, + ); + process.env.PATH = `${fixtureDir}${path.delimiter}${originalPath ?? ""}`; + + try { + const tree = createManagedProcessTree(500, true, "darwin"); + tree.descendantPids.add(600); + tree.descendantIdentities.set(600, "Wed Jul 29 12:00:00 2026"); + + await signalProcessTree(tree, false, "SIGCONT"); + + assert.equal(tree.descendantPids.has(process.pid), true); + assert.equal(tree.descendantIdentities.get(process.pid), "Wed Jul 29 12:00:01 2026"); + } finally { + process.env.PATH = originalPath; + await fs.rm(fixtureDir, { recursive: true, force: true }); + } + }, +); + test( "exited POSIX trees discover descendants spawned after the exit snapshot", { skip: process.platform === "win32" }, @@ -358,3 +439,31 @@ test( } }, ); + +test( + "final POSIX cleanup signals already-tracked live descendants after root exit", + { skip: process.platform === "win32" }, + async () => { + const child = spawn("sleep", ["30"], { + detached: true, + stdio: "ignore", + }); + const childPid = child.pid; + assert(childPid); + + try { + const childIdentity = await readProcessIdentity(childPid); + assert(childIdentity); + const tree = createManagedProcessTree(999_999, true, process.platform); + tree.descendantPids.add(childPid); + tree.descendantIdentities.set(childPid, childIdentity); + + assert.equal(await waitForProcessTreeExit(tree, () => false, 2_000, "SIGKILL"), true); + assert.throws(() => process.kill(childPid, 0)); + } finally { + if (child.exitCode === null && child.signalCode === null) { + child.kill("SIGKILL"); + } + } + }, +); diff --git a/test/queue-lease-store.test.ts b/test/queue-lease-store.test.ts index 72ba7e02..87d720a3 100644 --- a/test/queue-lease-store.test.ts +++ b/test/queue-lease-store.test.ts @@ -560,6 +560,53 @@ test("ensureOwnerIsUsable cleans up stale live owners", async () => { }); }); +test("stale cleanup preserves an owner refreshed before its cleanup claim", async () => { + await withTempHome(async (homeDir) => { + const sessionId = "stale-owner-refreshed-before-claim"; + const keeper = await startKeeperProcess(); + const { lockPath, socketPath } = queuePaths(homeDir, sessionId); + let claim: Awaited> | undefined; + + try { + await writeQueueOwnerLock({ + lockPath, + pid: keeper.pid, + sessionId, + socketPath, + heartbeatAt: "2000-01-01T00:00:00.000Z", + }); + const staleOwner = await readQueueOwnerRecord(sessionId); + assert(staleOwner); + claim = await claimQueueOwnerLock(lockPath, staleOwner.ownerGeneration); + assert(claim); + + const cleanup = ensureOwnerIsUsable(sessionId, staleOwner); + await new Promise((resolve) => { + setTimeout(resolve, 50); + }); + + const refreshedPath = `${lockPath}.refreshed`; + await fs.writeFile( + refreshedPath, + `${JSON.stringify({ ...staleOwner, heartbeatAt: new Date().toISOString() })}\n`, + "utf8", + ); + await fs.rename(refreshedPath, lockPath); + + assert.equal(await cleanup, false); + assert.equal(isProcessAlive(keeper.pid), true); + assert.equal((await readQueueOwnerStatus(sessionId))?.alive, true); + } finally { + await claim?.release(); + stopProcess(keeper); + await fs.rm(lockPath, { force: true }); + if (process.platform !== "win32") { + await fs.rm(socketPath, { force: true }); + } + } + }); +}); + test("tryAcquireQueueOwnerLease terminates stale live owners and acquires in the same attempt", async () => { await withTempHome(async (homeDir) => { const sessionId = "stale-live-owner-acquire"; diff --git a/test/queue-owner-process.test.ts b/test/queue-owner-process.test.ts index cc1dc13f..1b6fb08b 100644 --- a/test/queue-owner-process.test.ts +++ b/test/queue-owner-process.test.ts @@ -38,6 +38,14 @@ async function waitForCondition( } } +it("queue owner startup retries cover the full lease-before-bind grace period", () => { + const { queueOwnerStartupGraceMs, queueOwnerStartupMaxAttempts, queueConnectRetryMs } = + queueOwnerRuntimeTestInternals; + const retryWindowMs = (queueOwnerStartupMaxAttempts - 1) * queueConnectRetryMs; + + assert(retryWindowMs >= queueOwnerStartupGraceMs); +}); + describe("resolveQueueOwnerSpawnArgs", () => { it("prefers ACPX_QUEUE_OWNER_ARGS when provided", () => { const previous = process.env.ACPX_QUEUE_OWNER_ARGS; From 475bdb8bf8c94497607bd583a9b4139be1fa4c92 Mon Sep 17 00:00:00 2001 From: trumpyla Date: Wed, 29 Jul 2026 17:31:37 -0400 Subject: [PATCH 30/57] fix: preserve concurrent queue lease claims --- src/cli/queue/lease-store.ts | 10 ++++-- test/queue-lease-store.test.ts | 58 ++++++++++++++++++++++++++++++++++ 2 files changed, 65 insertions(+), 3 deletions(-) diff --git a/src/cli/queue/lease-store.ts b/src/cli/queue/lease-store.ts index a3cf6fb1..b00ee844 100644 --- a/src/cli/queue/lease-store.ts +++ b/src/cli/queue/lease-store.ts @@ -438,13 +438,17 @@ async function restoreDisplacedQueueOwnerLockClaim( claimPath: string, ): Promise { try { - await fs.rename(quarantinePath, claimPath); + await fs.link(quarantinePath, claimPath); } catch (error) { - if ((error as NodeJS.ErrnoException).code !== "EEXIST") { + const code = (error as NodeJS.ErrnoException).code; + if (code === "ENOENT") { + return; + } + if (code !== "EEXIST") { throw error; } - await unlinkIfPresent(quarantinePath); } + await unlinkIfPresent(quarantinePath); } async function queueOwnerLockClaimIsStale(claimPath: string, stat: Stats): Promise { diff --git a/test/queue-lease-store.test.ts b/test/queue-lease-store.test.ts index 87d720a3..a4361c14 100644 --- a/test/queue-lease-store.test.ts +++ b/test/queue-lease-store.test.ts @@ -480,6 +480,64 @@ test("identity-less lock claims age out after claimant PID reuse cannot be exclu }); }); +test("restoring a displaced lock claim never overwrites a concurrent claim", async () => { + await withTempHome(async () => { + const sessionId = "displaced-claim-restore-race"; + const lease = await tryAcquireQueueOwnerLease(sessionId); + assert(lease); + const lockStat = await fs.lstat(lease.lockPath); + const claimPath = `${lease.lockPath}.claim-${lockStat.dev}-${lockStat.ino}`; + const displacedClaim = { + claimId: "displaced-claim", + pid: process.pid, + }; + const concurrentClaim = { + claimId: "concurrent-claim", + pid: process.pid, + }; + await fs.writeFile(claimPath, `${JSON.stringify(displacedClaim)}\n`, "utf8"); + const staleTime = new Date(Date.now() - 20_000); + await fs.utimes(claimPath, staleTime, staleTime); + + const originalRename = fs.rename; + let raceInjected = false; + fs.rename = async (oldPath, newPath): Promise => { + await originalRename(oldPath, newPath); + if ( + !raceInjected && + oldPath === claimPath && + String(newPath).startsWith(`${claimPath}.reap-`) + ) { + raceInjected = true; + const refreshedTime = new Date(); + await fs.utimes(newPath, refreshedTime, refreshedTime); + await fs.writeFile(claimPath, `${JSON.stringify(concurrentClaim)}\n`, { + encoding: "utf8", + flag: "wx", + }); + } + }; + + try { + assert.equal(await claimQueueOwnerLock(lease.lockPath, lease.ownerGeneration), undefined); + assert.equal(raceInjected, true); + const survivingClaim = JSON.parse(await fs.readFile(claimPath, "utf8")) as { + claimId?: unknown; + }; + assert.equal(survivingClaim.claimId, concurrentClaim.claimId); + const claimFiles = await fs.readdir(path.dirname(claimPath)); + assert.equal( + claimFiles.some((fileName) => fileName.startsWith(`${path.basename(claimPath)}.reap-`)), + false, + ); + } finally { + fs.rename = originalRename; + await fs.rm(claimPath, { force: true }); + await releaseQueueOwnerLease(lease); + } + }); +}); + test("released owners cannot overwrite or remove a successor lease", async () => { await withTempHome(async () => { const sessionId = "released-owner-refresh"; From 5d3063b81189d8b9da851abf0891b88828d68f41 Mon Sep 17 00:00:00 2001 From: trumpyla Date: Wed, 29 Jul 2026 18:40:06 -0400 Subject: [PATCH 31/57] fix: close adversarial lifecycle races --- src/acp/process-tree.ts | 35 ++++++--- src/cli/queue/ipc.ts | 2 +- src/cli/queue/lease-store.ts | 86 ++++++++++++++++++-- test/process-tree.test.ts | 97 +++++++++++++++++++++++ test/queue-ipc-errors.test.ts | 60 +++++++++++++- test/queue-lease-store.test.ts | 138 ++++++++++++++++++++++++++++++++- 6 files changed, 398 insertions(+), 20 deletions(-) diff --git a/src/acp/process-tree.ts b/src/acp/process-tree.ts index 1e9d42dd..48743e2f 100644 --- a/src/acp/process-tree.ts +++ b/src/acp/process-tree.ts @@ -3,6 +3,7 @@ import fs from "node:fs/promises"; const PROCESS_TREE_POLL_MS = 25; const PROCESS_LIST_COMMAND_TIMEOUT_MS = 1_000; +const WINDOWS_PROCESS_LIST_COMMAND_TIMEOUT_MS = 5_000; const PROCESS_TREE_TRACKING_INITIAL_POLL_MS = 25; const PROCESS_TREE_TRACKING_MAX_POLL_MS = 1_000; const PROCESS_TREE_TRACKING_MAX_DURATION_MS = 10_000; @@ -519,17 +520,16 @@ async function listOwnedPosixProcesses(tree: ManagedProcessTree): Promise entry.pid === rootPid); + if (!posixRootIdentityMatches(tree, root)) { + return []; + } const ownedByPid = new Map( processList .filter((entry) => entry.processGroupId === rootPid) .map((entry) => [entry.pid, entry]), ); - const root = processList.find((entry) => entry.pid === rootPid); - if ( - root && - (!tree.rootIdentity || tree.rootIdentity === root.identity) && - root.processGroupId === rootPid - ) { + if (root) { tree.rootIdentity = root.identity; for (const descendant of walkValidatedDescendants(root, indexProcessesByParent(processList))) { ownedByPid.set(descendant.pid, descendant); @@ -538,6 +538,17 @@ async function listOwnedPosixProcesses(tree: ManagedProcessTree): Promise { const rootPid = tree.rootPid; if (!tree.killProcessGroup || !rootPid) { @@ -592,9 +603,8 @@ function discoverCurrentPosixGroupMembers( const currentGroup = processList.filter((entry) => entry.processGroupId === rootPid); if (currentGroup.some((entry) => entry.pid === rootPid)) { // A live process whose PID equals the old group leader means the numeric - // PID/PGID was recycled after the owned group became empty. - tree.descendantPids.clear(); - tree.descendantIdentities.clear(); + // PID/PGID was recycled. Do not adopt ambiguous group members, but retain + // descendants whose identities were validated by retainCurrentProcessTreePids. return; } recordProcessTreePids(tree, currentGroup); @@ -617,6 +627,8 @@ async function runWindowsProcessListCommand(): Promise { "powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", command], "powershell process list", + undefined, + WINDOWS_PROCESS_LIST_COMMAND_TIMEOUT_MS, ); } @@ -625,6 +637,7 @@ async function runCapturedCommand( args: string[], description: string, env?: NodeJS.ProcessEnv, + timeoutMs = PROCESS_LIST_COMMAND_TIMEOUT_MS, ): Promise { return await new Promise((resolve, reject) => { const child = spawn(command, args, { @@ -658,9 +671,9 @@ async function runCapturedCommand( } child.unref(); finish({ - error: new Error(`${description} did not exit within ${PROCESS_LIST_COMMAND_TIMEOUT_MS}ms`), + error: new Error(`${description} did not exit within ${timeoutMs}ms`), }); - }, PROCESS_LIST_COMMAND_TIMEOUT_MS); + }, timeoutMs); child.stdout.setEncoding("utf8"); child.stderr.setEncoding("utf8"); diff --git a/src/cli/queue/ipc.ts b/src/cli/queue/ipc.ts index 93b8fdd7..f64a3be4 100644 --- a/src/cli/queue/ipc.ts +++ b/src/cli/queue/ipc.ts @@ -425,7 +425,7 @@ async function submitToQueueOwner( return await runQueueOwnerRequest({ owner, request, - connectAttempts: options.startupProbe ? 1 : undefined, + connectAttempts: options.startupProbe && queueOwnerIsWithinStartupGrace(owner) ? 1 : undefined, onAccepted: ({ resolve }) => { options.onQueueAccepted?.(); options.outputFormatter.setContext({ diff --git a/src/cli/queue/lease-store.ts b/src/cli/queue/lease-store.ts index b00ee844..959c3072 100644 --- a/src/cli/queue/lease-store.ts +++ b/src/cli/queue/lease-store.ts @@ -1,6 +1,7 @@ import { randomInt, randomUUID } from "node:crypto"; import type { Stats } from "node:fs"; import fs from "node:fs/promises"; +import path from "node:path"; import { readProcessIdentity } from "../../acp/process-tree.js"; import { isProcessAlive } from "../../process-liveness.js"; import { queueBaseDir, queueLockFilePath, queueSocketBaseDir, queueSocketPath } from "./paths.js"; @@ -351,9 +352,7 @@ export async function claimQueueOwnerLock( return current?.claimId === claimRecord.claimId; }, release: async (): Promise => { - if ((await readQueueOwnerLockClaimRecord(claimPath))?.claimId === claimRecord.claimId) { - await unlinkIfPresent(claimPath); - } + await releaseQueueOwnerLockClaim(claimPath, claimRecord.claimId); }, }; if (!(await validateQueueOwnerLockClaim(lockPath, observedStat, expectedGeneration, claim))) { @@ -362,6 +361,42 @@ export async function claimQueueOwnerLock( return claim; } +async function releaseQueueOwnerLockClaim(claimPath: string, claimId: string): Promise { + if (await releaseQueueOwnerLockClaimAtPath(claimPath, claimId)) { + return; + } + + const directory = path.dirname(claimPath); + const displacedPrefix = `${path.basename(claimPath)}.reap-`; + for (const entry of await readDirectoryIfPresent(directory)) { + if (entry.startsWith(displacedPrefix)) { + await releaseQueueOwnerLockClaimAtPath(path.join(directory, entry), claimId); + } + } + + await releaseQueueOwnerLockClaimAtPath(claimPath, claimId); +} + +async function releaseQueueOwnerLockClaimAtPath( + candidatePath: string, + claimId: string, +): Promise { + if ((await readQueueOwnerLockClaimRecord(candidatePath))?.claimId !== claimId) { + return false; + } + await unlinkIfPresent(candidatePath); + return true; +} + +async function readDirectoryIfPresent(directory: string): Promise { + return await fs.readdir(directory).catch((error: NodeJS.ErrnoException) => { + if (error.code === "ENOENT") { + return []; + } + throw error; + }); +} + async function currentQueueOwnerLockClaimRecord(): Promise { currentProcessIdentityPromise ??= readProcessIdentity(process.pid); return { @@ -835,9 +870,18 @@ export async function releaseQueueOwnerLease(lease: QueueOwnerLease): Promise { await state.pendingRefresh; - const claim = await claimQueueOwnerLock(lease.lockPath, lease.ownerGeneration); + const claim = await claimQueueOwnerLockForCleanup(lease.lockPath, lease.ownerGeneration); if (!claim) { - return; + const currentOwner = await readQueueOwnerRecordAtPath(lease.lockPath); + if (!currentOwner || currentOwner.ownerGeneration !== lease.ownerGeneration) { + return; + } + if (await queueOwnerLockHasLiveExternalClaimant(lease.lockPath)) { + return; + } + throw new Error( + `Queue owner lease release is busy for session ${lease.sessionId}; retry cleanup`, + ); } try { if (!(await claim.isHeld())) { @@ -855,7 +899,34 @@ export async function releaseQueueOwnerLease(lease: QueueOwnerLease): Promise { + const lockStat = await lstatIfPresent(lockPath); + if (!lockStat) { + return false; + } + const claimPath = queueOwnerLockClaimPath(lockPath, lockStat); + const claimStat = await lstatIfPresent(claimPath); + if (!claimStat) { + return false; + } + const claimRecord = await readQueueOwnerLockClaimRecord(claimPath); + return Boolean( + claimRecord && + claimRecord.pid !== process.pid && + !(await queueOwnerLockClaimIsStale(claimPath, claimStat)), + ); } function queueOwnerLeaseState(lease: QueueOwnerLease): QueueOwnerLeaseState { @@ -877,6 +948,9 @@ export async function terminateQueueOwnerForSession(sessionId: string): Promise< } if (!(await cleanupStaleQueueOwner(sessionId, owner))) { + if (!(await readQueueOwnerRecord(sessionId))) { + return; + } throw new Error(`Queue owner cleanup is busy for session ${sessionId}; retry the operation`); } } diff --git a/test/process-tree.test.ts b/test/process-tree.test.ts index 176c2363..52f72c42 100644 --- a/test/process-tree.test.ts +++ b/test/process-tree.test.ts @@ -293,6 +293,36 @@ test( }, ); +test( + "Windows snapshots tolerate process-list startup beyond one second", + { skip: process.platform === "win32" }, + async () => { + const fixtureDir = await fs.mkdtemp(path.join(os.tmpdir(), "acpx-slow-powershell-")); + const powershellPath = path.join(fixtureDir, "powershell.exe"); + const originalPath = process.env.PATH; + await fs.writeFile( + powershellPath, + [ + "#!/bin/sh", + "sleep 1.2", + 'printf "500 1 1000\\n"', + `printf "${process.pid} 500 1100\\n"`, + ].join("\n"), + { mode: 0o755 }, + ); + process.env.PATH = `${fixtureDir}${path.delimiter}${originalPath ?? ""}`; + + try { + const tree = createManagedProcessTree(500, true, "win32"); + await captureProcessTreePids(tree, true); + assert.equal(tree.descendantPids.has(process.pid), true); + } finally { + process.env.PATH = originalPath; + await fs.rm(fixtureDir, { recursive: true, force: true }); + } + }, +); + test( "signaling a running POSIX group bounds its pre-signal descendant snapshot", { skip: process.platform === "win32" }, @@ -334,6 +364,73 @@ test( }, ); +test( + "exited POSIX trees preserve identity-validated descendants after group leader PID reuse", + { skip: process.platform === "win32" }, + async () => { + const fixtureDir = await fs.mkdtemp(path.join(os.tmpdir(), "acpx-posix-reused-leader-")); + const psPath = path.join(fixtureDir, "ps"); + const originalPath = process.env.PATH; + const descendantIdentity = "Wed Jul 29 12:00:01 2026"; + await fs.writeFile( + psPath, + [ + "#!/bin/sh", + 'printf "500 1 500 Wed Jul 29 12:01:00 2026\\n"', + `printf "${process.pid} 1 500 ${descendantIdentity}\\n"`, + ].join("\n"), + { mode: 0o755 }, + ); + process.env.PATH = `${fixtureDir}${path.delimiter}${originalPath ?? ""}`; + + try { + const tree = createManagedProcessTree(500, true, "darwin"); + tree.descendantPids.add(process.pid); + tree.descendantIdentities.set(process.pid, descendantIdentity); + + await signalProcessTree(tree, false, "SIGCONT"); + + assert.equal(tree.descendantPids.has(process.pid), true); + assert.equal(tree.descendantIdentities.get(process.pid), descendantIdentity); + } finally { + process.env.PATH = originalPath; + await fs.rm(fixtureDir, { recursive: true, force: true }); + } + }, +); + +test( + "running POSIX snapshots reject a recycled group with a mismatched root identity", + { skip: process.platform === "win32" }, + async () => { + const fixtureDir = await fs.mkdtemp(path.join(os.tmpdir(), "acpx-posix-recycled-group-")); + const psPath = path.join(fixtureDir, "ps"); + const originalPath = process.env.PATH; + await fs.writeFile( + psPath, + [ + "#!/bin/sh", + 'printf "500 1 500 Wed Jul 29 12:01:00 2026\\n"', + `printf "${process.pid} 500 500 Wed Jul 29 12:01:01 2026\\n"`, + ].join("\n"), + { mode: 0o755 }, + ); + process.env.PATH = `${fixtureDir}${path.delimiter}${originalPath ?? ""}`; + + try { + const tree = createManagedProcessTree(500, true, "darwin"); + tree.rootIdentity = "Wed Jul 29 12:00:00 2026"; + + await captureProcessTreePids(tree, true); + + assert.equal(tree.descendantPids.has(process.pid), false); + } finally { + process.env.PATH = originalPath; + await fs.rm(fixtureDir, { recursive: true, force: true }); + } + }, +); + test( "exited POSIX trees discover children of identity-validated escaped descendants", { skip: process.platform === "win32" }, diff --git a/test/queue-ipc-errors.test.ts b/test/queue-ipc-errors.test.ts index 858a2c6a..3f0bac96 100644 --- a/test/queue-ipc-errors.test.ts +++ b/test/queue-ipc-errors.test.ts @@ -897,7 +897,7 @@ test("startup probe fails closed for an older live owner without a socket", asyn return true; }, ); - assert.equal(getPerfMetricsSnapshot().timings["queue.connect"]?.count, 1); + assert((getPerfMetricsSnapshot().timings["queue.connect"]?.count ?? 0) > 1); await fs.access(lockPath); assert.equal(keeper.exitCode, null); assert.equal(keeper.signalCode, null); @@ -909,6 +909,64 @@ test("startup probe fails closed for an older live owner without a socket", asyn }); }); +test("startup probe retries a transient connection failure for an established owner", async () => { + await withTempHome(async (homeDir) => { + const sessionId = "startup-established-owner-transient-connect"; + const keeper = await startKeeperProcess(); + const { lockPath, socketPath } = queuePaths(homeDir, sessionId); + await writeQueueOwnerLock({ + lockPath, + pid: keeper.pid, + sessionId, + socketPath, + createdAt: "2000-01-01T00:00:00.000Z", + heartbeatAt: new Date().toISOString(), + }); + + const server = createSingleRequestServer((socket, request) => { + assert.equal(request.type, "submit_prompt"); + socket.write( + `${JSON.stringify({ + type: "accepted", + requestId: request.requestId, + })}\n`, + ); + socket.end(); + }); + const delayedListen = new Promise((resolve, reject) => { + setTimeout(() => { + void listenServer(server, socketPath).then(resolve, reject); + }, 75); + }); + + resetPerfMetrics(); + try { + const [outcome] = await Promise.all([ + trySubmitToRunningOwner({ + sessionId, + message: "hello", + permissionMode: "approve-reads", + outputFormatter: NOOP_OUTPUT_FORMATTER, + waitForCompletion: false, + startupProbe: true, + }), + delayedListen, + ]); + assert(outcome); + assert.equal("queued" in outcome, true); + assert((getPerfMetricsSnapshot().timings["queue.connect"]?.count ?? 0) > 1); + } finally { + resetPerfMetrics(); + await delayedListen.catch(() => { + // The submit failure remains the primary assertion signal. + }); + await closeServer(server); + await cleanupOwnerArtifacts({ socketPath, lockPath }); + stopProcess(keeper); + } + }); +}); + test("trySubmitToRunningOwner rejects MCP config changes for a live owner", async () => { await withTempHome(async (homeDir) => { const sessionId = "submit-mcp-config-owner-mismatch"; diff --git a/test/queue-lease-store.test.ts b/test/queue-lease-store.test.ts index a4361c14..e975b408 100644 --- a/test/queue-lease-store.test.ts +++ b/test/queue-lease-store.test.ts @@ -9,6 +9,7 @@ import { claimQueueOwnerLock, ensureOwnerIsUsable, isProcessAlive, + type QueueOwnerLease, readQueueOwnerRecord, readQueueOwnerStatus, refreshQueueOwnerLease, @@ -310,7 +311,7 @@ test("lock claims serialize cross-process refresh and release mutations", async await refreshQueueOwnerLease(lease, { queueDepth: 7 }); assert.equal(await claim.isHeld(), true); - await releaseQueueOwnerLease({ ...lease }); + await assert.rejects(releaseQueueOwnerLease(lease), /lease release is busy.*retry cleanup/); assert.equal(await claim.isHeld(), true); const blockedRecord = await readQueueOwnerRecord(sessionId); assert(blockedRecord); @@ -368,6 +369,46 @@ test("lock claims recover after a claimant crashes", async () => { }); }); +test("owner shutdown defers lease removal to a live external cleanup claimant", async () => { + await withTempHome(async () => { + const sessionId = "external-cleaner-owner-shutdown"; + const modulePath = fileURLToPath(new URL("../src/cli/queue/lease-store.js", import.meta.url)); + const script = ` + const { releaseQueueOwnerLease, tryAcquireQueueOwnerLease } = await import(${JSON.stringify(modulePath)}); + const lease = await tryAcquireQueueOwnerLease(${JSON.stringify(sessionId)}); + if (!lease) process.exit(2); + process.stdout.write(JSON.stringify(lease) + "\\n"); + process.on("SIGTERM", () => { + void releaseQueueOwnerLease(lease).then( + () => process.exit(0), + () => process.exit(1), + ); + }); + setInterval(() => {}, 60_000); + `; + const owner = spawn( + process.execPath, + ["--import", "tsx", "--input-type=module", "-e", script], + { stdio: ["ignore", "pipe", "inherit"] }, + ); + + try { + const lease = JSON.parse((await waitForChildOutput(owner)).toString()) as QueueOwnerLease; + const claim = await claimQueueOwnerLock(lease.lockPath, lease.ownerGeneration); + assert(claim); + owner.kill("SIGTERM"); + const [code] = (await once(owner, "exit")) as [number | null, NodeJS.Signals | null]; + assert.equal(code, 0); + await claim.release(); + await fs.rm(lease.lockPath, { force: true }); + } finally { + if (owner.exitCode == null && owner.signalCode == null) { + owner.kill("SIGKILL"); + } + } + }); +}); + test("explicit cleanup waits for an active lease claim", async () => { await withTempHome(async () => { const sessionId = "cleanup-claim-contention"; @@ -388,6 +429,46 @@ test("explicit cleanup waits for an active lease claim", async () => { }); }); +test("lease release waits for an active same-generation claim", async () => { + await withTempHome(async () => { + const sessionId = "release-claim-contention"; + const lease = await tryAcquireQueueOwnerLease(sessionId); + assert(lease); + const claim = await claimQueueOwnerLock(lease.lockPath, lease.ownerGeneration); + assert(claim); + + const release = releaseQueueOwnerLease(lease); + await new Promise((resolve) => { + setTimeout(resolve, 50); + }); + assert(await readQueueOwnerRecord(sessionId)); + await claim.release(); + await release; + + assert.equal(await readQueueOwnerRecord(sessionId), undefined); + }); +}); + +test("explicit cleanup succeeds when a contended lease is concurrently removed", async () => { + await withTempHome(async () => { + const sessionId = "cleanup-concurrent-removal"; + const lease = await tryAcquireQueueOwnerLease(sessionId); + assert(lease); + const claim = await claimQueueOwnerLock(lease.lockPath, lease.ownerGeneration); + assert(claim); + + const cleanup = terminateQueueOwnerForSession(sessionId); + await new Promise((resolve) => { + setTimeout(resolve, 50); + }); + await fs.rm(lease.lockPath); + await claim.release(); + + await cleanup; + assert.equal(await readQueueOwnerRecord(sessionId), undefined); + }); +}); + test("explicit cleanup fails visibly after prolonged lease claim contention", async () => { await withTempHome(async () => { const sessionId = "cleanup-prolonged-claim-contention"; @@ -538,6 +619,61 @@ test("restoring a displaced lock claim never overwrites a concurrent claim", asy }); }); +test("a claim released while temporarily displaced is not restored as an orphan", async () => { + await withTempHome(async () => { + const sessionId = "displaced-claim-release-race"; + const lease = await tryAcquireQueueOwnerLease(sessionId); + assert(lease); + const lockStat = await fs.lstat(lease.lockPath); + const claimPath = `${lease.lockPath}.claim-${lockStat.dev}-${lockStat.ino}`; + await fs.writeFile( + claimPath, + `${JSON.stringify({ claimId: "stale-claim", pid: 999_999_999 })}\n`, + "utf8", + ); + + const originalLink = fs.link; + const originalRename = fs.rename; + let concurrentClaim: Awaited>; + let raceInjected = false; + fs.rename = async (oldPath, newPath): Promise => { + if ( + !raceInjected && + oldPath === claimPath && + String(newPath).startsWith(`${claimPath}.reap-`) + ) { + raceInjected = true; + await fs.unlink(claimPath); + concurrentClaim = await claimQueueOwnerLock(lease.lockPath, lease.ownerGeneration); + assert(concurrentClaim); + } + await originalRename(oldPath, newPath); + }; + fs.link = async (existingPath, newPath): Promise => { + if ( + raceInjected && + concurrentClaim && + String(existingPath).startsWith(`${claimPath}.reap-`) && + newPath === claimPath + ) { + await concurrentClaim.release(); + } + await originalLink(existingPath, newPath); + }; + + try { + assert.equal(await claimQueueOwnerLock(lease.lockPath, lease.ownerGeneration), undefined); + assert.equal(raceInjected, true); + await assert.rejects(fs.access(claimPath)); + } finally { + fs.rename = originalRename; + fs.link = originalLink; + await fs.rm(claimPath, { force: true }); + await releaseQueueOwnerLease(lease); + } + }); +}); + test("released owners cannot overwrite or remove a successor lease", async () => { await withTempHome(async () => { const sessionId = "released-owner-refresh"; From ecdec7cad46c4f5ec7db0f4a155b8e096d6b21cb Mon Sep 17 00:00:00 2001 From: trumpyla Date: Wed, 29 Jul 2026 20:43:17 -0400 Subject: [PATCH 32/57] fix: close remaining lifecycle and persistence races --- src/acp/client.ts | 90 ++++++++- src/cli/queue/lease-store.ts | 104 +++++++++-- src/session/persistence/index.ts | 81 +++++---- src/session/persistence/repository.ts | 34 ++-- src/session/persistence/write-lock.ts | 251 ++++++++++++++++++++++++++ src/spawn-command-options.ts | 2 +- test/client.test.ts | 46 +++++ test/queue-lease-store.test.ts | 83 ++++++++- test/queue-test-helpers.ts | 2 + test/session-persistence.test.ts | 163 ++++++++++++++++- test/spawn-options.test.ts | 6 +- test/terminal.test.ts | 40 ++++ 12 files changed, 820 insertions(+), 82 deletions(-) create mode 100644 src/session/persistence/write-lock.ts diff --git a/src/acp/client.ts b/src/acp/client.ts index d6f8c334..281a4287 100644 --- a/src/acp/client.ts +++ b/src/acp/client.ts @@ -419,7 +419,14 @@ export class AcpClient { private options: AcpClientOptions; private connection?: ClientSideConnection; private agent?: ChildProcessByStdio; + private startingAgent?: ChildProcessByStdio; private agentProcessTree?: ManagedProcessTree; + private readonly agentTerminationPromises = new WeakMap< + ChildProcessByStdio, + Promise + >(); + private closePromise?: Promise; + private lifecycleGeneration = 0; private initResult?: InitializeResponse; private loadedSessionId?: string; private eventHandlers: Pick< @@ -619,18 +626,18 @@ export class AcpClient { } async start(): Promise { - if (this.connection && this.agent && isChildProcessRunning(this.agent)) { + if (!(await this.prepareForStart())) { return; } - if (this.connection || this.agent) { - await this.close(); - } + const startupGeneration = ++this.lifecycleGeneration; + this.closing = false; const launch = await this.resolveAgentLaunchPlan(); this.logAgentLaunch(launch); await this.ensureLaunchSupport(launch); + this.assertStartupIsCurrent(startupGeneration); const child = await this.spawnAgentProcess(launch); - this.closing = false; + this.assertStartupIsCurrent(startupGeneration); this.agentStartedAt = isoNow(); this.lastAgentExit = undefined; this.lastKnownPid = child.pid ?? undefined; @@ -667,9 +674,29 @@ export class AcpClient { startupFailure, startupStderr, launch, + startupGeneration, }); } + private async prepareForStart(): Promise { + if (this.closePromise) { + await this.closePromise; + } + if (this.connection && this.agent && isChildProcessRunning(this.agent)) { + return false; + } + if (this.connection || this.agent || this.startingAgent) { + await this.close(); + } + return true; + } + + private assertStartupIsCurrent(startupGeneration: number): void { + if (this.closing || startupGeneration !== this.lifecycleGeneration) { + throw new Error("ACP client closed during startup"); + } + } + private async resolveAgentLaunchPlan(): Promise { const configuredCommand = resolveAgentCommandParts( this.options.agentCommand, @@ -743,6 +770,7 @@ export class AcpClient { ...plan.spawnOptions, windowsVerbatimArguments: spawnCommand.windowsVerbatimArguments, }) as ChildProcessByStdio; + this.startingAgent = spawnedChild; const processTree = createManagedProcessTree( spawnedChild.pid, true, @@ -757,6 +785,9 @@ export class AcpClient { try { await waitForSpawn(spawnedChild); } catch (error) { + if (this.startingAgent === spawnedChild) { + this.startingAgent = undefined; + } throw new AgentSpawnError(this.options.agentCommand, error); } return requireAgentStdio(spawnedChild); @@ -826,6 +857,7 @@ export class AcpClient { startupFailure: StartupFailureWatcher; startupStderr: string[]; launch: AgentLaunchPlan; + startupGeneration: number; }): Promise { try { const initResult = await Promise.race([ @@ -833,8 +865,12 @@ export class AcpClient { params.startupFailure.promise, ]); params.startupFailure.dispose(); + this.assertStartupIsCurrent(params.startupGeneration); this.connection = params.connection; this.agent = params.child; + if (this.startingAgent === params.child) { + this.startingAgent = undefined; + } this.initResult = initResult; this.log(`initialized protocol version ${initResult.protocolVersion}`); } catch (error) { @@ -878,10 +914,13 @@ export class AcpClient { params.startupStderr, ); try { - await this.terminateAgentProcess(params.child); + await this.terminateAgentProcessOnce(params.child); } catch { // best effort } + if (this.startingAgent === params.child) { + this.startingAgent = undefined; + } if (params.launch.geminiAcp && error instanceof TimeoutError) { throw new GeminiAcpStartupTimeoutError( await buildGeminiAcpStartupTimeoutMessage(params.launch.spawnCommand), @@ -1369,13 +1408,33 @@ export class AcpClient { } async close(): Promise { + if (this.closePromise) { + return await this.closePromise; + } + const closePromise = this.closeInternal(); + this.closePromise = closePromise; + try { + await closePromise; + } finally { + if (this.closePromise === closePromise) { + this.closePromise = undefined; + } + } + } + + private async closeInternal(): Promise { this.closing = true; + this.lifecycleGeneration += 1; await this.terminalManager.shutdown(); - const agent = this.agent; - if (agent) { - await this.terminateAgentProcess(agent); + const agents = new Set( + [this.startingAgent, this.agent].filter( + (agent): agent is ChildProcessByStdio => agent !== undefined, + ), + ); + for (const agent of agents) { + await this.terminateAgentProcessOnce(agent); } if (this.pendingConnectionRequests.size > 0) { this.rejectPendingConnectionRequests( @@ -1412,9 +1471,22 @@ export class AcpClient { this.initResult = undefined; this.connection = undefined; this.agent = undefined; + this.startingAgent = undefined; this.agentProcessTree = undefined; } + private terminateAgentProcessOnce( + child: ChildProcessByStdio, + ): Promise { + const existing = this.agentTerminationPromises.get(child); + if (existing) { + return existing; + } + const termination = this.terminateAgentProcess(child); + this.agentTerminationPromises.set(child, termination); + return termination; + } + private async terminateAgentProcess( child: ChildProcessByStdio, ): Promise { diff --git a/src/cli/queue/lease-store.ts b/src/cli/queue/lease-store.ts index 959c3072..9cbf371c 100644 --- a/src/cli/queue/lease-store.ts +++ b/src/cli/queue/lease-store.ts @@ -25,6 +25,7 @@ const QUEUE_OWNER_MALFORMED_LOCK_STALE_MS = QUEUE_OWNER_STALE_HEARTBEAT_MS; export type QueueOwnerRecord = { pid: number; + processIdentity?: string; sessionId: string; socketPath: string; createdAt: string; @@ -41,6 +42,7 @@ export type QueueOwnerLease = { socketPath: string; createdAt: string; ownerGeneration: number; + processIdentity?: string; mcpConfigPath?: string; mcpConfigFingerprint?: string; }; @@ -75,6 +77,9 @@ function parseQueueOwnerRecord(raw: unknown): QueueOwnerRecord | null { return { pid: record.pid, + ...(typeof record.processIdentity === "string" + ? { processIdentity: record.processIdentity } + : {}), sessionId: record.sessionId, socketPath: record.socketPath, createdAt: record.createdAt, @@ -102,6 +107,7 @@ function hasValidQueueOwnerRecordFields(record: Record): record } { return ( isPositiveInteger(record.pid) && + isOptionalNonEmptyString(record.processIdentity) && typeof record.sessionId === "string" && typeof record.socketPath === "string" && typeof record.createdAt === "string" && @@ -119,6 +125,10 @@ function isNonNegativeInteger(value: unknown): value is number { return Number.isInteger(value) && (value as number) >= 0; } +function isOptionalNonEmptyString(value: unknown): value is string | undefined { + return value === undefined || isNonEmptyString(value); +} + function createOwnerGeneration(): number { return randomInt(1, 2 ** 48); } @@ -217,8 +227,8 @@ async function cleanupStaleQueueOwner( } async function terminateClaimedQueueOwner(owner: QueueOwnerRecord | undefined): Promise { - if (owner && isProcessAlive(owner.pid)) { - await terminateProcess(owner.pid); + if (owner?.processIdentity && owner.pid !== process.pid) { + await terminateProcess(owner.pid, owner.processIdentity); } } @@ -234,9 +244,8 @@ async function resolveQueueOwnerForCleanup( if (currentOwner?.ownerGeneration !== owner.ownerGeneration) { return false; } - return isProcessAlive(currentOwner.pid) && !isQueueOwnerHeartbeatStale(currentOwner) - ? false - : currentOwner; + const processState = await queueOwnerProcessState(currentOwner); + return ownerShouldBePreserved(processState, currentOwner) ? false : currentOwner; } async function claimQueueOwnerLockForCleanup( @@ -406,6 +415,11 @@ async function currentQueueOwnerLockClaimRecord(): Promise { + currentProcessIdentityPromise ??= readProcessIdentity(process.pid); + return await currentProcessIdentityPromise; +} + async function createOrRecoverQueueOwnerLockClaim( claimPath: string, claimRecord: QueueOwnerLockClaimRecord, @@ -505,6 +519,43 @@ function claimantProcessIsAlive(pid: number): boolean { return pid === process.pid || isProcessAlive(pid); } +type QueueOwnerProcessState = "matching" | "legacy" | "dead" | "mismatch" | "unverified"; + +async function queueOwnerProcessState(owner: QueueOwnerRecord): Promise { + if (!claimantProcessIsAlive(owner.pid)) { + return "dead"; + } + if (!owner.processIdentity) { + return "legacy"; + } + const currentIdentity = await readProcessIdentity(owner.pid); + if (!currentIdentity) { + return "unverified"; + } + return currentIdentity === owner.processIdentity ? "matching" : "mismatch"; +} + +function ownerMayStillBeUsable( + processState: QueueOwnerProcessState, + owner: QueueOwnerRecord, +): boolean { + // A fresh legacy lease may belong to an older acpx owner. Preserve it long + // enough for the authenticated socket handshake, but never signal its PID. + return ( + (processState === "matching" || processState === "legacy") && !isQueueOwnerHeartbeatStale(owner) + ); +} + +function ownerShouldBePreserved( + processState: QueueOwnerProcessState, + owner: QueueOwnerRecord, +): boolean { + return ( + ownerMayStillBeUsable(processState, owner) || + (processState === "unverified" && !isQueueOwnerHeartbeatStale(owner)) + ); +} + async function readQueueOwnerLockClaimRecord( claimPath: string, ): Promise { @@ -608,10 +659,16 @@ async function readQueueOwnerRecordAtPath(lockPath: string): Promise { +export async function terminateProcess( + pid: number, + expectedProcessIdentity?: string, +): Promise { if (!isProcessAlive(pid)) { return false; } + if (!(await processMayBeSignaled(pid, expectedProcessIdentity))) { + return false; + } try { process.kill(pid, "SIGTERM"); @@ -622,6 +679,9 @@ export async function terminateProcess(pid: number): Promise { if (await waitForProcessExit(pid, PROCESS_SIGTERM_GRACE_MS)) { return true; } + if (!(await processMayBeSignaled(pid, expectedProcessIdentity))) { + return true; + } try { process.kill(pid, "SIGKILL"); @@ -633,15 +693,31 @@ export async function terminateProcess(pid: number): Promise { return true; } +async function processMayBeSignaled( + pid: number, + expectedProcessIdentity: string | undefined, +): Promise { + return ( + expectedProcessIdentity === undefined || + (await processIdentityMatches(pid, expectedProcessIdentity)) + ); +} + +async function processIdentityMatches(pid: number, expectedIdentity: string): Promise { + return (await readProcessIdentity(pid)) === expectedIdentity; +} + export async function ensureOwnerIsUsable( sessionId: string, owner: QueueOwnerRecord, ): Promise { - const alive = isProcessAlive(owner.pid); - const stale = isQueueOwnerHeartbeatStale(owner); - if (alive && !stale) { + const processState = await queueOwnerProcessState(owner); + if (ownerMayStillBeUsable(processState, owner)) { return true; } + if (ownerShouldBePreserved(processState, owner)) { + return false; + } await retireStaleQueueOwner(sessionId, owner); return false; @@ -686,6 +762,7 @@ export async function tryAcquireQueueOwnerLease( const mcpConfigFingerprint = readMcpConfigFingerprint(mcpConfigOrNowIsoFactory); const mcpConfigMetadata = createMcpConfigMetadata(mcpConfigPath, mcpConfigFingerprint); await ensureQueueDir(); + const processIdentity = await readCurrentProcessIdentity(); const lockPath = queueLockFilePath(sessionId); const socketPath = queueSocketPath(sessionId); let createdAt = clock(); @@ -694,6 +771,7 @@ export async function tryAcquireQueueOwnerLease( JSON.stringify( { pid: process.pid, + ...(processIdentity ? { processIdentity } : {}), sessionId, socketPath, createdAt, @@ -737,6 +815,7 @@ export async function tryAcquireQueueOwnerLease( socketPath, createdAt, ownerGeneration, + ...(processIdentity ? { processIdentity } : {}), ...mcpConfigMetadata, }; queueOwnerLeaseStates.set(lease, { @@ -786,10 +865,10 @@ async function handleLeaseCollision(sessionId: string, error: unknown): Promise< return false; } - if (!isProcessAlive(owner.pid) || isQueueOwnerHeartbeatStale(owner)) { - return await retireStaleQueueOwner(sessionId, owner); + if (ownerShouldBePreserved(await queueOwnerProcessState(owner), owner)) { + return false; } - return false; + return await retireStaleQueueOwner(sessionId, owner); } function resolveLeaseArguments( @@ -837,6 +916,7 @@ export async function refreshQueueOwnerLease( const payload = JSON.stringify( { pid: process.pid, + ...(lease.processIdentity ? { processIdentity: lease.processIdentity } : {}), sessionId: lease.sessionId, socketPath: lease.socketPath, createdAt: lease.createdAt, diff --git a/src/session/persistence/index.ts b/src/session/persistence/index.ts index eca8decf..7b9aa74b 100644 --- a/src/session/persistence/index.ts +++ b/src/session/persistence/index.ts @@ -3,6 +3,7 @@ import path from "node:path"; import type { SessionRecord } from "../../types.js"; import { createAtomicWriteTempPath } from "./atomic-write.js"; import { parseSessionRecord } from "./parse.js"; +import { withSessionWriteLock } from "./write-lock.js"; const SESSION_INDEX_SCHEMA = "acpx.session-index.v1"; @@ -125,51 +126,55 @@ export async function writeSessionIndex( entries: SessionIndexEntry[]; }, ): Promise { - const filePath = sessionIndexPath(sessionDir); - const tempFile = createAtomicWriteTempPath(filePath); - const payload = JSON.stringify( - { - schema: SESSION_INDEX_SCHEMA, - files: [...index.files].toSorted(), - entries: [...index.entries].toSorted((a, b) => b.lastUsedAt.localeCompare(a.lastUsedAt)), - }, - null, - 2, - ); - await fs.writeFile(tempFile, `${payload}\n`, "utf8"); - await fs.rename(tempFile, filePath); + await withSessionWriteLock(sessionDir, async () => { + const filePath = sessionIndexPath(sessionDir); + const tempFile = createAtomicWriteTempPath(filePath); + const payload = JSON.stringify( + { + schema: SESSION_INDEX_SCHEMA, + files: [...index.files].toSorted(), + entries: [...index.entries].toSorted((a, b) => b.lastUsedAt.localeCompare(a.lastUsedAt)), + }, + null, + 2, + ); + await fs.writeFile(tempFile, `${payload}\n`, "utf8"); + await fs.rename(tempFile, filePath); + }); } export async function rebuildSessionIndex(sessionDir: string): Promise { - const entries = await fs.readdir(sessionDir, { withFileTypes: true }); - const files = entries - .filter( - (entry) => entry.isFile() && entry.name.endsWith(".json") && entry.name !== "index.json", - ) - .map((entry) => entry.name) - .toSorted(); + return await withSessionWriteLock(sessionDir, async () => { + const entries = await fs.readdir(sessionDir, { withFileTypes: true }); + const files = entries + .filter( + (entry) => entry.isFile() && entry.name.endsWith(".json") && entry.name !== "index.json", + ) + .map((entry) => entry.name) + .toSorted(); - const indexEntries: SessionIndexEntry[] = []; - for (const file of files) { - try { - const payload = await fs.readFile(path.join(sessionDir, file), "utf8"); - const parsed = parseSessionRecord(JSON.parse(payload)); - if (!parsed) { - continue; + const indexEntries: SessionIndexEntry[] = []; + for (const file of files) { + try { + const payload = await fs.readFile(path.join(sessionDir, file), "utf8"); + const parsed = parseSessionRecord(JSON.parse(payload)); + if (!parsed) { + continue; + } + indexEntries.push(toSessionIndexEntry(parsed, file)); + } catch { + // ignore corrupt session files while rebuilding the cache index } - indexEntries.push(toSessionIndexEntry(parsed, file)); - } catch { - // ignore corrupt session files while rebuilding the cache index } - } - const index: SessionIndex = { - schema: SESSION_INDEX_SCHEMA, - files, - entries: indexEntries, - }; - await writeSessionIndex(sessionDir, index); - return index; + const index: SessionIndex = { + schema: SESSION_INDEX_SCHEMA, + files, + entries: indexEntries, + }; + await writeSessionIndex(sessionDir, index); + return index; + }); } export async function loadOrRebuildSessionIndex(sessionDir: string): Promise { diff --git a/src/session/persistence/repository.ts b/src/session/persistence/repository.ts index 86a490e7..363b1a3d 100644 --- a/src/session/persistence/repository.ts +++ b/src/session/persistence/repository.ts @@ -16,6 +16,7 @@ import { } from "./index.js"; import { parseSessionRecord } from "./parse.js"; import { serializeSessionRecordForDisk } from "./serialize.js"; +import { withSessionWriteLock } from "./write-lock.js"; export const DEFAULT_HISTORY_LIMIT = 20; @@ -86,23 +87,24 @@ function matchesSessionEntry( export async function writeSessionRecord(record: SessionRecord): Promise { await measurePerf("session.write_record", async () => { await ensureSessionDir(); - - const persisted = serializeSessionRecordForDisk(record); - assertPersistedKeyPolicy(persisted); - - const file = sessionFilePath(record.acpxRecordId); - const tempFile = createAtomicWriteTempPath(file); - const payload = JSON.stringify(persisted, null, 2); - await fs.writeFile(tempFile, `${payload}\n`, "utf8"); - await fs.rename(tempFile, file); - const sessionDir = sessionBaseDir(); - const index = await loadOrRebuildSessionIndex(sessionDir); - const fileName = path.basename(file); - const entries = index.entries.filter((entry) => entry.file !== fileName); - entries.push(toSessionIndexEntry(record, fileName)); - const files = [...new Set([...index.files.filter((entry) => entry !== fileName), fileName])]; - await writeSessionIndex(sessionDir, { files, entries }); + await withSessionWriteLock(sessionDir, async () => { + const persisted = serializeSessionRecordForDisk(record); + assertPersistedKeyPolicy(persisted); + + const file = sessionFilePath(record.acpxRecordId); + const tempFile = createAtomicWriteTempPath(file); + const payload = JSON.stringify(persisted, null, 2); + await fs.writeFile(tempFile, `${payload}\n`, "utf8"); + await fs.rename(tempFile, file); + + const index = await loadOrRebuildSessionIndex(sessionDir); + const fileName = path.basename(file); + const entries = index.entries.filter((entry) => entry.file !== fileName); + entries.push(toSessionIndexEntry(record, fileName)); + const files = [...new Set([...index.files.filter((entry) => entry !== fileName), fileName])]; + await writeSessionIndex(sessionDir, { files, entries }); + }); }); } diff --git a/src/session/persistence/write-lock.ts b/src/session/persistence/write-lock.ts new file mode 100644 index 00000000..29a4fcc5 --- /dev/null +++ b/src/session/persistence/write-lock.ts @@ -0,0 +1,251 @@ +import { AsyncLocalStorage } from "node:async_hooks"; +import { randomUUID } from "node:crypto"; +import type { Stats } from "node:fs"; +import fs from "node:fs/promises"; +import path from "node:path"; +import { readProcessIdentity } from "../../acp/process-tree.js"; +import { isProcessAlive } from "../../process-liveness.js"; + +const SESSION_WRITE_LOCK_FILE = ".write.lock"; +const SESSION_WRITE_LOCK_WAIT_MS = 15_000; +const SESSION_WRITE_LOCK_POLL_MS = 10; +const SESSION_WRITE_LOCK_UNVERIFIED_STALE_MS = 120_000; + +type SessionWriteLockRecord = { + lockId: string; + pid: number; + processIdentity?: string; + createdAt: string; +}; + +type SessionWriteLockLease = { + lockPath: string; + lockId: string; +}; + +const heldSessionWriteLocks = new AsyncLocalStorage>(); +let currentProcessIdentityPromise: Promise | undefined; + +export async function withSessionWriteLock( + sessionDir: string, + operation: () => Promise, +): Promise { + const lockPath = path.join(path.resolve(sessionDir), SESSION_WRITE_LOCK_FILE); + const heldLocks = heldSessionWriteLocks.getStore(); + if (heldLocks?.has(lockPath)) { + return await operation(); + } + + const lease = await acquireSessionWriteLock(lockPath); + const nestedLocks = new Set(heldLocks); + nestedLocks.add(lockPath); + try { + return await heldSessionWriteLocks.run(nestedLocks, operation); + } finally { + await releaseSessionWriteLock(lease); + } +} + +async function acquireSessionWriteLock(lockPath: string): Promise { + await fs.mkdir(path.dirname(lockPath), { recursive: true }); + const lockRecord = await createSessionWriteLockRecord(); + const deadline = Date.now() + SESSION_WRITE_LOCK_WAIT_MS; + + for (;;) { + if (await tryCreateSessionWriteLock(lockPath, lockRecord)) { + return { lockPath, lockId: lockRecord.lockId }; + } + if (await recoverStaleSessionWriteLock(lockPath)) { + continue; + } + if (Date.now() >= deadline) { + throw new Error(`Timed out acquiring session persistence lock: ${lockPath}`); + } + await waitMs(SESSION_WRITE_LOCK_POLL_MS); + } +} + +async function createSessionWriteLockRecord(): Promise { + currentProcessIdentityPromise ??= readProcessIdentity(process.pid); + const processIdentity = await currentProcessIdentityPromise; + return { + lockId: randomUUID(), + pid: process.pid, + ...(processIdentity ? { processIdentity } : {}), + createdAt: new Date().toISOString(), + }; +} + +async function tryCreateSessionWriteLock( + lockPath: string, + lockRecord: SessionWriteLockRecord, +): Promise { + try { + await fs.writeFile(lockPath, `${JSON.stringify(lockRecord)}\n`, { + encoding: "utf8", + flag: "wx", + mode: 0o600, + }); + return true; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "EEXIST") { + return false; + } + throw error; + } +} + +async function recoverStaleSessionWriteLock(lockPath: string): Promise { + const observedStat = await lstatIfPresent(lockPath); + if (!observedStat || !(await sessionWriteLockIsStale(lockPath, observedStat))) { + return false; + } + + const quarantinePath = `${lockPath}.reap-${process.pid}-${randomUUID()}`; + try { + await fs.rename(lockPath, quarantinePath); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") { + return true; + } + throw error; + } + + const movedStat = await lstatIfPresent(quarantinePath); + if (!movedStat || !sameFileIdentity(observedStat, movedStat)) { + await restoreDisplacedSessionWriteLock(quarantinePath, lockPath); + return false; + } + if (!(await sessionWriteLockIsStale(quarantinePath, movedStat))) { + await restoreDisplacedSessionWriteLock(quarantinePath, lockPath); + return false; + } + await unlinkIfPresent(quarantinePath); + return true; +} + +async function sessionWriteLockIsStale(lockPath: string, stat: Stats): Promise { + const record = await readSessionWriteLockRecord(lockPath); + if (!record) { + return lockAgeMs(stat) > SESSION_WRITE_LOCK_UNVERIFIED_STALE_MS; + } + if (!sessionWriterIsAlive(record.pid)) { + return true; + } + if (!record.processIdentity) { + return lockAgeMs(stat) > SESSION_WRITE_LOCK_UNVERIFIED_STALE_MS; + } + const currentIdentity = await readProcessIdentity(record.pid); + return currentIdentity + ? currentIdentity !== record.processIdentity + : lockAgeMs(stat) > SESSION_WRITE_LOCK_UNVERIFIED_STALE_MS; +} + +function sessionWriterIsAlive(pid: number): boolean { + return pid === process.pid || isProcessAlive(pid); +} + +function lockAgeMs(stat: Stats): number { + return Date.now() - stat.mtimeMs; +} + +async function restoreDisplacedSessionWriteLock( + quarantinePath: string, + lockPath: string, +): Promise { + try { + await fs.link(quarantinePath, lockPath); + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code === "ENOENT") { + return; + } + if (code !== "EEXIST") { + throw error; + } + } + await unlinkIfPresent(quarantinePath); +} + +async function releaseSessionWriteLock(lease: SessionWriteLockLease): Promise { + const current = await readSessionWriteLockRecord(lease.lockPath); + if (current?.lockId === lease.lockId) { + await unlinkIfPresent(lease.lockPath); + } +} + +async function readSessionWriteLockRecord( + lockPath: string, +): Promise { + try { + return parseSessionWriteLockRecord(JSON.parse(await fs.readFile(lockPath, "utf8"))); + } catch { + return undefined; + } +} + +function parseSessionWriteLockRecord(value: unknown): SessionWriteLockRecord | undefined { + if (!isSessionWriteLockRecord(value)) { + return undefined; + } + return { + lockId: value.lockId, + pid: value.pid, + ...(value.processIdentity ? { processIdentity: value.processIdentity } : {}), + createdAt: value.createdAt, + }; +} + +function isSessionWriteLockRecord(value: unknown): value is SessionWriteLockRecord { + if (!value || typeof value !== "object" || Array.isArray(value)) { + return false; + } + const record = value as Record; + return ( + isNonEmptyString(record.lockId) && + isPositiveInteger(record.pid) && + isOptionalNonEmptyString(record.processIdentity) && + typeof record.createdAt === "string" + ); +} + +function isNonEmptyString(value: unknown): value is string { + return typeof value === "string" && value.length > 0; +} + +function isPositiveInteger(value: unknown): value is number { + return Number.isInteger(value) && (value as number) > 0; +} + +function isOptionalNonEmptyString(value: unknown): value is string | undefined { + return value === undefined || isNonEmptyString(value); +} + +async function lstatIfPresent(filePath: string): Promise { + try { + return await fs.lstat(filePath); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") { + return undefined; + } + throw error; + } +} + +function sameFileIdentity(left: Stats, right: Stats): boolean { + return left.dev === right.dev && left.ino === right.ino; +} + +async function unlinkIfPresent(filePath: string): Promise { + await fs.unlink(filePath).catch((error) => { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") { + throw error; + } + }); +} + +async function waitMs(ms: number): Promise { + await new Promise((resolve) => { + setTimeout(resolve, ms); + }); +} diff --git a/src/spawn-command-options.ts b/src/spawn-command-options.ts index f3167882..8db9e1cd 100644 --- a/src/spawn-command-options.ts +++ b/src/spawn-command-options.ts @@ -214,7 +214,7 @@ export function buildTerminalSpawnCommand( command: string, args: string[] | undefined, ): TerminalSpawnCommand { - return { command, args: args ?? [], killProcessGroup: false }; + return { command, args: args ?? [], killProcessGroup: true }; } export function buildTerminalShellSpawnCommand( diff --git a/test/client.test.ts b/test/client.test.ts index 04cb94a4..1e3426f0 100644 --- a/test/client.test.ts +++ b/test/client.test.ts @@ -1348,6 +1348,40 @@ test("AcpClient startup failure kills descendants left by an exited npx wrapper" } }); +test("AcpClient close terminates an adapter while initialization is still pending", async (t) => { + if (process.platform === "win32") { + t.skip("POSIX process-group cleanup assertion"); + return; + } + + const client = makeClient({ + agentCommand: `${JSON.stringify(process.execPath)} --eval ${JSON.stringify( + 'process.on("SIGTERM", () => {}); setInterval(() => {}, 1000);', + )}`, + }); + const startResult = client.start().then( + () => ({ type: "resolved" as const }), + (error: unknown) => ({ type: "rejected" as const, error }), + ); + let pid: number | undefined; + + try { + pid = await waitForClientPid(client); + await client.close(); + + assert.equal( + await waitForPidExit(pid), + true, + "close must terminate the detached adapter before initialization finishes", + ); + assert.equal((await startResult).type, "rejected"); + } finally { + if (pid) { + await terminateTestPid(pid); + } + } +}); + test("AcpClient close resets in-memory state and shuts down terminal manager", async () => { const client = makeClient(); const internals = asInternals(client); @@ -1454,6 +1488,18 @@ async function waitForPidExit(pid: number, timeoutMs = 2_000): Promise return !isPidAlive(pid); } +async function waitForClientPid(client: AcpClient, timeoutMs = 2_000): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + const pid = client.getAgentPid(); + if (pid) { + return pid; + } + await new Promise((resolve) => setTimeout(resolve, 25)); + } + throw new Error("Timed out waiting for adapter PID"); +} + function asInternals(client: AcpClient): ClientInternals { return client as unknown as ClientInternals; } diff --git a/test/queue-lease-store.test.ts b/test/queue-lease-store.test.ts index e975b408..add60bd3 100644 --- a/test/queue-lease-store.test.ts +++ b/test/queue-lease-store.test.ts @@ -5,6 +5,7 @@ import fs from "node:fs/promises"; import path from "node:path"; import test from "node:test"; import { fileURLToPath } from "node:url"; +import { readProcessIdentity } from "../src/acp/process-tree.js"; import { claimQueueOwnerLock, ensureOwnerIsUsable, @@ -47,6 +48,8 @@ test("tryAcquireQueueOwnerLease creates a lease that can be refreshed and releas const lease = await tryAcquireQueueOwnerLease("lease-create"); assert(lease); assert.equal(lease.sessionId, "lease-create"); + const expectedProcessIdentity = await readProcessIdentity(process.pid); + assert.equal(lease.processIdentity, expectedProcessIdentity); await refreshQueueOwnerLease( lease, @@ -58,6 +61,7 @@ test("tryAcquireQueueOwnerLease creates a lease that can be refreshed and releas const record = await readQueueOwnerRecord("lease-create"); assert(record); + assert.equal(record.processIdentity, expectedProcessIdentity); assert.equal(record.queueDepth, 2); assert.equal(record.heartbeatAt, "2026-03-26T00:00:00.000Z"); @@ -704,9 +708,11 @@ test("readQueueOwnerStatus returns live owner details for a healthy owner", asyn const { lockPath, socketPath } = queuePaths(homeDir, sessionId); try { + const processIdentity = await readProcessIdentity(keeper.pid!); await writeQueueOwnerLock({ lockPath, pid: keeper.pid, + processIdentity, sessionId, socketPath, queueDepth: 3, @@ -728,16 +734,22 @@ test("readQueueOwnerStatus returns live owner details for a healthy owner", asyn }); }); -test("ensureOwnerIsUsable cleans up stale live owners", async () => { +test("ensureOwnerIsUsable cleans up stale live owners", async (t) => { await withTempHome(async (homeDir) => { const sessionId = "stale-live-owner"; const keeper = await startKeeperProcess(); const { lockPath, socketPath } = queuePaths(homeDir, sessionId); try { + const processIdentity = await readProcessIdentity(keeper.pid!); + if (!processIdentity) { + t.skip("process identity unavailable in the managed environment"); + return; + } await writeQueueOwnerLock({ lockPath, pid: keeper.pid, + processIdentity, sessionId, socketPath, heartbeatAt: "2000-01-01T00:00:00.000Z", @@ -762,9 +774,11 @@ test("stale cleanup preserves an owner refreshed before its cleanup claim", asyn let claim: Awaited> | undefined; try { + const processIdentity = await readProcessIdentity(keeper.pid!); await writeQueueOwnerLock({ lockPath, pid: keeper.pid, + processIdentity, sessionId, socketPath, heartbeatAt: "2000-01-01T00:00:00.000Z", @@ -801,16 +815,22 @@ test("stale cleanup preserves an owner refreshed before its cleanup claim", asyn }); }); -test("tryAcquireQueueOwnerLease terminates stale live owners and acquires in the same attempt", async () => { +test("tryAcquireQueueOwnerLease terminates stale live owners and acquires in the same attempt", async (t) => { await withTempHome(async (homeDir) => { const sessionId = "stale-live-owner-acquire"; const keeper = await startKeeperProcess(); const { lockPath, socketPath } = queuePaths(homeDir, sessionId); try { + const processIdentity = await readProcessIdentity(keeper.pid!); + if (!processIdentity) { + t.skip("process identity unavailable in the managed environment"); + return; + } await writeQueueOwnerLock({ lockPath, pid: keeper.pid, + processIdentity, sessionId, socketPath, heartbeatAt: "2000-01-01T00:00:00.000Z", @@ -839,17 +859,76 @@ test("terminateProcess and terminateQueueOwnerForSession handle live and missing try { assert.equal(isProcessAlive(keeper.pid), true); + const processIdentity = await readProcessIdentity(keeper.pid!); + await writeQueueOwnerLock({ + lockPath, + pid: keeper.pid, + processIdentity, + sessionId, + socketPath, + }); + + await terminateQueueOwnerForSession(sessionId); + assert.equal(await readQueueOwnerRecord(sessionId), undefined); + } finally { + stopProcess(keeper); + } + }); +}); + +test("mismatched queue-owner identities are retired without signaling the reused pid", async (t) => { + await withTempHome(async (homeDir) => { + const sessionId = "reused-owner-pid"; + const keeper = await startKeeperProcess(); + const { lockPath, socketPath } = queuePaths(homeDir, sessionId); + + try { + const processIdentity = await readProcessIdentity(keeper.pid!); + if (!processIdentity) { + t.skip("process identity unavailable in the managed environment"); + return; + } + await writeQueueOwnerLock({ + lockPath, + pid: keeper.pid, + processIdentity: `${processIdentity}-reused`, + sessionId, + socketPath, + }); + + const owner = await readQueueOwnerRecord(sessionId); + assert(owner); + assert.equal(await ensureOwnerIsUsable(sessionId, owner), false); + assert.equal(await readQueueOwnerRecord(sessionId), undefined); + assert.equal(isProcessAlive(keeper.pid), true); + } finally { + stopProcess(keeper); + await fs.rm(lockPath, { force: true }); + } + }); +}); + +test("legacy queue-owner records are cleaned without signaling an unverifiable pid", async () => { + await withTempHome(async (homeDir) => { + const sessionId = "legacy-owner-cleanup"; + const keeper = await startKeeperProcess(); + const { lockPath, socketPath } = queuePaths(homeDir, sessionId); + + try { await writeQueueOwnerLock({ lockPath, pid: keeper.pid, sessionId, socketPath, + heartbeatAt: "2000-01-01T00:00:00.000Z", }); await terminateQueueOwnerForSession(sessionId); assert.equal(await readQueueOwnerRecord(sessionId), undefined); + assert.equal(isProcessAlive(keeper.pid), true); } finally { stopProcess(keeper); + await fs.rm(lockPath, { force: true }); } }); }); diff --git a/test/queue-test-helpers.ts b/test/queue-test-helpers.ts index 336e2ba4..2ac2f7a0 100644 --- a/test/queue-test-helpers.ts +++ b/test/queue-test-helpers.ts @@ -55,6 +55,7 @@ export async function writeQueueOwnerLock(options: { sessionId: string; socketPath: string; ownerGeneration?: number; + processIdentity?: string; queueDepth?: number; mcpConfigPath?: string; mcpConfigFingerprint?: string; @@ -75,6 +76,7 @@ export async function writeQueueOwnerLock(options: { heartbeatAt, ownerGeneration: options.ownerGeneration ?? Date.now() * 1_000 + Math.floor(Math.random() * 1_000), + ...(options.processIdentity ? { processIdentity: options.processIdentity } : {}), queueDepth: options.queueDepth ?? 0, ...(options.mcpConfigPath ? { mcpConfigPath: options.mcpConfigPath } : {}), ...(options.mcpConfigFingerprint diff --git a/test/session-persistence.test.ts b/test/session-persistence.test.ts index ca97030a..50734ce7 100644 --- a/test/session-persistence.test.ts +++ b/test/session-persistence.test.ts @@ -1,5 +1,5 @@ import assert from "node:assert/strict"; -import { spawn } from "node:child_process"; +import { spawn, type ChildProcess } from "node:child_process"; import { once } from "node:events"; import fs from "node:fs/promises"; import path from "node:path"; @@ -17,6 +17,7 @@ import { type SessionModule = typeof import("../src/session/session.js"); const SESSION_MODULE_URL = new URL("../src/session/session.js", import.meta.url); +const SESSION_REPOSITORY_URL = new URL("../src/session/persistence/repository.js", import.meta.url); test("SessionRecord allows optional closed and closedAt fields", () => { const record = makeSessionRecord({ @@ -563,6 +564,90 @@ test("writeSessionRecord maintains an index and listSessions rebuilds it when mi }); }); +test("writeSessionRecord serializes cross-process record and index publication", async () => { + await withTempHome(async (homeDir) => { + const sessionDir = path.join(homeDir, ".acpx", "sessions"); + const indexPath = path.join(sessionDir, "index.json"); + const firstReadyPath = path.join(homeDir, "first-index-ready"); + const releaseFirstPath = path.join(homeDir, "release-first-index"); + const firstRecord = makeSessionRecord({ + acpxRecordId: "concurrent-first", + acpSessionId: "concurrent-first", + agentCommand: "agent-a", + cwd: path.join(homeDir, "first"), + }); + const secondRecord = makeSessionRecord({ + acpxRecordId: "concurrent-second", + acpSessionId: "concurrent-second", + agentCommand: "agent-a", + cwd: path.join(homeDir, "second"), + }); + const first = spawnSessionRecordWriter({ + homeDir, + record: firstRecord, + pauseIndexPath: indexPath, + readyPath: firstReadyPath, + releasePath: releaseFirstPath, + }); + let second: ChildProcess | undefined; + + try { + await waitForFile(firstReadyPath); + second = spawnSessionRecordWriter({ homeDir, record: secondRecord }); + const secondResult = waitForSuccessfulChild(second); + + await Promise.race([secondResult, sleep(1_000)]); + await fs.writeFile(releaseFirstPath, "release\n", "utf8"); + await Promise.all([waitForSuccessfulChild(first), secondResult]); + + const index = JSON.parse(await fs.readFile(indexPath, "utf8")) as { + entries?: Array<{ acpxRecordId?: string }>; + }; + assert.deepEqual(index.entries?.map((entry) => entry.acpxRecordId).toSorted(), [ + "concurrent-first", + "concurrent-second", + ]); + } finally { + await fs.writeFile(releaseFirstPath, "release\n", "utf8").catch(() => {}); + stopChild(first); + if (second) { + stopChild(second); + } + } + }); +}); + +test("writeSessionRecord recovers a stale cross-process write lock", async () => { + await withTempHome(async (homeDir) => { + const sessionDir = path.join(homeDir, ".acpx", "sessions"); + const lockPath = path.join(sessionDir, ".write.lock"); + await fs.mkdir(sessionDir, { recursive: true }); + await fs.writeFile( + lockPath, + `${JSON.stringify({ + lockId: "stale-writer", + pid: 999_999, + createdAt: "2000-01-01T00:00:00.000Z", + })}\n`, + "utf8", + ); + const repository = await import( + `${SESSION_REPOSITORY_URL.href}?write_lock_test=${Date.now()}-${Math.random()}` + ); + + await repository.writeSessionRecord( + makeSessionRecord({ + acpxRecordId: "stale-lock-recovery", + acpSessionId: "stale-lock-recovery", + agentCommand: "agent-a", + cwd: path.join(homeDir, "stale-lock"), + }), + ); + + assert.equal(await fileExists(lockPath), false); + }); +}); + test("closeSession soft-closes and terminates matching process", async () => { await withTempHome(async (homeDir) => { const session = await loadSessionModule(); @@ -643,6 +728,82 @@ function makeSessionRecord( return makeSessionRecordFixture(overrides, { defaultName: false, defaultAcpx: false }); } +function spawnSessionRecordWriter(options: { + homeDir: string; + record: ReturnType; + pauseIndexPath?: string; + readyPath?: string; + releasePath?: string; +}): ChildProcess { + const script = ` + import fs from "node:fs/promises"; + const pauseIndexPath = ${JSON.stringify(options.pauseIndexPath)}; + const readyPath = ${JSON.stringify(options.readyPath)}; + const releasePath = ${JSON.stringify(options.releasePath)}; + if (pauseIndexPath && readyPath && releasePath) { + const originalRename = fs.rename.bind(fs); + let paused = false; + fs.rename = async (source, destination) => { + if (!paused && String(destination) === pauseIndexPath) { + paused = true; + await fs.writeFile(readyPath, "ready\\n", "utf8"); + for (;;) { + try { + await fs.access(releasePath); + break; + } catch { + await new Promise((resolve) => setTimeout(resolve, 10)); + } + } + } + return await originalRename(source, destination); + }; + } + const repository = await import(${JSON.stringify(SESSION_REPOSITORY_URL.href)}); + await repository.writeSessionRecord(${JSON.stringify(options.record)}); + `; + return spawn(process.execPath, ["--input-type=module", "-e", script], { + env: { ...process.env, HOME: options.homeDir }, + stdio: ["ignore", "ignore", "pipe"], + }); +} + +async function waitForSuccessfulChild(child: ChildProcess): Promise { + const stderrChunks: Buffer[] = []; + child.stderr?.on("data", (chunk: Buffer) => { + stderrChunks.push(chunk); + }); + const [code, signal] = (await once(child, "close")) as [number | null, string | null]; + assert.equal( + code, + 0, + `session writer failed: signal=${signal ?? "none"} stderr=${Buffer.concat(stderrChunks).toString("utf8")}`, + ); +} + +function stopChild(child: ChildProcess): void { + if (child.exitCode == null && child.signalCode == null) { + child.kill("SIGKILL"); + } +} + +async function waitForFile(filePath: string, timeoutMs = 3_000): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + if (await fileExists(filePath)) { + return; + } + await sleep(10); + } + throw new Error(`Timed out waiting for file: ${filePath}`); +} + +async function sleep(ms: number): Promise { + await new Promise((resolve) => { + setTimeout(resolve, ms); + }); +} + async function waitForExit(pid: number | undefined): Promise { if (pid == null) { return true; diff --git a/test/spawn-options.test.ts b/test/spawn-options.test.ts index 516d8d27..dabaf478 100644 --- a/test/spawn-options.test.ts +++ b/test/spawn-options.test.ts @@ -352,17 +352,17 @@ test("buildTerminalSpawnCommand preserves explicit argv", () => { assert.deepEqual(buildTerminalSpawnCommand("node", ["-e", "console.log('ok')"]), { command: "node", args: ["-e", "console.log('ok')"], - killProcessGroup: false, + killProcessGroup: true, }); assert.deepEqual(buildTerminalSpawnCommand("/tmp/tool with space", []), { command: "/tmp/tool with space", args: [], - killProcessGroup: false, + killProcessGroup: true, }); assert.deepEqual(buildTerminalSpawnCommand("/tmp/tool with space", undefined), { command: "/tmp/tool with space", args: [], - killProcessGroup: false, + killProcessGroup: true, }); }); diff --git a/test/terminal.test.ts b/test/terminal.test.ts index 571dab0f..65172ed3 100644 --- a/test/terminal.test.ts +++ b/test/terminal.test.ts @@ -532,6 +532,46 @@ test("terminal manager kills descendants that detach into a new process group", } }); +test("terminal manager kills detached descendants of structured direct commands", async (t) => { + if (process.platform === "win32") { + t.skip("POSIX detached descendant assertion"); + return; + } + const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "acpx-terminal-test-")); + const childPidPath = path.join(tmp, "direct-detached-child.pid"); + + try { + const manager = new TerminalManager({ + cwd: tmp, + permissionMode: "approve-all", + killGraceMs: 200, + }); + const detachedScript = "process.on('SIGTERM', () => {}); setInterval(() => {}, 1000);"; + const launcherScript = [ + "const { spawn } = require('node:child_process');", + "const fs = require('node:fs');", + `const child = spawn(process.execPath, ['-e', ${JSON.stringify(detachedScript)}], { detached: true, stdio: 'ignore' });`, + `fs.writeFileSync(${JSON.stringify(childPidPath)}, String(child.pid));`, + "setInterval(() => {}, 1000);", + ].join(""); + const created = await manager.createTerminal({ + sessionId: "session-1", + command: process.execPath, + args: ["-e", launcherScript], + }); + + const childPid = await waitForPidFile(childPidPath); + await manager.killTerminal({ + sessionId: "session-1", + terminalId: created.terminalId, + }); + + await assertPidExits(childPid); + } finally { + await fs.rm(tmp, { recursive: true, force: true }); + } +}); + test("terminal manager releases shell command groups after wrapper exit", async (t) => { if (process.platform === "win32") { t.skip("POSIX process group assertion"); From e93dc0c345ce6261054f25225516755b18847b26 Mon Sep 17 00:00:00 2001 From: trumpyla Date: Wed, 29 Jul 2026 21:12:07 -0400 Subject: [PATCH 33/57] test: preserve unverifiable legacy queue owners --- test/queue-ipc-errors.test.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/test/queue-ipc-errors.test.ts b/test/queue-ipc-errors.test.ts index 3f0bac96..1e8db341 100644 --- a/test/queue-ipc-errors.test.ts +++ b/test/queue-ipc-errors.test.ts @@ -1010,7 +1010,7 @@ test("trySubmitToRunningOwner rejects MCP config changes for a live owner", asyn }); }); -test("trySubmitToRunningOwner recovers stale owners before MCP conflict checks", async () => { +test("trySubmitToRunningOwner recovers stale legacy owners before MCP conflict checks", async () => { await withTempHome(async (homeDir) => { const sessionId = "submit-stale-mcp-config-owner"; const keeper = await startKeeperProcess(); @@ -1037,7 +1037,9 @@ test("trySubmitToRunningOwner recovers stale owners before MCP conflict checks", }); assert.equal(outcome, undefined); await assert.rejects(fs.access(lockPath)); - assert.equal(keeper.exitCode == null && keeper.signalCode == null, false); + // Legacy leases have no process identity. Retire the stale lease so the + // request can proceed, but do not signal a PID that may have been reused. + assert.equal(keeper.exitCode == null && keeper.signalCode == null, true); } finally { await cleanupOwnerArtifacts({ socketPath, lockPath }); stopProcess(keeper); From 09eb6026348dc213e3f09926456a8de476c07e31 Mon Sep 17 00:00:00 2001 From: trumpyla Date: Thu, 30 Jul 2026 15:26:00 -0400 Subject: [PATCH 34/57] fix: make lifecycle cleanup and persistence fail closed Source-Commit: d49652824abb75f7550ea24d74014353fcdd5d8e --- src/acp/client.ts | 26 +++- src/cli/queue/lease-store.ts | 102 ++++----------- src/cli/session/session-control.ts | 126 +----------------- src/process-command-match.ts | 101 +++++++++++++++ src/process-termination.ts | 72 +++++++++++ src/session/persistence/index.ts | 34 +++++ src/session/persistence/repository.ts | 74 +++++------ src/session/persistence/write-lock.ts | 13 +- test/client.test.ts | 21 ++- test/queue-lease-store.test.ts | 24 ++++ test/session-persistence.test.ts | 177 ++++++++++++++++++++++++++ test/sessions-prune.test.ts | 45 +++++++ 12 files changed, 570 insertions(+), 245 deletions(-) create mode 100644 src/process-command-match.ts create mode 100644 src/process-termination.ts diff --git a/src/acp/client.ts b/src/acp/client.ts index 281a4287..fca6ac34 100644 --- a/src/acp/client.ts +++ b/src/acp/client.ts @@ -641,7 +641,7 @@ export class AcpClient { this.agentStartedAt = isoNow(); this.lastAgentExit = undefined; this.lastKnownPid = child.pid ?? undefined; - this.attachAgentLifecycleObservers(child); + this.attachAgentLifecycleObservers(child, startupGeneration); const startupStderr: string[] = []; child.stderr.on("data", (chunk: Buffer | string) => { @@ -662,7 +662,12 @@ export class AcpClient { connection.signal.addEventListener( "abort", () => { - this.recordAgentExit("connection_close", child.exitCode ?? null, child.signalCode ?? null); + this.recordAgentExit( + "connection_close", + child.exitCode ?? null, + child.signalCode ?? null, + startupGeneration, + ); }, { once: true }, ); @@ -1424,7 +1429,6 @@ export class AcpClient { private async closeInternal(): Promise { this.closing = true; - this.lifecycleGeneration += 1; await this.terminalManager.shutdown(); @@ -1922,17 +1926,23 @@ export class AcpClient { private attachAgentLifecycleObservers( child: ChildProcessByStdio, + lifecycleGeneration: number, ): void { child.once("exit", (exitCode, signal) => { - this.recordAgentExit("process_exit", exitCode, signal); + this.recordAgentExit("process_exit", exitCode, signal, lifecycleGeneration); }); child.once("close", (exitCode, signal) => { - this.recordAgentExit("process_close", exitCode, signal); + this.recordAgentExit("process_close", exitCode, signal, lifecycleGeneration); }); child.stdout.once("close", () => { - this.recordAgentExit("pipe_close", child.exitCode ?? null, child.signalCode ?? null); + this.recordAgentExit( + "pipe_close", + child.exitCode ?? null, + child.signalCode ?? null, + lifecycleGeneration, + ); }); } @@ -1940,7 +1950,11 @@ export class AcpClient { reason: AgentDisconnectReason, exitCode: number | null, signal: NodeJS.Signals | null, + lifecycleGeneration?: number, ): void { + if (lifecycleGeneration !== undefined && lifecycleGeneration !== this.lifecycleGeneration) { + return; + } if (this.lastAgentExit) { return; } diff --git a/src/cli/queue/lease-store.ts b/src/cli/queue/lease-store.ts index 9cbf371c..5e2ab2c5 100644 --- a/src/cli/queue/lease-store.ts +++ b/src/cli/queue/lease-store.ts @@ -4,20 +4,12 @@ import fs from "node:fs/promises"; import path from "node:path"; import { readProcessIdentity } from "../../acp/process-tree.js"; import { isProcessAlive } from "../../process-liveness.js"; +import { terminateProcess } from "../../process-termination.js"; import { queueBaseDir, queueLockFilePath, queueSocketBaseDir, queueSocketPath } from "./paths.js"; export { isProcessAlive } from "../../process-liveness.js"; +export { terminateProcess } from "../../process-termination.js"; -// Budget for graceful SIGTERM shutdown of a queue-owner process. -// The owner runs AcpClient.close() during shutdown: -// stdin-close grace (100 ms) + SIGTERM wait (1 500 ms) + SIGKILL wait (1 000 ms) = 2 600 ms worst case. -// Process identity validation can add two bounded 1 000 ms process-list calls. -// Add ~1 900 ms of headroom for those calls, event-loop latency, and process startup overhead. -// If the owner does not exit within this window we escalate to SIGKILL. -const PROCESS_SIGTERM_GRACE_MS = 6_500; -// After SIGKILL the OS terminates the process almost immediately; 1 500 ms is generous. -const PROCESS_SIGKILL_GRACE_MS = 1_500; -const PROCESS_POLL_MS = 50; const QUEUE_OWNER_CLEANUP_CLAIM_WAIT_MS = 1_000; const QUEUE_OWNER_CLEANUP_CLAIM_POLL_MS = 10; const QUEUE_OWNER_STALE_HEARTBEAT_MS = 15_000; @@ -108,8 +100,7 @@ function hasValidQueueOwnerRecordFields(record: Record): record return ( isPositiveInteger(record.pid) && isOptionalNonEmptyString(record.processIdentity) && - typeof record.sessionId === "string" && - typeof record.socketPath === "string" && + hasValidQueueOwnerSocket(record) && typeof record.createdAt === "string" && typeof record.heartbeatAt === "string" && isPositiveInteger(record.ownerGeneration) && @@ -117,6 +108,15 @@ function hasValidQueueOwnerRecordFields(record: Record): record ); } +function hasValidQueueOwnerSocket( + record: Record, +): record is Record & { sessionId: string; socketPath: string } { + if (typeof record.sessionId !== "string" || typeof record.socketPath !== "string") { + return false; + } + return record.socketPath === queueSocketPath(record.sessionId); +} + function isPositiveInteger(value: unknown): value is number { return Number.isInteger(value) && (value as number) > 0; } @@ -184,18 +184,6 @@ async function removeSocketFile(socketPath: string): Promise { } } -async function waitForProcessExit(pid: number, timeoutMs: number): Promise { - const deadline = Date.now() + Math.max(0, timeoutMs); - while (Date.now() <= deadline) { - if (!isProcessAlive(pid)) { - return true; - } - await waitMs(PROCESS_POLL_MS); - } - - return !isProcessAlive(pid); -} - async function cleanupStaleQueueOwner( sessionId: string, owner: QueueOwnerRecord | undefined, @@ -218,7 +206,9 @@ async function cleanupStaleQueueOwner( return false; } const socketPath = claimedOwner?.socketPath ?? queueSocketPath(sessionId); - await terminateClaimedQueueOwner(claimedOwner); + if (!(await terminateClaimedQueueOwner(claimedOwner))) { + return false; + } await removeClaimedQueueOwnerFiles(claim, socketPath, lockPath); return true; } finally { @@ -226,10 +216,18 @@ async function cleanupStaleQueueOwner( } } -async function terminateClaimedQueueOwner(owner: QueueOwnerRecord | undefined): Promise { - if (owner?.processIdentity && owner.pid !== process.pid) { - await terminateProcess(owner.pid, owner.processIdentity); +async function terminateClaimedQueueOwner(owner: QueueOwnerRecord | undefined): Promise { + if (!owner || owner.pid === process.pid) { + return true; } + const processState = await queueOwnerProcessState(owner); + if (processState === "dead" || processState === "mismatch" || processState === "legacy") { + return true; + } + if (processState === "unverified" || !owner.processIdentity) { + return false; + } + return await terminateProcess(owner.pid, owner.processIdentity); } async function resolveQueueOwnerForCleanup( @@ -659,54 +657,6 @@ async function readQueueOwnerRecordAtPath(lockPath: string): Promise { - if (!isProcessAlive(pid)) { - return false; - } - if (!(await processMayBeSignaled(pid, expectedProcessIdentity))) { - return false; - } - - try { - process.kill(pid, "SIGTERM"); - } catch { - return false; - } - - if (await waitForProcessExit(pid, PROCESS_SIGTERM_GRACE_MS)) { - return true; - } - if (!(await processMayBeSignaled(pid, expectedProcessIdentity))) { - return true; - } - - try { - process.kill(pid, "SIGKILL"); - } catch { - return false; - } - - await waitForProcessExit(pid, PROCESS_SIGKILL_GRACE_MS); - return true; -} - -async function processMayBeSignaled( - pid: number, - expectedProcessIdentity: string | undefined, -): Promise { - return ( - expectedProcessIdentity === undefined || - (await processIdentityMatches(pid, expectedProcessIdentity)) - ); -} - -async function processIdentityMatches(pid: number, expectedIdentity: string): Promise { - return (await readProcessIdentity(pid)) === expectedIdentity; -} - export async function ensureOwnerIsUsable( sessionId: string, owner: QueueOwnerRecord, diff --git a/src/cli/session/session-control.ts b/src/cli/session/session-control.ts index 9e129e6f..c8359b1e 100644 --- a/src/cli/session/session-control.ts +++ b/src/cli/session/session-control.ts @@ -1,8 +1,4 @@ -import { execFile } from "node:child_process"; -import fs from "node:fs/promises"; -import path from "node:path"; -import { promisify } from "node:util"; -import { splitCommandLine } from "../../acp/client-process.js"; +import { firstAgentCommandToken, splitCommandLineLike } from "../../process-command-match.js"; import { applyConfigOptionsToRecord } from "../../session/config-options.js"; import { setCurrentModelId, @@ -12,7 +8,11 @@ import { } from "../../session/mode-preference.js"; import { currentModelIdFromSetModelResponse } from "../../session/model-application.js"; import { advertisedModelState } from "../../session/model-state.js"; -import { resolveSessionRecord, writeSessionRecord, isoNow } from "../../session/persistence.js"; +import { + closeSession as closePersistedSession, + resolveSessionRecord, + writeSessionRecord, +} from "../../session/persistence.js"; import type { SessionRecord, SessionSetConfigOptionResult, @@ -20,8 +20,6 @@ import type { SessionSetModeResult, } from "../../types.js"; import { - isProcessAlive, - terminateProcess, terminateQueueOwnerForSession, tryCancelOnRunningOwner, tryCloseSessionOnRunningOwner, @@ -42,8 +40,6 @@ import { runSessionSetModeDirect, } from "./prompt-runner.js"; -const execFileAsync = promisify(execFile); - export async function cancelSessionPrompt( options: SessionCancelOptions, ): Promise { @@ -171,100 +167,6 @@ export async function setSessionConfigOption( }); } -function firstAgentCommandToken(command: string): string | undefined { - try { - const parsed = splitCommandLine(command); - return parsed.command || undefined; - } catch { - return undefined; - } -} - -async function isLikelyMatchingProcess(pid: number, agentCommand: string): Promise { - const expectedToken = firstAgentCommandToken(agentCommand); - if (!expectedToken) { - return false; - } - - const argv = await readProcessArgv(pid); - if (argv.length === 0) { - return false; - } - - const executableBase = path.basename(argv[0]); - const expectedBase = path.basename(expectedToken); - return ( - executableBase === expectedBase || argv.some((entry) => path.basename(entry) === expectedBase) - ); -} - -async function readProcessArgv(pid: number): Promise { - const procArgv = await readProcCmdline(pid); - if (procArgv) { - return procArgv; - } - - const commandLine = - process.platform === "win32" - ? await readWindowsCommandLine(pid) - : await readPosixCommandLine(pid); - return splitCommandLineLike(commandLine); -} - -async function readProcCmdline(pid: number): Promise { - try { - const payload = await fs.readFile(`/proc/${pid}/cmdline`, "utf8"); - return payload - .split("\u0000") - .map((entry) => entry.trim()) - .filter((entry) => entry.length > 0); - } catch { - return undefined; - } -} - -async function readPosixCommandLine(pid: number): Promise { - try { - const { stdout } = await execFileAsync("ps", ["-p", String(pid), "-o", "command="]); - return stdout.trim() || undefined; - } catch { - return undefined; - } -} - -async function readWindowsCommandLine(pid: number): Promise { - try { - const { stdout } = await execFileAsync( - "powershell.exe", - [ - "-NoProfile", - "-NonInteractive", - "-Command", - `(Get-CimInstance Win32_Process -Filter "ProcessId = ${pid}").CommandLine`, - ], - { windowsHide: true }, - ); - return stdout.trim() || undefined; - } catch { - return undefined; - } -} - -function splitCommandLineLike(commandLine: string | undefined): string[] { - if (!commandLine) { - return []; - } - try { - const parsed = splitCommandLine(commandLine); - return [parsed.command, ...parsed.args]; - } catch { - return commandLine - .split(/\s+/u) - .map((entry) => entry.trim()) - .filter((entry) => entry.length > 0); - } -} - export const sessionControlTestInternals = { firstAgentCommandToken, splitCommandLineLike }; export async function closeSession(sessionId: string): Promise { @@ -273,19 +175,5 @@ export async function closeSession(sessionId: string): Promise { // Preserve local close semantics even if best-effort ACP session shutdown fails. }); await terminateQueueOwnerForSession(record.acpxRecordId); - - if ( - record.pid != null && - isProcessAlive(record.pid) && - (await isLikelyMatchingProcess(record.pid, record.agentCommand)) - ) { - await terminateProcess(record.pid); - } - - record.pid = undefined; - record.closed = true; - record.closedAt = isoNow(); - await writeSessionRecord(record); - - return record; + return await closePersistedSession(record.acpxRecordId); } diff --git a/src/process-command-match.ts b/src/process-command-match.ts new file mode 100644 index 00000000..7db30550 --- /dev/null +++ b/src/process-command-match.ts @@ -0,0 +1,101 @@ +import { execFile } from "node:child_process"; +import fs from "node:fs/promises"; +import path from "node:path"; +import { promisify } from "node:util"; +import { splitCommandLine } from "./acp/client-process.js"; + +const execFileAsync = promisify(execFile); + +export function firstAgentCommandToken(command: string): string | undefined { + try { + const parsed = splitCommandLine(command); + return parsed.command || undefined; + } catch { + return undefined; + } +} + +export async function isLikelyMatchingProcess(pid: number, agentCommand: string): Promise { + const expectedToken = firstAgentCommandToken(agentCommand); + if (!expectedToken) { + return false; + } + + const argv = await readProcessArgv(pid); + if (argv.length === 0) { + return false; + } + + const executableBase = path.basename(argv[0]); + const expectedBase = path.basename(expectedToken); + return ( + executableBase === expectedBase || argv.some((entry) => path.basename(entry) === expectedBase) + ); +} + +async function readProcessArgv(pid: number): Promise { + const procArgv = await readProcCmdline(pid); + if (procArgv) { + return procArgv; + } + + const commandLine = + process.platform === "win32" + ? await readWindowsCommandLine(pid) + : await readPosixCommandLine(pid); + return splitCommandLineLike(commandLine); +} + +async function readProcCmdline(pid: number): Promise { + try { + const payload = await fs.readFile(`/proc/${pid}/cmdline`, "utf8"); + return payload + .split("\u0000") + .map((entry) => entry.trim()) + .filter((entry) => entry.length > 0); + } catch { + return undefined; + } +} + +async function readPosixCommandLine(pid: number): Promise { + try { + const { stdout } = await execFileAsync("ps", ["-p", String(pid), "-o", "command="]); + return stdout.trim() || undefined; + } catch { + return undefined; + } +} + +async function readWindowsCommandLine(pid: number): Promise { + try { + const { stdout } = await execFileAsync( + "powershell.exe", + [ + "-NoProfile", + "-NonInteractive", + "-Command", + `(Get-CimInstance Win32_Process -Filter "ProcessId = ${pid}").CommandLine`, + ], + { windowsHide: true }, + ); + return stdout.trim() || undefined; + } catch { + return undefined; + } +} + +export function splitCommandLineLike(commandLine: string | undefined): string[] { + if (!commandLine) { + return []; + } + try { + const parsed = splitCommandLine(commandLine); + return [parsed.command, ...parsed.args]; + } catch { + return commandLine + .split(/\s+/u) + .map((entry) => entry.trim()) + .filter((entry) => entry.length > 0); + } +} diff --git a/src/process-termination.ts b/src/process-termination.ts new file mode 100644 index 00000000..bba15b0a --- /dev/null +++ b/src/process-termination.ts @@ -0,0 +1,72 @@ +import { readProcessIdentity } from "./acp/process-tree.js"; +import { isProcessAlive } from "./process-liveness.js"; + +// The queue owner may spend 2.6 seconds closing its ACP process tree. Identity +// checks can each add a bounded process-list call, so retain the existing +// headroom before escalating from SIGTERM. +const PROCESS_SIGTERM_GRACE_MS = 6_500; +const PROCESS_SIGKILL_GRACE_MS = 1_500; +const PROCESS_POLL_MS = 50; + +export async function terminateProcess( + pid: number, + expectedProcessIdentity?: string, + validateProcess: () => Promise = async () => true, +): Promise { + if (!isProcessAlive(pid)) { + return false; + } + if (!(await processMayBeSignaled(pid, expectedProcessIdentity, validateProcess))) { + return false; + } + + try { + process.kill(pid, "SIGTERM"); + } catch { + return false; + } + + if (await waitForProcessExit(pid, PROCESS_SIGTERM_GRACE_MS)) { + return true; + } + if (!(await processMayBeSignaled(pid, expectedProcessIdentity, validateProcess))) { + return true; + } + + try { + process.kill(pid, "SIGKILL"); + } catch { + return false; + } + + return await waitForProcessExit(pid, PROCESS_SIGKILL_GRACE_MS); +} + +async function processMayBeSignaled( + pid: number, + expectedProcessIdentity: string | undefined, + validateProcess: () => Promise, +): Promise { + if (expectedProcessIdentity !== undefined) { + return await processIdentityMatches(pid, expectedProcessIdentity); + } + return await validateProcess(); +} + +async function processIdentityMatches(pid: number, expectedIdentity: string): Promise { + return (await readProcessIdentity(pid)) === expectedIdentity; +} + +async function waitForProcessExit(pid: number, timeoutMs: number): Promise { + const deadline = Date.now() + Math.max(0, timeoutMs); + while (Date.now() <= deadline) { + if (!isProcessAlive(pid)) { + return true; + } + await new Promise((resolve) => { + setTimeout(resolve, PROCESS_POLL_MS); + }); + } + + return !isProcessAlive(pid); +} diff --git a/src/session/persistence/index.ts b/src/session/persistence/index.ts index 7b9aa74b..5b67348f 100644 --- a/src/session/persistence/index.ts +++ b/src/session/persistence/index.ts @@ -6,6 +6,7 @@ import { parseSessionRecord } from "./parse.js"; import { withSessionWriteLock } from "./write-lock.js"; const SESSION_INDEX_SCHEMA = "acpx.session-index.v1"; +const SESSION_INDEX_DIRTY_FILE = ".index.dirty"; export type SessionIndexEntry = { file: string; @@ -77,6 +78,37 @@ export function sessionIndexPath(sessionDir: string): string { return path.join(sessionDir, "index.json"); } +function sessionIndexDirtyPath(sessionDir: string): string { + return path.join(sessionDir, SESSION_INDEX_DIRTY_FILE); +} + +export async function markSessionIndexDirty(sessionDir: string): Promise { + await fs.writeFile(sessionIndexDirtyPath(sessionDir), `${process.pid}\n`, { + encoding: "utf8", + mode: 0o600, + }); +} + +export async function clearSessionIndexDirty(sessionDir: string): Promise { + await fs.unlink(sessionIndexDirtyPath(sessionDir)).catch((error) => { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") { + throw error; + } + }); +} + +async function sessionIndexIsDirty(sessionDir: string): Promise { + try { + await fs.access(sessionIndexDirtyPath(sessionDir)); + return true; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") { + return false; + } + throw error; + } +} + export function toSessionIndexEntry(record: SessionRecord, fileName: string): SessionIndexEntry { return { file: fileName, @@ -173,6 +205,7 @@ export async function rebuildSessionIndex(sessionDir: string): Promise file === files[index]) diff --git a/src/session/persistence/repository.ts b/src/session/persistence/repository.ts index 363b1a3d..f0d7ec84 100644 --- a/src/session/persistence/repository.ts +++ b/src/session/persistence/repository.ts @@ -5,10 +5,14 @@ import path from "node:path"; import { SessionNotFoundError, SessionResolutionError } from "../../errors.js"; import { incrementPerfCounter, measurePerf } from "../../perf-metrics.js"; import { assertPersistedKeyPolicy } from "../../persisted-key-policy.js"; +import { isLikelyMatchingProcess } from "../../process-command-match.js"; +import { terminateProcess } from "../../process-termination.js"; import type { SessionRecord } from "../../types.js"; import { createAtomicWriteTempPath } from "./atomic-write.js"; import { + clearSessionIndexDirty, loadOrRebuildSessionIndex, + markSessionIndexDirty, rebuildSessionIndex, toSessionIndexEntry, writeSessionIndex, @@ -95,15 +99,17 @@ export async function writeSessionRecord(record: SessionRecord): Promise { const file = sessionFilePath(record.acpxRecordId); const tempFile = createAtomicWriteTempPath(file); const payload = JSON.stringify(persisted, null, 2); + const index = await loadOrRebuildSessionIndex(sessionDir); + await markSessionIndexDirty(sessionDir); await fs.writeFile(tempFile, `${payload}\n`, "utf8"); await fs.rename(tempFile, file); - const index = await loadOrRebuildSessionIndex(sessionDir); const fileName = path.basename(file); const entries = index.entries.filter((entry) => entry.file !== fileName); entries.push(toSessionIndexEntry(record, fileName)); const files = [...new Set([...index.files.filter((entry) => entry !== fileName), fileName])]; await writeSessionIndex(sessionDir, { files, entries }); + await clearSessionIndexDirty(sessionDir); }); }); } @@ -295,19 +301,6 @@ function nextWalkParent( return parent; } -function killSignalCandidates(signal: NodeJS.Signals | undefined): NodeJS.Signals[] { - if (!signal) { - return ["SIGTERM", "SIGKILL"]; - } - - const normalized = signal.toUpperCase() as NodeJS.Signals; - if (normalized === "SIGKILL") { - return ["SIGKILL"]; - } - - return [normalized, "SIGKILL"]; -} - export type PruneOptions = { agentCommand?: string; before?: Date; @@ -336,6 +329,16 @@ function isSessionStreamFile(fileName: string, safeId: string): boolean { export async function pruneSessions(options: PruneOptions = {}): Promise { await ensureSessionDir(); + const sessionDir = sessionBaseDir(); + return await withSessionWriteLock(sessionDir, async () => { + return await pruneSessionsWhileLocked(options, sessionDir); + }); +} + +async function pruneSessionsWhileLocked( + options: PruneOptions, + sessionDir: string, +): Promise { const entries = await loadSessionIndexEntries(); const eligible = filterPruneCandidates(entries, options.agentCommand); @@ -350,7 +353,6 @@ export async function pruneSessions(options: PruneOptions = {}): Promise { } export async function closeSession(id: string): Promise { - const record = await resolveSessionRecord(id); - const now = isoNow(); - - if (record.pid) { - for (const signal of killSignalCandidates(record.lastAgentExitSignal ?? undefined)) { - try { - process.kill(record.pid, signal); - } catch { - // ignore - } - } - } - - record.closed = true; - record.closedAt = now; - record.pid = undefined; - record.lastUsedAt = now; - record.lastPromptAt = record.lastPromptAt ?? now; - - await writeSessionRecord(record); - await rebuildSessionIndex(sessionBaseDir()).catch(() => { - // best effort cache rebuild + await ensureSessionDir(); + const { record, pid, agentCommand } = await withSessionWriteLock(sessionBaseDir(), async () => { + const record = await resolveSessionRecord(id); + const pid = record.pid; + const agentCommand = record.agentCommand; + const now = isoNow(); + + record.closed = true; + record.closedAt = now; + record.pid = undefined; + record.lastUsedAt = now; + record.lastPromptAt = record.lastPromptAt ?? now; + await writeSessionRecord(record); + return { record, pid, agentCommand }; }); + + if (pid) { + await terminateProcess(pid, undefined, async () => { + return await isLikelyMatchingProcess(pid, agentCommand); + }); + } return record; } diff --git a/src/session/persistence/write-lock.ts b/src/session/persistence/write-lock.ts index 29a4fcc5..f0905cdd 100644 --- a/src/session/persistence/write-lock.ts +++ b/src/session/persistence/write-lock.ts @@ -30,7 +30,10 @@ export async function withSessionWriteLock( sessionDir: string, operation: () => Promise, ): Promise { - const lockPath = path.join(path.resolve(sessionDir), SESSION_WRITE_LOCK_FILE); + const resolvedSessionDir = path.resolve(sessionDir); + await fs.mkdir(resolvedSessionDir, { recursive: true }); + const canonicalSessionDir = await fs.realpath(resolvedSessionDir); + const lockPath = path.join(canonicalSessionDir, SESSION_WRITE_LOCK_FILE); const heldLocks = heldSessionWriteLocks.getStore(); if (heldLocks?.has(lockPath)) { return await operation(); @@ -124,7 +127,7 @@ async function recoverStaleSessionWriteLock(lockPath: string): Promise return true; } -async function sessionWriteLockIsStale(lockPath: string, stat: Stats): Promise { +export async function sessionWriteLockIsStale(lockPath: string, stat: Stats): Promise { const record = await readSessionWriteLockRecord(lockPath); if (!record) { return lockAgeMs(stat) > SESSION_WRITE_LOCK_UNVERIFIED_STALE_MS; @@ -133,12 +136,10 @@ async function sessionWriteLockIsStale(lockPath: string, stat: Stats): Promise SESSION_WRITE_LOCK_UNVERIFIED_STALE_MS; + return false; } const currentIdentity = await readProcessIdentity(record.pid); - return currentIdentity - ? currentIdentity !== record.processIdentity - : lockAgeMs(stat) > SESSION_WRITE_LOCK_UNVERIFIED_STALE_MS; + return currentIdentity ? currentIdentity !== record.processIdentity : false; } function sessionWriterIsAlive(pid: number): boolean { diff --git a/test/client.test.ts b/test/client.test.ts index 1e3426f0..e823c62b 100644 --- a/test/client.test.ts +++ b/test/client.test.ts @@ -80,6 +80,7 @@ type ClientInternals = { reason: "process_exit" | "process_close" | "pipe_close" | "connection_close", exitCode: number | null, signal: NodeJS.Signals | null, + lifecycleGeneration?: number, ) => void; filesystem?: { readTextFile: (params: { @@ -140,6 +141,7 @@ type ClientInternals = { lastKnownPid?: number; agentStartedAt?: string; closing: boolean; + lifecycleGeneration: number; observedSessionUpdates: number; processedSessionUpdates: number; suppressSessionUpdates: boolean; @@ -1174,6 +1176,18 @@ test("AcpClient lifecycle snapshot and cancel helpers reflect active prompt stat assert.deepEqual(cancelled, { stopReason: "cancelled" }); }); +test("AcpClient ignores lifecycle events from an earlier agent generation", () => { + const client = makeClient(); + const internals = asInternals(client); + internals.lifecycleGeneration = 2; + + internals.recordAgentExit?.("process_exit", 1, "SIGTERM", 1); + assert.equal(client.getAgentLifecycleSnapshot().lastExit, undefined); + + internals.recordAgentExit?.("process_exit", 0, null, 2); + assert.equal(client.getAgentLifecycleSnapshot().lastExit?.exitCode, 0); +}); + test("AcpClient rejects rich prompt content not advertised by promptCapabilities", async () => { const client = makeClient(); const internals = asInternals(client); @@ -1348,7 +1362,7 @@ test("AcpClient startup failure kills descendants left by an exited npx wrapper" } }); -test("AcpClient close terminates an adapter while initialization is still pending", async (t) => { +test("AcpClient close records the adapter exit while initialization is still pending", async (t) => { if (process.platform === "win32") { t.skip("POSIX process-group cleanup assertion"); return; @@ -1374,6 +1388,11 @@ test("AcpClient close terminates an adapter while initialization is still pendin true, "close must terminate the detached adapter before initialization finishes", ); + assert.equal( + typeof client.getAgentLifecycleSnapshot().lastExit?.exitedAt, + "string", + "close must retain the current adapter's exit lifecycle", + ); assert.equal((await startResult).type, "rejected"); } finally { if (pid) { diff --git a/test/queue-lease-store.test.ts b/test/queue-lease-store.test.ts index add60bd3..0a480f11 100644 --- a/test/queue-lease-store.test.ts +++ b/test/queue-lease-store.test.ts @@ -258,6 +258,30 @@ test("tryAcquireQueueOwnerLease removes a malformed lock only after it is stale" }); }); +test("stale cleanup never trusts a persisted socket path outside the queue directory", async () => { + await withTempHome(async (homeDir) => { + const sessionId = "untrusted-socket-path"; + const { lockPath } = queuePaths(homeDir, sessionId); + const unrelatedPath = path.join(homeDir, "unrelated-user-file"); + await fs.writeFile(unrelatedPath, "preserve me\n", "utf8"); + await writeQueueOwnerLock({ + lockPath, + pid: 999_999, + sessionId, + socketPath: unrelatedPath, + heartbeatAt: "2000-01-01T00:00:00.000Z", + }); + const staleTime = new Date("2000-01-01T00:00:00.000Z"); + await fs.utimes(lockPath, staleTime, staleTime); + + const lease = await tryAcquireQueueOwnerLease(sessionId); + + assert(lease); + assert.equal(await fs.readFile(unrelatedPath, "utf8"), "preserve me\n"); + await releaseQueueOwnerLease(lease); + }); +}); + test( "tryAcquireQueueOwnerLease ages out a stale dangling symlink lock", { skip: process.platform === "win32" }, diff --git a/test/session-persistence.test.ts b/test/session-persistence.test.ts index 50734ce7..865bf238 100644 --- a/test/session-persistence.test.ts +++ b/test/session-persistence.test.ts @@ -18,6 +18,7 @@ type SessionModule = typeof import("../src/session/session.js"); const SESSION_MODULE_URL = new URL("../src/session/session.js", import.meta.url); const SESSION_REPOSITORY_URL = new URL("../src/session/persistence/repository.js", import.meta.url); +const SESSION_WRITE_LOCK_URL = new URL("../src/session/persistence/write-lock.js", import.meta.url); test("SessionRecord allows optional closed and closedAt fields", () => { const record = makeSessionRecord({ @@ -564,6 +565,47 @@ test("writeSessionRecord maintains an index and listSessions rebuilds it when mi }); }); +test("a dirty marker rebuilds index metadata after an interrupted record update", async () => { + await withTempHome(async (homeDir) => { + const session = await loadSessionModule(); + const sessionDir = path.join(homeDir, ".acpx", "sessions"); + const recordPath = sessionFilePath(homeDir, "dirty-index-session"); + const dirtyPath = path.join(sessionDir, ".index.dirty"); + const cwd = path.join(homeDir, "repo"); + const initial = makeSessionRecord({ + acpxRecordId: "dirty-index-session", + acpSessionId: "dirty-index-session", + agentCommand: "agent-a", + cwd, + closed: false, + }); + await writeSessionRecord(homeDir, initial); + await session.listSessions(); + + await fs.writeFile( + recordPath, + `${JSON.stringify( + serializeSessionRecordForDisk({ + ...initial, + closed: true, + closedAt: "2026-01-01T00:00:00.000Z", + }), + null, + 2, + )}\n`, + "utf8", + ); + await fs.writeFile(dirtyPath, "interrupted\n", "utf8"); + + const found = await session.findSession({ + agentCommand: "agent-a", + cwd, + }); + assert.equal(found, undefined); + assert.equal(await fileExists(dirtyPath), false); + }); +}); + test("writeSessionRecord serializes cross-process record and index publication", async () => { await withTempHome(async (homeDir) => { const sessionDir = path.join(homeDir, ".acpx", "sessions"); @@ -648,6 +690,58 @@ test("writeSessionRecord recovers a stale cross-process write lock", async () => }); }); +test("session write lock reentrancy canonicalizes symlink aliases", async () => { + await withTempHome(async (homeDir) => { + const realSessionDir = path.join(homeDir, "real-sessions"); + const aliasSessionDir = path.join(homeDir, "session-alias"); + await fs.mkdir(realSessionDir, { recursive: true }); + await fs.symlink( + realSessionDir, + aliasSessionDir, + process.platform === "win32" ? "junction" : "dir", + ); + const { withSessionWriteLock } = await import( + `${SESSION_WRITE_LOCK_URL.href}?symlink_reentrancy=${Date.now()}-${Math.random()}` + ); + let nestedOperationRan = false; + + await withSessionWriteLock(aliasSessionDir, async () => { + await withSessionWriteLock(realSessionDir, async () => { + nestedOperationRan = true; + }); + }); + + assert.equal(nestedOperationRan, true); + }); +}); + +test("session write locks preserve live owners without a verifiable process identity", async () => { + await withTempHome(async (homeDir) => { + const sessionDir = path.join(homeDir, "sessions"); + const lockPath = path.join(sessionDir, ".write.lock"); + await fs.mkdir(sessionDir, { recursive: true }); + await fs.writeFile( + lockPath, + `${JSON.stringify({ + lockId: "live-legacy-writer", + pid: process.pid, + createdAt: "2000-01-01T00:00:00.000Z", + })}\n`, + "utf8", + ); + const staleTime = new Date("2000-01-01T00:00:00.000Z"); + await fs.utimes(lockPath, staleTime, staleTime); + const writeLockModule = await import( + `${SESSION_WRITE_LOCK_URL.href}?live_legacy=${Date.now()}-${Math.random()}` + ); + + assert.equal( + await writeLockModule.sessionWriteLockIsStale(lockPath, await fs.lstat(lockPath)), + false, + ); + }); +}); + test("closeSession soft-closes and terminates matching process", async () => { await withTempHome(async (homeDir) => { const session = await loadSessionModule(); @@ -693,6 +787,89 @@ test("closeSession soft-closes and terminates matching process", async () => { }); }); +test("closeSession does not signal a live process that does not match the recorded agent", async () => { + await withTempHome(async (homeDir) => { + const repository = await import( + `${SESSION_REPOSITORY_URL.href}?close_process_match=${Date.now()}-${Math.random()}` + ); + const child = spawn(process.execPath, ["-e", "setInterval(() => {}, 1000);"], { + stdio: "ignore", + }); + await once(child, "spawn"); + + const sessionId = "mismatched-live-session"; + await writeSessionRecord( + homeDir, + makeSessionRecord({ + acpxRecordId: sessionId, + acpSessionId: sessionId, + agentCommand: "definitely-not-the-node-runtime", + cwd: path.join(homeDir, "repo"), + pid: child.pid, + }), + ); + + try { + await repository.closeSession(sessionId); + await sleep(100); + assert.equal(await waitForExit(child.pid), false); + } finally { + stopChild(child); + } + }); +}); + +test("closeSession reads and updates the record while holding the session write lock", async () => { + await withTempHome(async (homeDir) => { + const session = await loadSessionModule(); + const sessionDir = path.join(homeDir, ".acpx", "sessions"); + const lockPath = path.join(sessionDir, ".write.lock"); + const sessionId = "concurrent-close"; + const initial = makeSessionRecord({ + acpxRecordId: sessionId, + acpSessionId: sessionId, + agentCommand: "agent-a", + cwd: path.join(homeDir, "repo"), + title: "initial title", + }); + await writeSessionRecord(homeDir, initial); + await session.listSessions(); + await fs.writeFile( + lockPath, + `${JSON.stringify({ + lockId: "active-writer", + pid: process.pid, + createdAt: new Date().toISOString(), + })}\n`, + "utf8", + ); + + const closing = session.closeSession(sessionId); + await sleep(200); + await fs.writeFile( + sessionFilePath(homeDir, sessionId), + `${JSON.stringify( + serializeSessionRecordForDisk({ + ...initial, + title: "concurrent title", + }), + null, + 2, + )}\n`, + "utf8", + ); + await fs.unlink(lockPath); + + await closing; + const stored = parseSessionRecord( + JSON.parse(await fs.readFile(sessionFilePath(homeDir, sessionId), "utf8")), + ); + assert.ok(stored); + assert.equal(stored.closed, true); + assert.equal(stored.title, "concurrent title"); + }); +}); + test("normalizeQueueOwnerTtlMs applies default and edge-case normalization", async () => { await withTempHome(async () => { const session = await loadSessionModule(); diff --git a/test/sessions-prune.test.ts b/test/sessions-prune.test.ts index 27f93deb..fbe8295a 100644 --- a/test/sessions-prune.test.ts +++ b/test/sessions-prune.test.ts @@ -81,6 +81,51 @@ test("pruneSessions deletes closed session files and removes them from the index }); }); +test("pruneSessions waits for the cross-process session write lock", async () => { + await withTempHome(async (homeDir) => { + const session = await loadSessionModule(); + const sessionDir = path.join(homeDir, ".acpx", "sessions"); + const lockPath = path.join(sessionDir, ".write.lock"); + const cwd = path.join(homeDir, "workspace"); + + await writeSessionRecord( + homeDir, + makeSessionRecord({ + acpxRecordId: "locked-prune", + acpSessionId: "locked-prune", + agentCommand: "agent-a", + cwd, + closed: true, + closedAt: "2026-01-01T00:00:00.000Z", + }), + ); + await session.listSessions(); + await fs.writeFile( + lockPath, + `${JSON.stringify({ + lockId: "active-writer", + pid: process.pid, + createdAt: new Date().toISOString(), + })}\n`, + "utf8", + ); + + const pruning = session.pruneSessions({ agentCommand: "agent-a" }); + const completedEarly = await Promise.race([ + pruning.then(() => true), + new Promise((resolve) => { + setTimeout(() => resolve(false), 500); + }), + ]); + assert.equal(completedEarly, false); + assert.equal(await fileExists(sessionFilePath(homeDir, "locked-prune")), true); + + await fs.unlink(lockPath); + const result = await pruning; + assert.equal(result.pruned.map((record) => record.acpxRecordId).join(","), "locked-prune"); + }); +}); + test("pruneSessions --dry-run does not delete files but returns correct count", async () => { await withTempHome(async (homeDir) => { const session = await loadSessionModule(); From c4a85d4a31bb885a59aea89c0e9fc61423773a6d Mon Sep 17 00:00:00 2001 From: trumpyla Date: Sat, 25 Jul 2026 23:45:41 -0400 Subject: [PATCH 35/57] perf(startup): cut ACP SDK from eager graph, add teardown and capability caching Source-Commit: d2f70209e56d8a7f84ac46f91a7a59ebd6fd4a9e --- package.json | 3 +- scripts/check-eager-graph.mjs | 96 +++++++++++++++++ src/acp/agent-command.ts | 41 ++++++- src/acp/capability-cache.ts | 150 ++++++++++++++++++++++++++ src/acp/client.ts | 67 ++++++------ src/cli-core.ts | 6 +- src/cli/command-handlers.ts | 25 +++-- src/cli/compare-command.ts | 6 +- src/cli/flags.ts | 5 +- src/cli/queue/owner-env.ts | 8 +- src/cli/session/session-management.ts | 25 +++-- src/types.ts | 8 ++ 12 files changed, 382 insertions(+), 58 deletions(-) create mode 100644 scripts/check-eager-graph.mjs create mode 100644 src/acp/capability-cache.ts diff --git a/package.json b/package.json index 78bc9232..99e3c152 100644 --- a/package.json +++ b/package.json @@ -38,7 +38,7 @@ "build": "tsdown src/cli.ts src/flows.ts src/runtime.ts --format esm --dts --clean --platform node --target node22 --no-fixedExtension", "build:quiet": "tsdown --logLevel silent 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", - "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": "pnpm run format:check && pnpm run typecheck && pnpm run lint && pnpm run build && pnpm run lint:eager-graph && pnpm run viewer:typecheck && pnpm run viewer:build && pnpm run test:coverage", "check:changed": "pnpm run check", "check:docs": "pnpm run format:docs:check && pnpm run lint:docs && pnpm run docs:site", "check:mutation": "pnpm run mutate", @@ -56,6 +56,7 @@ "format:docs:check": "git ls-files 'docs/**/*.md' 'examples/flows/**/*.md' 'README.md' | xargs oxfmt --check", "lint": "oxlint --type-aware --deny-warnings src scripts examples test conformance && pnpm run lint:persisted-key-casing && pnpm run lint:flow-schema-terms", "lint:docs": "markdownlint-cli2 README.md docs/**/*.md examples/flows/**/*.md", + "lint:eager-graph": "node scripts/check-eager-graph.mjs", "lint:fix": "oxlint --type-aware --fix src && pnpm run format", "lint:flow-schema-terms": "tsx scripts/lint-flow-schema-terms.ts", "lint:persisted-key-casing": "tsx scripts/lint-persisted-key-casing.ts", diff --git a/scripts/check-eager-graph.mjs b/scripts/check-eager-graph.mjs new file mode 100644 index 00000000..41f4f744 --- /dev/null +++ b/scripts/check-eager-graph.mjs @@ -0,0 +1,96 @@ +#!/usr/bin/env node +// Fails if any third-party package other than the allow-list enters the eager +// (statically imported) chunk closure of dist/cli.js. +// +// Startup cost is dominated by what is evaluated before the CLI can answer. +// @agentclientprotocol/sdk plus its transitive zod was 77% of module init until +// it was moved behind dynamic imports. That win is invisible to break: a single +// value import from the src/session/session.ts barrel silently re-welds the SDK +// into startup and nothing fails except the clock. This is a static assertion +// rather than a benchmark because benchmark noise on developer machines is +// larger than the regression it would need to catch. + +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const distDir = path.join(repoRoot, "dist"); +const entry = path.join(distDir, "cli.js"); + +const ALLOWED_EAGER_PACKAGES = new Set(["commander"]); + +// Matches static `import ... from "spec"` / `export ... from "spec"` only. +// Dynamic `import("spec")` is deliberately not matched: deferring is the point. +const STATIC_IMPORT = /(?:^|\n)\s*(?:import|export)[^;\n]*?from\s*["']([^"']+)["']/g; +const BARE_SIDE_EFFECT_IMPORT = /(?:^|\n)\s*import\s*["']([^"']+)["']/g; + +function specifiersIn(source) { + const found = []; + for (const re of [STATIC_IMPORT, BARE_SIDE_EFFECT_IMPORT]) { + re.lastIndex = 0; + let match; + while ((match = re.exec(source)) !== null) { + found.push(match[1]); + } + } + return found; +} + +function packageNameOf(specifier) { + const segments = specifier.split("/"); + return specifier.startsWith("@") ? segments.slice(0, 2).join("/") : segments[0]; +} + +if (!fs.existsSync(entry)) { + console.error(`check-eager-graph: ${path.relative(repoRoot, entry)} not found — run the build first.`); + process.exit(2); +} + +const visited = new Set(); +const offenders = new Map(); // package name -> chunk that imported it +const queue = [entry]; + +while (queue.length > 0) { + const file = queue.pop(); + if (visited.has(file)) { + continue; + } + visited.add(file); + + for (const specifier of specifiersIn(fs.readFileSync(file, "utf8"))) { + if (specifier.startsWith("node:")) { + continue; + } + + if (specifier.startsWith(".")) { + const resolved = path.resolve(path.dirname(file), specifier); + if (fs.existsSync(resolved)) { + queue.push(resolved); + } + continue; + } + + const pkg = packageNameOf(specifier); + if (!ALLOWED_EAGER_PACKAGES.has(pkg) && !offenders.has(pkg)) { + offenders.set(pkg, path.relative(repoRoot, file)); + } + } +} + +if (offenders.size > 0) { + console.error("check-eager-graph: third-party packages in the eager startup graph:\n"); + for (const [pkg, chunk] of offenders) { + console.error(` ${pkg} (statically imported by ${chunk})`); + } + console.error( + `\nAllowed: ${[...ALLOWED_EAGER_PACKAGES].join(", ")}.\n` + + "Move the import to its call site, or import types only. A value import from\n" + + "src/session/session.ts re-exports the ACP client and pulls in the SDK + zod.\n", + ); + process.exit(1); +} + +console.log( + `check-eager-graph: ok — ${visited.size} eager chunks, external packages limited to ${[...ALLOWED_EAGER_PACKAGES].join(", ")}.`, +); diff --git a/src/acp/agent-command.ts b/src/acp/agent-command.ts index 87328337..8c4c5b2d 100644 --- a/src/acp/agent-command.ts +++ b/src/acp/agent-command.ts @@ -6,6 +6,11 @@ import { resolveWindowsExecutablePath, } from "../spawn-command-options.js"; import { type AcpClientOptions } from "../types.js"; +import { + fingerprintExecutable, + readCachedCapability, + writeCachedCapability, +} from "./capability-cache.js"; import { basenameToken, splitCommandLine } from "./client-process.js"; const DEFAULT_AGENT_CLOSE_AFTER_STDIN_END_MS = 100; @@ -288,13 +293,39 @@ async function buildCopilotAcpUnsupportedMessage(command: string): Promise { - const helpOutput = await readCommandOutput(command, ["--help"], COPILOT_HELP_TIMEOUT_MS); - if (typeof helpOutput === "string" && !helpOutput.includes("--acp")) { - throw new CopilotAcpUnsupportedError(await buildCopilotAcpUnsupportedMessage(command), { - retryable: false, - }); + // `copilot --help` costs a whole extra process (~380ms measured) for an + // answer that only changes when the binary does, so it is cached against a + // fingerprint of that binary. + const fingerprint = await fingerprintExecutable(command); + const cached = await readCachedCapability(COPILOT_ACP_CAPABILITY_KEY, fingerprint); + if (cached === true) { + return; } + + if (cached === undefined) { + const helpOutput = await readCommandOutput(command, ["--help"], COPILOT_HELP_TIMEOUT_MS); + // Only a definite answer is cacheable: a timeout or spawn failure yields a + // non-string result, which must not be recorded as "supported". + if (typeof helpOutput === "string") { + await writeCachedCapability( + COPILOT_ACP_CAPABILITY_KEY, + fingerprint, + helpOutput.includes("--acp"), + ); + if (helpOutput.includes("--acp")) { + return; + } + } else { + return; + } + } + + throw new CopilotAcpUnsupportedError(await buildCopilotAcpUnsupportedMessage(command), { + retryable: false, + }); } export function buildClaudeCodeOptionsMeta( diff --git a/src/acp/capability-cache.ts b/src/acp/capability-cache.ts new file mode 100644 index 00000000..eb3f0256 --- /dev/null +++ b/src/acp/capability-cache.ts @@ -0,0 +1,150 @@ +import { createHash } from "node:crypto"; +import { constants as fsConstants } from "node:fs"; +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; + +/** + * Cache for capability probes that shell out to an agent CLI. + * + * Some adapters can only be interrogated by running them (e.g. `copilot --help` + * to learn whether `--acp` exists — measured at ~380ms on every launch). The + * answer is a property of the binary, not of the invocation, so it is cached + * against a fingerprint of that binary and recomputed only when the binary + * changes. + * + * The fingerprint is a hash of realpath + size + mtime rather than of the file + * contents: agent CLIs are tens of megabytes and hashing them on every launch + * would cost more than the probe it replaces. Any upgrade, reinstall, or + * rebuild moves at least one of those three. + * + * Every failure path degrades to "cache miss". A capability cache must never be + * able to break a launch. + */ + +type CacheEntry = { + fingerprint: string; + value: boolean; + recordedAt: string; +}; + +type CacheFile = Record; + +const CACHE_VERSION = "v1"; + +function cacheFilePath(homeDir: string = os.homedir()): string { + return path.join(homeDir, ".acpx", "cache", `cli-capabilities.${CACHE_VERSION}.json`); +} + +/** + * Agent commands are usually bare names resolved through PATH at spawn time, so + * resolve them the same way before fingerprinting. Returns the input unchanged + * when it already contains a separator. + */ +async function resolveExecutablePath(command: string): Promise { + if (command.includes(path.sep) || command.includes("/")) { + return command; + } + const pathEntries = (process.env.PATH ?? "").split(path.delimiter).filter(Boolean); + for (const entry of pathEntries) { + const candidate = path.join(entry, command); + try { + await fs.access(candidate, fsConstants.X_OK); + return candidate; + } catch { + // Not here; keep looking. + } + } + return undefined; +} + +/** + * Identifies the exact binary a capability answer belongs to. Returns undefined + * when the path cannot be resolved or stat'd, which forces a live probe. + */ +export async function fingerprintExecutable(binaryPath: string): Promise { + try { + const resolved = await resolveExecutablePath(binaryPath); + if (!resolved) { + return undefined; + } + const realPath = await fs.realpath(resolved); + const stats = await fs.stat(realPath); + return createHash("sha256") + .update(`${realPath}\0${stats.size}\0${stats.mtimeMs}`) + .digest("hex") + .slice(0, 32); + } catch { + return undefined; + } +} + +async function readCacheFile(homeDir?: string): Promise { + try { + const raw = await fs.readFile(cacheFilePath(homeDir), "utf8"); + const parsed = JSON.parse(raw) as unknown; + return parsed && typeof parsed === "object" && !Array.isArray(parsed) + ? (parsed as CacheFile) + : {}; + } catch { + return {}; + } +} + +/** + * Pure lookup: given a cache snapshot, what does it say about this key? + * Separated from I/O so the matching rule is testable without a filesystem. + */ +export function lookupCapability( + cache: CacheFile, + key: string, + fingerprint: string | undefined, +): boolean | undefined { + if (!fingerprint) { + return undefined; + } + const entry = cache[key]; + return entry?.fingerprint === fingerprint ? entry.value : undefined; +} + +/** Pure update: returns a new snapshot, never mutates the input. */ +export function withCapability(cache: CacheFile, key: string, entry: CacheEntry): CacheFile { + return { ...cache, [key]: entry }; +} + +export async function readCachedCapability( + key: string, + fingerprint: string | undefined, + homeDir?: string, +): Promise { + if (!fingerprint) { + return undefined; + } + return lookupCapability(await readCacheFile(homeDir), key, fingerprint); +} + +export async function writeCachedCapability( + key: string, + fingerprint: string | undefined, + value: boolean, + homeDir?: string, +): Promise { + if (!fingerprint) { + return; + } + try { + const filePath = cacheFilePath(homeDir); + const cache = withCapability(await readCacheFile(homeDir), key, { + fingerprint, + value, + recordedAt: new Date().toISOString(), + }); + await fs.mkdir(path.dirname(filePath), { recursive: true }); + // Write-then-rename so a concurrent reader never sees a partial file. + const tempPath = `${filePath}.${process.pid}.tmp`; + await fs.writeFile(tempPath, `${JSON.stringify(cache, null, 2)}\n`, "utf8"); + await fs.rename(tempPath, filePath); + } catch { + // Best effort: an unwritable cache must not fail the launch. + } +} diff --git a/src/acp/client.ts b/src/acp/client.ts index fca6ac34..cdacdce1 100644 --- a/src/acp/client.ts +++ b/src/acp/client.ts @@ -47,6 +47,7 @@ import { UnsupportedPromptContentError, } from "../errors.js"; import { FileSystemHandlers } from "../filesystem.js"; +import { measurePerf } from "../perf-metrics.js"; import { classifyPermissionDecision, decisionToResponse, @@ -136,6 +137,10 @@ const REPLAY_DRAIN_TIMEOUT_MS = 5_000; const DRAIN_POLL_INTERVAL_MS = 20; const AGENT_CLOSE_TERM_GRACE_MS = 1_500; const AGENT_CLOSE_KILL_GRACE_MS = 1_000; +// Read-only, single-shot commands do not need to wait out an adapter that +// ignores stdin-end; escalation to SIGKILL still reaps the child. +const AGENT_CLOSE_FAST_TERM_GRACE_MS = 150; +const AGENT_CLOSE_FAST_KILL_GRACE_MS = 250; const STARTUP_STDERR_MAX_CHARS = 8_192; const DEVIN_COMPATIBILITY_CLIENT_CAPABILITIES_META = Object.freeze({ "cognition.ai/requestDiagnostics": true, @@ -632,11 +637,13 @@ export class AcpClient { const startupGeneration = ++this.lifecycleGeneration; this.closing = false; - const launch = await this.resolveAgentLaunchPlan(); + const launch = await measurePerf("acp.start.resolve_launch", () => + this.resolveAgentLaunchPlan(), + ); this.logAgentLaunch(launch); - await this.ensureLaunchSupport(launch); + await measurePerf("acp.start.launch_support", () => this.ensureLaunchSupport(launch)); this.assertStartupIsCurrent(startupGeneration); - const child = await this.spawnAgentProcess(launch); + const child = await measurePerf("acp.start.spawn", () => this.spawnAgentProcess(launch)); this.assertStartupIsCurrent(startupGeneration); this.agentStartedAt = isoNow(); this.lastAgentExit = undefined; @@ -673,14 +680,16 @@ export class AcpClient { ); const startupFailure = this.createStartupFailureWatcher(child, startupStderr); - await this.initializeAgentConnection({ - child, - connection, - startupFailure, - startupStderr, - launch, - startupGeneration, - }); + await measurePerf("acp.start.initialize", () => + this.initializeAgentConnection({ + child, + connection, + startupFailure, + startupStderr, + launch, + startupGeneration, + }), + ); } private async prepareForStart(): Promise { @@ -896,10 +905,14 @@ export class AcpClient { }), clientInfo: resolveClientInfo(launch.devinAcp), }); - const initialized = launch.geminiAcp - ? await withTimeout(initializePromise, resolveGeminiAcpStartupTimeoutMs()) - : await initializePromise; - await this.authenticateIfRequired(connection, initialized.authMethods ?? []); + const initialized = await measurePerf("acp.initialize.rpc", () => + launch.geminiAcp + ? withTimeout(initializePromise, resolveGeminiAcpStartupTimeoutMs()) + : initializePromise, + ); + await measurePerf("acp.initialize.authenticate", () => + this.authenticateIfRequired(connection, initialized.authMethods ?? []), + ); return initialized; } @@ -1496,6 +1509,12 @@ export class AcpClient { ): Promise { const processTree = this.agentProcessTree ?? createManagedProcessTree(child.pid, true); const stdinCloseGraceMs = resolveAgentCloseAfterStdinEndMs(this.options.agentCommand); + const termGraceMs = this.options.fastTeardown + ? AGENT_CLOSE_FAST_TERM_GRACE_MS + : AGENT_CLOSE_TERM_GRACE_MS; + const killGraceMs = this.options.fastTeardown + ? AGENT_CLOSE_FAST_KILL_GRACE_MS + : AGENT_CLOSE_KILL_GRACE_MS; const processTreeSnapshot = captureProcessTreePids(processTree, isChildProcessRunning(child)); this.endAgentStdin(child); await processTreeSnapshot; @@ -1504,22 +1523,10 @@ export class AcpClient { () => isChildProcessRunning(child), stdinCloseGraceMs, ); - exited = await this.killAgentIfRunning( - child, - processTree, - exited, - "SIGTERM", - AGENT_CLOSE_TERM_GRACE_MS, - ); + exited = await this.killAgentIfRunning(child, processTree, exited, "SIGTERM", termGraceMs); if (!exited) { - this.log(`agent did not exit after ${AGENT_CLOSE_TERM_GRACE_MS}ms; forcing SIGKILL`); - exited = await this.killAgentIfRunning( - child, - processTree, - exited, - "SIGKILL", - AGENT_CLOSE_KILL_GRACE_MS, - ); + this.log(`agent did not exit after ${termGraceMs}ms; forcing SIGKILL`); + exited = await this.killAgentIfRunning(child, processTree, exited, "SIGKILL", killGraceMs); } // Ensure stdio handles don't keep this process alive after close() returns. diff --git a/src/cli-core.ts b/src/cli-core.ts index f1663eb4..aaf61625 100644 --- a/src/cli-core.ts +++ b/src/cli-core.ts @@ -20,7 +20,8 @@ import { parseTtlSeconds, resolveOutputPolicy, } from "./cli/flags.js"; -import { createOutputFormatter, getTextErrorRemediationHints } from "./cli/output/output.js"; +// output.ts is ~1300 lines and is only reached on error/format paths, so it is +// loaded at its call sites rather than eagerly. 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"; @@ -379,6 +380,7 @@ function isTopLevelVersionRequest(argv: string[]): boolean { } async function emitJsonErrorEvent(error: NormalizedOutputError): Promise { + const { createOutputFormatter } = await import("./cli/output/output.js"); const formatter = createOutputFormatter("json", { jsonContext: { sessionId: "unknown", @@ -411,6 +413,7 @@ async function emitRequestedError( } if (outputPolicy.format === "quiet") { + const { createOutputFormatter } = await import("./cli/output/output.js"); const formatter = createOutputFormatter("quiet"); formatter.onError(normalized); formatter.flush(); @@ -420,6 +423,7 @@ async function emitRequestedError( if (!outputPolicy.suppressNonJsonStderr) { process.stderr.write(`${normalized.message}\n`); if (outputPolicy.format === "text") { + const { getTextErrorRemediationHints } = await import("./cli/output/output.js"); for (const hint of getTextErrorRemediationHints(normalized)) { process.stderr.write(`${hint}\n`); } diff --git a/src/cli/command-handlers.ts b/src/cli/command-handlers.ts index 2aba8a74..6e834244 100644 --- a/src/cli/command-handlers.ts +++ b/src/cli/command-handlers.ts @@ -3,6 +3,7 @@ import path from "node:path"; import { Command, InvalidArgumentError } from "commander"; import { isLegacyZedCodexAcpInvocation } from "../acp/codex-compat.js"; import { AgentSpawnError } from "../errors.js"; +import { measurePerf } from "../perf-metrics.js"; import { loadPermissionPolicySpec } from "../permission-policy.js"; import { mergePromptSourceWithText, @@ -11,7 +12,8 @@ import { textPrompt, } from "../prompt-content.js"; import { exportSession } from "../session/export.js"; -import { importSession } from "../session/import.js"; +// importSession is loaded at its call site: the archive validator builds zod +// schemas at module scope, and only `acpx sessions import` needs them. import { findGitRepositoryRoot, findSession, @@ -712,8 +714,14 @@ async function tryListAgentSessions( config: ResolvedAcpxConfig, ): Promise { const permissionMode = resolvePermissionMode(globalFlags, config.defaultPermissions); - const permissionPolicy = await resolvePermissionPolicyFromFlags(globalFlags); - const { listAgentSessions } = await loadSessionModule(); + const permissionPolicy = await measurePerf("sessions.list.permission_policy", () => + resolvePermissionPolicyFromFlags(globalFlags), + ); + // Deferring the session module moved the ACP SDK + zod off the startup path; + // this span is where that cost is now paid. + const { listAgentSessions } = await measurePerf("sessions.list.load_session_module", () => + loadSessionModule(), + ); try { return await listAgentSessions({ agentCommand: agent.agentCommand, @@ -758,10 +766,12 @@ export async function handleSessionsList( return; } - const [result, { printAgentSessionsByFormat }] = await Promise.all([ - tryListAgentSessions(agent, flags, globalFlags, config), - loadOutputRenderModule(), - ]); + const [result, { printAgentSessionsByFormat }] = await measurePerf("sessions.list.total", () => + Promise.all([ + tryListAgentSessions(agent, flags, globalFlags, config), + loadOutputRenderModule(), + ]), + ); if (!result || result === "spawn-failed") { if (result !== "spawn-failed" && (flags.cursor || flags.filterCwd)) { @@ -1112,6 +1122,7 @@ export async function handleSessionsImport( ): Promise { const globalFlags = resolveGlobalFlags(command, config); const agent = resolveAgentInvocation(explicitAgentName, globalFlags, config); + const { importSession } = await import("../session/import.js"); const result = await importSession(archivePath, { name: flags.name, newCwd: flags.destinationCwd ? path.resolve(globalFlags.cwd, flags.destinationCwd) : undefined, diff --git a/src/cli/compare-command.ts b/src/cli/compare-command.ts index 4555359e..1db06527 100644 --- a/src/cli/compare-command.ts +++ b/src/cli/compare-command.ts @@ -10,7 +10,10 @@ import { PromptInputValidationError, textPrompt, } from "../prompt-content.js"; -import { runOnce } from "../session/session.js"; +// Type-only: the value is imported at the call site so the ACP SDK stays out of +// the eager startup graph. This edge is part of a three-edge cut set — see also +// src/cli/flags.ts and src/cli/queue/owner-env.ts. +import type { runOnce } from "../session/session.js"; import type { AcpJsonRpcMessage, OutputErrorAcpPayload, @@ -356,6 +359,7 @@ async function runAgentForCompare(params: { try { const agent = resolveAgentInvocation(params.agentName, params.globalFlags, params.config); + const { runOnce } = await import("../session/session.js"); const result = await runOnce({ agentCommand: agent.agentCommand, agentArgv: agent.agentArgv, diff --git a/src/cli/flags.ts b/src/cli/flags.ts index d8b2ac6e..e1d453ab 100644 --- a/src/cli/flags.ts +++ b/src/cli/flags.ts @@ -9,7 +9,6 @@ import { 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, NON_INTERACTIVE_PERMISSION_POLICIES, @@ -21,6 +20,10 @@ import { type PermissionMode, } from "../types.js"; import type { ResolvedAcpxConfig } from "./config.js"; +// Import from the type-only leaf rather than the session barrel: the barrel +// re-exports the ACP client, which drags the ACP SDK (and zod) into the eager +// startup graph for the sake of one numeric constant. +import { DEFAULT_QUEUE_OWNER_TTL_MS } from "./session/contracts.js"; import { toTimerMilliseconds } from "./timer-duration.js"; export type PermissionFlags = { diff --git a/src/cli/queue/owner-env.ts b/src/cli/queue/owner-env.ts index a00147f8..c0629760 100644 --- a/src/cli/queue/owner-env.ts +++ b/src/cli/queue/owner-env.ts @@ -2,10 +2,7 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { parseOptionalMcpServers } from "../../mcp-servers.js"; -import { - runSessionQueueOwner, - type QueueOwnerRuntimeOptions, -} from "../session/queue-owner-runtime.js"; +import type { QueueOwnerRuntimeOptions } from "../session/queue-owner-runtime.js"; const QUEUE_OWNER_PAYLOAD_FILE_ENV = "ACPX_QUEUE_OWNER_PAYLOAD_FILE"; const QUEUE_OWNER_PAYLOAD_ENV = "ACPX_QUEUE_OWNER_PAYLOAD"; @@ -200,6 +197,9 @@ function assignSessionEnv( export async function runQueueOwnerFromEnv(env: NodeJS.ProcessEnv): Promise { const payload = await readQueueOwnerPayloadFromEnv(env); const options = parseQueueOwnerPayload(payload); + // Deferred: the queue-owner runtime drags in the ACP SDK (and zod), which no + // other CLI entry path needs. Only the detached owner process reaches here. + const { runSessionQueueOwner } = await import("../session/queue-owner-runtime.js"); await runSessionQueueOwner(options); } diff --git a/src/cli/session/session-management.ts b/src/cli/session/session-management.ts index 325b6461..2060ebfc 100644 --- a/src/cli/session/session-management.ts +++ b/src/cli/session/session-management.ts @@ -2,6 +2,7 @@ import { AcpClient, type SessionCreateResult } from "../../acp/client.js"; import { formatErrorMessage } from "../../acp/error-normalization.js"; import { modelStateFromConfigOptions } from "../../acp/model-support.js"; import { withInterrupt, withTimeout } from "../../async-control.js"; +import { measurePerf } from "../../perf-metrics.js"; import { applyLifecycleSnapshotToRecord } from "../../runtime/engine/lifecycle.js"; import { persistSessionOptions } from "../../runtime/engine/session-options.js"; import { applyConfigOptionsToRecord } from "../../session/config-options.js"; @@ -247,23 +248,31 @@ export async function listAgentSessions(options: SessionListOptions): Promise { - await withTimeout(client.start(), options.timeoutMs); + await measurePerf("sessions.list.client_start", () => + withTimeout(client.start(), options.timeoutMs), + ); if (!client.supportsListSessions()) { return undefined; } const cwd = options.filterCwd ? absolutePath(options.filterCwd) : undefined; - const response = await withTimeout( - client.listSessions({ - ...(cwd ? { cwd } : {}), - ...(options.cursor ? { cursor: options.cursor } : {}), - }), - options.timeoutMs, + const response = await measurePerf("sessions.list.rpc", () => + withTimeout( + client.listSessions({ + ...(cwd ? { cwd } : {}), + ...(options.cursor ? { cursor: options.cursor } : {}), + }), + options.timeoutMs, + ), ); return { @@ -280,7 +289,7 @@ export async function listAgentSessions(options: SessionListOptions): Promise client.close()); } } diff --git a/src/types.ts b/src/types.ts index 40e8edca..bfaba439 100644 --- a/src/types.ts +++ b/src/types.ts @@ -214,6 +214,14 @@ export type AcpClientOptions = { terminal?: boolean; suppressSdkConsoleErrors?: boolean; verbose?: boolean; + /** + * Shorten the shutdown grace ladder. Only safe for read-only, single-shot + * operations (e.g. `sessions list`) where the agent holds no state worth + * flushing: adapters that ignore stdin-end otherwise cost the full SIGTERM + * grace on every invocation. Escalation to SIGKILL is unchanged, so the child + * is still reaped either way. + */ + fastTeardown?: boolean; sessionOptions?: { model?: string; allowedTools?: string[]; From 239e424529ef30f357d7fdb8547dac8e60225fec Mon Sep 17 00:00:00 2001 From: trumpyla Date: Thu, 30 Jul 2026 15:28:45 -0400 Subject: [PATCH 36/57] fix(ci): allow src/acp to import perf metrics Source-Commit: d25c86a23be4756121c5cfab4358145e70fa821c --- scripts/check-eager-graph.mjs | 4 +++- slophammer.yml | 5 +++++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/scripts/check-eager-graph.mjs b/scripts/check-eager-graph.mjs index 41f4f744..ca021888 100644 --- a/scripts/check-eager-graph.mjs +++ b/scripts/check-eager-graph.mjs @@ -43,7 +43,9 @@ function packageNameOf(specifier) { } if (!fs.existsSync(entry)) { - console.error(`check-eager-graph: ${path.relative(repoRoot, entry)} not found — run the build first.`); + console.error( + `check-eager-graph: ${path.relative(repoRoot, entry)} not found — run the build first.`, + ); process.exit(2); } diff --git a/slophammer.yml b/slophammer.yml index 4a0a03a5..accf41bf 100644 --- a/slophammer.yml +++ b/slophammer.yml @@ -29,6 +29,11 @@ typescript: - src/async-control - src/errors - src/filesystem + # Instrumentation is cross-cutting and already reachable from src/cli, + # src/session, and src/runtime. The connect path is where startup + # latency is actually spent, so leaving src/acp out would measure + # everything except the part that matters. + - src/perf-metrics - src/permission-prompt - src/permissions - src/prompt-content From 59f2d55e4c1e69839f8ed228b26b57412833fc4e Mon Sep 17 00:00:00 2001 From: trumpyla Date: Thu, 30 Jul 2026 15:56:04 -0400 Subject: [PATCH 37/57] fix: preserve live owners and publish locks atomically Source-Commit: d4459336b4fc8884fab220b5a945ab16c5da271e --- src/acp/client.ts | 8 ++ src/cli/queue/ipc.ts | 13 +++- src/cli/queue/lease-store.ts | 50 +++++++----- src/cli/session/queue-owner-runtime.ts | 8 +- src/cli/session/session-control.ts | 18 ++++- src/session/persistence/write-lock.ts | 20 +++-- test/client.test.ts | 18 +++++ test/compare-command.test.ts | 14 ++-- test/queue-ipc-errors.test.ts | 58 ++++++++------ test/queue-lease-store.test.ts | 21 +++-- test/queue-owner-lifecycle.test.ts | 101 ++++++++++++++++++++++++- test/session-persistence.test.ts | 97 ++++++++++++++++++++++++ 12 files changed, 350 insertions(+), 76 deletions(-) diff --git a/src/acp/client.ts b/src/acp/client.ts index cdacdce1..30bc3b0b 100644 --- a/src/acp/client.ts +++ b/src/acp/client.ts @@ -631,9 +631,11 @@ export class AcpClient { } async start(): Promise { + const requestedGeneration = this.lifecycleGeneration; if (!(await this.prepareForStart())) { return; } + this.assertStartupRequestIsCurrent(requestedGeneration); const startupGeneration = ++this.lifecycleGeneration; this.closing = false; @@ -705,6 +707,12 @@ export class AcpClient { return true; } + private assertStartupRequestIsCurrent(requestedGeneration: number): void { + if (this.closing || requestedGeneration !== this.lifecycleGeneration) { + throw new Error("ACP client closed during startup"); + } + } + private assertStartupIsCurrent(startupGeneration: number): void { if (this.closing || startupGeneration !== this.lifecycleGeneration) { throw new Error("ACP client closed during startup"); diff --git a/src/cli/queue/ipc.ts b/src/cli/queue/ipc.ts index f64a3be4..6522dcf0 100644 --- a/src/cli/queue/ipc.ts +++ b/src/cli/queue/ipc.ts @@ -44,10 +44,12 @@ export { QUEUE_CONNECT_RETRY_MS } from "./ipc-transport.js"; export const MAX_MESSAGE_BUFFER_SIZE = 10 * 1024 * 1024; export { isProcessAlive, + readQueueOwnerRecord, releaseQueueOwnerLease, terminateProcess, terminateQueueOwnerForSession, tryAcquireQueueOwnerLease, + waitForQueueOwnerGenerationRelease, waitMs, } from "./lease-store.js"; export type { QueueOwnerLease } from "./lease-store.js"; @@ -78,9 +80,14 @@ async function maybeRecoverStaleOwnerAfterProtocolMismatch(params: { return false; } - await terminateQueueOwnerForSession(params.sessionId).catch(() => { - // Preserve existing behavior if cleanup fails. - }); + if (await ensureOwnerIsUsable(params.sessionId, params.owner)) { + return false; + } + try { + await terminateQueueOwnerForSession(params.sessionId); + } catch { + return false; + } incrementPerfCounter("queue.owner.stale_recovered"); if (params.verbose) { diff --git a/src/cli/queue/lease-store.ts b/src/cli/queue/lease-store.ts index 5e2ab2c5..7c42897b 100644 --- a/src/cli/queue/lease-store.ts +++ b/src/cli/queue/lease-store.ts @@ -14,6 +14,8 @@ const QUEUE_OWNER_CLEANUP_CLAIM_WAIT_MS = 1_000; const QUEUE_OWNER_CLEANUP_CLAIM_POLL_MS = 10; const QUEUE_OWNER_STALE_HEARTBEAT_MS = 15_000; const QUEUE_OWNER_MALFORMED_LOCK_STALE_MS = QUEUE_OWNER_STALE_HEARTBEAT_MS; +const QUEUE_OWNER_RELEASE_WAIT_MS = 6_500; +const QUEUE_OWNER_RELEASE_POLL_MS = 50; export type QueueOwnerRecord = { pid: number; @@ -243,7 +245,7 @@ async function resolveQueueOwnerForCleanup( return false; } const processState = await queueOwnerProcessState(currentOwner); - return ownerShouldBePreserved(processState, currentOwner) ? false : currentOwner; + return ownerShouldBePreserved(processState) ? false : currentOwner; } async function claimQueueOwnerLockForCleanup( @@ -533,25 +535,14 @@ async function queueOwnerProcessState(owner: QueueOwnerRecord): Promise { const processState = await queueOwnerProcessState(owner); - if (ownerMayStillBeUsable(processState, owner)) { + if (ownerMayStillBeUsable(processState)) { return true; } - if (ownerShouldBePreserved(processState, owner)) { + if (ownerShouldBePreserved(processState)) { return false; } @@ -815,7 +806,7 @@ async function handleLeaseCollision(sessionId: string, error: unknown): Promise< return false; } - if (ownerShouldBePreserved(await queueOwnerProcessState(owner), owner)) { + if (ownerShouldBePreserved(await queueOwnerProcessState(owner))) { return false; } return await retireStaleQueueOwner(sessionId, owner); @@ -985,6 +976,23 @@ export async function terminateQueueOwnerForSession(sessionId: string): Promise< } } +export async function waitForQueueOwnerGenerationRelease( + sessionId: string, + ownerGeneration: number, + timeoutMs = QUEUE_OWNER_RELEASE_WAIT_MS, +): Promise { + const deadline = Date.now() + Math.max(0, timeoutMs); + while (Date.now() <= deadline) { + const owner = await readQueueOwnerRecord(sessionId); + if (!owner || owner.ownerGeneration !== ownerGeneration) { + return true; + } + await waitMs(QUEUE_OWNER_RELEASE_POLL_MS); + } + const owner = await readQueueOwnerRecord(sessionId); + return !owner || owner.ownerGeneration !== ownerGeneration; +} + export async function waitMs(ms: number): Promise { await new Promise((resolve) => { setTimeout(resolve, ms); diff --git a/src/cli/session/queue-owner-runtime.ts b/src/cli/session/queue-owner-runtime.ts index 140548a9..dc367c29 100644 --- a/src/cli/session/queue-owner-runtime.ts +++ b/src/cli/session/queue-owner-runtime.ts @@ -444,7 +444,13 @@ export async function runSessionQueueOwner(options: QueueOwnerRuntimeOptions): P await applyPendingCancel(); return true; }, - closeSession: async (timeoutMs?: number) => await closeActiveBackendSession(timeoutMs), + closeSession: async (timeoutMs?: number) => { + const closed = await closeActiveBackendSession(timeoutMs); + setImmediate(() => { + shutdown.request(); + }); + return closed; + }, setSessionMode: async (modeId: string, timeoutMs?: number) => { await turnController.setSessionMode(modeId, timeoutMs); }, diff --git a/src/cli/session/session-control.ts b/src/cli/session/session-control.ts index c8359b1e..9ad145f2 100644 --- a/src/cli/session/session-control.ts +++ b/src/cli/session/session-control.ts @@ -20,12 +20,14 @@ import type { SessionSetModeResult, } from "../../types.js"; import { + readQueueOwnerRecord, terminateQueueOwnerForSession, tryCancelOnRunningOwner, tryCloseSessionOnRunningOwner, trySetConfigOptionOnRunningOwner, trySetModelOnRunningOwner, trySetModeOnRunningOwner, + waitForQueueOwnerGenerationRelease, } from "../queue/ipc.js"; import type { SessionCancelOptions, @@ -171,9 +173,17 @@ export const sessionControlTestInternals = { firstAgentCommandToken, splitComman export async function closeSession(sessionId: string): Promise { 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); + const queueOwner = await readQueueOwnerRecord(record.acpxRecordId); + if (queueOwner) { + const closeResult = await tryCloseSessionOnRunningOwner({ + sessionId: record.acpxRecordId, + }).catch(() => undefined); + if ( + closeResult === undefined || + !(await waitForQueueOwnerGenerationRelease(record.acpxRecordId, queueOwner.ownerGeneration)) + ) { + await terminateQueueOwnerForSession(record.acpxRecordId); + } + } return await closePersistedSession(record.acpxRecordId); } diff --git a/src/session/persistence/write-lock.ts b/src/session/persistence/write-lock.ts index f0905cdd..769d2dd4 100644 --- a/src/session/persistence/write-lock.ts +++ b/src/session/persistence/write-lock.ts @@ -83,18 +83,26 @@ async function tryCreateSessionWriteLock( lockPath: string, lockRecord: SessionWriteLockRecord, ): Promise { + const tempPath = `${lockPath}.${process.pid}.${lockRecord.lockId}.tmp`; try { - await fs.writeFile(lockPath, `${JSON.stringify(lockRecord)}\n`, { + await fs.writeFile(tempPath, `${JSON.stringify(lockRecord)}\n`, { encoding: "utf8", flag: "wx", mode: 0o600, }); - return true; - } catch (error) { - if ((error as NodeJS.ErrnoException).code === "EEXIST") { - return false; + try { + await fs.link(tempPath, lockPath); + return true; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "EEXIST") { + return false; + } + throw error; } - throw error; + } finally { + await fs.rm(tempPath, { force: true }).catch(() => { + // best-effort cleanup after publication or contention + }); } } diff --git a/test/client.test.ts b/test/client.test.ts index e823c62b..5aa036fc 100644 --- a/test/client.test.ts +++ b/test/client.test.ts @@ -1401,6 +1401,24 @@ test("AcpClient close records the adapter exit while initialization is still pen } }); +test("AcpClient close cancels a start request before launch preparation resumes", async () => { + const client = makeClient(); + const internals = asInternals(client) as ReturnType & { + resolveAgentLaunchPlan: () => Promise; + }; + let launchPreparationCalls = 0; + internals.resolveAgentLaunchPlan = async () => { + launchPreparationCalls += 1; + throw new Error("launch preparation must not run after close"); + }; + + const startResult = client.start(); + await client.close(); + + await assert.rejects(startResult, /closed during startup/u); + assert.equal(launchPreparationCalls, 0); +}); + test("AcpClient close resets in-memory state and shuts down terminal manager", async () => { const client = makeClient(); const internals = asInternals(client); diff --git a/test/compare-command.test.ts b/test/compare-command.test.ts index 2ef61f60..13e4c2a8 100644 --- a/test/compare-command.test.ts +++ b/test/compare-command.test.ts @@ -171,8 +171,10 @@ class CompareAgent { return { stopReason: "end_turn" }; } - const delay = mode === "slow" ? 1200 : 10; - await sleep(delay); + const delay = mode === "timeout-slow" ? 60_000 : mode === "slow" ? 1200 : 10; + if (mode !== "timeout-fast") { + await sleep(delay); + } await this.connection.sessionUpdate({ sessionId: params.sessionId, update: { @@ -221,6 +223,8 @@ async function writeCompareConfig(homeDir: string, agentPath: string): Promise { await withTempHome(async (homeDir) => { const cwd = await setupCompareFixture(homeDir); const result = await runCli( - ["compare", "fast", "slow", "--timeout", "0.5", "--json", "summarize"], + ["compare", "timeout-fast", "timeout-slow", "--timeout", "2", "--json", "summarize"], homeDir, cwd, ); assert.equal(result.code, 3, result.stderr); const rows = JSON.parse(result.stdout) as CompareRow[]; - assert.equal(rows.find((row) => row.agent === "fast")?.status, "ok"); - assert.equal(rows.find((row) => row.agent === "slow")?.status, "cancelled"); + assert.equal(rows.find((row) => row.agent === "timeout-fast")?.status, "ok"); + assert.equal(rows.find((row) => row.agent === "timeout-slow")?.status, "cancelled"); }); }); diff --git a/test/queue-ipc-errors.test.ts b/test/queue-ipc-errors.test.ts index 1e8db341..1484d0e2 100644 --- a/test/queue-ipc-errors.test.ts +++ b/test/queue-ipc-errors.test.ts @@ -755,7 +755,7 @@ test("SessionQueueOwner rejects no-wait prompts when queue depth exceeds the lim }); }); -test("trySubmitToRunningOwner clears stale owner lock on protocol mismatch", async () => { +test("trySubmitToRunningOwner preserves a live owner after protocol mismatch", async () => { await withTempHome(async (homeDir) => { const sessionId = "submit-stale-owner-protocol-mismatch"; const keeper = await startKeeperProcess(); @@ -790,15 +790,19 @@ test("trySubmitToRunningOwner clears stale owner lock on protocol mismatch", asy await listenServer(server, socketPath); try { - const outcome = await trySubmitToRunningOwner({ - sessionId, - message: "hello", - permissionMode: "approve-reads", - outputFormatter: NOOP_OUTPUT_FORMATTER, - waitForCompletion: true, - }); - assert.equal(outcome, undefined); - await assert.rejects(fs.access(lockPath)); + await assert.rejects( + async () => + await trySubmitToRunningOwner({ + sessionId, + message: "hello", + permissionMode: "approve-reads", + outputFormatter: NOOP_OUTPUT_FORMATTER, + waitForCompletion: true, + }), + QueueProtocolError, + ); + await fs.access(lockPath); + assert.equal(keeper.exitCode == null && keeper.signalCode == null, true); } finally { await closeServer(server); await cleanupOwnerArtifacts({ socketPath, lockPath }); @@ -1010,7 +1014,7 @@ test("trySubmitToRunningOwner rejects MCP config changes for a live owner", asyn }); }); -test("trySubmitToRunningOwner recovers stale legacy owners before MCP conflict checks", async () => { +test("trySubmitToRunningOwner preserves stale live legacy owners", async () => { await withTempHome(async (homeDir) => { const sessionId = "submit-stale-mcp-config-owner"; const keeper = await startKeeperProcess(); @@ -1026,20 +1030,26 @@ test("trySubmitToRunningOwner recovers stale legacy owners before MCP conflict c }); try { - const outcome = await trySubmitToRunningOwner({ - sessionId, - message: "hello", - mcpConfigPath: "/tmp/new-mcp.json", - mcpConfigFingerprint: "fingerprint-v2", - permissionMode: "approve-reads", - outputFormatter: NOOP_OUTPUT_FORMATTER, - waitForCompletion: true, - }); - assert.equal(outcome, undefined); - await assert.rejects(fs.access(lockPath)); - // Legacy leases have no process identity. Retire the stale lease so the - // request can proceed, but do not signal a PID that may have been reused. + await assert.rejects( + async () => + await trySubmitToRunningOwner({ + sessionId, + message: "hello", + mcpConfigPath: "/tmp/new-mcp.json", + mcpConfigFingerprint: "fingerprint-v2", + permissionMode: "approve-reads", + outputFormatter: NOOP_OUTPUT_FORMATTER, + waitForCompletion: true, + }), + (error: unknown) => { + assert(error instanceof QueueConnectionError); + assert.equal(error.detailCode, "QUEUE_MCP_CONFIG_CONFLICT"); + return true; + }, + ); + await fs.access(lockPath); assert.equal(keeper.exitCode == null && keeper.signalCode == null, true); + assert.equal(await tryAcquireQueueOwnerLease(sessionId), undefined); } finally { await cleanupOwnerArtifacts({ socketPath, lockPath }); stopProcess(keeper); diff --git a/test/queue-lease-store.test.ts b/test/queue-lease-store.test.ts index 0a480f11..5ba8f6f6 100644 --- a/test/queue-lease-store.test.ts +++ b/test/queue-lease-store.test.ts @@ -758,7 +758,7 @@ test("readQueueOwnerStatus returns live owner details for a healthy owner", asyn }); }); -test("ensureOwnerIsUsable cleans up stale live owners", async (t) => { +test("ensureOwnerIsUsable preserves stale live owners", async (t) => { await withTempHome(async (homeDir) => { const sessionId = "stale-live-owner"; const keeper = await startKeeperProcess(); @@ -781,16 +781,16 @@ test("ensureOwnerIsUsable cleans up stale live owners", async (t) => { const owner = await readQueueOwnerRecord(sessionId); assert(owner); - assert.equal(await ensureOwnerIsUsable(sessionId, owner), false); - assert.equal(await readQueueOwnerRecord(sessionId), undefined); - assert.equal(isProcessAlive(keeper.pid), false); + assert.equal(await ensureOwnerIsUsable(sessionId, owner), true); + assert.equal((await readQueueOwnerRecord(sessionId))?.pid, keeper.pid); + assert.equal(isProcessAlive(keeper.pid), true); } finally { stopProcess(keeper); } }); }); -test("stale cleanup preserves an owner refreshed before its cleanup claim", async () => { +test("stale live owners remain usable while their lease refreshes", async () => { await withTempHome(async (homeDir) => { const sessionId = "stale-owner-refreshed-before-claim"; const keeper = await startKeeperProcess(); @@ -825,7 +825,7 @@ test("stale cleanup preserves an owner refreshed before its cleanup claim", asyn ); await fs.rename(refreshedPath, lockPath); - assert.equal(await cleanup, false); + assert.equal(await cleanup, true); assert.equal(isProcessAlive(keeper.pid), true); assert.equal((await readQueueOwnerStatus(sessionId))?.alive, true); } finally { @@ -839,7 +839,7 @@ test("stale cleanup preserves an owner refreshed before its cleanup claim", asyn }); }); -test("tryAcquireQueueOwnerLease terminates stale live owners and acquires in the same attempt", async (t) => { +test("tryAcquireQueueOwnerLease fails closed for stale live owners", async (t) => { await withTempHome(async (homeDir) => { const sessionId = "stale-live-owner-acquire"; const keeper = await startKeeperProcess(); @@ -861,10 +861,9 @@ test("tryAcquireQueueOwnerLease terminates stale live owners and acquires in the }); const lease = await tryAcquireQueueOwnerLease(sessionId); - assert(lease); - assert.equal((await readQueueOwnerRecord(sessionId))?.ownerGeneration, lease.ownerGeneration); - assert.equal(isProcessAlive(keeper.pid), false); - await releaseQueueOwnerLease(lease); + assert.equal(lease, undefined); + assert.equal((await readQueueOwnerRecord(sessionId))?.pid, keeper.pid); + assert.equal(isProcessAlive(keeper.pid), true); } finally { stopProcess(keeper); } diff --git a/test/queue-owner-lifecycle.test.ts b/test/queue-owner-lifecycle.test.ts index e2b152fd..a5f88f79 100644 --- a/test/queue-owner-lifecycle.test.ts +++ b/test/queue-owner-lifecycle.test.ts @@ -16,7 +16,7 @@ import path from "node:path"; import readline from "node:readline"; import { describe, it } from "node:test"; import { fileURLToPath, pathToFileURL } from "node:url"; -import { isProcessAlive } from "../src/cli/queue/lease-store.js"; +import { isProcessAlive, readQueueOwnerRecord } from "../src/cli/queue/lease-store.js"; import { queueLockFilePath, queueSocketPath } from "../src/cli/queue/paths.js"; import { makeSessionRecord, withTempHome, writeSessionRecordFile } from "./runtime-test-helpers.js"; @@ -80,6 +80,41 @@ async function waitForTerminalQueueMessage( throw new Error(`Queue result not received within ${timeoutMs}ms`); } +async function waitForQueueMessageType( + iterator: AsyncIterator, + expectedType: string, + timeoutMs = 5_000, +): Promise> { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + const remainingMs = Math.max(1, deadline - Date.now()); + let timer: NodeJS.Timeout | undefined; + try { + const line = await Promise.race([ + iterator.next(), + new Promise((_, reject) => { + timer = setTimeout( + () => reject(new Error(`timeout waiting for ${expectedType}`)), + remainingMs, + ); + }), + ]); + if (line.done) { + throw new Error(`queue socket closed before ${expectedType}`); + } + const message = JSON.parse(line.value) as Record; + if (message.type === expectedType) { + return message; + } + } finally { + if (timer) { + clearTimeout(timer); + } + } + } + throw new Error(`Queue message ${expectedType} not received within ${timeoutMs}ms`); +} + function waitForProcessExit( child: ReturnType, timeoutMs = 8_000, @@ -340,6 +375,70 @@ describe("queue owner lifecycle — graceful SIGTERM shutdown", () => { } }); }); + + it("exits and releases its lease after a close_session control request", async () => { + await withTempHome("acpx-lifecycle-close-request-", async (homeDir) => { + const cwd = path.join(homeDir, "workspace"); + await fs.mkdir(cwd, { recursive: true }); + + const record = makeSessionRecord({ + acpxRecordId: "lifecycle-close-request-test", + acpSessionId: "lifecycle-close-request-session", + agentCommand: `node ${JSON.stringify(MOCK_AGENT_PATH)}`, + cwd, + }); + await writeSessionRecordFile(homeDir, record); + + const socketPath = queueSocketPath(record.acpxRecordId, homeDir); + const lockPath = queueLockFilePath(record.acpxRecordId, homeDir); + const child = spawn(process.execPath, [CLI_PATH, "__queue-owner"], { + env: { + ...process.env, + HOME: homeDir, + ACPX_QUEUE_OWNER_PAYLOAD: JSON.stringify({ + sessionId: record.acpxRecordId, + permissionMode: "approve-reads", + }), + }, + stdio: ["ignore", "ignore", "pipe"], + }); + const stderrChunks: Buffer[] = []; + child.stderr?.on("data", (chunk: Buffer) => stderrChunks.push(chunk)); + let socket: net.Socket | undefined; + + try { + await waitUntil(() => fileExists(socketPath)); + const owner = await readQueueOwnerRecord(record.acpxRecordId); + assert(owner); + socket = await new Promise((resolve, reject) => { + const connection = net.createConnection(socketPath); + connection.once("connect", () => resolve(connection)); + connection.once("error", reject); + }); + const lines = readline.createInterface({ input: socket, crlfDelay: Infinity }); + socket.write( + `${JSON.stringify({ + type: "close_session", + requestId: "close-owner", + ownerGeneration: owner.ownerGeneration, + })}\n`, + ); + + await waitForQueueMessageType(lines[Symbol.asyncIterator](), "close_session_result"); + + const { code, signal } = await waitForProcessExit(child); + const stderr = Buffer.concat(stderrChunks).toString("utf8"); + assert.equal(signal, null, `queue owner should exit gracefully; stderr=${stderr}`); + assert.equal(code, 0, `expected queue owner exit code 0; stderr=${stderr}`); + assert.equal(await fileExists(lockPath), false); + } finally { + socket?.destroy(); + if (child.exitCode == null && child.signalCode == null) { + child.kill("SIGKILL"); + } + } + }); + }); }); describe("queue owner lifecycle — bridge process death on SIGTERM", () => { diff --git a/test/session-persistence.test.ts b/test/session-persistence.test.ts index 865bf238..21e22be1 100644 --- a/test/session-persistence.test.ts +++ b/test/session-persistence.test.ts @@ -690,6 +690,103 @@ test("writeSessionRecord recovers a stale cross-process write lock", async () => }); }); +test("writeSessionRecord recovers an old malformed write lock", async () => { + await withTempHome(async (homeDir) => { + const sessionDir = path.join(homeDir, ".acpx", "sessions"); + const lockPath = path.join(sessionDir, ".write.lock"); + await fs.mkdir(sessionDir, { recursive: true }); + await fs.writeFile(lockPath, '{"lockId":', "utf8"); + const oldTime = new Date(Date.now() - 180_000); + await fs.utimes(lockPath, oldTime, oldTime); + const repository = await import( + `${SESSION_REPOSITORY_URL.href}?malformed_write_lock_test=${Date.now()}-${Math.random()}` + ); + + await repository.writeSessionRecord( + makeSessionRecord({ + acpxRecordId: "malformed-lock-recovery", + acpSessionId: "malformed-lock-recovery", + agentCommand: "agent-a", + cwd: path.join(homeDir, "malformed-lock"), + }), + ); + + assert.equal(await fileExists(lockPath), false); + }); +}); + +test("session write locks are not visible until their complete record is published", async () => { + await withTempHome(async (homeDir) => { + const sessionDir = path.join(homeDir, "sessions"); + const lockPath = path.join(sessionDir, ".write.lock"); + await fs.mkdir(sessionDir, { recursive: true }); + const { withSessionWriteLock } = await import( + `${SESSION_WRITE_LOCK_URL.href}?atomic_publication=${Date.now()}-${Math.random()}` + ); + const originalWriteFile = fs.writeFile.bind(fs); + let releaseWrite: (() => void) | undefined; + const writeReleased = new Promise((resolve) => { + releaseWrite = resolve; + }); + let partialWriteReady: (() => void) | undefined; + const partialWriteStarted = new Promise((resolve) => { + partialWriteReady = resolve; + }); + let intercepted = false; + + Object.defineProperty(fs, "writeFile", { + configurable: true, + value: async ( + file: Parameters[0], + data: Parameters[1], + options?: Parameters[2], + ) => { + const fileName = + typeof file === "string" + ? path.basename(file) + : file instanceof URL + ? path.basename(file.pathname) + : Buffer.isBuffer(file) + ? path.basename(file.toString()) + : ""; + if (!intercepted && fileName.startsWith(".write.lock")) { + intercepted = true; + await originalWriteFile(file, '{"lockId":', options); + partialWriteReady?.(); + await writeReleased; + await originalWriteFile(file, data, { + encoding: "utf8", + flag: "w", + mode: 0o600, + }); + return; + } + return await originalWriteFile(file, data, options); + }, + }); + + const lockedOperation = withSessionWriteLock(sessionDir, async () => {}); + let publishedWhilePartial = false; + try { + await partialWriteStarted; + publishedWhilePartial = await fileExists(lockPath); + } finally { + Object.defineProperty(fs, "writeFile", { + configurable: true, + value: originalWriteFile, + }); + releaseWrite?.(); + await lockedOperation; + } + + assert.equal(publishedWhilePartial, false); + assert.equal( + (await fs.readdir(sessionDir)).some((name) => name.startsWith(".write.lock.")), + false, + ); + }); +}); + test("session write lock reentrancy canonicalizes symlink aliases", async () => { await withTempHome(async (homeDir) => { const realSessionDir = path.join(homeDir, "real-sessions"); From 09e3da8bee7874e240c04e3d122b242039356dcf Mon Sep 17 00:00:00 2001 From: trumpyla Date: Sat, 1 Aug 2026 12:32:38 -0400 Subject: [PATCH 38/57] fix: close final lifecycle review races Source-Commit: 7fbcd54a929f2884a4a0e42be8639db315079e08 Source-Scope: performance --- src/acp/client.ts | 2 +- src/acp/process-tree.ts | 22 ++++++++++++--- src/acp/terminal-manager.ts | 4 +-- src/cli/session/session-control.ts | 4 ++- src/session/persistence/write-lock.ts | 39 +++++++++++++++++++++++++++ test/process-tree.test.ts | 34 +++++++++++++++++++++++ test/session-persistence.test.ts | 4 +++ 7 files changed, 102 insertions(+), 7 deletions(-) diff --git a/src/acp/client.ts b/src/acp/client.ts index 30bc3b0b..14dce716 100644 --- a/src/acp/client.ts +++ b/src/acp/client.ts @@ -1564,7 +1564,7 @@ export class AcpClient { return true; } try { - await signalProcessTree(processTree, isChildProcessRunning(child), signal); + await signalProcessTree(processTree, () => isChildProcessRunning(child), signal); } catch { // best effort } diff --git a/src/acp/process-tree.ts b/src/acp/process-tree.ts index 48743e2f..d5bc71b6 100644 --- a/src/acp/process-tree.ts +++ b/src/acp/process-tree.ts @@ -17,6 +17,7 @@ export type ManagedProcessTree = { descendantIdentities: Map; rootIdentity?: string; rootIdentityFloor?: string; + rootRunning?: () => boolean; snapshotPromise?: Promise; }; @@ -65,6 +66,7 @@ export function beginProcessTreeTracking( if (!tree.killProcessGroup || !tree.rootPid) { return; } + tree.rootRunning = rootRunning; queueProcessTreeTracking(tree, rootRunning); } @@ -128,10 +130,18 @@ async function recordCurrentProcessTreePids(tree: ManagedProcessTree): Promise boolean), signal: NodeJS.Signals, ): Promise { const rootPid = tree.rootPid; @@ -158,13 +168,15 @@ export async function signalProcessTree( return; } - if (!rootRunning) { + let rootIsRunning = resolveRootRunning(rootRunning); + if (!rootIsRunning) { await captureProcessTreePids(tree, false); await refreshExitedProcessTreePids(tree); } else { await recordCurrentProcessTreePids(tree); } - for (const target of resolveProcessTreeSignalTargets(tree, rootRunning)) { + rootIsRunning = resolveRootRunning(rootRunning); + for (const target of resolveProcessTreeSignalTargets(tree, rootIsRunning)) { if (target.tree) { await killWindowsProcessTree(target.pid, signal); } else { @@ -173,6 +185,10 @@ export async function signalProcessTree( } } +function resolveRootRunning(rootRunning: boolean | (() => boolean)): boolean { + return typeof rootRunning === "function" ? rootRunning() : rootRunning; +} + export type ProcessTreeSignalTarget = { pid: number; tree: boolean; diff --git a/src/acp/terminal-manager.ts b/src/acp/terminal-manager.ts index 8b886fa4..5b4b6f51 100644 --- a/src/acp/terminal-manager.ts +++ b/src/acp/terminal-manager.ts @@ -463,7 +463,7 @@ export class TerminalManager { } try { - await signalProcessTree(terminal.processTree, this.isRunning(terminal), "SIGTERM"); + await signalProcessTree(terminal.processTree, () => this.isRunning(terminal), "SIGTERM"); } catch { return; } @@ -474,7 +474,7 @@ export class TerminalManager { } try { - await signalProcessTree(terminal.processTree, this.isRunning(terminal), "SIGKILL"); + await signalProcessTree(terminal.processTree, () => this.isRunning(terminal), "SIGKILL"); } catch { return; } diff --git a/src/cli/session/session-control.ts b/src/cli/session/session-control.ts index 9ad145f2..0aa823c5 100644 --- a/src/cli/session/session-control.ts +++ b/src/cli/session/session-control.ts @@ -185,5 +185,7 @@ export async function closeSession(sessionId: string): Promise { await terminateQueueOwnerForSession(record.acpxRecordId); } } - return await closePersistedSession(record.acpxRecordId); + const closedRecord = await closePersistedSession(record.acpxRecordId); + await terminateQueueOwnerForSession(record.acpxRecordId); + return closedRecord; } diff --git a/src/session/persistence/write-lock.ts b/src/session/persistence/write-lock.ts index 769d2dd4..edd14435 100644 --- a/src/session/persistence/write-lock.ts +++ b/src/session/persistence/write-lock.ts @@ -112,6 +112,33 @@ async function recoverStaleSessionWriteLock(lockPath: string): Promise return false; } + const recoveryPath = `${lockPath}.reaper-${observedStat.dev}-${observedStat.ino}`; + try { + await fs.mkdir(recoveryPath, { mode: 0o700 }); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "EEXIST") { + return false; + } + throw error; + } + + try { + return await recoverObservedStaleSessionWriteLock(lockPath, observedStat); + } finally { + await fs.rmdir(recoveryPath).catch(() => { + // A failed cleanup only blocks another reaper for this exact inode. + }); + } +} + +async function recoverObservedStaleSessionWriteLock( + lockPath: string, + observedStat: Stats, +): Promise { + if (!(await observedSessionWriteLockRemainsStale(lockPath, observedStat))) { + return false; + } + const quarantinePath = `${lockPath}.reap-${process.pid}-${randomUUID()}`; try { await fs.rename(lockPath, quarantinePath); @@ -135,6 +162,18 @@ async function recoverStaleSessionWriteLock(lockPath: string): Promise return true; } +async function observedSessionWriteLockRemainsStale( + lockPath: string, + observedStat: Stats, +): Promise { + const currentStat = await lstatIfPresent(lockPath); + return Boolean( + currentStat && + sameFileIdentity(observedStat, currentStat) && + (await sessionWriteLockIsStale(lockPath, currentStat)), + ); +} + export async function sessionWriteLockIsStale(lockPath: string, stat: Stats): Promise { const record = await readSessionWriteLockRecord(lockPath); if (!record) { diff --git a/test/process-tree.test.ts b/test/process-tree.test.ts index 52f72c42..92f16912 100644 --- a/test/process-tree.test.ts +++ b/test/process-tree.test.ts @@ -345,6 +345,40 @@ test( }, ); +test("process tracking discards a root identity captured after the child exits", async () => { + const fixtureDir = await fs.mkdtemp(path.join(os.tmpdir(), "acpx-exited-root-identity-")); + const powershellPath = path.join(fixtureDir, "powershell.exe"); + const originalPath = process.env.PATH; + await fs.writeFile( + powershellPath, + [ + "#!/bin/sh", + "sleep 0.1", + 'printf "500 1 1000\\n"', + `printf "${process.pid} 500 1100\\n"`, + ].join("\n"), + { mode: 0o755 }, + ); + process.env.PATH = `${fixtureDir}${path.delimiter}${originalPath ?? ""}`; + + try { + let rootRunning = true; + const tree = createManagedProcessTree(500, true, "win32", 0); + beginProcessTreeTracking(tree, () => rootRunning); + setTimeout(() => { + rootRunning = false; + }, 20); + + await tree.snapshotPromise; + + assert.equal(tree.rootIdentity, undefined); + assert.equal(tree.descendantPids.size, 0); + } finally { + process.env.PATH = originalPath; + await fs.rm(fixtureDir, { recursive: true, force: true }); + } +}); + test( "exited POSIX trees discard remembered PIDs whose identity changed", { skip: process.platform === "win32" }, diff --git a/test/session-persistence.test.ts b/test/session-persistence.test.ts index 21e22be1..d1b19ca8 100644 --- a/test/session-persistence.test.ts +++ b/test/session-persistence.test.ts @@ -687,6 +687,10 @@ test("writeSessionRecord recovers a stale cross-process write lock", async () => ); assert.equal(await fileExists(lockPath), false); + assert.equal( + (await fs.readdir(sessionDir)).some((name) => name.startsWith(".write.lock.reaper-")), + false, + ); }); }); From ed969f17977515adaba4154aff5a94bb1e8582c1 Mon Sep 17 00:00:00 2001 From: trumpyla Date: Fri, 31 Jul 2026 20:23:57 -0400 Subject: [PATCH 39/57] perf: resolve Windows capability commands through PATHEXT Source-Commit: e2804e2b5412be2705afb18e9bb4958e023f2d1b --- src/acp/agent-command.ts | 52 ++++-- src/acp/capability-cache.ts | 82 +++++++-- src/acp/client.ts | 12 +- src/spawn-command-options.ts | 178 +++++++++++++++----- test/capability-cache.test.ts | 131 +++++++++++++++ test/spawn-options.test.ts | 303 ++++++++++++++++++++++++++++++++++ 6 files changed, 689 insertions(+), 69 deletions(-) create mode 100644 test/capability-cache.test.ts diff --git a/src/acp/agent-command.ts b/src/acp/agent-command.ts index 8c4c5b2d..f53d6430 100644 --- a/src/acp/agent-command.ts +++ b/src/acp/agent-command.ts @@ -1,7 +1,7 @@ import { spawn } from "node:child_process"; import { CopilotAcpUnsupportedError } from "../errors.js"; import { - buildSpawnCommandOptions, + buildAgentSpawnCommand, readWindowsEnvValue, resolveWindowsExecutablePath, } from "../spawn-command-options.js"; @@ -27,6 +27,11 @@ type GeminiVersion = { parts: [number, number, number]; }; +type CommandExecutionContext = { + cwd?: string; + env?: NodeJS.ProcessEnv; +}; + const QODER_BENIGN_STDOUT_LINES = new Set([ "Received interrupt signal. Cleaning up resources...", "Cleanup completed. Exiting...", @@ -202,16 +207,19 @@ async function readCommandOutput( command: string, args: readonly string[], timeoutMs: number, + context: CommandExecutionContext = {}, ): Promise { return await new Promise((resolve) => { - const child = spawn( - command, - [...args], - buildSpawnCommandOptions(command, { - stdio: ["ignore", "pipe", "pipe"], - windowsHide: true, - }), - ); + const env = context.env ?? process.env; + const cwd = context.cwd ?? process.cwd(); + const spawnCommand = buildAgentSpawnCommand(command, args, process.platform, env, cwd); + const child = spawn(spawnCommand.command, spawnCommand.args, { + cwd, + env, + stdio: ["ignore", "pipe", "pipe"], + windowsHide: true, + windowsVerbatimArguments: spawnCommand.windowsVerbatimArguments, + }); let stdout = ""; let stderr = ""; @@ -276,13 +284,16 @@ export function buildClaudeAcpSessionCreateTimeoutMessage(): string { ].join(" "); } -async function buildCopilotAcpUnsupportedMessage(command: string): Promise { +async function buildCopilotAcpUnsupportedMessage( + command: string, + context: CommandExecutionContext, +): Promise { const parts = [ "GitHub Copilot CLI ACP stdio mode is not available in the installed copilot binary.", "acpx copilot expects a Copilot CLI release that supports --acp --stdio.", ]; - const helpOutput = await readCommandOutput(command, ["--help"], COPILOT_HELP_TIMEOUT_MS); + const helpOutput = await readCommandOutput(command, ["--help"], COPILOT_HELP_TIMEOUT_MS, context); if (typeof helpOutput === "string" && !helpOutput.includes("--acp")) { parts.push("Detected copilot --help output without --acp support."); } @@ -295,18 +306,26 @@ async function buildCopilotAcpUnsupportedMessage(command: string): Promise { +export async function ensureCopilotAcpSupport( + command: string, + context: CommandExecutionContext = {}, +): Promise { // `copilot --help` costs a whole extra process (~380ms measured) for an // answer that only changes when the binary does, so it is cached against a // fingerprint of that binary. - const fingerprint = await fingerprintExecutable(command); + const fingerprint = await fingerprintExecutable(command, context); const cached = await readCachedCapability(COPILOT_ACP_CAPABILITY_KEY, fingerprint); if (cached === true) { return; } if (cached === undefined) { - const helpOutput = await readCommandOutput(command, ["--help"], COPILOT_HELP_TIMEOUT_MS); + const helpOutput = await readCommandOutput( + command, + ["--help"], + COPILOT_HELP_TIMEOUT_MS, + context, + ); // Only a definite answer is cacheable: a timeout or spawn failure yields a // non-string result, which must not be recorded as "supported". if (typeof helpOutput === "string") { @@ -323,7 +342,7 @@ export async function ensureCopilotAcpSupport(command: string): Promise { } } - throw new CopilotAcpUnsupportedError(await buildCopilotAcpUnsupportedMessage(command), { + throw new CopilotAcpUnsupportedError(await buildCopilotAcpUnsupportedMessage(command, context), { retryable: false, }); } @@ -403,6 +422,7 @@ function isAppendSystemPrompt( export function resolveClaudeCodeExecutable( platform: NodeJS.Platform = process.platform, env: NodeJS.ProcessEnv = process.env, + cwd: string = process.cwd(), ): string | undefined { if (platform !== "win32") { return undefined; @@ -410,5 +430,5 @@ export function resolveClaudeCodeExecutable( if (readWindowsEnvValue(env, "CLAUDE_CODE_EXECUTABLE")) { return undefined; } - return resolveWindowsExecutablePath("claude", env); + return resolveWindowsExecutablePath("claude", env, cwd); } diff --git a/src/acp/capability-cache.ts b/src/acp/capability-cache.ts index eb3f0256..62d45a6b 100644 --- a/src/acp/capability-cache.ts +++ b/src/acp/capability-cache.ts @@ -3,6 +3,7 @@ import { constants as fsConstants } from "node:fs"; import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; +import { resolveWindowsCommand } from "../spawn-command-options.js"; /** * Cache for capability probes that shell out to an agent CLI. @@ -41,35 +42,92 @@ function cacheFilePath(homeDir: string = os.homedir()): string { * resolve them the same way before fingerprinting. Returns the input unchanged * when it already contains a separator. */ -async function resolveExecutablePath(command: string): Promise { - if (command.includes(path.sep) || command.includes("/")) { - return command; +export type ExecutableResolutionOptions = { + platform?: NodeJS.Platform; + env?: NodeJS.ProcessEnv; + cwd?: string; +}; + +async function isExecutableFile(candidate: string): Promise { + try { + const stats = await fs.stat(candidate); + if (!stats.isFile()) { + return false; + } + await fs.access(candidate, fsConstants.X_OK); + return true; + } catch { + return false; } - const pathEntries = (process.env.PATH ?? "").split(path.delimiter).filter(Boolean); +} + +async function firstExecutableCandidate( + pathEntries: readonly string[], + candidateNames: readonly string[], +): Promise { for (const entry of pathEntries) { - const candidate = path.join(entry, command); - try { - await fs.access(candidate, fsConstants.X_OK); - return candidate; - } catch { - // Not here; keep looking. + for (const candidateName of candidateNames) { + const candidate = path.join(entry, candidateName); + if (await isExecutableFile(candidate)) { + return candidate; + } } } return undefined; } +export async function resolveExecutablePath( + command: string, + options: ExecutableResolutionOptions = {}, +): Promise { + const platform = options.platform ?? process.platform; + const env = options.env ?? process.env; + const cwd = options.cwd ?? process.cwd(); + if (platform === "win32") { + return resolveWindowsCommand(command, env, cwd, false); + } + return await resolvePosixExecutablePath(command, env, cwd); +} + +async function resolvePosixExecutablePath( + command: string, + env: NodeJS.ProcessEnv, + cwd: string, +): Promise { + if (command.includes(path.sep) || command.includes("/")) { + return path.resolve(cwd, command); + } + const pathEntries = (env.PATH ?? "") + .split(path.delimiter) + .filter(Boolean) + .map((entry) => path.resolve(cwd, entry)); + return await firstExecutableCandidate(pathEntries, [command]); +} + /** * Identifies the exact binary a capability answer belongs to. Returns undefined * when the path cannot be resolved or stat'd, which forces a live probe. */ -export async function fingerprintExecutable(binaryPath: string): Promise { +export async function fingerprintExecutable( + binaryPath: string, + options: ExecutableResolutionOptions = {}, +): Promise { try { - const resolved = await resolveExecutablePath(binaryPath); + const platform = options.platform ?? process.platform; + if (platform === "win32") { + // Windows launchers may be stable, cwd-sensitive shims whose selected + // target cannot be inferred reliably from the launcher file itself. + return undefined; + } + const resolved = await resolveExecutablePath(binaryPath, options); if (!resolved) { return undefined; } const realPath = await fs.realpath(resolved); const stats = await fs.stat(realPath); + if (!stats.isFile()) { + return undefined; + } return createHash("sha256") .update(`${realPath}\0${stats.size}\0${stats.mtimeMs}`) .digest("hex") diff --git a/src/acp/client.ts b/src/acp/client.ts index 14dce716..65dedc39 100644 --- a/src/acp/client.ts +++ b/src/acp/client.ts @@ -766,12 +766,19 @@ export class AcpClient { private async ensureLaunchSupport(plan: AgentLaunchPlan): Promise { if (plan.copilotAcp) { - await ensureCopilotAcpSupport(plan.spawnCommand); + await ensureCopilotAcpSupport(plan.spawnCommand, { + cwd: plan.spawnOptions.cwd, + env: plan.spawnOptions.env, + }); } if (!plan.claudeAcp) { return; } - const claudeExe = resolveClaudeCodeExecutable(process.platform, plan.spawnOptions.env); + const claudeExe = resolveClaudeCodeExecutable( + process.platform, + plan.spawnOptions.env, + plan.spawnOptions.cwd, + ); if (claudeExe) { plan.spawnOptions.env.CLAUDE_CODE_EXECUTABLE = claudeExe; this.log(`resolved system Claude Code executable: ${claudeExe}`); @@ -786,6 +793,7 @@ export class AcpClient { plan.args, process.platform, plan.spawnOptions.env, + plan.spawnOptions.cwd, ); const rootCreatedAfterMs = Date.now(); const spawnedChild = spawn(spawnCommand.command, spawnCommand.args, { diff --git a/src/spawn-command-options.ts b/src/spawn-command-options.ts index 8db9e1cd..e56fd7ae 100644 --- a/src/spawn-command-options.ts +++ b/src/spawn-command-options.ts @@ -3,58 +3,127 @@ import fs from "node:fs"; import path from "node:path"; export function readWindowsEnvValue(env: NodeJS.ProcessEnv, key: string): string | undefined { - const matchedKey = Object.keys(env).find((entry) => entry.toUpperCase() === key); + // Node sorts Windows environment keys lexicographically before selecting the + // first case-insensitive match. Mirror that rule so command resolution and + // the eventual spawn observe the same value when differently cased keys + // coexist. + const normalizedKey = key.toUpperCase(); + const matchedKey = Object.keys(env) + .toSorted() + .find((entry) => entry.toUpperCase() === normalizedKey); return matchedKey ? env[matchedKey] : undefined; } -function windowsExecutableExtensions(env: NodeJS.ProcessEnv): string[] { +const WINDOWS_DIRECT_EXTENSIONS = new Set([".com", ".exe", ".bat", ".cmd"]); +const WINDOWS_NATIVE_WRAPPER_EXTENSIONS = new Set([".com", ".exe", ".bat", ".cmd", ".ps1"]); + +function windowsExecutableExtensions( + env: NodeJS.ProcessEnv, + supportedExtensions: ReadonlySet, +): string[] { return (readWindowsEnvValue(env, "PATHEXT") ?? ".COM;.EXE;.BAT;.CMD") .split(";") .map((value) => value.trim().toLowerCase()) - .filter((value) => value.length > 0); + .filter((value) => supportedExtensions.has(value)); } -function commandCandidates(command: string, env: NodeJS.ProcessEnv): string[] { +function commandCandidates( + command: string, + env: NodeJS.ProcessEnv, + supportedExtensions: ReadonlySet, +): string[] { const commandExtension = path.extname(command); if (commandExtension.length > 0) { return [command]; } - return windowsExecutableExtensions(env).map((extension) => `${command}${extension}`); + return windowsExecutableExtensions(env, supportedExtensions).map( + (extension) => `${command}${extension}`, + ); } function commandHasPath(command: string): boolean { return command.includes("/") || command.includes("\\") || path.isAbsolute(command); } -function resolveWindowsPathCommand(command: string, env: NodeJS.ProcessEnv): string | undefined { - const candidates = commandCandidates(command, env); - const pathValue = readWindowsEnvValue(env, "PATH"); - if (!pathValue) { +function windowsEnvHasKey(env: NodeJS.ProcessEnv, key: string): boolean { + return readWindowsEnvValue(env, key) !== undefined; +} + +function isWindowsPathQuote(character: string | undefined): character is '"' | "'" { + return character === '"' || character === "'"; +} + +function normalizeWindowsPathEntry(entry: string, cwd: string): string | undefined { + const start = isWindowsPathQuote(entry[0]) ? 1 : 0; + const end = isWindowsPathQuote(entry.at(-1)) ? -1 : entry.length; + const unquoted = entry.slice(start, end); + if (!unquoted) { return undefined; } + // Always resolve against the child cwd. On Windows, a root-relative entry + // such as `\tools` is absolute but still inherits the cwd drive. + return path.resolve(cwd, unquoted); +} - for (const directory of pathValue.split(";")) { - const resolved = findExistingCommandInDirectory(directory, candidates); - if (resolved) { - return resolved; +function splitWindowsPath(value: string): string[] { + const entries: string[] = []; + let entryStart = 0; + while (entryStart <= value.length) { + let separatorSearchStart = entryStart; + const quote = value[entryStart]; + if (isWindowsPathQuote(quote)) { + const closingQuote = value.indexOf(quote, entryStart + 1); + separatorSearchStart = closingQuote === -1 ? value.length : closingQuote; } + const separator = value.indexOf(";", separatorSearchStart); + if (separator === -1) { + entries.push(value.slice(entryStart)); + break; + } + entries.push(value.slice(entryStart, separator)); + entryStart = separator + 1; } + return entries; +} - return undefined; +function windowsSearchDirectories( + env: NodeJS.ProcessEnv, + cwd: string, + includeDefaultCurrentDirectory: boolean, +): string[] { + const configured = splitWindowsPath(readWindowsEnvValue(env, "PATH") ?? "") + .map((entry) => normalizeWindowsPathEntry(entry, cwd)) + .filter((entry): entry is string => entry !== undefined); + const searchesCurrentDirectory = + includeDefaultCurrentDirectory && !windowsEnvHasKey(env, "NODEFAULTCURRENTDIRECTORYINEXEPATH"); + return searchesCurrentDirectory ? [cwd, ...configured] : configured; } -function findExistingCommandInDirectory( - directory: string, - candidates: string[], -): string | undefined { - const trimmedDirectory = directory.trim(); - if (trimmedDirectory.length === 0) { - return undefined; +function windowsCommandPaths( + command: string, + env: NodeJS.ProcessEnv, + cwd: string, + includeDefaultCurrentDirectory: boolean, + supportedExtensions: ReadonlySet, +): string[] { + const candidates = commandCandidates(command, env, supportedExtensions); + if (commandHasPath(command)) { + return candidates.map((candidate) => path.resolve(cwd, candidate)); } - return candidates - .map((candidate) => path.join(trimmedDirectory, candidate)) - .find((resolved) => fs.existsSync(resolved)); + const paths: string[] = []; + for (const directory of windowsSearchDirectories(env, cwd, includeDefaultCurrentDirectory)) { + paths.push(...candidates.map((candidate) => path.join(directory, candidate))); + } + return paths; +} + +function isExistingFile(filePath: string): boolean { + try { + return fs.statSync(filePath).isFile(); + } catch { + return false; + } } function resolveWindowsWrapperToken(token: string, wrapperPath: string): string | undefined { @@ -90,14 +159,16 @@ function resolveWindowsWrapperExecutable(wrapperPath: string): string | undefine export function resolveWindowsCommand( command: string, env: NodeJS.ProcessEnv = process.env, + cwd: string = process.cwd(), + includeDefaultCurrentDirectory = true, ): string | undefined { - const candidates = commandCandidates(command, env); - - if (commandHasPath(command)) { - return candidates.find((candidate) => fs.existsSync(candidate)); - } - - return resolveWindowsPathCommand(command, env); + return windowsCommandPaths( + command, + env, + cwd, + includeDefaultCurrentDirectory, + WINDOWS_DIRECT_EXTENSIONS, + ).find((candidate) => isExistingFile(candidate)); } /** @@ -110,15 +181,27 @@ export function resolveWindowsCommand( export function resolveWindowsExecutablePath( command: string, env: NodeJS.ProcessEnv = process.env, + cwd: string = process.cwd(), ): string | undefined { - const resolved = resolveWindowsCommand(command, env); - if (!resolved) { - return undefined; + for (const candidate of windowsCommandPaths( + command, + env, + cwd, + false, + WINDOWS_NATIVE_WRAPPER_EXTENSIONS, + )) { + if (!isExistingFile(candidate)) { + continue; + } + return resolveNativeWindowsExecutable(candidate); } + return undefined; +} +function resolveNativeWindowsExecutable(resolved: string): string | undefined { const absolute = path.resolve(resolved); const extension = path.extname(absolute).toLowerCase(); - if (extension === ".exe") { + if (extension === ".com" || extension === ".exe") { return absolute; } if (extension !== ".cmd" && extension !== ".bat" && extension !== ".ps1") { @@ -135,11 +218,12 @@ function shouldUseWindowsBatchShell( command: string, platform: NodeJS.Platform = process.platform, env: NodeJS.ProcessEnv = process.env, + cwd: string = process.cwd(), ): boolean { if (platform !== "win32") { return false; } - const resolvedCommand = resolveWindowsCommand(command, env) ?? command; + const resolvedCommand = resolveWindowsCommand(command, env, cwd) ?? command; const ext = path.extname(resolvedCommand).toLowerCase(); return ext === ".cmd" || ext === ".bat"; } @@ -172,11 +256,26 @@ export function buildAgentSpawnCommand( args: readonly string[], platform: NodeJS.Platform = process.platform, env: NodeJS.ProcessEnv = process.env, + cwd: string = process.cwd(), ): AgentSpawnCommand { - if (!shouldUseWindowsBatchShell(command, platform, env)) { + if (platform !== "win32") { return { command, args: [...args] }; } - const resolvedCommand = path.win32.normalize(resolveWindowsCommand(command, env) ?? command); + return buildWindowsAgentSpawnCommand(command, args, env, cwd); +} + +function buildWindowsAgentSpawnCommand( + command: string, + args: readonly string[], + env: NodeJS.ProcessEnv, + cwd: string, +): AgentSpawnCommand { + const resolved = resolveWindowsCommand(command, env, cwd, false); + const resolvedCommand = path.win32.normalize(resolved ?? command); + const extension = path.extname(resolvedCommand).toLowerCase(); + if (extension !== ".cmd" && extension !== ".bat") { + return { command: resolved ?? command, args: [...args] }; + } const doubleEscapeMeta = CMD_SHIM_RE.test(resolvedCommand); const shellCommand = [ escapeCmdCommand(resolvedCommand), @@ -195,7 +294,8 @@ export function buildSpawnCommandOptions( platform: NodeJS.Platform = process.platform, env: NodeJS.ProcessEnv = process.env, ): Parameters[2] { - if (!shouldUseWindowsBatchShell(command, platform, env)) { + const cwd = typeof options.cwd === "string" ? options.cwd : process.cwd(); + if (!shouldUseWindowsBatchShell(command, platform, env, cwd)) { return options; } return { diff --git a/test/capability-cache.test.ts b/test/capability-cache.test.ts new file mode 100644 index 00000000..a32d3b0f --- /dev/null +++ b/test/capability-cache.test.ts @@ -0,0 +1,131 @@ +import assert from "node:assert/strict"; +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import test from "node:test"; +import { fingerprintExecutable, resolveExecutablePath } from "../src/acp/capability-cache.js"; + +async function writeExecutable(filePath: string): Promise { + await fs.writeFile(filePath, "test\n", { mode: 0o755 }); +} + +test("POSIX capability resolution applies the launch cwd to relative commands and PATH", async (t) => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "acpx-capability-cache-")); + t.after(async () => await fs.rm(root, { recursive: true, force: true })); + const cwd = path.join(root, "cwd"); + const bin = path.join(cwd, "bin"); + await fs.mkdir(bin, { recursive: true }); + await writeExecutable(path.join(cwd, "copilot")); + await writeExecutable(path.join(bin, "copilot")); + + assert.equal( + await resolveExecutablePath("./copilot", { + platform: "linux", + cwd, + env: { PATH: "" }, + }), + path.join(cwd, "copilot"), + ); + assert.equal( + await resolveExecutablePath("copilot", { + platform: "linux", + cwd, + env: { PATH: "bin" }, + }), + path.join(bin, "copilot"), + ); +}); + +test("Windows capability resolution does not let the working directory shadow PATH", async (t) => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "acpx-capability-cache-")); + t.after(async () => await fs.rm(root, { recursive: true, force: true })); + const cwd = path.join(root, "cwd"); + const bin = path.join(root, "bin"); + await fs.mkdir(cwd); + await fs.mkdir(bin); + await writeExecutable(path.join(cwd, "copilot")); + await writeExecutable(path.join(cwd, "copilot.cmd")); + await writeExecutable(path.join(bin, "copilot.exe")); + + assert.equal( + await resolveExecutablePath("copilot", { + platform: "win32", + cwd, + env: { Path: bin, Pathext: ".EXE;.CMD" }, + }), + path.join(bin, "copilot.exe"), + ); +}); + +test("Windows capability resolution normalizes quoted relative PATH entries", async (t) => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "acpx-capability-cache-")); + t.after(async () => await fs.rm(root, { recursive: true, force: true })); + const cwd = path.join(root, "cwd"); + const relativeBin = path.join(cwd, "tools"); + await fs.mkdir(cwd); + await fs.mkdir(relativeBin); + await writeExecutable(path.join(relativeBin, "copilot.cmd")); + + assert.equal( + await resolveExecutablePath("copilot", { + platform: "win32", + cwd, + env: { + Path: '"tools"', + Pathext: ".CMD", + NoDefaultCurrentDirectoryInExePath: "1", + }, + }), + path.join(relativeBin, "copilot.cmd"), + ); +}); + +test("Windows launcher shims bypass the executable capability cache", async (t) => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "acpx-capability-cache-")); + t.after(async () => await fs.rm(root, { recursive: true, force: true })); + await writeExecutable(path.join(root, "copilot.cmd")); + + assert.equal( + await fingerprintExecutable("copilot", { + platform: "win32", + cwd: root, + env: { PATH: root, PATHEXT: ".CMD" }, + }), + undefined, + ); +}); + +test("Windows native launchers bypass the executable capability cache", async (t) => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "acpx-capability-cache-")); + t.after(async () => await fs.rm(root, { recursive: true, force: true })); + await writeExecutable(path.join(root, "copilot.exe")); + + assert.equal( + await fingerprintExecutable("copilot", { + platform: "win32", + cwd: root, + env: { PATH: root, PATHEXT: ".EXE" }, + }), + undefined, + ); +}); + +test("capability resolution skips directories that look executable", async (t) => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "acpx-capability-cache-")); + t.after(async () => await fs.rm(root, { recursive: true, force: true })); + const first = path.join(root, "first"); + const second = path.join(root, "second"); + await fs.mkdir(first); + await fs.mkdir(second); + await fs.mkdir(path.join(first, "copilot.exe")); + await writeExecutable(path.join(second, "copilot.exe")); + + assert.equal( + await resolveExecutablePath("copilot", { + platform: "win32", + cwd: root, + env: { PATH: `${first};${second}`, PATHEXT: ".EXE" }, + }), + path.join(second, "copilot.exe"), + ); +}); diff --git a/test/spawn-options.test.ts b/test/spawn-options.test.ts index dabaf478..40705361 100644 --- a/test/spawn-options.test.ts +++ b/test/spawn-options.test.ts @@ -1,5 +1,6 @@ import assert from "node:assert/strict"; import { spawn } from "node:child_process"; +import fsSync from "node:fs"; import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; @@ -13,6 +14,7 @@ import { buildAgentSpawnCommand, buildTerminalShellSpawnCommand, buildTerminalSpawnCommand, + resolveWindowsCommand, resolveWindowsExecutablePath, } from "../src/spawn-command-options.js"; @@ -301,6 +303,176 @@ test("buildAgentSpawnCommand normalizes forward-slash batch paths for cmd.exe", }); }); +test("buildAgentSpawnCommand pins PATH executables instead of task-directory shims", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "acpx-windows-agent-spawn-")); + const cwd = path.join(root, "cwd"); + const bin = path.join(root, "bin"); + try { + await fs.mkdir(cwd); + await fs.mkdir(bin); + await fs.writeFile(path.join(cwd, "copilot.cmd"), "@echo off\r\n"); + const executable = path.join(bin, "copilot.exe"); + await fs.writeFile(executable, ""); + const command = buildAgentSpawnCommand( + "copilot", + ["--acp", "--stdio"], + "win32", + { PATH: bin, PATHEXT: ".EXE;.CMD" }, + cwd, + ); + + assert.deepEqual(command, { + command: executable, + args: ["--acp", "--stdio"], + }); + } finally { + await fs.rm(root, { recursive: true, force: true }); + } +}); + +test("buildAgentSpawnCommand matches Node's Windows env-key collision ordering", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "acpx-windows-agent-spawn-")); + const inheritedBin = path.join(root, "inherited"); + const overrideBin = path.join(root, "override"); + try { + await fs.mkdir(inheritedBin); + await fs.mkdir(overrideBin); + await fs.writeFile(path.join(inheritedBin, "copilot.exe"), ""); + const overrideExecutable = path.join(overrideBin, "copilot.exe"); + await fs.writeFile(overrideExecutable, ""); + const command = buildAgentSpawnCommand( + "copilot", + ["--acp", "--stdio"], + "win32", + { Path: inheritedBin, PATH: overrideBin, PATHEXT: ".EXE" }, + root, + ); + + assert.deepEqual(command, { + command: overrideExecutable, + args: ["--acp", "--stdio"], + }); + } finally { + await fs.rm(root, { recursive: true, force: true }); + } +}); + +test("buildAgentSpawnCommand skips unsupported implicit PATHEXT scripts", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "acpx-windows-agent-spawn-")); + const scripts = path.join(root, "scripts"); + const bin = path.join(root, "bin"); + try { + await fs.mkdir(scripts); + await fs.mkdir(bin); + await fs.writeFile(path.join(scripts, "copilot.ps1"), "exit 0\n"); + const executable = path.join(bin, "copilot.exe"); + await fs.writeFile(executable, ""); + const command = buildAgentSpawnCommand( + "copilot", + ["--acp", "--stdio"], + "win32", + { PATH: `${scripts};${bin}`, PATHEXT: ".PS1;.EXE" }, + root, + ); + + assert.deepEqual(command, { + command: executable, + args: ["--acp", "--stdio"], + }); + } finally { + await fs.rm(root, { recursive: true, force: true }); + } +}); + +test("Windows root-relative PATH entries inherit the child cwd drive", (t) => { + const expected = "D:\\tools\\copilot.exe"; + t.mock.method(path, "isAbsolute", path.win32.isAbsolute); + t.mock.method(path, "resolve", path.win32.resolve); + t.mock.method(path, "join", path.win32.join); + t.mock.method(fsSync, "statSync", (candidate: Parameters[0]) => { + return { + isFile: () => String(candidate).toLowerCase() === expected.toLowerCase(), + } as never; + }); + + assert.equal( + resolveWindowsCommand("copilot", { PATH: "\\tools", PATHEXT: ".EXE" }, "D:\\workspace", false), + expected, + ); +}); + +test("Windows PATH parsing preserves semicolons inside quoted entries", (t) => { + const expected = "C:\\SDK;v2\\bin\\copilot.exe"; + const visited: string[] = []; + t.mock.method(path, "isAbsolute", path.win32.isAbsolute); + t.mock.method(path, "resolve", path.win32.resolve); + t.mock.method(path, "join", path.win32.join); + t.mock.method(fsSync, "statSync", (candidate: Parameters[0]) => { + visited.push(String(candidate)); + return { + isFile: () => String(candidate).toLowerCase() === expected.toLowerCase(), + } as never; + }); + + assert.equal( + resolveWindowsCommand( + "copilot", + { PATH: '"C:\\SDK;v2\\bin";C:\\fallback', PATHEXT: ".EXE" }, + "C:\\workspace", + false, + ), + expected, + ); + assert.equal(visited[0], expected); +}); + +test("Windows PATH parsing supports single-quoted entries", (t) => { + const expected = "C:\\SDK;v2\\bin\\copilot.exe"; + t.mock.method(path, "isAbsolute", path.win32.isAbsolute); + t.mock.method(path, "resolve", path.win32.resolve); + t.mock.method(path, "join", path.win32.join); + t.mock.method(fsSync, "statSync", (candidate: Parameters[0]) => { + return { + isFile: () => String(candidate).toLowerCase() === expected.toLowerCase(), + } as never; + }); + + assert.equal( + resolveWindowsCommand( + "copilot", + { PATH: "'C:\\SDK;v2\\bin';C:\\fallback", PATHEXT: ".EXE" }, + "C:\\workspace", + false, + ), + expected, + ); +}); + +test("Windows PATH parsing preserves whitespace in unquoted entries", (t) => { + const incorrectlyTrimmed = "C:\\first\\copilot.exe"; + const expected = "C:\\second\\copilot.exe"; + t.mock.method(path, "isAbsolute", path.win32.isAbsolute); + t.mock.method(path, "resolve", path.win32.resolve); + t.mock.method(path, "join", path.win32.join); + t.mock.method(fsSync, "statSync", (candidate: Parameters[0]) => { + const normalized = String(candidate).toLowerCase(); + return { + isFile: () => + normalized === incorrectlyTrimmed.toLowerCase() || normalized === expected.toLowerCase(), + } as never; + }); + + assert.equal( + resolveWindowsCommand( + "copilot", + { PATH: " C:\\first;C:\\second", PATHEXT: ".EXE" }, + "C:\\workspace", + false, + ), + expected, + ); +}); + test("buildSpawnCommandOptions enables shell for PATH-resolved .cmd wrappers on Windows", async () => { const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "acpx-windows-spawn-")); const env = { @@ -510,6 +682,58 @@ test("buildTerminalSpawnOptions enables shell for PATH-resolved .cmd wrappers on } }); +test("buildSpawnCommandOptions resolves batch wrappers from the child working directory", async () => { + const cwd = await fs.mkdtemp(path.join(os.tmpdir(), "acpx-windows-spawn-cwd-")); + try { + await fs.writeFile(path.join(cwd, "copilot.cmd"), "@echo off\r\n"); + const env = { PATH: "", PATHEXT: ".EXE;.CMD" } as NodeJS.ProcessEnv; + const options = buildSpawnCommandOptions("copilot", { cwd, env, stdio: "pipe" }, "win32", env); + + assert.equal(options.shell, true); + } finally { + await fs.rm(cwd, { recursive: true, force: true }); + } +}); + +test("buildSpawnCommandOptions honors the Windows current-directory search opt-out", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "acpx-windows-spawn-cwd-")); + const cwd = path.join(root, "cwd"); + const bin = path.join(root, "bin"); + try { + await fs.mkdir(cwd); + await fs.mkdir(bin); + await fs.writeFile(path.join(cwd, "copilot.cmd"), "@echo off\r\n"); + await fs.writeFile(path.join(bin, "copilot.exe"), ""); + const env = { + PATH: bin, + PATHEXT: ".EXE;.CMD", + NoDefaultCurrentDirectoryInExePath: "1", + } as NodeJS.ProcessEnv; + const options = buildSpawnCommandOptions("copilot", { cwd, env, stdio: "pipe" }, "win32", env); + + assert.equal(options.shell, undefined); + } finally { + await fs.rm(root, { recursive: true, force: true }); + } +}); + +test("buildSpawnCommandOptions ignores undefined Windows current-directory opt-outs", async () => { + const cwd = await fs.mkdtemp(path.join(os.tmpdir(), "acpx-windows-spawn-cwd-")); + try { + await fs.writeFile(path.join(cwd, "copilot.cmd"), "@echo off\r\n"); + const env = { + PATH: "", + PATHEXT: ".EXE;.CMD", + NoDefaultCurrentDirectoryInExePath: undefined, + } as NodeJS.ProcessEnv; + const options = buildSpawnCommandOptions("copilot", { cwd, env, stdio: "pipe" }, "win32", env); + + assert.equal(options.shell, true); + } finally { + await fs.rm(cwd, { recursive: true, force: true }); + } +}); + test("buildTerminalSpawnOptions keeps shell disabled for non-batch commands", async () => { const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "acpx-windows-spawn-")); @@ -577,6 +801,68 @@ test("resolveClaudeCodeExecutable prefers a native sibling when PATH ordering fi } }); +test("resolveWindowsExecutablePath preserves PATHEXT precedence for native .com files", async () => { + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "acpx-claude-native-")); + try { + const comExecutable = path.join(tempDir, "claude.com"); + await fs.writeFile(comExecutable, ""); + await fs.writeFile(path.join(tempDir, "claude.exe"), ""); + const env = { + PATH: tempDir, + PATHEXT: ".COM;.EXE", + } as NodeJS.ProcessEnv; + + assert.equal(resolveWindowsExecutablePath("claude", env), comExecutable); + } finally { + await fs.rm(tempDir, { recursive: true, force: true }); + } +}); + +test("resolveWindowsExecutablePath stops after the first native executable", (t) => { + const expected = "C:\\fast\\claude.exe"; + const visited: string[] = []; + t.mock.method(path, "isAbsolute", path.win32.isAbsolute); + t.mock.method(path, "resolve", path.win32.resolve); + t.mock.method(path, "join", path.win32.join); + t.mock.method(fsSync, "statSync", (candidate: Parameters[0]) => { + visited.push(String(candidate)); + return { isFile: () => true } as never; + }); + + assert.equal( + resolveWindowsExecutablePath( + "claude", + { PATH: "C:\\fast;\\\\slow\\share", PATHEXT: ".EXE" }, + "C:\\workspace", + ), + expected, + ); + assert.deepEqual(visited, [expected]); +}); + +test("resolveWindowsExecutablePath does not cross installs after an unresolved first shim", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "acpx-claude-install-order-")); + const first = path.join(root, "first"); + const second = path.join(root, "second"); + try { + await fs.mkdir(first); + await fs.mkdir(second); + await fs.writeFile(path.join(first, "claude.cmd"), '@echo off\r\nnode "%~dp0cli.js" %*\r\n'); + await fs.writeFile(path.join(second, "claude.exe"), ""); + + assert.equal( + resolveWindowsExecutablePath( + "claude", + { PATH: `${first};${second}`, PATHEXT: ".CMD;.EXE" }, + root, + ), + undefined, + ); + } finally { + await fs.rm(root, { recursive: true, force: true }); + } +}); + test("resolveWindowsExecutablePath follows a wrapper to a native entrypoint", async () => { const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "acpx-claude-shim-")); try { @@ -599,6 +885,23 @@ test("resolveWindowsExecutablePath follows a wrapper to a native entrypoint", as } }); +test("resolveWindowsExecutablePath preserves PowerShell shims with native siblings", async () => { + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "acpx-claude-shim-")); + try { + await fs.writeFile(path.join(tempDir, "claude.ps1"), "exit 0\n"); + const executable = path.join(tempDir, "claude.exe"); + await fs.writeFile(executable, ""); + const env = { + PATH: tempDir, + PATHEXT: ".PS1", + } as NodeJS.ProcessEnv; + + assert.equal(resolveWindowsExecutablePath("claude", env), executable); + } finally { + await fs.rm(tempDir, { recursive: true, force: true }); + } +}); + test("resolveClaudeCodeExecutable returns undefined when CLAUDE_CODE_EXECUTABLE is already set", async () => { const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "acpx-claude-exe-")); try { From 92b7f03bdfbe027f981ca7b5e3ba1fb79ec19bce Mon Sep 17 00:00:00 2001 From: trumpyla Date: Sat, 1 Aug 2026 12:36:45 -0400 Subject: [PATCH 40/57] test: preserve alternate queue and persistence shields Source-Commit: 0abb6fedf5ff5169f0bfc50896480600aea3ec76 Source-Scope: performance --- test/queue-ipc-errors.test.ts | 41 +++++++++++++ test/session-persistence.test.ts | 100 +++++++++++++++++++++++++++++++ 2 files changed, 141 insertions(+) diff --git a/test/queue-ipc-errors.test.ts b/test/queue-ipc-errors.test.ts index 1484d0e2..cd8e477e 100644 --- a/test/queue-ipc-errors.test.ts +++ b/test/queue-ipc-errors.test.ts @@ -1057,6 +1057,47 @@ test("trySubmitToRunningOwner preserves stale live legacy owners", async () => { }); }); +test("trySubmitToRunningOwner fails closed for an older live owner without a socket", async () => { + await withTempHome(async (homeDir) => { + const sessionId = "submit-owner-not-accepting"; + const keeper = await startKeeperProcess(); + const { lockPath, socketPath } = queuePaths(homeDir, sessionId); + await writeQueueOwnerLock({ + lockPath, + pid: keeper.pid, + sessionId, + socketPath, + createdAt: "2000-01-01T00:00:00.000Z", + heartbeatAt: new Date().toISOString(), + }); + + try { + await assert.rejects( + async () => + await trySubmitToRunningOwner({ + sessionId, + message: "hello", + permissionMode: "approve-reads", + outputFormatter: NOOP_OUTPUT_FORMATTER, + waitForCompletion: true, + }), + (error: unknown) => { + assert(error instanceof QueueConnectionError); + assert.equal(error.detailCode, "QUEUE_NOT_ACCEPTING_REQUESTS"); + assert.equal(error.retryable, true); + return true; + }, + ); + await fs.access(lockPath); + assert.equal(keeper.exitCode, null); + assert.equal(keeper.signalCode, null); + } finally { + await cleanupOwnerArtifacts({ socketPath, lockPath }); + stopProcess(keeper); + } + }); +}); + test("trySubmitToRunningOwner marks quiet errors as outputAlreadyEmitted after formatter emits", async () => { // Regression test for double-emission in quiet mode. // diff --git a/test/session-persistence.test.ts b/test/session-persistence.test.ts index d1b19ca8..44f1eccd 100644 --- a/test/session-persistence.test.ts +++ b/test/session-persistence.test.ts @@ -719,6 +719,51 @@ test("writeSessionRecord recovers an old malformed write lock", async () => { }); }); +test("writeSessionRecord preserves an old identity-less lock held by a live writer", async () => { + await withTempHome(async (homeDir) => { + const sessionDir = path.join(homeDir, ".acpx", "sessions"); + const lockPath = path.join(sessionDir, ".write.lock"); + const record = makeSessionRecord({ + acpxRecordId: "live-unverifiable-lock", + acpSessionId: "live-unverifiable-lock", + agentCommand: "agent-a", + cwd: path.join(homeDir, "live-lock"), + }); + await fs.mkdir(sessionDir, { recursive: true }); + await fs.writeFile( + lockPath, + `${JSON.stringify({ + lockId: "live-unverifiable-writer", + pid: process.pid, + createdAt: "2000-01-01T00:00:00.000Z", + })}\n`, + "utf8", + ); + const oldTime = new Date(Date.now() - 180_000); + await fs.utimes(lockPath, oldTime, oldTime); + const repository = await import( + `${SESSION_REPOSITORY_URL.href}?live_unverifiable=${Date.now()}-${Math.random()}` + ); + let writeSettled = false; + const writing = repository.writeSessionRecord(record).then(() => { + writeSettled = true; + }); + + try { + await sleep(250); + assert.equal(writeSettled, false); + assert.equal(await fileExists(sessionFilePath(homeDir, record.acpxRecordId)), false); + + await fs.rm(lockPath); + await writing; + assert.equal(await fileExists(sessionFilePath(homeDir, record.acpxRecordId)), true); + } finally { + await fs.rm(lockPath, { force: true }); + await writing.catch(() => undefined); + } + }); +}); + test("session write locks are not visible until their complete record is published", async () => { await withTempHome(async (homeDir) => { const sessionDir = path.join(homeDir, "sessions"); @@ -843,6 +888,61 @@ test("session write locks preserve live owners without a verifiable process iden }); }); +test("pruneSessions waits for a concurrent session writer before selecting records", async () => { + await withTempHome(async (homeDir) => { + const repository = await import( + `${SESSION_REPOSITORY_URL.href}?prune_writer_test=${Date.now()}-${Math.random()}` + ); + const session = await loadSessionModule(); + const sessionDir = path.join(homeDir, ".acpx", "sessions"); + const indexPath = path.join(sessionDir, "index.json"); + const readyPath = path.join(homeDir, "writer-holds-session-lock"); + const releasePath = path.join(homeDir, "release-session-writer"); + const initial = makeSessionRecord({ + acpxRecordId: "prune-writer-session", + acpSessionId: "prune-writer-session", + agentCommand: "agent-a", + cwd: path.join(homeDir, "prune-writer"), + closed: true, + closedAt: "2020-01-01T00:00:00.000Z", + }); + await repository.writeSessionRecord(initial); + + const writer = spawnSessionRecordWriter({ + homeDir, + record: { + ...initial, + closed: false, + closedAt: undefined, + lastUsedAt: new Date().toISOString(), + }, + pauseIndexPath: indexPath, + readyPath, + releasePath, + }); + let pruneSettled = false; + + try { + await waitForFile(readyPath); + const pruneResult = session.pruneSessions({ agentCommand: "agent-a" }).then((result) => { + pruneSettled = true; + return result; + }); + await sleep(250); + assert.equal(pruneSettled, false); + + await fs.writeFile(releasePath, "release\n", "utf8"); + await waitForSuccessfulChild(writer); + const result = await pruneResult; + assert.equal(result.pruned.length, 0); + assert.equal(await fileExists(sessionFilePath(homeDir, initial.acpxRecordId)), true); + } finally { + await fs.writeFile(releasePath, "release\n", "utf8").catch(() => undefined); + stopChild(writer); + } + }); +}); + test("closeSession soft-closes and terminates matching process", async () => { await withTempHome(async (homeDir) => { const session = await loadSessionModule(); From 5ebe1163767eae4e9feb21edd633760933dc6be9 Mon Sep 17 00:00:00 2001 From: trumpyla Date: Sat, 1 Aug 2026 15:49:51 -0400 Subject: [PATCH 41/57] chore: align performance branch pnpm toolchain --- .github/workflows/ci.yml | 2 +- .github/workflows/conformance-nightly.yml | 2 +- .github/workflows/crabbox-hydrate.yml | 2 +- .github/workflows/release-binaries.yml | 2 +- .github/workflows/release.yml | 2 +- .github/workflows/supply-chain.yml | 6 +++--- package.json | 2 +- 7 files changed, 9 insertions(+), 9 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 415f0d98..2332061d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -12,7 +12,7 @@ concurrency: env: NODE_VERSION: 24 - PNPM_VERSION: 10.33.2 + PNPM_VERSION: 10.34.5 jobs: scope: diff --git a/.github/workflows/conformance-nightly.yml b/.github/workflows/conformance-nightly.yml index 121a0142..52a8d7da 100644 --- a/.github/workflows/conformance-nightly.yml +++ b/.github/workflows/conformance-nightly.yml @@ -22,7 +22,7 @@ concurrency: env: NODE_VERSION: 24 - PNPM_VERSION: 10.33.2 + PNPM_VERSION: 10.34.5 jobs: conformance-mock: diff --git a/.github/workflows/crabbox-hydrate.yml b/.github/workflows/crabbox-hydrate.yml index 4bd56680..fc1c4b62 100644 --- a/.github/workflows/crabbox-hydrate.yml +++ b/.github/workflows/crabbox-hydrate.yml @@ -30,7 +30,7 @@ permissions: contents: read env: - PNPM_VERSION: 10.33.2 + PNPM_VERSION: 10.34.5 jobs: hydrate: diff --git a/.github/workflows/release-binaries.yml b/.github/workflows/release-binaries.yml index fcbee8ca..ad1d9733 100644 --- a/.github/workflows/release-binaries.yml +++ b/.github/workflows/release-binaries.yml @@ -36,7 +36,7 @@ permissions: contents: read env: - PNPM_VERSION: 10.33.2 + PNPM_VERSION: 10.34.5 # Pinned exactly. The shipped binary *is* this Node build with a blob injected, # so a floating version silently changes the release's largest attack surface # (V8, OpenSSL, zlib) and makes builds non-reproducible. The SBOM records this diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 931e6ae8..4a708a0c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -22,7 +22,7 @@ concurrency: env: NODE_VERSION: 24 - PNPM_VERSION: 10.33.2 + PNPM_VERSION: 10.34.5 jobs: release: diff --git a/.github/workflows/supply-chain.yml b/.github/workflows/supply-chain.yml index e178aaef..327e4a3b 100644 --- a/.github/workflows/supply-chain.yml +++ b/.github/workflows/supply-chain.yml @@ -19,7 +19,7 @@ permissions: contents: read env: - PNPM_VERSION: 10.33.2 + PNPM_VERSION: 10.34.5 NODE_VERSION: 24.11.0 jobs: @@ -203,8 +203,8 @@ jobs: # the build on one trains people to ignore this job. run: | set -uo pipefail - # Distinguish "found advisories" from "could not run". pnpm 10.33.2 - # crashes on a gzipped audit response with + # Distinguish "found advisories" from "could not run". Some pnpm + # versions crash on a gzipped audit response with # `Unexpected token '\x1f' ... is not valid JSON`, which is a # transport bug, not a finding. Failing the build on it would make a # red audit job mean nothing, and a real advisory would be ignored diff --git a/package.json b/package.json index 64b68fdf..9ed149cf 100644 --- a/package.json +++ b/package.json @@ -133,5 +133,5 @@ "engines": { "node": ">=22.13.0" }, - "packageManager": "pnpm@10.33.2" + "packageManager": "pnpm@10.34.5" } From 6fd884631fab6545f8f751f1b39667b8444a82d0 Mon Sep 17 00:00:00 2001 From: trumpyla Date: Sun, 2 Aug 2026 11:08:41 -0400 Subject: [PATCH 42/57] docs: design reusable performance benchmark --- ...-three-way-performance-benchmark-design.md | 182 ++++++++++++++++++ 1 file changed, 182 insertions(+) create mode 100644 docs/superpowers/specs/2026-08-02-three-way-performance-benchmark-design.md diff --git a/docs/superpowers/specs/2026-08-02-three-way-performance-benchmark-design.md b/docs/superpowers/specs/2026-08-02-three-way-performance-benchmark-design.md new file mode 100644 index 00000000..81f6016e --- /dev/null +++ b/docs/superpowers/specs/2026-08-02-three-way-performance-benchmark-design.md @@ -0,0 +1,182 @@ +# Three-Way ACPX Performance Benchmark Design + +## Goal + +Create a reusable benchmark that compares built ACPX checkouts without changing +the revisions being measured. Use it to compare current OpenClaw `main`, +OpenClaw PR #478, and Artagon performance PR #2, then retain the tool on the +Artagon performance branch. + +The benchmark must answer two separate questions: + +1. How do the three revisions compare for end-to-end CLI latency and eager-load + size? +2. Which lifecycle stage accounts for any cold ACP regression? + +## Ownership and isolation + +The reusable benchmark belongs to `perf/consolidated-runtime`, the branch behind +Artagon PR #2. OpenClaw PR #478 remains unchanged so it can be measured as the +submitted lifecycle fix rather than as a benchmark-modified revision. + +The comparison uses three independent worktrees: + +- a detached worktree pinned to the fetched OpenClaw `main` SHA; +- a detached worktree pinned to OpenClaw PR #478's fetched head SHA; and +- the existing `perf/consolidated-runtime` worktree pinned to Artagon PR #2's + head SHA. + +The runner accepts worktree paths and never fetches, checks out, installs, +builds, or mutates Git state. Preparation remains an explicit outer workflow so +the report can record and verify the exact already-built inputs. + +## Components + +### Benchmark core + +A side-effect-free TypeScript module under `scripts/perf/` owns: + +- variant and scenario validation; +- deterministic alternating order generation; +- descriptive statistics; +- paired log-ratio deltas and seeded bootstrap confidence intervals; +- eager-import graph accounting; and +- JSON and Markdown report rendering. + +Keeping these functions separate lets focused tests prove the statistical and +reporting contract without spawning hundreds of processes. + +### Benchmark runner + +The executable TypeScript entrypoint under `scripts/perf/` accepts: + +```text +--baseline