diff --git a/.gitignore b/.gitignore index 6b655289..07bf2802 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,7 @@ !/qiming-backend/ !/qiming-file-server/ !/qiming-mobile/ +!/qiming-claude-code-acp-ts/ !/qimingclaw/ !/qimingcode/ diff --git a/qiming-claude-code-acp-ts/.github/dependabot.yml b/qiming-claude-code-acp-ts/.github/dependabot.yml new file mode 100644 index 00000000..30fb3095 --- /dev/null +++ b/qiming-claude-code-acp-ts/.github/dependabot.yml @@ -0,0 +1,18 @@ +version: 2 +updates: + - package-ecosystem: "github-actions" + directory: "/" + schedule: + # Check for updates to GitHub Actions every week + interval: "weekly" + - package-ecosystem: "npm" + directory: "/" + schedule: + interval: "daily" + allow: + - dependency-type: "all" + groups: + minor: + update-types: + - "minor" + - "patch" diff --git a/qiming-claude-code-acp-ts/.github/workflows/ci.yml b/qiming-claude-code-acp-ts/.github/workflows/ci.yml new file mode 100644 index 00000000..b078481f --- /dev/null +++ b/qiming-claude-code-acp-ts/.github/workflows/ci.yml @@ -0,0 +1,45 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: "ci-${{ github.ref }}" + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +jobs: + build: + name: Build + runs-on: ubuntu-latest + timeout-minutes: 5 + + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd + + - name: Use Node.js + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e + with: + node-version: "lts/*" + cache: "npm" + + - name: Install dependencies + run: npm ci + + - name: Check formatting + run: npm run format:check + + - name: Run linter + run: npm run lint + + - name: Build + run: npm run build + + - name: Run tests + run: npm run test:run diff --git a/qiming-claude-code-acp-ts/.github/workflows/publish-beta.yml b/qiming-claude-code-acp-ts/.github/workflows/publish-beta.yml new file mode 100644 index 00000000..d113cbd1 --- /dev/null +++ b/qiming-claude-code-acp-ts/.github/workflows/publish-beta.yml @@ -0,0 +1,32 @@ +name: Publish Beta +on: + push: + tags: + - 'v*.*.*-beta.*' + +jobs: + publish-beta: + name: Publish Beta to npm + runs-on: ubuntu-latest + permissions: + contents: write + id-token: write + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd + - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e + with: + node-version: "lts/*" + registry-url: "https://registry.npmjs.org" + - name: Update npm + run: npm install -g npm@latest + - run: npm ci + - run: npm run build + - run: npm publish --tag beta + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + + - name: Create GitHub Release (Pre-release) + uses: softprops/action-gh-release@v2 + with: + generate_release_notes: true + prerelease: true diff --git a/qiming-claude-code-acp-ts/.github/workflows/publish.yml b/qiming-claude-code-acp-ts/.github/workflows/publish.yml new file mode 100644 index 00000000..f2bfd0a6 --- /dev/null +++ b/qiming-claude-code-acp-ts/.github/workflows/publish.yml @@ -0,0 +1,33 @@ +name: Publish and Release +on: + push: + tags: + - 'v*.*.*' + - '!v*.*.*-beta.*' + +jobs: + publish-npm: + name: Publish to npm + runs-on: ubuntu-latest + permissions: + contents: write + id-token: write + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd + # Setup .npmrc file to publish to npm + - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e + with: + node-version: "lts/*" + registry-url: "https://registry.npmjs.org" + - name: Update npm + run: npm install -g npm@latest + - run: npm ci + - run: npm run build + - run: npm publish + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + + - name: Create GitHub Release + uses: softprops/action-gh-release@v2 + with: + generate_release_notes: true diff --git a/qiming-claude-code-acp-ts/.gitignore b/qiming-claude-code-acp-ts/.gitignore new file mode 100644 index 00000000..fa4666ac --- /dev/null +++ b/qiming-claude-code-acp-ts/.gitignore @@ -0,0 +1,7 @@ +node_modules + +.DS_Store +.claude/*.local.* +CLAUDE.local.md +.idea +dist \ No newline at end of file diff --git a/qiming-claude-code-acp-ts/.prettierignore b/qiming-claude-code-acp-ts/.prettierignore new file mode 100644 index 00000000..2f907b70 --- /dev/null +++ b/qiming-claude-code-acp-ts/.prettierignore @@ -0,0 +1,7 @@ +node_modules +dist +.agent-shell + +.release-please-manifest.json +release-please-config.json +CHANGELOG.md diff --git a/qiming-claude-code-acp-ts/.prettierrc.json b/qiming-claude-code-acp-ts/.prettierrc.json new file mode 100644 index 00000000..1d43a318 --- /dev/null +++ b/qiming-claude-code-acp-ts/.prettierrc.json @@ -0,0 +1,4 @@ +{ + "printWidth": 100, + "tabWidth": 2 +} diff --git a/qiming-claude-code-acp-ts/.release-please-manifest.json b/qiming-claude-code-acp-ts/.release-please-manifest.json new file mode 100644 index 00000000..05b60243 --- /dev/null +++ b/qiming-claude-code-acp-ts/.release-please-manifest.json @@ -0,0 +1,3 @@ +{ + ".": "0.37.0" +} diff --git a/qiming-claude-code-acp-ts/CHANGELOG.md b/qiming-claude-code-acp-ts/CHANGELOG.md new file mode 100644 index 00000000..89a46f62 --- /dev/null +++ b/qiming-claude-code-acp-ts/CHANGELOG.md @@ -0,0 +1,643 @@ +# Changelog + +## [0.37.0](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.36.1...v0.37.0) (2026-05-21) + + +### Features + +* add 'Default' option to effort level config ([#701](https://github.com/agentclientprotocol/claude-agent-acp/issues/701)) ([9e259d1](https://github.com/agentclientprotocol/claude-agent-acp/commit/9e259d128aa58dbb8107e53362781ca4e8ee071e)) +* **deps:** bump the claude-agent-sdk to 0.3.146 ([#700](https://github.com/agentclientprotocol/claude-agent-acp/issues/700)) ([72875b2](https://github.com/agentclientprotocol/claude-agent-acp/commit/72875b248c7b90a89638de2f1709eec9e2153e7e)) +* **deps:** bump the minor group with 13 updates ([#689](https://github.com/agentclientprotocol/claude-agent-acp/issues/689)) ([f66bc31](https://github.com/agentclientprotocol/claude-agent-acp/commit/f66bc31ae4bea73b48eeeb7f1db90c7d69cc0dac)) +* Emit tool calls for memory recall events ([#703](https://github.com/agentclientprotocol/claude-agent-acp/issues/703)) ([a0bfb98](https://github.com/agentclientprotocol/claude-agent-acp/commit/a0bfb98ba73eaf37e9c071e886c054f90a5d5629)), closes [#650](https://github.com/agentclientprotocol/claude-agent-acp/issues/650) + + +### Bug Fixes + +* Avoid cross-version model alias matches ([#702](https://github.com/agentclientprotocol/claude-agent-acp/issues/702)) ([e1e1c69](https://github.com/agentclientprotocol/claude-agent-acp/commit/e1e1c69029ef08579c841e6fbdffb32a5b94df06)) +* Avoid redundant initial model sync ([#704](https://github.com/agentclientprotocol/claude-agent-acp/issues/704)) ([b275f6f](https://github.com/agentclientprotocol/claude-agent-acp/commit/b275f6ff7f0f2e21adedbd8e63a6fc3d63cbbb8d)), closes [#646](https://github.com/agentclientprotocol/claude-agent-acp/issues/646) +* Don't expose /clear in commands ([#705](https://github.com/agentclientprotocol/claude-agent-acp/issues/705)) ([cfce130](https://github.com/agentclientprotocol/claude-agent-acp/commit/cfce1307076e93f43a3ed8cc0134f9bb14a0f2d6)) +* emit "cancelled" instead of "end_turn" when the session was interrupted. ([#694](https://github.com/agentclientprotocol/claude-agent-acp/issues/694)) ([2414a6f](https://github.com/agentclientprotocol/claude-agent-acp/commit/2414a6f98bec5bf50f3a13af528aa38d5a0fc974)) +* Recover prompt stream after a failed turn ([#706](https://github.com/agentclientprotocol/claude-agent-acp/issues/706)) ([2711f50](https://github.com/agentclientprotocol/claude-agent-acp/commit/2711f506d5799f0ae25160de311a4459ffb46c49)), closes [#654](https://github.com/agentclientprotocol/claude-agent-acp/issues/654) + +## [0.36.1](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.36.0...v0.36.1) (2026-05-18) + + +### Bug Fixes + +* flaky authentication bypass for gateway ([#686](https://github.com/agentclientprotocol/claude-agent-acp/issues/686)) ([db852dc](https://github.com/agentclientprotocol/claude-agent-acp/commit/db852dcf7c8e3b461fabb1bdcf0c80a27d1da77d)) + +## [0.36.0](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.35.0...v0.36.0) (2026-05-18) + + +### Features + +* Add experimental session delete support ([#682](https://github.com/agentclientprotocol/claude-agent-acp/issues/682)) ([2162d5a](https://github.com/agentclientprotocol/claude-agent-acp/commit/2162d5af62d493a82381bd88bc8fc67d376e358b)) +* Support experimental additionalDirectories field ([#684](https://github.com/agentclientprotocol/claude-agent-acp/issues/684)) ([f37e9a0](https://github.com/agentclientprotocol/claude-agent-acp/commit/f37e9a0d47d8d201d98b2177083664a6781895cc)) + +## [0.35.0](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.34.1...v0.35.0) (2026-05-16) + + +### Features + +* **deps:** bump actions/create-github-app-token from 3.1.1 to 3.2.0 ([#669](https://github.com/agentclientprotocol/claude-agent-acp/issues/669)) ([47df1f2](https://github.com/agentclientprotocol/claude-agent-acp/commit/47df1f2a0be57ddb8873ae8e341f748471c7bc53)) +* **deps:** bump hono from 4.12.18 to 4.12.19 in the minor group ([#671](https://github.com/agentclientprotocol/claude-agent-acp/issues/671)) ([5d7165d](https://github.com/agentclientprotocol/claude-agent-acp/commit/5d7165d96158da2ddf3087676cdb0a19d1ff57aa)) +* **deps:** update to claude-agent-sdk 0.3.143 ([#664](https://github.com/agentclientprotocol/claude-agent-acp/issues/664)) ([27ca2e5](https://github.com/agentclientprotocol/claude-agent-acp/commit/27ca2e5d40917887671cc4c39a854fd8f92c01e6)) +* Use SDK settings resolution for defaults ([#677](https://github.com/agentclientprotocol/claude-agent-acp/issues/677)) ([eb1259c](https://github.com/agentclientprotocol/claude-agent-acp/commit/eb1259cf88375ca3c20a219ab99f12f5488fb360)) + + +### Bug Fixes + +* Add task hooks for plan state updates ([#676](https://github.com/agentclientprotocol/claude-agent-acp/issues/676)) ([5ff7d50](https://github.com/agentclientprotocol/claude-agent-acp/commit/5ff7d50f0ec7f34a7d9d901223aed73e2dbcce68)) +* render local-command-stdout messages instead of dropping them ([#649](https://github.com/agentclientprotocol/claude-agent-acp/issues/649)) ([3b9b7d5](https://github.com/agentclientprotocol/claude-agent-acp/commit/3b9b7d5a56defd925eb2038fa97b6484ad951587)) + +## [0.34.1](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.34.0...v0.34.1) (2026-05-16) + + +### Bug Fixes + +* load credential error for bedrock gateway ([#667](https://github.com/agentclientprotocol/claude-agent-acp/issues/667)) ([8d76be3](https://github.com/agentclientprotocol/claude-agent-acp/commit/8d76be356a3e74c58a7c93c3c201cca6a317d784)) + +## [0.34.0](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.33.1...v0.34.0) (2026-05-15) + + +### Features + +* add bedrock gateway authentication ([#665](https://github.com/agentclientprotocol/claude-agent-acp/issues/665)) ([002c63a](https://github.com/agentclientprotocol/claude-agent-acp/commit/002c63a78b844c168d3dfc744cf408362a6adf97)) + +## [0.33.1](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.33.0...v0.33.1) (2026-05-07) + + +### Bug Fixes + +* Honor availableModels settings allowlist ([#637](https://github.com/agentclientprotocol/claude-agent-acp/issues/637)) ([867a3a0](https://github.com/agentclientprotocol/claude-agent-acp/commit/867a3a0de2a050592d79aa76d7dd3dd5b478162d)), closes [#620](https://github.com/agentclientprotocol/claude-agent-acp/issues/620) + +## [0.33.0](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.32.0...v0.33.0) (2026-05-07) + + +### Features + +* **deps:** bump the minor group with 14 updates ([#631](https://github.com/agentclientprotocol/claude-agent-acp/issues/631)) ([8b43ee8](https://github.com/agentclientprotocol/claude-agent-acp/commit/8b43ee813f05f5087a213a7f42154e39d6bc4a5a)) +* **deps:** Update to claude-agent-sdk 0.2.132 ([#636](https://github.com/agentclientprotocol/claude-agent-acp/issues/636)) ([0c8ff27](https://github.com/agentclientprotocol/claude-agent-acp/commit/0c8ff277f2af8d3085cc1b5cf33891630dede0b1)) + + +### Bug Fixes + +* Handle result origins in ACP agent ([#627](https://github.com/agentclientprotocol/claude-agent-acp/issues/627)) ([dba1998](https://github.com/agentclientprotocol/claude-agent-acp/commit/dba199839a25ba405f04b9d4409e647150fe0290)) + +## [0.32.0](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.31.4...v0.32.0) (2026-05-03) + + +### Features + +* **deps-dev:** Bump nanoid from 3.3.11 to 3.3.12 ([#622](https://github.com/agentclientprotocol/claude-agent-acp/issues/622)) ([c78ac62](https://github.com/agentclientprotocol/claude-agent-acp/commit/c78ac62d05283e5683b87b198923818f9f556a03)) +* **deps:** Bump the minor group with 2 updates ([#619](https://github.com/agentclientprotocol/claude-agent-acp/issues/619)) ([6ccd37c](https://github.com/agentclientprotocol/claude-agent-acp/commit/6ccd37ce947fbd55691f676344b15ef1266191ad)) +* **deps:** update to @anthropic-ai/claude-agent-sdk 0.2.126 ([#621](https://github.com/agentclientprotocol/claude-agent-acp/issues/621)) ([becc3b8](https://github.com/agentclientprotocol/claude-agent-acp/commit/becc3b86e60eb33e7823a0ef27d5aa99758750d7)) +* **deps:** Update to @anthropic-ai/claude-agent-sdk@0.2.123 ([#614](https://github.com/agentclientprotocol/claude-agent-acp/issues/614)) ([5b93119](https://github.com/agentclientprotocol/claude-agent-acp/commit/5b9311938d97f47400debd66f2b4792e50639c6b)) + + +### Bug Fixes + +* emit a real diff when Write overwrites an existing file ([#618](https://github.com/agentclientprotocol/claude-agent-acp/issues/618)) ([8d7e220](https://github.com/agentclientprotocol/claude-agent-acp/commit/8d7e22026e15d54208414bd9ad128eb86d41b451)) + +## [0.31.4](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.31.3...v0.31.4) (2026-04-28) + + +### Bug Fixes + +* gate auto mode on model support ([#604](https://github.com/agentclientprotocol/claude-agent-acp/issues/604)) ([ec47d34](https://github.com/agentclientprotocol/claude-agent-acp/commit/ec47d3446fa22e5895621f57c0a4497dbe044505)) + +## [0.31.3](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.31.2...v0.31.3) (2026-04-28) + + +### Bug Fixes + +* Rename effort config category to thought_level ([#609](https://github.com/agentclientprotocol/claude-agent-acp/issues/609)) ([76b4e96](https://github.com/agentclientprotocol/claude-agent-acp/commit/76b4e9650832e59a6b4915ad898fc66a065022fa)) + +## [0.31.2](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.31.1...v0.31.2) (2026-04-28) + + +### Bug Fixes + +* tolerate invalid values in settings.json ([#601](https://github.com/agentclientprotocol/claude-agent-acp/issues/601)) ([67af018](https://github.com/agentclientprotocol/claude-agent-acp/commit/67af018d0216238d8f3cd5bad04a55200518239b)) + +## [0.31.1](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.31.0...v0.31.1) (2026-04-27) + + +### Bug Fixes + +* detect glibc and use correct binary ([#599](https://github.com/agentclientprotocol/claude-agent-acp/issues/599)) ([f0801d5](https://github.com/agentclientprotocol/claude-agent-acp/commit/f0801d50b2390a3c48150b9b8507d30a70c18b84)) + +## [0.31.0](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.30.0...v0.31.0) (2026-04-24) + + +### Features + +* allow configuring models through env vars ([#586](https://github.com/agentclientprotocol/claude-agent-acp/issues/586)) ([1e15f3d](https://github.com/agentclientprotocol/claude-agent-acp/commit/1e15f3d8b0a06076ee62764ab576ac29fe056a38)) +* Support effort levels ([#464](https://github.com/agentclientprotocol/claude-agent-acp/issues/464)) ([9e1185b](https://github.com/agentclientprotocol/claude-agent-acp/commit/9e1185bfd55cbf83a653a0ff8c194f986cbd1574)) +* Update to acp sdk v0.20 ([#589](https://github.com/agentclientprotocol/claude-agent-acp/issues/589)) ([92adcbd](https://github.com/agentclientprotocol/claude-agent-acp/commit/92adcbd1ace2f2103742ffcf5c589eb1ca41b9b8)) +* Update to claude-agent-sdk 0.2.119 ([#587](https://github.com/agentclientprotocol/claude-agent-acp/issues/587)) ([ef1dbd1](https://github.com/agentclientprotocol/claude-agent-acp/commit/ef1dbd1cb457f0524d221ea07235abf49dc941a0)) + + +### Bug Fixes + +* Forward full systemPrompt preset options from _meta ([#591](https://github.com/agentclientprotocol/claude-agent-acp/issues/591)) ([b3ddfc3](https://github.com/agentclientprotocol/claude-agent-acp/commit/b3ddfc3b86703c23fe99d93611885c12b1e4812c)) +* Session Replay and Permission rendering ([#593](https://github.com/agentclientprotocol/claude-agent-acp/issues/593)) ([5faefab](https://github.com/agentclientprotocol/claude-agent-acp/commit/5faefab8ab9b725f7a3374808bf5579e411884bc)), closes [#579](https://github.com/agentclientprotocol/claude-agent-acp/issues/579) + +## [0.30.0](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.29.2...v0.30.0) (2026-04-20) + + +### Features + +* Update to claude-agent-sdk 0.2.114 ([#572](https://github.com/agentclientprotocol/claude-agent-acp/issues/572)) ([e9dd452](https://github.com/agentclientprotocol/claude-agent-acp/commit/e9dd45207d396302f20de82561135bf9558c0e76)) + +## [0.29.2](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.29.1...v0.29.2) (2026-04-17) + + +### Bug Fixes + +* Guard against null usage tokens ([#565](https://github.com/agentclientprotocol/claude-agent-acp/issues/565)) ([f7dc300](https://github.com/agentclientprotocol/claude-agent-acp/commit/f7dc300a165e70f6426f53920f12e24c04033cdc)), closes [#564](https://github.com/agentclientprotocol/claude-agent-acp/issues/564) + +## [0.29.1](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.29.0...v0.29.1) (2026-04-17) + + +### Bug Fixes + +* emit usage updates from Claude stream events ([#506](https://github.com/agentclientprotocol/claude-agent-acp/issues/506)) ([dd67450](https://github.com/agentclientprotocol/claude-agent-acp/commit/dd67450fd9bddc82db3e71273b535e07b2672804)) +* Remove dot from auto mode description ([#561](https://github.com/agentclientprotocol/claude-agent-acp/issues/561)) ([2ecfa83](https://github.com/agentclientprotocol/claude-agent-acp/commit/2ecfa83b26db58deaded210463fa6ff21d0dff70)) +* Update to claude-agent-sdk 0.2.112 to fix Auto bug with Opus 4.7 ([#562](https://github.com/agentclientprotocol/claude-agent-acp/issues/562)) ([079614a](https://github.com/agentclientprotocol/claude-agent-acp/commit/079614ab05afba17e2cce0d3d238df7b90e17389)) + +## [0.29.0](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.28.0...v0.29.0) (2026-04-16) + + +### Features + +* Update to claude-agent-sdk 0.2.111 (Opus 4.7) ([#557](https://github.com/agentclientprotocol/claude-agent-acp/issues/557)) ([85cd70c](https://github.com/agentclientprotocol/claude-agent-acp/commit/85cd70c9f3be47c9c404958547c4046d866db1c9)) + +## [0.28.0](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.27.0...v0.28.0) (2026-04-15) + + +### Features + +* Update to claude-agent-sdk 0.2.109 ([#549](https://github.com/agentclientprotocol/claude-agent-acp/issues/549)) ([07a0fbc](https://github.com/agentclientprotocol/claude-agent-acp/commit/07a0fbc2f6bc388541d064a436412bdd850772cb)) + +## [0.27.0](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.26.0...v0.27.0) (2026-04-13) + + +### Features + +* allow clients to opt into receiving raw SDK messages ([#527](https://github.com/agentclientprotocol/claude-agent-acp/issues/527)) ([403a668](https://github.com/agentclientprotocol/claude-agent-acp/commit/403a668078c067868062ed26cd4d3e36665b66b6)) +* Update to claude-agent-sdk 0.2.104 ([#537](https://github.com/agentclientprotocol/claude-agent-acp/issues/537)) ([6811943](https://github.com/agentclientprotocol/claude-agent-acp/commit/6811943f57ef08616be633a8197223d4072663cf)) + + +### Bug Fixes + +* Allow auto mode after plan mode and send description for auto mode ([#528](https://github.com/agentclientprotocol/claude-agent-acp/issues/528)) ([fb9aced](https://github.com/agentclientprotocol/claude-agent-acp/commit/fb9aced3151c40694f1f01fd95665c4f5d90eb67)) +* Better remote check for auth methods ([#538](https://github.com/agentclientprotocol/claude-agent-acp/issues/538)) ([93f58c0](https://github.com/agentclientprotocol/claude-agent-acp/commit/93f58c0d2fcf7365c7ca5a6e52f56663e2065ddb)) +* better shutdown logic ([#543](https://github.com/agentclientprotocol/claude-agent-acp/issues/543)) ([9fb631f](https://github.com/agentclientprotocol/claude-agent-acp/commit/9fb631f5d76a5c5f6f0a1b61bdef05fe368c754c)) +* exit process when ACP connection closes ([#530](https://github.com/agentclientprotocol/claude-agent-acp/issues/530)) ([5c81e99](https://github.com/agentclientprotocol/claude-agent-acp/commit/5c81e99fe5ccdf774819b7ff3a2bc78a6519d730)) +* guard tool info rendering when tool_use input is undefined ([#536](https://github.com/agentclientprotocol/claude-agent-acp/issues/536)) ([d627b8c](https://github.com/agentclientprotocol/claude-agent-acp/commit/d627b8c5e95be31ac03923ef67d91748ec8564c5)) +* Remove backup auth check from new session ([#544](https://github.com/agentclientprotocol/claude-agent-acp/issues/544)) ([32b16c1](https://github.com/agentclientprotocol/claude-agent-acp/commit/32b16c1ad99394238b0e5cd6c2f761c12debe142)) + +## [0.26.0](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.25.3...v0.26.0) (2026-04-08) + + +### Features + +* Update claude-agent-sdk to 0.2.96 ([#526](https://github.com/agentclientprotocol/claude-agent-acp/issues/526)) ([c073131](https://github.com/agentclientprotocol/claude-agent-acp/commit/c07313148808a55f27f385c10babbaf7511a7f12)) + + +### Bug Fixes + +* Remove bun builds from release ([#525](https://github.com/agentclientprotocol/claude-agent-acp/issues/525)) ([fcf5aaf](https://github.com/agentclientprotocol/claude-agent-acp/commit/fcf5aaf06dfe9f7d1b285b976eeb2d1e20ea8dec)) +* Use TUI login for remote environments ([#523](https://github.com/agentclientprotocol/claude-agent-acp/issues/523)) ([cc73e37](https://github.com/agentclientprotocol/claude-agent-acp/commit/cc73e37b41678aa67813c4fbef66ba33ca538743)) + +## [0.25.3](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.25.2...v0.25.3) (2026-04-06) + + +### Bug Fixes + +* Drop claude-agent-sdk back to 0.2.91 to fix broken import ([#513](https://github.com/agentclientprotocol/claude-agent-acp/issues/513)) ([26f3e8a](https://github.com/agentclientprotocol/claude-agent-acp/commit/26f3e8a5216295985fadb80fb3b977045c0c1b2c)) +* Recreate resumed sessions when params change ([#515](https://github.com/agentclientprotocol/claude-agent-acp/issues/515)) ([aa82193](https://github.com/agentclientprotocol/claude-agent-acp/commit/aa82193330026bae132fed8391c47dde777dcf5a)) + +## [0.25.2](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.25.1...v0.25.2) (2026-04-06) + + +### Bug Fixes + +* prioritize ANTHROPIC_MODEL env var over settings.model in model … ([#505](https://github.com/agentclientprotocol/claude-agent-acp/issues/505)) ([bea1a40](https://github.com/agentclientprotocol/claude-agent-acp/commit/bea1a40e9bfe1e06672b118a727f9339def3be23)) + +## [0.25.1](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.25.0...v0.25.1) (2026-04-06) + + +### Bug Fixes + +* add `auto` to valid modes in applySessionMode to fix mode cycling ([#507](https://github.com/agentclientprotocol/claude-agent-acp/issues/507)) ([15e91fb](https://github.com/agentclientprotocol/claude-agent-acp/commit/15e91fb5c3449de2600b583cb7a8d36b5b510443)) + +## [0.25.0](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.24.2...v0.25.0) (2026-04-03) + + +### Features + +* Add auto permission mode support ([#501](https://github.com/agentclientprotocol/claude-agent-acp/issues/501)) ([a161453](https://github.com/agentclientprotocol/claude-agent-acp/commit/a16145396fe2e6ead8734478961b2de65707e335)) +* Add separate Claude and Console terminal logins ([#502](https://github.com/agentclientprotocol/claude-agent-acp/issues/502)) ([063cd35](https://github.com/agentclientprotocol/claude-agent-acp/commit/063cd353809df8f3dda9e57d8ea848c0a6d44257)) +* Update to claude-agent-sdk 0.2.91 ([#500](https://github.com/agentclientprotocol/claude-agent-acp/issues/500)) ([65a2230](https://github.com/agentclientprotocol/claude-agent-acp/commit/65a223038576d72b74e1483fed10e982a1f842bd)) + + +### Bug Fixes + +* log warnings for malformed settings files instead of silent fallback ([#486](https://github.com/agentclientprotocol/claude-agent-acp/issues/486)) ([ae6c388](https://github.com/agentclientprotocol/claude-agent-acp/commit/ae6c38831415f9fc1de2d3dd1d4a247becbbd32f)) +* prevent race conditions in SettingsManager setCwd and debounce ([#485](https://github.com/agentclientprotocol/claude-agent-acp/issues/485)) ([7506223](https://github.com/agentclientprotocol/claude-agent-acp/commit/7506223cffb1aba4b4560feda11f69a1395a8c9d)) +* use current model's context window for usage_update size ([#412](https://github.com/agentclientprotocol/claude-agent-acp/issues/412)) ([d07799d](https://github.com/agentclientprotocol/claude-agent-acp/commit/d07799d7b3b4e438c8b158266c79723a0b592c07)) + +## [0.24.2](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.24.1...v0.24.2) (2026-03-27) + + +### Bug Fixes + +* Add explicit type checks for MCP servers (http/sse) ([#487](https://github.com/agentclientprotocol/claude-agent-acp/issues/487)) ([e00a439](https://github.com/agentclientprotocol/claude-agent-acp/commit/e00a43901fa2b4fd7d582e1de57277be88233007)) + +## [0.24.1](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.24.0...v0.24.1) (2026-03-26) + + +### Bug Fixes + +* Cleanup based on new idle state [#463](https://github.com/agentclientprotocol/claude-agent-acp/issues/463) ([#480](https://github.com/agentclientprotocol/claude-agent-acp/issues/480)) ([23b3073](https://github.com/agentclientprotocol/claude-agent-acp/commit/23b30730253752f0bc4e30b619a6236f16fafdb9)) + +## 0.24.0 + +Rename from `@zed-industries/claude-agent-acp` to `@agentclientprotocol/claude-agent-acp`. + +We are moving this to the main ACP org to better allow multiple teams to contribute and maintain this adapter. + +## 0.23.1 + +- Add back error_during_execution break point (#469) + +## 0.23.0 + +- Use idle session state as end of turn (#463) +- Update claude-agent-sdk to 0.2.83 (#462) +- Fix handling of local-only slash commands (#432) +- fix: correct null check for gatewayAuthMeta in subscription validation (#455) +- fix: include both stdout and stderr in Bash tool output (#456) +- fix: prevent prompt loop hang when cancel races with first result (#458) +- fix: dispose SettingsManager on session close to prevent resource leaks (#454) +- fix: restore plan content in ExitPlanMode tool call (#451) + +## 0.22.2 + +- Add experimental meta param for testing additional directories + +## 0.22.1 + +- Fix: invalid auth required state in gateway mode + +## 0.22.0 + +- Use stable list sessions method (#429) +- Use correct Claude CLI path for static binaries (#428) +- Update claude-agent-sdk to 0.2.76 (#427) +- fix: resolve model aliases in setSessionConfigOption (#401) (#403) +- Reuse existing sessions for load/resume if possible (#426) +- Remove interrupt flag from deny responses (#425) +- Allow Bypass permissions mode after Exiting plan (#410) +- Don't get out of sync when background task creates new init/result (try 2) (#400) +- Add session/close support (#409) + +## 0.21.0 + +- Update to claude-agent-sdk 0.2.71 +- show project-relative paths in tool call titles +- Lib: pass through tools array to control built-in tool availability +- fix: skip user replay +- fix: handle renamed Agent tool in toolInfoFromToolUse + +## 0.20.2 + +- Update to @anthropic-ai/claude-agent-sdk@0.2.68 + +## 0.20.1 + +- fix: inherit process.env when spawning agent subprocess + +## 0.20.0 + +- Update to @anthropic-ai/claude-agent-sdk@0.2.63 +- Respect user settings for permission mode and model selection +- Better handling of concurrent prompts +- Support --cli for node as well +- Propagate max_tokens stop reason instead of throwing internal error +- fix: throw resourceNotFound when loadSession fails to resume +- fix: add missing zod dependency +- Surface better error message when Claude Code process exits unexpectedly + +## 0.19.2 + +- Fix for broken notifications when reloading session messages + +## 0.19.1 + +- Support windows arm builds and clean up artifact files + +## 0.19.0 + +- Update to @anthropic-ai/claude-agent-sdk@0.2.62 +- Use SDK functions for listing and loading session history +- Build single-file executables using bun. +- Fix for overwritten disallowed tools. + +## 0.18.0 + +- Switch over to built-in Claude tools. We no longer replicate specific ACP tools and just rely on sending updates based on Claude's internal tools. This means it won't use client capabilities for files or terminals, but also means there will be less difference and hopefully issues arising from the differences in behavior. +- Support ACP session config options: https://agentclientprotocol.com/protocol/session-config-options +- Fix for image output from tool calls. + +## 0.17.1 + +- Update to @anthropic-ai/claude-agent-sdk@0.2.45 to add access to Sonnet 4.6 + +## 0.17.0 + +Rename from `@zed-industries/claude-code-acp` to `@zed-industries/claude-agent-acp` to align with the current [branding guidelines](https://platform.claude.com/docs/en/agent-sdk/overview#branding-guidelines) + +## 0.16.2 + +- Update to @anthropic-ai/claude-agent-sdk@0.2.44 +- fix: Replace all non-alphanumeric characters for session loading in encodeProjectPath (#307) +- don't include /login slash command in login command (#315) + +## 0.16.1 + +- Update to @anthropic-ai/claude-agent-sdk@0.2.38 +- Fix incorrect paths for session/list +- Fix available commands after loading a session +- Make loading session more permissive for finding events +- Fix overriding user-provided disallowedTools + +## 0.16.0 + +- Update to @anthropic-ai/claude-agent-sdk@0.2.34 +- Experimental support for session loading + +## 0.15.0 + +- Update to @anthropic-ai/claude-agent-sdk@0.2.32 (adds support for Opus 4.6 and 1M context Opus) + +## 0.14.0 + +- Update to @anthropic-ai/claude-agent-sdk@0.2.29 +- Update to using the recommended `CLAUDE_CONFIG_DIR` env variable for setting where config files are kept +- Support /context command +- Fix incorrect context type mapping for tool calls +- Fix glob metching for file permissions on Windows +- Support the `IS_SANDBOX` env var for supporting bypass permissions in root mode +- Fix missing notification for entering plan mode +- Experimental unstable support for listing sessions + +## 0.13.2 + +- Update to @anthropic-ai/claude-agent-sdk@0.2.22 +- Fix: return content from ACP write tool to help with issues with alternate providers. + +## 0.13.1 + +- Update to @anthropic-ai/claude-agent-sdk@0.2.7 +- Add TypeScript declaration files for library users +- Fixed error handling in custom ACP focused MCP tools + +## 0.13.0 + +- Update to @anthropic-ai/claude-agent-sdk@0.2.6 +- Update to @agentclientprotocol/sdk@0.13.0 + +## 0.12.6 + +- Fix model selection + +## 0.12.5 + +- Update to @anthropic-ai/claude-agent-sdk@v0.1.70 +- Unstable implementation of resuming sessions + +## 0.12.4 + +- Update to @anthropic-ai/claude-agent-sdk@v0.1.67 +- Better respect permissions specified in settings files +- Unstable implementation of forking + +## 0.12.3 + +- Update to @anthropic-ai/claude-agent-sdk@v0.1.65 +- Update to @agentclientprotocol/sdk@0.9.0 +- Allow agent to write plans and todos to its config directory +- Fix experimental resume ids + +## 0.12.2 + +- Fix duplicate tool use IDs error + +## 0.12.1 + +- Update to @anthropic-ai/claude-agent-sdk@v0.1.61 +- Update to @agentclientprotocol/sdk@0.8.0 + +## 0.12.0 + +- Update to @anthropic-ai/claude-agent-sdk@v0.1.59 + - Brings Opus to Claude Pro plans + - Support "Don't Ask" profile +- Unify ACP + Claude Code session ids + +## 0.11.0 + +- Update to @anthropic-ai/claude-agent-sdk@v0.1.57 +- Removed dependency on @anthropic-ai/claude-code since this is no longer needed + +## 0.10.10 + +- Update to @agentclientprotocol/sdk@0.7.0 + +## 0.10.9 + +- Update to @anthropic-ai/claude-agent-sdk@v0.1.55 +- Allow defining a custom logger when used as a library +- Allow specifying custom options when used as a library +- Add `CLAUDECODE=1` to terminal invocations to match default Claude Code behavior + +## 0.10.8 + +- Update to @anthropic-ai/claude-agent-sdk@v0.1.51 (adds support for Opus 4.5) + +## 0.10.7 + +- Fix read/edit tool error handling so upstream errors surface +- Update to @anthropic-ai/claude-agent-sdk@v0.1.50 + +## 0.10.6 + +- Disable experimental terminal auth support for now, as it was causing issues on Windows. Will revisit with a fix later. +- Update to @anthropic-ai/claude-agent-sdk@v0.1.46 + +## 0.10.5 + +- Better error messages at end of turn if there were any +- Add experimental support for disabling built-in tools via \_meta flag +- Update to @anthropic-ai/claude-agent-sdk@v0.1.44 + +## 0.10.4 + +- Fix tool call titles not appearing during approval in some cases +- Update to @anthropic-ai/claude-agent-sdk@v0.1.42 + +## 0.10.3 + +- Fix for experimental terminal auth support + +## 0.10.2 + +- Fix incorrect stop reason for tool call refusals + +## 0.10.1 + +- Add additional structured metadata to tool calls +- Update to @anthropic-ai/claude-agent-sdk@v0.1.37 + +## 0.10.0 + +- Update to @anthropic-ai/claude-agent-sdk@v0.1.30 +- Use `canUseTool` callback instead of launching an HTTP MCP server for permission checks. + +## 0.9.0 + +- Support slash commands coming from MCP servers (Prompts) + +## 0.8.0 + +- Revert changes to filename for cli entrypoint +- Provide library entrypoint via lib.ts + +## 0.7.0 + +- Allow importing from this package as a library in addition to running it as a CLI. Allows for easier integration into existing node applications. +- Update to @anthropic-ai/claude-agent-sdk@v0.1.27 + +## 0.6.10 + +- Provide `agentInfo` on initialization response. +- Update to @agentclientprotocol/sdk@0.5.1 +- Fix crash when receiving a hook_response event +- Fix for invalid locations when read call has no path + +## 0.6.9 + +- Update to @anthropic-ai/claude-agent-sdk@v0.1.26 +- Update to @agentclientprotocol/sdk@0.5.0 + +## 0.6.8 + +- Fix for duplicate tokens appearing in thread with streaming enabled +- Update to @anthropic-ai/claude-agent-sdk@v0.1.23 +- Update to @agentclientprotocol/sdk@0.4.9 + +## 0.6.7 + +- Fix for invalid plan input from the model introduced in latest agent-sdk + +## 0.6.6 + +- Do not enable bypassPermissions mode if in root/sudo mode, because Claude Code will not start + +## 0.6.5 + +- Fix for duplicated text content after streaming + +## 0.6.4 + +- Support streaming partial messages! +- Update to @anthropic-ai/claude-agent-sdk@v0.1.21 + +## 0.6.3 + +- Fix issue where slash commands were loaded before initialization was complete. + +## 0.6.2 + +- Fix bug where mode selection would sometimes fire before initialization was complete. +- Update to @anthropic-ai/claude-agent-sdk@v0.1.19 + +## 0.6.1 + +- Fix to allow bypassPermissions mode to be selected (it wasn't permitted previously) + +## 0.6.0 + +- Provide a model selector. We use the "default" model by default, and the user can change it via the client. +- Make sure writes require permissions when necessary: https://github.com/zed-industries/claude-code-acp/pull/92 +- Add support for appending or overriding the system prompt: https://github.com/zed-industries/claude-code-acp/pull/91 +- Update to @anthropic-ai/claude-agent-sdk@v0.1.15 +- Update to @agentclientprotocol/sdk@0.4.8 + +## 0.5.5 + +- Migrate to @agentclientprotocol/sdk@0.4.5 +- Update to @anthropic-ai/claude-agent-sdk@v0.1.13 + +## 0.5.4 + +- Update to @anthropic-ai/claude-agent-sdk@v0.1.11 +- Enable setting CLAUDE_CODE_EXECUTABLE to override the executable used by the SDK https://github.com/zed-industries/claude-code-acp/pull/86 + +## 0.5.3 + +- Update to @anthropic-ai/claude-agent-sdk@v0.1.8 +- Update to @zed-industries/agent-client-protocol@v0.4.5 + +## 0.5.2 + +- Add back @anthropic-ai/claude-code@2.0.1 as runtime dependency + +## 0.5.1 + +- Update to @anthropic-ai/claude-agent-sdk@v0.1.1 +- Make improvements to ACP tools provided to the model + +## 0.5.0 + +- Migrate to @anthropic-ai/claude-agent-sdk@v0.1.0 + +## v0.4.7 + +- More efficient file reads from the client. + +## v0.4.6 + +- Update to @anthropic-ai/claude-code@v1.0.128 + +## v0.4.5 + +- Update to @anthropic-ai/claude-code@v1.0.124 +- Update to @zed-industries/agent-client-protocol@v0.4.3 + +## v0.4.4 + +- Update to @anthropic-ai/claude-code@v1.0.123 +- Update to @zed-industries/agent-client-protocol@v0.4.2 + +## v0.4.3 + +- Move ACP tools over MCP from an "http" MCP server to an "sdk" one so more tool calls can stay in-memory. +- Update to @anthropic-ai/claude-code@v1.0.119 +- Update to @zed-industries/agent-client-protocol@v0.4.0 + +## v0.4.2 + +- Fix missing package.json metadata + +## v0.4.1 + +- Add support for /compact command [ecfd36a](https://github.com/zed-industries/claude-code-acp/commit/ecfd36afa6c4e31f12e1daf9b8a2bdc12dda1794) +- Add default limits to read tool [7bd1638](https://github.com/zed-industries/claude-code-acp/commit/7bd163818bb959b11fd2c933eff73ad83c57abb8) +- Better rendering of Tool errors [491efe3](https://github.com/zed-industries/claude-code-acp/commit/491efe32e8547075842e448d873fc01b2ffabf3a) +- Load managed-settings.json [f691024](https://github.com/zed-industries/claude-code-acp/commit/f691024350362858e00b97248ac68e356d2331c2) +- Update to @anthropic-ai/claude-code@v1.0.113 +- Update to @zed-industries/agent-client-protocol@v0.3.1 diff --git a/qiming-claude-code-acp-ts/LICENSE b/qiming-claude-code-acp-ts/LICENSE new file mode 100644 index 00000000..c24b0535 --- /dev/null +++ b/qiming-claude-code-acp-ts/LICENSE @@ -0,0 +1,191 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + Copyright 2025 Zed Industries, Inc. and contributors + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/qiming-claude-code-acp-ts/README.md b/qiming-claude-code-acp-ts/README.md new file mode 100644 index 00000000..cd4e836e --- /dev/null +++ b/qiming-claude-code-acp-ts/README.md @@ -0,0 +1,55 @@ +# ACP adapter for Claude Code (TypeScript) + +[![npm](https://img.shields.io/npm/v/claude-code-acp-ts)](https://www.npmjs.com/package/claude-code-acp-ts) + +> **Note**: This is a fork of [@zed-industries/claude-code-acp](https://github.com/zed-industries/claude-code-acp), maintained by [nuwax-ai](https://github.com/nuwax-ai). + +Use [Claude Agent SDK](https://platform.claude.com/docs/en/agent-sdk/overview#branding-guidelines) from [ACP-compatible](https://agentclientprotocol.com) clients! + +This tool implements an ACP agent by using the official [Claude Agent SDK](https://platform.claude.com/docs/en/agent-sdk/overview), supporting: + +- Context @-mentions +- Images +- Tool calls (with permission requests) +- Following +- Edit review +- TODO lists +- Interactive (and background) terminals +- Custom [Slash commands](https://docs.anthropic.com/en/docs/claude-code/slash-commands) +- Client MCP servers + +Learn more about the [Agent Client Protocol](https://agentclientprotocol.com/). + +## Contribution Policy + +This project does not require a Contributor License Agreement (CLA). Instead, contributions are accepted under the following terms: + +The latest version of Zed can already use this adapter out of the box. + +To use Claude Code, open the Agent Panel and click "New Claude Code Thread" from the `+` button menu in the top-right: + +https://github.com/user-attachments/assets/ddce66c7-79ac-47a3-ad59-4a6a3ca74903 + +Read the docs on [External Agent](https://zed.dev/docs/ai/external-agents) support. + +### Other clients + +Or try it with any of the other [ACP compatible clients](https://agentclientprotocol.com/overview/clients)! + +#### Installation + +Install the adapter from `npm`: + +```bash +npm install -g claude-code-acp-ts +``` + +You can then use `claude-code-acp-ts` as a regular ACP agent: + +```bash +ANTHROPIC_API_KEY=sk-... claude-code-acp-ts +``` + +## License + +Apache-2.0 diff --git a/qiming-claude-code-acp-ts/docs/RELEASES.md b/qiming-claude-code-acp-ts/docs/RELEASES.md new file mode 100644 index 00000000..067e1318 --- /dev/null +++ b/qiming-claude-code-acp-ts/docs/RELEASES.md @@ -0,0 +1,24 @@ +## How to publish a new release + +1. Create a new branch prep-vX.X.X + +```sh +git checkout -b prep-vX.X.X +``` + +2. Bump the version in package.json + +```sh +npm version vX.X.X +``` + +3. Add a new entry in CHANGELOG.md +4. Create a PR for `prev-vX.X.X` & merge it +5. Create tag and push it + +```sh +git tag vX.X.X +git push origin vX.X.X +``` + +6. Paste CHANGELOG into release notes and publish the version on GitHub diff --git a/qiming-claude-code-acp-ts/docs/model-configuration.md b/qiming-claude-code-acp-ts/docs/model-configuration.md new file mode 100644 index 00000000..6cc58aab --- /dev/null +++ b/qiming-claude-code-acp-ts/docs/model-configuration.md @@ -0,0 +1,56 @@ +# Model Configuration + +When using claude-agent-acp with alternative providers (e.g. AWS Bedrock), model IDs differ from the direct Anthropic API. The `CLAUDE_MODEL_CONFIG` environment variable lets you configure model overrides and availability at the deployment level. + +## `CLAUDE_MODEL_CONFIG` + +A JSON string with two optional fields: + +| Field | Type | Description | +| ----------------- | ------------------------ | ------------------------------------------------------------------------------------------------------------- | +| `modelOverrides` | `Record` | Maps Anthropic model IDs to provider-specific model IDs (e.g. Bedrock model IDs or ARNs) | +| `availableModels` | `string[]` | Restricts which models are offered to users. Accepts aliases (`"opus"`), prefixes (`"opus-4-5"`), or full IDs | + +### Examples + +**Bedrock model overrides:** + +```bash +CLAUDE_MODEL_CONFIG='{"modelOverrides":{"claude-opus-4-6":"us.anthropic.claude-opus-4-6-v1","claude-sonnet-4-5":"us.anthropic.claude-sonnet-4-5-v1"}}' +``` + +**Restrict available models:** + +```bash +CLAUDE_MODEL_CONFIG='{"availableModels":["opus","sonnet"]}' +``` + +**Both together:** + +```bash +CLAUDE_MODEL_CONFIG='{"modelOverrides":{"claude-opus-4-6":"us.anthropic.claude-opus-4-6-v1","claude-sonnet-4-5":"us.anthropic.claude-sonnet-4-5-v1"},"availableModels":["opus","sonnet"]}' +``` + +**Full Bedrock example:** + +```bash +CLAUDE_CODE_USE_BEDROCK=1 \ +AWS_REGION=us-west-2 \ +CLAUDE_MODEL_CONFIG='{"modelOverrides":{"claude-opus-4-6":"us.anthropic.claude-opus-4-6-v1"}}' \ +node dist/index.js +``` + +## Precedence + +When an ACP caller provides `settings` via `_meta.claudeCode.options.settings` in the `sessions/create` request, `CLAUDE_MODEL_CONFIG` is ignored entirely. The env var is a deployment-level fallback for cases where the caller does not configure model settings itself. + +| Source | Priority | +| -------------------------------------------- | ----------------------------------------------------- | +| `_meta.claudeCode.options.settings` (caller) | Highest — used if present | +| `CLAUDE_MODEL_CONFIG` (env var) | Fallback — used only when caller provides no settings | + +## Format details + +- The value must be valid JSON. Invalid JSON will cause session creation to fail with a parse error. +- Only `modelOverrides` and `availableModels` keys are read; other keys in the JSON are ignored. +- Both fields map directly to the Claude Agent SDK's `Settings` type. diff --git a/qiming-claude-code-acp-ts/eslint.config.js b/qiming-claude-code-acp-ts/eslint.config.js new file mode 100644 index 00000000..a7b54814 --- /dev/null +++ b/qiming-claude-code-acp-ts/eslint.config.js @@ -0,0 +1,48 @@ +import js from "@eslint/js"; +import tseslint from "@typescript-eslint/eslint-plugin"; +import tsparser from "@typescript-eslint/parser"; +import prettierConfig from "eslint-config-prettier"; +import globals from "globals"; + +export default [ + { + ignores: ["node_modules/", "dist/", "coverage/", "*.min.js", "*.config.js", ".github/"], + }, + js.configs.recommended, + { + files: ["src/**/*.ts"], + languageOptions: { + ecmaVersion: 2020, + sourceType: "module", + parser: tsparser, + parserOptions: { + project: "./tsconfig.json", + }, + globals: { + ...globals.node, + }, + }, + plugins: { + "@typescript-eslint": tseslint, + }, + rules: { + ...tseslint.configs.recommended.rules, + "@typescript-eslint/explicit-function-return-type": "off", + "@typescript-eslint/no-explicit-any": "off", + "@typescript-eslint/no-unused-vars": [ + "error", + { + argsIgnorePattern: "^_", + }, + ], + "no-console": "off", + "no-constant-condition": "off", + "default-case": "error", + "prefer-const": "error", + "no-var": "error", + eqeqeq: ["error", "always"], + curly: ["error", "all"], + }, + }, + prettierConfig, +]; diff --git a/qiming-claude-code-acp-ts/package-lock.json b/qiming-claude-code-acp-ts/package-lock.json new file mode 100644 index 00000000..f9a8be15 --- /dev/null +++ b/qiming-claude-code-acp-ts/package-lock.json @@ -0,0 +1,4010 @@ +{ + "name": "@agentclientprotocol/claude-agent-acp", + "version": "0.37.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@agentclientprotocol/claude-agent-acp", + "version": "0.37.0", + "license": "Apache-2.0", + "dependencies": { + "@agentclientprotocol/sdk": "0.22.1", + "@anthropic-ai/claude-agent-sdk": "0.3.146", + "zod": "^3.25.0 || ^4.0.0" + }, + "bin": { + "claude-agent-acp": "dist/index.js" + }, + "devDependencies": { + "@anthropic-ai/sdk": "0.97.1", + "@eslint/js": "10.0.1", + "@types/node": "25.9.1", + "@typescript-eslint/eslint-plugin": "8.59.4", + "@typescript-eslint/parser": "8.59.4", + "eslint": "10.4.0", + "eslint-config-prettier": "10.1.8", + "globals": "17.6.0", + "prettier": "3.8.3", + "ts-node": "10.9.2", + "typescript": "6.0.3", + "vitest": "4.1.7" + } + }, + "node_modules/@agentclientprotocol/sdk": { + "version": "0.22.1", + "resolved": "https://registry.npmjs.org/@agentclientprotocol/sdk/-/sdk-0.22.1.tgz", + "integrity": "sha512-DfqXtl/8gO9NImq094MTaCXEU2vkhh6v7q/kT+9UjZxUqj8hYaya2OjLVIqn16MzNHcXEpShTR2RIauLSYeDQQ==", + "license": "Apache-2.0", + "peerDependencies": { + "zod": "^3.25.0 || ^4.0.0" + } + }, + "node_modules/@anthropic-ai/claude-agent-sdk": { + "version": "0.3.146", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk/-/claude-agent-sdk-0.3.146.tgz", + "integrity": "sha512-hK9/Ng+hOyexUemTxdIUsSWJ9o2LFi2YNWzHwz8/YMCohUYOnFMZkBiENvUAb0WIc5hieOyBZrOIlg5OewuJMg==", + "license": "SEE LICENSE IN README.md", + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "@anthropic-ai/claude-agent-sdk-darwin-arm64": "0.3.146", + "@anthropic-ai/claude-agent-sdk-darwin-x64": "0.3.146", + "@anthropic-ai/claude-agent-sdk-linux-arm64": "0.3.146", + "@anthropic-ai/claude-agent-sdk-linux-arm64-musl": "0.3.146", + "@anthropic-ai/claude-agent-sdk-linux-x64": "0.3.146", + "@anthropic-ai/claude-agent-sdk-linux-x64-musl": "0.3.146", + "@anthropic-ai/claude-agent-sdk-win32-arm64": "0.3.146", + "@anthropic-ai/claude-agent-sdk-win32-x64": "0.3.146" + }, + "peerDependencies": { + "@anthropic-ai/sdk": ">=0.93.0", + "@modelcontextprotocol/sdk": "^1.29.0", + "zod": "^4.0.0" + } + }, + "node_modules/@anthropic-ai/claude-agent-sdk-darwin-arm64": { + "version": "0.3.146", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-darwin-arm64/-/claude-agent-sdk-darwin-arm64-0.3.146.tgz", + "integrity": "sha512-0IIvlEaenq2CRSVx5Bo5BaCtHQXS87GancM35WKEYveGVLn6DI+5G7ikYuTE4AKRPkMnogFtY4BJt6LulWGj+A==", + "cpu": [ + "arm64" + ], + "license": "SEE LICENSE IN LICENSE.md", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@anthropic-ai/claude-agent-sdk-darwin-x64": { + "version": "0.3.146", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-darwin-x64/-/claude-agent-sdk-darwin-x64-0.3.146.tgz", + "integrity": "sha512-Dk5xJ03Ff1JXbMRP1t2wc/TyfY6xF/2Ysp31wMhFPjoNiKSPHMWaIg242+T3CHdxLWmJ8plWHL1HL5cyZ/LCkw==", + "cpu": [ + "x64" + ], + "license": "SEE LICENSE IN LICENSE.md", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@anthropic-ai/claude-agent-sdk-linux-arm64": { + "version": "0.3.146", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-arm64/-/claude-agent-sdk-linux-arm64-0.3.146.tgz", + "integrity": "sha512-mzBXDDWWBAC/vDtAYpO1G/dq5QvJtYSPXsqcb+sNdcDhiuf4IYnYp7ytRncYlsUNDkLmX6Gk2jkWAHUUA2Lozg==", + "cpu": [ + "arm64" + ], + "license": "SEE LICENSE IN LICENSE.md", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@anthropic-ai/claude-agent-sdk-linux-arm64-musl": { + "version": "0.3.146", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-arm64-musl/-/claude-agent-sdk-linux-arm64-musl-0.3.146.tgz", + "integrity": "sha512-QlCid0ucdrmhUAOewfQjaofN2wlokWcfFTxSFePTSj1umk35JO7TDFP700F7jU49r1fPWIdvJpPwWGyB0DeFPA==", + "cpu": [ + "arm64" + ], + "license": "SEE LICENSE IN LICENSE.md", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@anthropic-ai/claude-agent-sdk-linux-x64": { + "version": "0.3.146", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-x64/-/claude-agent-sdk-linux-x64-0.3.146.tgz", + "integrity": "sha512-B2baXU1tCBT5CVlD7jJMKjpC4xdO45NUIWpqImmwuOfKvlM/PITjyTXyTY662mGZf1dBmdqBBsqirwFH/jhi8Q==", + "cpu": [ + "x64" + ], + "license": "SEE LICENSE IN LICENSE.md", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@anthropic-ai/claude-agent-sdk-linux-x64-musl": { + "version": "0.3.146", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-x64-musl/-/claude-agent-sdk-linux-x64-musl-0.3.146.tgz", + "integrity": "sha512-E3coK1ThQT08KIX80RLcsq7DWXFllCKOzoOe32it/bdtY56TBgPY9xemwXhIJ+cVBHTI9/MpBSIlKBcFCt+yQA==", + "cpu": [ + "x64" + ], + "license": "SEE LICENSE IN LICENSE.md", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@anthropic-ai/claude-agent-sdk-win32-arm64": { + "version": "0.3.146", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-win32-arm64/-/claude-agent-sdk-win32-arm64-0.3.146.tgz", + "integrity": "sha512-CIwQxGX2r/yWpjCJ6ahB3smKXhghWgGTxL98+LGW52TUwqTiBnlNrH9DPqqgv1/+Hyquw6xfLrKU+StyfMgiLw==", + "cpu": [ + "arm64" + ], + "license": "SEE LICENSE IN LICENSE.md", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@anthropic-ai/claude-agent-sdk-win32-x64": { + "version": "0.3.146", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-win32-x64/-/claude-agent-sdk-win32-x64-0.3.146.tgz", + "integrity": "sha512-qmxrsyaqA8s4HShqJls7ZCRjdoqN66Jo/hbjQNB3uHepD8tEO1iD19aPV4+osdLT7feMkhDBfLT07Q30R2NB5w==", + "cpu": [ + "x64" + ], + "license": "SEE LICENSE IN LICENSE.md", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@anthropic-ai/sdk": { + "version": "0.97.1", + "resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.97.1.tgz", + "integrity": "sha512-wOf7AUeJPitcVpvKO4UMu63mWH5SaVipkGd7OOQJt/G6VYGlV8D2Gp9dLxOrttDJh/9gqPqdaBwDGcBevumeAg==", + "license": "MIT", + "dependencies": { + "json-schema-to-ts": "^3.1.1", + "standardwebhooks": "^1.0.0" + }, + "bin": { + "anthropic-ai-sdk": "bin/cli" + }, + "peerDependencies": { + "zod": "^3.25.0 || ^4.0.0" + }, + "peerDependenciesMeta": { + "zod": { + "optional": true + } + } + }, + "node_modules/@babel/runtime": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz", + "integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@cspotcode/source-map-support": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", + "integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "0.3.9" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@emnapi/core": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", + "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.1", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", + "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", + "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", + "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.23.5", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.23.5.tgz", + "integrity": "sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^3.0.5", + "debug": "^4.3.1", + "minimatch": "^10.2.4" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.6.0.tgz", + "integrity": "sha512-ii6Bw9jJ2zi2cWA2Z+9/QZ/+3DX6kwaV5Q986D/CdP3Lap3w/pgQZ373FV7byY/i7L4IRH/G43I5dz1ClsCbpA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^1.2.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/core": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-1.2.1.tgz", + "integrity": "sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/js": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-10.0.1.tgz", + "integrity": "sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "eslint": "^10.0.0" + }, + "peerDependenciesMeta": { + "eslint": { + "optional": true + } + } + }, + "node_modules/@eslint/object-schema": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-3.0.5.tgz", + "integrity": "sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.7.1.tgz", + "integrity": "sha512-rZAP3aVgB9ds9KOeUSL+zZ21hPmo8dh6fnIFwRQj5EAZl9gzR7wxYbYXYysAM8CTqGmUGyp2S4kUdV17MnGuWQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^1.2.1", + "levn": "^0.4.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@hono/node-server": { + "version": "1.19.14", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.14.tgz", + "integrity": "sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=18.14.1" + }, + "peerDependencies": { + "hono": "^4" + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", + "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/types": "^0.15.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz", + "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.2", + "@humanfs/types": "^0.15.0", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/types": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz", + "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz", + "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.0.3", + "@jridgewell/sourcemap-codec": "^1.4.10" + } + }, + "node_modules/@modelcontextprotocol/sdk": { + "version": "1.29.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.29.0.tgz", + "integrity": "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "@hono/node-server": "^1.19.9", + "ajv": "^8.17.1", + "ajv-formats": "^3.0.1", + "content-type": "^1.0.5", + "cors": "^2.8.5", + "cross-spawn": "^7.0.5", + "eventsource": "^3.0.2", + "eventsource-parser": "^3.0.0", + "express": "^5.2.1", + "express-rate-limit": "^8.2.1", + "hono": "^4.11.4", + "jose": "^6.1.3", + "json-schema-typed": "^8.0.2", + "pkce-challenge": "^5.0.0", + "raw-body": "^3.0.0", + "zod": "^3.25 || ^4.0", + "zod-to-json-schema": "^3.25.1" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@cfworker/json-schema": "^4.1.1", + "zod": "^3.25 || ^4.0" + }, + "peerDependenciesMeta": { + "@cfworker/json-schema": { + "optional": true + }, + "zod": { + "optional": false + } + } + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz", + "integrity": "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.132.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.132.0.tgz", + "integrity": "sha512-FESMOxil5Se014ui/Eq8fT5uHJo6nIRwH0PfJrZJXs6Gek3ZVFOrpUv3YIZT20m+extU98Hg1Ym72U58rlsxUQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.2.tgz", + "integrity": "sha512-ZS4D1JPGn/MYQN/SYDWftIE/nVsM8j/AFOYEzAoOE2O3NktQOZru+/vYXGbR/qtdLdIfGCP0lcoJiYVzsEz+iQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.2.tgz", + "integrity": "sha512-vdFA9+C/rekyGce7WqHs/xoT0ioZEWaOFyZLIV1mEeNFaFDUQrPIo8Vs2GvJ6eetb3rzDUtUBgzto3ExpXJB3w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.2.tgz", + "integrity": "sha512-BewSOwTHazv77DTYiAZXSqqKZ4KP/KonFisDMVU7PImxoWfB2aepnPhd2E4SWz3zDzYgDNbs6jBmTdgNnF02GA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.2.tgz", + "integrity": "sha512-m41o7M0YWtUdqk61Tb+jnKb2rN++iRdIASlExkUoKfIAH30DOHCB8fVLzSUpbWHHU8esmEioY62PxzexE8MBuA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.2.tgz", + "integrity": "sha512-jcojB9H7W/jS29pMKWAK1N+fU99vXodHDTatS3b3y/XSOCiHo0kkA74pL3jJmkoQtYpOCxDvaKs1fo2Ij/1X5w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.2.tgz", + "integrity": "sha512-1jn6qDU5iiOgFgygDzKUuKP0maTi0/f1+sBLgvij/76C77Nm3ts6ufz9Bjg5q5dduxiUIxtq86JIoBvo1xQ4Ig==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.2.tgz", + "integrity": "sha512-QVLO/czFMdoMFSqlX3bcswcJNm/23r+qoa/jgtmFc/qEp6/jXmIkDjF/XIo8dPfGaiwy1xfQn8o77L79GeXFgw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.2.tgz", + "integrity": "sha512-hgO5Abm0w5UL6FEa2iFnZqo2KlK7TQ5QhV5x09hujBf7t5KzHQ1VmfPuTpqRy/rNlSxua3eWH374xxiVrP+lcA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.2.tgz", + "integrity": "sha512-fy8rXxuYEu602abC8MUNaPjYLIFzReOaEIEMKMUa0rFEUxNpVXhs15KSSQ4qlqSaM7B6rcj9rDZgADh/IGDzLQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.2.tgz", + "integrity": "sha512-0+bOkiQ779+r1WpoHOWHqncvyySci0vKph+myNDYb+im6meJAzHQXay6oEgnkHuUGouM1LKTZwqKpBow6Kj7CQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.2.tgz", + "integrity": "sha512-mjSkrzZK5Qsl0a9d1JgILOiuZOSDTVdKENcSXBoqbzSrspLR/4/IRVDo5wd2GgZjNss/viBFJdeq+j7qH2nypw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.2.tgz", + "integrity": "sha512-1v5vHasdfQAZoEHakBV72LIFAC9JjnymsiKxp+GEr/ma3+NJCPSaYK+qavInOovJkgwFrs7GccX2d6IgDA3Z5w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.2.tgz", + "integrity": "sha512-mb1VobWn6NheziTk5/WEaR6AKVbrwT5sOi6C7zk3gy/pD1qtJfU1j4PgTo2NJnOtbL9Dl3Aeei8w9jJ7qC2jZQ==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.10.0", + "@emnapi/runtime": "1.10.0", + "@napi-rs/wasm-runtime": "^1.1.4" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.2.tgz", + "integrity": "sha512-SqKonF56vA/L2yHwHYcEp2P34URpOZ7d1fS635cTkpDnUtEGdUbhI6NzsPdqeSWvAAeGDrxjWjNmibDIdFf9/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.2.tgz", + "integrity": "sha512-v7qRI7gXLRINcOGXt+7YmAZ6iFuyZVMIoXAxhd8oP+DR9dLfL9GfNIx7PLMxmhZdvq8waUJBQiWN9EKNy+TRBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@stablelib/base64": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@stablelib/base64/-/base64-1.0.1.tgz", + "integrity": "sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==", + "license": "MIT" + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tsconfig/node10": { + "version": "1.0.12", + "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.12.tgz", + "integrity": "sha512-UCYBaeFvM11aU2y3YPZ//O5Rhj+xKyzy7mvcIoAjASbigy8mHMryP5cK7dgjlz2hWxh1g5pLw084E0a/wlUSFQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tsconfig/node12": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/@tsconfig/node12/-/node12-1.0.11.tgz", + "integrity": "sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tsconfig/node14": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@tsconfig/node14/-/node14-1.0.3.tgz", + "integrity": "sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tsconfig/node16": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@tsconfig/node16/-/node16-1.0.4.tgz", + "integrity": "sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.2", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz", + "integrity": "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/esrecurse": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz", + "integrity": "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "25.9.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.1.tgz", + "integrity": "sha512-xfrlY7UD5rMJk3ZVJP8BNzS28J36YJg+xp+LPXV1TdWxr8uMH5A860QNxYDGQe/ylDSgjxE52Q9VnO7p75tJxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": ">=7.24.0 <7.24.7" + } + }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.59.4", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.59.4.tgz", + "integrity": "sha512-PegsU+XfyJJNjd4+u/k6f9yTyp0lEXXiPopUNobZcIAUJFGICFLN+sP0Rb3JehVmiij1Ph0dFGYqODoRo/2+6A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.59.4", + "@typescript-eslint/type-utils": "8.59.4", + "@typescript-eslint/utils": "8.59.4", + "@typescript-eslint/visitor-keys": "8.59.4", + "ignore": "^7.0.5", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "8.59.4", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.59.4", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.59.4.tgz", + "integrity": "sha512-zORHqO/tuhxY1zWuTvMUqddRxpiFJ72xVfcNoWpqdLjs6lfPbuQBJuW4pk+49/uBMy7Ssr4bzgjiKmmDB1UbZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.59.4", + "@typescript-eslint/types": "8.59.4", + "@typescript-eslint/typescript-estree": "8.59.4", + "@typescript-eslint/visitor-keys": "8.59.4", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.59.4", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.59.4.tgz", + "integrity": "sha512-Ly00Vu4oAacfDeHp2Zg85ioNG6l8HG+tN1D7J+xTHSxu9y0awYKJ2zH1rFBn8ZSfuGK+7FxK3Cgl3uAz0aZZLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.59.4", + "@typescript-eslint/types": "^8.59.4", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.59.4", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.59.4.tgz", + "integrity": "sha512-mUeR/3H1WrTAddJrwut8OoPjfauaztMQmRwV5fQTUyNVJCLiUXXe4lGEyYIL2oFDpP7UtgbGJXCt72wT0z2S3Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.59.4", + "@typescript-eslint/visitor-keys": "8.59.4" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.59.4", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.59.4.tgz", + "integrity": "sha512-DLCpnKgD4alVxTBSKulK+gU1KCqOgUXfDRDXh2mZgzokQKa/70ax93I2uVO3m/LLvIAtWZIFoiifudmIqAxpMA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.59.4", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.59.4.tgz", + "integrity": "sha512-uonTuPAAKr9XaBGqJ3LjYTh72zy5DyGesljO9gtmk/eFW0W1fRHjnwVYKB35Lm8d5Q5CluEW3gPHjTvZTmgrfA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.59.4", + "@typescript-eslint/typescript-estree": "8.59.4", + "@typescript-eslint/utils": "8.59.4", + "debug": "^4.4.3", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.59.4", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.59.4.tgz", + "integrity": "sha512-F1o7WJcCq+bc8dwcO/YsSEOudAH8RDtaOhM6wcAQhcUsFhnWQl81JKy48q1hoxAU0qrzM89+31GYh1515Zde3Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.59.4", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.59.4.tgz", + "integrity": "sha512-F+RuOmcDXo4+TPdfd/TCLS3m2nw8gE9XXyZLrA3JBfaA5tz9TtdkyD3YJFmPxulyc2cKbEok/CvFE3MgSLWnag==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.59.4", + "@typescript-eslint/tsconfig-utils": "8.59.4", + "@typescript-eslint/types": "8.59.4", + "@typescript-eslint/visitor-keys": "8.59.4", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.59.4", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.59.4.tgz", + "integrity": "sha512-cYXeNAUsG4lJo5dbc1FcKm+JwIWrj1/UpTORsC6tGMjEZ81DYcvIr9/ueikhMa/Y/gDQYGp+YX9/xQrXje5BJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.59.4", + "@typescript-eslint/types": "8.59.4", + "@typescript-eslint/typescript-estree": "8.59.4" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.59.4", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.59.4.tgz", + "integrity": "sha512-U3gxVaDVnuZKhSspW/MzMxE1kq7zOdc072FcSNoqA1I9p8HyKbBFfEHoWckBAMgNMph4MamwS5iTVzFmrnt8TQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.59.4", + "eslint-visitor-keys": "^5.0.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@vitest/expect": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.7.tgz", + "integrity": "sha512-1R+tw0ortHEbZDGMymm+pN7/AFQ/RkFFdtd7EN+VBpynKmLbP8A3rpEXdshBJ7+8hQ9zBJh/i1s0yKNtxAnU7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.7", + "@vitest/utils": "4.1.7", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.7.tgz", + "integrity": "sha512-vY7nuamKgfvpA1Koa3oYIw/k7D6kZnpGyNMZW8loow2bsBYla1TFdqTaXncWdRn4pgwNs+90RhnXhJScDwQeJA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.7", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.7.tgz", + "integrity": "sha512-umgCarTOYQWIaDMvGDRZij+6b9oVeLIyJzfN+AS88e0ZOU3QTgNNSTtjQOpcvWr3np1N0j4WgZj+sb3oYBDscw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.7.tgz", + "integrity": "sha512-BapjmAQ2aI78WdMEfeUWivnfVzB+VPGwWRQcJE0OUq7qEeEcBsCSf+0T5iREBNE5nBb4wA5Ya0W6IA+sghdEFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.7", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.7.tgz", + "integrity": "sha512-ZacLzja+TmJeZ1h14xW2FB/WpeimUD3haBXQPyJqxvo8jQTmfeA8zv58mtjN2C7EHXZDYVcVYdYmAxjkWVvKCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.7", + "@vitest/utils": "4.1.7", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.7.tgz", + "integrity": "sha512-kbkI5LMWakyuTIvs6fUJ5qdIVb1XVKsYJAT4OJ938cHMROYMSfmoQdZy0aaAnjbbc8F61vkoTqz/Az+/HiIu5Q==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.7.tgz", + "integrity": "sha512-T532WBu791cBxJlCl6SO+J14l81DQx6uQHm1bQbmCDY7nqlEIgkza/UFnSBNaUtSf41unldDFjdOBYEQC4b5Hw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.7", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/accepts": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "license": "MIT", + "peer": true, + "dependencies": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/acorn": { + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/acorn-walk": { + "version": "8.3.5", + "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.5.tgz", + "integrity": "sha512-HEHNfbars9v4pgpW6SO1KSPkfoS0xVOM/9UzkJltjlsHZmJasxg8aXkuZa7SMf8vKGIBhpUsPluQSqhJFCqebw==", + "dev": true, + "license": "MIT", + "dependencies": { + "acorn": "^8.11.0" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "license": "MIT", + "peer": true, + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/arg": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz", + "integrity": "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==", + "dev": true, + "license": "MIT" + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/body-parser": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz", + "integrity": "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==", + "license": "MIT", + "peer": true, + "dependencies": { + "bytes": "^3.1.2", + "content-type": "^1.0.5", + "debug": "^4.4.3", + "http-errors": "^2.0.0", + "iconv-lite": "^0.7.0", + "on-finished": "^2.4.1", + "qs": "^6.14.1", + "raw-body": "^3.0.1", + "type-is": "^2.0.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/brace-expansion": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", + "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "peer": true, + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/content-disposition": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz", + "integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=6.6.0" + } + }, + "node_modules/cors": { + "version": "2.8.6", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", + "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", + "license": "MIT", + "peer": true, + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/create-require": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz", + "integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/diff": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.4.tgz", + "integrity": "sha512-X07nttJQkwkfKfvTPG/KSnE2OMdcUCao6+eXF3wmnIQRn2aPAHH3VxDbDOdegkd6JbPsXqShpvEOHfAT+nCNwQ==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.3.1" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "peer": true, + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT", + "peer": true + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-module-lexer": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.1.0.tgz", + "integrity": "sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "peer": true, + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT", + "peer": true + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.4.0.tgz", + "integrity": "sha512-loXy6bWOoP3EP6JA7jo6p5jMpBJmHmsNZM5SFRHLdh1MGOPurMnNBj4ZlAbaqUAaQWbCr7jHV4P7gzAyryZWkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.2", + "@eslint/config-array": "^0.23.5", + "@eslint/config-helpers": "^0.6.0", + "@eslint/core": "^1.2.1", + "@eslint/plugin-kit": "^0.7.1", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.14.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^9.1.2", + "eslint-visitor-keys": "^5.0.1", + "espree": "^11.2.0", + "esquery": "^1.7.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "minimatch": "^10.2.4", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-config-prettier": { + "version": "10.1.8", + "resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-10.1.8.tgz", + "integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==", + "dev": true, + "license": "MIT", + "bin": { + "eslint-config-prettier": "bin/cli.js" + }, + "funding": { + "url": "https://opencollective.com/eslint-config-prettier" + }, + "peerDependencies": { + "eslint": ">=7.0.0" + } + }, + "node_modules/eslint-scope": { + "version": "9.1.2", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-9.1.2.tgz", + "integrity": "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "@types/esrecurse": "^4.3.1", + "@types/estree": "^1.0.8", + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint/node_modules/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/eslint/node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint/node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/eslint/node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/espree": { + "version": "11.2.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-11.2.0.tgz", + "integrity": "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.16.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^5.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree/node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/eventsource": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz", + "integrity": "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==", + "license": "MIT", + "peer": true, + "dependencies": { + "eventsource-parser": "^3.0.1" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/eventsource-parser": { + "version": "3.0.8", + "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.0.8.tgz", + "integrity": "sha512-70QWGkr4snxr0OXLRWsFLeRBIRPuQOvt4s8QYjmUlmlkyTZkRqS7EDVRZtzU3TiyDbXSzaOeF0XUKy8PchzukQ==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/express": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", + "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", + "license": "MIT", + "peer": true, + "dependencies": { + "accepts": "^2.0.0", + "body-parser": "^2.2.1", + "content-disposition": "^1.0.0", + "content-type": "^1.0.5", + "cookie": "^0.7.1", + "cookie-signature": "^1.2.1", + "debug": "^4.4.0", + "depd": "^2.0.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "finalhandler": "^2.1.0", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "merge-descriptors": "^2.0.0", + "mime-types": "^3.0.0", + "on-finished": "^2.4.1", + "once": "^1.4.0", + "parseurl": "^1.3.3", + "proxy-addr": "^2.0.7", + "qs": "^6.14.0", + "range-parser": "^1.2.1", + "router": "^2.2.0", + "send": "^1.1.0", + "serve-static": "^2.2.0", + "statuses": "^2.0.1", + "type-is": "^2.0.1", + "vary": "^1.1.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/express-rate-limit": { + "version": "8.5.2", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.5.2.tgz", + "integrity": "sha512-5Kb34ipNX694DH48vN9irak1Qx30nb0PLYHXfJgw4YEjiC3ZEmZJhwOp+VfiCYwFzvFTdB9QkArYS5kXa2cx2A==", + "license": "MIT", + "peer": true, + "dependencies": { + "ip-address": "^10.2.0" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/express-rate-limit" + }, + "peerDependencies": { + "express": ">= 4.11" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT" + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-sha256": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/fast-sha256/-/fast-sha256-1.3.0.tgz", + "integrity": "sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==", + "license": "Unlicense" + }, + "node_modules/fast-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz", + "integrity": "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause", + "peer": true + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/finalhandler": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", + "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", + "license": "MIT", + "peer": true, + "dependencies": { + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", + "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", + "dev": true, + "license": "ISC" + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "peer": true, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "peer": true, + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/globals": { + "version": "17.6.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-17.6.0.tgz", + "integrity": "sha512-sepffkT8stwnIYbsMBpoCHJuJM5l98FUF2AnE07hfvE0m/qp3R586hw4jF4uadbhvg1ooIdzuu7CsfD2jzCaNA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz", + "integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==", + "license": "MIT", + "peer": true, + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hono": { + "version": "4.12.21", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.21.tgz", + "integrity": "sha512-uV63apnb0kyPtAUwoWgaGh9HyIFcv8lgmzPZSiTBQAFOFGIzka5EZ1dZocmGnn0XdX0+XTqJ6Tqv7selMuGLRQ==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=16.9.0" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/iconv-lite": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", + "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", + "license": "MIT", + "peer": true, + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/ignore": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC", + "peer": true + }, + "node_modules/ip-address": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz", + "integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 12" + } + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-promise": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", + "license": "MIT", + "peer": true + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "license": "ISC" + }, + "node_modules/jose": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.3.tgz", + "integrity": "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==", + "license": "MIT", + "peer": true, + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-to-ts": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/json-schema-to-ts/-/json-schema-to-ts-3.1.1.tgz", + "integrity": "sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.18.3", + "ts-algebra": "^2.0.0" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT", + "peer": true + }, + "node_modules/json-schema-typed": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/json-schema-typed/-/json-schema-typed-8.0.2.tgz", + "integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==", + "license": "BSD-2-Clause", + "peer": true + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "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" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/make-error": { + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz", + "integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==", + "dev": true, + "license": "ISC" + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", + "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/merge-descriptors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "peer": true, + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.12", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", + "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/negotiator": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", + "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/obug": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", + "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT" + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "peer": true, + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "peer": true, + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-to-regexp": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", + "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==", + "license": "MIT", + "peer": true, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pkce-challenge": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.1.tgz", + "integrity": "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/postcss": { + "version": "8.5.15", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", + "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/prettier": { + "version": "3.8.3", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.3.tgz", + "integrity": "sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "peer": true, + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/qs": { + "version": "6.15.2", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz", + "integrity": "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==", + "license": "BSD-3-Clause", + "peer": true, + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", + "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", + "license": "MIT", + "peer": true, + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.7.0", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/rolldown": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.2.tgz", + "integrity": "sha512-oZx5zVDtVB44AW3eaifgDml1gWRDZGvjcfdxonE4swNPG98PrrXjaO/KrnUjzlMnztCCRVlUueA1kCXhARGk6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.132.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.0.2", + "@rolldown/binding-darwin-arm64": "1.0.2", + "@rolldown/binding-darwin-x64": "1.0.2", + "@rolldown/binding-freebsd-x64": "1.0.2", + "@rolldown/binding-linux-arm-gnueabihf": "1.0.2", + "@rolldown/binding-linux-arm64-gnu": "1.0.2", + "@rolldown/binding-linux-arm64-musl": "1.0.2", + "@rolldown/binding-linux-ppc64-gnu": "1.0.2", + "@rolldown/binding-linux-s390x-gnu": "1.0.2", + "@rolldown/binding-linux-x64-gnu": "1.0.2", + "@rolldown/binding-linux-x64-musl": "1.0.2", + "@rolldown/binding-openharmony-arm64": "1.0.2", + "@rolldown/binding-wasm32-wasi": "1.0.2", + "@rolldown/binding-win32-arm64-msvc": "1.0.2", + "@rolldown/binding-win32-x64-msvc": "1.0.2" + } + }, + "node_modules/router": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", + "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "debug": "^4.4.0", + "depd": "^2.0.0", + "is-promise": "^4.0.0", + "parseurl": "^1.3.3", + "path-to-regexp": "^8.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT", + "peer": true + }, + "node_modules/semver": { + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.0.tgz", + "integrity": "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", + "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "debug": "^4.4.3", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.1", + "mime-types": "^3.0.2", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/serve-static": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", + "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", + "license": "MIT", + "peer": true, + "dependencies": { + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.2.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC", + "peer": true + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "license": "MIT", + "peer": true, + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "license": "MIT", + "peer": true, + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "peer": true, + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "peer": true, + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/standardwebhooks": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/standardwebhooks/-/standardwebhooks-1.0.0.tgz", + "integrity": "sha512-BbHGOQK9olHPMvQNHWul6MYlrRTAOKn03rOe4A8O3CLWhNf4YHBqq2HJKKC+sfqpxiBY52pNeesD6jIiLDz8jg==", + "license": "MIT", + "dependencies": { + "@stablelib/base64": "^1.0.0", + "fast-sha256": "^1.3.0" + } + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/std-env": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.1.0.tgz", + "integrity": "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.1.2.tgz", + "integrity": "sha512-dAqSqE/RabpBKI8+h26GfLq6Vb3JVXs30XYQjdMjaj/c2tS8IYYMbIzP599KtRj7c57/wYApb3QjgRgXmrCukA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.16", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", + "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyrainbow": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", + "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=0.6" + } + }, + "node_modules/ts-algebra": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ts-algebra/-/ts-algebra-2.0.0.tgz", + "integrity": "sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==", + "license": "MIT" + }, + "node_modules/ts-api-utils": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", + "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, + "node_modules/ts-node": { + "version": "10.9.2", + "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz", + "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cspotcode/source-map-support": "^0.8.0", + "@tsconfig/node10": "^1.0.7", + "@tsconfig/node12": "^1.0.7", + "@tsconfig/node14": "^1.0.0", + "@tsconfig/node16": "^1.0.2", + "acorn": "^8.4.1", + "acorn-walk": "^8.1.1", + "arg": "^4.1.0", + "create-require": "^1.1.0", + "diff": "^4.0.1", + "make-error": "^1.1.1", + "v8-compile-cache-lib": "^3.0.1", + "yn": "3.1.1" + }, + "bin": { + "ts-node": "dist/bin.js", + "ts-node-cwd": "dist/bin-cwd.js", + "ts-node-esm": "dist/bin-esm.js", + "ts-node-script": "dist/bin-script.js", + "ts-node-transpile-only": "dist/bin-transpile.js", + "ts-script": "dist/bin-script-deprecated.js" + }, + "peerDependencies": { + "@swc/core": ">=1.2.50", + "@swc/wasm": ">=1.2.50", + "@types/node": "*", + "typescript": ">=2.7" + }, + "peerDependenciesMeta": { + "@swc/core": { + "optional": true + }, + "@swc/wasm": { + "optional": true + } + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD", + "optional": true + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/type-is": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz", + "integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==", + "license": "MIT", + "peer": true, + "dependencies": { + "content-type": "^2.0.0", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/type-is/node_modules/content-type": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz", + "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/typescript": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", + "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "7.24.6", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz", + "integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==", + "dev": true, + "license": "MIT" + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/v8-compile-cache-lib": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz", + "integrity": "sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==", + "dev": true, + "license": "MIT" + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/vite": { + "version": "8.0.14", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.14.tgz", + "integrity": "sha512-s4BJJ+5y1pYL6Otw51FHhVJQhPnuRinKig64g/1+EUNaJsd3gCKdD31IPFvswUgW9/60QT9oFHbZHbQK5imcxw==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.32.0", + "picomatch": "^4.0.4", + "postcss": "^8.5.15", + "rolldown": "1.0.2", + "tinyglobby": "^0.2.16" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.1.18", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vitest": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.7.tgz", + "integrity": "sha512-flYyaFd2CgoCoU+0UKt3pxksgC+S02iTDN0n3LtqaMeXsI9SBcdNujc2k0DeFLzUn/0k538yNjOSdwgCqcrwJA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.7", + "@vitest/mocker": "4.1.7", + "@vitest/pretty-format": "4.1.7", + "@vitest/runner": "4.1.7", + "@vitest/snapshot": "4.1.7", + "@vitest/spy": "4.1.7", + "@vitest/utils": "4.1.7", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.7", + "@vitest/browser-preview": "4.1.7", + "@vitest/browser-webdriverio": "4.1.7", + "@vitest/coverage-istanbul": "4.1.7", + "@vitest/coverage-v8": "4.1.7", + "@vitest/ui": "4.1.7", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC", + "peer": true + }, + "node_modules/yn": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz", + "integrity": "sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/zod": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", + "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-to-json-schema": { + "version": "3.25.2", + "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.2.tgz", + "integrity": "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==", + "license": "ISC", + "peer": true, + "peerDependencies": { + "zod": "^3.25.28 || ^4" + } + } + } +} diff --git a/qiming-claude-code-acp-ts/package.json b/qiming-claude-code-acp-ts/package.json new file mode 100644 index 00000000..dcd23ffd --- /dev/null +++ b/qiming-claude-code-acp-ts/package.json @@ -0,0 +1,81 @@ +{ + "name": "claude-code-acp-ts", + "publishConfig": { + "access": "public" + }, + "version": "0.38.1", + "description": "An ACP-compatible coding agent powered by the Claude Agent SDK (TypeScript)", + "main": "dist/lib.js", + "types": "dist/lib.d.ts", + "bin": { + "claude-code-acp-ts": "./dist/index.js" + }, + "type": "module", + "exports": { + ".": { + "types": "./dist/lib.d.ts", + "import": "./dist/lib.js" + }, + "./*": "./*" + }, + "files": [ + "dist/", + "!dist/tests/", + "README.md", + "LICENSE", + "package.json" + ], + "scripts": { + "build": "tsc", + "start": "node dist/index.js", + "dev": "npm run build && npm run start", + "lint": "eslint src --ext .ts", + "lint:fix": "eslint src --ext .ts --fix", + "format": "prettier --write .", + "format:check": "prettier --check .", + "check": "npm run lint && npm run format:check", + "test": "vitest", + "test:integration": "RUN_INTEGRATION_TESTS=true vitest run", + "test:run": "vitest run", + "test:coverage": "vitest run --coverage", + "prepublishOnly": "npm run build" + }, + "keywords": [ + "claude", + "acp", + "agent", + "anthropic", + "typescript", + "sdk", + "code" + ], + "homepage": "https://github.com/dongdada29/claude-code-acp-ts#readme", + "bugs": { + "url": "https://github.com/dongdada29/claude-code-acp-ts/issues" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/dongdada29/claude-code-acp-ts.git" + }, + "author": "Zed Industries", + "license": "Apache-2.0", + "dependencies": { + "@agentclientprotocol/sdk": "0.22.1", + "@anthropic-ai/claude-agent-sdk": "0.3.146", + "zod": "^3.25.0 || ^4.0.0" + }, + "devDependencies": { + "@anthropic-ai/sdk": "0.97.1", + "@eslint/js": "10.0.1", + "@types/node": "25.9.1", + "@typescript-eslint/eslint-plugin": "8.59.4", + "@typescript-eslint/parser": "8.59.4", + "eslint": "10.4.0", + "eslint-config-prettier": "10.1.8", + "globals": "17.6.0", + "prettier": "3.8.3", + "ts-node": "10.9.2", + "typescript": "6.0.3", + "vitest": "4.1.7" + } +} diff --git a/qiming-claude-code-acp-ts/pnpm-lock.yaml b/qiming-claude-code-acp-ts/pnpm-lock.yaml new file mode 100644 index 00000000..4dac5e73 --- /dev/null +++ b/qiming-claude-code-acp-ts/pnpm-lock.yaml @@ -0,0 +1,2522 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + '@agentclientprotocol/sdk': + specifier: 0.22.1 + version: 0.22.1(zod@4.4.3) + '@anthropic-ai/claude-agent-sdk': + specifier: 0.3.146 + version: 0.3.146(@anthropic-ai/sdk@0.97.1(zod@4.4.3))(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(zod@4.4.3) + zod: + specifier: ^3.25.0 || ^4.0.0 + version: 4.4.3 + devDependencies: + '@anthropic-ai/sdk': + specifier: 0.97.1 + version: 0.97.1(zod@4.4.3) + '@eslint/js': + specifier: 10.0.1 + version: 10.0.1(eslint@10.4.0) + '@types/node': + specifier: 25.9.1 + version: 25.9.1 + '@typescript-eslint/eslint-plugin': + specifier: 8.59.4 + version: 8.59.4(@typescript-eslint/parser@8.59.4(eslint@10.4.0)(typescript@6.0.3))(eslint@10.4.0)(typescript@6.0.3) + '@typescript-eslint/parser': + specifier: 8.59.4 + version: 8.59.4(eslint@10.4.0)(typescript@6.0.3) + eslint: + specifier: 10.4.0 + version: 10.4.0 + eslint-config-prettier: + specifier: 10.1.8 + version: 10.1.8(eslint@10.4.0) + globals: + specifier: 17.6.0 + version: 17.6.0 + prettier: + specifier: 3.8.3 + version: 3.8.3 + ts-node: + specifier: 10.9.2 + version: 10.9.2(@types/node@25.9.1)(typescript@6.0.3) + typescript: + specifier: 6.0.3 + version: 6.0.3 + vitest: + specifier: 4.1.7 + version: 4.1.7(@types/node@25.9.1)(vite@8.0.14(@types/node@25.9.1)) + +packages: + + '@agentclientprotocol/sdk@0.22.1': + resolution: {integrity: sha512-DfqXtl/8gO9NImq094MTaCXEU2vkhh6v7q/kT+9UjZxUqj8hYaya2OjLVIqn16MzNHcXEpShTR2RIauLSYeDQQ==} + peerDependencies: + zod: ^3.25.0 || ^4.0.0 + + '@anthropic-ai/claude-agent-sdk-darwin-arm64@0.3.146': + resolution: {integrity: sha512-0IIvlEaenq2CRSVx5Bo5BaCtHQXS87GancM35WKEYveGVLn6DI+5G7ikYuTE4AKRPkMnogFtY4BJt6LulWGj+A==} + cpu: [arm64] + os: [darwin] + + '@anthropic-ai/claude-agent-sdk-darwin-x64@0.3.146': + resolution: {integrity: sha512-Dk5xJ03Ff1JXbMRP1t2wc/TyfY6xF/2Ysp31wMhFPjoNiKSPHMWaIg242+T3CHdxLWmJ8plWHL1HL5cyZ/LCkw==} + cpu: [x64] + os: [darwin] + + '@anthropic-ai/claude-agent-sdk-linux-arm64-musl@0.3.146': + resolution: {integrity: sha512-QlCid0ucdrmhUAOewfQjaofN2wlokWcfFTxSFePTSj1umk35JO7TDFP700F7jU49r1fPWIdvJpPwWGyB0DeFPA==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@anthropic-ai/claude-agent-sdk-linux-arm64@0.3.146': + resolution: {integrity: sha512-mzBXDDWWBAC/vDtAYpO1G/dq5QvJtYSPXsqcb+sNdcDhiuf4IYnYp7ytRncYlsUNDkLmX6Gk2jkWAHUUA2Lozg==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@anthropic-ai/claude-agent-sdk-linux-x64-musl@0.3.146': + resolution: {integrity: sha512-E3coK1ThQT08KIX80RLcsq7DWXFllCKOzoOe32it/bdtY56TBgPY9xemwXhIJ+cVBHTI9/MpBSIlKBcFCt+yQA==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@anthropic-ai/claude-agent-sdk-linux-x64@0.3.146': + resolution: {integrity: sha512-B2baXU1tCBT5CVlD7jJMKjpC4xdO45NUIWpqImmwuOfKvlM/PITjyTXyTY662mGZf1dBmdqBBsqirwFH/jhi8Q==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@anthropic-ai/claude-agent-sdk-win32-arm64@0.3.146': + resolution: {integrity: sha512-CIwQxGX2r/yWpjCJ6ahB3smKXhghWgGTxL98+LGW52TUwqTiBnlNrH9DPqqgv1/+Hyquw6xfLrKU+StyfMgiLw==} + cpu: [arm64] + os: [win32] + + '@anthropic-ai/claude-agent-sdk-win32-x64@0.3.146': + resolution: {integrity: sha512-qmxrsyaqA8s4HShqJls7ZCRjdoqN66Jo/hbjQNB3uHepD8tEO1iD19aPV4+osdLT7feMkhDBfLT07Q30R2NB5w==} + cpu: [x64] + os: [win32] + + '@anthropic-ai/claude-agent-sdk@0.3.146': + resolution: {integrity: sha512-hK9/Ng+hOyexUemTxdIUsSWJ9o2LFi2YNWzHwz8/YMCohUYOnFMZkBiENvUAb0WIc5hieOyBZrOIlg5OewuJMg==} + engines: {node: '>=18.0.0'} + peerDependencies: + '@anthropic-ai/sdk': '>=0.93.0' + '@modelcontextprotocol/sdk': ^1.29.0 + zod: ^4.0.0 + + '@anthropic-ai/sdk@0.97.1': + resolution: {integrity: sha512-wOf7AUeJPitcVpvKO4UMu63mWH5SaVipkGd7OOQJt/G6VYGlV8D2Gp9dLxOrttDJh/9gqPqdaBwDGcBevumeAg==} + hasBin: true + peerDependencies: + zod: ^3.25.0 || ^4.0.0 + peerDependenciesMeta: + zod: + optional: true + + '@babel/runtime@7.29.7': + resolution: {integrity: sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==} + engines: {node: '>=6.9.0'} + + '@cspotcode/source-map-support@0.8.1': + resolution: {integrity: sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==} + engines: {node: '>=12'} + + '@emnapi/core@1.10.0': + resolution: {integrity: sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==} + + '@emnapi/runtime@1.10.0': + resolution: {integrity: sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==} + + '@emnapi/wasi-threads@1.2.1': + resolution: {integrity: sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==} + + '@eslint-community/eslint-utils@4.9.1': + resolution: {integrity: sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + peerDependencies: + eslint: ^6.0.0 || ^7.0.0 || >=8.0.0 + + '@eslint-community/regexpp@4.12.2': + resolution: {integrity: sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==} + engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} + + '@eslint/config-array@0.23.5': + resolution: {integrity: sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + '@eslint/config-helpers@0.6.0': + resolution: {integrity: sha512-ii6Bw9jJ2zi2cWA2Z+9/QZ/+3DX6kwaV5Q986D/CdP3Lap3w/pgQZ373FV7byY/i7L4IRH/G43I5dz1ClsCbpA==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + '@eslint/core@1.2.1': + resolution: {integrity: sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + '@eslint/js@10.0.1': + resolution: {integrity: sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + peerDependencies: + eslint: ^10.0.0 + peerDependenciesMeta: + eslint: + optional: true + + '@eslint/object-schema@3.0.5': + resolution: {integrity: sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + '@eslint/plugin-kit@0.7.1': + resolution: {integrity: sha512-rZAP3aVgB9ds9KOeUSL+zZ21hPmo8dh6fnIFwRQj5EAZl9gzR7wxYbYXYysAM8CTqGmUGyp2S4kUdV17MnGuWQ==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + '@hono/node-server@1.19.14': + resolution: {integrity: sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==} + engines: {node: '>=18.14.1'} + peerDependencies: + hono: ^4 + + '@humanfs/core@0.19.2': + resolution: {integrity: sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==} + engines: {node: '>=18.18.0'} + + '@humanfs/node@0.16.8': + resolution: {integrity: sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==} + engines: {node: '>=18.18.0'} + + '@humanfs/types@0.15.0': + resolution: {integrity: sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==} + engines: {node: '>=18.18.0'} + + '@humanwhocodes/module-importer@1.0.1': + resolution: {integrity: sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==} + engines: {node: '>=12.22'} + + '@humanwhocodes/retry@0.4.3': + resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==} + engines: {node: '>=18.18'} + + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + + '@jridgewell/trace-mapping@0.3.9': + resolution: {integrity: sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==} + + '@modelcontextprotocol/sdk@1.29.0': + resolution: {integrity: sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ==} + engines: {node: '>=18'} + peerDependencies: + '@cfworker/json-schema': ^4.1.1 + zod: ^3.25 || ^4.0 + peerDependenciesMeta: + '@cfworker/json-schema': + optional: true + + '@napi-rs/wasm-runtime@1.1.4': + resolution: {integrity: sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==} + peerDependencies: + '@emnapi/core': ^1.7.1 + '@emnapi/runtime': ^1.7.1 + + '@oxc-project/types@0.132.0': + resolution: {integrity: sha512-FESMOxil5Se014ui/Eq8fT5uHJo6nIRwH0PfJrZJXs6Gek3ZVFOrpUv3YIZT20m+extU98Hg1Ym72U58rlsxUQ==} + + '@rolldown/binding-android-arm64@1.0.2': + resolution: {integrity: sha512-ZS4D1JPGn/MYQN/SYDWftIE/nVsM8j/AFOYEzAoOE2O3NktQOZru+/vYXGbR/qtdLdIfGCP0lcoJiYVzsEz+iQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@rolldown/binding-darwin-arm64@1.0.2': + resolution: {integrity: sha512-vdFA9+C/rekyGce7WqHs/xoT0ioZEWaOFyZLIV1mEeNFaFDUQrPIo8Vs2GvJ6eetb3rzDUtUBgzto3ExpXJB3w==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@rolldown/binding-darwin-x64@1.0.2': + resolution: {integrity: sha512-BewSOwTHazv77DTYiAZXSqqKZ4KP/KonFisDMVU7PImxoWfB2aepnPhd2E4SWz3zDzYgDNbs6jBmTdgNnF02GA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@rolldown/binding-freebsd-x64@1.0.2': + resolution: {integrity: sha512-m41o7M0YWtUdqk61Tb+jnKb2rN++iRdIASlExkUoKfIAH30DOHCB8fVLzSUpbWHHU8esmEioY62PxzexE8MBuA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@rolldown/binding-linux-arm-gnueabihf@1.0.2': + resolution: {integrity: sha512-jcojB9H7W/jS29pMKWAK1N+fU99vXodHDTatS3b3y/XSOCiHo0kkA74pL3jJmkoQtYpOCxDvaKs1fo2Ij/1X5w==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@rolldown/binding-linux-arm64-gnu@1.0.2': + resolution: {integrity: sha512-1jn6qDU5iiOgFgygDzKUuKP0maTi0/f1+sBLgvij/76C77Nm3ts6ufz9Bjg5q5dduxiUIxtq86JIoBvo1xQ4Ig==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-arm64-musl@1.0.2': + resolution: {integrity: sha512-QVLO/czFMdoMFSqlX3bcswcJNm/23r+qoa/jgtmFc/qEp6/jXmIkDjF/XIo8dPfGaiwy1xfQn8o77L79GeXFgw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@rolldown/binding-linux-ppc64-gnu@1.0.2': + resolution: {integrity: sha512-hgO5Abm0w5UL6FEa2iFnZqo2KlK7TQ5QhV5x09hujBf7t5KzHQ1VmfPuTpqRy/rNlSxua3eWH374xxiVrP+lcA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-s390x-gnu@1.0.2': + resolution: {integrity: sha512-fy8rXxuYEu602abC8MUNaPjYLIFzReOaEIEMKMUa0rFEUxNpVXhs15KSSQ4qlqSaM7B6rcj9rDZgADh/IGDzLQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-x64-gnu@1.0.2': + resolution: {integrity: sha512-0+bOkiQ779+r1WpoHOWHqncvyySci0vKph+myNDYb+im6meJAzHQXay6oEgnkHuUGouM1LKTZwqKpBow6Kj7CQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-x64-musl@1.0.2': + resolution: {integrity: sha512-mjSkrzZK5Qsl0a9d1JgILOiuZOSDTVdKENcSXBoqbzSrspLR/4/IRVDo5wd2GgZjNss/viBFJdeq+j7qH2nypw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [musl] + + '@rolldown/binding-openharmony-arm64@1.0.2': + resolution: {integrity: sha512-1v5vHasdfQAZoEHakBV72LIFAC9JjnymsiKxp+GEr/ma3+NJCPSaYK+qavInOovJkgwFrs7GccX2d6IgDA3Z5w==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@rolldown/binding-wasm32-wasi@1.0.2': + resolution: {integrity: sha512-mb1VobWn6NheziTk5/WEaR6AKVbrwT5sOi6C7zk3gy/pD1qtJfU1j4PgTo2NJnOtbL9Dl3Aeei8w9jJ7qC2jZQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [wasm32] + + '@rolldown/binding-win32-arm64-msvc@1.0.2': + resolution: {integrity: sha512-SqKonF56vA/L2yHwHYcEp2P34URpOZ7d1fS635cTkpDnUtEGdUbhI6NzsPdqeSWvAAeGDrxjWjNmibDIdFf9/A==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@rolldown/binding-win32-x64-msvc@1.0.2': + resolution: {integrity: sha512-v7qRI7gXLRINcOGXt+7YmAZ6iFuyZVMIoXAxhd8oP+DR9dLfL9GfNIx7PLMxmhZdvq8waUJBQiWN9EKNy+TRBQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + + '@rolldown/pluginutils@1.0.1': + resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==} + + '@stablelib/base64@1.0.1': + resolution: {integrity: sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==} + + '@standard-schema/spec@1.1.0': + resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + + '@tsconfig/node10@1.0.12': + resolution: {integrity: sha512-UCYBaeFvM11aU2y3YPZ//O5Rhj+xKyzy7mvcIoAjASbigy8mHMryP5cK7dgjlz2hWxh1g5pLw084E0a/wlUSFQ==} + + '@tsconfig/node12@1.0.11': + resolution: {integrity: sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==} + + '@tsconfig/node14@1.0.3': + resolution: {integrity: sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==} + + '@tsconfig/node16@1.0.4': + resolution: {integrity: sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==} + + '@tybys/wasm-util@0.10.2': + resolution: {integrity: sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==} + + '@types/chai@5.2.3': + resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + + '@types/deep-eql@4.0.2': + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + + '@types/esrecurse@4.3.1': + resolution: {integrity: sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==} + + '@types/estree@1.0.9': + resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + + '@types/json-schema@7.0.15': + resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} + + '@types/node@25.9.1': + resolution: {integrity: sha512-xfrlY7UD5rMJk3ZVJP8BNzS28J36YJg+xp+LPXV1TdWxr8uMH5A860QNxYDGQe/ylDSgjxE52Q9VnO7p75tJxg==} + + '@typescript-eslint/eslint-plugin@8.59.4': + resolution: {integrity: sha512-PegsU+XfyJJNjd4+u/k6f9yTyp0lEXXiPopUNobZcIAUJFGICFLN+sP0Rb3JehVmiij1Ph0dFGYqODoRo/2+6A==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + '@typescript-eslint/parser': ^8.59.4 + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/parser@8.59.4': + resolution: {integrity: sha512-zORHqO/tuhxY1zWuTvMUqddRxpiFJ72xVfcNoWpqdLjs6lfPbuQBJuW4pk+49/uBMy7Ssr4bzgjiKmmDB1UbZQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/project-service@8.59.4': + resolution: {integrity: sha512-Ly00Vu4oAacfDeHp2Zg85ioNG6l8HG+tN1D7J+xTHSxu9y0awYKJ2zH1rFBn8ZSfuGK+7FxK3Cgl3uAz0aZZLg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/scope-manager@8.59.4': + resolution: {integrity: sha512-mUeR/3H1WrTAddJrwut8OoPjfauaztMQmRwV5fQTUyNVJCLiUXXe4lGEyYIL2oFDpP7UtgbGJXCt72wT0z2S3Q==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@typescript-eslint/tsconfig-utils@8.59.4': + resolution: {integrity: sha512-DLCpnKgD4alVxTBSKulK+gU1KCqOgUXfDRDXh2mZgzokQKa/70ax93I2uVO3m/LLvIAtWZIFoiifudmIqAxpMA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/type-utils@8.59.4': + resolution: {integrity: sha512-uonTuPAAKr9XaBGqJ3LjYTh72zy5DyGesljO9gtmk/eFW0W1fRHjnwVYKB35Lm8d5Q5CluEW3gPHjTvZTmgrfA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/types@8.59.4': + resolution: {integrity: sha512-F1o7WJcCq+bc8dwcO/YsSEOudAH8RDtaOhM6wcAQhcUsFhnWQl81JKy48q1hoxAU0qrzM89+31GYh1515Zde3Q==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@typescript-eslint/typescript-estree@8.59.4': + resolution: {integrity: sha512-F+RuOmcDXo4+TPdfd/TCLS3m2nw8gE9XXyZLrA3JBfaA5tz9TtdkyD3YJFmPxulyc2cKbEok/CvFE3MgSLWnag==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/utils@8.59.4': + resolution: {integrity: sha512-cYXeNAUsG4lJo5dbc1FcKm+JwIWrj1/UpTORsC6tGMjEZ81DYcvIr9/ueikhMa/Y/gDQYGp+YX9/xQrXje5BJw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/visitor-keys@8.59.4': + resolution: {integrity: sha512-U3gxVaDVnuZKhSspW/MzMxE1kq7zOdc072FcSNoqA1I9p8HyKbBFfEHoWckBAMgNMph4MamwS5iTVzFmrnt8TQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@vitest/expect@4.1.7': + resolution: {integrity: sha512-1R+tw0ortHEbZDGMymm+pN7/AFQ/RkFFdtd7EN+VBpynKmLbP8A3rpEXdshBJ7+8hQ9zBJh/i1s0yKNtxAnU7w==} + + '@vitest/mocker@4.1.7': + resolution: {integrity: sha512-vY7nuamKgfvpA1Koa3oYIw/k7D6kZnpGyNMZW8loow2bsBYla1TFdqTaXncWdRn4pgwNs+90RhnXhJScDwQeJA==} + peerDependencies: + msw: ^2.4.9 + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/pretty-format@4.1.7': + resolution: {integrity: sha512-umgCarTOYQWIaDMvGDRZij+6b9oVeLIyJzfN+AS88e0ZOU3QTgNNSTtjQOpcvWr3np1N0j4WgZj+sb3oYBDscw==} + + '@vitest/runner@4.1.7': + resolution: {integrity: sha512-BapjmAQ2aI78WdMEfeUWivnfVzB+VPGwWRQcJE0OUq7qEeEcBsCSf+0T5iREBNE5nBb4wA5Ya0W6IA+sghdEFw==} + + '@vitest/snapshot@4.1.7': + resolution: {integrity: sha512-ZacLzja+TmJeZ1h14xW2FB/WpeimUD3haBXQPyJqxvo8jQTmfeA8zv58mtjN2C7EHXZDYVcVYdYmAxjkWVvKCw==} + + '@vitest/spy@4.1.7': + resolution: {integrity: sha512-kbkI5LMWakyuTIvs6fUJ5qdIVb1XVKsYJAT4OJ938cHMROYMSfmoQdZy0aaAnjbbc8F61vkoTqz/Az+/HiIu5Q==} + + '@vitest/utils@4.1.7': + resolution: {integrity: sha512-T532WBu791cBxJlCl6SO+J14l81DQx6uQHm1bQbmCDY7nqlEIgkza/UFnSBNaUtSf41unldDFjdOBYEQC4b5Hw==} + + accepts@2.0.0: + resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==} + engines: {node: '>= 0.6'} + + acorn-jsx@5.3.2: + resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} + peerDependencies: + acorn: ^6.0.0 || ^7.0.0 || ^8.0.0 + + acorn-walk@8.3.5: + resolution: {integrity: sha512-HEHNfbars9v4pgpW6SO1KSPkfoS0xVOM/9UzkJltjlsHZmJasxg8aXkuZa7SMf8vKGIBhpUsPluQSqhJFCqebw==} + engines: {node: '>=0.4.0'} + + acorn@8.16.0: + resolution: {integrity: sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==} + engines: {node: '>=0.4.0'} + hasBin: true + + ajv-formats@3.0.1: + resolution: {integrity: sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==} + peerDependencies: + ajv: ^8.0.0 + peerDependenciesMeta: + ajv: + optional: true + + ajv@6.15.0: + resolution: {integrity: sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==} + + ajv@8.20.0: + resolution: {integrity: sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==} + + arg@4.1.3: + resolution: {integrity: sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==} + + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + + balanced-match@4.0.4: + resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} + engines: {node: 18 || 20 || >=22} + + body-parser@2.2.2: + resolution: {integrity: sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==} + engines: {node: '>=18'} + + brace-expansion@5.0.6: + resolution: {integrity: sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==} + engines: {node: 18 || 20 || >=22} + + bytes@3.1.2: + resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==} + engines: {node: '>= 0.8'} + + call-bind-apply-helpers@1.0.2: + resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} + engines: {node: '>= 0.4'} + + call-bound@1.0.4: + resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==} + engines: {node: '>= 0.4'} + + chai@6.2.2: + resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} + engines: {node: '>=18'} + + content-disposition@1.1.0: + resolution: {integrity: sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==} + engines: {node: '>=18'} + + content-type@1.0.5: + resolution: {integrity: sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==} + engines: {node: '>= 0.6'} + + content-type@2.0.0: + resolution: {integrity: sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==} + engines: {node: '>=18'} + + convert-source-map@2.0.0: + resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + + cookie-signature@1.2.2: + resolution: {integrity: sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==} + engines: {node: '>=6.6.0'} + + cookie@0.7.2: + resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==} + engines: {node: '>= 0.6'} + + cors@2.8.6: + resolution: {integrity: sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==} + engines: {node: '>= 0.10'} + + create-require@1.1.1: + resolution: {integrity: sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==} + + cross-spawn@7.0.6: + resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} + engines: {node: '>= 8'} + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + deep-is@0.1.4: + resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} + + depd@2.0.0: + resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} + engines: {node: '>= 0.8'} + + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} + + diff@4.0.4: + resolution: {integrity: sha512-X07nttJQkwkfKfvTPG/KSnE2OMdcUCao6+eXF3wmnIQRn2aPAHH3VxDbDOdegkd6JbPsXqShpvEOHfAT+nCNwQ==} + engines: {node: '>=0.3.1'} + + dunder-proto@1.0.1: + resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} + engines: {node: '>= 0.4'} + + ee-first@1.1.1: + resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} + + encodeurl@2.0.0: + resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==} + engines: {node: '>= 0.8'} + + es-define-property@1.0.1: + resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} + engines: {node: '>= 0.4'} + + es-errors@1.3.0: + resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} + engines: {node: '>= 0.4'} + + es-module-lexer@2.1.0: + resolution: {integrity: sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==} + + es-object-atoms@1.1.2: + resolution: {integrity: sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==} + engines: {node: '>= 0.4'} + + escape-html@1.0.3: + resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==} + + escape-string-regexp@4.0.0: + resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} + engines: {node: '>=10'} + + eslint-config-prettier@10.1.8: + resolution: {integrity: sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==} + hasBin: true + peerDependencies: + eslint: '>=7.0.0' + + eslint-scope@9.1.2: + resolution: {integrity: sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + eslint-visitor-keys@3.4.3: + resolution: {integrity: sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + + eslint-visitor-keys@5.0.1: + resolution: {integrity: sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + eslint@10.4.0: + resolution: {integrity: sha512-loXy6bWOoP3EP6JA7jo6p5jMpBJmHmsNZM5SFRHLdh1MGOPurMnNBj4ZlAbaqUAaQWbCr7jHV4P7gzAyryZWkQ==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + hasBin: true + peerDependencies: + jiti: '*' + peerDependenciesMeta: + jiti: + optional: true + + espree@11.2.0: + resolution: {integrity: sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + esquery@1.7.0: + resolution: {integrity: sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==} + engines: {node: '>=0.10'} + + esrecurse@4.3.0: + resolution: {integrity: sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==} + engines: {node: '>=4.0'} + + estraverse@5.3.0: + resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} + engines: {node: '>=4.0'} + + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + + esutils@2.0.3: + resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} + engines: {node: '>=0.10.0'} + + etag@1.8.1: + resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==} + engines: {node: '>= 0.6'} + + eventsource-parser@3.1.0: + resolution: {integrity: sha512-kJezFj9YFAMLeORyi7aCLxLbD5/qWMQnoMVlVPyHIll7lgRJCc3JVln9Vgl9nwQi0YkMnhdGTMNn7CkRRAptMg==} + engines: {node: '>=18.0.0'} + + eventsource@3.0.7: + resolution: {integrity: sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==} + engines: {node: '>=18.0.0'} + + expect-type@1.3.0: + resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} + engines: {node: '>=12.0.0'} + + express-rate-limit@8.5.2: + resolution: {integrity: sha512-5Kb34ipNX694DH48vN9irak1Qx30nb0PLYHXfJgw4YEjiC3ZEmZJhwOp+VfiCYwFzvFTdB9QkArYS5kXa2cx2A==} + engines: {node: '>= 16'} + peerDependencies: + express: '>= 4.11' + + express@5.2.1: + resolution: {integrity: sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==} + engines: {node: '>= 18'} + + fast-deep-equal@3.1.3: + resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + + fast-json-stable-stringify@2.1.0: + resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} + + fast-levenshtein@2.0.6: + resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} + + fast-sha256@1.3.0: + resolution: {integrity: sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==} + + fast-uri@3.1.2: + resolution: {integrity: sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + file-entry-cache@8.0.0: + resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==} + engines: {node: '>=16.0.0'} + + finalhandler@2.1.1: + resolution: {integrity: sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==} + engines: {node: '>= 18.0.0'} + + find-up@5.0.0: + resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} + engines: {node: '>=10'} + + flat-cache@4.0.1: + resolution: {integrity: sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==} + engines: {node: '>=16'} + + flatted@3.4.2: + resolution: {integrity: sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==} + + forwarded@0.2.0: + resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==} + engines: {node: '>= 0.6'} + + fresh@2.0.0: + resolution: {integrity: sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==} + engines: {node: '>= 0.8'} + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + function-bind@1.1.2: + resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + + get-intrinsic@1.3.0: + resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} + engines: {node: '>= 0.4'} + + get-proto@1.0.1: + resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} + engines: {node: '>= 0.4'} + + glob-parent@6.0.2: + resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} + engines: {node: '>=10.13.0'} + + globals@17.6.0: + resolution: {integrity: sha512-sepffkT8stwnIYbsMBpoCHJuJM5l98FUF2AnE07hfvE0m/qp3R586hw4jF4uadbhvg1ooIdzuu7CsfD2jzCaNA==} + engines: {node: '>=18'} + + gopd@1.2.0: + resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} + engines: {node: '>= 0.4'} + + has-symbols@1.1.0: + resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} + engines: {node: '>= 0.4'} + + hasown@2.0.3: + resolution: {integrity: sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==} + engines: {node: '>= 0.4'} + + hono@4.12.23: + resolution: {integrity: sha512-eIaZ9qDgu7XV0pxOCrg7/WhnQ6Ivm22UcxhXx/A3dcbqbbYgBEkc6e/J/s7j2tS96zoB0S9VBdLwQNCWwUo4LA==} + engines: {node: '>=16.9.0'} + + http-errors@2.0.1: + resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==} + engines: {node: '>= 0.8'} + + iconv-lite@0.7.2: + resolution: {integrity: sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==} + engines: {node: '>=0.10.0'} + + ignore@5.3.2: + resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} + engines: {node: '>= 4'} + + ignore@7.0.5: + resolution: {integrity: sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==} + engines: {node: '>= 4'} + + imurmurhash@0.1.4: + resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} + engines: {node: '>=0.8.19'} + + inherits@2.0.4: + resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + + ip-address@10.2.0: + resolution: {integrity: sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==} + engines: {node: '>= 12'} + + ipaddr.js@1.9.1: + resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==} + engines: {node: '>= 0.10'} + + is-extglob@2.1.1: + resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} + engines: {node: '>=0.10.0'} + + is-glob@4.0.3: + resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} + engines: {node: '>=0.10.0'} + + is-promise@4.0.0: + resolution: {integrity: sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==} + + isexe@2.0.0: + resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + + jose@6.2.3: + resolution: {integrity: sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==} + + json-buffer@3.0.1: + resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==} + + json-schema-to-ts@3.1.1: + resolution: {integrity: sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g==} + engines: {node: '>=16'} + + json-schema-traverse@0.4.1: + resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} + + json-schema-traverse@1.0.0: + resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} + + json-schema-typed@8.0.2: + resolution: {integrity: sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==} + + json-stable-stringify-without-jsonify@1.0.1: + resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} + + keyv@4.5.4: + resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} + + levn@0.4.1: + resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} + engines: {node: '>= 0.8.0'} + + lightningcss-android-arm64@1.32.0: + resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [android] + + lightningcss-darwin-arm64@1.32.0: + resolution: {integrity: sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [darwin] + + lightningcss-darwin-x64@1.32.0: + resolution: {integrity: sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [darwin] + + lightningcss-freebsd-x64@1.32.0: + resolution: {integrity: sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [freebsd] + + lightningcss-linux-arm-gnueabihf@1.32.0: + resolution: {integrity: sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==} + engines: {node: '>= 12.0.0'} + cpu: [arm] + os: [linux] + + lightningcss-linux-arm64-gnu@1.32.0: + resolution: {integrity: sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + lightningcss-linux-arm64-musl@1.32.0: + resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==} + 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==} + 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==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [musl] + + lightningcss-win32-arm64-msvc@1.32.0: + resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [win32] + + lightningcss-win32-x64-msvc@1.32.0: + resolution: {integrity: sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [win32] + + lightningcss@1.32.0: + resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==} + engines: {node: '>= 12.0.0'} + + locate-path@6.0.0: + resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} + engines: {node: '>=10'} + + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + + make-error@1.3.6: + resolution: {integrity: sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==} + + math-intrinsics@1.1.0: + resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} + engines: {node: '>= 0.4'} + + media-typer@1.1.0: + resolution: {integrity: sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==} + engines: {node: '>= 0.8'} + + merge-descriptors@2.0.0: + resolution: {integrity: sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==} + engines: {node: '>=18'} + + mime-db@1.54.0: + resolution: {integrity: sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==} + engines: {node: '>= 0.6'} + + mime-types@3.0.2: + resolution: {integrity: sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==} + engines: {node: '>=18'} + + minimatch@10.2.5: + resolution: {integrity: sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==} + engines: {node: 18 || 20 || >=22} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + nanoid@3.3.12: + resolution: {integrity: sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + natural-compare@1.4.0: + resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} + + negotiator@1.0.0: + resolution: {integrity: sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==} + engines: {node: '>= 0.6'} + + object-assign@4.1.1: + resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} + engines: {node: '>=0.10.0'} + + object-inspect@1.13.4: + resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} + engines: {node: '>= 0.4'} + + obug@2.1.1: + resolution: {integrity: sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==} + + on-finished@2.4.1: + resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==} + engines: {node: '>= 0.8'} + + once@1.4.0: + resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} + + optionator@0.9.4: + resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} + engines: {node: '>= 0.8.0'} + + p-limit@3.1.0: + resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} + engines: {node: '>=10'} + + p-locate@5.0.0: + resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} + engines: {node: '>=10'} + + parseurl@1.3.3: + resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} + engines: {node: '>= 0.8'} + + path-exists@4.0.0: + resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} + engines: {node: '>=8'} + + path-key@3.1.1: + resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} + engines: {node: '>=8'} + + path-to-regexp@8.4.2: + resolution: {integrity: sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==} + + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@4.0.4: + resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} + engines: {node: '>=12'} + + pkce-challenge@5.0.1: + resolution: {integrity: sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==} + engines: {node: '>=16.20.0'} + + postcss@8.5.15: + resolution: {integrity: sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==} + engines: {node: ^10 || ^12 || >=14} + + prelude-ls@1.2.1: + resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} + engines: {node: '>= 0.8.0'} + + prettier@3.8.3: + resolution: {integrity: sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw==} + engines: {node: '>=14'} + hasBin: true + + proxy-addr@2.0.7: + resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} + engines: {node: '>= 0.10'} + + punycode@2.3.1: + resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} + engines: {node: '>=6'} + + qs@6.15.2: + resolution: {integrity: sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==} + engines: {node: '>=0.6'} + + range-parser@1.2.1: + resolution: {integrity: sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==} + engines: {node: '>= 0.6'} + + raw-body@3.0.2: + resolution: {integrity: sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==} + engines: {node: '>= 0.10'} + + require-from-string@2.0.2: + resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} + engines: {node: '>=0.10.0'} + + rolldown@1.0.2: + resolution: {integrity: sha512-oZx5zVDtVB44AW3eaifgDml1gWRDZGvjcfdxonE4swNPG98PrrXjaO/KrnUjzlMnztCCRVlUueA1kCXhARGk6g==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + + router@2.2.0: + resolution: {integrity: sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==} + engines: {node: '>= 18'} + + safer-buffer@2.1.2: + resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + + semver@7.8.1: + resolution: {integrity: sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg==} + engines: {node: '>=10'} + hasBin: true + + send@1.2.1: + resolution: {integrity: sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==} + engines: {node: '>= 18'} + + serve-static@2.2.1: + resolution: {integrity: sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==} + engines: {node: '>= 18'} + + setprototypeof@1.2.0: + resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} + + shebang-command@2.0.0: + resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} + engines: {node: '>=8'} + + shebang-regex@3.0.0: + resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} + engines: {node: '>=8'} + + side-channel-list@1.0.1: + resolution: {integrity: sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==} + engines: {node: '>= 0.4'} + + side-channel-map@1.0.1: + resolution: {integrity: sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==} + engines: {node: '>= 0.4'} + + side-channel-weakmap@1.0.2: + resolution: {integrity: sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==} + engines: {node: '>= 0.4'} + + side-channel@1.1.0: + resolution: {integrity: sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==} + engines: {node: '>= 0.4'} + + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + + standardwebhooks@1.0.0: + resolution: {integrity: sha512-BbHGOQK9olHPMvQNHWul6MYlrRTAOKn03rOe4A8O3CLWhNf4YHBqq2HJKKC+sfqpxiBY52pNeesD6jIiLDz8jg==} + + statuses@2.0.2: + resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} + engines: {node: '>= 0.8'} + + std-env@4.1.0: + resolution: {integrity: sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==} + + tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + + tinyexec@1.2.2: + resolution: {integrity: sha512-M/Q0B2cp4K7kynaT/vnED1j8TlLY+Pp7C6Wl2bl/7u/F0mUVwdyOpwomQb8JpYLitHUssAJRmLZdMCGsrx7i+g==} + engines: {node: '>=18'} + + tinyglobby@0.2.16: + resolution: {integrity: sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==} + engines: {node: '>=12.0.0'} + + tinyrainbow@3.1.0: + resolution: {integrity: sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==} + engines: {node: '>=14.0.0'} + + toidentifier@1.0.1: + resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} + engines: {node: '>=0.6'} + + ts-algebra@2.0.0: + resolution: {integrity: sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==} + + ts-api-utils@2.5.0: + resolution: {integrity: sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==} + engines: {node: '>=18.12'} + peerDependencies: + typescript: '>=4.8.4' + + ts-node@10.9.2: + resolution: {integrity: sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==} + hasBin: true + peerDependencies: + '@swc/core': '>=1.2.50' + '@swc/wasm': '>=1.2.50' + '@types/node': '*' + typescript: '>=2.7' + peerDependenciesMeta: + '@swc/core': + optional: true + '@swc/wasm': + optional: true + + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + + type-check@0.4.0: + resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} + engines: {node: '>= 0.8.0'} + + type-is@2.1.0: + resolution: {integrity: sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==} + engines: {node: '>= 18'} + + typescript@6.0.3: + resolution: {integrity: sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==} + engines: {node: '>=14.17'} + hasBin: true + + undici-types@7.24.6: + resolution: {integrity: sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==} + + unpipe@1.0.0: + resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==} + engines: {node: '>= 0.8'} + + uri-js@4.4.1: + resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} + + v8-compile-cache-lib@3.0.1: + resolution: {integrity: sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==} + + vary@1.1.2: + resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} + engines: {node: '>= 0.8'} + + vite@8.0.14: + resolution: {integrity: sha512-s4BJJ+5y1pYL6Otw51FHhVJQhPnuRinKig64g/1+EUNaJsd3gCKdD31IPFvswUgW9/60QT9oFHbZHbQK5imcxw==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@types/node': ^20.19.0 || >=22.12.0 + '@vitejs/devtools': ^0.1.18 + esbuild: ^0.27.0 || ^0.28.0 + jiti: '>=1.21.0' + less: ^4.0.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: '>=0.54.8' + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + '@vitejs/devtools': + optional: true + esbuild: + optional: true + jiti: + optional: true + less: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + vitest@4.1.7: + resolution: {integrity: sha512-flYyaFd2CgoCoU+0UKt3pxksgC+S02iTDN0n3LtqaMeXsI9SBcdNujc2k0DeFLzUn/0k538yNjOSdwgCqcrwJA==} + engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@opentelemetry/api': ^1.9.0 + '@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0 + '@vitest/browser-playwright': 4.1.7 + '@vitest/browser-preview': 4.1.7 + '@vitest/browser-webdriverio': 4.1.7 + '@vitest/coverage-istanbul': 4.1.7 + '@vitest/coverage-v8': 4.1.7 + '@vitest/ui': 4.1.7 + happy-dom: '*' + jsdom: '*' + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@opentelemetry/api': + optional: true + '@types/node': + optional: true + '@vitest/browser-playwright': + optional: true + '@vitest/browser-preview': + optional: true + '@vitest/browser-webdriverio': + optional: true + '@vitest/coverage-istanbul': + optional: true + '@vitest/coverage-v8': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + + which@2.0.2: + resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} + engines: {node: '>= 8'} + hasBin: true + + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + + word-wrap@1.2.5: + resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} + engines: {node: '>=0.10.0'} + + wrappy@1.0.2: + resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + + yn@3.1.1: + resolution: {integrity: sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==} + engines: {node: '>=6'} + + yocto-queue@0.1.0: + resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} + engines: {node: '>=10'} + + zod-to-json-schema@3.25.2: + resolution: {integrity: sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==} + peerDependencies: + zod: ^3.25.28 || ^4 + + zod@4.4.3: + resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==} + +snapshots: + + '@agentclientprotocol/sdk@0.22.1(zod@4.4.3)': + dependencies: + zod: 4.4.3 + + '@anthropic-ai/claude-agent-sdk-darwin-arm64@0.3.146': + optional: true + + '@anthropic-ai/claude-agent-sdk-darwin-x64@0.3.146': + optional: true + + '@anthropic-ai/claude-agent-sdk-linux-arm64-musl@0.3.146': + optional: true + + '@anthropic-ai/claude-agent-sdk-linux-arm64@0.3.146': + optional: true + + '@anthropic-ai/claude-agent-sdk-linux-x64-musl@0.3.146': + optional: true + + '@anthropic-ai/claude-agent-sdk-linux-x64@0.3.146': + optional: true + + '@anthropic-ai/claude-agent-sdk-win32-arm64@0.3.146': + optional: true + + '@anthropic-ai/claude-agent-sdk-win32-x64@0.3.146': + optional: true + + '@anthropic-ai/claude-agent-sdk@0.3.146(@anthropic-ai/sdk@0.97.1(zod@4.4.3))(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(zod@4.4.3)': + dependencies: + '@anthropic-ai/sdk': 0.97.1(zod@4.4.3) + '@modelcontextprotocol/sdk': 1.29.0(zod@4.4.3) + zod: 4.4.3 + optionalDependencies: + '@anthropic-ai/claude-agent-sdk-darwin-arm64': 0.3.146 + '@anthropic-ai/claude-agent-sdk-darwin-x64': 0.3.146 + '@anthropic-ai/claude-agent-sdk-linux-arm64': 0.3.146 + '@anthropic-ai/claude-agent-sdk-linux-arm64-musl': 0.3.146 + '@anthropic-ai/claude-agent-sdk-linux-x64': 0.3.146 + '@anthropic-ai/claude-agent-sdk-linux-x64-musl': 0.3.146 + '@anthropic-ai/claude-agent-sdk-win32-arm64': 0.3.146 + '@anthropic-ai/claude-agent-sdk-win32-x64': 0.3.146 + + '@anthropic-ai/sdk@0.97.1(zod@4.4.3)': + dependencies: + json-schema-to-ts: 3.1.1 + standardwebhooks: 1.0.0 + optionalDependencies: + zod: 4.4.3 + + '@babel/runtime@7.29.7': {} + + '@cspotcode/source-map-support@0.8.1': + dependencies: + '@jridgewell/trace-mapping': 0.3.9 + + '@emnapi/core@1.10.0': + dependencies: + '@emnapi/wasi-threads': 1.2.1 + tslib: 2.8.1 + optional: true + + '@emnapi/runtime@1.10.0': + dependencies: + tslib: 2.8.1 + optional: true + + '@emnapi/wasi-threads@1.2.1': + dependencies: + tslib: 2.8.1 + optional: true + + '@eslint-community/eslint-utils@4.9.1(eslint@10.4.0)': + dependencies: + eslint: 10.4.0 + eslint-visitor-keys: 3.4.3 + + '@eslint-community/regexpp@4.12.2': {} + + '@eslint/config-array@0.23.5': + dependencies: + '@eslint/object-schema': 3.0.5 + debug: 4.4.3 + minimatch: 10.2.5 + transitivePeerDependencies: + - supports-color + + '@eslint/config-helpers@0.6.0': + dependencies: + '@eslint/core': 1.2.1 + + '@eslint/core@1.2.1': + dependencies: + '@types/json-schema': 7.0.15 + + '@eslint/js@10.0.1(eslint@10.4.0)': + optionalDependencies: + eslint: 10.4.0 + + '@eslint/object-schema@3.0.5': {} + + '@eslint/plugin-kit@0.7.1': + dependencies: + '@eslint/core': 1.2.1 + levn: 0.4.1 + + '@hono/node-server@1.19.14(hono@4.12.23)': + dependencies: + hono: 4.12.23 + + '@humanfs/core@0.19.2': + dependencies: + '@humanfs/types': 0.15.0 + + '@humanfs/node@0.16.8': + dependencies: + '@humanfs/core': 0.19.2 + '@humanfs/types': 0.15.0 + '@humanwhocodes/retry': 0.4.3 + + '@humanfs/types@0.15.0': {} + + '@humanwhocodes/module-importer@1.0.1': {} + + '@humanwhocodes/retry@0.4.3': {} + + '@jridgewell/resolve-uri@3.1.2': {} + + '@jridgewell/sourcemap-codec@1.5.5': {} + + '@jridgewell/trace-mapping@0.3.9': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.5 + + '@modelcontextprotocol/sdk@1.29.0(zod@4.4.3)': + dependencies: + '@hono/node-server': 1.19.14(hono@4.12.23) + ajv: 8.20.0 + ajv-formats: 3.0.1(ajv@8.20.0) + content-type: 1.0.5 + cors: 2.8.6 + cross-spawn: 7.0.6 + eventsource: 3.0.7 + eventsource-parser: 3.1.0 + express: 5.2.1 + express-rate-limit: 8.5.2(express@5.2.1) + hono: 4.12.23 + jose: 6.2.3 + json-schema-typed: 8.0.2 + pkce-challenge: 5.0.1 + raw-body: 3.0.2 + zod: 4.4.3 + zod-to-json-schema: 3.25.2(zod@4.4.3) + transitivePeerDependencies: + - supports-color + + '@napi-rs/wasm-runtime@1.1.4(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)': + dependencies: + '@emnapi/core': 1.10.0 + '@emnapi/runtime': 1.10.0 + '@tybys/wasm-util': 0.10.2 + optional: true + + '@oxc-project/types@0.132.0': {} + + '@rolldown/binding-android-arm64@1.0.2': + optional: true + + '@rolldown/binding-darwin-arm64@1.0.2': + optional: true + + '@rolldown/binding-darwin-x64@1.0.2': + optional: true + + '@rolldown/binding-freebsd-x64@1.0.2': + optional: true + + '@rolldown/binding-linux-arm-gnueabihf@1.0.2': + optional: true + + '@rolldown/binding-linux-arm64-gnu@1.0.2': + optional: true + + '@rolldown/binding-linux-arm64-musl@1.0.2': + optional: true + + '@rolldown/binding-linux-ppc64-gnu@1.0.2': + optional: true + + '@rolldown/binding-linux-s390x-gnu@1.0.2': + optional: true + + '@rolldown/binding-linux-x64-gnu@1.0.2': + optional: true + + '@rolldown/binding-linux-x64-musl@1.0.2': + optional: true + + '@rolldown/binding-openharmony-arm64@1.0.2': + optional: true + + '@rolldown/binding-wasm32-wasi@1.0.2': + dependencies: + '@emnapi/core': 1.10.0 + '@emnapi/runtime': 1.10.0 + '@napi-rs/wasm-runtime': 1.1.4(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0) + optional: true + + '@rolldown/binding-win32-arm64-msvc@1.0.2': + optional: true + + '@rolldown/binding-win32-x64-msvc@1.0.2': + optional: true + + '@rolldown/pluginutils@1.0.1': {} + + '@stablelib/base64@1.0.1': {} + + '@standard-schema/spec@1.1.0': {} + + '@tsconfig/node10@1.0.12': {} + + '@tsconfig/node12@1.0.11': {} + + '@tsconfig/node14@1.0.3': {} + + '@tsconfig/node16@1.0.4': {} + + '@tybys/wasm-util@0.10.2': + dependencies: + tslib: 2.8.1 + optional: true + + '@types/chai@5.2.3': + dependencies: + '@types/deep-eql': 4.0.2 + assertion-error: 2.0.1 + + '@types/deep-eql@4.0.2': {} + + '@types/esrecurse@4.3.1': {} + + '@types/estree@1.0.9': {} + + '@types/json-schema@7.0.15': {} + + '@types/node@25.9.1': + dependencies: + undici-types: 7.24.6 + + '@typescript-eslint/eslint-plugin@8.59.4(@typescript-eslint/parser@8.59.4(eslint@10.4.0)(typescript@6.0.3))(eslint@10.4.0)(typescript@6.0.3)': + dependencies: + '@eslint-community/regexpp': 4.12.2 + '@typescript-eslint/parser': 8.59.4(eslint@10.4.0)(typescript@6.0.3) + '@typescript-eslint/scope-manager': 8.59.4 + '@typescript-eslint/type-utils': 8.59.4(eslint@10.4.0)(typescript@6.0.3) + '@typescript-eslint/utils': 8.59.4(eslint@10.4.0)(typescript@6.0.3) + '@typescript-eslint/visitor-keys': 8.59.4 + eslint: 10.4.0 + ignore: 7.0.5 + natural-compare: 1.4.0 + ts-api-utils: 2.5.0(typescript@6.0.3) + typescript: 6.0.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/parser@8.59.4(eslint@10.4.0)(typescript@6.0.3)': + dependencies: + '@typescript-eslint/scope-manager': 8.59.4 + '@typescript-eslint/types': 8.59.4 + '@typescript-eslint/typescript-estree': 8.59.4(typescript@6.0.3) + '@typescript-eslint/visitor-keys': 8.59.4 + debug: 4.4.3 + eslint: 10.4.0 + typescript: 6.0.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/project-service@8.59.4(typescript@6.0.3)': + dependencies: + '@typescript-eslint/tsconfig-utils': 8.59.4(typescript@6.0.3) + '@typescript-eslint/types': 8.59.4 + debug: 4.4.3 + typescript: 6.0.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/scope-manager@8.59.4': + dependencies: + '@typescript-eslint/types': 8.59.4 + '@typescript-eslint/visitor-keys': 8.59.4 + + '@typescript-eslint/tsconfig-utils@8.59.4(typescript@6.0.3)': + dependencies: + typescript: 6.0.3 + + '@typescript-eslint/type-utils@8.59.4(eslint@10.4.0)(typescript@6.0.3)': + dependencies: + '@typescript-eslint/types': 8.59.4 + '@typescript-eslint/typescript-estree': 8.59.4(typescript@6.0.3) + '@typescript-eslint/utils': 8.59.4(eslint@10.4.0)(typescript@6.0.3) + debug: 4.4.3 + eslint: 10.4.0 + ts-api-utils: 2.5.0(typescript@6.0.3) + typescript: 6.0.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/types@8.59.4': {} + + '@typescript-eslint/typescript-estree@8.59.4(typescript@6.0.3)': + dependencies: + '@typescript-eslint/project-service': 8.59.4(typescript@6.0.3) + '@typescript-eslint/tsconfig-utils': 8.59.4(typescript@6.0.3) + '@typescript-eslint/types': 8.59.4 + '@typescript-eslint/visitor-keys': 8.59.4 + debug: 4.4.3 + minimatch: 10.2.5 + semver: 7.8.1 + tinyglobby: 0.2.16 + ts-api-utils: 2.5.0(typescript@6.0.3) + typescript: 6.0.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/utils@8.59.4(eslint@10.4.0)(typescript@6.0.3)': + dependencies: + '@eslint-community/eslint-utils': 4.9.1(eslint@10.4.0) + '@typescript-eslint/scope-manager': 8.59.4 + '@typescript-eslint/types': 8.59.4 + '@typescript-eslint/typescript-estree': 8.59.4(typescript@6.0.3) + eslint: 10.4.0 + typescript: 6.0.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/visitor-keys@8.59.4': + dependencies: + '@typescript-eslint/types': 8.59.4 + eslint-visitor-keys: 5.0.1 + + '@vitest/expect@4.1.7': + dependencies: + '@standard-schema/spec': 1.1.0 + '@types/chai': 5.2.3 + '@vitest/spy': 4.1.7 + '@vitest/utils': 4.1.7 + chai: 6.2.2 + tinyrainbow: 3.1.0 + + '@vitest/mocker@4.1.7(vite@8.0.14(@types/node@25.9.1))': + dependencies: + '@vitest/spy': 4.1.7 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 8.0.14(@types/node@25.9.1) + + '@vitest/pretty-format@4.1.7': + dependencies: + tinyrainbow: 3.1.0 + + '@vitest/runner@4.1.7': + dependencies: + '@vitest/utils': 4.1.7 + pathe: 2.0.3 + + '@vitest/snapshot@4.1.7': + dependencies: + '@vitest/pretty-format': 4.1.7 + '@vitest/utils': 4.1.7 + magic-string: 0.30.21 + pathe: 2.0.3 + + '@vitest/spy@4.1.7': {} + + '@vitest/utils@4.1.7': + dependencies: + '@vitest/pretty-format': 4.1.7 + convert-source-map: 2.0.0 + tinyrainbow: 3.1.0 + + accepts@2.0.0: + dependencies: + mime-types: 3.0.2 + negotiator: 1.0.0 + + acorn-jsx@5.3.2(acorn@8.16.0): + dependencies: + acorn: 8.16.0 + + acorn-walk@8.3.5: + dependencies: + acorn: 8.16.0 + + acorn@8.16.0: {} + + ajv-formats@3.0.1(ajv@8.20.0): + optionalDependencies: + ajv: 8.20.0 + + ajv@6.15.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-json-stable-stringify: 2.1.0 + json-schema-traverse: 0.4.1 + uri-js: 4.4.1 + + ajv@8.20.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-uri: 3.1.2 + json-schema-traverse: 1.0.0 + require-from-string: 2.0.2 + + arg@4.1.3: {} + + assertion-error@2.0.1: {} + + balanced-match@4.0.4: {} + + body-parser@2.2.2: + dependencies: + bytes: 3.1.2 + content-type: 1.0.5 + debug: 4.4.3 + http-errors: 2.0.1 + iconv-lite: 0.7.2 + on-finished: 2.4.1 + qs: 6.15.2 + raw-body: 3.0.2 + type-is: 2.1.0 + transitivePeerDependencies: + - supports-color + + brace-expansion@5.0.6: + dependencies: + balanced-match: 4.0.4 + + bytes@3.1.2: {} + + call-bind-apply-helpers@1.0.2: + dependencies: + es-errors: 1.3.0 + function-bind: 1.1.2 + + call-bound@1.0.4: + dependencies: + call-bind-apply-helpers: 1.0.2 + get-intrinsic: 1.3.0 + + chai@6.2.2: {} + + content-disposition@1.1.0: {} + + content-type@1.0.5: {} + + content-type@2.0.0: {} + + convert-source-map@2.0.0: {} + + cookie-signature@1.2.2: {} + + cookie@0.7.2: {} + + cors@2.8.6: + dependencies: + object-assign: 4.1.1 + vary: 1.1.2 + + create-require@1.1.1: {} + + cross-spawn@7.0.6: + dependencies: + path-key: 3.1.1 + shebang-command: 2.0.0 + which: 2.0.2 + + debug@4.4.3: + dependencies: + ms: 2.1.3 + + deep-is@0.1.4: {} + + depd@2.0.0: {} + + detect-libc@2.1.2: {} + + diff@4.0.4: {} + + dunder-proto@1.0.1: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-errors: 1.3.0 + gopd: 1.2.0 + + ee-first@1.1.1: {} + + encodeurl@2.0.0: {} + + es-define-property@1.0.1: {} + + es-errors@1.3.0: {} + + es-module-lexer@2.1.0: {} + + es-object-atoms@1.1.2: + dependencies: + es-errors: 1.3.0 + + escape-html@1.0.3: {} + + escape-string-regexp@4.0.0: {} + + eslint-config-prettier@10.1.8(eslint@10.4.0): + dependencies: + eslint: 10.4.0 + + eslint-scope@9.1.2: + dependencies: + '@types/esrecurse': 4.3.1 + '@types/estree': 1.0.9 + esrecurse: 4.3.0 + estraverse: 5.3.0 + + eslint-visitor-keys@3.4.3: {} + + eslint-visitor-keys@5.0.1: {} + + eslint@10.4.0: + dependencies: + '@eslint-community/eslint-utils': 4.9.1(eslint@10.4.0) + '@eslint-community/regexpp': 4.12.2 + '@eslint/config-array': 0.23.5 + '@eslint/config-helpers': 0.6.0 + '@eslint/core': 1.2.1 + '@eslint/plugin-kit': 0.7.1 + '@humanfs/node': 0.16.8 + '@humanwhocodes/module-importer': 1.0.1 + '@humanwhocodes/retry': 0.4.3 + '@types/estree': 1.0.9 + ajv: 6.15.0 + cross-spawn: 7.0.6 + debug: 4.4.3 + escape-string-regexp: 4.0.0 + eslint-scope: 9.1.2 + eslint-visitor-keys: 5.0.1 + espree: 11.2.0 + esquery: 1.7.0 + esutils: 2.0.3 + fast-deep-equal: 3.1.3 + file-entry-cache: 8.0.0 + find-up: 5.0.0 + glob-parent: 6.0.2 + ignore: 5.3.2 + imurmurhash: 0.1.4 + is-glob: 4.0.3 + json-stable-stringify-without-jsonify: 1.0.1 + minimatch: 10.2.5 + natural-compare: 1.4.0 + optionator: 0.9.4 + transitivePeerDependencies: + - supports-color + + espree@11.2.0: + dependencies: + acorn: 8.16.0 + acorn-jsx: 5.3.2(acorn@8.16.0) + eslint-visitor-keys: 5.0.1 + + esquery@1.7.0: + dependencies: + estraverse: 5.3.0 + + esrecurse@4.3.0: + dependencies: + estraverse: 5.3.0 + + estraverse@5.3.0: {} + + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.9 + + esutils@2.0.3: {} + + etag@1.8.1: {} + + eventsource-parser@3.1.0: {} + + eventsource@3.0.7: + dependencies: + eventsource-parser: 3.1.0 + + expect-type@1.3.0: {} + + express-rate-limit@8.5.2(express@5.2.1): + dependencies: + express: 5.2.1 + ip-address: 10.2.0 + + express@5.2.1: + dependencies: + accepts: 2.0.0 + body-parser: 2.2.2 + content-disposition: 1.1.0 + content-type: 1.0.5 + cookie: 0.7.2 + cookie-signature: 1.2.2 + debug: 4.4.3 + depd: 2.0.0 + encodeurl: 2.0.0 + escape-html: 1.0.3 + etag: 1.8.1 + finalhandler: 2.1.1 + fresh: 2.0.0 + http-errors: 2.0.1 + merge-descriptors: 2.0.0 + mime-types: 3.0.2 + on-finished: 2.4.1 + once: 1.4.0 + parseurl: 1.3.3 + proxy-addr: 2.0.7 + qs: 6.15.2 + range-parser: 1.2.1 + router: 2.2.0 + send: 1.2.1 + serve-static: 2.2.1 + statuses: 2.0.2 + type-is: 2.1.0 + vary: 1.1.2 + transitivePeerDependencies: + - supports-color + + fast-deep-equal@3.1.3: {} + + fast-json-stable-stringify@2.1.0: {} + + fast-levenshtein@2.0.6: {} + + fast-sha256@1.3.0: {} + + fast-uri@3.1.2: {} + + fdir@6.5.0(picomatch@4.0.4): + optionalDependencies: + picomatch: 4.0.4 + + file-entry-cache@8.0.0: + dependencies: + flat-cache: 4.0.1 + + finalhandler@2.1.1: + dependencies: + debug: 4.4.3 + encodeurl: 2.0.0 + escape-html: 1.0.3 + on-finished: 2.4.1 + parseurl: 1.3.3 + statuses: 2.0.2 + transitivePeerDependencies: + - supports-color + + find-up@5.0.0: + dependencies: + locate-path: 6.0.0 + path-exists: 4.0.0 + + flat-cache@4.0.1: + dependencies: + flatted: 3.4.2 + keyv: 4.5.4 + + flatted@3.4.2: {} + + forwarded@0.2.0: {} + + fresh@2.0.0: {} + + fsevents@2.3.3: + optional: true + + function-bind@1.1.2: {} + + get-intrinsic@1.3.0: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-define-property: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.2 + function-bind: 1.1.2 + get-proto: 1.0.1 + gopd: 1.2.0 + has-symbols: 1.1.0 + hasown: 2.0.3 + math-intrinsics: 1.1.0 + + get-proto@1.0.1: + dependencies: + dunder-proto: 1.0.1 + es-object-atoms: 1.1.2 + + glob-parent@6.0.2: + dependencies: + is-glob: 4.0.3 + + globals@17.6.0: {} + + gopd@1.2.0: {} + + has-symbols@1.1.0: {} + + hasown@2.0.3: + dependencies: + function-bind: 1.1.2 + + hono@4.12.23: {} + + http-errors@2.0.1: + dependencies: + depd: 2.0.0 + inherits: 2.0.4 + setprototypeof: 1.2.0 + statuses: 2.0.2 + toidentifier: 1.0.1 + + iconv-lite@0.7.2: + dependencies: + safer-buffer: 2.1.2 + + ignore@5.3.2: {} + + ignore@7.0.5: {} + + imurmurhash@0.1.4: {} + + inherits@2.0.4: {} + + ip-address@10.2.0: {} + + ipaddr.js@1.9.1: {} + + is-extglob@2.1.1: {} + + is-glob@4.0.3: + dependencies: + is-extglob: 2.1.1 + + is-promise@4.0.0: {} + + isexe@2.0.0: {} + + jose@6.2.3: {} + + json-buffer@3.0.1: {} + + json-schema-to-ts@3.1.1: + dependencies: + '@babel/runtime': 7.29.7 + ts-algebra: 2.0.0 + + json-schema-traverse@0.4.1: {} + + json-schema-traverse@1.0.0: {} + + json-schema-typed@8.0.2: {} + + json-stable-stringify-without-jsonify@1.0.1: {} + + keyv@4.5.4: + dependencies: + json-buffer: 3.0.1 + + levn@0.4.1: + dependencies: + prelude-ls: 1.2.1 + type-check: 0.4.0 + + lightningcss-android-arm64@1.32.0: + optional: true + + lightningcss-darwin-arm64@1.32.0: + optional: true + + lightningcss-darwin-x64@1.32.0: + optional: true + + lightningcss-freebsd-x64@1.32.0: + optional: true + + lightningcss-linux-arm-gnueabihf@1.32.0: + optional: true + + lightningcss-linux-arm64-gnu@1.32.0: + optional: true + + lightningcss-linux-arm64-musl@1.32.0: + optional: true + + lightningcss-linux-x64-gnu@1.32.0: + optional: true + + lightningcss-linux-x64-musl@1.32.0: + optional: true + + lightningcss-win32-arm64-msvc@1.32.0: + optional: true + + lightningcss-win32-x64-msvc@1.32.0: + optional: true + + lightningcss@1.32.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 + + locate-path@6.0.0: + dependencies: + p-locate: 5.0.0 + + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + + make-error@1.3.6: {} + + math-intrinsics@1.1.0: {} + + media-typer@1.1.0: {} + + merge-descriptors@2.0.0: {} + + mime-db@1.54.0: {} + + mime-types@3.0.2: + dependencies: + mime-db: 1.54.0 + + minimatch@10.2.5: + dependencies: + brace-expansion: 5.0.6 + + ms@2.1.3: {} + + nanoid@3.3.12: {} + + natural-compare@1.4.0: {} + + negotiator@1.0.0: {} + + object-assign@4.1.1: {} + + object-inspect@1.13.4: {} + + obug@2.1.1: {} + + on-finished@2.4.1: + dependencies: + ee-first: 1.1.1 + + once@1.4.0: + dependencies: + wrappy: 1.0.2 + + optionator@0.9.4: + dependencies: + deep-is: 0.1.4 + fast-levenshtein: 2.0.6 + levn: 0.4.1 + prelude-ls: 1.2.1 + type-check: 0.4.0 + word-wrap: 1.2.5 + + p-limit@3.1.0: + dependencies: + yocto-queue: 0.1.0 + + p-locate@5.0.0: + dependencies: + p-limit: 3.1.0 + + parseurl@1.3.3: {} + + path-exists@4.0.0: {} + + path-key@3.1.1: {} + + path-to-regexp@8.4.2: {} + + pathe@2.0.3: {} + + picocolors@1.1.1: {} + + picomatch@4.0.4: {} + + pkce-challenge@5.0.1: {} + + postcss@8.5.15: + dependencies: + nanoid: 3.3.12 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + prelude-ls@1.2.1: {} + + prettier@3.8.3: {} + + proxy-addr@2.0.7: + dependencies: + forwarded: 0.2.0 + ipaddr.js: 1.9.1 + + punycode@2.3.1: {} + + qs@6.15.2: + dependencies: + side-channel: 1.1.0 + + range-parser@1.2.1: {} + + raw-body@3.0.2: + dependencies: + bytes: 3.1.2 + http-errors: 2.0.1 + iconv-lite: 0.7.2 + unpipe: 1.0.0 + + require-from-string@2.0.2: {} + + rolldown@1.0.2: + dependencies: + '@oxc-project/types': 0.132.0 + '@rolldown/pluginutils': 1.0.1 + optionalDependencies: + '@rolldown/binding-android-arm64': 1.0.2 + '@rolldown/binding-darwin-arm64': 1.0.2 + '@rolldown/binding-darwin-x64': 1.0.2 + '@rolldown/binding-freebsd-x64': 1.0.2 + '@rolldown/binding-linux-arm-gnueabihf': 1.0.2 + '@rolldown/binding-linux-arm64-gnu': 1.0.2 + '@rolldown/binding-linux-arm64-musl': 1.0.2 + '@rolldown/binding-linux-ppc64-gnu': 1.0.2 + '@rolldown/binding-linux-s390x-gnu': 1.0.2 + '@rolldown/binding-linux-x64-gnu': 1.0.2 + '@rolldown/binding-linux-x64-musl': 1.0.2 + '@rolldown/binding-openharmony-arm64': 1.0.2 + '@rolldown/binding-wasm32-wasi': 1.0.2 + '@rolldown/binding-win32-arm64-msvc': 1.0.2 + '@rolldown/binding-win32-x64-msvc': 1.0.2 + + router@2.2.0: + dependencies: + debug: 4.4.3 + depd: 2.0.0 + is-promise: 4.0.0 + parseurl: 1.3.3 + path-to-regexp: 8.4.2 + transitivePeerDependencies: + - supports-color + + safer-buffer@2.1.2: {} + + semver@7.8.1: {} + + send@1.2.1: + dependencies: + debug: 4.4.3 + encodeurl: 2.0.0 + escape-html: 1.0.3 + etag: 1.8.1 + fresh: 2.0.0 + http-errors: 2.0.1 + mime-types: 3.0.2 + ms: 2.1.3 + on-finished: 2.4.1 + range-parser: 1.2.1 + statuses: 2.0.2 + transitivePeerDependencies: + - supports-color + + serve-static@2.2.1: + dependencies: + encodeurl: 2.0.0 + escape-html: 1.0.3 + parseurl: 1.3.3 + send: 1.2.1 + transitivePeerDependencies: + - supports-color + + setprototypeof@1.2.0: {} + + shebang-command@2.0.0: + dependencies: + shebang-regex: 3.0.0 + + shebang-regex@3.0.0: {} + + side-channel-list@1.0.1: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + + side-channel-map@1.0.1: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + + side-channel-weakmap@1.0.2: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + side-channel-map: 1.0.1 + + side-channel@1.1.0: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + side-channel-list: 1.0.1 + side-channel-map: 1.0.1 + side-channel-weakmap: 1.0.2 + + siginfo@2.0.0: {} + + source-map-js@1.2.1: {} + + stackback@0.0.2: {} + + standardwebhooks@1.0.0: + dependencies: + '@stablelib/base64': 1.0.1 + fast-sha256: 1.3.0 + + statuses@2.0.2: {} + + std-env@4.1.0: {} + + tinybench@2.9.0: {} + + tinyexec@1.2.2: {} + + tinyglobby@0.2.16: + dependencies: + fdir: 6.5.0(picomatch@4.0.4) + picomatch: 4.0.4 + + tinyrainbow@3.1.0: {} + + toidentifier@1.0.1: {} + + ts-algebra@2.0.0: {} + + ts-api-utils@2.5.0(typescript@6.0.3): + dependencies: + typescript: 6.0.3 + + ts-node@10.9.2(@types/node@25.9.1)(typescript@6.0.3): + dependencies: + '@cspotcode/source-map-support': 0.8.1 + '@tsconfig/node10': 1.0.12 + '@tsconfig/node12': 1.0.11 + '@tsconfig/node14': 1.0.3 + '@tsconfig/node16': 1.0.4 + '@types/node': 25.9.1 + acorn: 8.16.0 + acorn-walk: 8.3.5 + arg: 4.1.3 + create-require: 1.1.1 + diff: 4.0.4 + make-error: 1.3.6 + typescript: 6.0.3 + v8-compile-cache-lib: 3.0.1 + yn: 3.1.1 + + tslib@2.8.1: + optional: true + + type-check@0.4.0: + dependencies: + prelude-ls: 1.2.1 + + type-is@2.1.0: + dependencies: + content-type: 2.0.0 + media-typer: 1.1.0 + mime-types: 3.0.2 + + typescript@6.0.3: {} + + undici-types@7.24.6: {} + + unpipe@1.0.0: {} + + uri-js@4.4.1: + dependencies: + punycode: 2.3.1 + + v8-compile-cache-lib@3.0.1: {} + + vary@1.1.2: {} + + vite@8.0.14(@types/node@25.9.1): + dependencies: + lightningcss: 1.32.0 + picomatch: 4.0.4 + postcss: 8.5.15 + rolldown: 1.0.2 + tinyglobby: 0.2.16 + optionalDependencies: + '@types/node': 25.9.1 + fsevents: 2.3.3 + + vitest@4.1.7(@types/node@25.9.1)(vite@8.0.14(@types/node@25.9.1)): + dependencies: + '@vitest/expect': 4.1.7 + '@vitest/mocker': 4.1.7(vite@8.0.14(@types/node@25.9.1)) + '@vitest/pretty-format': 4.1.7 + '@vitest/runner': 4.1.7 + '@vitest/snapshot': 4.1.7 + '@vitest/spy': 4.1.7 + '@vitest/utils': 4.1.7 + es-module-lexer: 2.1.0 + expect-type: 1.3.0 + magic-string: 0.30.21 + obug: 2.1.1 + pathe: 2.0.3 + picomatch: 4.0.4 + std-env: 4.1.0 + tinybench: 2.9.0 + tinyexec: 1.2.2 + tinyglobby: 0.2.16 + tinyrainbow: 3.1.0 + vite: 8.0.14(@types/node@25.9.1) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 25.9.1 + transitivePeerDependencies: + - msw + + which@2.0.2: + dependencies: + isexe: 2.0.0 + + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 + + word-wrap@1.2.5: {} + + wrappy@1.0.2: {} + + yn@3.1.1: {} + + yocto-queue@0.1.0: {} + + zod-to-json-schema@3.25.2(zod@4.4.3): + dependencies: + zod: 4.4.3 + + zod@4.4.3: {} diff --git a/qiming-claude-code-acp-ts/release-please-config.json b/qiming-claude-code-acp-ts/release-please-config.json new file mode 100644 index 00000000..ea933f2e --- /dev/null +++ b/qiming-claude-code-acp-ts/release-please-config.json @@ -0,0 +1,13 @@ +{ + "bump-minor-pre-major": true, + "bump-patch-for-minor-pre-major": true, + "packages": { + ".": { + "changelog-path": "CHANGELOG.md", + "release-type": "node", + "bump-minor-pre-major": true, + "bump-patch-for-minor-pre-major": true + } + }, + "$schema": "https://raw.githubusercontent.com/googleapis/release-please/main/schemas/config.json" +} diff --git a/qiming-claude-code-acp-ts/src/acp-agent.ts b/qiming-claude-code-acp-ts/src/acp-agent.ts new file mode 100644 index 00000000..4c4d4caf --- /dev/null +++ b/qiming-claude-code-acp-ts/src/acp-agent.ts @@ -0,0 +1,3287 @@ +import { + Agent, + AgentSideConnection, + AuthenticateRequest, + AuthMethod, + AvailableCommand, + CancelNotification, + ClientCapabilities, + ForkSessionRequest, + ForkSessionResponse, + InitializeRequest, + InitializeResponse, + ListSessionsRequest, + ListSessionsResponse, + LoadSessionRequest, + LoadSessionResponse, + ndJsonStream, + NewSessionRequest, + NewSessionResponse, + PermissionOption, + PromptRequest, + PromptResponse, + ReadTextFileRequest, + ReadTextFileResponse, + RequestError, + ResumeSessionRequest, + ResumeSessionResponse, + SessionConfigOption, + SessionModelState, + SessionModeState, + SessionNotification, + SetSessionConfigOptionRequest, + SetSessionConfigOptionResponse, + SetSessionModelRequest, + SetSessionModelResponse, + SetSessionModeRequest, + SetSessionModeResponse, + CloseSessionRequest, + CloseSessionResponse, + DeleteSessionRequest, + DeleteSessionResponse, + TerminalHandle, + TerminalOutputResponse, + WriteTextFileRequest, + WriteTextFileResponse, + StopReason, +} from "@agentclientprotocol/sdk"; +import { + CanUseTool, + deleteSession, + getSessionMessages, + listSessions, + McpServerConfig, + ModelInfo, + ModelUsage, + Options, + PermissionMode, + PermissionUpdate, + Query, + query, + Settings, + SDKAssistantMessageError, + SDKMessageOrigin, + SDKPartialAssistantMessage, + SDKUserMessage, + SlashCommand, +} from "@anthropic-ai/claude-agent-sdk"; +import { ContentBlockParam } from "@anthropic-ai/sdk/resources"; +import { BetaContentBlock, BetaRawContentBlockDelta } from "@anthropic-ai/sdk/resources/beta.mjs"; +import { randomUUID } from "node:crypto"; +import * as os from "node:os"; +import * as path from "node:path"; +import packageJson from "../package.json" with { type: "json" }; +import { SettingsManager } from "./settings.js"; +import { + applyTaskCreate, + applyTaskUpdate, + ClaudePlanEntry, + createPostToolUseHook, + createTaskHook, + parseTaskCreateOutput, + planEntries, + registerHookCallback, + TaskState, + taskStateToPlanEntries, + toolInfoFromToolUse, + toolUpdateFromDiffToolResponse, + toolUpdateFromToolResult, +} from "./tools.js"; +import { nodeToWebReadable, nodeToWebWritable, Pushable, unreachable } from "./utils.js"; + +export const CLAUDE_CONFIG_DIR = + process.env.CLAUDE_CONFIG_DIR ?? path.join(os.homedir(), ".claude"); + +const MAX_TITLE_LENGTH = 256; + +function sanitizeTitle(text: string): string { + // Replace newlines and collapse whitespace + const sanitized = text + .replace(/[\r\n]+/g, " ") + .replace(/\s+/g, " ") + .trim(); + if (sanitized.length <= MAX_TITLE_LENGTH) { + return sanitized; + } + return sanitized.slice(0, MAX_TITLE_LENGTH - 1) + "…"; +} + +/** + * Logger interface for customizing logging output + */ +export interface Logger { + log: (...args: any[]) => void; + error: (...args: any[]) => void; +} + +type AccumulatedUsage = { + inputTokens: number; + outputTokens: number; + cachedReadTokens: number; + cachedWriteTokens: number; +}; + +type UsageSnapshot = { + input_tokens: number; + output_tokens: number; + cache_read_input_tokens: number; + cache_creation_input_tokens: number; +}; + +const ZERO_USAGE = Object.freeze({ + input_tokens: 0, + output_tokens: 0, + cache_read_input_tokens: 0, + cache_creation_input_tokens: 0, +}); + +const DEFAULT_CONTEXT_WINDOW = 200000; + +type Session = { + query: Query; + input: Pushable; + cancelled: boolean; + cwd: string; + /** Serialized snapshot of session-defining params (cwd, mcpServers) used to + * detect when loadSession/resumeSession is called with changed values. */ + sessionFingerprint: string; + settingsManager: SettingsManager; + accumulatedUsage: AccumulatedUsage; + modes: SessionModeState; + models: SessionModelState; + modelInfos: ModelInfo[]; + configOptions: SessionConfigOption[]; + promptRunning: boolean; + pendingMessages: Map void; order: number }>; + nextPendingOrder: number; + abortController: AbortController; + emitRawSDKMessages: boolean | SDKMessageFilter[]; + /** Context window size of the last top-level assistant model, carried across + * prompts so mid-stream usage_update notifications report a correct `size` + * before the turn's first result message arrives. Defaults to + * DEFAULT_CONTEXT_WINDOW, refreshed from each result's modelUsage, and + * invalidated when the user switches the session's model. */ + contextWindowSize: number; + /** Accumulated task list for the session, keyed by task ID. Task IDs are + * per-session, so this state must not be shared across sessions. */ + taskState: TaskState; +}; + +/** Compute a stable fingerprint of the session-defining params so we can + * detect when a loadSession/resumeSession call requires tearing down and + * recreating the underlying Query process. MCP servers are sorted by name + * so that ordering differences don't trigger unnecessary recreations. */ +function computeSessionFingerprint(params: { + cwd: string; + mcpServers?: NewSessionRequest["mcpServers"]; +}): string { + const servers = [...(params.mcpServers ?? [])].sort((a, b) => a.name.localeCompare(b.name)); + return JSON.stringify({ cwd: params.cwd, mcpServers: servers }); +} + +type BackgroundTerminal = + | { + handle: TerminalHandle; + status: "started"; + lastOutput: TerminalOutputResponse | null; + } + | { + status: "aborted" | "exited" | "killed" | "timedOut"; + pendingOutput: TerminalOutputResponse; + }; + +export type SDKMessageFilter = { + type: string; + subtype?: string; + origin?: SDKMessageOrigin["kind"]; +}; + +/** + * Extra metadata that can be given when creating a new session. + */ +export type NewSessionMeta = { + claudeCode?: { + /** + * Options forwarded to Claude Code when starting a new session. + * Those parameters will be ignored and managed by ACP: + * - cwd + * - includePartialMessages + * - allowDangerouslySkipPermissions + * - permissionMode + * - canUseTool + * - executable + * Those parameters will be used and updated to work with ACP: + * - hooks (merged with ACP's hooks) + * - mcpServers (merged with ACP's mcpServers) + * - disallowedTools (merged with ACP's disallowedTools) + * - tools (passed through; defaults to claude_code preset if not provided) + */ + options?: Options; + /** + * When set, raw SDK messages are emitted as extNotification("_claude/sdkMessage", message) + * in addition to normal processing. + * - true: emit all messages + * - false/undefined: emit nothing (default) + * - SDKMessageFilter[]: emit only messages matching at least one filter + */ + emitRawSDKMessages?: boolean | SDKMessageFilter[]; + }; + additionalRoots?: string[]; +}; + +/** + * Extra metadata for 'gateway' authentication requests. + */ +type GatewayAuthMeta = { + /** + * These parameters are mapped to environment variables to: + * - Redirect API calls via baseUrl + * - Inject custom headers + * - Bypass the default Claude login requirement + */ + gateway: { + baseUrl: string; + headers: Record; + }; +}; + +type GatewayAuthRequest = AuthenticateRequest & { _meta?: GatewayAuthMeta }; + +/** + * Extra metadata that the agent provides for each tool_call / tool_update update. + */ +export type ToolUpdateMeta = { + claudeCode?: { + /* The name of the tool that was used in Claude Code. */ + toolName: string; + /* The structured output provided by Claude Code. */ + toolResponse?: unknown; + }; + /* Terminal metadata for Bash tool execution, matching codex-acp's _meta protocol. */ + terminal_info?: { + terminal_id: string; + }; + terminal_output?: { + terminal_id: string; + data: string; + }; + terminal_exit?: { + terminal_id: string; + exit_code: number; + signal: string | null; + }; +}; + +export type ToolUseCache = { + [key: string]: { + type: "tool_use" | "server_tool_use" | "mcp_tool_use"; + id: string; + name: string; + input: unknown; + }; +}; + +export async function claudeCliPath(): Promise { + if (process.env.CLAUDE_CODE_EXECUTABLE) { + return process.env.CLAUDE_CODE_EXECUTABLE; + } + // The SDK's CLI is a native binary shipped as a platform-specific optional + // dependency of @anthropic-ai/claude-agent-sdk. Resolve via a require bound + // to the SDK so nested installs are found even when npm doesn't hoist. + const { createRequire } = await import("node:module"); + const req = createRequire(import.meta.resolve("@anthropic-ai/claude-agent-sdk")); + const ext = process.platform === "win32" ? ".exe" : ""; + // On linux, both glibc and musl variants may be installed side-by-side + // (e.g. bunx hydrates every optional dep), so picking one by trial is + // unreliable: the wrong binary segfaults at runtime instead of failing to + // spawn. Detect the runtime libc and prefer the matching variant, falling + // back to the other only if the preferred one isn't installed. + const candidates = + process.platform === "linux" + ? isMuslLibc() + ? [ + `@anthropic-ai/claude-agent-sdk-linux-${process.arch}-musl/claude${ext}`, + `@anthropic-ai/claude-agent-sdk-linux-${process.arch}/claude${ext}`, + ] + : [ + `@anthropic-ai/claude-agent-sdk-linux-${process.arch}/claude${ext}`, + `@anthropic-ai/claude-agent-sdk-linux-${process.arch}-musl/claude${ext}`, + ] + : [`@anthropic-ai/claude-agent-sdk-${process.platform}-${process.arch}/claude${ext}`]; + for (const candidate of candidates) { + try { + return req.resolve(candidate); + } catch { + // try next candidate + } + } + throw new Error( + `Claude native binary not found for ${process.platform}-${process.arch}. ` + + `Reinstall @anthropic-ai/claude-agent-sdk without --omit=optional, or set CLAUDE_CODE_EXECUTABLE.`, + ); +} + +function isMuslLibc(): boolean { + // process.report.getReport().header.glibcVersionRuntime is populated when + // Node is dynamically linked against glibc, and absent on musl. + const report = process.report?.getReport() as + | { header?: { glibcVersionRuntime?: string } } + | undefined; + return !report?.header?.glibcVersionRuntime; +} + +function shouldHideClaudeAuth(): boolean { + return process.argv.includes("--hide-claude-auth"); +} + +// Bypass Permissions doesn't work if we are a root/sudo user +const IS_ROOT = (process.geteuid?.() ?? process.getuid?.()) === 0; +const ALLOW_BYPASS = !IS_ROOT || !!process.env.IS_SANDBOX; + +// Slash commands that the SDK handles locally without replaying the user +// message and without invoking the model. +const LOCAL_ONLY_COMMANDS = new Set(["/context", "/heapdump", "/extra-usage"]); + +// The Claude SDK persists local slash command invocations (e.g. `/model`) and +// their output as user messages in the session transcript, wrapping the +// payload in these XML-like markers that the CLI uses for its own display. +// The live prompt loop drops them; replay must strip them too or they leak +// into the UI on session/load. +const LOCAL_COMMAND_TAG_PATTERN = + /<(command-name|command-message|command-args|local-command-stdout|local-command-stderr)>[\s\S]*?<\/\1>/g; + +function stripMarkerTags(text: string): string { + return text.replace(LOCAL_COMMAND_TAG_PATTERN, ""); +} + +/** + * Return user-message content with local-command marker tags removed, or + * `null` if nothing meaningful remains (caller should skip the message). + * Preserves real prose that's mixed in alongside the markers — e.g. a + * message like `hi` becomes `hi`. + */ +export function stripLocalCommandMetadata(content: unknown): unknown | null { + if (typeof content === "string") { + const stripped = stripMarkerTags(content); + return stripped.trim() === "" ? null : stripped; + } + if (!Array.isArray(content)) return content; + + const kept: unknown[] = []; + for (const block of content) { + if ( + block && + typeof block === "object" && + "type" in block && + (block as { type: unknown }).type === "text" && + "text" in block && + typeof (block as { text: unknown }).text === "string" + ) { + const stripped = stripMarkerTags((block as { text: string }).text); + if (stripped.trim() === "") continue; + kept.push({ ...(block as object), text: stripped }); + } else { + kept.push(block); + } + } + if (kept.length === 0) return null; + return kept; +} + +export function isLocalCommandMetadata(content: unknown): boolean { + return stripLocalCommandMetadata(content) === null; +} + +const PERMISSION_MODE_ALIASES: Record = { + auto: "auto", + default: "default", + acceptedits: "acceptEdits", + dontask: "dontAsk", + plan: "plan", + bypasspermissions: "bypassPermissions", + bypass: "bypassPermissions", +}; + +export function resolvePermissionMode( + defaultMode?: unknown, + logger: Logger = console, +): PermissionMode { + if (defaultMode === undefined) { + return "default"; + } + + if (typeof defaultMode !== "string") { + logger.error("Ignoring permissions.defaultMode from settings: expected a string."); + return "default"; + } + + const normalized = defaultMode.trim().toLowerCase(); + if (normalized === "") { + logger.error("Ignoring permissions.defaultMode from settings: expected a non-empty string."); + return "default"; + } + + const mapped = PERMISSION_MODE_ALIASES[normalized]; + if (!mapped) { + logger.error(`Ignoring permissions.defaultMode from settings: unknown value '${defaultMode}'.`); + return "default"; + } + + if (mapped === "bypassPermissions" && !ALLOW_BYPASS) { + logger.error( + "Ignoring permissions.defaultMode from settings: bypassPermissions is not available when running as root.", + ); + return "default"; + } + + return mapped; +} + +/** + * Builds the label for the "Always Allow" permission option so the user can see + * the exact scope they are committing to. Uses the SDK-provided suggestions + * when available (e.g. `Bash(npm test:*)`) and falls back to naming the whole + * tool so "Always Allow" is never a blank check without disclosure. + */ +export function describeAlwaysAllow( + suggestions: PermissionUpdate[] | undefined, + toolName: string, +): string { + if (!suggestions || suggestions.length === 0) { + return `Always Allow all ${toolName}`; + } + + const ruleLabels: string[] = []; + const directories: string[] = []; + + for (const update of suggestions) { + if (update.type === "addRules" && update.behavior === "allow") { + for (const rule of update.rules) { + ruleLabels.push( + rule.ruleContent ? `${rule.toolName}(${rule.ruleContent})` : `all ${rule.toolName}`, + ); + } + } else if (update.type === "addDirectories") { + directories.push(...update.directories); + } + } + + const parts: string[] = []; + if (ruleLabels.length > 0) { + parts.push(ruleLabels.join(", ")); + } + if (directories.length > 0) { + parts.push(`access to ${directories.join(", ")}`); + } + + if (parts.length === 0) { + return `Always Allow all ${toolName}`; + } + + return `Always Allow ${parts.join(" and ")}`; +} + +// Implement the ACP Agent interface +export class ClaudeAcpAgent implements Agent { + sessions: { + [key: string]: Session; + }; + client: AgentSideConnection; + toolUseCache: ToolUseCache; + backgroundTerminals: { [key: string]: BackgroundTerminal } = {}; + clientCapabilities?: ClientCapabilities; + logger: Logger; + gatewayAuthRequest?: GatewayAuthRequest; + + constructor(client: AgentSideConnection, logger?: Logger) { + this.sessions = {}; + this.client = client; + this.toolUseCache = {}; + this.logger = logger ?? console; + } + + async initialize(request: InitializeRequest): Promise { + this.clientCapabilities = request.clientCapabilities; + + // Bypasses standard auth by routing requests through a custom Anthropic-protocol gateway. + // Only offered when the client advertises `auth._meta.gateway` capability. + const supportsGatewayAuth = request.clientCapabilities?.auth?._meta?.gateway === true; + + const gatewayAuthMethod: AuthMethod = { + id: "gateway", + name: "Custom model gateway", + description: "Use a custom gateway to authenticate and access models", + _meta: { + gateway: { + protocol: "anthropic", + }, + }, + }; + + const gatewayBedrockAuthMethod: AuthMethod = { + id: "gateway-bedrock", + name: "Custom model gateway", + description: "Use a custom gateway to authenticate and access models", + _meta: { + gateway: { + protocol: "bedrock", + }, + }, + }; + + const supportsTerminalAuth = request.clientCapabilities?.auth?.terminal === true; + const supportsMetaTerminalAuth = request.clientCapabilities?._meta?.["terminal-auth"] === true; + + // Detect remote environments where the OAuth browser redirect to localhost + // won't work. This matches the SDK's internal isRemote check. In these cases, + // the `auth login` subcommand would fall back to a device-code-like manual + // flow, which doesn't work well over ACP, so we offer the TUI login instead. + const isRemote = !!( + process.env.NO_BROWSER || + process.env.SSH_CONNECTION || + process.env.SSH_CLIENT || + process.env.SSH_TTY || + process.env.CLAUDE_CODE_REMOTE + ); + const terminalAuthMethods: AuthMethod[] = []; + + if (isRemote) { + const remoteLoginMethod: AuthMethod = { + description: "Run `claude /login` in the terminal", + name: "Log in with Claude", + id: "claude-login", + type: "terminal", + args: ["--cli"], + }; + + if (supportsMetaTerminalAuth) { + remoteLoginMethod._meta = { + "terminal-auth": { + command: process.execPath, + args: [...process.argv.slice(1), "--cli"], + label: "Claude Login", + }, + }; + } + + if (!shouldHideClaudeAuth() && (supportsTerminalAuth || supportsMetaTerminalAuth)) { + terminalAuthMethods.push(remoteLoginMethod); + } + } else { + const claudeLoginMethod: AuthMethod = { + description: "Use Claude subscription ", + name: "Claude Subscription", + id: "claude-ai-login", + type: "terminal", + args: ["--cli", "auth", "login", "--claudeai"], + }; + + const consoleLoginMethod: AuthMethod = { + description: "Use Anthropic Console (API usage billing)", + name: "Anthropic Console", + id: "console-login", + type: "terminal", + args: ["--cli", "auth", "login", "--console"], + }; + + if (supportsMetaTerminalAuth) { + const baseArgs = process.argv.slice(1); + claudeLoginMethod._meta = { + "terminal-auth": { + command: process.execPath, + args: [...baseArgs, "--cli", "auth", "login", "--claudeai"], + label: "Claude Login", + }, + }; + consoleLoginMethod._meta = { + "terminal-auth": { + command: process.execPath, + args: [...baseArgs, "--cli", "auth", "login", "--console"], + label: "Anthropic Console Login", + }, + }; + } + + if (!shouldHideClaudeAuth() && (supportsTerminalAuth || supportsMetaTerminalAuth)) { + terminalAuthMethods.push(claudeLoginMethod); + } + if (supportsTerminalAuth || supportsMetaTerminalAuth) { + terminalAuthMethods.push(consoleLoginMethod); + } + } + + return { + protocolVersion: 1, + agentCapabilities: { + _meta: { + claudeCode: { + promptQueueing: true, + }, + }, + promptCapabilities: { + image: true, + embeddedContext: true, + }, + mcpCapabilities: { + http: true, + sse: true, + }, + loadSession: true, + sessionCapabilities: { + additionalDirectories: {}, + close: {}, + delete: {}, + fork: {}, + list: {}, + resume: {}, + }, + }, + agentInfo: { + name: packageJson.name, + title: "Claude Agent", + version: packageJson.version, + }, + authMethods: [ + ...terminalAuthMethods, + ...(supportsGatewayAuth ? [gatewayAuthMethod, gatewayBedrockAuthMethod] : []), + ], + }; + } + + async newSession(params: NewSessionRequest): Promise { + const response = await this.createSession(params, { + // Revisit these meta values once we support resume + resume: (params._meta as NewSessionMeta | undefined)?.claudeCode?.options?.resume, + }); + // Needs to happen after we return the session + setTimeout(() => { + this.sendAvailableCommandsUpdate(response.sessionId); + }, 0); + return response; + } + + async unstable_forkSession(params: ForkSessionRequest): Promise { + const response = await this.createSession( + { + cwd: params.cwd, + mcpServers: params.mcpServers ?? [], + additionalDirectories: params.additionalDirectories, + _meta: params._meta, + }, + { + resume: params.sessionId, + forkSession: true, + }, + ); + // Needs to happen after we return the session + setTimeout(() => { + this.sendAvailableCommandsUpdate(response.sessionId); + }, 0); + return response; + } + + async resumeSession(params: ResumeSessionRequest): Promise { + const result = await this.getOrCreateSession(params); + + // Needs to happen after we return the session + setTimeout(() => { + this.sendAvailableCommandsUpdate(params.sessionId); + }, 0); + return result; + } + + async loadSession(params: LoadSessionRequest): Promise { + const result = await this.getOrCreateSession(params); + + await this.replaySessionHistory(params.sessionId); + + // Send available commands after replay so it doesn't interleave with history + setTimeout(() => { + this.sendAvailableCommandsUpdate(params.sessionId); + }, 0); + + return result; + } + + async listSessions(params: ListSessionsRequest): Promise { + const sdk_sessions = await listSessions({ dir: params.cwd ?? undefined }); + const sessions = []; + + for (const session of sdk_sessions) { + if (!session.cwd) continue; + sessions.push({ + sessionId: session.sessionId, + cwd: session.cwd, + title: sanitizeTitle(session.summary), + updatedAt: new Date(session.lastModified).toISOString(), + }); + } + return { + sessions, + }; + } + + async authenticate(_params: AuthenticateRequest): Promise { + if (_params.methodId === "gateway" || _params.methodId === "gateway-bedrock") { + this.gatewayAuthRequest = _params as GatewayAuthRequest; + return; + } + throw new Error("Method not implemented."); + } + + async prompt(params: PromptRequest): Promise { + const session = this.sessions[params.sessionId]; + if (!session) { + throw new Error("Session not found"); + } + + session.cancelled = false; + session.accumulatedUsage = { + inputTokens: 0, + outputTokens: 0, + cachedReadTokens: 0, + cachedWriteTokens: 0, + }; + + let lastAssistantTotalUsage: number | null = null; + let lastAssistantUsage: UsageSnapshot | null = null; + let lastAssistantModel: string | null = null; + // When the Claude SDK classifies a turn as failed (e.g. rate limit, auth + // problem, billing), it sets a categorical `error` field on the + // `SDKAssistantMessage` that precedes the final `result` message. We + // capture it here so the subsequent `RequestError.internalError` can + // forward it to clients as structured `data`, sparing them from + // pattern-matching on the human-readable message text. + let lastAssistantError: SDKAssistantMessageError | undefined; + + const userMessage = promptToClaude(params); + + const promptUuid = randomUUID(); + userMessage.uuid = promptUuid; + + // These local-only commands return a result without replaying the user + // message. Mark promptReplayed=true so their result isn't consumed as a + // background task result. + const firstText = params.prompt[0]?.type === "text" ? params.prompt[0].text : ""; + const isLocalOnlyCommand = + firstText.startsWith("/") && LOCAL_ONLY_COMMANDS.has(firstText.split(" ", 1)[0]); + + if (session.promptRunning) { + session.input.push(userMessage); + const order = session.nextPendingOrder++; + const cancelled = await new Promise((resolve) => { + session.pendingMessages.set(promptUuid, { resolve, order }); + }); + if (cancelled) { + return { stopReason: "cancelled" }; + } + } else { + session.input.push(userMessage); + } + + session.promptRunning = true; + let handedOff = false; + let errored = false; + let stopReason: StopReason = "end_turn"; + + try { + while (true) { + const { value: message, done } = await session.query.next(); + + if (done || !message) { + if (session.cancelled) { + return { stopReason: "cancelled" }; + } + break; + } + + if ( + session.emitRawSDKMessages && + shouldEmitRawMessage(session.emitRawSDKMessages, message) + ) { + await this.client.extNotification("_claude/sdkMessage", { + sessionId: params.sessionId, + message: message as Record, + }); + } + + switch (message.type) { + case "system": + switch (message.subtype) { + case "init": + break; + case "status": { + if (message.status === "compacting") { + await this.client.sessionUpdate({ + sessionId: message.session_id, + update: { + sessionUpdate: "agent_message_chunk", + content: { type: "text", text: "Compacting..." }, + }, + }); + } + break; + } + case "compact_boundary": { + // Send used:0 immediately so the client doesn't keep showing + // the stale pre-compaction context size until the next turn. + // + // This is a deliberate approximation: we don't know the exact + // post-compaction token count (only the SDK's next API call + // reveals that). But used:0 is directionally correct — context + // just dropped dramatically — and the real value replaces it + // within seconds when the next result message arrives. + // The alternative (no update) leaves the client showing e.g. + // "944k/1m" right after the user sees "Compacting completed", + // which is confusing and wrong. + lastAssistantTotalUsage = 0; + lastAssistantUsage = null; + await this.client.sessionUpdate({ + sessionId: message.session_id, + update: { + sessionUpdate: "usage_update", + used: 0, + size: session.contextWindowSize, + }, + }); + await this.client.sessionUpdate({ + sessionId: message.session_id, + update: { + sessionUpdate: "agent_message_chunk", + content: { type: "text", text: "\n\nCompacting completed." }, + }, + }); + break; + } + case "local_command_output": { + await this.client.sessionUpdate({ + sessionId: message.session_id, + update: { + sessionUpdate: "agent_message_chunk", + content: { type: "text", text: message.content }, + }, + }); + break; + } + case "session_state_changed": { + if (message.state === "idle") { + if (session.cancelled) { + stopReason = "cancelled"; + } + return { stopReason, usage: sessionUsage(session) }; + } + break; + } + case "memory_recall": { + const isSynthesis = message.mode === "synthesize"; + const locations = isSynthesis + ? [] + : message.memories.map((m) => ({ path: m.path })); + const content = isSynthesis + ? message.memories + .filter( + (m): m is (typeof message.memories)[number] & { content: string } => + typeof m.content === "string", + ) + .map((m) => ({ + type: "content" as const, + content: { type: "text" as const, text: m.content }, + })) + : []; + const count = message.memories.length; + const title = isSynthesis + ? "Recalled synthesized memory" + : `Recalled ${count} ${count === 1 ? "memory" : "memories"}`; + await this.client.sessionUpdate({ + sessionId: message.session_id, + update: { + sessionUpdate: "tool_call", + toolCallId: message.uuid, + title, + kind: "read", + status: "completed", + ...(locations.length > 0 && { locations }), + ...(content.length > 0 && { content }), + _meta: { + claudeCode: { + toolName: "memory_recall", + toolResponse: { mode: message.mode }, + }, + } satisfies ToolUpdateMeta, + }, + }); + break; + } + case "hook_started": + case "hook_progress": + case "hook_response": + case "files_persisted": + case "task_started": + case "task_notification": + case "task_progress": + case "task_updated": + case "elicitation_complete": + case "plugin_install": + case "notification": + case "api_retry": + case "mirror_error": + case "permission_denied": + // Todo: process via status api: https://docs.claude.com/en/docs/claude-code/hooks#hook-output + break; + default: + unreachable(message, this.logger); + break; + } + break; + case "result": { + // Accumulate usage from this result + session.accumulatedUsage.inputTokens += message.usage.input_tokens; + session.accumulatedUsage.outputTokens += message.usage.output_tokens; + session.accumulatedUsage.cachedReadTokens += message.usage.cache_read_input_tokens; + session.accumulatedUsage.cachedWriteTokens += message.usage.cache_creation_input_tokens; + + const matchingModelUsage = lastAssistantModel + ? getMatchingModelUsage(message.modelUsage, lastAssistantModel) + : null; + // Only overwrite when we have an authoritative value — a miss + // (e.g. a turn with no top-level assistant message) would + // otherwise discard the window learned on a prior turn and + // leave the next prompt's mid-stream updates reporting 200k. + if (matchingModelUsage) { + session.contextWindowSize = matchingModelUsage.contextWindow; + } + + // Task-notification followups are autonomous work triggered by a + // task-notification system message, not by the user's prompt. + // They should not influence the user-turn lifecycle (stop reason, + // slash-command output forwarding) but their cost is real. + const isTaskNotification = message.origin?.kind === "task-notification"; + + // Send usage_update notification + if (lastAssistantTotalUsage !== null) { + await this.client.sessionUpdate({ + sessionId: params.sessionId, + update: { + sessionUpdate: "usage_update", + used: lastAssistantTotalUsage, + size: session.contextWindowSize, + cost: { + amount: message.total_cost_usd, + currency: "USD", + }, + ...(message.origin && { + _meta: { "_claude/origin": message.origin }, + }), + }, + }); + } + + if (session.cancelled) { + if (!isTaskNotification) { + stopReason = "cancelled"; + } + break; + } + + switch (message.subtype) { + case "success": { + if (message.result.includes("Please run /login")) { + throw RequestError.authRequired(); + } + if (message.stop_reason === "max_tokens") { + if (!isTaskNotification) { + stopReason = "max_tokens"; + } + break; + } + if (message.is_error) { + throw RequestError.internalError( + errorKindData(lastAssistantError), + message.result, + ); + } + // For local-only commands (no model invocation), the result + // text is the command output — forward it to the client. + // Task-notification followups never originate from a user + // slash command, so skip the forwarding for them. + if (isLocalOnlyCommand && !isTaskNotification) { + for (const notification of toAcpNotifications( + message.result, + "assistant", + params.sessionId, + this.toolUseCache, + this.client, + this.logger, + )) { + await this.client.sessionUpdate(notification); + } + } + break; + } + case "error_during_execution": { + if (message.stop_reason === "max_tokens") { + if (!isTaskNotification) { + stopReason = "max_tokens"; + } + break; + } + if (message.is_error) { + throw RequestError.internalError( + errorKindData(lastAssistantError), + message.errors.join(", ") || message.subtype, + ); + } + if (!isTaskNotification) { + stopReason = "end_turn"; + } + break; + } + case "error_max_budget_usd": + case "error_max_turns": + case "error_max_structured_output_retries": + if (message.is_error) { + throw RequestError.internalError( + errorKindData(lastAssistantError), + message.errors.join(", ") || message.subtype, + ); + } + if (!isTaskNotification) { + stopReason = "max_turn_requests"; + } + break; + default: + unreachable(message, this.logger); + break; + } + break; + } + case "stream_event": { + if ( + message.parent_tool_use_id === null && + (message.event.type === "message_start" || message.event.type === "message_delta") + ) { + if (message.event.type === "message_start") { + lastAssistantUsage = snapshotFromUsage(message.event.message.usage); + const model = message.event.message.model; + if (model && model !== "") { + lastAssistantModel = model; + // Only upgrade from the default — once a `result` has given + // us an authoritative window, trust it over the heuristic. + // Model switches invalidate the cached window via + // `syncSessionConfigState`, which resets us back to the + // default so this branch runs again for the new model. + if (session.contextWindowSize === DEFAULT_CONTEXT_WINDOW) { + const inferred = inferContextWindowFromModel(model); + if (inferred !== null) { + session.contextWindowSize = inferred; + } + } + } + } else { + const usage = message.event.usage; + const prev: Readonly = lastAssistantUsage ?? ZERO_USAGE; + // Per Anthropic API, message_delta usage fields are *cumulative*; + // nullable fields (input_tokens and the cache fields) fall back + // to the prior snapshot when the server omits them from this + // delta. Only output_tokens is guaranteed non-null. + lastAssistantUsage = { + input_tokens: usage.input_tokens ?? prev.input_tokens, + output_tokens: usage.output_tokens, + cache_read_input_tokens: + usage.cache_read_input_tokens ?? prev.cache_read_input_tokens, + cache_creation_input_tokens: + usage.cache_creation_input_tokens ?? prev.cache_creation_input_tokens, + }; + } + + const nextUsage = totalTokens(lastAssistantUsage); + if (nextUsage !== lastAssistantTotalUsage) { + lastAssistantTotalUsage = nextUsage; + await this.client.sessionUpdate({ + sessionId: params.sessionId, + update: { + sessionUpdate: "usage_update", + used: nextUsage, + size: session.contextWindowSize, + }, + }); + } + } + for (const notification of streamEventToAcpNotifications( + message, + params.sessionId, + this.toolUseCache, + this.client, + this.logger, + { + clientCapabilities: this.clientCapabilities, + cwd: session.cwd, + taskState: session.taskState, + }, + )) { + await this.client.sessionUpdate(notification); + } + break; + } + case "user": + case "assistant": { + if (session.cancelled) { + break; + } + + // Check for prompt replay + if (message.type === "user" && "uuid" in message && message.uuid) { + if (message.uuid === promptUuid) { + break; + } + + const pending = session.pendingMessages.get(message.uuid as string); + if (pending) { + pending.resolve(false); + session.pendingMessages.delete(message.uuid as string); + handedOff = true; + // the current loop stops with end_turn, + // the loop of the next prompt continues running + return { stopReason: "end_turn", usage: sessionUsage(session) }; + } + if ("isReplay" in message && message.isReplay) { + // not pending or unrelated replay message + break; + } + } + + // Snapshot the latest top-level assistant usage and model so the + // next `result` can emit a usage_update tied to the right context + // window. Subagent messages are excluded to keep the snapshot + // aligned with what the user's current selection is producing. + if (message.type === "assistant" && message.parent_tool_use_id === null) { + lastAssistantUsage = snapshotFromUsage(message.message.usage); + lastAssistantTotalUsage = totalTokens(lastAssistantUsage); + if (message.message.model && message.message.model !== "") { + lastAssistantModel = message.message.model; + } + if (message.error) { + lastAssistantError = message.error; + } + } + + // Strip / markers and render any + // remaining prose. Skill bodies and built-in slash commands (e.g. + // /usage, /status, /model) arrive wrapped in these tags; pure-marker + // payloads (e.g. /compact's malformed output) strip to null and are + // skipped. Mirrors the replay path at replaySessionHistory. + if ( + typeof message.message.content === "string" && + message.message.content.includes("") + ) { + const stripped = stripLocalCommandMetadata(message.message.content); + if (typeof stripped === "string") { + for (const notification of toAcpNotifications( + stripped, + message.message.role, + params.sessionId, + this.toolUseCache, + this.client, + this.logger, + { + clientCapabilities: this.clientCapabilities, + parentToolUseId: message.parent_tool_use_id, + cwd: session.cwd, + taskState: session.taskState, + }, + )) { + await this.client.sessionUpdate(notification); + } + } else { + this.logger.log(message.message.content); + } + break; + } + + if ( + typeof message.message.content === "string" && + message.message.content.includes("") + ) { + this.logger.error(message.message.content); + break; + } + // Skip these user messages for now, since they seem to just be messages we don't want in the feed + if ( + message.type === "user" && + (typeof message.message.content === "string" || + (Array.isArray(message.message.content) && + message.message.content.length === 1 && + message.message.content[0].type === "text")) + ) { + break; + } + + if ( + message.type === "assistant" && + message.message.model === "" && + Array.isArray(message.message.content) && + message.message.content.length === 1 && + message.message.content[0].type === "text" && + message.message.content[0].text.includes("Please run /login") + ) { + throw RequestError.authRequired(); + } + + const content = + message.type === "assistant" + ? // Handled by stream events above + message.message.content.filter( + (item) => !["text", "thinking"].includes(item.type), + ) + : message.message.content; + + for (const notification of toAcpNotifications( + content, + message.message.role, + params.sessionId, + this.toolUseCache, + this.client, + this.logger, + { + clientCapabilities: this.clientCapabilities, + parentToolUseId: message.parent_tool_use_id, + cwd: session.cwd, + taskState: session.taskState, + }, + )) { + await this.client.sessionUpdate(notification); + } + break; + } + case "tool_progress": + case "tool_use_summary": + case "auth_status": + case "prompt_suggestion": + case "rate_limit_event": + break; + default: + unreachable(message); + break; + } + } + throw new Error("Session did not end in result"); + } catch (error) { + errored = true; + // A failed turn typically leaves a trailing `session_state_changed: idle` + // (and possibly more) in the query iterator. If we don't drain it here, + // the next prompt's first `query.next()` consumes that stale idle and + // short-circuits to end_turn with zero usage + // Bounded so a misbehaving SDK can't hang the next prompt indefinitely. + try { + await session.query.interrupt(); + const MAX_DRAIN = 100; + for (let i = 0; i < MAX_DRAIN; i++) { + const { value: m, done } = await session.query.next(); + if (done || !m) break; + if (m.type === "system" && m.subtype === "session_state_changed" && m.state === "idle") { + break; + } + if (i === MAX_DRAIN - 1) { + this.logger.error( + `Session ${params.sessionId}: drained ${MAX_DRAIN} messages after error without observing idle`, + ); + } + } + } catch (drainErr) { + this.logger.error( + `Session ${params.sessionId}: failed to drain query after prompt error:`, + drainErr, + ); + } + + if (error instanceof RequestError || !(error instanceof Error)) { + throw error; + } + const message = error.message; + if ( + message.includes("ProcessTransport") || + message.includes("terminated process") || + message.includes("process exited with") || + message.includes("process terminated by signal") || + message.includes("Failed to write to process stdin") + ) { + this.logger.error(`Session ${params.sessionId}: Claude Agent process died: ${message}`); + session.settingsManager.dispose(); + session.input.end(); + delete this.sessions[params.sessionId]; + throw RequestError.internalError( + undefined, + "The Claude Agent process exited unexpectedly. Please start a new session.", + ); + } + throw error; + } finally { + if (!handedOff) { + session.promptRunning = false; + if (errored) { + // The query stream was just drained — handing pending prompts off + // onto it would let them race with the recovery. Cancel them so + // each waiting prompt() returns stopReason: "cancelled" and the + // client can decide whether to retry. + for (const pending of session.pendingMessages.values()) { + pending.resolve(true); + } + session.pendingMessages.clear(); + } else if (session.pendingMessages.size > 0) { + // This usually should not happen, but in case the loop finishes + // without claude sending all message replays, we resolve the + // next pending prompt call to ensure no prompts get stuck. + const next = [...session.pendingMessages.entries()].sort( + (a, b) => a[1].order - b[1].order, + )[0]; + if (next) { + next[1].resolve(false); + session.pendingMessages.delete(next[0]); + } + } + } + } + } + + async cancel(params: CancelNotification): Promise { + const session = this.sessions[params.sessionId]; + if (!session) { + return; + } + session.cancelled = true; + for (const [, pending] of session.pendingMessages) { + pending.resolve(true); + } + session.pendingMessages.clear(); + await session.query.interrupt(); + } + + /** Cleanly tear down a session: cancel in-flight work, dispose resources, + * and remove it from the session map. */ + private async teardownSession(sessionId: string): Promise { + const session = this.sessions[sessionId]; + if (!session) { + return; + } + await this.cancel({ sessionId }); + session.settingsManager.dispose(); + session.abortController.abort(); + session.query.close(); + delete this.sessions[sessionId]; + } + + /** Tear down all active sessions. Called when the ACP connection closes. */ + async dispose(): Promise { + await Promise.all(Object.keys(this.sessions).map((id) => this.teardownSession(id))); + } + + async closeSession(params: CloseSessionRequest): Promise { + if (!this.sessions[params.sessionId]) { + throw new Error("Session not found"); + } + await this.teardownSession(params.sessionId); + return {}; + } + + async unstable_deleteSession(params: DeleteSessionRequest): Promise { + // Tear down any active in-memory state first so the on-disk file isn't + // recreated by an outstanding query writing to it. + if (this.sessions[params.sessionId]) { + await this.teardownSession(params.sessionId); + } + await deleteSession(params.sessionId); + return {}; + } + + async unstable_setSessionModel( + params: SetSessionModelRequest, + ): Promise { + const session = this.sessions[params.sessionId]; + if (!session) { + throw new Error("Session not found"); + } + // Resolve aliases (e.g. "opus", "opus[1m]") to canonical model IDs so + // downstream lookups in modelInfos succeed and the effort option isn't + // silently dropped. + const resolved = resolveModelPreference(session.modelInfos, params.modelId); + const modelId = resolved?.value ?? params.modelId; + await session.query.setModel(modelId); + await this.updateConfigOption(params.sessionId, "model", modelId); + } + + async setSessionMode(params: SetSessionModeRequest): Promise { + if (!this.sessions[params.sessionId]) { + throw new Error("Session not found"); + } + + await this.applySessionMode(params.sessionId, params.modeId); + await this.updateConfigOption(params.sessionId, "mode", params.modeId); + return {}; + } + + async setSessionConfigOption( + params: SetSessionConfigOptionRequest, + ): Promise { + const session = this.sessions[params.sessionId]; + if (!session) { + throw new Error("Session not found"); + } + if (typeof params.value !== "string") { + throw new Error(`Invalid value for config option ${params.configId}: ${params.value}`); + } + + const option = session.configOptions.find((o) => o.id === params.configId); + if (!option) { + throw new Error(`Unknown config option: ${params.configId}`); + } + + const allValues = + "options" in option && Array.isArray(option.options) + ? option.options.flatMap((o) => ("options" in o ? o.options : [o])) + : []; + let validValue = allValues.find((o) => o.value === params.value); + + // For model options, fall back to resolveModelPreference when the exact + // value doesn't match. This lets callers use human-friendly aliases like + // "opus" or "sonnet" instead of full model IDs like "claude-opus-4-6". + if (!validValue && params.configId === "model") { + const modelInfos: ModelInfo[] = allValues.map((o) => ({ + value: o.value, + displayName: o.name, + description: o.description ?? "", + })); + const resolved = resolveModelPreference(modelInfos, params.value); + if (resolved) { + validValue = allValues.find((o) => o.value === resolved.value); + } + } + + if (!validValue) { + throw new Error(`Invalid value for config option ${params.configId}: ${params.value}`); + } + + // Use the canonical option value so downstream code always receives the + // model ID rather than the caller-supplied alias. + const resolvedValue = validValue.value; + + if (params.configId === "mode") { + await this.applySessionMode(params.sessionId, resolvedValue); + await this.client.sessionUpdate({ + sessionId: params.sessionId, + update: { + sessionUpdate: "current_mode_update", + currentModeId: resolvedValue, + }, + }); + } else if (params.configId === "model") { + await this.sessions[params.sessionId].query.setModel(resolvedValue); + } + // Effort SDK sync is handled inside applyConfigOptionValue so that direct + // effort changes and effort changes induced by a model switch go through + // the same path. + + await this.applyConfigOptionValue(params.sessionId, session, params.configId, resolvedValue); + + return { configOptions: session.configOptions }; + } + + private async applySessionMode(sessionId: string, modeId: string): Promise { + switch (modeId) { + case "auto": + case "default": + case "acceptEdits": + case "bypassPermissions": + case "dontAsk": + case "plan": + break; + default: + throw new Error("Invalid Mode"); + } + + const session = this.sessions[sessionId]; + if (!session) { + throw new Error("Session not found"); + } + if (!session.modes.availableModes.some((mode) => mode.id === modeId)) { + throw new Error(`Mode ${modeId} is not available in this session`); + } + + try { + await session.query.setPermissionMode(modeId); + } catch (error) { + if (error instanceof Error) { + if (!error.message) { + error.message = "Invalid Mode"; + } + throw error; + } else { + // eslint-disable-next-line preserve-caught-error + throw new Error("Invalid Mode"); + } + } + } + + private async replaySessionHistory(sessionId: string): Promise { + const toolUseCache: ToolUseCache = {}; + const messages = await getSessionMessages(sessionId); + + for (const message of messages) { + // @ts-expect-error - untyped in SDK but we handle all of these + let content: unknown = message.message.content; + // @ts-expect-error - untyped in SDK but we handle all of these + if (message.message.role === "user") { + content = stripLocalCommandMetadata(content); + if (content === null) continue; + } + + for (const notification of toAcpNotifications( + // @ts-expect-error - untyped in SDK but we handle all of these + content, + // @ts-expect-error - untyped in SDK but we handle all of these + message.message.role, + sessionId, + toolUseCache, + this.client, + this.logger, + { + registerHooks: false, + clientCapabilities: this.clientCapabilities, + cwd: this.sessions[sessionId]?.cwd, + taskState: this.sessions[sessionId]?.taskState, + }, + )) { + await this.client.sessionUpdate(notification); + } + } + } + + async readTextFile(params: ReadTextFileRequest): Promise { + const response = await this.client.readTextFile(params); + return response; + } + + async writeTextFile(params: WriteTextFileRequest): Promise { + const response = await this.client.writeTextFile(params); + return response; + } + + canUseTool(sessionId: string): CanUseTool { + return async (toolName, toolInput, { signal, suggestions, toolUseID }) => { + const alwaysAllowLabel = describeAlwaysAllow(suggestions, toolName); + const supportsTerminalOutput = this.clientCapabilities?._meta?.["terminal_output"] === true; + const session = this.sessions[sessionId]; + if (!session) { + return { + behavior: "deny", + message: "Session not found", + }; + } + + if (toolName === "ExitPlanMode") { + const optionsAll: PermissionOption[] = [ + { kind: "allow_always", name: 'Yes, and use "auto" mode', optionId: "auto" }, + { + kind: "allow_always", + name: "Yes, and auto-accept edits", + optionId: "acceptEdits", + }, + { kind: "allow_once", name: "Yes, and manually approve edits", optionId: "default" }, + { kind: "reject_once", name: "No, keep planning", optionId: "plan" }, + ]; + if (ALLOW_BYPASS) { + optionsAll.unshift({ + kind: "allow_always", + name: "Yes, and bypass permissions", + optionId: "bypassPermissions", + }); + } + // Filter against the session's currently-advertised modes so we never + // present options the active model can't honor (e.g. `auto` on Haiku). + // `bypassPermissions` is already covered by `availableModes` via + // `buildAvailableModes`/`ALLOW_BYPASS`. The `plan` option is a + // "keep planning" reject path; it's always present in `availableModes`. + const options = optionsAll.filter((o) => + session.modes.availableModes.some((m) => m.id === o.optionId), + ); + + const response = await this.client.requestPermission({ + options, + sessionId, + toolCall: { + toolCallId: toolUseID, + rawInput: toolInput, + ...toolInfoFromToolUse( + { name: toolName, input: toolInput, id: toolUseID }, + supportsTerminalOutput, + session?.cwd, + ), + }, + }); + + if (signal.aborted || response.outcome?.outcome === "cancelled") { + throw new Error("Tool use aborted"); + } + const selectedMode = + response.outcome?.outcome === "selected" ? response.outcome.optionId : undefined; + const selectedModeWasOffered = options.some((option) => option.optionId === selectedMode); + if ( + selectedModeWasOffered && + (selectedMode === "default" || + selectedMode === "acceptEdits" || + selectedMode === "auto" || + selectedMode === "bypassPermissions") + ) { + await this.client.sessionUpdate({ + sessionId, + update: { + sessionUpdate: "current_mode_update", + currentModeId: selectedMode, + }, + }); + await this.updateConfigOption(sessionId, "mode", selectedMode); + + return { + behavior: "allow", + updatedInput: toolInput, + updatedPermissions: suggestions ?? [ + { type: "setMode", mode: selectedMode, destination: "session" }, + ], + }; + } else { + return { + behavior: "deny", + message: "User rejected request to exit plan mode.", + }; + } + } + + if (session.modes.currentModeId === "bypassPermissions") { + return { + behavior: "allow", + updatedInput: toolInput, + updatedPermissions: suggestions ?? [ + { type: "addRules", rules: [{ toolName }], behavior: "allow", destination: "session" }, + ], + }; + } + + const response = await this.client.requestPermission({ + options: [ + { + kind: "allow_always", + name: alwaysAllowLabel, + optionId: "allow_always", + }, + { kind: "allow_once", name: "Allow", optionId: "allow" }, + { kind: "reject_once", name: "Reject", optionId: "reject" }, + ], + sessionId, + toolCall: { + toolCallId: toolUseID, + rawInput: toolInput, + ...toolInfoFromToolUse( + { name: toolName, input: toolInput, id: toolUseID }, + supportsTerminalOutput, + session?.cwd, + ), + }, + }); + if (signal.aborted || response.outcome?.outcome === "cancelled") { + throw new Error("Tool use aborted"); + } + if ( + response.outcome?.outcome === "selected" && + (response.outcome.optionId === "allow" || response.outcome.optionId === "allow_always") + ) { + // If Claude Code has suggestions, it will update their settings already + if (response.outcome.optionId === "allow_always") { + return { + behavior: "allow", + updatedInput: toolInput, + updatedPermissions: suggestions ?? [ + { + type: "addRules", + rules: [{ toolName }], + behavior: "allow", + destination: "session", + }, + ], + }; + } + return { + behavior: "allow", + updatedInput: toolInput, + }; + } else { + return { + behavior: "deny", + message: "User refused permission to run tool", + }; + } + }; + } + + private async sendAvailableCommandsUpdate(sessionId: string): Promise { + const session = this.sessions[sessionId]; + if (!session) return; + const commands = await session.query.supportedCommands(); + await this.client.sessionUpdate({ + sessionId, + update: { + sessionUpdate: "available_commands_update", + availableCommands: getAvailableSlashCommands(commands), + }, + }); + } + + private async updateConfigOption( + sessionId: string, + configId: string, + value: string, + ): Promise { + const session = this.sessions[sessionId]; + if (!session) return; + + await this.applyConfigOptionValue(sessionId, session, configId, value); + + await this.client.sessionUpdate({ + sessionId, + update: { + sessionUpdate: "config_option_update", + configOptions: session.configOptions, + }, + }); + } + + private async applyConfigOptionValue( + sessionId: string, + session: Session, + configId: string, + value: string, + ): Promise { + if (configId === "mode") { + session.modes = { ...session.modes, currentModeId: value }; + session.configOptions = session.configOptions.map((o) => + o.id === configId && typeof o.currentValue === "string" ? { ...o, currentValue: value } : o, + ); + } else if (configId === "model") { + if (session.models.currentModelId !== value) { + // The cached context window was learned for the previous model; reset + // to the new model's heuristic so mid-stream updates between now and + // the next `result` reflect the user's selection instead of the old + // model's window. + session.contextWindowSize = inferContextWindowFromModel(value) ?? DEFAULT_CONTEXT_WINDOW; + } + session.models = { ...session.models, currentModelId: value }; + + // Recompute availableModes for the new model and clamp the current + // mode if the SDK no longer offers it (today: "auto" on Haiku). + // `ModelInfo.supportsAutoMode` is the canonical SDK signal. + const newModelInfo = session.modelInfos.find((m) => m.value === value); + const newAvailableModes = buildAvailableModes(newModelInfo); + // Capture BEFORE mutating session.modes so the log message reflects + // the invalidated mode rather than "default". + const previousModeId = session.modes.currentModeId; + let modeDowngraded = false; + if (!newAvailableModes.some((m) => m.id === previousModeId)) { + session.modes = { + availableModes: newAvailableModes, + currentModeId: "default", + }; + try { + await session.query.setPermissionMode("default"); + } catch (err) { + // Failing the entire model switch over a bookkeeping sync error is + // worse UX than logging and continuing; the user explicitly asked + // to change models. The next setPermissionMode from the user will + // either succeed or surface a fresh error. + this.logger.error( + `Failed to sync permissionMode to "default" after model switch invalidated "${previousModeId}":`, + err, + ); + } + modeDowngraded = true; + } else { + session.modes = { ...session.modes, availableModes: newAvailableModes }; + } + + // Rebuild config options since effort levels depend on the selected model + const effortOpt = session.configOptions.find((o) => o.id === "effort"); + const currentEffort = + typeof effortOpt?.currentValue === "string" ? effortOpt.currentValue : undefined; + session.configOptions = buildConfigOptions( + session.modes, + session.models, + session.modelInfos, + currentEffort, + ); + + // Sync effort with the SDK if it changed after the model switch + const newEffortOpt = session.configOptions.find((o) => o.id === "effort"); + const newEffort = + typeof newEffortOpt?.currentValue === "string" ? newEffortOpt.currentValue : undefined; + if (newEffort !== currentEffort) { + await session.query.applyFlagSettings({ + effortLevel: toSdkEffortLevel(newEffort), + }); + } + + // Emit current_mode_update only after session.modes AND + // session.configOptions have been fully reconciled. This way, a failure + // in the configOptions/effort rebuild above can't leave the client with + // a clamped currentModeId but stale configOptions, and the notification + // still precedes the caller's config_option_update so order-sensitive + // clients update currentModeId before re-rendering the option list. + if (modeDowngraded) { + await this.client.sessionUpdate({ + sessionId, + update: { + sessionUpdate: "current_mode_update", + currentModeId: "default", + }, + }); + } + } else { + session.configOptions = session.configOptions.map((o) => + o.id === configId && typeof o.currentValue === "string" ? { ...o, currentValue: value } : o, + ); + if (configId === "effort") { + await session.query.applyFlagSettings({ + effortLevel: toSdkEffortLevel(value), + }); + } + } + } + + private async getOrCreateSession(params: { + sessionId: string; + cwd: string; + mcpServers?: NewSessionRequest["mcpServers"]; + additionalDirectories?: NewSessionRequest["additionalDirectories"]; + _meta?: NewSessionRequest["_meta"]; + }): Promise { + const existingSession = this.sessions[params.sessionId]; + if (existingSession) { + const fingerprint = computeSessionFingerprint(params); + if (fingerprint === existingSession.sessionFingerprint) { + return { + sessionId: params.sessionId, + modes: existingSession.modes, + models: existingSession.models, + configOptions: existingSession.configOptions, + }; + } + + // Session-defining params changed (e.g. cwd pointed at a git worktree, + // or MCP servers reconfigured). Tear down the existing session and + // recreate it so the underlying Query process picks up the new values. + await this.teardownSession(params.sessionId); + } + + const response = await this.createSession( + { + cwd: params.cwd, + mcpServers: params.mcpServers ?? [], + additionalDirectories: params.additionalDirectories, + _meta: params._meta, + }, + { + resume: params.sessionId, + }, + ); + + return { + sessionId: response.sessionId, + modes: response.modes, + models: response.models, + configOptions: response.configOptions, + }; + } + + private async createSession( + params: NewSessionRequest, + creationOpts: { resume?: string; forkSession?: boolean } = {}, + ): Promise { + // We want to create a new session id unless it is resume, + // but not resume + forkSession. + let sessionId; + if (creationOpts.forkSession) { + sessionId = randomUUID(); + } else if (creationOpts.resume) { + sessionId = creationOpts.resume; + } else { + sessionId = randomUUID(); + } + + const input = new Pushable(); + + const settingsManager = new SettingsManager(params.cwd, { + logger: this.logger, + }); + await settingsManager.initialize(); + + const mcpServers: Record = {}; + if (Array.isArray(params.mcpServers)) { + for (const server of params.mcpServers) { + if ("type" in server && (server.type === "http" || server.type === "sse")) { + // HTTP or SSE type MCP server + mcpServers[server.name] = { + type: server.type, + url: server.url, + headers: server.headers + ? Object.fromEntries(server.headers.map((e) => [e.name, e.value])) + : undefined, + }; + } else if (!("type" in server)) { + // Stdio type MCP server (with or without explicit type field) + mcpServers[server.name] = { + type: "stdio", + command: server.command, + args: server.args, + env: server.env + ? Object.fromEntries(server.env.map((e) => [e.name, e.value])) + : undefined, + }; + } + } + } + + let systemPrompt: Options["systemPrompt"] = { type: "preset", preset: "claude_code" }; + if (params._meta?.systemPrompt) { + const customPrompt = params._meta.systemPrompt; + if (typeof customPrompt === "string") { + systemPrompt = customPrompt; + } else if ( + typeof customPrompt === "object" && + customPrompt !== null && + !Array.isArray(customPrompt) + ) { + // Forward all preset options (append, excludeDynamicSections, and + // anything the SDK adds later) while locking type/preset. + systemPrompt = { + ...(customPrompt as object), + type: "preset", + preset: "claude_code", + } as Options["systemPrompt"]; + } + } + + const permissionMode = resolvePermissionMode( + settingsManager.getSettings().permissions?.defaultMode, + this.logger, + ); + + // Extract options from _meta if provided + const sessionMeta = params._meta as NewSessionMeta | undefined; + const userProvidedOptions = sessionMeta?.claudeCode?.options; + + // Configure thinking tokens from environment variable + const maxThinkingTokens = process.env.MAX_THINKING_TOKENS + ? parseInt(process.env.MAX_THINKING_TOKENS, 10) + : undefined; + + // Parse model configuration from environment (e.g. Bedrock model overrides) + const modelConfig = parseModelConfig(process.env.CLAUDE_MODEL_CONFIG); + + // Disable this for now, not a great way to expose this over ACP at the moment (in progress work so we can revisit) + const disallowedTools = ["AskUserQuestion", "WebFetch", "WebSearch"]; + + // Resolve which built-in tools to expose. + // Explicit tools array from _meta.claudeCode.options takes precedence. + // disableBuiltInTools is a legacy shorthand for tools: [] — kept for + // backward compatibility but callers should prefer the tools array. + const tools: Options["tools"] = + userProvidedOptions?.tools ?? + (params._meta?.disableBuiltInTools === true ? [] : { type: "preset", preset: "claude_code" }); + + const abortController = userProvidedOptions?.abortController || new AbortController(); + + // Per-session task state. Created here (rather than in the session record + // below) so the TaskCreated/TaskCompleted hook callbacks can close over + // the same Map that the streaming message handler will read from. + const taskState: TaskState = new Map(); + + const options: Options = { + systemPrompt, + settingSources: ["user", "project", "local"], + ...(maxThinkingTokens !== undefined && { maxThinkingTokens }), + ...userProvidedOptions, + // CLAUDE_MODEL_CONFIG env var is a fallback for model + // configuration (e.g. Bedrock model ID overrides). When the caller + // provides settings via _meta, we intentionally ignore the env var — + // the caller is assumed to have full control over model configuration. + ...(!userProvidedOptions?.settings && + modelConfig && { + settings: { + ...(modelConfig.modelOverrides && { modelOverrides: modelConfig.modelOverrides }), + ...(modelConfig.availableModels && { availableModels: modelConfig.availableModels }), + }, + }), + env: { + ...process.env, + ...userProvidedOptions?.env, + ...createEnvForGateway(this.gatewayAuthRequest), + // Opt-in to session state events like when the agent is idle + CLAUDE_CODE_EMIT_SESSION_STATE_EVENTS: "1", + }, + // Override certain fields that must be controlled by ACP + cwd: params.cwd, + includePartialMessages: true, + mcpServers: { ...(userProvidedOptions?.mcpServers || {}), ...mcpServers }, + // If we want bypassPermissions to be an option, we have to allow it here. + // But it doesn't work in root mode, so we only activate it if it will work. + allowDangerouslySkipPermissions: ALLOW_BYPASS, + permissionMode, + canUseTool: this.canUseTool(sessionId), + pathToClaudeCodeExecutable: process.env.CLAUDE_CODE_EXECUTABLE ?? (await claudeCliPath()), + extraArgs: { + ...userProvidedOptions?.extraArgs, + "replay-user-messages": "", + }, + disallowedTools: [...(userProvidedOptions?.disallowedTools || []), ...disallowedTools], + tools, + hooks: { + ...userProvidedOptions?.hooks, + PostToolUse: [ + ...(userProvidedOptions?.hooks?.PostToolUse || []), + { + hooks: [ + createPostToolUseHook(this.logger, { + onEnterPlanMode: async () => { + await this.client.sessionUpdate({ + sessionId, + update: { + sessionUpdate: "current_mode_update", + currentModeId: "plan", + }, + }); + await this.updateConfigOption(sessionId, "mode", "plan"); + }, + }), + ], + }, + ], + TaskCreated: [ + ...(userProvidedOptions?.hooks?.TaskCreated || []), + { + hooks: [ + createTaskHook({ + taskState, + onChange: async () => { + await this.client.sessionUpdate({ + sessionId, + update: { + sessionUpdate: "plan", + entries: taskStateToPlanEntries(taskState), + }, + }); + }, + }), + ], + }, + ], + TaskCompleted: [ + ...(userProvidedOptions?.hooks?.TaskCompleted || []), + { + hooks: [ + createTaskHook({ + taskState, + onChange: async () => { + await this.client.sessionUpdate({ + sessionId, + update: { + sessionUpdate: "plan", + entries: taskStateToPlanEntries(taskState), + }, + }); + }, + }), + ], + }, + ], + }, + ...creationOpts, + abortController, + }; + + // Prefer the official ACP `additionalDirectories` field. Fall back to the + // legacy `_meta.additionalRoots` extension for clients that haven't been + // updated yet. Either source is merged with directories supplied via + // `_meta.claudeCode.options.additionalDirectories` (SDK pass-through). + const acpAdditionalDirectories = + params.additionalDirectories ?? sessionMeta?.additionalRoots ?? []; + options.additionalDirectories = [ + ...(userProvidedOptions?.additionalDirectories ?? []), + ...acpAdditionalDirectories, + ]; + + if (creationOpts?.resume === undefined || creationOpts?.forkSession) { + // Set our own session id if not resuming an existing session. + options.sessionId = sessionId; + } + + // Handle abort controller from meta options + if (abortController?.signal.aborted) { + throw new Error("Cancelled"); + } + + const q = query({ + prompt: input, + options, + }); + + let initializationResult; + try { + initializationResult = await q.initializationResult(); + } catch (error) { + if ( + creationOpts.resume && + error instanceof Error && + (error.message === "Query closed before response received" || + error.message.includes("No conversation found with session ID")) + ) { + throw RequestError.resourceNotFound(sessionId); + } + throw error; + } + + if ( + shouldHideClaudeAuth() && + initializationResult.account.subscriptionType && + !this.gatewayAuthRequest + ) { + throw RequestError.authRequired( + undefined, + "This integration does not support using claude.ai subscriptions.", + ); + } + + // Apply user's `availableModels` allowlist from settings.json before any + // downstream model handling. The SDK only enforces this allowlist in its + // own UI, not in `initializationResult.models`, so we filter here to keep + // configOptions, the current-model resolver, and the stored modelInfos + // consistent with what the user configured. + const settingsAvailableModels = settingsManager.getSettings().availableModels; + const allowedModels = Array.isArray(settingsAvailableModels) + ? applyAvailableModelsAllowlist(initializationResult.models, settingsAvailableModels) + : initializationResult.models; + + const models = await getAvailableModels( + q, + allowedModels, + initializationResult.models, + settingsManager, + this.logger, + ); + + // Gate `auto` (and future model-specific modes) on the resolved model's + // `ModelInfo`. See `buildAvailableModes` for the canonical SDK signal. + const currentModelInfo = allowedModels.find((m) => m.value === models.currentModelId); + const availableModes = buildAvailableModes(currentModelInfo); + + // Clamp `permissionMode` if the resolved session does not offer it. The + // common case is `permissions.defaultMode: "auto"` resolving to a model + // that does not support auto mode (e.g. Haiku); without this clamp the + // SDK would later throw `"auto mode unavailable for this model"` from + // `setPermissionMode`. Keep `permissionMode` as the resolved user intent + // (matches what was passed into `options.permissionMode` above) and use + // `effectiveMode` for the post-clamp value the session actually runs in. + let effectiveMode: PermissionMode = permissionMode; + if (!availableModes.some((m) => m.id === effectiveMode)) { + if (effectiveMode === "auto") { + this.logger.error( + `permissions.defaultMode "auto" is not available for model ` + + `"${models.currentModelId}"; falling back to "default".`, + ); + } else { + this.logger.error( + `permissions.defaultMode "${effectiveMode}" is not available in ` + + `this session; falling back to "default".`, + ); + } + effectiveMode = "default"; + // Sync the SDK so it doesn't keep "auto" cached internally. Wrapped in + // try/catch since failing here would abort session creation entirely. + try { + await q.setPermissionMode("default"); + } catch (err) { + this.logger.error("Failed to sync clamped permissionMode to SDK:", err); + } + } + + const modes = { + currentModeId: effectiveMode, + availableModes, + }; + + const configOptions = buildConfigOptions( + modes, + models, + allowedModels, + settingsManager.getSettings().effortLevel, + ); + + // Apply the initial effort level to the SDK so it matches the UI default + const initialEffort = configOptions.find((o) => o.id === "effort"); + if ( + initialEffort && + typeof initialEffort.currentValue === "string" && + initialEffort.currentValue !== "default" + ) { + await q.applyFlagSettings({ + effortLevel: initialEffort.currentValue as Settings["effortLevel"], + }); + } + + this.sessions[sessionId] = { + query: q, + input: input, + cancelled: false, + cwd: params.cwd, + sessionFingerprint: computeSessionFingerprint(params), + settingsManager, + accumulatedUsage: { + inputTokens: 0, + outputTokens: 0, + cachedReadTokens: 0, + cachedWriteTokens: 0, + }, + modes, + models, + modelInfos: allowedModels, + configOptions, + promptRunning: false, + pendingMessages: new Map(), + nextPendingOrder: 0, + abortController, + emitRawSDKMessages: sessionMeta?.claudeCode?.emitRawSDKMessages ?? false, + contextWindowSize: + inferContextWindowFromModel(models.currentModelId) ?? DEFAULT_CONTEXT_WINDOW, + taskState, + }; + + return { + sessionId, + models, + modes, + configOptions, + }; + } +} + +function shouldEmitRawMessage( + config: boolean | SDKMessageFilter[], + message: { type: string; subtype?: string; origin?: SDKMessageOrigin }, +): boolean { + if (config === true) return true; + if (config === false) return false; + return config.some( + (f) => + f.type === message.type && + (f.subtype === undefined || f.subtype === message.subtype) && + (f.origin === undefined || f.origin === message.origin?.kind), + ); +} + +function sessionUsage(session: Session) { + return { + inputTokens: session.accumulatedUsage.inputTokens, + outputTokens: session.accumulatedUsage.outputTokens, + cachedReadTokens: session.accumulatedUsage.cachedReadTokens, + cachedWriteTokens: session.accumulatedUsage.cachedWriteTokens, + totalTokens: + session.accumulatedUsage.inputTokens + + session.accumulatedUsage.outputTokens + + session.accumulatedUsage.cachedReadTokens + + session.accumulatedUsage.cachedWriteTokens, + }; +} + +/** Sum all four fields as a proxy for post-turn context occupancy: the current + * turn's output becomes next turn's input. Per the Anthropic API, input_tokens + * excludes cache tokens — cache_read and cache_creation are reported + * separately — so summing all four is not double-counting. */ +function totalTokens(usage: UsageSnapshot): number { + return ( + usage.input_tokens + + usage.output_tokens + + usage.cache_read_input_tokens + + usage.cache_creation_input_tokens + ); +} + +/** + * Build the `data` payload attached to a `RequestError.internalError` when we + * have a categorical error from the Claude SDK. Returns `undefined` when no + * categorical error is available, matching the previous behavior of passing + * `undefined` to `RequestError.internalError`. + * + * The `errorKind` field is a convention for ACP clients to dispatch on + * without having to pattern-match the human-readable message text. Clients + * that don't understand it fall back to the existing message-based rendering. + */ +function errorKindData( + errorKind: SDKAssistantMessageError | undefined, +): { errorKind: SDKAssistantMessageError } | undefined { + return errorKind ? { errorKind } : undefined; +} + +/** Project a nullable API usage object into our non-null snapshot shape. + * Both SDK message_start and assistant message `usage` have `number | null` + * cache fields; we coerce absent values to 0 so `totalTokens` never hits + * NaN. `input_tokens`/`output_tokens` are typed `number` by the SDK but + * synthetic or third-party-backend stream events have been observed emitting + * them as null/undefined — coerce those too so a malformed upstream event + * can't leak NaN into the wire `used` field. Delta events have different + * semantics (cumulative + prev fallback) and are handled inline. */ +function snapshotFromUsage(usage: { + input_tokens?: number | null; + output_tokens?: number | null; + cache_read_input_tokens?: number | null; + cache_creation_input_tokens?: number | null; +}): UsageSnapshot { + return { + input_tokens: usage.input_tokens ?? 0, + output_tokens: usage.output_tokens ?? 0, + cache_read_input_tokens: usage.cache_read_input_tokens ?? 0, + cache_creation_input_tokens: usage.cache_creation_input_tokens ?? 0, + }; +} + +function createEnvForGateway(request?: GatewayAuthRequest) { + if (!request?._meta) { + return {}; + } + const customHeaders = Object.entries(request._meta.gateway.headers) + .map(([key, value]) => `${key}: ${value}`) + .join("\n"); + + if (request.methodId === "gateway-bedrock") { + return { + CLAUDE_CODE_USE_BEDROCK: "1", + AWS_BEARER_TOKEN_BEDROCK: " ", // Must be non-empty to bypass pass configuration check + ANTHROPIC_BEDROCK_BASE_URL: request._meta.gateway.baseUrl, + ANTHROPIC_CUSTOM_HEADERS: customHeaders, + }; + } + return { + ANTHROPIC_BASE_URL: request._meta.gateway.baseUrl, + ANTHROPIC_CUSTOM_HEADERS: customHeaders, + ANTHROPIC_AUTH_TOKEN: " ", // Must be specified to bypass claude login requirement + }; +} + +/** + * Build the list of permission modes the agent will advertise for the given + * model. `auto` is gated by `ModelInfo.supportsAutoMode === true`, which is + * the SDK's model-level availability signal. `undefined`/`false` both exclude + * `auto`. `bypassPermissions` is still gated by `ALLOW_BYPASS`. + */ +function buildAvailableModes(modelInfo: ModelInfo | undefined): SessionModeState["availableModes"] { + const modes: SessionModeState["availableModes"] = []; + + // Only advertise "auto" when the SDK reports the model supports it. + if (modelInfo?.supportsAutoMode === true) { + modes.push({ + id: "auto", + name: "Auto", + description: "Use a model classifier to approve/deny permission prompts", + }); + } + + modes.push( + { + id: "default", + name: "Default", + description: "Standard behavior, prompts for dangerous operations", + }, + { + id: "acceptEdits", + name: "Accept Edits", + description: "Auto-accept file edit operations", + }, + { + id: "plan", + name: "Plan Mode", + description: "Planning mode, no actual tool execution", + }, + { + id: "dontAsk", + name: "Don't Ask", + description: "Don't prompt for permissions, deny if not pre-approved", + }, + ); + + if (ALLOW_BYPASS) { + modes.push({ + id: "bypassPermissions", + name: "Bypass Permissions", + description: "Bypass all permission checks", + }); + } + + return modes; +} + +// Translate a UI effort value into the flag-layer payload. The SDK +// shallow-merges `applyFlagSettings`, drops `undefined` during JSON transport, +// and only clears a key when an explicit `null` is sent — see +// `applyFlagSettings` in @anthropic-ai/claude-agent-sdk. Mapping both the +// `"default"` sentinel and `undefined` (effort option absent for the model) to +// `null` ensures any previously-applied flag is actually cleared. +function toSdkEffortLevel(value: string | undefined): Settings["effortLevel"] | null { + return value === undefined || value === "default" ? null : (value as Settings["effortLevel"]); +} + +function buildConfigOptions( + modes: SessionModeState, + models: SessionModelState, + modelInfos: ModelInfo[], + currentEffortLevel?: string, +): SessionConfigOption[] { + const options: SessionConfigOption[] = [ + { + id: "mode", + name: "Mode", + description: "Session permission mode", + category: "mode", + type: "select", + currentValue: modes.currentModeId, + options: modes.availableModes.map((m) => ({ + value: m.id, + name: m.name, + description: m.description, + })), + }, + { + id: "model", + name: "Model", + description: "AI model to use", + category: "model", + type: "select", + currentValue: models.currentModelId, + options: models.availableModels.map((m) => ({ + value: m.modelId, + name: m.name, + description: m.description ?? undefined, + })), + }, + ]; + + // Add effort level option based on the currently selected model + const currentModelInfo = modelInfos.find((m) => m.value === models.currentModelId); + const supportedLevels = currentModelInfo?.supportsEffort + ? (currentModelInfo.supportedEffortLevels ?? []) + : []; + + if (supportedLevels.length > 0) { + const effortOptions = [ + { value: "default", name: "Default" }, + ...supportedLevels.map((level) => ({ + value: level, + name: level + .split(/[_-]/) + .map((part) => (part ? part.charAt(0).toUpperCase() + part.slice(1) : part)) + .join(" "), + })), + ]; + + const includes = (l: string) => l === "default" || (supportedLevels as string[]).includes(l); + const validEffort = + currentEffortLevel && includes(currentEffortLevel) ? currentEffortLevel : "default"; + + options.push({ + id: "effort", + name: "Effort", + description: "Available effort levels for this model", + category: "thought_level", + type: "select", + currentValue: validEffort, + options: effortOptions, + }); + } + + return options; +} + +// Claude Code CLI persists display strings like "opus[1m]" in settings, +// but the SDK model list uses IDs like "claude-opus-4-6-1m". +const MODEL_CONTEXT_HINT_PATTERN = /\[(\d+m)\]$/i; + +// Captures a model family version such as `4-6` or `4.7` so we can keep +// `claude-opus-4-6` from being copied onto the SDK's `opus` alias when that +// alias currently resolves to a different family version (e.g. Opus 4.7). +const MODEL_FAMILY_VERSION_PATTERN = /\b(\d+)[-.](\d+)\b/; + +function extractModelFamilyVersion(s: string): string | null { + const match = s.match(MODEL_FAMILY_VERSION_PATTERN); + return match ? `${match[1]}.${match[2]}` : null; +} + +function modelVersionsCompatible(preference: string, candidate: ModelInfo): boolean { + const preferred = extractModelFamilyVersion(preference); + if (!preferred) return true; + const candidateVersion = + extractModelFamilyVersion(candidate.value) ?? + extractModelFamilyVersion(candidate.displayName) ?? + extractModelFamilyVersion(candidate.description); + if (!candidateVersion) return true; + return preferred === candidateVersion; +} + +function tokenizeModelPreference(model: string): { tokens: string[]; contextHint?: string } { + const lower = model.trim().toLowerCase(); + const contextHint = lower.match(MODEL_CONTEXT_HINT_PATTERN)?.[1]?.toLowerCase(); + + const normalized = lower.replace(MODEL_CONTEXT_HINT_PATTERN, " $1 "); + const rawTokens = normalized.split(/[^a-z0-9]+/).filter(Boolean); + const tokens = rawTokens + .map((token) => { + if (token === "opusplan") return "opus"; + if (token === "best" || token === "default") return ""; + return token; + }) + .filter((token) => token && token !== "claude") + .filter((token) => /[a-z]/.test(token) || token.endsWith("m")); + + return { tokens, contextHint }; +} + +function scoreModelMatch(model: ModelInfo, tokens: string[], contextHint?: string): number { + const haystack = `${model.value} ${model.displayName}`.toLowerCase(); + let score = 0; + for (const token of tokens) { + if (haystack.includes(token)) { + score += token === contextHint ? 3 : 1; + } + } + return score; +} + +function resolveModelPreference(models: ModelInfo[], preference: string): ModelInfo | null { + const trimmed = preference.trim(); + if (!trimmed) return null; + + const lower = trimmed.toLowerCase(); + + // Exact match on value or display name + const directMatch = models.find( + (model) => + model.value === trimmed || + model.value.toLowerCase() === lower || + model.displayName.toLowerCase() === lower, + ); + if (directMatch) return directMatch; + + // Substring match + const includesMatch = models.find((model) => { + if (!modelVersionsCompatible(trimmed, model)) return false; + const value = model.value.toLowerCase(); + const display = model.displayName.toLowerCase(); + return value.includes(lower) || display.includes(lower) || lower.includes(value); + }); + if (includesMatch) return includesMatch; + + // Tokenized matching for aliases like "opus[1m]" + const { tokens, contextHint } = tokenizeModelPreference(trimmed); + if (tokens.length === 0) return null; + + let bestMatch: ModelInfo | null = null; + let bestScore = 0; + for (const model of models) { + if (!modelVersionsCompatible(trimmed, model)) continue; + const score = scoreModelMatch(model, tokens, contextHint); + if (0 < score && (!bestMatch || bestScore < score)) { + bestMatch = model; + bestScore = score; + } + } + + return bestMatch; +} + +function resolveSettingsModel( + models: ModelInfo[], + settingsModel: unknown, + logger: Logger, +): ModelInfo | null { + if (settingsModel === undefined) { + return null; + } + if (typeof settingsModel !== "string") { + const typeLabel = settingsModel === null ? "null" : typeof settingsModel; + logger.error(`Ignoring model from settings: expected a string, got ${typeLabel}.`); + return null; + } + return resolveModelPreference(models, settingsModel); +} + +/** + * Restrict the SDK's model list to the user's `availableModels` allowlist + * (already merged-and-deduped across settings sources by `SettingsManager`). + * The user's exact entries become the model IDs surfaced via configOptions + * and passed to `setModel`, which prevents Claude Code from silently + * substituting a date-pinned variant (e.g. `haiku` → + * `claude-haiku-4-5-20251001`) that the user may not have access to. + * + * Display info and capability flags are copied from the closest SDK match so + * the UI still renders sensible names and effort levels. + * + * Semantics from https://code.claude.com/docs/en/model-config#restrict-model-selection: + * - `undefined` is handled by the caller (no allowlist applied). + * - The Default option is unaffected by `availableModels` — it always remains + * available, even when the allowlist is `[]`. + */ +function applyAvailableModelsAllowlist(sdkModels: ModelInfo[], allowlist: string[]): ModelInfo[] { + // Default is always preserved per the docs. Synthesize one if the SDK + // didn't surface it so downstream code (e.g. `getAvailableModels` picking + // `models[0]` as a fallback) still has something to work with. + const defaultModel = sdkModels.find((m) => m.value === "default") ?? { + value: "default", + displayName: "Default", + description: "", + }; + const result: ModelInfo[] = [defaultModel]; + const seen = new Set([defaultModel.value]); + + const sdkModelsWithoutDefault = sdkModels.filter((m) => m.value !== "default"); + + for (const entry of allowlist) { + const trimmed = entry.trim(); + if (!trimmed || seen.has(trimmed)) continue; + + const sdkMatch = resolveModelPreference(sdkModelsWithoutDefault, trimmed); + if (sdkMatch) { + result.push({ ...sdkMatch, value: trimmed }); + } else { + result.push({ value: trimmed, displayName: trimmed, description: "" }); + } + seen.add(trimmed); + } + + return result; +} + +async function getAvailableModels( + query: Query, + models: ModelInfo[], + sdkModels: ModelInfo[], + settingsManager: SettingsManager, + logger: Logger, +): Promise { + const settings = settingsManager.getSettings(); + + let currentModel = models[0]; + let resolvedFromInput: string | undefined; + + // Model priority (highest to lowest): + // 1. ANTHROPIC_MODEL environment variable + // 2. settings.model (user configuration) + // 3. models[0] (default first model) + if (process.env.ANTHROPIC_MODEL) { + const match = resolveModelPreference(models, process.env.ANTHROPIC_MODEL); + if (match) { + currentModel = match; + resolvedFromInput = process.env.ANTHROPIC_MODEL; + } + } else if (typeof settings.model === "string") { + const match = resolveSettingsModel(models, settings.model, logger); + if (match) { + currentModel = match; + resolvedFromInput = settings.model; + } + } + + // Skip the setModel round-trip when we can prove the SDK has already landed + // on the same model. Two cases qualify: + // (a) No override applied — currentModel stayed at models[0]; the SDK is on + // its own default and we have nothing to sync. + // (b) The resolver returned the user's input verbatim AND that value exists + // in the SDK's original model list — meaning no fuzzy match or + // allowlist rewrite was involved, and the SDK (which reads the same + // ANTHROPIC_MODEL / settings.json) will have arrived at the same entry. + // Anything else (fuzzy match, allowlist-synthesized value, alias) gets a + // setModel call so we don't drift from the user's intended pin. + const sdkSawSameValue = sdkModels.some((m) => m.value === currentModel.value); + const skipSetModel = + resolvedFromInput === undefined || + (currentModel.value === resolvedFromInput && sdkSawSameValue); + if (!skipSetModel) { + await query.setModel(currentModel.value); + } + + return { + availableModels: models.map((model) => ({ + modelId: model.value, + name: model.displayName, + description: model.description, + })), + currentModelId: currentModel.value, + }; +} + +function getAvailableSlashCommands(commands: SlashCommand[]): AvailableCommand[] { + const UNSUPPORTED_COMMANDS = [ + "clear", + "cost", + "keybindings-help", + "login", + "logout", + "output-style:new", + "release-notes", + "todos", + ]; + + return commands + .map((command) => { + const input = command.argumentHint + ? { + hint: Array.isArray(command.argumentHint) + ? command.argumentHint.join(" ") + : command.argumentHint, + } + : null; + let name = command.name; + if (command.name.endsWith(" (MCP)")) { + name = `mcp:${name.replace(" (MCP)", "")}`; + } + return { + name, + description: command.description || "", + input, + }; + }) + .filter((command: AvailableCommand) => !UNSUPPORTED_COMMANDS.includes(command.name)); +} + +function formatUriAsLink(uri: string): string { + try { + if (uri.startsWith("file://")) { + const path = uri.slice(7); // Remove "file://" + const name = path.split("/").pop() || path; + return `[@${name}](${uri})`; + } else if (uri.startsWith("zed://")) { + const parts = uri.split("/"); + const name = parts[parts.length - 1] || uri; + return `[@${name}](${uri})`; + } + return uri; + } catch { + return uri; + } +} + +export function promptToClaude(prompt: PromptRequest): SDKUserMessage { + const content: any[] = []; + const context: any[] = []; + + for (const chunk of prompt.prompt) { + switch (chunk.type) { + case "text": { + let text = chunk.text; + // change /mcp:server:command args -> /server:command (MCP) args + const mcpMatch = text.match(/^\/mcp:([^:\s]+):(\S+)(?:\s(.*))?$/); + if (mcpMatch) { + const [, server, command, args] = mcpMatch; + text = `/${server}:${command} (MCP)${args ? ` ${args}` : ""}`; + } + content.push({ type: "text", text }); + break; + } + case "resource_link": { + const formattedUri = formatUriAsLink(chunk.uri); + content.push({ + type: "text", + text: formattedUri, + }); + break; + } + case "resource": { + if ("text" in chunk.resource) { + const formattedUri = formatUriAsLink(chunk.resource.uri); + content.push({ + type: "text", + text: formattedUri, + }); + context.push({ + type: "text", + text: `\n\n${chunk.resource.text}\n`, + }); + } + // Ignore blob resources (unsupported) + break; + } + case "image": + if (chunk.data) { + content.push({ + type: "image", + source: { + type: "base64", + data: chunk.data, + media_type: chunk.mimeType, + }, + }); + } else if (chunk.uri && chunk.uri.startsWith("http")) { + content.push({ + type: "image", + source: { + type: "url", + url: chunk.uri, + }, + }); + } + break; + // Ignore audio and other unsupported types + default: + break; + } + } + + content.push(...context); + + return { + type: "user", + message: { + role: "user", + content: content, + }, + session_id: prompt.sessionId, + parent_tool_use_id: null, + }; +} + +/** + * Convert an SDKAssistantMessage (Claude) to a SessionNotification (ACP). + * Only handles text, image, and thinking chunks for now. + */ +export function toAcpNotifications( + content: string | ContentBlockParam[] | BetaContentBlock[] | BetaRawContentBlockDelta[], + role: "assistant" | "user", + sessionId: string, + toolUseCache: ToolUseCache, + client: AgentSideConnection, + logger: Logger, + options?: { + registerHooks?: boolean; + clientCapabilities?: ClientCapabilities; + parentToolUseId?: string | null; + cwd?: string; + taskState?: TaskState; + }, +): SessionNotification[] { + const taskState = options?.taskState ?? new Map(); + const registerHooks = options?.registerHooks !== false; + const supportsTerminalOutput = options?.clientCapabilities?._meta?.["terminal_output"] === true; + if (typeof content === "string") { + const update: SessionNotification["update"] = { + sessionUpdate: role === "assistant" ? "agent_message_chunk" : "user_message_chunk", + content: { + type: "text", + text: content, + }, + }; + + if (options?.parentToolUseId) { + update._meta = { + ...update._meta, + claudeCode: { + ...(update._meta?.claudeCode || {}), + parentToolUseId: options.parentToolUseId, + }, + }; + } + + return [{ sessionId, update }]; + } + + const output = []; + // Only handle the first chunk for streaming; extend as needed for batching + for (const chunk of content) { + let update: SessionNotification["update"] | null = null; + switch (chunk.type) { + case "text": + case "text_delta": + update = { + sessionUpdate: role === "assistant" ? "agent_message_chunk" : "user_message_chunk", + content: { + type: "text", + text: chunk.text, + }, + }; + break; + case "image": + update = { + sessionUpdate: role === "assistant" ? "agent_message_chunk" : "user_message_chunk", + content: { + type: "image", + data: chunk.source.type === "base64" ? chunk.source.data : "", + mimeType: chunk.source.type === "base64" ? chunk.source.media_type : "", + uri: chunk.source.type === "url" ? chunk.source.url : undefined, + }, + }; + break; + case "thinking": + case "thinking_delta": + update = { + sessionUpdate: "agent_thought_chunk", + content: { + type: "text", + text: chunk.thinking, + }, + }; + break; + case "tool_use": + case "server_tool_use": + case "mcp_tool_use": { + const alreadyCached = chunk.id in toolUseCache; + toolUseCache[chunk.id] = chunk; + if (chunk.name === "TodoWrite") { + // @ts-expect-error - sometimes input is empty object or undefined + if (Array.isArray(chunk.input?.todos)) { + update = { + sessionUpdate: "plan", + entries: planEntries(chunk.input as { todos: ClaudePlanEntry[] }), + }; + } + } else if ( + chunk.name === "TaskCreate" || + chunk.name === "TaskUpdate" || + chunk.name === "TaskList" || + chunk.name === "TaskGet" + ) { + // Task* tool_use is suppressed; the plan update is emitted at + // tool_result time once we have the task ID (for TaskCreate) and + // confirmation that the change took effect. + } else { + // Only register hooks on first encounter to avoid double-firing + if (registerHooks && !alreadyCached) { + registerHookCallback(chunk.id, { + onPostToolUseHook: async (toolUseId, toolInput, toolResponse) => { + const toolUse = toolUseCache[toolUseId]; + if (toolUse) { + // Both `Edit` and `Write` produce a structuredPatch in their + // PostToolUse tool_response. For Edit the diff replaces the + // optimistic content built at tool_use time. For Write the + // optimistic content (built from `input.content` alone with + // `oldText: null`) shows "creation" semantics regardless of + // whether the file existed; the structuredPatch from the + // hook lets us emit the real diff for `type: "update"`. The + // helper returns `{}` if the response shape isn't usable. + const editDiff = + toolUse.name === "Edit" || toolUse.name === "Write" + ? toolUpdateFromDiffToolResponse(toolResponse) + : {}; + const update: SessionNotification["update"] = { + _meta: { + claudeCode: { + toolResponse, + toolName: toolUse.name, + }, + } satisfies ToolUpdateMeta, + toolCallId: toolUseId, + sessionUpdate: "tool_call_update", + ...editDiff, + }; + await client.sessionUpdate({ + sessionId, + update, + }); + } else { + logger.error( + `[claude-agent-acp] Got a tool response for tool use that wasn't tracked: ${toolUseId}`, + ); + } + }, + }); + } + + let rawInput; + try { + rawInput = JSON.parse(JSON.stringify(chunk.input)); + } catch { + // ignore if we can't turn it to JSON + } + + if (alreadyCached) { + // Second encounter (full assistant message after streaming) — + // send as tool_call_update to refine the existing tool_call + // rather than emitting a duplicate tool_call. + update = { + _meta: { + claudeCode: { + toolName: chunk.name, + }, + } satisfies ToolUpdateMeta, + toolCallId: chunk.id, + sessionUpdate: "tool_call_update", + rawInput, + ...toolInfoFromToolUse(chunk, supportsTerminalOutput, options?.cwd), + }; + } else { + // First encounter (streaming content_block_start or replay) — + // send as tool_call with terminal_info for Bash tools. + update = { + _meta: { + claudeCode: { + toolName: chunk.name, + }, + ...(chunk.name === "Bash" && supportsTerminalOutput + ? { terminal_info: { terminal_id: chunk.id } } + : {}), + } satisfies ToolUpdateMeta, + toolCallId: chunk.id, + sessionUpdate: "tool_call", + rawInput, + status: "pending", + ...toolInfoFromToolUse(chunk, supportsTerminalOutput, options?.cwd), + }; + } + } + break; + } + + case "tool_result": + case "tool_search_tool_result": + case "web_fetch_tool_result": + case "web_search_tool_result": + case "code_execution_tool_result": + case "bash_code_execution_tool_result": + case "text_editor_code_execution_tool_result": + case "mcp_tool_result": { + const toolUse = toolUseCache[chunk.tool_use_id]; + if (!toolUse) { + logger.error( + `[claude-agent-acp] Got a tool result for tool use that wasn't tracked: ${chunk.tool_use_id}`, + ); + break; + } + + if ( + toolUse.name === "TaskCreate" || + toolUse.name === "TaskUpdate" || + toolUse.name === "TaskList" || + toolUse.name === "TaskGet" + ) { + // Headless/SDK sessions emit Task* tools instead of TodoWrite. + // TaskCreate / TaskUpdate mutate the accumulated task list; TaskList + // and TaskGet are read-only so we just suppress their tool_call / + // tool_result events. The plan update is emitted as a snapshot of + // the accumulated state, mirroring the legacy TodoWrite behavior. + const isError = "is_error" in chunk && chunk.is_error; + if (!isError) { + if (toolUse.name === "TaskCreate") { + applyTaskCreate( + taskState, + toolUse.input as Parameters[1], + parseTaskCreateOutput(chunk.content), + ); + } else if (toolUse.name === "TaskUpdate") { + applyTaskUpdate(taskState, toolUse.input as Parameters[1]); + } + } + if (!isError && (toolUse.name === "TaskCreate" || toolUse.name === "TaskUpdate")) { + update = { + sessionUpdate: "plan", + entries: taskStateToPlanEntries(taskState), + }; + } + } else if (toolUse.name !== "TodoWrite") { + const { _meta: toolMeta, ...toolUpdate } = toolUpdateFromToolResult( + chunk, + toolUseCache[chunk.tool_use_id], + supportsTerminalOutput, + ); + + // When terminal output is supported, send terminal_output as a + // separate notification to match codex-acp's streaming lifecycle: + // 1. tool_call → _meta.terminal_info (already sent above) + // 2. tool_call_update → _meta.terminal_output (sent here) + // 3. tool_call_update → _meta.terminal_exit (sent below with status) + if (toolMeta?.terminal_output) { + output.push({ + sessionId, + update: { + _meta: { + terminal_output: toolMeta.terminal_output, + ...(options?.parentToolUseId + ? { claudeCode: { parentToolUseId: options.parentToolUseId } } + : {}), + }, + toolCallId: chunk.tool_use_id, + sessionUpdate: "tool_call_update" as const, + }, + }); + } + + update = { + _meta: { + claudeCode: { + toolName: toolUse.name, + }, + ...(toolMeta?.terminal_exit ? { terminal_exit: toolMeta.terminal_exit } : {}), + } satisfies ToolUpdateMeta, + toolCallId: chunk.tool_use_id, + sessionUpdate: "tool_call_update", + status: "is_error" in chunk && chunk.is_error ? "failed" : "completed", + rawOutput: chunk.content, + ...toolUpdate, + }; + } + break; + } + + case "document": + case "search_result": + case "redacted_thinking": + case "input_json_delta": + case "citations_delta": + case "signature_delta": + case "container_upload": + case "compaction": + case "compaction_delta": + case "advisor_tool_result": + break; + + default: + unreachable(chunk, logger); + break; + } + if (update) { + if (options?.parentToolUseId) { + update._meta = { + ...update._meta, + claudeCode: { + ...(update._meta?.claudeCode || {}), + parentToolUseId: options.parentToolUseId, + }, + }; + } + output.push({ sessionId, update }); + } + } + + return output; +} + +export function streamEventToAcpNotifications( + message: SDKPartialAssistantMessage, + sessionId: string, + toolUseCache: ToolUseCache, + client: AgentSideConnection, + logger: Logger, + options?: { + clientCapabilities?: ClientCapabilities; + cwd?: string; + taskState?: TaskState; + }, +): SessionNotification[] { + const event = message.event; + switch (event.type) { + case "content_block_start": + return toAcpNotifications( + [event.content_block], + "assistant", + sessionId, + toolUseCache, + client, + logger, + { + clientCapabilities: options?.clientCapabilities, + parentToolUseId: message.parent_tool_use_id, + cwd: options?.cwd, + taskState: options?.taskState, + }, + ); + case "content_block_delta": + return toAcpNotifications( + [event.delta], + "assistant", + sessionId, + toolUseCache, + client, + logger, + { + clientCapabilities: options?.clientCapabilities, + parentToolUseId: message.parent_tool_use_id, + cwd: options?.cwd, + taskState: options?.taskState, + }, + ); + // No content. `ping` is a Messages-API keep-alive event that the SDK's + // `BetaRawMessageStreamEvent` union doesn't include even though the + // wire format emits it; the `as never` cast lets us no-op it here + // instead of letting it fall through to `unreachable`. + case "ping" as never: + case "message_start": + case "message_delta": + case "message_stop": + case "content_block_stop": + return []; + + default: + unreachable(event, logger); + return []; + } +} + +export function runAcp() { + const input = nodeToWebWritable(process.stdout); + const output = nodeToWebReadable(process.stdin); + + const stream = ndJsonStream(input, output); + let agent!: ClaudeAcpAgent; + const connection = new AgentSideConnection((client) => { + agent = new ClaudeAcpAgent(client); + return agent; + }, stream); + return { connection, agent }; +} + +function commonPrefixLength(a: string, b: string) { + let i = 0; + while (i < a.length && i < b.length && a[i] === b[i]) { + i++; + } + return i; +} + +/** Best-effort first guess of a model's context window from its ID, used only + * until a `result` message arrives with the authoritative `modelUsage` value. + * Anthropic 1M-context variants encode "1m" as a distinct token in the SDK + * model ID (e.g., "claude-opus-4-6-1m"), which `\b1m\b` catches without also + * matching things like "10m" or embedded substrings. */ +function inferContextWindowFromModel(model: string): number | null { + if (/\b1m\b/i.test(model)) return 1_000_000; + return null; +} + +function parseModelConfig( + raw: string | undefined, +): { modelOverrides?: Record; availableModels?: string[] } | undefined { + if (!raw) return undefined; + const parsed = JSON.parse(raw); + if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) { + throw new Error("CLAUDE_MODEL_CONFIG must be a JSON object"); + } + const result: { modelOverrides?: Record; availableModels?: string[] } = {}; + if (parsed.modelOverrides !== undefined) result.modelOverrides = parsed.modelOverrides; + if (parsed.availableModels !== undefined) result.availableModels = parsed.availableModels; + return Object.keys(result).length > 0 ? result : undefined; +} + +function getMatchingModelUsage(modelUsage: Record, currentModel: string) { + let bestKey: string | null = null; + let bestLen = 0; + + for (const key of Object.keys(modelUsage)) { + const len = commonPrefixLength(key, currentModel); + if (len > bestLen) { + bestLen = len; + bestKey = key; + } + } + + if (bestKey) { + return modelUsage[bestKey]; + } +} diff --git a/qiming-claude-code-acp-ts/src/index.ts b/qiming-claude-code-acp-ts/src/index.ts new file mode 100644 index 00000000..b60b9031 --- /dev/null +++ b/qiming-claude-code-acp-ts/src/index.ts @@ -0,0 +1,82 @@ +#!/usr/bin/env node + +import { resolveSettings } from "@anthropic-ai/claude-agent-sdk"; +import { claudeCliPath, runAcp } from "./acp-agent.js"; +import packageJson from "../package.json" with { type: "json" }; + +if (process.argv.includes("-v") || process.argv.includes("--version")) { + console.log(packageJson.version); + process.exit(0); +} + +if (process.argv.includes("--cli")) { + const { spawn } = await import("node:child_process"); + const args = process.argv.slice(2).filter((arg) => arg !== "--cli"); + const child = spawn(await claudeCliPath(), args, { stdio: "inherit" }); + + const signals = + process.platform === "win32" + ? (["SIGINT", "SIGTERM"] as const) + : (["SIGINT", "SIGTERM", "SIGHUP"] as const); + for (const sig of signals) { + process.on(sig, () => { + if (!child.killed) child.kill(sig); + }); + } + + child.on("exit", (code, signal) => { + if (signal && process.platform !== "win32") { + // Remove our listener so re-raising actually terminates instead of + // re-entering the no-op handler, which would let us exit with code 0 + // instead of the signal's conventional 128+N. + process.removeAllListeners(signal); + process.kill(process.pid, signal); + } else { + process.exit(code ?? 1); + } + }); + child.on("error", (err) => { + console.error(err); + process.exit(1); + }); +} else { + // Apply env vars from the managed-policy tier before any SDK call so the + // SDK subprocess inherits them. Going through resolveSettings (vs. a raw + // read of managed-settings.json) also picks up MDM sources on macOS and + // HKLM/HKCU on Windows. + const policy = await resolveSettings({ settingSources: [] }); + for (const [key, value] of Object.entries(policy.effective.env ?? {})) { + process.env[key] = value; + } + + // stdout is used to send messages to the client + // we redirect everything else to stderr to make sure it doesn't interfere with ACP + console.log = console.error; + console.info = console.error; + console.warn = console.error; + console.debug = console.error; + + process.on("unhandledRejection", (reason, promise) => { + console.error("Unhandled Rejection at:", promise, "reason:", reason); + }); + + const { connection, agent } = runAcp(); + + async function shutdown() { + await agent.dispose().catch((err) => { + console.error("Error during cleanup:", err); + }); + process.exit(0); + } + + // Exit cleanly when the ACP connection closes (e.g. stdin EOF, transport + // error). Without this, `process.stdin.resume()` keeps the event loop + // alive indefinitely, causing orphan process accumulation in oneshot mode. + connection.closed.then(shutdown); + + process.on("SIGTERM", shutdown); + process.on("SIGINT", shutdown); + + // Keep process alive while connection is open + process.stdin.resume(); +} diff --git a/qiming-claude-code-acp-ts/src/lib.ts b/qiming-claude-code-acp-ts/src/lib.ts new file mode 100644 index 00000000..e9751743 --- /dev/null +++ b/qiming-claude-code-acp-ts/src/lib.ts @@ -0,0 +1,23 @@ +// Export the main agent class and utilities for library usage +export { + ClaudeAcpAgent, + isLocalCommandMetadata, + stripLocalCommandMetadata, + runAcp, + toAcpNotifications, + streamEventToAcpNotifications, + type ToolUpdateMeta, + type NewSessionMeta, + type SDKMessageFilter, +} from "./acp-agent.js"; +export { nodeToWebReadable, nodeToWebWritable, Pushable, unreachable } from "./utils.js"; +export { + toolInfoFromToolUse, + toDisplayPath, + planEntries, + toolUpdateFromToolResult, +} from "./tools.js"; +export { SettingsManager, type SettingsManagerOptions } from "./settings.js"; + +// Export types +export type { ClaudePlanEntry } from "./tools.js"; diff --git a/qiming-claude-code-acp-ts/src/settings.ts b/qiming-claude-code-acp-ts/src/settings.ts new file mode 100644 index 00000000..8fc0c1a4 --- /dev/null +++ b/qiming-claude-code-acp-ts/src/settings.ts @@ -0,0 +1,212 @@ +import * as fs from "node:fs"; +import * as path from "node:path"; +// resolveSettings and filterEscalatingDefaultMode are marked @alpha in the +// SDK; API may shift in a future release. +import { + filterEscalatingDefaultMode, + resolveSettings, + type Settings, +} from "@anthropic-ai/claude-agent-sdk"; +import { CLAUDE_CONFIG_DIR } from "./acp-agent.js"; + +/** + * Permission rule format examples: + * - "Read" - matches all Read tool calls + * - "Read(./.env)" - matches specific path + * - "Read(./.env.*)" - glob pattern + * - "Read(./secrets/**)" - recursive glob + * - "Bash(npm run lint)" - exact command prefix + * - "Bash(npm run:*)" - command prefix with wildcard + * + * Docs: https://code.claude.com/docs/en/iam#tool-specific-permission-rules + */ + +function getManagedSettingsPath(): string { + switch (process.platform) { + case "darwin": + return "/Library/Application Support/ClaudeCode/managed-settings.json"; + case "win32": + return "C:\\Program Files\\ClaudeCode\\managed-settings.json"; + default: + return "/etc/claude-code/managed-settings.json"; + } +} + +export interface SettingsManagerOptions { + onChange?: () => void; + logger?: { log: (...args: any[]) => void; error: (...args: any[]) => void }; +} + +/** + * Manages Claude Code settings using the SDK's `resolveSettings` merge engine + * so the values we see match what `query()` would observe. + * + * Watches the user/project/local/managed settings files for changes and + * re-resolves through the SDK on update. Escalating `permissions.defaultMode` + * values from repo-committed sources are filtered out via + * `filterEscalatingDefaultMode`, matching the CLI's trust policy. + */ +export class SettingsManager { + private cwd: string; + private effective: Settings = {}; + private watchers: fs.FSWatcher[] = []; + private onChange?: () => void; + private logger: { log: (...args: any[]) => void; error: (...args: any[]) => void }; + private initialized = false; + private disposed = false; + private debounceTimer: ReturnType | null = null; + private initPromise: Promise | null = null; + + constructor(cwd: string, options?: SettingsManagerOptions) { + this.cwd = cwd; + this.onChange = options?.onChange; + this.logger = options?.logger ?? console; + } + + /** + * Initialize the settings manager by loading all settings and setting up file watchers + */ + async initialize(): Promise { + if (this.initialized) { + return; + } + if (this.initPromise) { + return this.initPromise; + } + + this.disposed = false; + this.initPromise = this.loadAllSettings().then(() => { + if (!this.disposed) { + this.setupWatchers(); + this.initialized = true; + } + this.initPromise = null; + }); + return this.initPromise; + } + + /** + * Paths the SDK reads when resolving settings for this cwd. Watching the + * containing directories means we pick up file creation as well as edits. + */ + private getWatchedPaths(): string[] { + return [ + path.join(CLAUDE_CONFIG_DIR, "settings.json"), + path.join(this.cwd, ".claude", "settings.json"), + path.join(this.cwd, ".claude", "settings.local.json"), + getManagedSettingsPath(), + ]; + } + + /** + * Resolves the effective settings via the SDK and applies the CLI's trust + * filter for escalating `permissions.defaultMode` values. + */ + private async loadAllSettings(): Promise { + try { + const resolved = await resolveSettings({ cwd: this.cwd }); + this.effective = filterEscalatingDefaultMode(resolved); + } catch (error) { + this.logger.error("Failed to resolve settings:", error); + this.effective = {}; + } + } + + /** + * Sets up file watchers for all settings files + */ + private setupWatchers(): void { + for (const filePath of this.getWatchedPaths()) { + try { + const dir = path.dirname(filePath); + const filename = path.basename(filePath); + + if (fs.existsSync(dir)) { + const watcher = fs.watch(dir, (eventType, changedFilename) => { + if (changedFilename === filename) { + this.handleSettingsChange(); + } + }); + + watcher.on("error", (error) => { + this.logger.error(`Settings watcher error for ${filePath}:`, error); + }); + + this.watchers.push(watcher); + } + } catch (error) { + this.logger.error(`Failed to set up watcher for ${filePath}:`, error); + } + } + } + + /** + * Handles settings file changes with debouncing to avoid rapid reloads + */ + private handleSettingsChange(): void { + if (this.debounceTimer) { + clearTimeout(this.debounceTimer); + } + + this.debounceTimer = setTimeout(async () => { + this.debounceTimer = null; + if (this.disposed) { + return; + } + try { + await this.loadAllSettings(); + if (!this.disposed) { + this.onChange?.(); + } + } catch (error) { + this.logger.error("Failed to reload settings:", error); + } + }, 100); + } + + /** + * Returns the current merged settings + */ + getSettings(): Settings { + return this.effective; + } + + /** + * Returns the current working directory + */ + getCwd(): string { + return this.cwd; + } + + /** + * Updates the working directory and reloads project-specific settings + */ + async setCwd(cwd: string): Promise { + if (this.cwd === cwd) { + return; + } + + this.dispose(); + this.cwd = cwd; + await this.initialize(); + } + + /** + * Disposes of file watchers and cleans up resources + */ + dispose(): void { + this.disposed = true; + this.initialized = false; + this.initPromise = null; + + if (this.debounceTimer) { + clearTimeout(this.debounceTimer); + this.debounceTimer = null; + } + + for (const watcher of this.watchers) { + watcher.close(); + } + this.watchers = []; + } +} diff --git a/qiming-claude-code-acp-ts/src/tests/.claude/commands/quick-math.md b/qiming-claude-code-acp-ts/src/tests/.claude/commands/quick-math.md new file mode 100644 index 00000000..004d62fd --- /dev/null +++ b/qiming-claude-code-acp-ts/src/tests/.claude/commands/quick-math.md @@ -0,0 +1,5 @@ +--- +description: 10 * 3 = 30 +--- + +What is 10 \* 3. Only respond with the number. diff --git a/qiming-claude-code-acp-ts/src/tests/.claude/commands/say-hello.md b/qiming-claude-code-acp-ts/src/tests/.claude/commands/say-hello.md new file mode 100644 index 00000000..3ec102b7 --- /dev/null +++ b/qiming-claude-code-acp-ts/src/tests/.claude/commands/say-hello.md @@ -0,0 +1,6 @@ +--- +description: Say hello +argument-hint: name +--- + +Respond with "Hello $1" and nothing else. diff --git a/qiming-claude-code-acp-ts/src/tests/acp-agent-settings.test.ts b/qiming-claude-code-acp-ts/src/tests/acp-agent-settings.test.ts new file mode 100644 index 00000000..14c9af04 --- /dev/null +++ b/qiming-claude-code-acp-ts/src/tests/acp-agent-settings.test.ts @@ -0,0 +1,782 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import * as fs from "node:fs"; +import * as path from "node:path"; +import * as os from "node:os"; +import type { AgentSideConnection } from "@agentclientprotocol/sdk"; +import type { ClaudeAcpAgent as ClaudeAcpAgentType } from "../acp-agent.js"; + +const { querySpy } = vi.hoisted(() => ({ + querySpy: vi.fn(), +})); + +vi.mock("@anthropic-ai/claude-agent-sdk", async () => { + const actual = await vi.importActual("@anthropic-ai/claude-agent-sdk"); + return { + ...actual, + query: querySpy, + }; +}); + +describe("ClaudeAcpAgent settings", () => { + let tempDir: string; + let originalClaudeConfigDir: string | undefined; + + function createMockClient(): AgentSideConnection { + return { + sessionUpdate: async () => {}, + requestPermission: async () => ({ outcome: { outcome: "cancelled" } }), + readTextFile: async () => ({ content: "" }), + writeTextFile: async () => ({}), + } as unknown as AgentSideConnection; + } + + function mockQuery() { + let capturedOptions: any; + const setModelSpy = vi.fn(); + querySpy.mockImplementation(({ options }: any) => { + capturedOptions = options; + return { + initializationResult: async () => ({ + models: [ + { + value: "claude-sonnet-4-6", + displayName: "Claude Sonnet 4.5", + description: "Default", + }, + ], + }), + setModel: setModelSpy, + supportedCommands: async () => [], + } as any; + }); + return { getCapturedOptions: () => capturedOptions, setModelSpy }; + } + + beforeEach(async () => { + tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "acp-agent-settings-")); + originalClaudeConfigDir = process.env.CLAUDE_CONFIG_DIR; + process.env.CLAUDE_CONFIG_DIR = tempDir; + querySpy.mockReset(); + vi.resetModules(); + }); + + afterEach(async () => { + if (originalClaudeConfigDir) { + process.env.CLAUDE_CONFIG_DIR = originalClaudeConfigDir; + } else { + delete process.env.CLAUDE_CONFIG_DIR; + } + await fs.promises.rm(tempDir, { recursive: true, force: true }); + }); + + it("uses permissions.defaultMode for new sessions", async () => { + await fs.promises.writeFile( + path.join(tempDir, "settings.json"), + JSON.stringify({ + permissions: { + defaultMode: "dontAsk", + }, + }), + ); + + const projectDir = path.join(tempDir, "project"); + await fs.promises.mkdir(projectDir, { recursive: true }); + + const { getCapturedOptions } = mockQuery(); + + const { ClaudeAcpAgent } = await import("../acp-agent.js"); + const agent: ClaudeAcpAgentType = new ClaudeAcpAgent(createMockClient()); + + const response = await (agent as any).createSession({ + cwd: projectDir, + mcpServers: [], + _meta: { disableBuiltInTools: true }, + }); + + expect(getCapturedOptions().permissionMode).toBe("dontAsk"); + expect(getCapturedOptions().settingSources).toEqual(["user", "project", "local"]); + expect(response.modes.currentModeId).toBe("dontAsk"); + }); + + it("supports acceptEdits mode defaults", async () => { + await fs.promises.writeFile( + path.join(tempDir, "settings.json"), + JSON.stringify({ + permissions: { + defaultMode: "acceptEdits", + }, + }), + ); + + const projectDir = path.join(tempDir, "project"); + await fs.promises.mkdir(projectDir, { recursive: true }); + + const { getCapturedOptions } = mockQuery(); + + const { ClaudeAcpAgent } = await import("../acp-agent.js"); + const agent: ClaudeAcpAgentType = new ClaudeAcpAgent(createMockClient()); + + const response = await (agent as any).createSession({ + cwd: projectDir, + mcpServers: [], + _meta: { disableBuiltInTools: true }, + }); + + expect(getCapturedOptions().permissionMode).toBe("acceptEdits"); + expect(response.modes.currentModeId).toBe("acceptEdits"); + }); + + it("drops escalating defaultMode when it comes from project-tier settings", async () => { + // bypassPermissions in .claude/settings.json (a repo-committed tier) is + // filtered by the SDK's trust policy and clamps to 'default'. + const projectDir = path.join(tempDir, "project"); + await fs.promises.mkdir(path.join(projectDir, ".claude"), { recursive: true }); + await fs.promises.writeFile( + path.join(projectDir, ".claude", "settings.json"), + JSON.stringify({ permissions: { defaultMode: "bypassPermissions" } }), + ); + + const { getCapturedOptions } = mockQuery(); + + const { ClaudeAcpAgent } = await import("../acp-agent.js"); + const agent: ClaudeAcpAgentType = new ClaudeAcpAgent(createMockClient()); + + const response = await (agent as any).createSession({ + cwd: projectDir, + mcpServers: [], + _meta: { disableBuiltInTools: true }, + }); + + expect(getCapturedOptions().permissionMode).toBe("default"); + expect(response.modes.currentModeId).toBe("default"); + }); + + it("defaults to 'default' when no permissions.defaultMode is set", async () => { + const projectDir = path.join(tempDir, "project"); + await fs.promises.mkdir(projectDir, { recursive: true }); + + const { getCapturedOptions } = mockQuery(); + + const { ClaudeAcpAgent } = await import("../acp-agent.js"); + const agent: ClaudeAcpAgentType = new ClaudeAcpAgent(createMockClient()); + + const response = await (agent as any).createSession({ + cwd: projectDir, + mcpServers: [], + _meta: { disableBuiltInTools: true }, + }); + + expect(getCapturedOptions().permissionMode).toBe("default"); + expect(response.modes.currentModeId).toBe("default"); + }); + + it("falls back to 'default' when permissions.defaultMode is invalid", async () => { + await fs.promises.writeFile( + path.join(tempDir, "settings.json"), + JSON.stringify({ + permissions: { + defaultMode: "not-a-real-mode", + }, + }), + ); + + const projectDir = path.join(tempDir, "project"); + await fs.promises.mkdir(projectDir, { recursive: true }); + + const { getCapturedOptions } = mockQuery(); + + const { ClaudeAcpAgent } = await import("../acp-agent.js"); + const agent: ClaudeAcpAgentType = new ClaudeAcpAgent(createMockClient()); + + const response = await (agent as any).createSession({ + cwd: projectDir, + mcpServers: [], + _meta: { disableBuiltInTools: true }, + }); + + // Bad mode is ignored at the usage site; session creation must not throw. + expect(getCapturedOptions().permissionMode).toBe("default"); + expect(response.modes.currentModeId).toBe("default"); + }); + + it("ignores model from settings when it is not a string", async () => { + await fs.promises.writeFile( + path.join(tempDir, "settings.json"), + JSON.stringify({ + model: 123, + }), + ); + + const projectDir = path.join(tempDir, "project"); + await fs.promises.mkdir(projectDir, { recursive: true }); + + const { setModelSpy } = mockQuery(); + + const { ClaudeAcpAgent } = await import("../acp-agent.js"); + const agent: ClaudeAcpAgentType = new ClaudeAcpAgent(createMockClient()); + + const response = await (agent as any).createSession({ + cwd: projectDir, + mcpServers: [], + _meta: { disableBuiltInTools: true }, + }); + + // Bad model is ignored at the usage site; falls back to the first SDK model. + // No setModel call is needed because no override was applied — the SDK is + // already on its own default. + expect(setModelSpy).not.toHaveBeenCalled(); + expect(response.models.currentModelId).toBe("claude-sonnet-4-6"); + }); + + describe("auto mode availability per model", () => { + function mockQueryWithModels(models: any[]): { + getCapturedOptions: () => any; + setModelSpy: ReturnType; + setPermissionModeSpy: ReturnType; + } { + let capturedOptions: any; + const setModelSpy = vi.fn(); + const setPermissionModeSpy = vi.fn(); + querySpy.mockImplementation(({ options }: any) => { + capturedOptions = options; + return { + initializationResult: async () => ({ models }), + setModel: setModelSpy, + setPermissionMode: setPermissionModeSpy, + supportedCommands: async () => [], + } as any; + }); + return { + getCapturedOptions: () => capturedOptions, + setModelSpy, + setPermissionModeSpy, + }; + } + + it("omits `auto` from availableModes when the resolved model lacks supportsAutoMode", async () => { + const projectDir = path.join(tempDir, "project"); + await fs.promises.mkdir(projectDir, { recursive: true }); + + mockQueryWithModels([ + { + value: "claude-haiku-4-5", + displayName: "Claude Haiku", + description: "Fast", + // supportsAutoMode intentionally omitted + }, + ]); + + const { ClaudeAcpAgent } = await import("../acp-agent.js"); + const agent: ClaudeAcpAgentType = new ClaudeAcpAgent(createMockClient()); + + const response = await (agent as any).createSession({ + cwd: projectDir, + mcpServers: [], + _meta: { disableBuiltInTools: true }, + }); + + const modeIds: string[] = response.modes.availableModes.map((m: any) => m.id); + expect(modeIds).not.toContain("auto"); + expect(modeIds).toEqual( + expect.arrayContaining(["default", "acceptEdits", "plan", "dontAsk"]), + ); + }); + + it("includes `auto` when the resolved model has supportsAutoMode: true", async () => { + const projectDir = path.join(tempDir, "project"); + await fs.promises.mkdir(projectDir, { recursive: true }); + + mockQueryWithModels([ + { + value: "claude-opus-4-5", + displayName: "Claude Opus", + description: "Most capable", + supportsAutoMode: true, + }, + ]); + + const { ClaudeAcpAgent } = await import("../acp-agent.js"); + const agent: ClaudeAcpAgentType = new ClaudeAcpAgent(createMockClient()); + + const response = await (agent as any).createSession({ + cwd: projectDir, + mcpServers: [], + _meta: { disableBuiltInTools: true }, + }); + + const modeIds: string[] = response.modes.availableModes.map((m: any) => m.id); + expect(modeIds).toContain("auto"); + }); + + it("clamps permissions.defaultMode='auto' to 'default' on a model that lacks supportsAutoMode", async () => { + await fs.promises.writeFile( + path.join(tempDir, "settings.json"), + JSON.stringify({ permissions: { defaultMode: "auto" } }), + ); + + const projectDir = path.join(tempDir, "project"); + await fs.promises.mkdir(projectDir, { recursive: true }); + + const { getCapturedOptions, setPermissionModeSpy } = mockQueryWithModels([ + { + value: "claude-haiku-4-5", + displayName: "Claude Haiku", + description: "Fast", + // supportsAutoMode intentionally omitted + }, + ]); + + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + + try { + const { ClaudeAcpAgent } = await import("../acp-agent.js"); + const agent: ClaudeAcpAgentType = new ClaudeAcpAgent(createMockClient()); + + const response = await (agent as any).createSession({ + cwd: projectDir, + mcpServers: [], + _meta: { disableBuiltInTools: true }, + }); + + // Options.permissionMode is built before init resolves, so it still + // carries the user-typed value; the SDK was synced via + // setPermissionMode after we discovered the model can't honor it. + expect(getCapturedOptions().permissionMode).toBe("auto"); + expect(setPermissionModeSpy).toHaveBeenCalledWith("default"); + expect(response.modes.currentModeId).toBe("default"); + expect(response.modes.availableModes.map((m: any) => m.id)).not.toContain("auto"); + + // A descriptive warning was logged so operators see the clamp. + const messages = errorSpy.mock.calls.map((c) => c.join(" ")); + expect(messages.some((m) => m.includes("auto") && m.includes("claude-haiku-4-5"))).toBe( + true, + ); + } finally { + errorSpy.mockRestore(); + } + }); + + it("does not clamp permissions.defaultMode='auto' on a model that supports auto", async () => { + await fs.promises.writeFile( + path.join(tempDir, "settings.json"), + JSON.stringify({ permissions: { defaultMode: "auto" } }), + ); + + const projectDir = path.join(tempDir, "project"); + await fs.promises.mkdir(projectDir, { recursive: true }); + + const { getCapturedOptions, setPermissionModeSpy } = mockQueryWithModels([ + { + value: "claude-opus-4-5", + displayName: "Claude Opus", + description: "Most capable", + supportsAutoMode: true, + }, + ]); + + const { ClaudeAcpAgent } = await import("../acp-agent.js"); + const agent: ClaudeAcpAgentType = new ClaudeAcpAgent(createMockClient()); + + const response = await (agent as any).createSession({ + cwd: projectDir, + mcpServers: [], + _meta: { disableBuiltInTools: true }, + }); + + expect(getCapturedOptions().permissionMode).toBe("auto"); + expect(setPermissionModeSpy).not.toHaveBeenCalled(); + expect(response.modes.currentModeId).toBe("auto"); + }); + }); + + describe("availableModels allowlist from settings", () => { + function mockQueryWithModels(models: any[]): { + setModelSpy: ReturnType; + } { + const setModelSpy = vi.fn(); + querySpy.mockImplementation(() => { + return { + initializationResult: async () => ({ models }), + setModel: setModelSpy, + supportedCommands: async () => [], + } as any; + }); + return { setModelSpy }; + } + + it("restricts configOptions to the user's allowlist using their exact IDs", async () => { + // Reproduces the scenario from + // https://github.com/agentclientprotocol/claude-agent-acp/issues/620: + // user lists `claude-haiku-4-5` (no date pin) in availableModels, but + // the SDK still surfaces its `haiku` alias which resolves to a + // date-pinned variant the user doesn't have access to. + await fs.promises.writeFile( + path.join(tempDir, "settings.json"), + JSON.stringify({ + availableModels: [ + "claude-sonnet-4-6[1m]", + "claude-opus-4-6[1m]", + "claude-haiku-4-5", + "claude-opus-4-7[1m]", + ], + }), + ); + + const projectDir = path.join(tempDir, "project"); + await fs.promises.mkdir(projectDir, { recursive: true }); + + mockQueryWithModels([ + { value: "default", displayName: "Default", description: "Default model" }, + { + value: "sonnet[1m]", + displayName: "Sonnet (1M context)", + description: "Sonnet 4.6 long context", + }, + { + value: "opus[1m]", + displayName: "Opus (1M context)", + description: "Opus 1M context", + }, + { value: "haiku", displayName: "Haiku", description: "Fast" }, + ]); + + const { ClaudeAcpAgent } = await import("../acp-agent.js"); + const agent: ClaudeAcpAgentType = new ClaudeAcpAgent(createMockClient()); + + const response = await (agent as any).createSession({ + cwd: projectDir, + mcpServers: [], + _meta: { disableBuiltInTools: true }, + }); + + const modelOption = response.configOptions.find((o: any) => o.id === "model"); + expect(modelOption.options.map((o: any) => o.value)).toEqual([ + "default", + "claude-sonnet-4-6[1m]", + "claude-opus-4-6[1m]", + "claude-haiku-4-5", + "claude-opus-4-7[1m]", + ]); + }); + + it("unions availableModels across user and project settings", async () => { + // https://code.claude.com/docs/en/model-config#merge-behavior + await fs.promises.writeFile( + path.join(tempDir, "settings.json"), + JSON.stringify({ availableModels: ["claude-haiku-4-5"] }), + ); + + const projectDir = path.join(tempDir, "project"); + await fs.promises.mkdir(path.join(projectDir, ".claude"), { recursive: true }); + await fs.promises.writeFile( + path.join(projectDir, ".claude", "settings.json"), + JSON.stringify({ + availableModels: ["claude-haiku-4-5", "claude-opus-4-7[1m]"], + }), + ); + + mockQueryWithModels([ + { value: "default", displayName: "Default", description: "Default model" }, + { value: "haiku", displayName: "Haiku", description: "Fast" }, + ]); + + const { ClaudeAcpAgent } = await import("../acp-agent.js"); + const agent: ClaudeAcpAgentType = new ClaudeAcpAgent(createMockClient()); + + const response = await (agent as any).createSession({ + cwd: projectDir, + mcpServers: [], + _meta: { disableBuiltInTools: true }, + }); + + const modelOption = response.configOptions.find((o: any) => o.id === "model"); + // User and project entries are unioned and deduplicated. + expect(modelOption.options.map((o: any) => o.value)).toEqual([ + "default", + "claude-haiku-4-5", + "claude-opus-4-7[1m]", + ]); + }); + + it("returns only the default entry when availableModels is an empty array", async () => { + await fs.promises.writeFile( + path.join(tempDir, "settings.json"), + JSON.stringify({ availableModels: [] }), + ); + + const projectDir = path.join(tempDir, "project"); + await fs.promises.mkdir(projectDir, { recursive: true }); + + mockQueryWithModels([ + { value: "default", displayName: "Default", description: "Default model" }, + { value: "haiku", displayName: "Haiku", description: "Fast" }, + ]); + + const { ClaudeAcpAgent } = await import("../acp-agent.js"); + const agent: ClaudeAcpAgentType = new ClaudeAcpAgent(createMockClient()); + + const response = await (agent as any).createSession({ + cwd: projectDir, + mcpServers: [], + _meta: { disableBuiltInTools: true }, + }); + + const modelOption = response.configOptions.find((o: any) => o.id === "model"); + expect(modelOption.options.map((o: any) => o.value)).toEqual(["default"]); + }); + + it("does not filter when availableModels is absent from settings", async () => { + const projectDir = path.join(tempDir, "project"); + await fs.promises.mkdir(projectDir, { recursive: true }); + + mockQueryWithModels([ + { value: "default", displayName: "Default", description: "Default model" }, + { value: "haiku", displayName: "Haiku", description: "Fast" }, + ]); + + const { ClaudeAcpAgent } = await import("../acp-agent.js"); + const agent: ClaudeAcpAgentType = new ClaudeAcpAgent(createMockClient()); + + const response = await (agent as any).createSession({ + cwd: projectDir, + mcpServers: [], + _meta: { disableBuiltInTools: true }, + }); + + const modelOption = response.configOptions.find((o: any) => o.id === "model"); + expect(modelOption.options.map((o: any) => o.value)).toEqual(["default", "haiku"]); + }); + + it("passes the user's exact ID to setModel when it matches an SDK alias", async () => { + // Without the allowlist, the SDK would resolve `haiku` to a + // date-pinned variant. Forcing setModel to receive `claude-haiku-4-5` + // is exactly what the issue's workaround + // (`ANTHROPIC_DEFAULT_HAIKU_MODEL`) achieves manually. + await fs.promises.writeFile( + path.join(tempDir, "settings.json"), + JSON.stringify({ + availableModels: ["claude-haiku-4-5"], + model: "claude-haiku-4-5", + }), + ); + + const projectDir = path.join(tempDir, "project"); + await fs.promises.mkdir(projectDir, { recursive: true }); + + const { setModelSpy } = mockQueryWithModels([ + { value: "default", displayName: "Default", description: "Default model" }, + { value: "haiku", displayName: "Haiku", description: "Fast" }, + ]); + + const { ClaudeAcpAgent } = await import("../acp-agent.js"); + const agent: ClaudeAcpAgentType = new ClaudeAcpAgent(createMockClient()); + + const response = await (agent as any).createSession({ + cwd: projectDir, + mcpServers: [], + _meta: { disableBuiltInTools: true }, + }); + + expect(setModelSpy).toHaveBeenCalledWith("claude-haiku-4-5"); + expect(response.models.currentModelId).toBe("claude-haiku-4-5"); + }); + + it("does not inherit display info across mismatched model family versions", async () => { + // https://github.com/agentclientprotocol/claude-agent-acp/issues/639: + // when the SDK's `opus` alias resolves to Opus 4.7, an allowlist entry + // of `claude-opus-4-6` (or `claude-opus-4-6[1m]`) used to substring-match + // `opus` and inherit the "Opus 4.7" display info. With version-aware + // matching, these entries fall back to showing their literal ID rather + // than a misleading newer name. + await fs.promises.writeFile( + path.join(tempDir, "settings.json"), + JSON.stringify({ + availableModels: [ + "claude-opus-4-6", + "claude-opus-4-6[1m]", + "claude-opus-4-7", + "claude-opus-4-7[1m]", + ], + }), + ); + + const projectDir = path.join(tempDir, "project"); + await fs.promises.mkdir(projectDir, { recursive: true }); + + mockQueryWithModels([ + { value: "default", displayName: "Default", description: "Default model" }, + { + value: "opus", + displayName: "Opus 4.7", + description: "Claude Opus 4.7 — complex tasks, higher cost", + }, + { + value: "opus[1m]", + displayName: "Opus 4.7 (1M context)", + description: "Opus 4.7 with 1M context", + }, + ]); + + const { ClaudeAcpAgent } = await import("../acp-agent.js"); + const agent: ClaudeAcpAgentType = new ClaudeAcpAgent(createMockClient()); + + const response = await (agent as any).createSession({ + cwd: projectDir, + mcpServers: [], + _meta: { disableBuiltInTools: true }, + }); + + const modelOption = response.configOptions.find((o: any) => o.id === "model"); + const byValue: Record = {}; + for (const opt of modelOption.options) { + byValue[opt.value] = { name: opt.name, description: opt.description }; + } + + // 4-6 entries must NOT inherit the 4.7 SDK alias display info. + expect(byValue["claude-opus-4-6"].name).toBe("claude-opus-4-6"); + expect(byValue["claude-opus-4-6"].description).toBe(""); + expect(byValue["claude-opus-4-6[1m]"].name).toBe("claude-opus-4-6[1m]"); + expect(byValue["claude-opus-4-6[1m]"].description).toBe(""); + + // 4-7 entries continue to inherit display info from a 4.7 SDK alias. + expect(byValue["claude-opus-4-7"].name).toBe("Opus 4.7"); + expect(byValue["claude-opus-4-7[1m]"].name).toMatch(/Opus 4\.7/); + }); + }); + + it("resolves model aliases like opus[1m] to the correct model", async () => { + await fs.promises.writeFile( + path.join(tempDir, "settings.json"), + JSON.stringify({ + model: "opus[1m]", + }), + ); + + const projectDir = path.join(tempDir, "project"); + await fs.promises.mkdir(projectDir, { recursive: true }); + + const setModelSpy = vi.fn(); + querySpy.mockImplementation(({ options: _options }: any) => { + return { + initializationResult: async () => ({ + models: [ + { + value: "claude-opus-4-6", + displayName: "Claude Opus 4.6", + description: "Base", + }, + { + value: "claude-opus-4-6-1m", + displayName: "Claude Opus 4.6 (1M)", + description: "Long context", + }, + ], + }), + setModel: setModelSpy, + supportedCommands: async () => [], + } as any; + }); + + const { ClaudeAcpAgent } = await import("../acp-agent.js"); + const agent: ClaudeAcpAgentType = new ClaudeAcpAgent(createMockClient()); + + const response = await (agent as any).createSession({ + cwd: projectDir, + mcpServers: [], + _meta: { disableBuiltInTools: true }, + }); + + expect(setModelSpy).toHaveBeenCalledWith("claude-opus-4-6-1m"); + expect(response.models.currentModelId).toBe("claude-opus-4-6-1m"); + }); + + it("skips the initial setModel when the resolved value matches the SDK's model list verbatim", async () => { + // Covers the launcher case from PR #646: the launcher bakes the model into + // ANTHROPIC_MODEL, the SDK already starts on that model, and a second + // setModel call would be a redundant round-trip (and on some launcher + // setups, more fragile than launch-time selection). + const originalEnv = process.env.ANTHROPIC_MODEL; + process.env.ANTHROPIC_MODEL = "claude-opus-4-6"; + + const projectDir = path.join(tempDir, "project"); + await fs.promises.mkdir(projectDir, { recursive: true }); + + const setModelSpy = vi.fn(); + querySpy.mockImplementation(() => { + return { + initializationResult: async () => ({ + models: [ + { value: "default", displayName: "Default", description: "" }, + { value: "claude-sonnet-4-6", displayName: "Claude Sonnet 4.6", description: "" }, + { value: "claude-opus-4-6", displayName: "Claude Opus 4.6", description: "" }, + ], + }), + setModel: setModelSpy, + supportedCommands: async () => [], + } as any; + }); + + try { + const { ClaudeAcpAgent } = await import("../acp-agent.js"); + const agent: ClaudeAcpAgentType = new ClaudeAcpAgent(createMockClient()); + + const response = await (agent as any).createSession({ + cwd: projectDir, + mcpServers: [], + _meta: { disableBuiltInTools: true }, + }); + + expect(setModelSpy).not.toHaveBeenCalled(); + expect(response.models.currentModelId).toBe("claude-opus-4-6"); + } finally { + if (originalEnv === undefined) { + delete process.env.ANTHROPIC_MODEL; + } else { + process.env.ANTHROPIC_MODEL = originalEnv; + } + } + }); + + it("still calls setModel when the allowlist synthesizes a value the SDK has not surfaced", async () => { + // The allowlist may rewrite a model's `value` to the user's literal ID + // (e.g., `claude-haiku-4-5`) even when the SDK only exposed an alias + // (`haiku`). In that case the SDK has not independently arrived at the + // user's preferred ID, so we must sync via setModel. + await fs.promises.writeFile( + path.join(tempDir, "settings.json"), + JSON.stringify({ + availableModels: ["claude-haiku-4-5"], + model: "claude-haiku-4-5", + }), + ); + + const projectDir = path.join(tempDir, "project"); + await fs.promises.mkdir(projectDir, { recursive: true }); + + const setModelSpy = vi.fn(); + querySpy.mockImplementation(() => { + return { + initializationResult: async () => ({ + models: [ + { value: "default", displayName: "Default", description: "" }, + { value: "haiku", displayName: "Haiku", description: "Fast" }, + ], + }), + setModel: setModelSpy, + supportedCommands: async () => [], + } as any; + }); + + const { ClaudeAcpAgent } = await import("../acp-agent.js"); + const agent: ClaudeAcpAgentType = new ClaudeAcpAgent(createMockClient()); + + const response = await (agent as any).createSession({ + cwd: projectDir, + mcpServers: [], + _meta: { disableBuiltInTools: true }, + }); + + expect(setModelSpy).toHaveBeenCalledWith("claude-haiku-4-5"); + expect(response.models.currentModelId).toBe("claude-haiku-4-5"); + }); +}); diff --git a/qiming-claude-code-acp-ts/src/tests/acp-agent.test.ts b/qiming-claude-code-acp-ts/src/tests/acp-agent.test.ts new file mode 100644 index 00000000..85d79ca0 --- /dev/null +++ b/qiming-claude-code-acp-ts/src/tests/acp-agent.test.ts @@ -0,0 +1,3910 @@ +import { describe, it, expect, beforeAll, beforeEach, afterAll, vi } from "vitest"; +import { spawn, spawnSync } from "child_process"; +import { + Agent, + AgentSideConnection, + AvailableCommand, + Client, + ClientSideConnection, + ndJsonStream, + NewSessionResponse, + ReadTextFileRequest, + ReadTextFileResponse, + RequestPermissionRequest, + RequestPermissionResponse, + SessionNotification, + WriteTextFileRequest, + WriteTextFileResponse, +} from "@agentclientprotocol/sdk"; +import { nodeToWebWritable, nodeToWebReadable } from "../utils.js"; +import { + markdownEscape, + toolInfoFromToolUse, + toDisplayPath, + toolUpdateFromToolResult, + toolUpdateFromDiffToolResponse, +} from "../tools.js"; +import { + toAcpNotifications, + promptToClaude, + isLocalCommandMetadata, + stripLocalCommandMetadata, + ClaudeAcpAgent, + claudeCliPath, + describeAlwaysAllow, + streamEventToAcpNotifications, + type SDKMessageFilter, +} from "../acp-agent.js"; +import { Pushable } from "../utils.js"; +import { deleteSession, query, SDKAssistantMessage } from "@anthropic-ai/claude-agent-sdk"; +import { randomUUID } from "crypto"; + +vi.mock("@anthropic-ai/claude-agent-sdk", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + deleteSession: vi.fn(), + }; +}); +import type { + BetaToolResultBlockParam, + BetaToolSearchToolResultBlockParam, + BetaWebSearchToolResultBlockParam, + BetaWebFetchToolResultBlockParam, + BetaCodeExecutionToolResultBlockParam, +} from "@anthropic-ai/sdk/resources/beta.mjs"; + +describe.skipIf(!process.env.RUN_INTEGRATION_TESTS)("ACP subprocess integration", () => { + let child: ReturnType; + + beforeAll(async () => { + const valid = spawnSync("tsc", { stdio: "inherit" }); + if (valid.status) { + throw new Error("failed to compile"); + } + // Start the subprocess + child = spawn("npm", ["run", "--silent", "dev"], { + stdio: ["pipe", "pipe", "inherit"], + env: process.env, + }); + child.on("error", (error) => { + console.error("Error starting subprocess:", error); + }); + child.on("exit", (exit) => { + console.error("Exited with", exit); + }); + }); + + afterAll(() => { + child.kill(); + }); + + class TestClient implements Client { + agent: Agent; + files: Map = new Map(); + receivedText: string = ""; + resolveAvailableCommands: (commands: AvailableCommand[]) => void; + availableCommandsPromise: Promise; + + constructor(agent: Agent) { + this.agent = agent; + this.resolveAvailableCommands = () => {}; + this.availableCommandsPromise = new Promise((resolve) => { + this.resolveAvailableCommands = resolve; + }); + } + + takeReceivedText() { + const text = this.receivedText; + this.receivedText = ""; + return text; + } + + async requestPermission(params: RequestPermissionRequest): Promise { + const optionId = params.options.find((p) => p.kind === "allow_once")!.optionId; + + return { outcome: { outcome: "selected", optionId } }; + } + + async sessionUpdate(params: SessionNotification): Promise { + console.error("RECEIVED", JSON.stringify(params, null, 4)); + + switch (params.update.sessionUpdate) { + case "agent_message_chunk": { + if (params.update.content.type === "text") { + this.receivedText += params.update.content.text; + } + break; + } + case "available_commands_update": + this.resolveAvailableCommands(params.update.availableCommands); + break; + default: + break; + } + } + + async writeTextFile(params: WriteTextFileRequest): Promise { + this.files.set(params.path, params.content); + return {}; + } + + async readTextFile(params: ReadTextFileRequest): Promise { + const content = this.files.get(params.path) ?? ""; + return { + content, + }; + } + } + + async function setupTestSession(cwd: string): Promise<{ + client: TestClient; + connection: ClientSideConnection; + newSessionResponse: NewSessionResponse; + }> { + let client; + const input = nodeToWebWritable(child.stdin!); + const output = nodeToWebReadable(child.stdout!); + const stream = ndJsonStream(input, output); + const connection = new ClientSideConnection((agent) => { + client = new TestClient(agent); + return client; + }, stream); + + await connection.initialize({ + protocolVersion: 1, + clientCapabilities: { + fs: { + readTextFile: true, + writeTextFile: true, + }, + }, + }); + + const newSessionResponse = await connection.newSession({ + cwd, + mcpServers: [], + }); + + return { client: client!, connection, newSessionResponse }; + } + + it("should connect to the ACP subprocess", async () => { + const { client, connection, newSessionResponse } = await setupTestSession("./"); + + await connection.prompt({ + prompt: [ + { + type: "text", + text: "Hello", + }, + ], + sessionId: newSessionResponse.sessionId, + }); + + expect(client.takeReceivedText()).not.toEqual(""); + }, 30000); + + it("should include available commands", async () => { + const { client, connection, newSessionResponse } = await setupTestSession(__dirname); + + const commands = await client.availableCommandsPromise; + + expect(commands).toContainEqual({ + name: "quick-math", + description: "10 * 3 = 30 (project)", + input: null, + }); + expect(commands).toContainEqual({ + name: "say-hello", + description: "Say hello (project)", + input: { hint: "name" }, + }); + + await connection.prompt({ + prompt: [ + { + type: "text", + text: "/quick-math", + }, + ], + sessionId: newSessionResponse.sessionId, + }); + + expect(client.takeReceivedText()).toContain("30"); + + await connection.prompt({ + prompt: [ + { + type: "text", + text: "/say-hello GPT-5", + }, + ], + sessionId: newSessionResponse.sessionId, + }); + + expect(client.takeReceivedText()).toContain("Hello GPT-5"); + }, 30000); + + it("/compact works", async () => { + const { client, connection, newSessionResponse } = await setupTestSession(__dirname); + + const commands = await client.availableCommandsPromise; + + expect(commands).toContainEqual({ + description: "Free up context by summarizing the conversation so far", + input: { + hint: "", + }, + name: "compact", + }); + + // Send something + await connection.prompt({ + prompt: [{ type: "text", text: "Hi" }], + sessionId: newSessionResponse.sessionId, + }); + // Clear response + client.takeReceivedText(); + + await connection.prompt({ + prompt: [ + { + type: "text", + text: "/compact", + }, + ], + sessionId: newSessionResponse.sessionId, + }); + + expect(client.takeReceivedText()).toContain("Compacting...\n\nCompacting completed."); + }, 30000); +}); + +describe("tool conversions", () => { + it("should handle Bash nicely", () => { + const tool_use = { + type: "tool_use", + id: "toolu_01VtsS2mxUFwpBJZYd7BmbC9", + name: "Bash", + input: { + command: "rm README.md.rm", + description: "Delete README.md.rm file", + }, + }; + + expect(toolInfoFromToolUse(tool_use)).toStrictEqual({ + kind: "execute", + title: "rm README.md.rm", + content: [ + { + content: { + text: "Delete README.md.rm file", + type: "text", + }, + type: "content", + }, + ], + }); + }); + + it("should handle Glob nicely", () => { + const tool_use = { + type: "tool_use", + id: "toolu_01VtsS2mxUFwpBJZYd7BmbC9", + name: "Glob", + input: { + pattern: "*/**.ts", + }, + }; + + expect(toolInfoFromToolUse(tool_use)).toStrictEqual({ + kind: "search", + title: "Find `*/**.ts`", + content: [], + locations: [], + }); + }); + + it("should handle Task tool calls", () => { + const tool_use = { + type: "tool_use", + id: "toolu_01ANYHYDsXcDPKgxhg7us9bj", + name: "Task", + input: { + description: "Handle user's work request", + prompt: + 'The user has asked me to "Create a Task to do the work!" but hasn\'t specified what specific work they want done. I need to:\n\n1. First understand what work needs to be done by examining the current state of the repository\n2. Look at the git status to see what files have been modified\n3. Check if there are any obvious tasks that need completion based on the current state\n4. If the work isn\'t clear from the context, ask the user to specify what work they want accomplished\n\nThe git status shows: "M src/tests/acp-agent.test.ts" - there\'s a modified test file that might need attention.\n\nPlease examine the repository state and determine what work needs to be done, then either complete it or ask the user for clarification on the specific task they want accomplished.', + subagent_type: "general-purpose", + }, + }; + + expect(toolInfoFromToolUse(tool_use)).toStrictEqual({ + kind: "think", + title: "Handle user's work request", + content: [ + { + content: { + text: 'The user has asked me to "Create a Task to do the work!" but hasn\'t specified what specific work they want done. I need to:\n\n1. First understand what work needs to be done by examining the current state of the repository\n2. Look at the git status to see what files have been modified\n3. Check if there are any obvious tasks that need completion based on the current state\n4. If the work isn\'t clear from the context, ask the user to specify what work they want accomplished\n\nThe git status shows: "M src/tests/acp-agent.test.ts" - there\'s a modified test file that might need attention.\n\nPlease examine the repository state and determine what work needs to be done, then either complete it or ask the user for clarification on the specific task they want accomplished.', + type: "text", + }, + type: "content", + }, + ], + }); + }); + + it("should handle Grep tool calls", () => { + const tool_use = { + type: "tool_use", + id: "toolu_016j8oGSD3eAZ9KT62Y7Jsjb", + name: "Grep", + input: { + pattern: ".*", + }, + }; + + expect(toolInfoFromToolUse(tool_use)).toStrictEqual({ + kind: "search", + title: 'grep ".*"', + content: [], + }); + }); + + it("should handle Write tool calls", () => { + const tool_use = { + type: "tool_use", + id: "toolu_01ABC123XYZ789", + name: "Write", + input: { + file_path: "/Users/test/project/example.txt", + content: "Hello, World!\nThis is test content.", + }, + }; + + expect(toolInfoFromToolUse(tool_use)).toStrictEqual({ + kind: "edit", + title: "Write /Users/test/project/example.txt", + content: [ + { + type: "diff", + path: "/Users/test/project/example.txt", + oldText: null, + newText: "Hello, World!\nThis is test content.", + }, + ], + locations: [{ path: "/Users/test/project/example.txt" }], + }); + }); + + it("should handle Write tool calls", () => { + const tool_use = { + type: "tool_use", + id: "toolu_01GHI789JKL456", + name: "Write", + input: { + file_path: "/Users/test/project/config.json", + content: '{"version": "1.0.0"}', + }, + }; + + expect(toolInfoFromToolUse(tool_use)).toStrictEqual({ + kind: "edit", + title: "Write /Users/test/project/config.json", + content: [ + { + type: "diff", + path: "/Users/test/project/config.json", + oldText: null, + newText: '{"version": "1.0.0"}', + }, + ], + locations: [{ path: "/Users/test/project/config.json" }], + }); + }); + + it("should handle Edit tool calls", () => { + const tool_use = { + type: "tool_use", + id: "toolu_01EDIT123", + name: "Edit", + input: { + file_path: "/Users/test/project/test.txt", + old_string: "old text", + new_string: "new text", + }, + }; + + expect(toolInfoFromToolUse(tool_use)).toStrictEqual({ + kind: "edit", + title: "Edit /Users/test/project/test.txt", + content: [ + { + type: "diff", + path: "/Users/test/project/test.txt", + oldText: "old text", + newText: "new text", + }, + ], + locations: [{ path: "/Users/test/project/test.txt" }], + }); + }); + + it("should handle Edit tool calls with replace_all", () => { + const tool_use = { + type: "tool_use", + id: "toolu_01EDIT456", + name: "Edit", + input: { + replace_all: false, + file_path: "/Users/benbrandt/github/codex-acp/src/thread.rs", + old_string: + "struct PromptState {\n active_command: Option,\n active_web_search: Option,\n}", + new_string: + "struct PromptState {\n active_commands: HashMap,\n active_web_search: Option,\n}", + }, + }; + + expect(toolInfoFromToolUse(tool_use)).toStrictEqual({ + kind: "edit", + title: "Edit /Users/benbrandt/github/codex-acp/src/thread.rs", + content: [ + { + type: "diff", + path: "/Users/benbrandt/github/codex-acp/src/thread.rs", + oldText: + "struct PromptState {\n active_command: Option,\n active_web_search: Option,\n}", + newText: + "struct PromptState {\n active_commands: HashMap,\n active_web_search: Option,\n}", + }, + ], + locations: [{ path: "/Users/benbrandt/github/codex-acp/src/thread.rs" }], + }); + }); + + it("should handle Edit tool calls without file_path", () => { + const tool_use = { + type: "tool_use", + id: "toolu_01EDIT789", + name: "Edit", + input: {}, + }; + + expect(toolInfoFromToolUse(tool_use)).toStrictEqual({ + kind: "edit", + title: "Edit", + content: [], + locations: [], + }); + }); + + it("should handle Read tool calls", () => { + const tool_use = { + type: "tool_use", + id: "toolu_01MNO456PQR789", + name: "Read", + input: { + file_path: "/Users/test/project/readme.md", + }, + }; + + expect(toolInfoFromToolUse(tool_use)).toStrictEqual({ + kind: "read", + title: "Read /Users/test/project/readme.md", + content: [], + locations: [{ path: "/Users/test/project/readme.md", line: 1 }], + }); + }); + + it("should handle Read tool calls", () => { + const tool_use = { + type: "tool_use", + id: "toolu_01YZA789BCD123", + name: "Read", + input: { + file_path: "/Users/test/project/data.json", + }, + }; + + expect(toolInfoFromToolUse(tool_use)).toStrictEqual({ + kind: "read", + title: "Read /Users/test/project/data.json", + content: [], + locations: [{ path: "/Users/test/project/data.json", line: 1 }], + }); + }); + + it("should handle Read with limit", () => { + const tool_use = { + type: "tool_use", + id: "toolu_01EFG456HIJ789", + name: "Read", + input: { + file_path: "/Users/test/project/large.txt", + limit: 100, + }, + }; + + expect(toolInfoFromToolUse(tool_use)).toStrictEqual({ + kind: "read", + title: "Read /Users/test/project/large.txt (1 - 100)", + content: [], + locations: [{ path: "/Users/test/project/large.txt", line: 1 }], + }); + }); + + it("should handle Read with offset and limit", () => { + const tool_use = { + type: "tool_use", + id: "toolu_01KLM789NOP456", + name: "Read", + input: { + file_path: "/Users/test/project/large.txt", + offset: 50, + limit: 100, + }, + }; + + expect(toolInfoFromToolUse(tool_use)).toStrictEqual({ + kind: "read", + title: "Read /Users/test/project/large.txt (50 - 149)", + content: [], + locations: [{ path: "/Users/test/project/large.txt", line: 50 }], + }); + }); + + it("should handle Read with only offset", () => { + const tool_use = { + type: "tool_use", + id: "toolu_01QRS123TUV789", + name: "Read", + input: { + file_path: "/Users/test/project/large.txt", + offset: 200, + }, + }; + + expect(toolInfoFromToolUse(tool_use)).toStrictEqual({ + kind: "read", + title: "Read /Users/test/project/large.txt (from line 200)", + content: [], + locations: [{ path: "/Users/test/project/large.txt", line: 200 }], + }); + }); + + it("should use relative path in title when cwd is provided", () => { + const tool_use = { + type: "tool_use", + id: "toolu_01READ_CWD", + name: "Read", + input: { file_path: "/Users/test/project/src/main.ts" }, + }; + + const result = toolInfoFromToolUse(tool_use, false, "/Users/test/project"); + expect(result.title).toBe("Read src/main.ts"); + // locations.path stays absolute for navigation + expect(result.locations).toStrictEqual([{ path: "/Users/test/project/src/main.ts", line: 1 }]); + }); + + it("should handle plan entries", () => { + const received: SDKAssistantMessage = { + type: "assistant", + message: { + id: "msg_017eNosJgww7F5qD4a8BcAcx", + type: "message", + role: "assistant", + container: null, + model: "claude-sonnet-4-20250514", + content: [ + { + type: "tool_use", + id: "toolu_01HaXZ4LfdchSeSR8ygt4zyq", + name: "TodoWrite", + input: { + todos: [ + { + content: "Analyze existing test coverage and identify gaps", + status: "in_progress", + activeForm: "Analyzing existing test coverage", + }, + { + content: "Add comprehensive edge case tests", + status: "pending", + activeForm: "Adding comprehensive edge case tests", + }, + { + content: "Add performance and timing tests", + status: "pending", + activeForm: "Adding performance and timing tests", + }, + { + content: "Add error handling and panic behavior tests", + status: "pending", + activeForm: "Adding error handling tests", + }, + { + content: "Add concurrent access and race condition tests", + status: "pending", + activeForm: "Adding concurrent access tests", + }, + { + content: "Add tests for Each function with various data types", + status: "pending", + activeForm: "Adding Each function tests", + }, + { + content: "Add benchmark tests for performance measurement", + status: "pending", + activeForm: "Adding benchmark tests", + }, + { + content: "Improve test organization and helper functions", + status: "pending", + activeForm: "Improving test organization", + }, + ], + }, + }, + ], + stop_reason: null, + stop_sequence: null, + stop_details: null, + diagnostics: null, + usage: { + input_tokens: 6, + cache_creation_input_tokens: 326, + cache_read_input_tokens: 17265, + cache_creation: { + ephemeral_5m_input_tokens: 326, + ephemeral_1h_input_tokens: 0, + }, + output_tokens: 1, + service_tier: "standard", + server_tool_use: null, + inference_geo: null, + iterations: null, + speed: null, + }, + context_management: null, + }, + parent_tool_use_id: null, + session_id: "d056596f-e328-41e9-badd-b07122ae5227", + uuid: "b7c3330c-de8f-4bba-ac53-68c7f76ffeb5", + }; + expect( + toAcpNotifications( + received.message.content, + received.message.role, + "test", + {}, + {} as AgentSideConnection, + console, + ), + ).toStrictEqual([ + { + sessionId: "test", + update: { + sessionUpdate: "plan", + entries: [ + { + content: "Analyze existing test coverage and identify gaps", + priority: "medium", + status: "in_progress", + }, + { + content: "Add comprehensive edge case tests", + priority: "medium", + status: "pending", + }, + { + content: "Add performance and timing tests", + priority: "medium", + status: "pending", + }, + { + content: "Add error handling and panic behavior tests", + priority: "medium", + status: "pending", + }, + { + content: "Add concurrent access and race condition tests", + priority: "medium", + status: "pending", + }, + { + content: "Add tests for Each function with various data types", + priority: "medium", + status: "pending", + }, + { + content: "Add benchmark tests for performance measurement", + priority: "medium", + status: "pending", + }, + { + content: "Improve test organization and helper functions", + priority: "medium", + status: "pending", + }, + ], + }, + }, + ]); + }); + + it("should return empty update for successful edit result", () => { + const toolUse = { + type: "tool_use", + id: "toolu_01MNO345", + name: "Edit", + input: { + file_path: "/Users/test/project/test.txt", + old_string: "old", + new_string: "new", + }, + }; + + const toolResult = { + content: [ + { + type: "text" as const, + text: "not valid json", + }, + ], + tool_use_id: "test", + is_error: false, + type: "tool_result" as const, + }; + + const update = toolUpdateFromToolResult(toolResult, toolUse); + + // Should return empty object when parsing fails + expect(update).toEqual({}); + }); + + it("should return content update for edit failure", () => { + const toolUse = { + type: "tool_use", + id: "toolu_01MNO345", + name: "Edit", + input: { + file_path: "/Users/test/project/test.txt", + old_string: "old", + new_string: "new", + }, + }; + + const toolResult = { + content: [ + { + type: "text" as const, + text: "Failed to find `old_string`", + }, + ], + tool_use_id: "test", + is_error: true, + type: "tool_result" as const, + }; + + const update = toolUpdateFromToolResult(toolResult, toolUse); + + // Should return empty object when parsing fails + expect(update).toEqual({ + content: [ + { + content: { type: "text", text: "```\nFailed to find `old_string`\n```" }, + type: "content", + }, + ], + }); + }); + + it("should transform tool_reference content to valid ACP content", () => { + const toolUse = { + type: "tool_use", + id: "toolu_01MNO345", + name: "ToolSearch", + input: { query: "test" }, + }; + + const toolResult: BetaToolResultBlockParam = { + content: [ + { + type: "tool_reference", + tool_name: "some_discovered_tool", + }, + ], + tool_use_id: "toolu_01MNO345", + is_error: false, + type: "tool_result", + }; + + const update = toolUpdateFromToolResult(toolResult, toolUse); + + expect(update).toEqual({ + content: [ + { + type: "content", + content: { type: "text", text: "Tool: some_discovered_tool" }, + }, + ], + }); + }); + + it("should transform web_search_result content to valid ACP content", () => { + const toolUse = { + type: "tool_use", + id: "toolu_01MNO345", + name: "WebSearch", + input: { query: "test" }, + }; + + const toolResult: BetaWebSearchToolResultBlockParam = { + content: [ + { + type: "web_search_result", + title: "Test Result", + url: "https://example.com", + encrypted_content: "...", + page_age: null, + }, + ], + tool_use_id: "toolu_01MNO345", + type: "web_search_tool_result", + }; + + const update = toolUpdateFromToolResult(toolResult, toolUse); + + expect(update).toEqual({ + content: [ + { + type: "content", + content: { type: "text", text: "Test Result (https://example.com)" }, + }, + ], + }); + }); + + it("should transform web_search_tool_result_error to valid ACP content", () => { + const toolUse = { + type: "tool_use", + id: "toolu_01MNO345", + name: "WebSearch", + input: { query: "test" }, + }; + + const toolResult: BetaWebSearchToolResultBlockParam = { + content: { + type: "web_search_tool_result_error", + error_code: "unavailable", + }, + tool_use_id: "toolu_01MNO345", + type: "web_search_tool_result", + }; + + const update = toolUpdateFromToolResult(toolResult, toolUse); + + expect(update).toEqual({ + content: [ + { + type: "content", + content: { type: "text", text: "Error: unavailable" }, + }, + ], + }); + }); + + it("should transform code_execution_result content to valid ACP content", () => { + const toolUse = { + type: "tool_use", + id: "toolu_01MNO345", + name: "CodeExecution", + input: {}, + }; + + const toolResult: BetaCodeExecutionToolResultBlockParam = { + content: { + type: "code_execution_result", + stdout: "Hello World", + stderr: "", + return_code: 0, + content: [], + }, + tool_use_id: "toolu_01MNO345", + type: "code_execution_tool_result", + }; + + const update = toolUpdateFromToolResult(toolResult, toolUse); + + expect(update).toEqual({ + content: [ + { + type: "content", + content: { type: "text", text: "Output: Hello World" }, + }, + ], + }); + }); + + it("should transform web_fetch_result content to valid ACP content", () => { + const toolUse = { + type: "tool_use", + id: "toolu_01MNO345", + name: "WebFetch", + input: { url: "https://example.com" }, + }; + + const toolResult: BetaWebFetchToolResultBlockParam = { + content: { + type: "web_fetch_result", + url: "https://example.com", + content: { + type: "document", + citations: null, + title: null, + source: { type: "text", media_type: "text/plain", data: "Page content here" }, + }, + }, + tool_use_id: "toolu_01MNO345", + type: "web_fetch_tool_result", + }; + + const update = toolUpdateFromToolResult(toolResult, toolUse); + + expect(update).toEqual({ + content: [ + { + type: "content", + content: { type: "text", text: "Fetched: https://example.com" }, + }, + ], + }); + }); + + it("should transform tool_search_tool_search_result to valid ACP content", () => { + const toolUse = { + type: "tool_use", + id: "toolu_01MNO345", + name: "ToolSearch", + input: { query: "test" }, + }; + + const toolResult: BetaToolSearchToolResultBlockParam = { + content: { + type: "tool_search_tool_search_result", + tool_references: [ + { type: "tool_reference", tool_name: "tool_a" }, + { type: "tool_reference", tool_name: "tool_b" }, + ], + }, + tool_use_id: "toolu_01MNO345", + type: "tool_search_tool_result", + }; + + const update = toolUpdateFromToolResult(toolResult, toolUse); + + expect(update).toEqual({ + content: [ + { + type: "content", + content: { type: "text", text: "Tools found: tool_a, tool_b" }, + }, + ], + }); + }); +}); + +describe("toDisplayPath", () => { + it("should relativize paths inside cwd and keep absolute paths outside", () => { + expect(toDisplayPath("/Users/test/project/src/main.ts", "/Users/test/project")).toBe( + "src/main.ts", + ); + expect(toDisplayPath("/etc/hosts", "/Users/test/project")).toBe("/etc/hosts"); + expect(toDisplayPath("/Users/test/project/src/main.ts")).toBe( + "/Users/test/project/src/main.ts", + ); + // Partial directory name match should not be treated as inside cwd + expect(toDisplayPath("/Users/test/project-other/file.ts", "/Users/test/project")).toBe( + "/Users/test/project-other/file.ts", + ); + }); +}); + +describe("toolUpdateFromDiffToolResponse", () => { + it("should return empty for non-object input", () => { + expect(toolUpdateFromDiffToolResponse(null)).toEqual({}); + expect(toolUpdateFromDiffToolResponse(undefined)).toEqual({}); + expect(toolUpdateFromDiffToolResponse("string")).toEqual({}); + }); + + it("should return empty when filePath or structuredPatch is missing", () => { + expect(toolUpdateFromDiffToolResponse({})).toEqual({}); + expect(toolUpdateFromDiffToolResponse({ filePath: "/foo.ts" })).toEqual({}); + expect(toolUpdateFromDiffToolResponse({ structuredPatch: [] })).toEqual({}); + }); + + it("should build diff content from a single-hunk structuredPatch", () => { + const toolResponse = { + filePath: "/Users/test/project/test.txt", + structuredPatch: [ + { + oldStart: 1, + oldLines: 3, + newStart: 1, + newLines: 3, + lines: [" context before", "-old line", "+new line", " context after"], + }, + ], + }; + + expect(toolUpdateFromDiffToolResponse(toolResponse)).toEqual({ + content: [ + { + type: "diff", + path: "/Users/test/project/test.txt", + oldText: "context before\nold line\ncontext after", + newText: "context before\nnew line\ncontext after", + }, + ], + locations: [{ path: "/Users/test/project/test.txt", line: 1 }], + }); + }); + + it("should build multiple diff content blocks for replaceAll with multiple hunks", () => { + const toolResponse = { + filePath: "/Users/test/project/file.ts", + structuredPatch: [ + { + oldStart: 5, + oldLines: 1, + newStart: 5, + newLines: 1, + lines: ["-oldValue", "+newValue"], + }, + { + oldStart: 20, + oldLines: 1, + newStart: 20, + newLines: 1, + lines: ["-oldValue", "+newValue"], + }, + ], + }; + + expect(toolUpdateFromDiffToolResponse(toolResponse)).toEqual({ + content: [ + { + type: "diff", + path: "/Users/test/project/file.ts", + oldText: "oldValue", + newText: "newValue", + }, + { + type: "diff", + path: "/Users/test/project/file.ts", + oldText: "oldValue", + newText: "newValue", + }, + ], + locations: [ + { path: "/Users/test/project/file.ts", line: 5 }, + { path: "/Users/test/project/file.ts", line: 20 }, + ], + }); + }); + + it("should handle deletion (newText becomes empty string)", () => { + const toolResponse = { + filePath: "/Users/test/project/file.ts", + structuredPatch: [ + { + oldStart: 10, + oldLines: 2, + newStart: 10, + newLines: 1, + lines: [" context", "-removed line"], + }, + ], + }; + + expect(toolUpdateFromDiffToolResponse(toolResponse)).toEqual({ + content: [ + { + type: "diff", + path: "/Users/test/project/file.ts", + oldText: "context\nremoved line", + newText: "context", + }, + ], + locations: [{ path: "/Users/test/project/file.ts", line: 10 }], + }); + }); + + it("should return empty for empty structuredPatch array", () => { + const toolResponse = { + filePath: "/Users/test/project/file.ts", + structuredPatch: [], + }; + + expect(toolUpdateFromDiffToolResponse(toolResponse)).toEqual({}); + }); +}); + +describe("stripLocalCommandMetadata", () => { + it("returns null for strings that are pure marker metadata", () => { + expect(stripLocalCommandMetadata("/model")).toBeNull(); + expect( + stripLocalCommandMetadata("out"), + ).toBeNull(); + expect( + stripLocalCommandMetadata("err"), + ).toBeNull(); + expect( + stripLocalCommandMetadata( + "/model\n model\n opus", + ), + ).toBeNull(); + }); + + it("returns the string unchanged for real content", () => { + expect(stripLocalCommandMetadata("hi")).toBe("hi"); + expect(stripLocalCommandMetadata("please run /model with args")).toBe( + "please run /model with args", + ); + }); + + // Regression: in the original bug report the entire /model preamble and + // the user's real "hi" prompt were concatenated into a single message. + // We want to strip the marker tags and preserve the real prose, not drop + // the whole message. + it("strips marker tags from mixed-content strings, preserving real prose", () => { + const mixed = + "/model\n model\n opus" + + "Set model to opus (claude-opus-4-7)" + + "/model\n model\n opus[1m]" + + "Set model to opus[1m] (claude-opus-4-7[1m])" + + "hi"; + const stripped = stripLocalCommandMetadata(mixed); + expect(typeof stripped).toBe("string"); + expect(stripped as string).not.toContain(""); + expect(stripped as string).not.toContain(""); + expect(stripped as string).not.toContain(""); + expect(stripped as string).not.toContain(""); + expect((stripped as string).trimEnd()).toMatch(/hi$/); + }); + + it("drops marker-only blocks from mixed arrays, keeping real blocks", () => { + const result = stripLocalCommandMetadata([ + { type: "text", text: "/model" }, + { type: "text", text: "ok" }, + { type: "text", text: "hi" }, + ]); + expect(result).toEqual([{ type: "text", text: "hi" }]); + }); + + it("returns null when every block is a marker", () => { + expect( + stripLocalCommandMetadata([ + { type: "text", text: "/model" }, + { type: "text", text: "ok" }, + ]), + ).toBeNull(); + }); + + it("strips tags inside a text block while keeping the trailing prose", () => { + const result = stripLocalCommandMetadata([ + { + type: "text", + text: "/modelokhi", + }, + ]); + expect(result).toEqual([{ type: "text", text: "hi" }]); + }); + + it("leaves non-text blocks alone", () => { + const image = { type: "image", source: { type: "base64", data: "", media_type: "image/png" } }; + const result = stripLocalCommandMetadata([ + { type: "text", text: "/model" }, + image, + ]); + expect(result).toEqual([image]); + }); + + it("handles null/undefined/non-container shapes", () => { + expect(stripLocalCommandMetadata(null)).toBeNull(); + expect(stripLocalCommandMetadata(undefined)).toBeUndefined(); + expect(stripLocalCommandMetadata({ arbitrary: "object" })).toEqual({ arbitrary: "object" }); + }); +}); + +describe("isLocalCommandMetadata", () => { + it("is true when stripping leaves nothing", () => { + expect(isLocalCommandMetadata("/model")).toBe(true); + expect( + isLocalCommandMetadata([{ type: "text", text: "/model" }]), + ).toBe(true); + }); + + it("is false when real content survives stripping", () => { + expect(isLocalCommandMetadata("hi")).toBe(false); + expect(isLocalCommandMetadata("/modelhi")).toBe(false); + expect( + isLocalCommandMetadata([ + { type: "text", text: "/model" }, + { type: "text", text: "hi" }, + ]), + ).toBe(false); + }); +}); + +describe("escape markdown", () => { + it("should escape markdown characters", () => { + let text = "Hello *world*!"; + let escaped = markdownEscape(text); + expect(escaped).toEqual("```\nHello *world*!\n```"); + + text = "for example:\n```markdown\nHello *world*!\n```\n"; + escaped = markdownEscape(text); + expect(escaped).toEqual("````\nfor example:\n```markdown\nHello *world*!\n```\n````"); + }); +}); + +describe("prompt conversion", () => { + it("should not change built-in slash commands", () => { + const message = promptToClaude({ + sessionId: "test", + prompt: [ + { + type: "text", + text: "/compact args", + }, + ], + }); + expect(message.message.content).toEqual([ + { + text: "/compact args", + type: "text", + }, + ]); + }); + + it("should remove MCP prefix from MCP slash commands", () => { + const message = promptToClaude({ + sessionId: "test", + prompt: [ + { + type: "text", + text: "/mcp:server:name args", + }, + ], + }); + expect(message.message.content).toEqual([ + { + text: "/server:name (MCP) args", + type: "text", + }, + ]); + }); +}); + +describe.skipIf(!process.env.RUN_INTEGRATION_TESTS)("SDK behavior", () => { + it("finds vendored cli path", async () => { + const path = await claudeCliPath(); + expect(path).toMatch(/@anthropic-ai\/claude-agent-sdk-[^/]+\/claude(\.exe)?$/); + }); + + it("query has a 'default' model", async () => { + const q = query({ prompt: "hi" }); + const models = await q.supportedModels(); + const defaultModel = models.find((m) => m.value === "default"); + expect(defaultModel).toBeDefined(); + }, 10000); + + it("custom session id", async () => { + const sessionId = randomUUID(); + const q = query({ + prompt: "hi", + options: { + systemPrompt: { type: "preset", preset: "claude_code" }, + sessionId, + settingSources: ["user", "project", "local"], + includePartialMessages: true, + }, + }); + + const { value } = await q.next(); + expect(value).toMatchObject({ type: "system", session_id: sessionId }); + }, 10000); +}); + +describe("permission requests", () => { + it("should include title field in tool permission request structure", () => { + // Test various tool types to ensure title is correctly generated + const testCases = [ + { + toolUse: { + type: "tool_use" as const, + id: "test-1", + name: "Write", + input: { file_path: "/test/file.txt", content: "test" }, + }, + expectedTitlePart: "/test/file.txt", + }, + { + toolUse: { + type: "tool_use" as const, + id: "test-2", + name: "Bash", + input: { command: "ls -la", description: "List files" }, + }, + expectedTitlePart: "ls -la", + }, + { + toolUse: { + type: "tool_use" as const, + id: "test-3", + name: "Read", + input: { file_path: "/test/data.json" }, + }, + expectedTitlePart: "/test/data.json", + }, + ]; + + for (const testCase of testCases) { + // Get the tool info that would be used in requestPermission + const toolInfo = toolInfoFromToolUse(testCase.toolUse); + + // Verify toolInfo has a title + expect(toolInfo.title).toBeDefined(); + expect(toolInfo.title).toContain(testCase.expectedTitlePart); + + // Verify the structure that our fix creates for requestPermission + // We now spread the full toolInfo (title, kind, content, locations) + const requestStructure = { + toolCall: { + toolCallId: testCase.toolUse.id, + rawInput: testCase.toolUse.input, + ...toolInfo, + }, + }; + + // Ensure the title field is present and populated + expect(requestStructure.toolCall.title).toBeDefined(); + expect(requestStructure.toolCall.title).toContain(testCase.expectedTitlePart); + + // Ensure kind is included so the client can render appropriate UI + expect(requestStructure.toolCall.kind).toBeDefined(); + expect(typeof requestStructure.toolCall.kind).toBe("string"); + + // Ensure content is included so the client always has tool call details + expect(requestStructure.toolCall.content).toBeDefined(); + expect(Array.isArray(requestStructure.toolCall.content)).toBe(true); + } + }); + + describe("describeAlwaysAllow", () => { + it("falls back to naming the whole tool when no suggestions are provided", () => { + expect(describeAlwaysAllow(undefined, "Bash")).toBe("Always Allow all Bash"); + expect(describeAlwaysAllow([], "Read")).toBe("Always Allow all Read"); + }); + + it("includes the scoped rule content from a suggestion", () => { + const label = describeAlwaysAllow( + [ + { + type: "addRules", + rules: [{ toolName: "Bash", ruleContent: "npm test:*" }], + behavior: "allow", + destination: "session", + }, + ], + "Bash", + ); + expect(label).toBe("Always Allow Bash(npm test:*)"); + }); + + it("indicates a tool-wide rule when the suggestion has no ruleContent", () => { + const label = describeAlwaysAllow( + [ + { + type: "addRules", + rules: [{ toolName: "Read" }], + behavior: "allow", + destination: "session", + }, + ], + "Read", + ); + expect(label).toBe("Always Allow all Read"); + }); + + it("joins multiple rules and directory suggestions", () => { + const label = describeAlwaysAllow( + [ + { + type: "addRules", + rules: [ + { toolName: "Bash", ruleContent: "git status" }, + { toolName: "Bash", ruleContent: "git diff:*" }, + ], + behavior: "allow", + destination: "session", + }, + { + type: "addDirectories", + directories: ["/tmp/work"], + destination: "session", + }, + ], + "Bash", + ); + expect(label).toBe("Always Allow Bash(git status), Bash(git diff:*) and access to /tmp/work"); + }); + + it("ignores non-allow rules and falls back when nothing is left", () => { + const label = describeAlwaysAllow( + [ + { + type: "addRules", + rules: [{ toolName: "Bash", ruleContent: "rm -rf:*" }], + behavior: "deny", + destination: "session", + }, + ], + "Bash", + ); + expect(label).toBe("Always Allow all Bash"); + }); + }); +}); + +describe("stop reason propagation", () => { + function createMockAgent() { + const mockClient = { + sessionUpdate: async () => {}, + } as unknown as AgentSideConnection; + return new ClaudeAcpAgent(mockClient, { log: () => {}, error: () => {} }); + } + + function createResultMessage(overrides: { + subtype: "success" | "error_during_execution"; + stop_reason: string | null; + is_error: boolean; + result?: string; + errors?: string[]; + }) { + return { + type: "result" as const, + subtype: overrides.subtype, + stop_reason: overrides.stop_reason, + is_error: overrides.is_error, + result: overrides.result ?? "", + errors: overrides.errors ?? [], + duration_ms: 0, + duration_api_ms: 0, + num_turns: 1, + total_cost_usd: 0, + usage: { + input_tokens: 10, + output_tokens: 5, + cache_read_input_tokens: 0, + cache_creation_input_tokens: 0, + }, + modelUsage: {}, + permission_denials: [], + uuid: randomUUID(), + session_id: "test-session", + }; + } + + function injectSession(agent: ClaudeAcpAgent, messages: any[]) { + const input = new Pushable(); + async function* messageGenerator() { + // Wait for the prompt to push its user message so we can replay it + const iter = input[Symbol.asyncIterator](); + const { value: userMessage, done } = await iter.next(); + if (!done && userMessage) { + yield { + type: "user", + message: userMessage.message, + parent_tool_use_id: null, + uuid: userMessage.uuid, + session_id: "test-session", + isReplay: true, + }; + } + yield* messages; + } + agent.sessions["test-session"] = { + query: messageGenerator() as any, + input, + cancelled: false, + cwd: "/test", + sessionFingerprint: JSON.stringify({ cwd: "/test", mcpServers: [] }), + modes: { + currentModeId: "default", + availableModes: [], + }, + models: { + currentModelId: "default", + availableModels: [], + }, + modelInfos: [], + settingsManager: { dispose: vi.fn() } as any, + accumulatedUsage: { + inputTokens: 0, + outputTokens: 0, + cachedReadTokens: 0, + cachedWriteTokens: 0, + }, + configOptions: [], + promptRunning: false, + pendingMessages: new Map(), + nextPendingOrder: 0, + abortController: new AbortController(), + emitRawSDKMessages: false, + contextWindowSize: 200000, + taskState: new Map(), + }; + } + + it("should return max_tokens when success result has stop_reason max_tokens", async () => { + const agent = createMockAgent(); + injectSession(agent, [ + createResultMessage({ subtype: "success", stop_reason: "max_tokens", is_error: false }), + { type: "system", subtype: "session_state_changed", state: "idle" }, + ]); + + const response = await agent.prompt({ + sessionId: "test-session", + prompt: [{ type: "text", text: "test" }], + }); + + expect(response.stopReason).toBe("max_tokens"); + }); + + it("should return max_tokens when success result has stop_reason max_tokens and is_error true", async () => { + const agent = createMockAgent(); + injectSession(agent, [ + createResultMessage({ + subtype: "success", + stop_reason: "max_tokens", + is_error: true, + result: "Token limit reached", + }), + { type: "system", subtype: "session_state_changed", state: "idle" }, + ]); + + const response = await agent.prompt({ + sessionId: "test-session", + prompt: [{ type: "text", text: "test" }], + }); + + expect(response.stopReason).toBe("max_tokens"); + }); + + it("should return max_tokens when error_during_execution has stop_reason max_tokens", async () => { + const agent = createMockAgent(); + injectSession(agent, [ + createResultMessage({ + subtype: "error_during_execution", + stop_reason: "max_tokens", + is_error: true, + errors: ["some error"], + }), + { type: "system", subtype: "session_state_changed", state: "idle" }, + ]); + + const response = await agent.prompt({ + sessionId: "test-session", + prompt: [{ type: "text", text: "test" }], + }); + + expect(response.stopReason).toBe("max_tokens"); + }); + + it("should return end_turn for success with null stop_reason", async () => { + const agent = createMockAgent(); + injectSession(agent, [ + createResultMessage({ subtype: "success", stop_reason: null, is_error: false }), + { type: "system", subtype: "session_state_changed", state: "idle" }, + ]); + + const response = await agent.prompt({ + sessionId: "test-session", + prompt: [{ type: "text", text: "test" }], + }); + + expect(response.stopReason).toBe("end_turn"); + }); + + it("should consume background task results and return the prompt's own result", async () => { + const agent = createMockAgent(); + const input = new Pushable(); + + const backgroundTaskResult = createResultMessage({ + subtype: "success", + stop_reason: null, + is_error: false, + }); + // Background task used some tokens + backgroundTaskResult.usage.input_tokens = 100; + backgroundTaskResult.usage.output_tokens = 50; + + const promptResult = createResultMessage({ + subtype: "success", + stop_reason: null, + is_error: false, + }); + + async function* messageGenerator() { + // Background task init + result arrive before our prompt's replay + yield { type: "system", subtype: "init", session_id: "test-session" }; + yield backgroundTaskResult; + + // Now the prompt's user message replay arrives + const iter = input[Symbol.asyncIterator](); + const { value: userMessage } = await iter.next(); + yield { + type: "user", + message: userMessage.message, + parent_tool_use_id: null, + uuid: userMessage.uuid, + session_id: "test-session", + isReplay: true, + }; + + // Then the prompt's own result + yield promptResult; + yield { type: "system", subtype: "session_state_changed", state: "idle" }; + } + + agent.sessions["test-session"] = { + query: messageGenerator() as any, + input, + cwd: "/tmp/test", + sessionFingerprint: JSON.stringify({ cwd: "/tmp/test", mcpServers: [] }), + cancelled: false, + modes: { + currentModeId: "default", + availableModes: [], + }, + models: { + currentModelId: "default", + availableModels: [], + }, + modelInfos: [], + settingsManager: { dispose: vi.fn() } as any, + accumulatedUsage: { + inputTokens: 0, + outputTokens: 0, + cachedReadTokens: 0, + cachedWriteTokens: 0, + }, + abortController: new AbortController(), + configOptions: [], + promptRunning: false, + pendingMessages: new Map(), + nextPendingOrder: 0, + emitRawSDKMessages: false, + contextWindowSize: 200000, + taskState: new Map(), + }; + + const response = await agent.prompt({ + sessionId: "test-session", + prompt: [{ type: "text", text: "test" }], + }); + + expect(response.stopReason).toBe("end_turn"); + // Usage should include both background task and prompt result tokens + expect(response.usage?.inputTokens).toBe( + backgroundTaskResult.usage.input_tokens + promptResult.usage.input_tokens, + ); + expect(response.usage?.outputTokens).toBe( + backgroundTaskResult.usage.output_tokens + promptResult.usage.output_tokens, + ); + }); + + it("should throw internal error for success with is_error true and no max_tokens", async () => { + const agent = createMockAgent(); + injectSession(agent, [ + createResultMessage({ + subtype: "success", + stop_reason: "end_turn", + is_error: true, + result: "Something went wrong", + }), + ]); + + await expect( + agent.prompt({ + sessionId: "test-session", + prompt: [{ type: "text", text: "test" }], + }), + ).rejects.toThrow("Internal error"); + }); + + it("forwards SDKAssistantMessage.error as structured data on internal errors", async () => { + const agent = createMockAgent(); + const assistantMessage: SDKAssistantMessage = { + type: "assistant", + parent_tool_use_id: null, + error: "rate_limit", + uuid: randomUUID(), + session_id: "test-session", + message: { + id: "msg-1", + type: "message", + role: "assistant", + container: null, + model: "claude-sonnet-4-20250514", + content: [], + stop_reason: "stop_sequence", + stop_sequence: null, + usage: { + input_tokens: 0, + output_tokens: 0, + cache_read_input_tokens: 0, + cache_creation_input_tokens: 0, + server_tool_use: { web_search_requests: 0, web_fetch_requests: 0 }, + service_tier: null, + cache_creation: { + ephemeral_1h_input_tokens: 0, + ephemeral_5m_input_tokens: 0, + }, + } as any, + } as any, + }; + + injectSession(agent, [ + assistantMessage, + createResultMessage({ + subtype: "success", + stop_reason: "end_turn", + is_error: true, + result: "You've hit your limit · resets 8pm", + }), + ]); + + const err = await agent + .prompt({ + sessionId: "test-session", + prompt: [{ type: "text", text: "test" }], + }) + .then( + () => null, + (e) => e, + ); + + expect(err).not.toBeNull(); + expect((err as { data: unknown }).data).toEqual({ errorKind: "rate_limit" }); + }); + + it("omits errorKind data when no SDKAssistantMessage.error was observed", async () => { + const agent = createMockAgent(); + injectSession(agent, [ + createResultMessage({ + subtype: "success", + stop_reason: "end_turn", + is_error: true, + result: "Something went wrong", + }), + ]); + + const err = await agent + .prompt({ + sessionId: "test-session", + prompt: [{ type: "text", text: "test" }], + }) + .then( + () => null, + (e) => e, + ); + + expect(err).not.toBeNull(); + expect((err as { data: unknown }).data).toBeUndefined(); + }); +}); + +describe("session/close", () => { + function createMockAgent() { + const mockClient = { + sessionUpdate: async () => {}, + } as unknown as AgentSideConnection; + return new ClaudeAcpAgent(mockClient, { log: () => {}, error: () => {} }); + } + + function injectSession(agent: ClaudeAcpAgent, sessionId: string) { + function* empty() {} + const gen = Object.assign(empty(), { interrupt: vi.fn(), close: vi.fn() }); + agent.sessions[sessionId] = { + query: gen as any, + input: new Pushable(), + cancelled: false, + cwd: "/test", + sessionFingerprint: JSON.stringify({ cwd: "/test", mcpServers: [] }), + modes: { + currentModeId: "default", + availableModes: [], + }, + models: { + currentModelId: "default", + availableModels: [], + }, + modelInfos: [], + settingsManager: { dispose: vi.fn() } as any, + accumulatedUsage: { + inputTokens: 0, + outputTokens: 0, + cachedReadTokens: 0, + cachedWriteTokens: 0, + }, + configOptions: [], + promptRunning: false, + pendingMessages: new Map(), + nextPendingOrder: 0, + abortController: new AbortController(), + emitRawSDKMessages: false, + contextWindowSize: 200000, + taskState: new Map(), + }; + return agent.sessions[sessionId]!; + } + + it("should close an existing session and remove it", async () => { + const agent = createMockAgent(); + const session = injectSession(agent, "session-1"); + + expect(agent.sessions["session-1"]).toBeDefined(); + + const result = await agent.closeSession({ sessionId: "session-1" }); + + expect(result).toEqual({}); + expect(agent.sessions["session-1"]).toBeUndefined(); + expect(session.query.interrupt).toHaveBeenCalled(); + expect(session.settingsManager.dispose).toHaveBeenCalled(); + }); + + it("should abort the session's abort controller", async () => { + const agent = createMockAgent(); + const session = injectSession(agent, "session-2"); + + expect(session.abortController.signal.aborted).toBe(false); + + await agent.closeSession({ sessionId: "session-2" }); + + expect(session.abortController.signal.aborted).toBe(true); + }); + + it("should throw when closing a non-existent session", async () => { + const agent = createMockAgent(); + + await expect(agent.closeSession({ sessionId: "non-existent" })).rejects.toThrow( + "Session not found", + ); + }); + + it("should not affect other sessions when closing one", async () => { + const agent = createMockAgent(); + injectSession(agent, "session-a"); + injectSession(agent, "session-b"); + + await agent.closeSession({ sessionId: "session-a" }); + + expect(agent.sessions["session-a"]).toBeUndefined(); + expect(agent.sessions["session-b"]).toBeDefined(); + }); +}); + +describe("session/delete", () => { + function createMockAgent() { + const mockClient = { + sessionUpdate: async () => {}, + } as unknown as AgentSideConnection; + return new ClaudeAcpAgent(mockClient, { log: () => {}, error: () => {} }); + } + + function injectSession(agent: ClaudeAcpAgent, sessionId: string) { + function* empty() {} + const gen = Object.assign(empty(), { interrupt: vi.fn(), close: vi.fn() }); + agent.sessions[sessionId] = { + query: gen as any, + input: new Pushable(), + cancelled: false, + cwd: "/test", + sessionFingerprint: JSON.stringify({ cwd: "/test", mcpServers: [] }), + modes: { currentModeId: "default", availableModes: [] }, + models: { currentModelId: "default", availableModels: [] }, + modelInfos: [], + settingsManager: { dispose: vi.fn() } as any, + accumulatedUsage: { + inputTokens: 0, + outputTokens: 0, + cachedReadTokens: 0, + cachedWriteTokens: 0, + }, + configOptions: [], + promptRunning: false, + pendingMessages: new Map(), + nextPendingOrder: 0, + abortController: new AbortController(), + emitRawSDKMessages: false, + contextWindowSize: 200000, + taskState: new Map(), + }; + return agent.sessions[sessionId]!; + } + + beforeEach(() => { + vi.mocked(deleteSession).mockReset(); + vi.mocked(deleteSession).mockResolvedValue(undefined); + }); + + it("tears down the active session and deletes it from disk", async () => { + const agent = createMockAgent(); + const session = injectSession(agent, "session-1"); + + const result = await agent.unstable_deleteSession({ sessionId: "session-1" }); + + expect(result).toEqual({}); + expect(agent.sessions["session-1"]).toBeUndefined(); + expect(session.query.interrupt).toHaveBeenCalled(); + expect(session.settingsManager.dispose).toHaveBeenCalled(); + expect(session.abortController.signal.aborted).toBe(true); + expect(deleteSession).toHaveBeenCalledWith("session-1"); + }); + + it("deletes a session from disk that is not currently active", async () => { + const agent = createMockAgent(); + + const result = await agent.unstable_deleteSession({ sessionId: "not-active" }); + + expect(result).toEqual({}); + expect(deleteSession).toHaveBeenCalledWith("not-active"); + }); + + it("propagates errors from the SDK delete call", async () => { + const agent = createMockAgent(); + vi.mocked(deleteSession).mockRejectedValueOnce(new Error("Session not found on disk")); + + await expect(agent.unstable_deleteSession({ sessionId: "missing" })).rejects.toThrow( + "Session not found on disk", + ); + }); + + it("does not affect other sessions when deleting one", async () => { + const agent = createMockAgent(); + injectSession(agent, "session-a"); + injectSession(agent, "session-b"); + + await agent.unstable_deleteSession({ sessionId: "session-a" }); + + expect(agent.sessions["session-a"]).toBeUndefined(); + expect(agent.sessions["session-b"]).toBeDefined(); + }); +}); + +describe("getOrCreateSession param change detection", () => { + function createMockAgent() { + const mockClient = { + sessionUpdate: async () => {}, + } as unknown as AgentSideConnection; + return new ClaudeAcpAgent(mockClient, { log: () => {}, error: () => {} }); + } + + function injectSession( + agent: ClaudeAcpAgent, + sessionId: string, + opts: { cwd?: string; mcpServers?: { name: string }[] } = {}, + ) { + const cwd = opts.cwd ?? "/test"; + const mcpServers = (opts.mcpServers ?? []) as any[]; + function* empty() {} + const gen = Object.assign(empty(), { + interrupt: vi.fn(), + close: vi.fn(), + supportedCommands: vi.fn().mockResolvedValue([]), + }); + agent.sessions[sessionId] = { + query: gen as any, + input: new Pushable(), + cancelled: false, + cwd, + sessionFingerprint: JSON.stringify({ + cwd, + mcpServers: [...mcpServers].sort((a: any, b: any) => a.name.localeCompare(b.name)), + }), + modes: { currentModeId: "default", availableModes: [] }, + models: { currentModelId: "default", availableModels: [] }, + modelInfos: [], + settingsManager: { dispose: vi.fn() } as any, + accumulatedUsage: { + inputTokens: 0, + outputTokens: 0, + cachedReadTokens: 0, + cachedWriteTokens: 0, + }, + configOptions: [], + promptRunning: false, + pendingMessages: new Map(), + nextPendingOrder: 0, + abortController: new AbortController(), + emitRawSDKMessages: false, + contextWindowSize: 200000, + taskState: new Map(), + }; + return agent.sessions[sessionId]!; + } + + it("returns cached session when params are unchanged", async () => { + const agent = createMockAgent(); + const session = injectSession(agent, "s1", { cwd: "/project" }); + + await agent.resumeSession({ + sessionId: "s1", + cwd: "/project", + mcpServers: [], + }); + + // Session object should be the exact same reference (not recreated) + expect(agent.sessions["s1"]).toBe(session); + expect(session.settingsManager.dispose).not.toHaveBeenCalled(); + }); + + it("tears down existing session when cwd changes", async () => { + const agent = createMockAgent(); + const session = injectSession(agent, "s1", { cwd: "/old" }); + + // Mock createSession to avoid spawning a real process. + // It will throw, but we can catch that — we only need to verify + // the old session was torn down before createSession was attempted. + const createSessionSpy = vi + .spyOn(agent as any, "createSession") + .mockRejectedValue(new Error("mock")); + + await expect( + agent.resumeSession({ sessionId: "s1", cwd: "/new", mcpServers: [] }), + ).rejects.toThrow("mock"); + + // Old session should have been fully torn down + expect(session.settingsManager.dispose).toHaveBeenCalled(); + expect(session.abortController.signal.aborted).toBe(true); + expect(session.query.interrupt).toHaveBeenCalled(); + expect(agent.sessions["s1"]).toBeUndefined(); + + // createSession should have been called with the new cwd + expect(createSessionSpy).toHaveBeenCalledWith( + expect.objectContaining({ cwd: "/new" }), + expect.objectContaining({ resume: "s1" }), + ); + }); + + it("tears down existing session when mcpServers change", async () => { + const agent = createMockAgent(); + const session = injectSession(agent, "s1", { cwd: "/project" }); + + const createSessionSpy = vi + .spyOn(agent as any, "createSession") + .mockRejectedValue(new Error("mock")); + + await expect( + agent.resumeSession({ + sessionId: "s1", + cwd: "/project", + mcpServers: [{ name: "new-server", command: "node", args: ["server.js"], env: [] }], + }), + ).rejects.toThrow("mock"); + + expect(session.settingsManager.dispose).toHaveBeenCalled(); + expect(session.abortController.signal.aborted).toBe(true); + expect(agent.sessions["s1"]).toBeUndefined(); + expect(createSessionSpy).toHaveBeenCalled(); + }); + + it("treats mcpServers in different order as unchanged", async () => { + const agent = createMockAgent(); + const servers = [ + { name: "b-server", command: "node", args: ["b.js"], env: [] }, + { name: "a-server", command: "node", args: ["a.js"], env: [] }, + ] as const; + const session = injectSession(agent, "s1", { + cwd: "/project", + mcpServers: servers as any, + }); + + // Same servers but reversed order — should NOT trigger teardown + await agent.resumeSession({ + sessionId: "s1", + cwd: "/project", + mcpServers: [...servers].reverse() as any, + }); + + expect(agent.sessions["s1"]).toBe(session); + expect(session.settingsManager.dispose).not.toHaveBeenCalled(); + }); +}); + +describe("usage_update computation", () => { + function createAssistantMessage(overrides: { + model: string; + usage?: { + input_tokens: number; + output_tokens: number; + cache_read_input_tokens: number; + cache_creation_input_tokens: number; + }; + }) { + return { + type: "assistant" as const, + parent_tool_use_id: null, + uuid: randomUUID(), + session_id: "test-session", + message: { + model: overrides.model, + content: [{ type: "text", text: "hello" }], + usage: overrides.usage ?? { + input_tokens: 100, + output_tokens: 50, + cache_read_input_tokens: 20, + cache_creation_input_tokens: 10, + }, + }, + }; + } + + function createResultMessageWithModel(overrides: { + modelUsage: Record< + string, + { + inputTokens: number; + outputTokens: number; + cacheReadInputTokens: number; + cacheCreationInputTokens: number; + webSearchRequests: number; + costUSD: number; + contextWindow: number; + maxOutputTokens: number; + } + >; + }) { + return { + type: "result" as const, + subtype: "success" as const, + stop_reason: "end_turn", + is_error: false, + result: "", + errors: [], + duration_ms: 0, + duration_api_ms: 0, + num_turns: 1, + total_cost_usd: 0.01, + usage: { + input_tokens: 10, + output_tokens: 5, + cache_read_input_tokens: 0, + cache_creation_input_tokens: 0, + }, + modelUsage: overrides.modelUsage, + permission_denials: [], + uuid: randomUUID(), + session_id: "test-session", + }; + } + + function createStreamEvent( + eventType: "message_start" | "message_delta", + payload: Record, + parentToolUseId: string | null = null, + ) { + return { + type: "stream_event" as const, + parent_tool_use_id: parentToolUseId, + uuid: randomUUID(), + session_id: "test-session", + event: + eventType === "message_start" + ? { type: "message_start" as const, message: payload } + : { type: "message_delta" as const, ...payload }, + }; + } + + function createMockAgentWithCapture() { + const updates: any[] = []; + const mockClient = { + sessionUpdate: async (notification: any) => { + updates.push(notification); + }, + } as unknown as AgentSideConnection; + const agent = new ClaudeAcpAgent(mockClient, { log: () => {}, error: () => {} }); + return { agent, updates }; + } + + function injectSession(agent: ClaudeAcpAgent, messages: any[]) { + const input = new Pushable(); + async function* messageGenerator() { + // Wait for the prompt to push its user message so we can replay it + const iter = input[Symbol.asyncIterator](); + const { value: userMessage, done } = await iter.next(); + if (!done && userMessage) { + yield { + type: "user", + message: userMessage.message, + parent_tool_use_id: null, + uuid: userMessage.uuid, + session_id: "test-session", + isReplay: true, + }; + } + yield* messages; + } + agent.sessions["test-session"] = { + query: messageGenerator() as any, + input, + cancelled: false, + cwd: "/test", + sessionFingerprint: JSON.stringify({ cwd: "/test", mcpServers: [] }), + modes: { + currentModeId: "default", + availableModes: [], + }, + models: { + currentModelId: "default", + availableModels: [], + }, + modelInfos: [], + settingsManager: {} as any, + accumulatedUsage: { + inputTokens: 0, + outputTokens: 0, + cachedReadTokens: 0, + cachedWriteTokens: 0, + }, + configOptions: [], + promptRunning: false, + pendingMessages: new Map(), + nextPendingOrder: 0, + abortController: new AbortController(), + emitRawSDKMessages: false, + contextWindowSize: 200000, + taskState: new Map(), + }; + } + + it("used sums all token types as post-turn context occupancy proxy", async () => { + const { agent, updates } = createMockAgentWithCapture(); + injectSession(agent, [ + createAssistantMessage({ + model: "claude-opus-4-20250514", + usage: { + input_tokens: 1000, + output_tokens: 500, + cache_read_input_tokens: 200, + cache_creation_input_tokens: 100, + }, + }), + createResultMessageWithModel({ + modelUsage: { + "claude-opus-4-20250514": { + inputTokens: 1000, + outputTokens: 500, + cacheReadInputTokens: 200, + cacheCreationInputTokens: 100, + webSearchRequests: 0, + costUSD: 0.01, + contextWindow: 1000000, + maxOutputTokens: 16384, + }, + }, + }), + { type: "system", subtype: "session_state_changed", state: "idle" }, + ]); + + await agent.prompt({ sessionId: "test-session", prompt: [{ type: "text", text: "test" }] }); + + const usageUpdate = updates.find((u: any) => u.update?.sessionUpdate === "usage_update"); + expect(usageUpdate).toBeDefined(); + // used = input(1000) + output(500) + cache_read(200) + cache_creation(100) = 1800 + expect(usageUpdate.update.used).toBe(1800); + }); + + it("coerces null input/output tokens so wire `used` is never null", async () => { + // Synthetic or third-party-backend stream events have been observed + // emitting input_tokens/output_tokens as null. Without coercion the + // snapshot leaks NaN into totalTokens(), and JSON.stringify(NaN) === "null" + // produces a malformed `used: null` that schema-validating ACP clients reject. + const { agent, updates } = createMockAgentWithCapture(); + injectSession(agent, [ + createAssistantMessage({ + model: "claude-opus-4-20250514", + usage: { + input_tokens: null, + output_tokens: null, + cache_read_input_tokens: 200, + cache_creation_input_tokens: 100, + } as unknown as { + input_tokens: number; + output_tokens: number; + cache_read_input_tokens: number; + cache_creation_input_tokens: number; + }, + }), + createResultMessageWithModel({ + modelUsage: { + "claude-opus-4-20250514": { + inputTokens: 0, + outputTokens: 0, + cacheReadInputTokens: 200, + cacheCreationInputTokens: 100, + webSearchRequests: 0, + costUSD: 0.01, + contextWindow: 1000000, + maxOutputTokens: 16384, + }, + }, + }), + { type: "system", subtype: "session_state_changed", state: "idle" }, + ]); + + await agent.prompt({ sessionId: "test-session", prompt: [{ type: "text", text: "test" }] }); + + const usageUpdates = updates.filter((u: any) => u.update?.sessionUpdate === "usage_update"); + expect(usageUpdates.length).toBeGreaterThan(0); + for (const u of usageUpdates) { + expect(u.update.used).not.toBeNull(); + expect(Number.isFinite(u.update.used)).toBe(true); + // Round-trip through JSON to catch the NaN -> "null" serialization bug. + const wire = JSON.parse(JSON.stringify(u.update)); + expect(wire.used).not.toBeNull(); + expect(typeof wire.used).toBe("number"); + } + }); + + it("stream_event message_start emits usage_update before result", async () => { + const { agent, updates } = createMockAgentWithCapture(); + injectSession(agent, [ + createStreamEvent("message_start", { + model: "claude-opus-4-20250514", + usage: { + input_tokens: 1000, + output_tokens: 500, + cache_read_input_tokens: 200, + cache_creation_input_tokens: 100, + }, + }), + createResultMessageWithModel({ + modelUsage: { + "claude-opus-4-20250514": { + inputTokens: 1000, + outputTokens: 500, + cacheReadInputTokens: 200, + cacheCreationInputTokens: 100, + webSearchRequests: 0, + costUSD: 0.01, + contextWindow: 1000000, + maxOutputTokens: 16384, + }, + }, + }), + { type: "system", subtype: "session_state_changed", state: "idle" }, + ]); + + await agent.prompt({ sessionId: "test-session", prompt: [{ type: "text", text: "test" }] }); + + const usageUpdates = updates.filter((u: any) => u.update?.sessionUpdate === "usage_update"); + expect(usageUpdates).toHaveLength(2); + expect(usageUpdates[0].update.used).toBe(1800); + // First prompt of a session has no prior result to learn the window from, + // so the mid-stream update falls back to the default context window. + expect(usageUpdates[0].update.size).toBe(200000); + expect(usageUpdates[0].update.cost).toBeUndefined(); + expect(usageUpdates[1].update.used).toBe(1800); + expect(usageUpdates[1].update.size).toBe(1000000); + expect(usageUpdates[1].update.cost).toBeDefined(); + }); + + it("stream_event message_delta patches previous snapshot", async () => { + const { agent, updates } = createMockAgentWithCapture(); + injectSession(agent, [ + createStreamEvent("message_start", { + model: "claude-opus-4-20250514", + usage: { + input_tokens: 1000, + output_tokens: 0, + cache_read_input_tokens: 200, + cache_creation_input_tokens: 100, + }, + }), + createStreamEvent("message_delta", { + usage: { output_tokens: 500 }, + }), + createResultMessageWithModel({ + modelUsage: { + "claude-opus-4-20250514": { + inputTokens: 1000, + outputTokens: 500, + cacheReadInputTokens: 200, + cacheCreationInputTokens: 100, + webSearchRequests: 0, + costUSD: 0.01, + contextWindow: 1000000, + maxOutputTokens: 16384, + }, + }, + }), + { type: "system", subtype: "session_state_changed", state: "idle" }, + ]); + + await agent.prompt({ sessionId: "test-session", prompt: [{ type: "text", text: "test" }] }); + + const usageUpdates = updates.filter((u: any) => u.update?.sessionUpdate === "usage_update"); + expect(usageUpdates).toHaveLength(3); + expect(usageUpdates[0].update.used).toBe(1300); + expect(usageUpdates[0].update.cost).toBeUndefined(); + expect(usageUpdates[1].update.used).toBe(1800); + expect(usageUpdates[1].update.cost).toBeUndefined(); + expect(usageUpdates[2].update.used).toBe(1800); + expect(usageUpdates[2].update.cost).toBeDefined(); + }); + + it("mid-stream size is inferred from a 1M model name before the first result", async () => { + // On the very first prompt there is no learned context window yet, so the + // mid-stream update would otherwise fall back to 200k. A "-1m" suffix in + // the SDK model ID is enough signal to emit 1_000_000 up front. + const { agent, updates } = createMockAgentWithCapture(); + injectSession(agent, [ + createStreamEvent("message_start", { + model: "claude-opus-4-6-1m", + usage: { + input_tokens: 2000, + output_tokens: 1000, + cache_read_input_tokens: 0, + cache_creation_input_tokens: 0, + }, + }), + createResultMessageWithModel({ + modelUsage: { + "claude-opus-4-6-1m": { + inputTokens: 2000, + outputTokens: 1000, + cacheReadInputTokens: 0, + cacheCreationInputTokens: 0, + webSearchRequests: 0, + costUSD: 0.02, + contextWindow: 1000000, + maxOutputTokens: 16384, + }, + }, + }), + { type: "system", subtype: "session_state_changed", state: "idle" }, + ]); + + await agent.prompt({ sessionId: "test-session", prompt: [{ type: "text", text: "test" }] }); + + const usageUpdates = updates.filter((u: any) => u.update?.sessionUpdate === "usage_update"); + expect(usageUpdates).toHaveLength(2); + expect(usageUpdates[0].update.size).toBe(1000000); + expect(usageUpdates[1].update.size).toBe(1000000); + }); + + it("duplicate stream_event totals do not re-emit usage_update", async () => { + // A message_delta whose cumulative totals match the prior snapshot should + // not trigger a duplicate usage_update — only the result adds cost on top. + const { agent, updates } = createMockAgentWithCapture(); + injectSession(agent, [ + createStreamEvent("message_start", { + model: "claude-opus-4-20250514", + usage: { + input_tokens: 1000, + output_tokens: 500, + cache_read_input_tokens: 200, + cache_creation_input_tokens: 100, + }, + }), + createStreamEvent("message_delta", { + usage: { output_tokens: 500 }, + }), + createResultMessageWithModel({ + modelUsage: { + "claude-opus-4-20250514": { + inputTokens: 1000, + outputTokens: 500, + cacheReadInputTokens: 200, + cacheCreationInputTokens: 100, + webSearchRequests: 0, + costUSD: 0.01, + contextWindow: 1000000, + maxOutputTokens: 16384, + }, + }, + }), + { type: "system", subtype: "session_state_changed", state: "idle" }, + ]); + + await agent.prompt({ sessionId: "test-session", prompt: [{ type: "text", text: "test" }] }); + + const usageUpdates = updates.filter((u: any) => u.update?.sessionUpdate === "usage_update"); + expect(usageUpdates).toHaveLength(2); + expect(usageUpdates[0].update.used).toBe(1800); + expect(usageUpdates[0].update.cost).toBeUndefined(); + expect(usageUpdates[1].update.used).toBe(1800); + expect(usageUpdates[1].update.cost).toBeDefined(); + }); + + it("mid-stream size uses the session's learned context window", async () => { + // Session state persists the model's context window across prompts, so a + // mid-stream update in a later prompt reports the real size immediately + // instead of snapping back to the 200k default before the result arrives. + const { agent, updates } = createMockAgentWithCapture(); + injectSession(agent, [ + createStreamEvent("message_start", { + model: "claude-opus-4-20250514", + usage: { + input_tokens: 2000, + output_tokens: 1000, + cache_read_input_tokens: 0, + cache_creation_input_tokens: 0, + }, + }), + createResultMessageWithModel({ + modelUsage: { + "claude-opus-4-20250514": { + inputTokens: 2000, + outputTokens: 1000, + cacheReadInputTokens: 0, + cacheCreationInputTokens: 0, + webSearchRequests: 0, + costUSD: 0.02, + contextWindow: 1000000, + maxOutputTokens: 16384, + }, + }, + }), + { type: "system", subtype: "session_state_changed", state: "idle" }, + ]); + // Simulate a prior prompt having learned the 1M window for this model. + agent.sessions["test-session"].contextWindowSize = 1000000; + + await agent.prompt({ sessionId: "test-session", prompt: [{ type: "text", text: "test" }] }); + + const usageUpdates = updates.filter((u: any) => u.update?.sessionUpdate === "usage_update"); + expect(usageUpdates).toHaveLength(2); + expect(usageUpdates[0].update.size).toBe(1000000); + expect(usageUpdates[1].update.size).toBe(1000000); + }); + + it("switching to a 1M model seeds the context window from the heuristic", async () => { + // The heuristic runs at config-change time so mid-stream updates in the + // next prompt already report 1M — without waiting for message_start or + // the next `result` to correct us. + const { agent, updates } = createMockAgentWithCapture(); + injectSession(agent, [ + createStreamEvent("message_start", { + model: "claude-opus-4-6-1m", + usage: { + input_tokens: 2000, + output_tokens: 1000, + cache_read_input_tokens: 0, + cache_creation_input_tokens: 0, + }, + }), + createResultMessageWithModel({ + modelUsage: { + "claude-opus-4-6-1m": { + inputTokens: 2000, + outputTokens: 1000, + cacheReadInputTokens: 0, + cacheCreationInputTokens: 0, + webSearchRequests: 0, + costUSD: 0.02, + contextWindow: 1000000, + maxOutputTokens: 16384, + }, + }, + }), + { type: "system", subtype: "session_state_changed", state: "idle" }, + ]); + const session = agent.sessions["test-session"]; + expect(session.contextWindowSize).toBe(200000); + + await (agent as any).applyConfigOptionValue( + "test-session", + session, + "model", + "claude-opus-4-6-1m", + ); + expect(session.contextWindowSize).toBe(1000000); + + await agent.prompt({ sessionId: "test-session", prompt: [{ type: "text", text: "test" }] }); + + const usageUpdates = updates.filter((u: any) => u.update?.sessionUpdate === "usage_update"); + expect(usageUpdates).toHaveLength(2); + expect(usageUpdates[0].update.size).toBe(1000000); + expect(usageUpdates[1].update.size).toBe(1000000); + }); + + it("result with no matching modelUsage preserves the learned window", async () => { + // A turn whose `result.modelUsage` doesn't contain the current top-level + // model (e.g. no top-level assistant message, or only a subagent ran) must + // not clobber the window learned on a prior turn — otherwise the next + // prompt's mid-stream updates regress to the 200k default. + const { agent, updates } = createMockAgentWithCapture(); + injectSession(agent, [ + createResultMessageWithModel({ + modelUsage: { + "claude-haiku-4-5-20251001": { + inputTokens: 100, + outputTokens: 50, + cacheReadInputTokens: 0, + cacheCreationInputTokens: 0, + webSearchRequests: 0, + costUSD: 0.001, + contextWindow: 200000, + maxOutputTokens: 8192, + }, + }, + }), + { type: "system", subtype: "session_state_changed", state: "idle" }, + ]); + const session = agent.sessions["test-session"]; + session.contextWindowSize = 1000000; + + await agent.prompt({ sessionId: "test-session", prompt: [{ type: "text", text: "test" }] }); + + expect(session.contextWindowSize).toBe(1000000); + // The emit itself falls back to session.contextWindowSize, which is + // unchanged from the learned value. + const usageUpdates = updates.filter((u: any) => u.update?.sessionUpdate === "usage_update"); + // No lastAssistantTotalUsage was set (no top-level assistant / stream + // event), so the result branch skips its emit entirely. + expect(usageUpdates).toHaveLength(0); + }); + + it("switching the session's model invalidates the learned context window", async () => { + // When the user switches models mid-session, the window learned for the + // previous model would otherwise persist into the next prompt's first + // mid-stream update. applyConfigOptionValue should reset it so the next + // turn's first update falls back to the heuristic (here: 200k default). + const { agent, updates } = createMockAgentWithCapture(); + injectSession(agent, [ + createStreamEvent("message_start", { + model: "claude-sonnet-4-6", + usage: { + input_tokens: 1000, + output_tokens: 500, + cache_read_input_tokens: 0, + cache_creation_input_tokens: 0, + }, + }), + createResultMessageWithModel({ + modelUsage: { + "claude-sonnet-4-6": { + inputTokens: 1000, + outputTokens: 500, + cacheReadInputTokens: 0, + cacheCreationInputTokens: 0, + webSearchRequests: 0, + costUSD: 0.01, + contextWindow: 200000, + maxOutputTokens: 16384, + }, + }, + }), + { type: "system", subtype: "session_state_changed", state: "idle" }, + ]); + const session = agent.sessions["test-session"]; + session.contextWindowSize = 1000000; + session.models = { ...session.models, currentModelId: "claude-opus-4-6-1m" }; + + // User flips the selector to a 200k model. + await (agent as any).applyConfigOptionValue( + "test-session", + session, + "model", + "claude-sonnet-4-6", + ); + + await agent.prompt({ sessionId: "test-session", prompt: [{ type: "text", text: "test" }] }); + + const usageUpdates = updates.filter((u: any) => u.update?.sessionUpdate === "usage_update"); + expect(usageUpdates).toHaveLength(2); + expect(usageUpdates[0].update.size).toBe(200000); + expect(usageUpdates[1].update.size).toBe(200000); + }); + + it("non-usage stream events do not re-emit usage_update", async () => { + // content_block_* and message_stop carry no usage fields; they must not + // trigger duplicate emits between the real message_start / message_delta + // / result updates. + const { agent, updates } = createMockAgentWithCapture(); + injectSession(agent, [ + createStreamEvent("message_start", { + model: "claude-opus-4-20250514", + usage: { + input_tokens: 1000, + output_tokens: 0, + cache_read_input_tokens: 0, + cache_creation_input_tokens: 0, + }, + }), + { + type: "stream_event" as const, + parent_tool_use_id: null, + uuid: randomUUID(), + session_id: "test-session", + event: { type: "content_block_start", index: 0, content_block: { type: "text", text: "" } }, + }, + { + type: "stream_event" as const, + parent_tool_use_id: null, + uuid: randomUUID(), + session_id: "test-session", + event: { type: "content_block_delta", index: 0, delta: { type: "text_delta", text: "hi" } }, + }, + { + type: "stream_event" as const, + parent_tool_use_id: null, + uuid: randomUUID(), + session_id: "test-session", + event: { type: "content_block_stop", index: 0 }, + }, + createStreamEvent("message_delta", { + usage: { output_tokens: 200 }, + }), + { + type: "stream_event" as const, + parent_tool_use_id: null, + uuid: randomUUID(), + session_id: "test-session", + event: { type: "message_stop" }, + }, + createResultMessageWithModel({ + modelUsage: { + "claude-opus-4-20250514": { + inputTokens: 1000, + outputTokens: 200, + cacheReadInputTokens: 0, + cacheCreationInputTokens: 0, + webSearchRequests: 0, + costUSD: 0.01, + contextWindow: 1000000, + maxOutputTokens: 16384, + }, + }, + }), + { type: "system", subtype: "session_state_changed", state: "idle" }, + ]); + + await agent.prompt({ sessionId: "test-session", prompt: [{ type: "text", text: "test" }] }); + + const usageUpdates = updates.filter((u: any) => u.update?.sessionUpdate === "usage_update"); + // Exactly three: message_start (1000), message_delta (1200), result (1200 + cost). + expect(usageUpdates).toHaveLength(3); + expect(usageUpdates[0].update.used).toBe(1000); + expect(usageUpdates[1].update.used).toBe(1200); + expect(usageUpdates[2].update.used).toBe(1200); + expect(usageUpdates[2].update.cost).toBeDefined(); + }); + + it("subagent stream_event does not emit usage_update", async () => { + const { agent, updates } = createMockAgentWithCapture(); + injectSession(agent, [ + createStreamEvent( + "message_start", + { + model: "claude-haiku-4-5-20251001", + usage: { + input_tokens: 500, + output_tokens: 100, + cache_read_input_tokens: 0, + cache_creation_input_tokens: 0, + }, + }, + "tool_use_123", + ), + createResultMessageWithModel({ + modelUsage: { + "claude-haiku-4-5-20251001": { + inputTokens: 500, + outputTokens: 100, + cacheReadInputTokens: 0, + cacheCreationInputTokens: 0, + webSearchRequests: 0, + costUSD: 0.001, + contextWindow: 200000, + maxOutputTokens: 8192, + }, + }, + }), + { type: "system", subtype: "session_state_changed", state: "idle" }, + ]); + + await agent.prompt({ sessionId: "test-session", prompt: [{ type: "text", text: "test" }] }); + + const usageUpdates = updates.filter((u: any) => u.update?.sessionUpdate === "usage_update"); + expect(usageUpdates).toHaveLength(0); + }); + + it("size reflects the current model's context window, not min across all", async () => { + const { agent, updates } = createMockAgentWithCapture(); + injectSession(agent, [ + createAssistantMessage({ model: "claude-opus-4-20250514" }), + createResultMessageWithModel({ + modelUsage: { + "claude-opus-4-20250514": { + inputTokens: 100, + outputTokens: 50, + cacheReadInputTokens: 20, + cacheCreationInputTokens: 10, + webSearchRequests: 0, + costUSD: 0.01, + contextWindow: 1000000, + maxOutputTokens: 16384, + }, + "claude-sonnet-4-20250514": { + inputTokens: 50, + outputTokens: 25, + cacheReadInputTokens: 10, + cacheCreationInputTokens: 5, + webSearchRequests: 0, + costUSD: 0.005, + contextWindow: 200000, + maxOutputTokens: 16384, + }, + }, + }), + { type: "system", subtype: "session_state_changed", state: "idle" }, + ]); + + await agent.prompt({ sessionId: "test-session", prompt: [{ type: "text", text: "test" }] }); + + const usageUpdate = updates.find((u: any) => u.update?.sessionUpdate === "usage_update"); + expect(usageUpdate).toBeDefined(); + // size should be 1000000 (Opus), not 200000 (min of both) + expect(usageUpdate.update.size).toBe(1000000); + }); + + it("after model switch, size updates to the new model's window", async () => { + const { agent, updates } = createMockAgentWithCapture(); + // Simulate: assistant on Sonnet with both models in modelUsage + injectSession(agent, [ + createAssistantMessage({ model: "claude-sonnet-4-20250514" }), + createResultMessageWithModel({ + modelUsage: { + "claude-opus-4-20250514": { + inputTokens: 100, + outputTokens: 50, + cacheReadInputTokens: 20, + cacheCreationInputTokens: 10, + webSearchRequests: 0, + costUSD: 0.01, + contextWindow: 1000000, + maxOutputTokens: 16384, + }, + "claude-sonnet-4-20250514": { + inputTokens: 50, + outputTokens: 25, + cacheReadInputTokens: 10, + cacheCreationInputTokens: 5, + webSearchRequests: 0, + costUSD: 0.005, + contextWindow: 200000, + maxOutputTokens: 16384, + }, + }, + }), + { type: "system", subtype: "session_state_changed", state: "idle" }, + ]); + + await agent.prompt({ sessionId: "test-session", prompt: [{ type: "text", text: "test" }] }); + + const usageUpdate = updates.find((u: any) => u.update?.sessionUpdate === "usage_update"); + expect(usageUpdate).toBeDefined(); + // size should be 200000 (Sonnet - the current model) + expect(usageUpdate.update.size).toBe(200000); + }); + + it("after switching back to original model, size returns to original window", async () => { + const { agent, updates } = createMockAgentWithCapture(); + // Last assistant message is Opus again + injectSession(agent, [ + createAssistantMessage({ model: "claude-sonnet-4-20250514" }), + createAssistantMessage({ model: "claude-opus-4-20250514" }), + createResultMessageWithModel({ + modelUsage: { + "claude-opus-4-20250514": { + inputTokens: 200, + outputTokens: 100, + cacheReadInputTokens: 40, + cacheCreationInputTokens: 20, + webSearchRequests: 0, + costUSD: 0.02, + contextWindow: 1000000, + maxOutputTokens: 16384, + }, + "claude-sonnet-4-20250514": { + inputTokens: 50, + outputTokens: 25, + cacheReadInputTokens: 10, + cacheCreationInputTokens: 5, + webSearchRequests: 0, + costUSD: 0.005, + contextWindow: 200000, + maxOutputTokens: 16384, + }, + }, + }), + { type: "system", subtype: "session_state_changed", state: "idle" }, + ]); + + await agent.prompt({ sessionId: "test-session", prompt: [{ type: "text", text: "test" }] }); + + const usageUpdate = updates.find((u: any) => u.update?.sessionUpdate === "usage_update"); + expect(usageUpdate).toBeDefined(); + // size should be 1000000 (Opus - switched back) + expect(usageUpdate.update.size).toBe(1000000); + }); + + it("subagent assistant messages do not affect size (top-level model is used)", async () => { + const { agent, updates } = createMockAgentWithCapture(); + // Top-level assistant on Opus, then subagent on Haiku (parent_tool_use_id set) + injectSession(agent, [ + createAssistantMessage({ model: "claude-opus-4-20250514" }), + { + type: "assistant" as const, + parent_tool_use_id: "tool_use_123", + uuid: randomUUID(), + session_id: "test-session", + message: { + model: "claude-haiku-4-5-20251001", + content: [{ type: "text", text: "subagent response" }], + usage: { + input_tokens: 50, + output_tokens: 25, + cache_read_input_tokens: 0, + cache_creation_input_tokens: 0, + }, + }, + }, + createResultMessageWithModel({ + modelUsage: { + "claude-opus-4-20250514": { + inputTokens: 100, + outputTokens: 50, + cacheReadInputTokens: 20, + cacheCreationInputTokens: 10, + webSearchRequests: 0, + costUSD: 0.01, + contextWindow: 1000000, + maxOutputTokens: 16384, + }, + "claude-haiku-4-5-20251001": { + inputTokens: 50, + outputTokens: 25, + cacheReadInputTokens: 0, + cacheCreationInputTokens: 0, + webSearchRequests: 0, + costUSD: 0.001, + contextWindow: 200000, + maxOutputTokens: 8192, + }, + }, + }), + { type: "system", subtype: "session_state_changed", state: "idle" }, + ]); + + await agent.prompt({ sessionId: "test-session", prompt: [{ type: "text", text: "test" }] }); + + const usageUpdate = updates.find((u: any) => u.update?.sessionUpdate === "usage_update"); + expect(usageUpdate).toBeDefined(); + // size should be 1000000 (Opus - the top-level model), NOT 200000 (Haiku subagent) + expect(usageUpdate.update.size).toBe(1000000); + }); + + it("prefix-matches when assistant model has date suffix but modelUsage key does not", async () => { + const { agent, updates } = createMockAgentWithCapture(); + // The API response has the full versioned model ID on assistant messages, + // but the SDK's streaming path may key modelUsage by the shorter alias. + injectSession(agent, [ + createAssistantMessage({ model: "claude-opus-4-6-20250514" }), + createResultMessageWithModel({ + modelUsage: { + "claude-opus-4-6": { + inputTokens: 100, + outputTokens: 50, + cacheReadInputTokens: 20, + cacheCreationInputTokens: 10, + webSearchRequests: 0, + costUSD: 0.01, + contextWindow: 1000000, + maxOutputTokens: 16384, + }, + }, + }), + { type: "system", subtype: "session_state_changed", state: "idle" }, + ]); + + await agent.prompt({ sessionId: "test-session", prompt: [{ type: "text", text: "test" }] }); + + const usageUpdate = updates.find((u: any) => u.update?.sessionUpdate === "usage_update"); + expect(usageUpdate).toBeDefined(); + // Should match via prefix: "claude-opus-4-6-20250514".startsWith("claude-opus-4-6") + expect(usageUpdate.update.size).toBe(1000000); + }); + + it("prefix-matches when modelUsage key has date suffix but assistant model does not", async () => { + const { agent, updates } = createMockAgentWithCapture(); + injectSession(agent, [ + createAssistantMessage({ model: "claude-opus-4-6" }), + createResultMessageWithModel({ + modelUsage: { + "claude-opus-4-6-20250514": { + inputTokens: 100, + outputTokens: 50, + cacheReadInputTokens: 20, + cacheCreationInputTokens: 10, + webSearchRequests: 0, + costUSD: 0.01, + contextWindow: 1000000, + maxOutputTokens: 16384, + }, + }, + }), + { type: "system", subtype: "session_state_changed", state: "idle" }, + ]); + + await agent.prompt({ sessionId: "test-session", prompt: [{ type: "text", text: "test" }] }); + + const usageUpdate = updates.find((u: any) => u.update?.sessionUpdate === "usage_update"); + expect(usageUpdate).toBeDefined(); + expect(usageUpdate.update.size).toBe(1000000); + }); + + it("synthetic assistant messages do not override lastAssistantModel", async () => { + const { agent, updates } = createMockAgentWithCapture(); + // Real assistant on Opus, then a synthetic message (e.g. from /compact) + injectSession(agent, [ + createAssistantMessage({ model: "claude-opus-4-20250514" }), + { + type: "assistant" as const, + parent_tool_use_id: null, + uuid: randomUUID(), + session_id: "test-session", + message: { + model: "", + content: [{ type: "text", text: "compacted" }], + usage: { + input_tokens: 0, + output_tokens: 0, + cache_read_input_tokens: 0, + cache_creation_input_tokens: 0, + }, + }, + }, + createResultMessageWithModel({ + modelUsage: { + "claude-opus-4-20250514": { + inputTokens: 100, + outputTokens: 50, + cacheReadInputTokens: 20, + cacheCreationInputTokens: 10, + webSearchRequests: 0, + costUSD: 0.01, + contextWindow: 1000000, + maxOutputTokens: 16384, + }, + }, + }), + { type: "system", subtype: "session_state_changed", state: "idle" }, + ]); + + await agent.prompt({ sessionId: "test-session", prompt: [{ type: "text", text: "test" }] }); + + const usageUpdate = updates.find((u: any) => u.update?.sessionUpdate === "usage_update"); + expect(usageUpdate).toBeDefined(); + // size should be 1000000 (Opus), not 200000 (the fallback if overrode the model) + expect(usageUpdate.update.size).toBe(1000000); + }); +}); + +describe("emitRawSDKMessages", () => { + function createMockAgentWithExtNotification() { + const updates: any[] = []; + const extNotifications: { method: string; params: any }[] = []; + const mockClient = { + sessionUpdate: async (notification: any) => { + updates.push(notification); + }, + extNotification: async (method: string, params: any) => { + extNotifications.push({ method, params }); + }, + } as unknown as AgentSideConnection; + const agent = new ClaudeAcpAgent(mockClient, { log: () => {}, error: () => {} }); + return { agent, updates, extNotifications }; + } + + function injectSession( + agent: ClaudeAcpAgent, + messages: any[], + emitRawSDKMessages: boolean | SDKMessageFilter[], + ) { + const input = new Pushable(); + async function* messageGenerator() { + const iter = input[Symbol.asyncIterator](); + const { value: userMessage, done } = await iter.next(); + if (!done && userMessage) { + yield { + type: "user", + message: userMessage.message, + parent_tool_use_id: null, + uuid: userMessage.uuid, + session_id: "test-session", + isReplay: true, + }; + } + yield* messages; + } + agent.sessions["test-session"] = { + query: messageGenerator() as any, + input, + cancelled: false, + cwd: "/test", + sessionFingerprint: JSON.stringify({ cwd: "/test", mcpServers: [] }), + modes: { currentModeId: "default", availableModes: [] }, + models: { currentModelId: "default", availableModels: [] }, + modelInfos: [], + settingsManager: { dispose: vi.fn() } as any, + accumulatedUsage: { + inputTokens: 0, + outputTokens: 0, + cachedReadTokens: 0, + cachedWriteTokens: 0, + }, + configOptions: [], + promptRunning: false, + pendingMessages: new Map(), + nextPendingOrder: 0, + abortController: new AbortController(), + emitRawSDKMessages, + contextWindowSize: 200000, + taskState: new Map(), + }; + } + + function createResultMessage() { + return { + type: "result" as const, + subtype: "success" as const, + is_error: false, + result: "", + errors: [], + stop_reason: "end_turn" as const, + cost_usd: 0, + duration_ms: 0, + duration_api_ms: 0, + num_turns: 1, + total_cost_usd: 0, + usage: { + input_tokens: 10, + output_tokens: 5, + cache_read_input_tokens: 0, + cache_creation_input_tokens: 0, + }, + modelUsage: {}, + permission_denials: [], + uuid: randomUUID(), + session_id: "test-session", + }; + } + + it("emits all raw messages when set to true", async () => { + const { agent, extNotifications } = createMockAgentWithExtNotification(); + const systemMsg = { + type: "system", + subtype: "status", + status: "compacting", + session_id: "test-session", + }; + injectSession( + agent, + [ + systemMsg, + createResultMessage(), + { type: "system", subtype: "session_state_changed", state: "idle" }, + ], + true, + ); + + await agent.prompt({ sessionId: "test-session", prompt: [{ type: "text", text: "test" }] }); + + // Should have emitted extNotifications for all messages (user replay + system + result + session_state_changed) + expect(extNotifications.length).toBeGreaterThanOrEqual(3); + expect(extNotifications.every((n) => n.method === "_claude/sdkMessage")).toBe(true); + }); + + it("does not emit when set to false", async () => { + const { agent, extNotifications } = createMockAgentWithExtNotification(); + injectSession( + agent, + [ + { type: "system", subtype: "status", status: "compacting", session_id: "test-session" }, + createResultMessage(), + { type: "system", subtype: "session_state_changed", state: "idle" }, + ], + false, + ); + + await agent.prompt({ sessionId: "test-session", prompt: [{ type: "text", text: "test" }] }); + + expect(extNotifications).toHaveLength(0); + }); + + it("emits only messages matching a filter array", async () => { + const { agent, extNotifications } = createMockAgentWithExtNotification(); + injectSession( + agent, + [ + { type: "system", subtype: "compact_boundary", session_id: "test-session" }, + { type: "system", subtype: "status", status: "compacting", session_id: "test-session" }, + createResultMessage(), + { type: "system", subtype: "session_state_changed", state: "idle" }, + ], + [{ type: "system", subtype: "compact_boundary" }], + ); + + await agent.prompt({ sessionId: "test-session", prompt: [{ type: "text", text: "test" }] }); + + // Only the compact_boundary message should have been emitted + const sdkMessages = extNotifications.filter((n) => n.method === "_claude/sdkMessage"); + expect(sdkMessages).toHaveLength(1); + expect(sdkMessages[0].params.sessionId).toBe("test-session"); + expect(sdkMessages[0].params.message.type).toBe("system"); + expect(sdkMessages[0].params.message.subtype).toBe("compact_boundary"); + }); + + it("filter without subtype matches all messages of that type", async () => { + const { agent, extNotifications } = createMockAgentWithExtNotification(); + injectSession( + agent, + [ + { type: "system", subtype: "compact_boundary", session_id: "test-session" }, + { type: "system", subtype: "status", status: "compacting", session_id: "test-session" }, + createResultMessage(), + { type: "system", subtype: "session_state_changed", state: "idle" }, + ], + [{ type: "system" }], + ); + + await agent.prompt({ sessionId: "test-session", prompt: [{ type: "text", text: "test" }] }); + + const sdkMessages = extNotifications.filter((n) => n.method === "_claude/sdkMessage"); + // All system messages should match (compact_boundary + status + session_state_changed) + const systemMessages = sdkMessages.filter((n) => n.params.message.type === "system"); + expect(systemMessages).toHaveLength(3); + }); + + it("supports multiple filters", async () => { + const { agent, extNotifications } = createMockAgentWithExtNotification(); + injectSession( + agent, + [ + { type: "system", subtype: "compact_boundary", session_id: "test-session" }, + { type: "system", subtype: "status", status: "compacting", session_id: "test-session" }, + createResultMessage(), + { type: "system", subtype: "session_state_changed", state: "idle" }, + ], + [{ type: "system", subtype: "compact_boundary" }, { type: "result" }], + ); + + await agent.prompt({ sessionId: "test-session", prompt: [{ type: "text", text: "test" }] }); + + const sdkMessages = extNotifications.filter((n) => n.method === "_claude/sdkMessage"); + expect(sdkMessages).toHaveLength(2); + expect(sdkMessages[0].params.message.type).toBe("system"); + expect(sdkMessages[0].params.message.subtype).toBe("compact_boundary"); + expect(sdkMessages[1].params.message.type).toBe("result"); + }); + + it("filter by origin kind only emits matching results", async () => { + const { agent, extNotifications } = createMockAgentWithExtNotification(); + injectSession( + agent, + [ + { ...createResultMessage(), origin: { kind: "channel", server: "acp" } }, + { ...createResultMessage(), origin: { kind: "task-notification" } }, + { type: "system", subtype: "session_state_changed", state: "idle" }, + ], + [{ type: "result", origin: "task-notification" }], + ); + + await agent.prompt({ sessionId: "test-session", prompt: [{ type: "text", text: "test" }] }); + + const sdkMessages = extNotifications.filter((n) => n.method === "_claude/sdkMessage"); + expect(sdkMessages).toHaveLength(1); + expect(sdkMessages[0].params.message.origin.kind).toBe("task-notification"); + }); + + it("filter without origin matches results regardless of origin", async () => { + const { agent, extNotifications } = createMockAgentWithExtNotification(); + injectSession( + agent, + [ + { ...createResultMessage(), origin: { kind: "channel", server: "acp" } }, + { ...createResultMessage(), origin: { kind: "task-notification" } }, + { type: "system", subtype: "session_state_changed", state: "idle" }, + ], + [{ type: "result" }], + ); + + await agent.prompt({ sessionId: "test-session", prompt: [{ type: "text", text: "test" }] }); + + const sdkMessages = extNotifications.filter((n) => n.method === "_claude/sdkMessage"); + expect(sdkMessages).toHaveLength(2); + }); +}); + +describe("result origin handling", () => { + function createMockAgentWithCapture() { + const updates: any[] = []; + const mockClient = { + sessionUpdate: async (notification: any) => { + updates.push(notification); + }, + } as unknown as AgentSideConnection; + const agent = new ClaudeAcpAgent(mockClient, { log: () => {}, error: () => {} }); + return { agent, updates }; + } + + function injectSession(agent: ClaudeAcpAgent, messages: any[]) { + const input = new Pushable(); + async function* messageGenerator() { + const iter = input[Symbol.asyncIterator](); + const { value: userMessage, done } = await iter.next(); + if (!done && userMessage) { + yield { + type: "user", + message: userMessage.message, + parent_tool_use_id: null, + uuid: userMessage.uuid, + session_id: "test-session", + isReplay: true, + }; + } + yield* messages; + } + agent.sessions["test-session"] = { + query: messageGenerator() as any, + input, + cancelled: false, + cwd: "/test", + sessionFingerprint: JSON.stringify({ cwd: "/test", mcpServers: [] }), + modes: { currentModeId: "default", availableModes: [] }, + models: { currentModelId: "default", availableModels: [] }, + modelInfos: [], + settingsManager: { dispose: vi.fn() } as any, + accumulatedUsage: { + inputTokens: 0, + outputTokens: 0, + cachedReadTokens: 0, + cachedWriteTokens: 0, + }, + configOptions: [], + promptRunning: false, + pendingMessages: new Map(), + nextPendingOrder: 0, + abortController: new AbortController(), + emitRawSDKMessages: false, + contextWindowSize: 200000, + taskState: new Map(), + }; + } + + function createAssistantMessage() { + return { + type: "assistant" as const, + parent_tool_use_id: null, + uuid: randomUUID(), + session_id: "test-session", + message: { + model: "claude-sonnet-4-6", + content: [{ type: "text", text: "hello" }], + usage: { + input_tokens: 10, + output_tokens: 5, + cache_read_input_tokens: 0, + cache_creation_input_tokens: 0, + }, + }, + }; + } + + function createResult(overrides: Record = {}) { + return { + type: "result" as const, + subtype: "success" as const, + stop_reason: "end_turn", + is_error: false, + result: "", + errors: [], + duration_ms: 0, + duration_api_ms: 0, + num_turns: 1, + total_cost_usd: 0.01, + usage: { + input_tokens: 10, + output_tokens: 5, + cache_read_input_tokens: 0, + cache_creation_input_tokens: 0, + }, + modelUsage: {}, + permission_denials: [], + uuid: randomUUID(), + session_id: "test-session", + ...overrides, + }; + } + + it("forwards origin in usage_update _meta", async () => { + const { agent, updates } = createMockAgentWithCapture(); + injectSession(agent, [ + createAssistantMessage(), + createResult({ origin: { kind: "channel", server: "acp" } }), + { type: "system", subtype: "session_state_changed", state: "idle" }, + ]); + + await agent.prompt({ sessionId: "test-session", prompt: [{ type: "text", text: "test" }] }); + + const usageUpdate = updates.find((u: any) => u.update?.sessionUpdate === "usage_update"); + expect(usageUpdate).toBeDefined(); + expect(usageUpdate.update._meta).toEqual({ + "_claude/origin": { kind: "channel", server: "acp" }, + }); + }); + + it("omits _meta when origin is absent", async () => { + const { agent, updates } = createMockAgentWithCapture(); + injectSession(agent, [ + createAssistantMessage(), + createResult(), + { type: "system", subtype: "session_state_changed", state: "idle" }, + ]); + + await agent.prompt({ sessionId: "test-session", prompt: [{ type: "text", text: "test" }] }); + + const usageUpdate = updates.find((u: any) => u.update?.sessionUpdate === "usage_update"); + expect(usageUpdate).toBeDefined(); + expect(usageUpdate.update._meta).toBeUndefined(); + }); + + it("task-notification result with max_tokens does not override the user-turn stopReason", async () => { + const { agent } = createMockAgentWithCapture(); + injectSession(agent, [ + createAssistantMessage(), + // User-turn result completes normally + createResult({ origin: { kind: "channel", server: "acp" } }), + // Task-notification followup hits max_tokens — must not bleed into the user's stopReason + createResult({ + stop_reason: "max_tokens", + origin: { kind: "task-notification" }, + }), + { type: "system", subtype: "session_state_changed", state: "idle" }, + ]); + + const response = await agent.prompt({ + sessionId: "test-session", + prompt: [{ type: "text", text: "test" }], + }); + + expect(response.stopReason).toBe("end_turn"); + }); + + it("user-prompted result with max_tokens still sets stopReason", async () => { + const { agent } = createMockAgentWithCapture(); + injectSession(agent, [ + createAssistantMessage(), + createResult({ + stop_reason: "max_tokens", + origin: { kind: "channel", server: "acp" }, + }), + { type: "system", subtype: "session_state_changed", state: "idle" }, + ]); + + const response = await agent.prompt({ + sessionId: "test-session", + prompt: [{ type: "text", text: "test" }], + }); + + expect(response.stopReason).toBe("max_tokens"); + }); +}); + +describe("memory_recall handling", () => { + function createMockAgentWithCapture() { + const updates: any[] = []; + const mockClient = { + sessionUpdate: async (notification: any) => { + updates.push(notification); + }, + } as unknown as AgentSideConnection; + const agent = new ClaudeAcpAgent(mockClient, { log: () => {}, error: () => {} }); + return { agent, updates }; + } + + function injectSession(agent: ClaudeAcpAgent, messages: any[]) { + const input = new Pushable(); + async function* messageGenerator() { + const iter = input[Symbol.asyncIterator](); + const { value: userMessage, done } = await iter.next(); + if (!done && userMessage) { + yield { + type: "user", + message: userMessage.message, + parent_tool_use_id: null, + uuid: userMessage.uuid, + session_id: "test-session", + isReplay: true, + }; + } + yield* messages; + } + agent.sessions["test-session"] = { + query: messageGenerator() as any, + input, + cancelled: false, + cwd: "/test", + sessionFingerprint: JSON.stringify({ cwd: "/test", mcpServers: [] }), + modes: { currentModeId: "default", availableModes: [] }, + models: { currentModelId: "default", availableModels: [] }, + modelInfos: [], + settingsManager: { dispose: vi.fn() } as any, + accumulatedUsage: { + inputTokens: 0, + outputTokens: 0, + cachedReadTokens: 0, + cachedWriteTokens: 0, + }, + configOptions: [], + promptRunning: false, + pendingMessages: new Map(), + nextPendingOrder: 0, + abortController: new AbortController(), + emitRawSDKMessages: false, + contextWindowSize: 200000, + taskState: new Map(), + }; + } + + function createResult() { + return { + type: "result" as const, + subtype: "success" as const, + stop_reason: "end_turn", + is_error: false, + result: "", + errors: [], + duration_ms: 0, + duration_api_ms: 0, + num_turns: 1, + total_cost_usd: 0, + usage: { + input_tokens: 10, + output_tokens: 5, + cache_read_input_tokens: 0, + cache_creation_input_tokens: 0, + }, + modelUsage: {}, + permission_denials: [], + uuid: randomUUID(), + session_id: "test-session", + }; + } + + it("emits a synthetic tool_call for select mode with one location per memory", async () => { + const { agent, updates } = createMockAgentWithCapture(); + const recallUuid = randomUUID(); + injectSession(agent, [ + { + type: "system", + subtype: "memory_recall", + mode: "select", + memories: [ + { path: "/Users/test/.claude/memory/user_role.md", scope: "personal" }, + { path: "/Users/test/.claude/memory/feedback_testing.md", scope: "personal" }, + { path: "/Users/test/.claude/team/conventions.md", scope: "team" }, + ], + uuid: recallUuid, + session_id: "test-session", + }, + createResult(), + { type: "system", subtype: "session_state_changed", state: "idle" }, + ]); + + await agent.prompt({ sessionId: "test-session", prompt: [{ type: "text", text: "hi" }] }); + + const toolCall = updates.find((u: any) => u.update?.sessionUpdate === "tool_call"); + expect(toolCall).toBeDefined(); + expect(toolCall.update).toMatchObject({ + sessionUpdate: "tool_call", + toolCallId: recallUuid, + title: "Recalled 3 memories", + kind: "read", + status: "completed", + locations: [ + { path: "/Users/test/.claude/memory/user_role.md" }, + { path: "/Users/test/.claude/memory/feedback_testing.md" }, + { path: "/Users/test/.claude/team/conventions.md" }, + ], + _meta: { + claudeCode: { toolName: "memory_recall", toolResponse: { mode: "select" } }, + }, + }); + expect(toolCall.update.content).toBeUndefined(); + }); + + it("uses singular 'memory' in title when exactly one entry", async () => { + const { agent, updates } = createMockAgentWithCapture(); + injectSession(agent, [ + { + type: "system", + subtype: "memory_recall", + mode: "select", + memories: [{ path: "/Users/test/.claude/memory/user_role.md", scope: "personal" }], + uuid: randomUUID(), + session_id: "test-session", + }, + createResult(), + { type: "system", subtype: "session_state_changed", state: "idle" }, + ]); + + await agent.prompt({ sessionId: "test-session", prompt: [{ type: "text", text: "hi" }] }); + + const toolCall = updates.find((u: any) => u.update?.sessionUpdate === "tool_call"); + expect(toolCall.update.title).toBe("Recalled 1 memory"); + }); + + it("emits synthesis content and no locations for synthesize mode", async () => { + const { agent, updates } = createMockAgentWithCapture(); + injectSession(agent, [ + { + type: "system", + subtype: "memory_recall", + mode: "synthesize", + memories: [ + { + path: "", + scope: "personal", + content: "The user prefers terse responses and writes Go.", + }, + ], + uuid: randomUUID(), + session_id: "test-session", + }, + createResult(), + { type: "system", subtype: "session_state_changed", state: "idle" }, + ]); + + await agent.prompt({ sessionId: "test-session", prompt: [{ type: "text", text: "hi" }] }); + + const toolCall = updates.find((u: any) => u.update?.sessionUpdate === "tool_call"); + expect(toolCall).toBeDefined(); + expect(toolCall.update.title).toBe("Recalled synthesized memory"); + expect(toolCall.update.locations).toBeUndefined(); + expect(toolCall.update.content).toEqual([ + { + type: "content", + content: { type: "text", text: "The user prefers terse responses and writes Go." }, + }, + ]); + expect(toolCall.update._meta.claudeCode.toolResponse).toEqual({ mode: "synthesize" }); + }); +}); + +describe("post-error recovery", () => { + function createMockAgent() { + const mockClient = { + sessionUpdate: async () => {}, + } as unknown as AgentSideConnection; + return new ClaudeAcpAgent(mockClient, { log: () => {}, error: () => {} }); + } + + function createResultMessage(overrides: { + subtype: "success" | "error_during_execution"; + stop_reason: string | null; + is_error: boolean; + result?: string; + errors?: string[]; + }) { + return { + type: "result" as const, + subtype: overrides.subtype, + stop_reason: overrides.stop_reason, + is_error: overrides.is_error, + result: overrides.result ?? "", + errors: overrides.errors ?? [], + duration_ms: 0, + duration_api_ms: 0, + num_turns: 1, + total_cost_usd: 0, + usage: { + input_tokens: 10, + output_tokens: 5, + cache_read_input_tokens: 0, + cache_creation_input_tokens: 0, + }, + modelUsage: {}, + permission_denials: [], + uuid: randomUUID(), + session_id: "test-session", + }; + } + + // Two-turn generator: turn 1 yields the caller-supplied `firstTurn` + // messages (including a trailing idle that the drain must consume). + // Turn 2 yields a clean success + idle, used to verify the next prompt + // sees real messages rather than the stale idle. + function injectTwoTurnSession(agent: ClaudeAcpAgent, firstTurn: unknown[]) { + const input = new Pushable(); + const interrupt = vi.fn(async () => {}); + const close = vi.fn(); + async function* messageGenerator() { + const iter = input[Symbol.asyncIterator](); + + const first = await iter.next(); + if (!first.done && first.value) { + yield { + type: "user", + message: first.value.message, + parent_tool_use_id: null, + uuid: first.value.uuid, + session_id: "test-session", + isReplay: true, + }; + } + yield* firstTurn; + + const second = await iter.next(); + if (!second.done && second.value) { + yield { + type: "user", + message: second.value.message, + parent_tool_use_id: null, + uuid: second.value.uuid, + session_id: "test-session", + isReplay: true, + }; + } + yield createResultMessage({ subtype: "success", stop_reason: null, is_error: false }); + yield { type: "system", subtype: "session_state_changed", state: "idle" }; + } + const gen = Object.assign(messageGenerator(), { interrupt, close }); + agent.sessions["test-session"] = { + query: gen as any, + input, + cancelled: false, + cwd: "/test", + sessionFingerprint: JSON.stringify({ cwd: "/test", mcpServers: [] }), + modes: { currentModeId: "default", availableModes: [] }, + models: { currentModelId: "default", availableModels: [] }, + modelInfos: [], + settingsManager: { dispose: vi.fn() } as any, + accumulatedUsage: { + inputTokens: 0, + outputTokens: 0, + cachedReadTokens: 0, + cachedWriteTokens: 0, + }, + configOptions: [], + promptRunning: false, + pendingMessages: new Map(), + nextPendingOrder: 0, + abortController: new AbortController(), + emitRawSDKMessages: false, + contextWindowSize: 200000, + taskState: new Map(), + }; + return { interrupt }; + } + + it("drains a failed turn's trailing idle so the next prompt is not short-circuited", async () => { + const agent = createMockAgent(); + const { interrupt } = injectTwoTurnSession(agent, [ + createResultMessage({ + subtype: "success", + stop_reason: "end_turn", + is_error: true, + result: "boom", + }), + // Trailing idle from the failed turn. Without draining, the next + // prompt's first query.next() would consume this and short-circuit + // to end_turn with zero usage (issue #654). + { type: "system", subtype: "session_state_changed", state: "idle" }, + ]); + + await expect( + agent.prompt({ + sessionId: "test-session", + prompt: [{ type: "text", text: "first" }], + }), + ).rejects.toThrow(); + + expect(interrupt).toHaveBeenCalled(); + + const second = await agent.prompt({ + sessionId: "test-session", + prompt: [{ type: "text", text: "second" }], + }); + expect(second.stopReason).toBe("end_turn"); + expect(second.usage?.inputTokens).toBe(10); + expect(second.usage?.outputTokens).toBe(5); + }); + + it("cancels all queued pending prompts when a turn errors", async () => { + const agent = createMockAgent(); + injectTwoTurnSession(agent, [ + createResultMessage({ + subtype: "success", + stop_reason: "end_turn", + is_error: true, + result: "boom", + }), + { type: "system", subtype: "session_state_changed", state: "idle" }, + ]); + + // Simulate two prompts already queued behind the running turn. Both + // resolvers should fire with `true` (cancelled) when the running + // prompt errors, and the map should be cleared. + const session = agent.sessions["test-session"]; + let resolveA!: (cancelled: boolean) => void; + let resolveB!: (cancelled: boolean) => void; + const pendingA = new Promise((r) => (resolveA = r)); + const pendingB = new Promise((r) => (resolveB = r)); + session.pendingMessages.set("uuid-a", { resolve: resolveA, order: 0 }); + session.pendingMessages.set("uuid-b", { resolve: resolveB, order: 1 }); + + await expect( + agent.prompt({ + sessionId: "test-session", + prompt: [{ type: "text", text: "first" }], + }), + ).rejects.toThrow(); + + await expect(pendingA).resolves.toBe(true); + await expect(pendingB).resolves.toBe(true); + expect(session.pendingMessages.size).toBe(0); + }); +}); + +describe("streamEventToAcpNotifications", () => { + it("treats `ping` keep-alive events as no-ops without logging to stderr", () => { + const errors: unknown[][] = []; + const logger = { + log: () => {}, + error: (...args: unknown[]) => { + errors.push(args); + }, + }; + const pingMessage = { + type: "stream_event", + parent_tool_use_id: null, + uuid: randomUUID(), + session_id: "test-session", + // The SDK's typed `BetaRawMessageStreamEvent` union doesn't include + // `ping`, but the API emits it on the wire and the SDK passes it + // through. Cast through `unknown` to feed the realistic runtime shape. + event: { type: "ping" } as unknown, + } as Parameters[0]; + + const result = streamEventToAcpNotifications( + pingMessage, + "test-session", + {}, + { sessionUpdate: async () => {} } as unknown as Parameters< + typeof streamEventToAcpNotifications + >[3], + logger, + ); + + expect(result).toEqual([]); + expect(errors).toEqual([]); + }); +}); diff --git a/qiming-claude-code-acp-ts/src/tests/additional-roots.test.ts b/qiming-claude-code-acp-ts/src/tests/additional-roots.test.ts new file mode 100644 index 00000000..f410f690 --- /dev/null +++ b/qiming-claude-code-acp-ts/src/tests/additional-roots.test.ts @@ -0,0 +1,108 @@ +import { mkdtemp, rm } from "node:fs/promises"; +import * as os from "node:os"; +import * as path from "node:path"; +import { AgentSideConnection, SessionNotification } from "@agentclientprotocol/sdk"; +import type { Options } from "@anthropic-ai/claude-agent-sdk"; +import type { ClaudeAcpAgent as ClaudeAcpAgentType } from "../acp-agent.js"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +let capturedOptions: Options | undefined; +vi.mock("@anthropic-ai/claude-agent-sdk", async () => ({ + ...(await vi.importActual( + "@anthropic-ai/claude-agent-sdk", + )), + query: ({ options }: { options: Options }) => { + capturedOptions = options; + return { + initializationResult: async () => ({ + models: [ + { + value: "claude-sonnet-4-6", + displayName: "Claude Sonnet", + description: "Fast", + supportsAutoMode: true, + }, + ], + }), + setModel: async () => {}, + setPermissionMode: async () => {}, + supportedCommands: async () => [], + [Symbol.asyncIterator]: async function* () {}, + }; + }, +})); +vi.mock("../tools.js", async () => ({ + ...(await vi.importActual("../tools.js")), + registerHookCallback: vi.fn(), +})); + +describe("additionalRoots", () => { + let agent: ClaudeAcpAgentType; + const tempDirs: string[] = []; + const newSession = (meta: Record, cwd = "/test") => + agent.newSession({ cwd, mcpServers: [], _meta: meta }); + + beforeEach(async () => { + capturedOptions = undefined; + tempDirs.length = 0; + vi.resetModules(); + const { ClaudeAcpAgent } = await import("../acp-agent.js"); + agent = new ClaudeAcpAgent({ + sessionUpdate: async (_notification: SessionNotification) => {}, + requestPermission: async () => ({ outcome: { outcome: "cancelled" } }), + readTextFile: async () => ({ content: "" }), + writeTextFile: async () => ({}), + } as unknown as AgentSideConnection); + }); + + afterEach( + async () => + void (await Promise.all(tempDirs.map((dir) => rm(dir, { recursive: true, force: true })))), + ); + + it("passes through relative roots as provided", async () => { + const projectRoot = await mkdtemp(path.join(os.tmpdir(), "claude-project-")); + tempDirs.push(projectRoot); + await newSession({ additionalRoots: ["."] }, projectRoot); + expect(capturedOptions!.additionalDirectories).toEqual(["."]); + }); + + it("merges additionalRoots with user additionalDirectories without normalization", async () => { + const root = await mkdtemp(path.join(os.tmpdir(), "claude-root-")); + tempDirs.push(root); + await newSession({ + additionalRoots: ["", root], + claudeCode: { options: { additionalDirectories: ["/workspace/shared"] } }, + }); + expect(capturedOptions!.additionalDirectories).toEqual(["/workspace/shared", "", root]); + }); + + it("prefers the official ACP additionalDirectories field over _meta.additionalRoots", async () => { + await agent.newSession({ + cwd: "/test", + mcpServers: [], + additionalDirectories: ["/from/official"], + _meta: { additionalRoots: ["/from/meta"] }, + }); + expect(capturedOptions!.additionalDirectories).toEqual(["/from/official"]); + }); + + it("merges official ACP additionalDirectories with claudeCode SDK additionalDirectories", async () => { + await agent.newSession({ + cwd: "/test", + mcpServers: [], + additionalDirectories: ["/from/official"], + _meta: { claudeCode: { options: { additionalDirectories: ["/from/sdk"] } } }, + }); + expect(capturedOptions!.additionalDirectories).toEqual(["/from/sdk", "/from/official"]); + }); + + it("falls back to _meta.additionalRoots when the official field is omitted", async () => { + await agent.newSession({ + cwd: "/test", + mcpServers: [], + _meta: { additionalRoots: ["/from/meta"] }, + }); + expect(capturedOptions!.additionalDirectories).toEqual(["/from/meta"]); + }); +}); diff --git a/qiming-claude-code-acp-ts/src/tests/authorization.test.ts b/qiming-claude-code-acp-ts/src/tests/authorization.test.ts new file mode 100644 index 00000000..d9a36c7b --- /dev/null +++ b/qiming-claude-code-acp-ts/src/tests/authorization.test.ts @@ -0,0 +1,296 @@ +import { describe, expect, it, Mock, vi, afterEach, beforeEach } from "vitest"; +import { ClaudeAcpAgent } from "../acp-agent.js"; +import { AgentSideConnection } from "@agentclientprotocol/sdk"; + +const mockQuery = vi.hoisted(() => + vi.fn(() => ({ + initializationResult: vi.fn().mockResolvedValue({ + models: [ + { value: "id", displayName: "name", description: "description", supportsAutoMode: true }, + ], + }), + setModel: vi.fn(), + setPermissionMode: vi.fn(), + supportedCommands: vi.fn().mockResolvedValue([]), + })), +); + +vi.mock("@anthropic-ai/claude-agent-sdk", () => ({ + query: mockQuery, +})); + +describe("authorization", () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + //await all pending events like + vi.runAllTimers(); + vi.useRealTimers(); + + vi.unstubAllGlobals(); + vi.resetAllMocks(); + }); + + async function createAgentMock(): Promise<[ClaudeAcpAgent, Mock]> { + const connectionMock = { + sessionUpdate: async (_: any) => {}, + } as AgentSideConnection; + + const agent = new ClaudeAcpAgent(connectionMock); + + return [agent, mockQuery]; + } + + it("gateway auth not offered without capability", async () => { + const [agent] = await createAgentMock(); + + const initializeResponse = await agent.initialize({ + protocolVersion: 1, + clientCapabilities: {}, + }); + expect(initializeResponse.authMethods).not.toContainEqual( + expect.objectContaining({ id: "gateway" }), + ); + expect(initializeResponse.authMethods).not.toContainEqual( + expect.objectContaining({ id: "gateway-bedrock" }), + ); + }); + + it("gateway auth offered when client advertises auth._meta.gateway capability", async () => { + const [agent] = await createAgentMock(); + + const initializeResponse = await agent.initialize({ + protocolVersion: 1, + clientCapabilities: { + auth: { _meta: { gateway: true } }, + } as any, + }); + expect(initializeResponse.authMethods).toContainEqual( + expect.objectContaining({ + id: "gateway", + _meta: { gateway: { protocol: "anthropic" } }, + }), + ); + expect(initializeResponse.authMethods).toContainEqual( + expect.objectContaining({ + id: "gateway-bedrock", + _meta: { gateway: { protocol: "bedrock" } }, + }), + ); + }); + + it("uses gateway env after anthropic gateway auth", async () => { + const [agent, mockQuery] = await createAgentMock(); + + const initializeResponse = await agent.initialize({ + protocolVersion: 1, + clientCapabilities: { + auth: { terminal: true, _meta: { gateway: true } }, + } as any, + }); + expect(initializeResponse.authMethods).toContainEqual( + expect.objectContaining({ id: "gateway" }), + ); + + await agent.authenticate({ + methodId: "gateway", + _meta: { gateway: { baseUrl: "https://gateway.example", headers: { "x-api-key": "test" } } }, + }); + + await agent.newSession({ + cwd: "testRoot", + mcpServers: [], + _meta: { + claudeCode: { + options: { + env: { + userEnv: "userEnv", + }, + }, + }, + }, + }); + + expect(mockQuery).toHaveBeenCalledWith( + expect.objectContaining({ + options: expect.objectContaining({ + env: expect.objectContaining({ + ANTHROPIC_AUTH_TOKEN: " ", + ANTHROPIC_BASE_URL: "https://gateway.example", + ANTHROPIC_CUSTOM_HEADERS: "x-api-key: test", + userEnv: "userEnv", + }), + }), + }), + ); + }); + + it("uses gateway env after gateway auth", async () => { + const [agent, mockQuery] = await createAgentMock(); + + await agent.initialize({ + protocolVersion: 1, + clientCapabilities: { + auth: { terminal: true, _meta: { gateway: true } }, + } as any, + }); + + await agent.authenticate({ + methodId: "gateway-bedrock", + _meta: { + gateway: { baseUrl: "https://gateway.example", headers: { "custom-header": "test" } }, + }, + }); + + await agent.newSession({ + cwd: "testRoot", + mcpServers: [], + }); + + expect(mockQuery).toHaveBeenCalledWith( + expect.objectContaining({ + options: expect.objectContaining({ + env: expect.objectContaining({ + CLAUDE_CODE_USE_BEDROCK: "1", + AWS_BEARER_TOKEN_BEDROCK: " ", + ANTHROPIC_BEDROCK_BASE_URL: "https://gateway.example", + ANTHROPIC_CUSTOM_HEADERS: "custom-header: test", + }), + }), + }), + ); + }); + + it("hide claude authentication without terminal-auth", async () => { + const [agent] = await createAgentMock(); + vi.stubGlobal("process", { ...process, argv: ["--hide-claude-auth"] }); + + const initializeResponse = await agent.initialize({ + protocolVersion: 1, + clientCapabilities: { + auth: { _meta: { gateway: true } }, + } as any, + }); + expect(initializeResponse.authMethods).not.toContainEqual( + expect.objectContaining({ id: "claude-ai-login" }), + ); + expect(initializeResponse.authMethods).not.toContainEqual( + expect.objectContaining({ id: "console-login" }), + ); + expect(initializeResponse.authMethods).toContainEqual( + expect.objectContaining({ id: "gateway" }), + ); + }); + + it("hide claude auth but still show console login when terminal-auth is set", async () => { + const [agent] = await createAgentMock(); + vi.stubGlobal("process", { ...process, argv: ["--hide-claude-auth"] }); + + const initializeResponse = await agent.initialize({ + protocolVersion: 1, + clientCapabilities: { + _meta: { "terminal-auth": true }, + }, + }); + expect(initializeResponse.authMethods).not.toContainEqual( + expect.objectContaining({ id: "claude-ai-login" }), + ); + expect(initializeResponse.authMethods).toContainEqual( + expect.objectContaining({ id: "console-login" }), + ); + }); + + it("hide claude auth but still show console login with terminal capability", async () => { + const [agent] = await createAgentMock(); + vi.stubGlobal("process", { ...process, argv: ["--hide-claude-auth"] }); + + const initializeResponse = await agent.initialize({ + protocolVersion: 1, + clientCapabilities: { + auth: { terminal: true }, + }, + }); + expect(initializeResponse.authMethods).not.toContainEqual( + expect.objectContaining({ id: "claude-ai-login" }), + ); + expect(initializeResponse.authMethods).toContainEqual( + expect.objectContaining({ id: "console-login" }), + ); + }); + + it("SSH session falls back to single legacy login method", async () => { + const [agent] = await createAgentMock(); + vi.stubGlobal("process", { ...process, env: { ...process.env, SSH_TTY: "/dev/pts/0" } }); + + const initializeResponse = await agent.initialize({ + protocolVersion: 1, + clientCapabilities: { auth: { terminal: true } }, + }); + + expect(initializeResponse.authMethods).toContainEqual( + expect.objectContaining({ id: "claude-login" }), + ); + expect(initializeResponse.authMethods).not.toContainEqual( + expect.objectContaining({ id: "claude-ai-login" }), + ); + expect(initializeResponse.authMethods).not.toContainEqual( + expect.objectContaining({ id: "console-login" }), + ); + }); + + it("CLAUDE_CODE_REMOTE falls back to single legacy login method", async () => { + const [agent] = await createAgentMock(); + vi.stubGlobal("process", { ...process, env: { ...process.env, CLAUDE_CODE_REMOTE: "1" } }); + + const initializeResponse = await agent.initialize({ + protocolVersion: 1, + clientCapabilities: { auth: { terminal: true } }, + }); + + expect(initializeResponse.authMethods).toContainEqual( + expect.objectContaining({ id: "claude-login" }), + ); + expect(initializeResponse.authMethods).not.toContainEqual( + expect.objectContaining({ id: "claude-ai-login" }), + ); + expect(initializeResponse.authMethods).not.toContainEqual( + expect.objectContaining({ id: "console-login" }), + ); + }); + + it("remote environment respects hide-claude-auth", async () => { + const [agent] = await createAgentMock(); + vi.stubGlobal("process", { + ...process, + argv: ["--hide-claude-auth"], + env: { ...process.env, SSH_CONNECTION: "192.168.1.1 12345 192.168.1.2 22" }, + }); + + const initializeResponse = await agent.initialize({ + protocolVersion: 1, + clientCapabilities: { auth: { terminal: true } }, + }); + + expect(initializeResponse.authMethods).not.toContainEqual( + expect.objectContaining({ id: "claude-login" }), + ); + }); + + it("show claude authentication", async () => { + const [agent] = await createAgentMock(); + + const initializeResponse = await agent.initialize({ + protocolVersion: 1, + clientCapabilities: { auth: { terminal: true } }, + }); + + expect(initializeResponse.authMethods).toContainEqual( + expect.objectContaining({ id: "claude-ai-login" }), + ); + expect(initializeResponse.authMethods).toContainEqual( + expect.objectContaining({ id: "console-login" }), + ); + }); +}); diff --git a/qiming-claude-code-acp-ts/src/tests/create-session-options.test.ts b/qiming-claude-code-acp-ts/src/tests/create-session-options.test.ts new file mode 100644 index 00000000..da0add5b --- /dev/null +++ b/qiming-claude-code-acp-ts/src/tests/create-session-options.test.ts @@ -0,0 +1,476 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import { AgentSideConnection, SessionNotification } from "@agentclientprotocol/sdk"; +import type { Options } from "@anthropic-ai/claude-agent-sdk"; +import type { ClaudeAcpAgent as ClaudeAcpAgentType } from "../acp-agent.js"; + +let capturedOptions: Options | undefined; +vi.mock("@anthropic-ai/claude-agent-sdk", async () => { + const actual = await vi.importActual( + "@anthropic-ai/claude-agent-sdk", + ); + return { + ...actual, + query: (args: { prompt: unknown; options: Options }) => { + capturedOptions = args.options; + return { + initializationResult: async () => ({ + models: [ + { + value: "claude-sonnet-4-6", + displayName: "Claude Sonnet", + description: "Fast", + supportsAutoMode: true, + }, + ], + }), + setModel: async () => {}, + setPermissionMode: async () => {}, + supportedCommands: async () => [], + [Symbol.asyncIterator]: async function* () {}, + }; + }, + }; +}); + +vi.mock("../tools.js", async () => { + const actual = await vi.importActual("../tools.js"); + return { + ...actual, + registerHookCallback: vi.fn(), + }; +}); + +describe("createSession options merging", () => { + let agent: ClaudeAcpAgentType; + let ClaudeAcpAgent: typeof ClaudeAcpAgentType; + + function createMockClient(): AgentSideConnection { + return { + sessionUpdate: async (_notification: SessionNotification) => {}, + requestPermission: async () => ({ outcome: { outcome: "cancelled" } }), + readTextFile: async () => ({ content: "" }), + writeTextFile: async () => ({}), + } as unknown as AgentSideConnection; + } + + beforeEach(async () => { + capturedOptions = undefined; + + vi.resetModules(); + const acpAgent = await import("../acp-agent.js"); + ClaudeAcpAgent = acpAgent.ClaudeAcpAgent; + + agent = new ClaudeAcpAgent(createMockClient()); + }); + + it("merges user-provided disallowedTools with ACP internal list", async () => { + await agent.newSession({ + cwd: "/test", + mcpServers: [], + _meta: { + claudeCode: { + options: { + disallowedTools: ["WebSearch", "WebFetch"], + }, + }, + }, + }); + + // User-provided tools should be present + expect(capturedOptions!.disallowedTools).toContain("WebSearch"); + expect(capturedOptions!.disallowedTools).toContain("WebFetch"); + // ACP's internal disallowed tool should also be present + expect(capturedOptions!.disallowedTools).toContain("AskUserQuestion"); + }); + + it("works when user provides no disallowedTools", async () => { + await agent.newSession({ + cwd: "/test", + mcpServers: [], + }); + + expect(capturedOptions!.disallowedTools).toContain("AskUserQuestion"); + }); + + it("works when user provides empty disallowedTools", async () => { + await agent.newSession({ + cwd: "/test", + mcpServers: [], + _meta: { + claudeCode: { + options: { + disallowedTools: [], + }, + }, + }, + }); + + expect(capturedOptions!.disallowedTools).toContain("AskUserQuestion"); + }); + + it("sets tools to empty array when disableBuiltInTools is true", async () => { + await agent.newSession({ + cwd: "/test", + mcpServers: [], + _meta: { + disableBuiltInTools: true, + claudeCode: { + options: { + disallowedTools: ["CustomTool"], + }, + }, + }, + }); + + // disableBuiltInTools removes all built-in tools from context + expect(capturedOptions!.tools).toEqual([]); + // User-provided and ACP disallowedTools still apply + expect(capturedOptions!.disallowedTools).toContain("CustomTool"); + expect(capturedOptions!.disallowedTools).toContain("AskUserQuestion"); + }); + + it("merges user-provided hooks with ACP hooks", async () => { + const userPreToolUseHook = { hooks: [{ command: "echo pre" }] }; + + await agent.newSession({ + cwd: "/test", + mcpServers: [], + _meta: { + claudeCode: { + options: { + hooks: { + PreToolUse: [userPreToolUseHook], + PostToolUse: [{ hooks: [{ command: "echo user-post" }] }], + }, + }, + }, + }, + }); + + // User's PreToolUse hooks should be preserved + expect(capturedOptions!.hooks?.PreToolUse).toEqual([userPreToolUseHook]); + // PostToolUse should contain both user and ACP hooks + expect(capturedOptions!.hooks?.PostToolUse).toHaveLength(2); + }); + + it("inherits HOME and PATH from process.env when no env is provided", async () => { + await agent.newSession({ + cwd: "/test", + mcpServers: [], + }); + + expect(capturedOptions?.env?.HOME).toBe(process.env.HOME); + expect(capturedOptions?.env?.PATH).toBe(process.env.PATH); + }); + + it("merges user-provided env vars on top of process.env", async () => { + await agent.newSession({ + cwd: "/test", + mcpServers: [], + _meta: { + claudeCode: { + options: { + env: { + CUSTOM_VAR: "custom-value", + }, + }, + }, + }, + }); + + expect(capturedOptions?.env?.HOME).toBe(process.env.HOME); + expect(capturedOptions?.env?.PATH).toBe(process.env.PATH); + expect(capturedOptions?.env?.CUSTOM_VAR).toBe("custom-value"); + }); + + it("allows user-provided env vars to override process.env entries", async () => { + await agent.newSession({ + cwd: "/test", + mcpServers: [], + _meta: { + claudeCode: { + options: { + env: { + HOME: "/custom/home", + }, + }, + }, + }, + }); + + expect(capturedOptions?.env?.HOME).toBe("/custom/home"); + }); + + it("defaults tools to claude_code preset when not provided", async () => { + await agent.newSession({ + cwd: "/test", + mcpServers: [], + }); + + expect(capturedOptions!.tools).toEqual({ type: "preset", preset: "claude_code" }); + }); + + it("passes through user-provided tools string array", async () => { + await agent.newSession({ + cwd: "/test", + mcpServers: [], + _meta: { + claudeCode: { + options: { + tools: ["Read", "Glob"], + }, + }, + }, + }); + + expect(capturedOptions!.tools).toEqual(["Read", "Glob"]); + }); + + it("explicit tools array takes precedence over disableBuiltInTools", async () => { + await agent.newSession({ + cwd: "/test", + mcpServers: [], + _meta: { + disableBuiltInTools: true, + claudeCode: { + options: { + tools: ["Read", "Glob"], + }, + }, + }, + }); + + expect(capturedOptions!.tools).toEqual(["Read", "Glob"]); + }); + + it("passes through empty tools array to disable all built-in tools", async () => { + await agent.newSession({ + cwd: "/test", + mcpServers: [], + _meta: { + claudeCode: { + options: { + tools: [], + }, + }, + }, + }); + + expect(capturedOptions!.tools).toEqual([]); + }); + + describe("systemPrompt via _meta", () => { + it("defaults to the claude_code preset when not provided", async () => { + await agent.newSession({ cwd: "/test", mcpServers: [] }); + + expect(capturedOptions!.systemPrompt).toEqual({ + type: "preset", + preset: "claude_code", + }); + }); + + it("replaces the preset when a string is provided", async () => { + await agent.newSession({ + cwd: "/test", + mcpServers: [], + _meta: { systemPrompt: "custom prompt" }, + }); + + expect(capturedOptions!.systemPrompt).toBe("custom prompt"); + }); + + it("forwards append", async () => { + await agent.newSession({ + cwd: "/test", + mcpServers: [], + _meta: { systemPrompt: { append: "extra instructions" } }, + }); + + expect(capturedOptions!.systemPrompt).toEqual({ + type: "preset", + preset: "claude_code", + append: "extra instructions", + }); + }); + + it("forwards excludeDynamicSections", async () => { + await agent.newSession({ + cwd: "/test", + mcpServers: [], + _meta: { systemPrompt: { excludeDynamicSections: true } }, + }); + + expect(capturedOptions!.systemPrompt).toEqual({ + type: "preset", + preset: "claude_code", + excludeDynamicSections: true, + }); + }); + + it("forwards append and excludeDynamicSections together", async () => { + await agent.newSession({ + cwd: "/test", + mcpServers: [], + _meta: { + systemPrompt: { + append: "extra instructions", + excludeDynamicSections: true, + }, + }, + }); + + expect(capturedOptions!.systemPrompt).toEqual({ + type: "preset", + preset: "claude_code", + append: "extra instructions", + excludeDynamicSections: true, + }); + }); + + it("ignores caller-provided type/preset overrides", async () => { + await agent.newSession({ + cwd: "/test", + mcpServers: [], + _meta: { + systemPrompt: { + type: "something-else", + preset: "other-preset", + append: "extra", + }, + }, + }); + + expect(capturedOptions!.systemPrompt).toEqual({ + type: "preset", + preset: "claude_code", + append: "extra", + }); + }); + }); + + describe("CLAUDE_MODEL_CONFIG", () => { + let originalModelConfig: string | undefined; + + beforeEach(() => { + originalModelConfig = process.env.CLAUDE_MODEL_CONFIG; + delete process.env.CLAUDE_MODEL_CONFIG; + }); + + afterEach(() => { + if (originalModelConfig !== undefined) { + process.env.CLAUDE_MODEL_CONFIG = originalModelConfig; + } else { + delete process.env.CLAUDE_MODEL_CONFIG; + } + }); + + it("passes modelOverrides as settings", async () => { + process.env.CLAUDE_MODEL_CONFIG = JSON.stringify({ + modelOverrides: { "claude-opus-4-6": "us.anthropic.claude-opus-4-6-v1" }, + }); + + await agent.newSession({ cwd: "/test", mcpServers: [] }); + + expect(capturedOptions!.settings).toEqual({ + modelOverrides: { "claude-opus-4-6": "us.anthropic.claude-opus-4-6-v1" }, + }); + }); + + it("passes availableModels as settings", async () => { + process.env.CLAUDE_MODEL_CONFIG = JSON.stringify({ + availableModels: ["opus", "sonnet"], + }); + + await agent.newSession({ cwd: "/test", mcpServers: [] }); + + expect(capturedOptions!.settings).toEqual({ + availableModels: ["opus", "sonnet"], + }); + }); + + it("passes both modelOverrides and availableModels", async () => { + process.env.CLAUDE_MODEL_CONFIG = JSON.stringify({ + modelOverrides: { "claude-opus-4-6": "us.anthropic.claude-opus-4-6-v1" }, + availableModels: ["opus"], + }); + + await agent.newSession({ cwd: "/test", mcpServers: [] }); + + expect(capturedOptions!.settings).toEqual({ + modelOverrides: { "claude-opus-4-6": "us.anthropic.claude-opus-4-6-v1" }, + availableModels: ["opus"], + }); + }); + + it("does not add settings when env var is not set", async () => { + await agent.newSession({ cwd: "/test", mcpServers: [] }); + + expect(capturedOptions!.settings).toBeUndefined(); + }); + + it("ignores env var when _meta provides settings", async () => { + process.env.CLAUDE_MODEL_CONFIG = JSON.stringify({ + modelOverrides: { "claude-opus-4-6": "us.anthropic.claude-opus-4-6-v1" }, + }); + + await agent.newSession({ + cwd: "/test", + mcpServers: [], + _meta: { + claudeCode: { + options: { + settings: { + model: "claude-sonnet-4-6", + modelOverrides: { "claude-opus-4-6": "meta-value" }, + }, + }, + }, + }, + }); + + // _meta settings take precedence; env var is ignored entirely + expect(capturedOptions!.settings).toEqual({ + model: "claude-sonnet-4-6", + modelOverrides: { "claude-opus-4-6": "meta-value" }, + }); + }); + + it("throws on invalid JSON", async () => { + process.env.CLAUDE_MODEL_CONFIG = "not-json"; + + await expect(agent.newSession({ cwd: "/test", mcpServers: [] })).rejects.toThrow(); + }); + }); + + it("merges user-provided mcpServers with ACP mcpServers", async () => { + await agent.newSession({ + cwd: "/test", + mcpServers: [ + { + name: "acp-server", + command: "node", + args: ["acp-server.js"], + env: [], + }, + ], + _meta: { + claudeCode: { + options: { + mcpServers: { + "user-server": { + type: "stdio", + command: "node", + args: ["server.js"], + }, + }, + }, + }, + }, + }); + + // User-provided MCP server should be present + expect(capturedOptions!.mcpServers).toHaveProperty("user-server"); + // ACP-provided MCP server should also be present + expect(capturedOptions!.mcpServers).toHaveProperty("acp-server"); + }); +}); diff --git a/qiming-claude-code-acp-ts/src/tests/resolve-permission-mode.test.ts b/qiming-claude-code-acp-ts/src/tests/resolve-permission-mode.test.ts new file mode 100644 index 00000000..cd275150 --- /dev/null +++ b/qiming-claude-code-acp-ts/src/tests/resolve-permission-mode.test.ts @@ -0,0 +1,57 @@ +import { describe, it, expect, vi } from "vitest"; +import { resolvePermissionMode, type Logger } from "../acp-agent.js"; + +function mockLogger() { + const error = vi.fn<(...args: any[]) => void>(); + const log = vi.fn<(...args: any[]) => void>(); + const logger: Logger = { log, error }; + return { logger, error, log }; +} + +describe("resolvePermissionMode", () => { + it("returns 'default' when no mode is provided", () => { + expect(resolvePermissionMode()).toBe("default"); + expect(resolvePermissionMode(undefined)).toBe("default"); + }); + + it("resolves exact canonical modes", () => { + expect(resolvePermissionMode("default")).toBe("default"); + expect(resolvePermissionMode("acceptEdits")).toBe("acceptEdits"); + expect(resolvePermissionMode("dontAsk")).toBe("dontAsk"); + expect(resolvePermissionMode("plan")).toBe("plan"); + expect(resolvePermissionMode("bypassPermissions")).toBe("bypassPermissions"); + }); + + it("resolves case-insensitive aliases", () => { + expect(resolvePermissionMode("DontAsk")).toBe("dontAsk"); + expect(resolvePermissionMode("DONTASK")).toBe("dontAsk"); + expect(resolvePermissionMode("AcceptEdits")).toBe("acceptEdits"); + expect(resolvePermissionMode("bypass")).toBe("bypassPermissions"); + }); + + it("trims whitespace", () => { + expect(resolvePermissionMode(" dontAsk ")).toBe("dontAsk"); + }); + + it("falls back to 'default' and logs on non-string values", () => { + for (const value of [123, true, {}]) { + const { logger, error } = mockLogger(); + expect(resolvePermissionMode(value, logger)).toBe("default"); + expect(error).toHaveBeenCalledWith(expect.stringContaining("expected a string")); + } + }); + + it("falls back to 'default' and logs on empty string", () => { + for (const value of ["", " "]) { + const { logger, error } = mockLogger(); + expect(resolvePermissionMode(value, logger)).toBe("default"); + expect(error).toHaveBeenCalledWith(expect.stringContaining("expected a non-empty string")); + } + }); + + it("falls back to 'default' and logs on unknown mode", () => { + const { logger, error } = mockLogger(); + expect(resolvePermissionMode("yolo", logger)).toBe("default"); + expect(error).toHaveBeenCalledWith(expect.stringContaining("yolo")); + }); +}); diff --git a/qiming-claude-code-acp-ts/src/tests/session-config-options.test.ts b/qiming-claude-code-acp-ts/src/tests/session-config-options.test.ts new file mode 100644 index 00000000..cbad1fd9 --- /dev/null +++ b/qiming-claude-code-acp-ts/src/tests/session-config-options.test.ts @@ -0,0 +1,1227 @@ +import { describe, it, expect, beforeEach, vi } from "vitest"; +import { AgentSideConnection, SessionNotification } from "@agentclientprotocol/sdk"; +import type { ModelInfo } from "@anthropic-ai/claude-agent-sdk"; +import type { ClaudeAcpAgent as ClaudeAcpAgentType } from "../acp-agent.js"; + +const { registerHookCallbackSpy } = vi.hoisted(() => ({ + registerHookCallbackSpy: vi.fn(), +})); + +vi.mock("../tools.js", async () => { + const actual = await vi.importActual("../tools.js"); + return { + ...actual, + registerHookCallback: registerHookCallbackSpy, + }; +}); + +const SESSION_ID = "test-session-id"; + +const MOCK_MODES = { + currentModeId: "default", + availableModes: [ + { id: "default", name: "Default", description: "Standard behavior" }, + { id: "plan", name: "Plan Mode", description: "Planning mode" }, + { id: "acceptEdits", name: "Accept Edits", description: "Auto-accept edits" }, + ], +}; + +const MOCK_MODELS = { + currentModelId: "claude-opus-4-5", + availableModels: [ + { modelId: "claude-opus-4-5", name: "Claude Opus", description: "Most capable" }, + { modelId: "claude-sonnet-4-6", name: "Claude Sonnet", description: "Balanced" }, + ], +}; + +const MOCK_CONFIG_OPTIONS = [ + { + id: "mode", + name: "Mode", + type: "select", + category: "mode", + currentValue: "default", + options: MOCK_MODES.availableModes.map((m) => ({ + value: m.id, + name: m.name, + description: m.description, + })), + }, + { + id: "model", + name: "Model", + type: "select", + category: "model", + currentValue: "claude-opus-4-5", + options: MOCK_MODELS.availableModels.map((m) => ({ + value: m.modelId, + name: m.name, + description: m.description, + })), + }, + { + id: "effort", + name: "Effort", + description: "Available effort levels for this model", + type: "select", + category: "effort", + currentValue: "default", + options: [ + { value: "default", name: "Default" }, + { value: "low", name: "Low" }, + { value: "medium", name: "Medium" }, + { value: "high", name: "High" }, + ], + }, +]; + +describe("session config options", () => { + let agent: ClaudeAcpAgentType; + let ClaudeAcpAgent: typeof ClaudeAcpAgentType; + let sessionUpdates: SessionNotification[]; + let createSessionSpy: ReturnType; + let setPermissionModeSpy: ReturnType; + let setModelSpy: ReturnType; + let applyFlagSettingsSpy: ReturnType; + + function createMockClient(): AgentSideConnection { + return { + sessionUpdate: async (notification: SessionNotification) => { + sessionUpdates.push(notification); + }, + requestPermission: async () => ({ outcome: { outcome: "cancelled" } }), + readTextFile: async () => ({ content: "" }), + writeTextFile: async () => ({}), + } as unknown as AgentSideConnection; + } + + function populateSession() { + setPermissionModeSpy = vi.fn(); + setModelSpy = vi.fn(); + applyFlagSettingsSpy = vi.fn(); + + (agent as unknown as { sessions: Record }).sessions[SESSION_ID] = { + query: { + setPermissionMode: setPermissionModeSpy, + setModel: setModelSpy, + applyFlagSettings: applyFlagSettingsSpy, + supportedCommands: async () => [], + }, + input: null, + cancelled: false, + permissionMode: "default", + settingsManager: {}, + modes: structuredClone(MOCK_MODES), + models: structuredClone(MOCK_MODELS), + modelInfos: MOCK_MODELS.availableModels.map( + (m): ModelInfo => ({ + value: m.modelId, + displayName: m.name, + description: m.description, + supportsEffort: true, + supportedEffortLevels: ["low", "medium", "high"], + }), + ), + configOptions: structuredClone(MOCK_CONFIG_OPTIONS), + contextWindowSize: 200000, + }; + } + + beforeEach(async () => { + sessionUpdates = []; + registerHookCallbackSpy.mockClear(); + + vi.resetModules(); + const acpAgent = await import("../acp-agent.js"); + ClaudeAcpAgent = acpAgent.ClaudeAcpAgent; + + agent = new ClaudeAcpAgent(createMockClient()); + createSessionSpy = vi.fn(async () => ({ + sessionId: SESSION_ID, + modes: MOCK_MODES, + models: MOCK_MODELS, + configOptions: MOCK_CONFIG_OPTIONS, + })); + (agent as unknown as { createSession: typeof createSessionSpy }).createSession = + createSessionSpy; + }); + + describe("newSession returns configOptions", () => { + it("includes configOptions in the response", async () => { + const response = await agent.newSession({ cwd: "/test", mcpServers: [] }); + expect(response.configOptions).toBeDefined(); + expect(response.configOptions).toEqual(MOCK_CONFIG_OPTIONS); + }); + + it("includes mode and model config options", async () => { + const response = await agent.newSession({ cwd: "/test", mcpServers: [] }); + const modeOption = response.configOptions?.find((o) => o.id === "mode"); + const modelOption = response.configOptions?.find((o) => o.id === "model"); + expect(modeOption).toBeDefined(); + expect(modelOption).toBeDefined(); + }); + }); + + describe("loadSession returns configOptions", () => { + it("includes configOptions from createSession", async () => { + // loadSession calls findSessionFile first - override the whole method + const loadSessionSpy = vi.fn(async () => ({ + modes: MOCK_MODES, + models: MOCK_MODELS, + configOptions: MOCK_CONFIG_OPTIONS, + })); + (agent as unknown as { loadSession: typeof loadSessionSpy }).loadSession = loadSessionSpy; + + const response = await agent.loadSession({ + cwd: "/test", + sessionId: SESSION_ID, + mcpServers: [], + }); + expect(response.configOptions).toEqual(MOCK_CONFIG_OPTIONS); + }); + }); + + describe("setSessionConfigOption", () => { + beforeEach(() => { + populateSession(); + }); + + it("throws when session not found", async () => { + await expect( + agent.setSessionConfigOption({ + sessionId: "nonexistent", + configId: "mode", + value: "plan", + }), + ).rejects.toThrow("Session not found"); + }); + + it("throws when config option not found", async () => { + await expect( + agent.setSessionConfigOption({ + sessionId: SESSION_ID, + configId: "unknown-option", + value: "some-value", + }), + ).rejects.toThrow("Unknown config option: unknown-option"); + }); + + it("throws when value is not valid for the option", async () => { + await expect( + agent.setSessionConfigOption({ + sessionId: SESSION_ID, + configId: "mode", + value: "invalid-mode", + }), + ).rejects.toThrow("Invalid value for config option mode: invalid-mode"); + }); + + it("changes mode, sends current_mode_update but not config_option_update", async () => { + await agent.setSessionConfigOption({ + sessionId: SESSION_ID, + configId: "mode", + value: "plan", + }); + + expect(setPermissionModeSpy).toHaveBeenCalledWith("plan"); + + const modeUpdate = sessionUpdates.find( + (n) => n.update.sessionUpdate === "current_mode_update", + ); + expect(modeUpdate?.update).toMatchObject({ + sessionUpdate: "current_mode_update", + currentModeId: "plan", + }); + + const configUpdate = sessionUpdates.find( + (n) => n.update.sessionUpdate === "config_option_update", + ); + expect(configUpdate).toBeUndefined(); + }); + + it("changes model and does not send a config_option_update notification", async () => { + await agent.setSessionConfigOption({ + sessionId: SESSION_ID, + configId: "model", + value: "claude-sonnet-4-6", + }); + + expect(setModelSpy).toHaveBeenCalledWith("claude-sonnet-4-6"); + + const configUpdate = sessionUpdates.find( + (n) => n.update.sessionUpdate === "config_option_update", + ); + expect(configUpdate).toBeUndefined(); + }); + + it("resolves model alias 'opus' to full model ID", async () => { + const response = await agent.setSessionConfigOption({ + sessionId: SESSION_ID, + configId: "model", + value: "opus", + }); + + expect(setModelSpy).toHaveBeenCalledWith("claude-opus-4-5"); + + const modelOption = response.configOptions.find((o) => o.id === "model"); + expect(modelOption?.currentValue).toBe("claude-opus-4-5"); + }); + + it("resolves model alias 'sonnet' to full model ID", async () => { + await agent.setSessionConfigOption({ + sessionId: SESSION_ID, + configId: "model", + value: "sonnet", + }); + + expect(setModelSpy).toHaveBeenCalledWith("claude-sonnet-4-6"); + }); + + it("resolves display name to model ID", async () => { + await agent.setSessionConfigOption({ + sessionId: SESSION_ID, + configId: "model", + value: "Claude Sonnet", + }); + + expect(setModelSpy).toHaveBeenCalledWith("claude-sonnet-4-6"); + }); + + it("still works with exact model ID", async () => { + const response = await agent.setSessionConfigOption({ + sessionId: SESSION_ID, + configId: "model", + value: "claude-sonnet-4-6", + }); + + expect(setModelSpy).toHaveBeenCalledWith("claude-sonnet-4-6"); + const modelOption = response.configOptions.find((o) => o.id === "model"); + expect(modelOption?.currentValue).toBe("claude-sonnet-4-6"); + }); + + it("throws for completely invalid model value", async () => { + await expect( + agent.setSessionConfigOption({ + sessionId: SESSION_ID, + configId: "model", + value: "gpt-4", + }), + ).rejects.toThrow("Invalid value for config option model: gpt-4"); + }); + + it("returns full configOptions in the response", async () => { + const response = await agent.setSessionConfigOption({ + sessionId: SESSION_ID, + configId: "mode", + value: "plan", + }); + + expect(response.configOptions).toHaveLength(MOCK_CONFIG_OPTIONS.length); + const modeOption = response.configOptions.find((o) => o.id === "mode"); + expect(modeOption?.currentValue).toBe("plan"); + }); + + it("other options are unchanged when one is updated", async () => { + const response = await agent.setSessionConfigOption({ + sessionId: SESSION_ID, + configId: "mode", + value: "plan", + }); + + const modelOption = response.configOptions.find((o) => o.id === "model"); + expect(modelOption?.currentValue).toBe("claude-opus-4-5"); + }); + }); + + describe("setSessionMode sends config_option_update", () => { + beforeEach(() => { + populateSession(); + }); + + it("sends config_option_update when mode is changed via setSessionMode", async () => { + await agent.setSessionMode({ sessionId: SESSION_ID, modeId: "acceptEdits" }); + + const configUpdate = sessionUpdates.find( + (n) => n.update.sessionUpdate === "config_option_update", + ); + expect(configUpdate).toBeDefined(); + expect(configUpdate?.update).toMatchObject({ + sessionUpdate: "config_option_update", + configOptions: expect.arrayContaining([ + expect.objectContaining({ id: "mode", currentValue: "acceptEdits" }), + ]), + }); + }); + + it("updates stored configOptions currentValue when mode changes", async () => { + await agent.setSessionMode({ sessionId: SESSION_ID, modeId: "plan" }); + + const session = ( + agent as unknown as { + sessions: Record; + } + ).sessions[SESSION_ID]; + const modeOption = session.configOptions.find((o) => o.id === "mode"); + expect(modeOption?.currentValue).toBe("plan"); + }); + + it("does not send config_option_update for an invalid mode", async () => { + await expect( + agent.setSessionMode({ sessionId: SESSION_ID, modeId: "not-a-mode" as any }), + ).rejects.toThrow("Invalid Mode"); + + const configUpdate = sessionUpdates.find( + (n) => n.update.sessionUpdate === "config_option_update", + ); + expect(configUpdate).toBeUndefined(); + }); + }); + + describe("unstable_setSessionModel sends config_option_update", () => { + beforeEach(() => { + populateSession(); + }); + + it("sends config_option_update when model is changed via setSessionModel", async () => { + await agent.unstable_setSessionModel({ + sessionId: SESSION_ID, + modelId: "claude-sonnet-4-6", + }); + + const configUpdate = sessionUpdates.find( + (n) => n.update.sessionUpdate === "config_option_update", + ); + expect(configUpdate).toBeDefined(); + expect(configUpdate?.update).toMatchObject({ + sessionUpdate: "config_option_update", + configOptions: expect.arrayContaining([ + expect.objectContaining({ id: "model", currentValue: "claude-sonnet-4-6" }), + ]), + }); + }); + + it("updates stored configOptions currentValue when model changes", async () => { + await agent.unstable_setSessionModel({ + sessionId: SESSION_ID, + modelId: "claude-sonnet-4-6", + }); + + const session = ( + agent as unknown as { + sessions: Record; + } + ).sessions[SESSION_ID]; + const modelOption = session.configOptions.find((o) => o.id === "model"); + expect(modelOption?.currentValue).toBe("claude-sonnet-4-6"); + }); + + it("includes updated effort in config_option_update when model drops effort support", async () => { + const session = (agent as unknown as { sessions: Record }).sessions[SESSION_ID]; + session.modelInfos = [ + { + value: "claude-opus-4-5", + displayName: "Claude Opus", + description: "Most capable", + supportsEffort: true, + supportedEffortLevels: ["low", "medium", "high"], + }, + { + value: "claude-sonnet-4-6", + displayName: "Claude Sonnet", + description: "Balanced", + supportsEffort: false, + }, + ]; + + await agent.unstable_setSessionModel({ + sessionId: SESSION_ID, + modelId: "claude-sonnet-4-6", + }); + + const configUpdate = sessionUpdates.find( + (n) => n.update.sessionUpdate === "config_option_update", + ); + expect(configUpdate).toBeDefined(); + const effortOption = (configUpdate?.update as any).configOptions.find( + (o: any) => o.id === "effort", + ); + expect(effortOption).toBeUndefined(); + expect(applyFlagSettingsSpy).toHaveBeenCalledWith({ effortLevel: null }); + }); + + it("clamps effort in config_option_update when new model has different supported levels", async () => { + // Set current effort to "max" which the new model won't support + const session = (agent as unknown as { sessions: Record }).sessions[SESSION_ID]; + const effortOpt = session.configOptions.find((o: any) => o.id === "effort"); + if (effortOpt) effortOpt.currentValue = "max"; + + session.modelInfos = [ + { + value: "claude-opus-4-5", + displayName: "Claude Opus", + description: "Most capable", + supportsEffort: true, + supportedEffortLevels: ["low", "medium", "high", "max"], + }, + { + value: "claude-sonnet-4-6", + displayName: "Claude Sonnet", + description: "Balanced", + supportsEffort: true, + supportedEffortLevels: ["low", "medium", "high"], + }, + ]; + + await agent.unstable_setSessionModel({ + sessionId: SESSION_ID, + modelId: "claude-sonnet-4-6", + }); + + const configUpdate = sessionUpdates.find( + (n) => n.update.sessionUpdate === "config_option_update", + ); + const effortOption = (configUpdate?.update as any).configOptions.find( + (o: any) => o.id === "effort", + ); + expect(effortOption).toBeDefined(); + expect(effortOption.currentValue).toBe("default"); + expect(applyFlagSettingsSpy).toHaveBeenCalledWith({ effortLevel: null }); + }); + + it("preserves effort in config_option_update when new model supports same level", async () => { + // Set effort to "low" first + const session = (agent as unknown as { sessions: Record }).sessions[SESSION_ID]; + const effortOpt = session.configOptions.find((o: any) => o.id === "effort"); + if (effortOpt) effortOpt.currentValue = "low"; + + await agent.unstable_setSessionModel({ + sessionId: SESSION_ID, + modelId: "claude-sonnet-4-6", + }); + + const configUpdate = sessionUpdates.find( + (n) => n.update.sessionUpdate === "config_option_update", + ); + const effortOption = (configUpdate?.update as any).configOptions.find( + (o: any) => o.id === "effort", + ); + expect(effortOption?.currentValue).toBe("low"); + // Effort didn't change, so applyFlagSettings should NOT be called + expect(applyFlagSettingsSpy).not.toHaveBeenCalled(); + }); + }); + + describe("no config_option_update notification when using setSessionConfigOption", () => { + beforeEach(() => { + populateSession(); + }); + + it("sends no config_option_update when setting mode via config option", async () => { + await agent.setSessionConfigOption({ + sessionId: SESSION_ID, + configId: "mode", + value: "plan", + }); + + const configUpdates = sessionUpdates.filter( + (n) => n.update.sessionUpdate === "config_option_update", + ); + expect(configUpdates).toHaveLength(0); + }); + + it("sends no config_option_update when setting model via config option", async () => { + await agent.setSessionConfigOption({ + sessionId: SESSION_ID, + configId: "model", + value: "claude-sonnet-4-6", + }); + + const configUpdates = sessionUpdates.filter( + (n) => n.update.sessionUpdate === "config_option_update", + ); + expect(configUpdates).toHaveLength(0); + }); + }); + + describe("setSessionConfigOption for effort", () => { + beforeEach(() => { + populateSession(); + }); + + it("calls applyFlagSettings with effortLevel", async () => { + await agent.setSessionConfigOption({ + sessionId: SESSION_ID, + configId: "effort", + value: "low", + }); + + expect(applyFlagSettingsSpy).toHaveBeenCalledWith({ effortLevel: "low" }); + }); + + it("calls applyFlagSettings with null effortLevel for 'default'", async () => { + // Set effort to a non-default value first + const session = (agent as unknown as { sessions: Record }).sessions[SESSION_ID]; + const effortOpt = session.configOptions.find((o: any) => o.id === "effort"); + if (effortOpt) effortOpt.currentValue = "high"; + + await agent.setSessionConfigOption({ + sessionId: SESSION_ID, + configId: "effort", + value: "default", + }); + + expect(applyFlagSettingsSpy).toHaveBeenCalledWith({ effortLevel: null }); + + // The SDK's applyFlagSettings travels over a JSON pipe and only clears a + // flag-layer key when an explicit `null` is sent — `undefined` is + // dropped during JSON.stringify, which would leave the previous effort + // override in place. Round-trip the call args through JSON to make sure + // the key actually reaches the SDK. + const calls = applyFlagSettingsSpy.mock.calls; + const lastCallArgs = calls[calls.length - 1]?.[0]; + const serialized = JSON.parse(JSON.stringify(lastCallArgs)); + expect(serialized).toHaveProperty("effortLevel", null); + }); + + it("updates effort currentValue in returned configOptions", async () => { + const response = await agent.setSessionConfigOption({ + sessionId: SESSION_ID, + configId: "effort", + value: "medium", + }); + + const effortOption = response.configOptions.find((o) => o.id === "effort"); + expect(effortOption?.currentValue).toBe("medium"); + }); + + it("throws for invalid effort value", async () => { + await expect( + agent.setSessionConfigOption({ + sessionId: SESSION_ID, + configId: "effort", + value: "turbo", + }), + ).rejects.toThrow("Invalid value for config option effort: turbo"); + }); + + it("does not send config_option_update notification", async () => { + await agent.setSessionConfigOption({ + sessionId: SESSION_ID, + configId: "effort", + value: "low", + }); + + const configUpdates = sessionUpdates.filter( + (n) => n.update.sessionUpdate === "config_option_update", + ); + expect(configUpdates).toHaveLength(0); + }); + + it("other options are unchanged when effort is updated", async () => { + const response = await agent.setSessionConfigOption({ + sessionId: SESSION_ID, + configId: "effort", + value: "low", + }); + + const modeOption = response.configOptions.find((o) => o.id === "mode"); + expect(modeOption?.currentValue).toBe("default"); + const modelOption = response.configOptions.find((o) => o.id === "model"); + expect(modelOption?.currentValue).toBe("claude-opus-4-5"); + }); + }); + + describe("effort level and model switch interactions", () => { + beforeEach(() => { + populateSession(); + }); + + it("drops effort option when switching to a model without effort support", async () => { + // Make sonnet not support effort + const session = (agent as unknown as { sessions: Record }).sessions[SESSION_ID]; + session.modelInfos = [ + { + value: "claude-opus-4-5", + displayName: "Claude Opus", + description: "Most capable", + supportsEffort: true, + supportedEffortLevels: ["low", "medium", "high"], + }, + { + value: "claude-sonnet-4-6", + displayName: "Claude Sonnet", + description: "Balanced", + supportsEffort: false, + }, + ]; + + const response = await agent.setSessionConfigOption({ + sessionId: SESSION_ID, + configId: "model", + value: "claude-sonnet-4-6", + }); + + const effortOption = response.configOptions.find((o) => o.id === "effort"); + expect(effortOption).toBeUndefined(); + }); + + it("clears effort via applyFlagSettings when switching to a model without effort", async () => { + const session = (agent as unknown as { sessions: Record }).sessions[SESSION_ID]; + session.modelInfos = [ + { + value: "claude-opus-4-5", + displayName: "Claude Opus", + description: "Most capable", + supportsEffort: true, + supportedEffortLevels: ["low", "medium", "high"], + }, + { + value: "claude-sonnet-4-6", + displayName: "Claude Sonnet", + description: "Balanced", + supportsEffort: false, + }, + ]; + + await agent.setSessionConfigOption({ + sessionId: SESSION_ID, + configId: "model", + value: "claude-sonnet-4-6", + }); + + expect(applyFlagSettingsSpy).toHaveBeenCalledWith({ effortLevel: null }); + }); + + it("adds effort option when switching to a model that supports effort", async () => { + const session = (agent as unknown as { sessions: Record }).sessions[SESSION_ID]; + // Start with sonnet (no effort) as current + session.models = { ...session.models, currentModelId: "claude-sonnet-4-6" }; + session.modelInfos = [ + { + value: "claude-opus-4-5", + displayName: "Claude Opus", + description: "Most capable", + supportsEffort: true, + supportedEffortLevels: ["low", "medium", "high"], + }, + { + value: "claude-sonnet-4-6", + displayName: "Claude Sonnet", + description: "Balanced", + supportsEffort: false, + }, + ]; + // Remove effort from current config options + session.configOptions = session.configOptions.filter((o: any) => o.id !== "effort"); + + const response = await agent.setSessionConfigOption({ + sessionId: SESSION_ID, + configId: "model", + value: "claude-opus-4-5", + }); + + const effortOption = response.configOptions.find((o) => o.id === "effort"); + expect(effortOption).toBeDefined(); + // No previous effort, so defaults to "default" (no effort override) + expect(effortOption?.currentValue).toBe("default"); + }); + + it("clamps effort to valid value when new model has different supported levels", async () => { + const session = (agent as unknown as { sessions: Record }).sessions[SESSION_ID]; + // Set current effort to "max" (not supported by sonnet in our mock) + const effortOpt = session.configOptions.find((o: any) => o.id === "effort"); + if (effortOpt) effortOpt.currentValue = "max"; + + session.modelInfos = [ + { + value: "claude-opus-4-5", + displayName: "Claude Opus", + description: "Most capable", + supportsEffort: true, + supportedEffortLevels: ["low", "medium", "high", "max"], + }, + { + value: "claude-sonnet-4-6", + displayName: "Claude Sonnet", + description: "Balanced", + supportsEffort: true, + supportedEffortLevels: ["low", "medium", "high"], + }, + ]; + + const response = await agent.setSessionConfigOption({ + sessionId: SESSION_ID, + configId: "model", + value: "claude-sonnet-4-6", + }); + + const effortOption = response.configOptions.find((o) => o.id === "effort"); + expect(effortOption).toBeDefined(); + // "max" is not in sonnet's levels, so should fall back to "default" (no effort override) + expect(effortOption?.currentValue).toBe("default"); + // SDK should be told to clear the effort override + expect(applyFlagSettingsSpy).toHaveBeenCalledWith({ effortLevel: null }); + }); + + it("preserves effort value when new model supports the same level", async () => { + // Set effort to "low" + await agent.setSessionConfigOption({ + sessionId: SESSION_ID, + configId: "effort", + value: "low", + }); + + // Switch model — both support "low" + const response = await agent.setSessionConfigOption({ + sessionId: SESSION_ID, + configId: "model", + value: "claude-sonnet-4-6", + }); + + const effortOption = response.configOptions.find((o) => o.id === "effort"); + expect(effortOption?.currentValue).toBe("low"); + // applyFlagSettings was called once for the effort change, but not again for the model switch + expect(applyFlagSettingsSpy).toHaveBeenCalledTimes(1); + }); + }); + + describe("bidirectional consistency", () => { + beforeEach(() => { + populateSession(); + }); + + it("setSessionConfigOption for mode also calls underlying setPermissionMode", async () => { + await agent.setSessionConfigOption({ + sessionId: SESSION_ID, + configId: "mode", + value: "acceptEdits", + }); + + expect(setPermissionModeSpy).toHaveBeenCalledWith("acceptEdits"); + }); + + it("setSessionConfigOption for model also calls underlying setModel", async () => { + await agent.setSessionConfigOption({ + sessionId: SESSION_ID, + configId: "model", + value: "claude-sonnet-4-6", + }); + + expect(setModelSpy).toHaveBeenCalledWith("claude-sonnet-4-6"); + }); + + it("setSessionMode also syncs configOptions", async () => { + await agent.setSessionMode({ sessionId: SESSION_ID, modeId: "plan" }); + + const session = ( + agent as unknown as { + sessions: Record; + } + ).sessions[SESSION_ID]; + expect(session.configOptions.find((o) => o.id === "mode")?.currentValue).toBe("plan"); + }); + + it("setSessionModel also syncs configOptions", async () => { + await agent.unstable_setSessionModel({ + sessionId: SESSION_ID, + modelId: "claude-sonnet-4-6", + }); + + const session = ( + agent as unknown as { + sessions: Record; + } + ).sessions[SESSION_ID]; + expect(session.configOptions.find((o) => o.id === "model")?.currentValue).toBe( + "claude-sonnet-4-6", + ); + }); + }); + + describe("auto mode availability per model", () => { + /** + * Augment the session populated by `populateSession()` with a Haiku entry + * (no `supportsAutoMode`), Opus + Sonnet entries with `supportsAutoMode: + * true`, and seed `availableModes` so it currently includes `auto`. This + * exercises the per-model recomputation done by `applyConfigOptionValue` + * on a model switch. + */ + function setupHaikuOpusSession(currentModeId: string = "default") { + const session = (agent as unknown as { sessions: Record }).sessions[SESSION_ID]; + session.modelInfos = [ + { + value: "claude-opus-4-5", + displayName: "Claude Opus", + description: "Most capable", + supportsEffort: true, + supportedEffortLevels: ["low", "medium", "high"], + supportsAutoMode: true, + }, + { + value: "claude-sonnet-4-6", + displayName: "Claude Sonnet", + description: "Balanced", + supportsEffort: true, + supportedEffortLevels: ["low", "medium", "high"], + supportsAutoMode: true, + }, + { + value: "claude-haiku-4-5", + displayName: "Claude Haiku", + description: "Fast", + supportsEffort: true, + supportedEffortLevels: ["low", "medium", "high"], + // supportsAutoMode intentionally omitted + }, + ]; + session.models = { + currentModelId: "claude-opus-4-5", + availableModels: [ + { modelId: "claude-opus-4-5", name: "Claude Opus", description: "Most capable" }, + { modelId: "claude-sonnet-4-6", name: "Claude Sonnet", description: "Balanced" }, + { modelId: "claude-haiku-4-5", name: "Claude Haiku", description: "Fast" }, + ], + }; + session.modes = { + currentModeId, + availableModes: [ + { + id: "auto", + name: "Auto", + description: "Use a model classifier to approve/deny permission prompts", + }, + { + id: "default", + name: "Default", + description: "Standard behavior, prompts for dangerous operations", + }, + { + id: "acceptEdits", + name: "Accept Edits", + description: "Auto-accept file edit operations", + }, + { id: "plan", name: "Plan Mode", description: "Planning mode" }, + { + id: "dontAsk", + name: "Don't Ask", + description: "Don't prompt for permissions, deny if not pre-approved", + }, + ], + }; + // Reflect the seeded availableModes/availableModels in configOptions so + // the pre-state matches what `createSession` would have produced for + // Opus, and `setSessionConfigOption` validation can accept the seeded + // model ids (notably the new Haiku entry). + session.configOptions = session.configOptions.map((o: any) => { + if (o.id === "mode") { + return { + ...o, + currentValue: currentModeId, + options: session.modes.availableModes.map((m: any) => ({ + value: m.id, + name: m.name, + description: m.description, + })), + }; + } + if (o.id === "model") { + return { + ...o, + currentValue: session.models.currentModelId, + options: session.models.availableModels.map((m: any) => ({ + value: m.modelId, + name: m.name, + description: m.description, + })), + }; + } + return o; + }); + return session; + } + + beforeEach(() => { + populateSession(); + }); + + it("drops `auto` from available modes when switching to Haiku", async () => { + setupHaikuOpusSession("default"); + + await agent.unstable_setSessionModel({ + sessionId: SESSION_ID, + modelId: "claude-haiku-4-5", + }); + + const configUpdate = sessionUpdates.find( + (n) => n.update.sessionUpdate === "config_option_update", + ); + expect(configUpdate).toBeDefined(); + const modeOption = (configUpdate?.update as any).configOptions.find( + (o: any) => o.id === "mode", + ); + expect(modeOption).toBeDefined(); + const modeValues = modeOption.options.map((o: any) => o.value); + expect(modeValues).not.toContain("auto"); + expect(modeValues).toEqual( + expect.arrayContaining(["default", "acceptEdits", "plan", "dontAsk"]), + ); + }); + + it("clamps to `default` and emits current_mode_update when Opus(auto) → Haiku", async () => { + setupHaikuOpusSession("auto"); + + await agent.unstable_setSessionModel({ + sessionId: SESSION_ID, + modelId: "claude-haiku-4-5", + }); + + // SDK was synced to "default". + expect(setPermissionModeSpy).toHaveBeenCalledWith("default"); + + // current_mode_update was emitted before config_option_update so a + // client applying notifications in order observes the mode change + // before re-rendering the config-option list. + const modeUpdateIdx = sessionUpdates.findIndex( + (n) => n.update.sessionUpdate === "current_mode_update", + ); + const configUpdateIdx = sessionUpdates.findIndex( + (n) => n.update.sessionUpdate === "config_option_update", + ); + expect(modeUpdateIdx).toBeGreaterThanOrEqual(0); + expect(configUpdateIdx).toBeGreaterThanOrEqual(0); + expect(modeUpdateIdx).toBeLessThan(configUpdateIdx); + expect((sessionUpdates[modeUpdateIdx].update as any).currentModeId).toBe("default"); + + // configOptions reflect the clamped mode. + const modeOption = (sessionUpdates[configUpdateIdx].update as any).configOptions.find( + (o: any) => o.id === "mode", + ); + expect(modeOption.currentValue).toBe("default"); + }); + + it("re-adds `auto` when switching from Haiku back to Opus", async () => { + const session = setupHaikuOpusSession("default"); + // Pretend Haiku is the current model with no `auto`. + session.models.currentModelId = "claude-haiku-4-5"; + session.modes.availableModes = session.modes.availableModes.filter( + (m: any) => m.id !== "auto", + ); + const modeOpt = session.configOptions.find((o: any) => o.id === "mode"); + modeOpt.options = session.modes.availableModes.map((m: any) => ({ + value: m.id, + name: m.name, + description: m.description, + })); + + await agent.unstable_setSessionModel({ + sessionId: SESSION_ID, + modelId: "claude-opus-4-5", + }); + + const configUpdate = sessionUpdates.find( + (n) => n.update.sessionUpdate === "config_option_update", + ); + expect(configUpdate).toBeDefined(); + const modeOption = (configUpdate?.update as any).configOptions.find( + (o: any) => o.id === "mode", + ); + const modeValues = modeOption.options.map((o: any) => o.value); + expect(modeValues).toContain("auto"); + + // The current mode ("default") is still valid on Opus, so no + // current_mode_update should have been emitted by the model switch. + const modeUpdates = sessionUpdates.filter( + (n) => n.update.sessionUpdate === "current_mode_update", + ); + expect(modeUpdates).toHaveLength(0); + }); + + it("preserves the current mode when it remains valid after a model switch", async () => { + setupHaikuOpusSession("plan"); + + await agent.unstable_setSessionModel({ + sessionId: SESSION_ID, + modelId: "claude-haiku-4-5", + }); + + // `plan` is in availableModes for both Opus and Haiku, so no clamp. + expect(setPermissionModeSpy).not.toHaveBeenCalledWith("default"); + + const modeUpdates = sessionUpdates.filter( + (n) => n.update.sessionUpdate === "current_mode_update", + ); + expect(modeUpdates).toHaveLength(0); + + const configUpdate = sessionUpdates.find( + (n) => n.update.sessionUpdate === "config_option_update", + ); + const modeOption = (configUpdate?.update as any).configOptions.find( + (o: any) => o.id === "mode", + ); + expect(modeOption.currentValue).toBe("plan"); + }); + + it("clamps mode and emits current_mode_update via setSessionConfigOption(model)", async () => { + // Mirrors the unstable_setSessionModel(auto → Haiku) test, but goes + // through the request/response API. The `current_mode_update` side + // effect must still fire so clients learn about the clamp regardless of + // which entry point triggered the model switch. + setupHaikuOpusSession("auto"); + + const response = await agent.setSessionConfigOption({ + sessionId: SESSION_ID, + configId: "model", + value: "claude-haiku-4-5", + }); + + expect(setPermissionModeSpy).toHaveBeenCalledWith("default"); + + const modeUpdates = sessionUpdates.filter( + (n) => n.update.sessionUpdate === "current_mode_update", + ); + expect(modeUpdates).toHaveLength(1); + expect((modeUpdates[0].update as any).currentModeId).toBe("default"); + + // setSessionConfigOption is a request/response API: it returns the new + // configOptions in the response rather than emitting a + // config_option_update notification. + const configUpdates = sessionUpdates.filter( + (n) => n.update.sessionUpdate === "config_option_update", + ); + expect(configUpdates).toHaveLength(0); + + const modeOption = response.configOptions.find((o: any) => o.id === "mode"); + expect(modeOption).toBeDefined(); + expect((modeOption as any).currentValue).toBe("default"); + expect((modeOption as any).options.map((o: any) => o.value)).not.toContain("auto"); + }); + + it("rejects direct setSessionMode to `auto` when the active model does not offer it", async () => { + const session = setupHaikuOpusSession("default"); + session.models.currentModelId = "claude-haiku-4-5"; + session.modes.availableModes = session.modes.availableModes.filter( + (mode: any) => mode.id !== "auto", + ); + + await expect(agent.setSessionMode({ sessionId: SESSION_ID, modeId: "auto" })).rejects.toThrow( + "Mode auto is not available in this session", + ); + + expect(setPermissionModeSpy).not.toHaveBeenCalledWith("auto"); + expect(sessionUpdates).toHaveLength(0); + }); + }); + + describe("ExitPlanMode permission options filtered by availableModes", () => { + let capturedPermissionRequest: any; + let permissionResponse: any; + + beforeEach(() => { + capturedPermissionRequest = null; + permissionResponse = { outcome: { outcome: "cancelled" } }; + // Replace the default mock client with one that captures the + // requestPermission call so we can assert on the offered options. + (agent as any).client = { + sessionUpdate: async (notification: SessionNotification) => { + sessionUpdates.push(notification); + }, + requestPermission: async (params: any) => { + capturedPermissionRequest = params; + return permissionResponse; + }, + readTextFile: async () => ({ content: "" }), + writeTextFile: async () => ({}), + }; + populateSession(); + }); + + it("omits the `auto` option on a model without supportsAutoMode", async () => { + const session = (agent as unknown as { sessions: Record }).sessions[SESSION_ID]; + // Haiku-shaped session: availableModes does NOT include `auto`. + session.modes = { + currentModeId: "plan", + availableModes: [ + { id: "default", name: "Default", description: "Standard" }, + { id: "acceptEdits", name: "Accept Edits", description: "Auto-accept edits" }, + { id: "plan", name: "Plan Mode", description: "Planning mode" }, + { id: "dontAsk", name: "Don't Ask", description: "Deny if not pre-approved" }, + ], + }; + + const canUseTool = (agent as any).canUseTool(SESSION_ID); + const signal = new AbortController().signal; + try { + await canUseTool( + "ExitPlanMode", + { plan: "do stuff" }, + { signal, suggestions: undefined, toolUseID: "toolu_1" }, + ); + } catch { + // The mock client returns `cancelled`, which makes canUseTool throw. + // We only care about the captured requestPermission options. + } + + expect(capturedPermissionRequest).not.toBeNull(); + const optionIds = capturedPermissionRequest.options.map((o: any) => o.optionId); + expect(optionIds).not.toContain("auto"); + expect(optionIds).toEqual(expect.arrayContaining(["default", "acceptEdits", "plan"])); + }); + + it("denies a selected `auto` option if the client did not receive that option", async () => { + const session = (agent as unknown as { sessions: Record }).sessions[SESSION_ID]; + session.modes = { + currentModeId: "plan", + availableModes: [ + { id: "default", name: "Default", description: "Standard" }, + { id: "acceptEdits", name: "Accept Edits", description: "Auto-accept edits" }, + { id: "plan", name: "Plan Mode", description: "Planning mode" }, + { id: "dontAsk", name: "Don't Ask", description: "Deny if not pre-approved" }, + ], + }; + permissionResponse = { outcome: { outcome: "selected", optionId: "auto" } }; + + const canUseTool = (agent as any).canUseTool(SESSION_ID); + const result = await canUseTool( + "ExitPlanMode", + { plan: "do stuff" }, + { signal: new AbortController().signal, suggestions: undefined, toolUseID: "toolu_2" }, + ); + + expect(capturedPermissionRequest).not.toBeNull(); + const optionIds = capturedPermissionRequest.options.map((o: any) => o.optionId); + expect(optionIds).not.toContain("auto"); + expect(result.behavior).toBe("deny"); + expect(sessionUpdates).toHaveLength(0); + }); + + it("includes the `auto` option on a model with supportsAutoMode", async () => { + const session = (agent as unknown as { sessions: Record }).sessions[SESSION_ID]; + session.modes = { + currentModeId: "plan", + availableModes: [ + { id: "auto", name: "Auto", description: "Use a model classifier" }, + { id: "default", name: "Default", description: "Standard" }, + { id: "acceptEdits", name: "Accept Edits", description: "Auto-accept edits" }, + { id: "plan", name: "Plan Mode", description: "Planning mode" }, + { id: "dontAsk", name: "Don't Ask", description: "Deny if not pre-approved" }, + ], + }; + + const canUseTool = (agent as any).canUseTool(SESSION_ID); + const signal = new AbortController().signal; + try { + await canUseTool( + "ExitPlanMode", + { plan: "do stuff" }, + { signal, suggestions: undefined, toolUseID: "toolu_3" }, + ); + } catch { + // mock returns cancelled + } + + expect(capturedPermissionRequest).not.toBeNull(); + const optionIds = capturedPermissionRequest.options.map((o: any) => o.optionId); + expect(optionIds).toContain("auto"); + }); + }); +}); diff --git a/qiming-claude-code-acp-ts/src/tests/session-load.test.ts b/qiming-claude-code-acp-ts/src/tests/session-load.test.ts new file mode 100644 index 00000000..7b3fb57e --- /dev/null +++ b/qiming-claude-code-acp-ts/src/tests/session-load.test.ts @@ -0,0 +1,333 @@ +import { describe, it, expect } from "vitest"; +import { randomUUID } from "crypto"; +import { AgentSideConnection, RequestError, SessionNotification } from "@agentclientprotocol/sdk"; +import { query, getSessionMessages } from "@anthropic-ai/claude-agent-sdk"; +import { ClaudeAcpAgent } from "../acp-agent.js"; +import { Pushable } from "../utils.js"; +import type { SDKUserMessage } from "@anthropic-ai/claude-agent-sdk"; + +function createMockClient(): AgentSideConnection { + return { + sessionUpdate: async (_notification: SessionNotification) => {}, + requestPermission: async () => ({ outcome: { outcome: "cancelled" } }), + readTextFile: async () => ({ content: "" }), + writeTextFile: async () => ({}), + } as unknown as AgentSideConnection; +} + +describe.skipIf(!process.env.RUN_INTEGRATION_TESTS)("session load/resume lifecycle", () => { + it("SDK: session created but never prompted has no messages and is not resumable", async () => { + // Create a session via the SDK, initialize it, but never send a prompt + const sessionId = randomUUID(); + const input = new Pushable(); + + const q = query({ + prompt: input, + options: { + systemPrompt: { type: "preset", preset: "claude_code" }, + sessionId, + settingSources: ["user", "project", "local"], + includePartialMessages: true, + }, + }); + + // initializationResult() works without needing a prompt pushed + const initResult = await q.initializationResult(); + expect(initResult).toBeDefined(); + + // Close without ever prompting + input.end(); + q.return(undefined); + + // Verify no messages were stored + const messages = await getSessionMessages(sessionId); + expect(messages).toEqual([]); + + // Verify the session is not resumable + const input2 = new Pushable(); + const q2 = query({ + prompt: input2, + options: { + systemPrompt: { type: "preset", preset: "claude_code" }, + resume: sessionId, + settingSources: ["user", "project", "local"], + includePartialMessages: true, + }, + }); + + await expect(q2.initializationResult()).rejects.toThrow( + /No conversation found with session ID/, + ); + + input2.end(); + q2.return(undefined); + }, 30000); + + it("ACP: loadSession throws resourceNotFound for a non-existent session", async () => { + const agent = new ClaudeAcpAgent(createMockClient()); + const bogusSessionId = randomUUID(); + + try { + await expect( + agent.loadSession({ + sessionId: bogusSessionId, + cwd: process.cwd(), + mcpServers: [], + }), + ).rejects.toThrow(RequestError); + } finally { + await agent.dispose(); + } + }, 30000); + + it("ACP: resumeSession throws resourceNotFound for a non-existent session", async () => { + const agent = new ClaudeAcpAgent(createMockClient()); + const bogusSessionId = randomUUID(); + + try { + await expect( + agent.resumeSession({ + sessionId: bogusSessionId, + cwd: process.cwd(), + mcpServers: [], + }), + ).rejects.toThrow(RequestError); + } finally { + await agent.dispose(); + } + }, 30000); + + it("ACP: newSession without prompt, then loadSession on fresh agent throws resourceNotFound", async () => { + // Step 1: Create a real session via ACP, never prompt, dispose + const agentA = new ClaudeAcpAgent(createMockClient()); + const { sessionId } = await agentA.newSession({ + cwd: process.cwd(), + mcpServers: [], + }); + expect(sessionId).toBeDefined(); + await agentA.dispose(); + + // Step 2: Fresh agent tries to load that session + const agentB = new ClaudeAcpAgent(createMockClient()); + + try { + await expect( + agentB.loadSession({ + sessionId, + cwd: process.cwd(), + mcpServers: [], + }), + ).rejects.toThrow(RequestError); + } finally { + await agentB.dispose(); + } + }, 30000); + + it("ACP: newSession without prompt, then resumeSession on fresh agent throws resourceNotFound", async () => { + const agentA = new ClaudeAcpAgent(createMockClient()); + const { sessionId } = await agentA.newSession({ + cwd: process.cwd(), + mcpServers: [], + }); + await agentA.dispose(); + + const agentB = new ClaudeAcpAgent(createMockClient()); + + try { + await expect( + agentB.resumeSession({ + sessionId, + cwd: process.cwd(), + mcpServers: [], + }), + ).rejects.toThrow(RequestError); + } finally { + await agentB.dispose(); + } + }, 30000); + + // Regression test for https://github.com/zed-industries/claude-code-acp/issues/579 + // The client (Zed) renders its own local slash commands — e.g. `/model` — + // by injecting user-message prompts whose text is wrapped in + // `` / `` markers. The Claude SDK + // persists those messages verbatim in the session transcript. Without + // filtering, `loadSession` replays the markers as user_message_chunks + // ahead of the real prompt, leaking CLI internals into the transcript. + it("ACP: loadSession does not replay local-command metadata user messages", async () => { + const recordedUserChunks: string[] = []; + const client = { + sessionUpdate: async (notification: SessionNotification) => { + if (notification.update.sessionUpdate === "user_message_chunk") { + const content = notification.update.content; + if (content.type === "text") recordedUserChunks.push(content.text); + } + }, + requestPermission: async () => ({ outcome: { outcome: "cancelled" } }), + readTextFile: async () => ({ content: "" }), + writeTextFile: async () => ({}), + } as unknown as AgentSideConnection; + + const commandName = + "/model\n model\n opus"; + const commandStdout = + "Set model to opus (claude-opus-4-7)"; + + // Step 1: create a session via the SDK, push the marker user messages + // followed by a real "hi" prompt. We bypass the ACP agent's prompt loop + // so we're only testing what the loadSession replay path does, not how + // prompts get routed. + const sessionId = randomUUID(); + const input = new Pushable(); + const q = query({ + prompt: input, + options: { + systemPrompt: { type: "preset", preset: "claude_code" }, + sessionId, + settingSources: ["user", "project", "local"], + includePartialMessages: true, + }, + }); + await q.initializationResult(); + + // Zed renders its local slash commands by mixing the marker blocks into + // the user's prompt content array alongside the real text. The Claude + // SDK persists the message verbatim — including the marker blocks — + // which is what loadSession must filter when it replays. + input.push({ + type: "user", + message: { + role: "user", + content: [ + { type: "text", text: commandName }, + { type: "text", text: commandStdout }, + { type: "text", text: "hi" }, + ], + }, + session_id: sessionId, + parent_tool_use_id: null, + }); + + for await (const msg of q) { + if (msg.type === "result") break; + } + input.end(); + q.return(undefined); + + // Sanity check: the SDK transcript contains the metadata we're filtering. + const sdkMessages = await getSessionMessages(sessionId); + const sdkTexts = sdkMessages + .map((m) => { + const c = (m as { message?: { content?: unknown } }).message?.content; + if (typeof c === "string") return c; + if (Array.isArray(c)) + return c + .map((b) => + b && typeof b === "object" && "text" in b + ? String((b as { text: unknown }).text) + : "", + ) + .join(""); + return ""; + }) + .join("\n"); + expect(sdkTexts).toContain(""); + expect(sdkTexts).toContain(""); + expect(sdkTexts).toContain("hi"); + + // Step 2: load the session through the ACP agent and confirm the markers + // never reach the client as user_message_chunks, while the real "hi" + // prompt does. + const agent = new ClaudeAcpAgent(client); + try { + await agent.loadSession({ + sessionId, + cwd: process.cwd(), + mcpServers: [], + }); + } finally { + await agent.dispose(); + } + + for (const chunk of recordedUserChunks) { + expect(chunk).not.toContain(""); + expect(chunk).not.toContain(""); + expect(chunk).not.toContain(""); + expect(chunk).not.toContain(""); + expect(chunk).not.toContain(""); + } + expect(recordedUserChunks.some((c) => c.includes("hi"))).toBe(true); + }, 60000); + + // Second regression: the original issue showed the markers and the real + // "hi" prompt all concatenated into a single string ending with "hi". + // loadSession must strip the marker tags in-place rather than dropping the + // whole message. + it("ACP: loadSession strips marker tags from a concatenated single-string prompt", async () => { + const recordedUserChunks: string[] = []; + const client = { + sessionUpdate: async (notification: SessionNotification) => { + if (notification.update.sessionUpdate === "user_message_chunk") { + const content = notification.update.content; + if (content.type === "text") recordedUserChunks.push(content.text); + } + }, + requestPermission: async () => ({ outcome: { outcome: "cancelled" } }), + readTextFile: async () => ({ content: "" }), + writeTextFile: async () => ({}), + } as unknown as AgentSideConnection; + + // Exact shape from https://github.com/zed-industries/claude-code-acp/issues/579. + const concatenated = + "/model\n model\n opus" + + "Set model to opus (claude-opus-4-7)\n" + + "/model\n model\n opus[1m]" + + "Set model to opus[1m] (claude-opus-4-7[1m])" + + "hi"; + + const sessionId = randomUUID(); + const input = new Pushable(); + const q = query({ + prompt: input, + options: { + systemPrompt: { type: "preset", preset: "claude_code" }, + sessionId, + settingSources: ["user", "project", "local"], + includePartialMessages: true, + }, + }); + await q.initializationResult(); + + input.push({ + type: "user", + message: { role: "user", content: concatenated }, + session_id: sessionId, + parent_tool_use_id: null, + }); + for await (const msg of q) { + if (msg.type === "result") break; + } + input.end(); + q.return(undefined); + + const agent = new ClaudeAcpAgent(client); + try { + await agent.loadSession({ + sessionId, + cwd: process.cwd(), + mcpServers: [], + }); + } finally { + await agent.dispose(); + } + + expect(recordedUserChunks.length).toBeGreaterThan(0); + for (const chunk of recordedUserChunks) { + expect(chunk).not.toContain(""); + expect(chunk).not.toContain(""); + expect(chunk).not.toContain(""); + expect(chunk).not.toContain(""); + expect(chunk).not.toContain(""); + } + expect(recordedUserChunks.some((c) => c.includes("hi"))).toBe(true); + }, 60000); +}); diff --git a/qiming-claude-code-acp-ts/src/tests/settings.test.ts b/qiming-claude-code-acp-ts/src/tests/settings.test.ts new file mode 100644 index 00000000..f2cb37cb --- /dev/null +++ b/qiming-claude-code-acp-ts/src/tests/settings.test.ts @@ -0,0 +1,206 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { SettingsManager } from "../settings.js"; +import * as fs from "node:fs"; +import * as path from "node:path"; +import * as os from "node:os"; + +describe("SettingsManager", () => { + let tempDir: string; + let settingsManager: SettingsManager; + + beforeEach(async () => { + tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "settings-test-")); + }); + + afterEach(async () => { + settingsManager?.dispose(); + await fs.promises.rm(tempDir, { recursive: true, force: true }); + }); + + describe("settings merging", () => { + it("should merge model setting with later sources taking precedence", async () => { + const claudeDir = path.join(tempDir, ".claude"); + await fs.promises.mkdir(claudeDir, { recursive: true }); + + // Project settings with one model + await fs.promises.writeFile( + path.join(claudeDir, "settings.json"), + JSON.stringify({ + model: "claude-3-5-sonnet", + }), + ); + + settingsManager = new SettingsManager(tempDir); + await settingsManager.initialize(); + + let settings = settingsManager.getSettings(); + expect(settings.model).toBe("claude-3-5-sonnet"); + + // Add local settings that override the model + await fs.promises.writeFile( + path.join(claudeDir, "settings.local.json"), + JSON.stringify({ + model: "claude-3-5-haiku", + }), + ); + + // Re-initialize to pick up local settings + settingsManager.dispose(); + settingsManager = new SettingsManager(tempDir); + await settingsManager.initialize(); + + settings = settingsManager.getSettings(); + expect(settings.model).toBe("claude-3-5-haiku"); + }); + + it("should expose availableModels from settings", async () => { + const claudeDir = path.join(tempDir, ".claude"); + await fs.promises.mkdir(claudeDir, { recursive: true }); + + await fs.promises.writeFile( + path.join(claudeDir, "settings.json"), + JSON.stringify({ + availableModels: ["claude-haiku-4-5", "claude-opus-4-7[1m]"], + }), + ); + + settingsManager = new SettingsManager(tempDir); + await settingsManager.initialize(); + + const settings = settingsManager.getSettings(); + expect(settings.availableModels).toEqual(["claude-haiku-4-5", "claude-opus-4-7[1m]"]); + }); + + it("should union and dedupe availableModels across sources", async () => { + // Per Claude Code docs: "When `availableModels` is set at multiple + // levels, such as user settings and project settings, arrays are + // merged and deduplicated." + // https://code.claude.com/docs/en/model-config#merge-behavior + const claudeDir = path.join(tempDir, ".claude"); + await fs.promises.mkdir(claudeDir, { recursive: true }); + + await fs.promises.writeFile( + path.join(claudeDir, "settings.json"), + JSON.stringify({ + availableModels: ["claude-haiku-4-5", "claude-opus-4-7[1m]"], + }), + ); + await fs.promises.writeFile( + path.join(claudeDir, "settings.local.json"), + JSON.stringify({ + // claude-opus-4-7[1m] overlaps with project; should be deduped. + availableModels: ["claude-opus-4-7[1m]", "claude-sonnet-4-6[1m]"], + }), + ); + + settingsManager = new SettingsManager(tempDir); + await settingsManager.initialize(); + + const settings = settingsManager.getSettings(); + expect(settings.availableModels).toEqual([ + "claude-haiku-4-5", + "claude-opus-4-7[1m]", + "claude-sonnet-4-6[1m]", + ]); + }); + + it("should merge permissions.defaultMode with later sources taking precedence", async () => { + const claudeDir = path.join(tempDir, ".claude"); + await fs.promises.mkdir(claudeDir, { recursive: true }); + + await fs.promises.writeFile( + path.join(claudeDir, "settings.json"), + JSON.stringify({ + permissions: { + defaultMode: "dontAsk", + }, + }), + ); + + settingsManager = new SettingsManager(tempDir); + await settingsManager.initialize(); + + let settings = settingsManager.getSettings(); + expect(settings.permissions?.defaultMode).toBe("dontAsk"); + + // Local settings override the mode + await fs.promises.writeFile( + path.join(claudeDir, "settings.local.json"), + JSON.stringify({ + permissions: { + defaultMode: "plan", + }, + }), + ); + + settingsManager.dispose(); + settingsManager = new SettingsManager(tempDir); + await settingsManager.initialize(); + + settings = settingsManager.getSettings(); + expect(settings.permissions?.defaultMode).toBe("plan"); + }); + }); + + describe("escalating defaultMode trust filter", () => { + // The SDK's filterEscalatingDefaultMode drops escalating values + // (bypassPermissions / auto / acceptEdits) that came from a repo-committed + // tier (.claude/settings.json), preventing a checked-in file from + // silently escalating permissions. Local (.claude/settings.local.json) + // and user-tier sources are not committed by convention, so escalating + // values from those tiers are preserved. + + it.each(["bypassPermissions", "auto", "acceptEdits"] as const)( + "drops %s when set in project-tier settings", + async (mode) => { + const claudeDir = path.join(tempDir, ".claude"); + await fs.promises.mkdir(claudeDir, { recursive: true }); + + await fs.promises.writeFile( + path.join(claudeDir, "settings.json"), + JSON.stringify({ permissions: { defaultMode: mode } }), + ); + + settingsManager = new SettingsManager(tempDir); + await settingsManager.initialize(); + + expect(settingsManager.getSettings().permissions?.defaultMode).toBeUndefined(); + }, + ); + + it.each(["plan", "dontAsk"] as const)( + "preserves non-escalating %s from project-tier settings", + async (mode) => { + const claudeDir = path.join(tempDir, ".claude"); + await fs.promises.mkdir(claudeDir, { recursive: true }); + + await fs.promises.writeFile( + path.join(claudeDir, "settings.json"), + JSON.stringify({ permissions: { defaultMode: mode } }), + ); + + settingsManager = new SettingsManager(tempDir); + await settingsManager.initialize(); + + expect(settingsManager.getSettings().permissions?.defaultMode).toBe(mode); + }, + ); + + it("preserves escalating defaultMode when it comes from local-tier settings", async () => { + // settings.local.json is git-ignored by convention, so the trust + // filter does not apply. + const claudeDir = path.join(tempDir, ".claude"); + await fs.promises.mkdir(claudeDir, { recursive: true }); + + await fs.promises.writeFile( + path.join(claudeDir, "settings.local.json"), + JSON.stringify({ permissions: { defaultMode: "acceptEdits" } }), + ); + + settingsManager = new SettingsManager(tempDir); + await settingsManager.initialize(); + + expect(settingsManager.getSettings().permissions?.defaultMode).toBe("acceptEdits"); + }); + }); +}); diff --git a/qiming-claude-code-acp-ts/src/tests/tools.test.ts b/qiming-claude-code-acp-ts/src/tests/tools.test.ts new file mode 100644 index 00000000..2642b3c5 --- /dev/null +++ b/qiming-claude-code-acp-ts/src/tests/tools.test.ts @@ -0,0 +1,1950 @@ +import { describe, it, expect } from "vitest"; +import { AgentSideConnection, ClientCapabilities } from "@agentclientprotocol/sdk"; +import { ImageBlockParam, ToolResultBlockParam } from "@anthropic-ai/sdk/resources"; +import { + BetaMCPToolResultBlock, + BetaTextBlock, + BetaWebSearchResultBlock, + BetaWebSearchToolResultBlock, + BetaBashCodeExecutionToolResultBlock, + BetaBashCodeExecutionResultBlock, + BetaBashCodeExecutionToolResultBlockParam, +} from "@anthropic-ai/sdk/resources/beta.mjs"; +import { toAcpNotifications, ToolUseCache, Logger } from "../acp-agent.js"; +import { + toolUpdateFromToolResult, + createPostToolUseHook, + createTaskHook, + toolInfoFromToolUse, + planEntries, + applyTaskCreate, + applyTaskUpdate, + parseTaskCreateOutput, + taskStateToPlanEntries, + TaskState, +} from "../tools.js"; + +describe("rawOutput in tool call updates", () => { + const mockClient = {} as AgentSideConnection; + const mockLogger: Logger = { log: () => {}, error: () => {} }; + + it("should include rawOutput with string content for tool_result", () => { + const toolUseCache: ToolUseCache = { + toolu_123: { + type: "tool_use", + id: "toolu_123", + name: "Bash", + input: { command: "echo hello" }, + }, + }; + + const toolResult: ToolResultBlockParam = { + type: "tool_result", + tool_use_id: "toolu_123", + content: "hello\n", + is_error: false, + }; + + const notifications = toAcpNotifications( + [toolResult], + "assistant", + "test-session", + toolUseCache, + mockClient, + mockLogger, + ); + + expect(notifications).toHaveLength(1); + expect(notifications[0].update).toMatchObject({ + sessionUpdate: "tool_call_update", + toolCallId: "toolu_123", + status: "completed", + rawOutput: "hello\n", + }); + }); + + it("should include rawOutput with array content for tool_result", () => { + const toolUseCache: ToolUseCache = { + toolu_456: { + type: "tool_use", + id: "toolu_456", + name: "Read", + input: { file_path: "/test/file.txt" }, + }, + }; + + // ToolResultBlockParam content can be string or array of TextBlockParam + const toolResult: ToolResultBlockParam = { + type: "tool_result", + tool_use_id: "toolu_456", + content: [{ type: "text", text: "Line 1\nLine 2\nLine 3" }], + is_error: false, + }; + + const notifications = toAcpNotifications( + [toolResult], + "assistant", + "test-session", + toolUseCache, + mockClient, + mockLogger, + ); + + expect(notifications).toHaveLength(1); + expect(notifications[0].update).toMatchObject({ + sessionUpdate: "tool_call_update", + toolCallId: "toolu_456", + status: "completed", + rawOutput: [{ type: "text", text: "Line 1\nLine 2\nLine 3" }], + }); + }); + + it("should include rawOutput for mcp_tool_result with string content", () => { + const toolUseCache: ToolUseCache = { + toolu_789: { + type: "tool_use", + id: "toolu_789", + name: "mcp__server__tool", + input: { query: "test" }, + }, + }; + + // BetaMCPToolResultBlock content can be string or Array + const toolResult: BetaMCPToolResultBlock = { + type: "mcp_tool_result", + tool_use_id: "toolu_789", + content: '{"result": "success", "data": [1, 2, 3]}', + is_error: false, + }; + + const notifications = toAcpNotifications( + [toolResult], + "assistant", + "test-session", + toolUseCache, + mockClient, + mockLogger, + ); + + expect(notifications).toHaveLength(1); + expect(notifications[0].update).toMatchObject({ + sessionUpdate: "tool_call_update", + toolCallId: "toolu_789", + status: "completed", + rawOutput: '{"result": "success", "data": [1, 2, 3]}', + }); + }); + + it("should include rawOutput for mcp_tool_result with array content", () => { + const toolUseCache: ToolUseCache = { + toolu_abc: { + type: "tool_use", + id: "toolu_abc", + name: "mcp__server__search", + input: { term: "test" }, + }, + }; + + // BetaTextBlock requires citations field + const arrayContent: BetaTextBlock[] = [ + { type: "text", text: "Result 1", citations: null }, + { type: "text", text: "Result 2", citations: null }, + ]; + + const toolResult: BetaMCPToolResultBlock = { + type: "mcp_tool_result", + tool_use_id: "toolu_abc", + content: arrayContent, + is_error: false, + }; + + const notifications = toAcpNotifications( + [toolResult], + "assistant", + "test-session", + toolUseCache, + mockClient, + mockLogger, + ); + + expect(notifications).toHaveLength(1); + expect(notifications[0].update).toMatchObject({ + sessionUpdate: "tool_call_update", + toolCallId: "toolu_abc", + status: "completed", + rawOutput: arrayContent, + }); + }); + + it("should include rawOutput for web_search_tool_result", () => { + const toolUseCache: ToolUseCache = { + toolu_web: { + type: "tool_use", + id: "toolu_web", + name: "WebSearch", + input: { query: "test search" }, + }, + }; + + // BetaWebSearchResultBlock from SDK + const searchResults: BetaWebSearchResultBlock[] = [ + { + type: "web_search_result", + url: "https://example.com", + title: "Example", + encrypted_content: "encrypted content here", + page_age: "2 days ago", + }, + ]; + + const toolResult: BetaWebSearchToolResultBlock = { + type: "web_search_tool_result", + tool_use_id: "toolu_web", + content: searchResults, + }; + + const notifications = toAcpNotifications( + [toolResult], + "assistant", + "test-session", + toolUseCache, + mockClient, + mockLogger, + ); + + expect(notifications).toHaveLength(1); + expect(notifications[0].update).toMatchObject({ + sessionUpdate: "tool_call_update", + toolCallId: "toolu_web", + status: "completed", + rawOutput: searchResults, + }); + }); + + it("should include rawOutput for bash_code_execution_tool_result", () => { + const toolUseCache: ToolUseCache = { + toolu_bash: { + type: "tool_use", + id: "toolu_bash", + name: "Bash", + input: { command: "ls -la" }, + }, + }; + + // BetaBashCodeExecutionResultBlock from SDK + const bashResult: BetaBashCodeExecutionResultBlock = { + type: "bash_code_execution_result", + stdout: "file1.txt\nfile2.txt", + stderr: "", + return_code: 0, + content: [], + }; + + const toolResult: BetaBashCodeExecutionToolResultBlock = { + type: "bash_code_execution_tool_result", + tool_use_id: "toolu_bash", + content: bashResult, + }; + + const notifications = toAcpNotifications( + [toolResult], + "assistant", + "test-session", + toolUseCache, + mockClient, + mockLogger, + ); + + expect(notifications).toHaveLength(1); + expect(notifications[0].update).toMatchObject({ + sessionUpdate: "tool_call_update", + toolCallId: "toolu_bash", + status: "completed", + rawOutput: bashResult, + }); + }); + + it("should set status to failed when is_error is true", () => { + const toolUseCache: ToolUseCache = { + toolu_err: { + type: "tool_use", + id: "toolu_err", + name: "Bash", + input: { command: "invalid_command" }, + }, + }; + + const toolResult: ToolResultBlockParam = { + type: "tool_result", + tool_use_id: "toolu_err", + content: "command not found: invalid_command", + is_error: true, + }; + + const notifications = toAcpNotifications( + [toolResult], + "assistant", + "test-session", + toolUseCache, + mockClient, + mockLogger, + ); + + expect(notifications).toHaveLength(1); + expect(notifications[0].update).toMatchObject({ + sessionUpdate: "tool_call_update", + toolCallId: "toolu_err", + status: "failed", + rawOutput: "command not found: invalid_command", + }); + }); + + it("should not emit tool_call_update for TodoWrite (emits plan instead)", () => { + const toolUseCache: ToolUseCache = { + toolu_todo: { + type: "tool_use", + id: "toolu_todo", + name: "TodoWrite", + input: { todos: [{ content: "Test task", status: "pending" }] }, + }, + }; + + const toolResult: ToolResultBlockParam = { + type: "tool_result", + tool_use_id: "toolu_todo", + content: "Todos updated successfully", + is_error: false, + }; + + const notifications = toAcpNotifications( + [toolResult], + "assistant", + "test-session", + toolUseCache, + mockClient, + mockLogger, + ); + + // TodoWrite should not emit tool_call_update - it emits plan updates instead + expect(notifications).toHaveLength(0); + }); + + it("should convert Read tool base64 image content to ACP image format", () => { + const toolUseCache: ToolUseCache = { + toolu_img: { + type: "tool_use", + id: "toolu_img", + name: "Read", + input: { file_path: "/test/image.png" }, + }, + }; + + const imageBlock: ImageBlockParam = { + type: "image", + source: { type: "base64", data: "iVBORw0KGgo=", media_type: "image/png" }, + }; + + const toolResult: ToolResultBlockParam = { + type: "tool_result", + tool_use_id: "toolu_img", + content: [imageBlock], + is_error: false, + }; + + const notifications = toAcpNotifications( + [toolResult], + "assistant", + "test-session", + toolUseCache, + mockClient, + mockLogger, + ); + + expect(notifications).toHaveLength(1); + expect(notifications[0].update).toMatchObject({ + sessionUpdate: "tool_call_update", + toolCallId: "toolu_img", + status: "completed", + content: [ + { + type: "content", + content: { type: "image", data: "iVBORw0KGgo=", mimeType: "image/png" }, + }, + ], + }); + }); + + it("should handle Read tool with mixed text and image content", () => { + const toolUseCache: ToolUseCache = { + toolu_mix: { + type: "tool_use", + id: "toolu_mix", + name: "Read", + input: { file_path: "/test/image.png" }, + }, + }; + + const imageBlock: ImageBlockParam = { + type: "image", + source: { type: "base64", data: "iVBORw0KGgo=", media_type: "image/png" }, + }; + + const toolResult: ToolResultBlockParam = { + type: "tool_result", + tool_use_id: "toolu_mix", + content: [{ type: "text", text: "File preview:" }, imageBlock], + is_error: false, + }; + + const notifications = toAcpNotifications( + [toolResult], + "assistant", + "test-session", + toolUseCache, + mockClient, + mockLogger, + ); + + expect(notifications).toHaveLength(1); + expect(notifications[0].update).toMatchObject({ + sessionUpdate: "tool_call_update", + toolCallId: "toolu_mix", + status: "completed", + content: [ + { + type: "content", + content: { type: "text", text: "```\nFile preview:\n```" }, + }, + { + type: "content", + content: { type: "image", data: "iVBORw0KGgo=", mimeType: "image/png" }, + }, + ], + }); + }); +}); + +describe("Bash terminal output", () => { + const mockClient = {} as AgentSideConnection; + const mockLogger: Logger = { log: () => {}, error: () => {} }; + + const bashToolUse = { + type: "tool_use", + id: "toolu_bash", + name: "Bash", + input: { command: "ls -la" }, + }; + + const makeBashResult = ( + stdout: string, + stderr: string, + return_code: number, + ): BetaBashCodeExecutionToolResultBlockParam => ({ + type: "bash_code_execution_tool_result", + tool_use_id: "toolu_bash", + content: { + type: "bash_code_execution_result", + stdout, + stderr, + return_code, + content: [], + }, + }); + + describe("toolUpdateFromToolResult", () => { + it("should return formatted content without _meta when supportsTerminalOutput is false", () => { + const toolResult = makeBashResult("file1.txt\nfile2.txt", "", 0); + const update = toolUpdateFromToolResult(toolResult, bashToolUse, false); + + expect(update).toEqual({ + content: [ + { + type: "content", + content: { + type: "text", + text: "```console\nfile1.txt\nfile2.txt\n```", + }, + }, + ], + }); + expect(update._meta).toBeUndefined(); + }); + + it("should return no content with _meta when supportsTerminalOutput is true", () => { + const toolResult = makeBashResult("file1.txt\nfile2.txt", "", 0); + const update = toolUpdateFromToolResult(toolResult, bashToolUse, true); + + expect(update.content).toEqual([{ type: "terminal", terminalId: "toolu_bash" }]); + expect(update._meta).toEqual({ + terminal_info: { + terminal_id: "toolu_bash", + }, + terminal_output: { + terminal_id: "toolu_bash", + data: "file1.txt\nfile2.txt", + }, + terminal_exit: { + terminal_id: "toolu_bash", + exit_code: 0, + signal: null, + }, + }); + }); + + it("should include exit_code from return_code in terminal_exit", () => { + const toolResult = makeBashResult("", "command not found", 127); + const update = toolUpdateFromToolResult(toolResult, bashToolUse, true); + + expect(update._meta?.terminal_exit).toEqual({ + terminal_id: "toolu_bash", + exit_code: 127, + signal: null, + }); + }); + + it("should fall back to stderr when stdout is empty", () => { + const toolResult = makeBashResult("", "some error output", 1); + const update = toolUpdateFromToolResult(toolResult, bashToolUse, false); + + expect(update.content).toEqual([ + { + type: "content", + content: { + type: "text", + text: "```console\nsome error output\n```", + }, + }, + ]); + }); + + it("should return no content with _meta when output is empty and supportsTerminalOutput is true", () => { + const toolResult = makeBashResult("", "", 0); + const update = toolUpdateFromToolResult(toolResult, bashToolUse, true); + + expect(update.content).toEqual([{ type: "terminal", terminalId: "toolu_bash" }]); + expect(update._meta).toEqual({ + terminal_info: { + terminal_id: "toolu_bash", + }, + terminal_output: { + terminal_id: "toolu_bash", + data: "", + }, + terminal_exit: { + terminal_id: "toolu_bash", + exit_code: 0, + signal: null, + }, + }); + }); + + it("should return empty object when output is empty and supportsTerminalOutput is false", () => { + const toolResult = makeBashResult("", "", 0); + const update = toolUpdateFromToolResult(toolResult, bashToolUse, false); + + expect(update).toEqual({}); + }); + + it("should default supportsTerminalOutput to false when not provided", () => { + const toolResult = makeBashResult("hello", "", 0); + const update = toolUpdateFromToolResult(toolResult, bashToolUse); + + expect(update._meta).toBeUndefined(); + expect(update.content).toEqual([ + { + type: "content", + content: { + type: "text", + text: "```console\nhello\n```", + }, + }, + ]); + }); + + it("should preserve trailing whitespace in _meta data when supportsTerminalOutput is true", () => { + const toolResult = makeBashResult("hello\n\n\n", "", 0); + const update = toolUpdateFromToolResult(toolResult, bashToolUse, true); + + expect(update.content).toEqual([{ type: "terminal", terminalId: "toolu_bash" }]); + expect(update._meta?.terminal_output?.data).toBe("hello\n\n\n"); + }); + + describe("with plain string tool_result (production format)", () => { + const makeStringBashResult = ( + content: string, + is_error: boolean = false, + ): ToolResultBlockParam => ({ + type: "tool_result", + tool_use_id: "toolu_bash", + content, + is_error, + }); + + it("should format string content as sh code block without _meta when supportsTerminalOutput is false", () => { + const toolResult = makeStringBashResult("Cargo.lock\nCargo.toml\nREADME.md"); + const update = toolUpdateFromToolResult(toolResult, bashToolUse, false); + + expect(update).toEqual({ + content: [ + { + type: "content", + content: { + type: "text", + text: "```console\nCargo.lock\nCargo.toml\nREADME.md\n```", + }, + }, + ], + }); + expect(update._meta).toBeUndefined(); + }); + + it("should return no content with _meta when supportsTerminalOutput is true", () => { + const toolResult = makeStringBashResult("Cargo.lock\nCargo.toml\nREADME.md"); + const update = toolUpdateFromToolResult(toolResult, bashToolUse, true); + + expect(update.content).toEqual([{ type: "terminal", terminalId: "toolu_bash" }]); + expect(update._meta).toEqual({ + terminal_info: { terminal_id: "toolu_bash" }, + terminal_output: { terminal_id: "toolu_bash", data: "Cargo.lock\nCargo.toml\nREADME.md" }, + terminal_exit: { terminal_id: "toolu_bash", exit_code: 0, signal: null }, + }); + }); + + it("should use error handler when is_error is true (early return before Bash case)", () => { + const toolResult = makeStringBashResult("command not found: bad_cmd", true); + const update = toolUpdateFromToolResult(toolResult, bashToolUse, true); + + // is_error with content hits the early error return at the top of + // toolUpdateFromToolResult, before reaching the Bash switch case. + // So there's no terminal _meta, just error-formatted content. + expect(update._meta).toBeUndefined(); + expect(update.content).toEqual([ + { + type: "content", + content: { + type: "text", + text: "```\ncommand not found: bad_cmd\n```", + }, + }, + ]); + }); + + it("should return empty object for empty string content without terminal support", () => { + const toolResult = makeStringBashResult(""); + const update = toolUpdateFromToolResult(toolResult, bashToolUse, false); + + expect(update).toEqual({}); + }); + + it("should return no content with _meta for empty string content with terminal support", () => { + const toolResult = makeStringBashResult(""); + const update = toolUpdateFromToolResult(toolResult, bashToolUse, true); + + expect(update.content).toEqual([{ type: "terminal", terminalId: "toolu_bash" }]); + expect(update._meta).toEqual({ + terminal_info: { terminal_id: "toolu_bash" }, + terminal_output: { terminal_id: "toolu_bash", data: "" }, + terminal_exit: { terminal_id: "toolu_bash", exit_code: 0, signal: null }, + }); + }); + + it("should handle array content with text blocks", () => { + const toolResult: ToolResultBlockParam = { + type: "tool_result", + tool_use_id: "toolu_bash", + content: [{ type: "text", text: "line1\nline2" }], + is_error: false, + }; + const update = toolUpdateFromToolResult(toolResult, bashToolUse, false); + + expect(update).toEqual({ + content: [ + { + type: "content", + content: { + type: "text", + text: "```console\nline1\nline2\n```", + }, + }, + ], + }); + }); + }); + }); + + describe("toAcpNotifications with clientCapabilities", () => { + const toolUseCache: ToolUseCache = { + toolu_bash: { + type: "tool_use", + id: "toolu_bash", + name: "Bash", + input: { command: "ls -la" }, + }, + }; + + const bashResult: BetaBashCodeExecutionResultBlock = { + type: "bash_code_execution_result", + stdout: "file1.txt\nfile2.txt", + stderr: "", + return_code: 0, + content: [], + }; + + const toolResult: BetaBashCodeExecutionToolResultBlock = { + type: "bash_code_execution_tool_result", + tool_use_id: "toolu_bash", + content: bashResult, + }; + + it("should include terminal _meta when client declares terminal_output support", () => { + const clientCapabilities: ClientCapabilities = { + _meta: { terminal_output: true }, + }; + + const notifications = toAcpNotifications( + [toolResult], + "assistant", + "test-session", + toolUseCache, + mockClient, + mockLogger, + { clientCapabilities }, + ); + + // Split into 2 notifications: terminal_output, then terminal_exit + completion + expect(notifications).toHaveLength(2); + + // First notification: terminal_output only + const outputUpdate = notifications[0].update; + expect(outputUpdate).toMatchObject({ + sessionUpdate: "tool_call_update", + toolCallId: "toolu_bash", + }); + expect((outputUpdate as any)._meta).toEqual({ + terminal_output: { terminal_id: "toolu_bash", data: "file1.txt\nfile2.txt" }, + }); + expect((outputUpdate as any).status).toBeUndefined(); + + // Second notification: terminal_exit + status + content + const exitUpdate = notifications[1].update; + expect(exitUpdate).toMatchObject({ + sessionUpdate: "tool_call_update", + toolCallId: "toolu_bash", + status: "completed", + }); + expect((exitUpdate as any)._meta).toMatchObject({ + terminal_exit: { terminal_id: "toolu_bash", exit_code: 0, signal: null }, + }); + // terminal_info and terminal_output should NOT be on the exit notification + expect((exitUpdate as any)._meta).not.toHaveProperty("terminal_info"); + expect((exitUpdate as any)._meta).not.toHaveProperty("terminal_output"); + }); + + it("should not include terminal _meta when client does not declare terminal_output support", () => { + const notifications = toAcpNotifications( + [toolResult], + "assistant", + "test-session", + toolUseCache, + mockClient, + mockLogger, + ); + + expect(notifications).toHaveLength(1); + const update = notifications[0].update; + expect(update).toMatchObject({ + sessionUpdate: "tool_call_update", + toolCallId: "toolu_bash", + status: "completed", + }); + expect((update as any)._meta).not.toHaveProperty("terminal_info"); + expect((update as any)._meta).not.toHaveProperty("terminal_output"); + expect((update as any)._meta).not.toHaveProperty("terminal_exit"); + }); + + it("should not include terminal _meta when _meta.terminal_output is false", () => { + const clientCapabilities: ClientCapabilities = { + _meta: { terminal_output: false }, + }; + + const notifications = toAcpNotifications( + [toolResult], + "assistant", + "test-session", + toolUseCache, + mockClient, + mockLogger, + { clientCapabilities }, + ); + + expect(notifications).toHaveLength(1); + expect((notifications[0].update as any)._meta).not.toHaveProperty("terminal_output"); + }); + + it("should include formatted content only when terminal_output is not supported", () => { + const withSupport = toAcpNotifications( + [toolResult], + "assistant", + "test-session", + toolUseCache, + mockClient, + mockLogger, + { clientCapabilities: { _meta: { terminal_output: true } } }, + ); + + const withoutSupport = toAcpNotifications( + [toolResult], + "assistant", + "test-session", + toolUseCache, + mockClient, + mockLogger, + ); + + // With support: output is delivered via terminal_output _meta, content references the terminal widget + expect(withSupport).toHaveLength(2); + expect((withSupport[1].update as any).content).toEqual([ + { type: "terminal", terminalId: "toolu_bash" }, + ]); + + // Without support: content is on the only notification + expect((withoutSupport[0].update as any).content).toEqual([ + { + type: "content", + content: { + type: "text", + text: "```console\nfile1.txt\nfile2.txt\n```", + }, + }, + ]); + }); + + it("should preserve claudeCode in _meta alongside terminal_exit on completion notification", () => { + const clientCapabilities: ClientCapabilities = { + _meta: { terminal_output: true }, + }; + + const notifications = toAcpNotifications( + [toolResult], + "assistant", + "test-session", + toolUseCache, + mockClient, + mockLogger, + { clientCapabilities }, + ); + + expect(notifications).toHaveLength(2); + + // First notification (terminal_output) has no claudeCode + const outputMeta = (notifications[0].update as any)._meta; + expect(outputMeta.terminal_output).toBeDefined(); + expect(outputMeta.claudeCode).toBeUndefined(); + + // Second notification (completion) has claudeCode + terminal_exit + const exitMeta = (notifications[1].update as any)._meta; + expect(exitMeta.claudeCode).toEqual({ toolName: "Bash" }); + expect(exitMeta.terminal_exit).toBeDefined(); + }); + }); + + describe("post-tool-use hook sends diff content for Edit tool", () => { + it("should include content and locations from structuredPatch in hook update", async () => { + const toolUseCache: ToolUseCache = {}; + + const hookUpdates: any[] = []; + const mockClientWithUpdate = { + sessionUpdate: async (notification: any) => { + hookUpdates.push(notification); + }, + } as unknown as AgentSideConnection; + + // Register hook callback by processing tool_use + toAcpNotifications( + [ + { + type: "tool_use" as const, + id: "toolu_edit_hook", + name: "Edit", + input: { + file_path: "/Users/test/project/file.ts", + old_string: "old text", + new_string: "new text", + }, + }, + ], + "assistant", + "test-session", + toolUseCache, + mockClientWithUpdate, + mockLogger, + ); + + // Fire PostToolUse hook with a structuredPatch in tool_response + const hook = createPostToolUseHook(mockLogger); + await hook( + { + hook_event_name: "PostToolUse", + tool_name: "Edit", + tool_input: { + file_path: "/Users/test/project/file.ts", + old_string: "old text", + new_string: "new text", + }, + tool_response: { + filePath: "/Users/test/project/file.ts", + oldString: "old text", + newString: "new text", + structuredPatch: [ + { + oldStart: 5, + oldLines: 3, + newStart: 5, + newLines: 3, + lines: [" context before", "-old text", "+new text", " context after"], + }, + ], + }, + tool_use_id: "toolu_edit_hook", + session_id: "test-session", + transcript_path: "/tmp/test", + cwd: "/tmp", + }, + "toolu_edit_hook", + { signal: AbortSignal.abort() }, + ); + + expect(hookUpdates).toHaveLength(1); + const hookUpdate = hookUpdates[0].update; + expect(hookUpdate._meta.claudeCode.toolName).toBe("Edit"); + expect(hookUpdate.content).toEqual([ + { + type: "diff", + path: "/Users/test/project/file.ts", + oldText: "context before\nold text\ncontext after", + newText: "context before\nnew text\ncontext after", + }, + ]); + expect(hookUpdate.locations).toEqual([{ path: "/Users/test/project/file.ts", line: 5 }]); + }); + + it("should include multiple diff blocks for replaceAll with multiple hunks", async () => { + const toolUseCache: ToolUseCache = {}; + + const hookUpdates: any[] = []; + const mockClientWithUpdate = { + sessionUpdate: async (notification: any) => { + hookUpdates.push(notification); + }, + } as unknown as AgentSideConnection; + + toAcpNotifications( + [ + { + type: "tool_use" as const, + id: "toolu_edit_replace_all", + name: "Edit", + input: { + file_path: "/Users/test/project/file.ts", + old_string: "foo", + new_string: "bar", + replace_all: true, + }, + }, + ], + "assistant", + "test-session", + toolUseCache, + mockClientWithUpdate, + mockLogger, + ); + + const hook = createPostToolUseHook(mockLogger); + await hook( + { + hook_event_name: "PostToolUse", + tool_name: "Edit", + tool_input: { + file_path: "/Users/test/project/file.ts", + old_string: "foo", + new_string: "bar", + replace_all: true, + }, + tool_response: { + filePath: "/Users/test/project/file.ts", + oldString: "foo", + newString: "bar", + replaceAll: true, + structuredPatch: [ + { + oldStart: 3, + oldLines: 1, + newStart: 3, + newLines: 1, + lines: ["-foo", "+bar"], + }, + { + oldStart: 15, + oldLines: 1, + newStart: 15, + newLines: 1, + lines: ["-foo", "+bar"], + }, + ], + }, + tool_use_id: "toolu_edit_replace_all", + session_id: "test-session", + transcript_path: "/tmp/test", + cwd: "/tmp", + }, + "toolu_edit_replace_all", + { signal: AbortSignal.abort() }, + ); + + expect(hookUpdates).toHaveLength(1); + const hookUpdate = hookUpdates[0].update; + expect(hookUpdate.content).toEqual([ + { type: "diff", path: "/Users/test/project/file.ts", oldText: "foo", newText: "bar" }, + { type: "diff", path: "/Users/test/project/file.ts", oldText: "foo", newText: "bar" }, + ]); + expect(hookUpdate.locations).toEqual([ + { path: "/Users/test/project/file.ts", line: 3 }, + { path: "/Users/test/project/file.ts", line: 15 }, + ]); + }); + + it("should not include content/locations for non-Edit tools", async () => { + const toolUseCache: ToolUseCache = {}; + + const hookUpdates: any[] = []; + const mockClientWithUpdate = { + sessionUpdate: async (notification: any) => { + hookUpdates.push(notification); + }, + } as unknown as AgentSideConnection; + + toAcpNotifications( + [ + { + type: "tool_use" as const, + id: "toolu_bash_no_diff", + name: "Bash", + input: { command: "echo hi" }, + }, + ], + "assistant", + "test-session", + toolUseCache, + mockClientWithUpdate, + mockLogger, + ); + + const hook = createPostToolUseHook(mockLogger); + await hook( + { + hook_event_name: "PostToolUse", + tool_name: "Bash", + tool_input: { command: "echo hi" }, + tool_response: "hi", + tool_use_id: "toolu_bash_no_diff", + session_id: "test-session", + transcript_path: "/tmp/test", + cwd: "/tmp", + }, + "toolu_bash_no_diff", + { signal: AbortSignal.abort() }, + ); + + expect(hookUpdates).toHaveLength(1); + const hookUpdate = hookUpdates[0].update; + expect(hookUpdate.content).toBeUndefined(); + expect(hookUpdate.locations).toBeUndefined(); + }); + }); + + describe("post-tool-use hook sends diff content for Write tool", () => { + // Regression: previously the dispatch only invoked + // toolUpdateFromDiffToolResponse for `Edit`, so a Write that + // overwrote an existing file (FileWriteOutput.type === "update") + // showed a "creation" diff (oldText: null, full new content) at + // tool_use time and was never corrected after the tool ran. + it("should emit a real diff for Write of an existing file (type: update)", async () => { + const toolUseCache: ToolUseCache = {}; + const hookUpdates: any[] = []; + const mockClientWithUpdate = { + sessionUpdate: async (notification: any) => { + hookUpdates.push(notification); + }, + } as unknown as AgentSideConnection; + + toAcpNotifications( + [ + { + type: "tool_use" as const, + id: "toolu_write_update", + name: "Write", + input: { + file_path: "/Users/test/project/file.ts", + content: "line1\nNEW line2\nline3", + }, + }, + ], + "assistant", + "test-session", + toolUseCache, + mockClientWithUpdate, + mockLogger, + ); + + const hook = createPostToolUseHook(mockLogger); + await hook( + { + hook_event_name: "PostToolUse", + tool_name: "Write", + tool_input: { + file_path: "/Users/test/project/file.ts", + content: "line1\nNEW line2\nline3", + }, + tool_response: { + type: "update", + filePath: "/Users/test/project/file.ts", + content: "line1\nNEW line2\nline3", + originalFile: "line1\nold line2\nline3", + structuredPatch: [ + { + oldStart: 1, + oldLines: 3, + newStart: 1, + newLines: 3, + lines: [" line1", "-old line2", "+NEW line2", " line3"], + }, + ], + userModified: false, + }, + tool_use_id: "toolu_write_update", + session_id: "test-session", + transcript_path: "/tmp/test", + cwd: "/tmp", + }, + "toolu_write_update", + { signal: AbortSignal.abort() }, + ); + + expect(hookUpdates).toHaveLength(1); + const hookUpdate = hookUpdates[0].update; + expect(hookUpdate._meta.claudeCode.toolName).toBe("Write"); + expect(hookUpdate.content).toEqual([ + { + type: "diff", + path: "/Users/test/project/file.ts", + oldText: "line1\nold line2\nline3", + newText: "line1\nNEW line2\nline3", + }, + ]); + expect(hookUpdate.locations).toEqual([{ path: "/Users/test/project/file.ts", line: 1 }]); + }); + + it("should still emit a sensible diff for Write of a brand-new file (type: create)", async () => { + const toolUseCache: ToolUseCache = {}; + const hookUpdates: any[] = []; + const mockClientWithUpdate = { + sessionUpdate: async (notification: any) => { + hookUpdates.push(notification); + }, + } as unknown as AgentSideConnection; + + toAcpNotifications( + [ + { + type: "tool_use" as const, + id: "toolu_write_create", + name: "Write", + input: { + file_path: "/Users/test/project/new.ts", + content: "first\nsecond", + }, + }, + ], + "assistant", + "test-session", + toolUseCache, + mockClientWithUpdate, + mockLogger, + ); + + const hook = createPostToolUseHook(mockLogger); + await hook( + { + hook_event_name: "PostToolUse", + tool_name: "Write", + tool_input: { + file_path: "/Users/test/project/new.ts", + content: "first\nsecond", + }, + tool_response: { + type: "create", + filePath: "/Users/test/project/new.ts", + content: "first\nsecond", + originalFile: null, + structuredPatch: [ + { + oldStart: 0, + oldLines: 0, + newStart: 1, + newLines: 2, + lines: ["+first", "+second"], + }, + ], + }, + tool_use_id: "toolu_write_create", + session_id: "test-session", + transcript_path: "/tmp/test", + cwd: "/tmp", + }, + "toolu_write_create", + { signal: AbortSignal.abort() }, + ); + + expect(hookUpdates).toHaveLength(1); + const hookUpdate = hookUpdates[0].update; + expect(hookUpdate.content).toEqual([ + { + type: "diff", + path: "/Users/test/project/new.ts", + oldText: null, + newText: "first\nsecond", + }, + ]); + }); + }); + + describe("post-tool-use hook preserves terminal _meta", () => { + it("should send terminal_output and terminal_exit as separate notifications, and hook should only have claudeCode", async () => { + const clientCapabilities: ClientCapabilities = { + _meta: { terminal_output: true }, + }; + + const toolUseCache: ToolUseCache = {}; + + // Capture session updates sent by the hook callback + const hookUpdates: any[] = []; + const mockClientWithUpdate = { + sessionUpdate: async (notification: any) => { + hookUpdates.push(notification); + }, + } as unknown as AgentSideConnection; + + // Step 1: Process tool_use chunk — registers the PostToolUse hook callback + const toolUseChunk = { + type: "tool_use" as const, + id: "toolu_bash_hook", + name: "Bash", + input: { command: "ls -la" }, + }; + const toolUseNotifications = toAcpNotifications( + [toolUseChunk], + "assistant", + "test-session", + toolUseCache, + mockClientWithUpdate, + mockLogger, + { clientCapabilities }, + ); + + // The initial tool_call should include terminal_info in _meta + expect(toolUseNotifications).toHaveLength(1); + expect((toolUseNotifications[0].update as any)._meta).toMatchObject({ + terminal_info: { terminal_id: "toolu_bash_hook" }, + }); + + // Step 2: Process bash result — produces separate terminal_output and terminal_exit notifications + const bashResult: BetaBashCodeExecutionResultBlock = { + type: "bash_code_execution_result", + stdout: "file1.txt", + stderr: "", + return_code: 0, + content: [], + }; + const toolResult: BetaBashCodeExecutionToolResultBlock = { + type: "bash_code_execution_tool_result", + tool_use_id: "toolu_bash_hook", + content: bashResult, + }; + const resultNotifications = toAcpNotifications( + [toolResult], + "assistant", + "test-session", + toolUseCache, + mockClientWithUpdate, + mockLogger, + { clientCapabilities }, + ); + + // Should produce 2 notifications: terminal_output, then terminal_exit + completion + expect(resultNotifications).toHaveLength(2); + + // First: terminal_output only + expect((resultNotifications[0].update as any)._meta).toEqual({ + terminal_output: { terminal_id: "toolu_bash_hook", data: "file1.txt" }, + }); + + // Second: terminal_exit + status + expect((resultNotifications[1].update as any)._meta).toMatchObject({ + terminal_exit: { terminal_id: "toolu_bash_hook", exit_code: 0, signal: null }, + }); + expect((resultNotifications[1].update as any).status).toBe("completed"); + + // Step 3: Fire the PostToolUse hook (simulates what Claude Code SDK does) + const hook = createPostToolUseHook(mockLogger); + await hook( + { + hook_event_name: "PostToolUse", + tool_name: "Bash", + tool_input: { command: "ls -la" }, + tool_response: "file1.txt", + tool_use_id: "toolu_bash_hook", + session_id: "test-session", + transcript_path: "/tmp/test", + cwd: "/tmp", + }, + "toolu_bash_hook", + { signal: AbortSignal.abort() }, + ); + + // Step 4: Hook update should only have claudeCode, no terminal fields + // (terminal events were already sent as separate notifications) + expect(hookUpdates).toHaveLength(1); + const hookMeta = hookUpdates[0].update._meta; + expect(hookMeta.claudeCode).toMatchObject({ + toolName: "Bash", + toolResponse: "file1.txt", + }); + expect(hookMeta.terminal_info).toBeUndefined(); + expect(hookMeta.terminal_output).toBeUndefined(); + expect(hookMeta.terminal_exit).toBeUndefined(); + }); + + it("should not include terminal _meta in hook update when client lacks terminal_output support", async () => { + const toolUseCache: ToolUseCache = {}; + + const hookUpdates: any[] = []; + const mockClientWithUpdate = { + sessionUpdate: async (notification: any) => { + hookUpdates.push(notification); + }, + } as unknown as AgentSideConnection; + + // Process tool_use (registers hook) + toAcpNotifications( + [ + { + type: "tool_use" as const, + id: "toolu_bash_no_term", + name: "Bash", + input: { command: "echo hi" }, + }, + ], + "assistant", + "test-session", + toolUseCache, + mockClientWithUpdate, + mockLogger, + // No clientCapabilities — terminal_output not supported + ); + + // Process bash result + const bashResult: BetaBashCodeExecutionResultBlock = { + type: "bash_code_execution_result", + stdout: "hi", + stderr: "", + return_code: 0, + content: [], + }; + toAcpNotifications( + [ + { + type: "bash_code_execution_tool_result", + tool_use_id: "toolu_bash_no_term", + content: bashResult, + } as BetaBashCodeExecutionToolResultBlock, + ], + "assistant", + "test-session", + toolUseCache, + mockClientWithUpdate, + mockLogger, + ); + + // Fire hook + const hook = createPostToolUseHook(mockLogger); + await hook( + { + hook_event_name: "PostToolUse", + tool_name: "Bash", + tool_input: { command: "echo hi" }, + tool_response: "hi", + tool_use_id: "toolu_bash_no_term", + session_id: "test-session", + transcript_path: "/tmp/test", + cwd: "/tmp", + }, + "toolu_bash_no_term", + { signal: AbortSignal.abort() }, + ); + + // Hook update should only have claudeCode, no terminal fields + expect(hookUpdates).toHaveLength(1); + const hookMeta = hookUpdates[0].update._meta; + expect(hookMeta.claudeCode).toBeDefined(); + expect(hookMeta.terminal_info).toBeUndefined(); + expect(hookMeta.terminal_output).toBeUndefined(); + expect(hookMeta.terminal_exit).toBeUndefined(); + }); + }); +}); + +describe("toolInfoFromToolUse - ExitPlanMode", () => { + it("should include plan text in content when input.plan is provided", () => { + const toolUse = { + name: "ExitPlanMode", + id: "toolu_plan_1", + input: { + plan: "# My Plan\n\n## Step 1\nDo something", + planFilePath: "/tmp/plan.md", + }, + }; + + const info = toolInfoFromToolUse(toolUse, false); + + expect(info.kind).toBe("switch_mode"); + expect(info.title).toBe("Ready to code?"); + expect(info.content).toHaveLength(1); + expect(info.content![0]).toEqual({ + type: "content", + content: { type: "text", text: "# My Plan\n\n## Step 1\nDo something" }, + }); + }); + + it("should return empty content when input.plan is not provided", () => { + const toolUse = { + name: "ExitPlanMode", + id: "toolu_plan_2", + input: {}, + }; + + const info = toolInfoFromToolUse(toolUse, false); + + expect(info.kind).toBe("switch_mode"); + expect(info.content).toEqual([]); + }); +}); + +describe("toolInfoFromToolUse - undefined input regression", () => { + it("Read with undefined input should not throw", () => { + const toolUse = { name: "Read", id: "toolu_read_undef", input: undefined }; + const info = toolInfoFromToolUse(toolUse, false); + expect(info.title).toBe("Read File"); + expect(info.locations).toEqual([]); + }); + + it("Grep with undefined input should not throw", () => { + const toolUse = { name: "Grep", id: "toolu_grep_undef", input: undefined }; + const info = toolInfoFromToolUse(toolUse, false); + expect(info.title).toBe("grep"); + }); + + it("Glob with undefined input should not throw", () => { + const toolUse = { name: "Glob", id: "toolu_glob_undef", input: undefined }; + const info = toolInfoFromToolUse(toolUse, false); + expect(info.title).toBe("Find"); + expect(info.locations).toEqual([]); + }); + + it("WebSearch with undefined input should not throw", () => { + const toolUse = { name: "WebSearch", id: "toolu_ws_undef", input: undefined }; + const info = toolInfoFromToolUse(toolUse, false); + expect(info.title).toBe("Web search"); + }); + + it("TodoWrite with undefined input should not throw", () => { + const toolUse = { name: "TodoWrite", id: "toolu_todo_undef", input: undefined }; + const info = toolInfoFromToolUse(toolUse, false); + expect(info.title).toBe("Update TODOs"); + }); +}); + +describe("planEntries - undefined input regression", () => { + it("should return empty array when input is undefined", () => { + expect(planEntries(undefined)).toEqual([]); + }); + + it("should return empty array when input has no todos", () => { + expect(planEntries({} as any)).toEqual([]); + }); + + it("should still map valid todos correctly", () => { + const result = planEntries({ + todos: [ + { content: "Task 1", status: "pending", activeForm: "" }, + { content: "Task 2", status: "completed", activeForm: "" }, + ], + }); + expect(result).toEqual([ + { content: "Task 1", status: "pending", priority: "medium" }, + { content: "Task 2", status: "completed", priority: "medium" }, + ]); + }); +}); + +describe("toAcpNotifications - TodoWrite with undefined input regression", () => { + const mockClient = {} as AgentSideConnection; + const mockLogger: Logger = { log: () => {}, error: () => {} }; + + it("should not throw when TodoWrite tool_use has undefined input", () => { + const toolUseCache: ToolUseCache = {}; + + const notifications = toAcpNotifications( + [ + { + type: "tool_use" as const, + id: "toolu_todo_undef", + name: "TodoWrite", + input: undefined as any, + }, + ], + "assistant", + "test-session", + toolUseCache, + mockClient, + mockLogger, + ); + + // TodoWrite with undefined input should not crash, and should not emit plan update + const planUpdates = notifications.filter((n) => (n.update as any).sessionUpdate === "plan"); + expect(planUpdates).toHaveLength(0); + }); + + it("should still emit plan update when TodoWrite has valid input", () => { + const toolUseCache: ToolUseCache = {}; + + const notifications = toAcpNotifications( + [ + { + type: "tool_use" as const, + id: "toolu_todo_valid", + name: "TodoWrite", + input: { todos: [{ content: "Do X", status: "pending" }] }, + }, + ], + "assistant", + "test-session", + toolUseCache, + mockClient, + mockLogger, + ); + + const planUpdates = notifications.filter((n) => (n.update as any).sessionUpdate === "plan"); + expect(planUpdates).toHaveLength(1); + }); +}); + +describe("parseTaskCreateOutput", () => { + it("parses JSON-string content", () => { + const parsed = parseTaskCreateOutput(JSON.stringify({ task: { id: "1", subject: "X" } })); + expect(parsed).toEqual({ task: { id: "1", subject: "X" } }); + }); + + it("parses array-of-text-block content", () => { + const parsed = parseTaskCreateOutput([ + { type: "text", text: JSON.stringify({ task: { id: "2", subject: "Y" } }) }, + ]); + expect(parsed).toEqual({ task: { id: "2", subject: "Y" } }); + }); + + it("returns undefined for non-JSON content", () => { + expect(parseTaskCreateOutput("not json")).toBeUndefined(); + expect(parseTaskCreateOutput([{ type: "text", text: "not json" }])).toBeUndefined(); + }); + + it("returns undefined when task.id is missing", () => { + expect(parseTaskCreateOutput(JSON.stringify({ task: { subject: "X" } }))).toBeUndefined(); + }); +}); + +describe("applyTaskCreate / applyTaskUpdate", () => { + it("creates an entry on TaskCreate when both input and output are present", () => { + const state: TaskState = new Map(); + applyTaskCreate( + state, + { subject: "Write tests", description: "Cover Task* flow", activeForm: "Writing tests" }, + { task: { id: "1", subject: "Write tests" } }, + ); + expect(state.get("1")).toEqual({ + subject: "Write tests", + status: "pending", + activeForm: "Writing tests", + description: "Cover Task* flow", + }); + }); + + it("is a no-op when the output has no task ID", () => { + const state: TaskState = new Map(); + applyTaskCreate(state, { subject: "X", description: "Y" }, undefined); + expect(state.size).toBe(0); + }); + + it("updates fields by task ID and keeps insertion order in plan entries", () => { + const state: TaskState = new Map(); + applyTaskCreate(state, { subject: "A", description: "" }, { task: { id: "1", subject: "A" } }); + applyTaskCreate(state, { subject: "B", description: "" }, { task: { id: "2", subject: "B" } }); + applyTaskUpdate(state, { taskId: "1", status: "in_progress" }); + expect(taskStateToPlanEntries(state)).toEqual([ + { content: "A", status: "in_progress", priority: "medium" }, + { content: "B", status: "pending", priority: "medium" }, + ]); + }); + + it("removes entries when status is 'deleted'", () => { + const state: TaskState = new Map(); + applyTaskCreate(state, { subject: "A", description: "" }, { task: { id: "1", subject: "A" } }); + applyTaskUpdate(state, { taskId: "1", status: "deleted" }); + expect(state.size).toBe(0); + }); + + it("creates a placeholder entry when TaskUpdate carries a subject for an unseen task", () => { + const state: TaskState = new Map(); + applyTaskUpdate(state, { taskId: "5", subject: "Late arrival", status: "in_progress" }); + expect(state.get("5")).toEqual({ + subject: "Late arrival", + status: "in_progress", + activeForm: undefined, + description: undefined, + }); + }); + + it("skips TaskUpdate for an unseen task when no subject is available", () => { + const state: TaskState = new Map(); + applyTaskUpdate(state, { taskId: "5", status: "in_progress" }); + // Without a subject we'd render an empty-content plan entry, so the + // update is dropped instead of synthesizing a blank placeholder. + expect(state.has("5")).toBe(false); + }); +}); + +describe("toAcpNotifications - Task* tools", () => { + const mockClient = {} as AgentSideConnection; + const mockLogger: Logger = { log: () => {}, error: () => {} }; + + it("suppresses tool_call for TaskCreate/TaskUpdate/TaskList/TaskGet on tool_use", () => { + const toolUseCache: ToolUseCache = {}; + const taskState: TaskState = new Map(); + + const notifications = toAcpNotifications( + [ + { type: "tool_use", id: "1", name: "TaskCreate", input: { subject: "A", description: "" } }, + { + type: "tool_use", + id: "2", + name: "TaskUpdate", + input: { taskId: "1", status: "in_progress" }, + }, + { type: "tool_use", id: "3", name: "TaskList", input: {} }, + { type: "tool_use", id: "4", name: "TaskGet", input: { taskId: "1" } }, + ] as any, + "assistant", + "test-session", + toolUseCache, + mockClient, + mockLogger, + { taskState }, + ); + + expect(notifications).toHaveLength(0); + expect(taskState.size).toBe(0); + }); + + it("emits a plan snapshot after a TaskCreate tool_result and accumulates state", () => { + const toolUseCache: ToolUseCache = {}; + const taskState: TaskState = new Map(); + + toAcpNotifications( + [ + { + type: "tool_use", + id: "1", + name: "TaskCreate", + input: { subject: "First", description: "" }, + }, + ] as any, + "assistant", + "test-session", + toolUseCache, + mockClient, + mockLogger, + { taskState }, + ); + + const created = toAcpNotifications( + [ + { + type: "tool_result", + tool_use_id: "1", + content: JSON.stringify({ task: { id: "1", subject: "First" } }), + is_error: false, + }, + ] as any, + "user", + "test-session", + toolUseCache, + mockClient, + mockLogger, + { taskState }, + ); + + expect(created).toHaveLength(1); + expect(created[0].update).toMatchObject({ + sessionUpdate: "plan", + entries: [{ content: "First", status: "pending", priority: "medium" }], + }); + + // A second TaskCreate accumulates rather than replacing. + toAcpNotifications( + [ + { + type: "tool_use", + id: "2", + name: "TaskCreate", + input: { subject: "Second", description: "" }, + }, + ] as any, + "assistant", + "test-session", + toolUseCache, + mockClient, + mockLogger, + { taskState }, + ); + + const second = toAcpNotifications( + [ + { + type: "tool_result", + tool_use_id: "2", + content: JSON.stringify({ task: { id: "2", subject: "Second" } }), + is_error: false, + }, + ] as any, + "user", + "test-session", + toolUseCache, + mockClient, + mockLogger, + { taskState }, + ); + + expect(second[0].update).toMatchObject({ + sessionUpdate: "plan", + entries: [ + { content: "First", status: "pending", priority: "medium" }, + { content: "Second", status: "pending", priority: "medium" }, + ], + }); + }); + + it("emits a plan snapshot reflecting status changes after a TaskUpdate tool_result", () => { + const toolUseCache: ToolUseCache = {}; + const taskState: TaskState = new Map([["1", { subject: "First", status: "pending" as const }]]); + toolUseCache["update-1"] = { + type: "tool_use", + id: "update-1", + name: "TaskUpdate", + input: { taskId: "1", status: "completed" }, + }; + + const notifications = toAcpNotifications( + [ + { + type: "tool_result", + tool_use_id: "update-1", + content: JSON.stringify({ + success: true, + taskId: "1", + updatedFields: ["status"], + }), + is_error: false, + }, + ] as any, + "user", + "test-session", + toolUseCache, + mockClient, + mockLogger, + { taskState }, + ); + + expect(notifications).toHaveLength(1); + expect(notifications[0].update).toMatchObject({ + sessionUpdate: "plan", + entries: [{ content: "First", status: "completed", priority: "medium" }], + }); + }); + + it("suppresses TaskList and TaskGet tool_result without touching task state", () => { + const toolUseCache: ToolUseCache = { + "list-1": { type: "tool_use", id: "list-1", name: "TaskList", input: {} }, + "get-1": { type: "tool_use", id: "get-1", name: "TaskGet", input: { taskId: "1" } }, + }; + const taskState: TaskState = new Map([ + ["1", { subject: "Existing", status: "in_progress" as const }], + ]); + + const notifications = toAcpNotifications( + [ + { type: "tool_result", tool_use_id: "list-1", content: "...", is_error: false }, + { type: "tool_result", tool_use_id: "get-1", content: "...", is_error: false }, + ] as any, + "user", + "test-session", + toolUseCache, + mockClient, + mockLogger, + { taskState }, + ); + + expect(notifications).toHaveLength(0); + expect(taskState.get("1")).toEqual({ subject: "Existing", status: "in_progress" }); + }); + + it("does not apply TaskCreate/TaskUpdate when the tool_result reports an error", () => { + const toolUseCache: ToolUseCache = { + "create-1": { + type: "tool_use", + id: "create-1", + name: "TaskCreate", + input: { subject: "A", description: "" }, + }, + }; + const taskState: TaskState = new Map(); + + const notifications = toAcpNotifications( + [ + { + type: "tool_result", + tool_use_id: "create-1", + content: "task creation failed", + is_error: true, + }, + ] as any, + "user", + "test-session", + toolUseCache, + mockClient, + mockLogger, + { taskState }, + ); + + expect(notifications).toHaveLength(0); + expect(taskState.size).toBe(0); + }); +}); + +describe("createTaskHook", () => { + it("registers a task on TaskCreated and fires onChange", async () => { + const taskState: TaskState = new Map(); + let changes = 0; + const hook = createTaskHook({ + taskState, + onChange: async () => { + changes++; + }, + }); + + await hook( + { + hook_event_name: "TaskCreated", + task_id: "t-1", + task_subject: "Investigate flaky test", + task_description: "Repro and fix", + } as any, + undefined, + { signal: new AbortController().signal }, + ); + + expect(taskState.get("t-1")).toEqual({ + subject: "Investigate flaky test", + status: "pending", + description: "Repro and fix", + }); + expect(changes).toBe(1); + }); + + it("does not clobber an existing entry on TaskCreated", async () => { + const taskState: TaskState = new Map([ + ["t-1", { subject: "Investigate flaky test", status: "in_progress" as const }], + ]); + let changes = 0; + const hook = createTaskHook({ + taskState, + onChange: async () => { + changes++; + }, + }); + + await hook( + { + hook_event_name: "TaskCreated", + task_id: "t-1", + task_subject: "Investigate flaky test", + } as any, + undefined, + { signal: new AbortController().signal }, + ); + + expect(taskState.get("t-1")?.status).toBe("in_progress"); + expect(changes).toBe(0); + }); + + it("marks a task completed on TaskCompleted", async () => { + const taskState: TaskState = new Map([ + ["t-1", { subject: "Investigate flaky test", status: "in_progress" as const }], + ]); + let changes = 0; + const hook = createTaskHook({ + taskState, + onChange: async () => { + changes++; + }, + }); + + await hook( + { + hook_event_name: "TaskCompleted", + task_id: "t-1", + task_subject: "Investigate flaky test", + } as any, + undefined, + { signal: new AbortController().signal }, + ); + + expect(taskState.get("t-1")?.status).toBe("completed"); + expect(changes).toBe(1); + }); + + it("is a no-op for unrelated hook events", async () => { + const taskState: TaskState = new Map(); + let changes = 0; + const hook = createTaskHook({ + taskState, + onChange: async () => { + changes++; + }, + }); + + await hook({ hook_event_name: "PostToolUse", tool_name: "Read" } as any, undefined, { + signal: new AbortController().signal, + }); + + expect(taskState.size).toBe(0); + expect(changes).toBe(0); + }); +}); diff --git a/qiming-claude-code-acp-ts/src/tools.ts b/qiming-claude-code-acp-ts/src/tools.ts new file mode 100644 index 00000000..71a90fe1 --- /dev/null +++ b/qiming-claude-code-acp-ts/src/tools.ts @@ -0,0 +1,968 @@ +import { + ContentBlock, + PlanEntry, + ToolCallContent, + ToolCallLocation, + ToolKind, +} from "@agentclientprotocol/sdk"; +import { HookCallback } from "@anthropic-ai/claude-agent-sdk"; +import { + AgentInput, + BashInput, + FileEditInput, + FileReadInput, + FileWriteInput, + GlobInput, + GrepInput, + TaskCreateInput, + TaskCreateOutput, + TaskUpdateInput, + TodoWriteInput, + WebFetchInput, + WebSearchInput, +} from "@anthropic-ai/claude-agent-sdk/sdk-tools.js"; +import { + ImageBlockParam, + TextBlockParam, + ToolResultBlockParam, + WebSearchResultBlock, + WebSearchToolResultBlockParam, + WebSearchToolResultError, +} from "@anthropic-ai/sdk/resources"; +import { + BetaBashCodeExecutionResultBlock, + BetaBashCodeExecutionToolResultBlockParam, + BetaBashCodeExecutionToolResultError, + BetaCodeExecutionResultBlock, + BetaCodeExecutionToolResultBlockParam, + BetaCodeExecutionToolResultError, + BetaImageBlockParam, + BetaRequestMCPToolResultBlockParam, + BetaTextEditorCodeExecutionCreateResultBlock, + BetaTextEditorCodeExecutionStrReplaceResultBlock, + BetaTextEditorCodeExecutionToolResultBlockParam, + BetaTextEditorCodeExecutionToolResultError, + BetaTextEditorCodeExecutionViewResultBlock, + BetaToolReferenceBlock, + BetaToolResultBlockParam, + BetaToolSearchToolResultBlockParam, + BetaToolSearchToolResultError, + BetaToolSearchToolSearchResultBlock, + BetaWebFetchBlock, + BetaWebFetchToolResultBlockParam, + BetaWebFetchToolResultErrorBlock, + BetaWebSearchToolResultBlockParam, +} from "@anthropic-ai/sdk/resources/beta.mjs"; +import path from "node:path"; +import { Logger } from "./acp-agent.js"; + +/** + * Union of all possible content types that can appear in tool results from the Anthropic SDK. + * These are transformed to valid ACP ContentBlock types by toValidAcpContent(). + */ +type ToolResultContent = + | TextBlockParam + | ImageBlockParam + | BetaImageBlockParam + | BetaToolReferenceBlock + | BetaToolSearchToolSearchResultBlock + | BetaToolSearchToolResultError + | WebSearchResultBlock + | WebSearchToolResultError + | BetaWebFetchBlock + | BetaWebFetchToolResultErrorBlock + | BetaCodeExecutionResultBlock + | BetaCodeExecutionToolResultError + | BetaBashCodeExecutionResultBlock + | BetaBashCodeExecutionToolResultError + | BetaTextEditorCodeExecutionViewResultBlock + | BetaTextEditorCodeExecutionCreateResultBlock + | BetaTextEditorCodeExecutionStrReplaceResultBlock + | BetaTextEditorCodeExecutionToolResultError; + +interface ToolInfo { + title: string; + kind: ToolKind; + content: ToolCallContent[]; + locations?: ToolCallLocation[]; +} + +interface ToolUpdate { + title?: string; + content?: ToolCallContent[]; + locations?: ToolCallLocation[]; + _meta?: { + terminal_info?: { + terminal_id: string; + }; + terminal_output?: { + terminal_id: string; + data: string; + }; + terminal_exit?: { + terminal_id: string; + exit_code: number; + signal: string | null; + }; + }; +} + +/** + * Convert an absolute file path to a project-relative path for display. + * Returns the original path if it's outside the project directory or if no cwd is provided. + */ +export function toDisplayPath(filePath: string, cwd?: string): string { + if (!cwd) return filePath; + const resolvedCwd = path.resolve(cwd); + const resolvedFile = path.resolve(filePath); + if (resolvedFile.startsWith(resolvedCwd + path.sep) || resolvedFile === resolvedCwd) { + return path.relative(resolvedCwd, resolvedFile); + } + return filePath; +} + +export function toolInfoFromToolUse( + toolUse: any, + supportsTerminalOutput: boolean = false, + cwd?: string, +): ToolInfo { + const name = toolUse.name; + + switch (name) { + case "Agent": + case "Task": { + const input = toolUse.input as AgentInput | BashInput | undefined; + return { + title: input?.description ? input.description : "Task", + kind: "think", + content: + input && "prompt" in input + ? [ + { + type: "content", + content: { type: "text", text: input.prompt }, + }, + ] + : [], + }; + } + + case "Bash": { + const input = toolUse.input as BashInput | undefined; + return { + title: input?.command ? input.command : "Terminal", + kind: "execute", + content: supportsTerminalOutput + ? [{ type: "terminal" as const, terminalId: toolUse.id }] + : input && input.description + ? [ + { + type: "content", + content: { type: "text", text: input.description }, + }, + ] + : [], + }; + } + + case "Read": { + const input = toolUse.input as FileReadInput | undefined; + let limit = ""; + if (input?.limit && input.limit > 0) { + limit = " (" + (input.offset ?? 1) + " - " + ((input.offset ?? 1) + input.limit - 1) + ")"; + } else if (input?.offset) { + limit = " (from line " + input.offset + ")"; + } + const displayPath = input?.file_path ? toDisplayPath(input.file_path, cwd) : "File"; + return { + title: "Read " + displayPath + limit, + kind: "read", + locations: input?.file_path + ? [ + { + path: input.file_path, + line: input.offset ?? 1, + }, + ] + : [], + content: [], + }; + } + + case "Write": { + const input = toolUse.input as FileWriteInput | undefined; + let content: ToolCallContent[] = []; + if (input && input.file_path) { + content = [ + { + type: "diff", + path: input.file_path, + oldText: null, + newText: input.content, + }, + ]; + } else if (input && input.content) { + content = [ + { + type: "content", + content: { type: "text", text: input.content }, + }, + ]; + } + const displayPath = input?.file_path ? toDisplayPath(input.file_path, cwd) : undefined; + return { + title: displayPath ? `Write ${displayPath}` : "Write", + kind: "edit", + content, + locations: input?.file_path ? [{ path: input.file_path }] : [], + }; + } + + case "Edit": { + const input = toolUse.input as FileEditInput | undefined; + let content: ToolCallContent[] = []; + if (input && input.file_path && (input.old_string || input.new_string)) { + content = [ + { + type: "diff", + path: input.file_path, + oldText: input.old_string || null, + newText: input.new_string ?? "", + }, + ]; + } + const displayPath = input?.file_path ? toDisplayPath(input.file_path, cwd) : undefined; + return { + title: displayPath ? `Edit ${displayPath}` : "Edit", + kind: "edit", + content, + locations: input?.file_path ? [{ path: input.file_path }] : [], + }; + } + + case "Glob": { + const input = toolUse.input as GlobInput | undefined; + let label = "Find"; + if (input?.path) { + label += ` \`${input.path}\``; + } + if (input?.pattern) { + label += ` \`${input.pattern}\``; + } + return { + title: label, + kind: "search", + content: [], + locations: input?.path ? [{ path: input.path }] : [], + }; + } + + case "Grep": { + const input = toolUse.input as GrepInput | undefined; + let label = "grep"; + + if (input?.["-i"]) { + label += " -i"; + } + if (input?.["-n"]) { + label += " -n"; + } + + if (input?.["-A"] !== undefined) { + label += ` -A ${input["-A"]}`; + } + if (input?.["-B"] !== undefined) { + label += ` -B ${input["-B"]}`; + } + if (input?.["-C"] !== undefined) { + label += ` -C ${input["-C"]}`; + } + + if (input?.output_mode) { + switch (input.output_mode) { + case "files_with_matches": + label += " -l"; + break; + case "count": + label += " -c"; + break; + case "content": + default: + break; + } + } + + if (input?.head_limit !== undefined) { + label += ` | head -${input.head_limit}`; + } + + if (input?.glob) { + label += ` --include="${input.glob}"`; + } + + if (input?.type) { + label += ` --type=${input.type}`; + } + + if (input?.multiline) { + label += " -P"; + } + + if (input?.pattern) { + label += ` "${input.pattern}"`; + } + + if (input?.path) { + label += ` ${input.path}`; + } + + return { + title: label, + kind: "search", + content: [], + }; + } + + case "WebFetch": { + const input = toolUse.input as WebFetchInput; + return { + title: input?.url ? `Fetch ${input.url}` : "Fetch", + kind: "fetch", + content: + input && input.prompt + ? [ + { + type: "content", + content: { type: "text", text: input.prompt }, + }, + ] + : [], + }; + } + + case "WebSearch": { + const input = toolUse.input as WebSearchInput | undefined; + let label = input?.query ? `"${input.query}"` : "Web search"; + + if (input?.allowed_domains && input.allowed_domains.length > 0) { + label += ` (allowed: ${input.allowed_domains.join(", ")})`; + } + + if (input?.blocked_domains && input.blocked_domains.length > 0) { + label += ` (blocked: ${input.blocked_domains.join(", ")})`; + } + + return { + title: label, + kind: "fetch", + content: [], + }; + } + + case "TodoWrite": { + const input = toolUse.input as TodoWriteInput | undefined; + return { + title: Array.isArray(input?.todos) + ? `Update TODOs: ${input.todos.map((todo: any) => todo.content).join(", ")}` + : "Update TODOs", + kind: "think", + content: [], + }; + } + + case "TaskCreate": { + const input = toolUse.input as TaskCreateInput | undefined; + return { + title: input?.subject ? `Create task: ${input.subject}` : "Create task", + kind: "think", + content: [], + }; + } + + case "TaskUpdate": { + const input = toolUse.input as TaskUpdateInput | undefined; + return { + title: input?.subject ? `Update task: ${input.subject}` : "Update task", + kind: "think", + content: [], + }; + } + + case "TaskList": { + return { + title: "List tasks", + kind: "think", + content: [], + }; + } + + case "TaskGet": { + return { + title: "Get task", + kind: "think", + content: [], + }; + } + + case "ExitPlanMode": { + const planInput = toolUse.input as { plan?: string } | undefined; + return { + title: "Ready to code?", + kind: "switch_mode", + content: planInput?.plan + ? [{ type: "content" as const, content: { type: "text" as const, text: planInput.plan } }] + : [], + }; + } + + case "Other": { + const input = toolUse.input; + let output; + try { + output = JSON.stringify(input, null, 2); + } catch { + output = typeof input === "string" ? input : "{}"; + } + return { + title: name || "Unknown Tool", + kind: "other", + content: [ + { + type: "content", + content: { + type: "text", + text: `\`\`\`json\n${output}\`\`\``, + }, + }, + ], + }; + } + + default: + return { + title: name || "Unknown Tool", + kind: "other", + content: [], + }; + } +} + +export function toolUpdateFromToolResult( + toolResult: + | ToolResultBlockParam + | BetaToolResultBlockParam + | BetaWebSearchToolResultBlockParam + | BetaWebFetchToolResultBlockParam + | WebSearchToolResultBlockParam + | BetaCodeExecutionToolResultBlockParam + | BetaBashCodeExecutionToolResultBlockParam + | BetaTextEditorCodeExecutionToolResultBlockParam + | BetaRequestMCPToolResultBlockParam + | BetaToolSearchToolResultBlockParam, + toolUse: any | undefined, + supportsTerminalOutput: boolean = false, +): ToolUpdate { + if ( + "is_error" in toolResult && + toolResult.is_error && + toolResult.content && + toolResult.content.length > 0 + ) { + // Only return errors + return toAcpContentUpdate(toolResult.content, true); + } + + switch (toolUse?.name) { + case "Read": + if (Array.isArray(toolResult.content) && toolResult.content.length > 0) { + return { + content: toolResult.content.map((content: any) => ({ + type: "content", + content: + content.type === "text" + ? { + type: "text", + text: markdownEscape(content.text), + } + : toAcpContentBlock(content, false), + })), + }; + } else if (typeof toolResult.content === "string" && toolResult.content.length > 0) { + return { + content: [ + { + type: "content", + content: { + type: "text", + text: markdownEscape(toolResult.content), + }, + }, + ], + }; + } + return {}; + + case "Bash": { + const result = toolResult.content; + const terminalId = "tool_use_id" in toolResult ? String(toolResult.tool_use_id) : ""; + const isError = "is_error" in toolResult && toolResult.is_error; + + // Extract output and exit code from either format: + // 1. BetaBashCodeExecutionResultBlock: { type: "bash_code_execution_result", stdout, stderr, return_code } + // 2. Plain string content from a regular tool_result + // 3. Array content (e.g. [{ type: "text", text: "..." }]) + let output = ""; + let exitCode = isError ? 1 : 0; + + if ( + result && + typeof result === "object" && + "type" in result && + result.type === "bash_code_execution_result" + ) { + const bashResult = result as BetaBashCodeExecutionResultBlock; + output = [bashResult.stdout, bashResult.stderr].filter(Boolean).join("\n"); + exitCode = bashResult.return_code; + } else if (typeof result === "string") { + output = result; + } else if ( + Array.isArray(result) && + result.length > 0 && + "text" in result[0] && + typeof result[0].text === "string" + ) { + output = result.map((c: any) => c.text).join("\n"); + } + + if (supportsTerminalOutput) { + return { + content: [{ type: "terminal" as const, terminalId }], + _meta: { + terminal_info: { + terminal_id: terminalId, + }, + terminal_output: { + terminal_id: terminalId, + data: output, + }, + terminal_exit: { + terminal_id: terminalId, + exit_code: exitCode, + signal: null, + }, + }, + }; + } + // Fallback: format output as a code block without terminal _meta + if (output.trim()) { + return { + content: [ + { + type: "content", + content: { + type: "text", + text: `\`\`\`console\n${output.trimEnd()}\n\`\`\``, + }, + }, + ], + }; + } + return {}; + } + + case "Edit": // Edit is handled in hooks + case "Write": { + return {}; + } + + case "ExitPlanMode": { + return { title: "Exited Plan Mode" }; + } + + default: { + return toAcpContentUpdate( + toolResult.content, + "is_error" in toolResult ? toolResult.is_error : false, + ); + } + } +} + +function toAcpContentUpdate( + content: any, + isError: boolean = false, +): { content?: ToolCallContent[] } { + if (Array.isArray(content) && content.length > 0) { + return { + content: content.map((c: any) => ({ + type: "content" as const, + content: toAcpContentBlock(c, isError), + })), + }; + } else if (typeof content === "object" && content !== null && "type" in content) { + return { + content: [ + { + type: "content" as const, + content: toAcpContentBlock(content, isError), + }, + ], + }; + } else if (typeof content === "string" && content.length > 0) { + return { + content: [ + { + type: "content", + content: { + type: "text", + text: isError ? `\`\`\`\n${content}\n\`\`\`` : content, + }, + }, + ], + }; + } + return {}; +} + +function toAcpContentBlock(content: ToolResultContent, isError: boolean): ContentBlock { + const wrapText = (text: string): ContentBlock => ({ + type: "text" as const, + text: isError ? `\`\`\`\n${text}\n\`\`\`` : text, + }); + + switch (content.type) { + case "text": + return { + type: "text" as const, + text: isError ? `\`\`\`\n${content.text}\n\`\`\`` : content.text, + }; + case "image": + if (content.source.type === "base64") { + return { + type: "image" as const, + data: content.source.data, + mimeType: content.source.media_type, + }; + } + // URL and file-based images can't be converted to ACP format (requires data) + return wrapText( + content.source.type === "url" + ? `[image: ${content.source.url}]` + : "[image: file reference]", + ); + + case "tool_reference": + return wrapText(`Tool: ${content.tool_name}`); + case "tool_search_tool_search_result": + return wrapText( + `Tools found: ${content.tool_references.map((r) => r.tool_name).join(", ") || "none"}`, + ); + case "tool_search_tool_result_error": + return wrapText( + `Error: ${content.error_code}${content.error_message ? ` - ${content.error_message}` : ""}`, + ); + case "web_search_result": + return wrapText(`${content.title} (${content.url})`); + case "web_search_tool_result_error": + return wrapText(`Error: ${content.error_code}`); + case "web_fetch_result": + return wrapText(`Fetched: ${content.url}`); + case "web_fetch_tool_result_error": + return wrapText(`Error: ${content.error_code}`); + case "code_execution_result": + return wrapText(`Output: ${content.stdout || content.stderr || ""}`); + case "bash_code_execution_result": + return wrapText(`Output: ${content.stdout || content.stderr || ""}`); + case "code_execution_tool_result_error": + case "bash_code_execution_tool_result_error": + return wrapText(`Error: ${content.error_code}`); + case "text_editor_code_execution_view_result": + return wrapText(content.content); + case "text_editor_code_execution_create_result": + return wrapText(content.is_file_update ? "File updated" : "File created"); + case "text_editor_code_execution_str_replace_result": + return wrapText(content.lines?.join("\n") || ""); + case "text_editor_code_execution_tool_result_error": + return wrapText( + `Error: ${content.error_code}${content.error_message ? ` - ${content.error_message}` : ""}`, + ); + + default: + return wrapText(JSON.stringify(content)); + } +} + +export type ClaudePlanEntry = { + content: string; + status: "pending" | "in_progress" | "completed"; + activeForm: string; +}; + +export function planEntries(input: { todos: ClaudePlanEntry[] } | undefined): PlanEntry[] { + return (input?.todos ?? []).map((todo) => ({ + content: todo.content, + status: todo.status, + priority: "medium", + })); +} + +/** + * Per-session task list accumulated from Task* tool calls (TaskCreate / + * TaskUpdate). The headless/SDK session emits these as incremental tool + * calls keyed by task ID, replacing the snapshot-style TodoWrite tool. + * Iteration order is insertion order (Map semantics), matching the order + * tasks are created. + */ +export type TaskEntry = { + subject: string; + status: "pending" | "in_progress" | "completed"; + activeForm?: string; + description?: string; +}; +export type TaskState = Map; + +/** + * Best-effort parse of a TaskCreate tool_result content into the structured + * TaskCreateOutput. The SDK delivers tool outputs either as a string or as + * an array of TextBlockParam-like blocks containing JSON text; try both. + */ +export function parseTaskCreateOutput(content: unknown): TaskCreateOutput | undefined { + const tryParse = (text: string): TaskCreateOutput | undefined => { + try { + const parsed = JSON.parse(text); + if ( + parsed && + typeof parsed === "object" && + parsed.task && + typeof parsed.task.id === "string" + ) { + return parsed as TaskCreateOutput; + } + } catch { + // ignore + } + return undefined; + }; + + if (typeof content === "string") { + return tryParse(content); + } + if (Array.isArray(content)) { + for (const block of content) { + if (block && typeof block === "object" && "type" in block && block.type === "text") { + const text = (block as { text?: unknown }).text; + if (typeof text === "string") { + const parsed = tryParse(text); + if (parsed) return parsed; + } + } + } + } + return undefined; +} + +export function applyTaskCreate( + state: TaskState, + input: TaskCreateInput | undefined, + output: TaskCreateOutput | undefined, +): void { + const taskId = output?.task?.id; + if (!taskId || !input) return; + state.set(taskId, { + subject: input.subject, + status: "pending", + activeForm: input.activeForm, + description: input.description, + }); +} + +export function applyTaskUpdate(state: TaskState, input: TaskUpdateInput | undefined): void { + if (!input?.taskId) return; + if (input.status === "deleted") { + state.delete(input.taskId); + return; + } + const existing = state.get(input.taskId); + // Without a subject from either the existing entry or the update payload, + // we'd produce a plan entry with empty `content` — drop the update. + const subject = input.subject ?? existing?.subject; + if (!subject) return; + state.set(input.taskId, { + subject, + status: input.status ?? existing?.status ?? "pending", + activeForm: input.activeForm ?? existing?.activeForm, + description: input.description ?? existing?.description, + }); +} + +export function taskStateToPlanEntries(state: TaskState): PlanEntry[] { + return Array.from(state.values()).map((task) => ({ + content: task.subject, + status: task.status, + priority: "medium", + })); +} + +export function markdownEscape(text: string): string { + let escape = "```"; + for (const [m] of text.matchAll(/^```+/gm)) { + while (m.length >= escape.length) { + escape += "`"; + } + } + return escape + "\n" + text + (text.endsWith("\n") ? "" : "\n") + escape; +} + +interface DiffToolResponseHunk { + oldStart: number; + oldLines: number; + newStart: number; + newLines: number; + lines: string[]; +} + +interface DiffToolResponse { + filePath?: string; + structuredPatch?: DiffToolResponseHunk[]; +} + +/** + * Builds diff ToolUpdate content from the structured toolResponse provided by + * the PostToolUse hook for diff-producing tools (Edit, Write). Unlike parsing + * the plain unified diff string, this uses the pre-parsed structuredPatch + * which supports multiple replacement sites (replaceAll) and always includes + * context lines for better readability. + */ +export function toolUpdateFromDiffToolResponse(toolResponse: unknown): { + content?: ToolCallContent[]; + locations?: ToolCallLocation[]; +} { + if (!toolResponse || typeof toolResponse !== "object") return {}; + const response = toolResponse as DiffToolResponse; + if (!response.filePath || !Array.isArray(response.structuredPatch)) return {}; + + const content: ToolCallContent[] = []; + const locations: ToolCallLocation[] = []; + + for (const { lines, newStart } of response.structuredPatch) { + const oldText: string[] = []; + const newText: string[] = []; + for (const line of lines) { + if (line.startsWith("-")) { + oldText.push(line.slice(1)); + } else if (line.startsWith("+")) { + newText.push(line.slice(1)); + } else { + oldText.push(line.slice(1)); + newText.push(line.slice(1)); + } + } + if (oldText.length > 0 || newText.length > 0) { + locations.push({ path: response.filePath, line: newStart }); + content.push({ + type: "diff", + path: response.filePath, + oldText: oldText.join("\n") || null, + newText: newText.join("\n"), + }); + } + } + + const result: { content?: ToolCallContent[]; locations?: ToolCallLocation[] } = {}; + if (content.length > 0) result.content = content; + if (locations.length > 0) result.locations = locations; + return result; +} + +/* A global variable to store callbacks that should be executed when receiving hooks from Claude Code */ +const toolUseCallbacks: { + [toolUseId: string]: { + onPostToolUseHook?: ( + toolUseID: string, + toolInput: unknown, + toolResponse: unknown, + ) => Promise; + }; +} = {}; + +/* Setup callbacks that will be called when receiving hooks from Claude Code */ +export const registerHookCallback = ( + toolUseID: string, + { + onPostToolUseHook, + }: { + onPostToolUseHook?: ( + toolUseID: string, + toolInput: unknown, + toolResponse: unknown, + ) => Promise; + }, +) => { + toolUseCallbacks[toolUseID] = { + onPostToolUseHook, + }; +}; + +/* A callback for Claude Code that is called when receiving a PostToolUse hook */ +export const createPostToolUseHook = + ( + logger: Logger = console, + options?: { + onEnterPlanMode?: () => Promise; + }, + ): HookCallback => + async (input: any, toolUseID: string | undefined): Promise<{ continue: boolean }> => { + if (input.hook_event_name === "PostToolUse") { + // Handle EnterPlanMode tool - notify client of mode change after successful execution + if (input.tool_name === "EnterPlanMode" && options?.onEnterPlanMode) { + await options.onEnterPlanMode(); + } + + if (toolUseID) { + const onPostToolUseHook = toolUseCallbacks[toolUseID]?.onPostToolUseHook; + if (onPostToolUseHook) { + await onPostToolUseHook(toolUseID, input.tool_input, input.tool_response); + delete toolUseCallbacks[toolUseID]; // Cleanup after execution + } else { + logger.error(`No onPostToolUseHook found for tool use ID: ${toolUseID}`); + delete toolUseCallbacks[toolUseID]; + } + } + } + return { continue: true }; + }; + +/** + * Hook callback for `TaskCreated` / `TaskCompleted` events. The SDK fires + * these for both user-facing TaskCreate tool calls and subagent task + * creation, giving us `task_id` + `task_subject` without having to parse + * tool_result payloads. + * + * Populating `taskState` from the hook means a later `TaskUpdate` (which + * typically only carries `taskId` + `status`) finds an existing entry with + * a real subject, instead of synthesizing a placeholder with empty content. + */ +export const createTaskHook = + (options: { taskState: TaskState; onChange?: () => Promise }): HookCallback => + async (input): Promise<{ continue: boolean }> => { + const taskId = + "task_id" in input && typeof input.task_id === "string" ? input.task_id : undefined; + if (!taskId) return { continue: true }; + + if (input.hook_event_name === "TaskCreated") { + if (!input.task_subject) return { continue: true }; + if (options.taskState.has(taskId)) return { continue: true }; + options.taskState.set(taskId, { + subject: input.task_subject, + status: "pending", + description: input.task_description, + }); + if (options.onChange) await options.onChange(); + } else if (input.hook_event_name === "TaskCompleted") { + const existing = options.taskState.get(taskId); + if (!existing || existing.status === "completed") return { continue: true }; + options.taskState.set(taskId, { ...existing, status: "completed" }); + if (options.onChange) await options.onChange(); + } + return { continue: true }; + }; diff --git a/qiming-claude-code-acp-ts/src/utils.ts b/qiming-claude-code-acp-ts/src/utils.ts new file mode 100644 index 00000000..f444f7a5 --- /dev/null +++ b/qiming-claude-code-acp-ts/src/utils.ts @@ -0,0 +1,89 @@ +// A pushable async iterable: allows you to push items and consume them with for-await. + +import { Readable, Writable } from "node:stream"; +import { WritableStream, ReadableStream } from "node:stream/web"; +import { Logger } from "./acp-agent.js"; + +// Useful for bridging push-based and async-iterator-based code. +export class Pushable implements AsyncIterable { + private queue: T[] = []; + private resolvers: ((value: IteratorResult) => void)[] = []; + private done = false; + + push(item: T) { + if (this.resolvers.length > 0) { + const resolve = this.resolvers.shift()!; + resolve({ value: item, done: false }); + } else { + this.queue.push(item); + } + } + + end() { + this.done = true; + while (this.resolvers.length > 0) { + const resolve = this.resolvers.shift()!; + resolve({ value: undefined as any, done: true }); + } + } + + [Symbol.asyncIterator](): AsyncIterator { + return { + next: (): Promise> => { + if (this.queue.length > 0) { + const value = this.queue.shift()!; + return Promise.resolve({ value, done: false }); + } + if (this.done) { + return Promise.resolve({ value: undefined as any, done: true }); + } + return new Promise>((resolve) => { + this.resolvers.push(resolve); + }); + }, + }; + } +} + +// Helper to convert Node.js streams to Web Streams +export function nodeToWebWritable(nodeStream: Writable): WritableStream { + return new WritableStream({ + write(chunk) { + return new Promise((resolve, reject) => { + nodeStream.write(Buffer.from(chunk), (err) => { + if (err) { + reject(err); + } else { + resolve(); + } + }); + }); + }, + }); +} + +export function nodeToWebReadable(nodeStream: Readable): ReadableStream { + return new ReadableStream({ + start(controller) { + nodeStream.on("data", (chunk: Buffer) => { + controller.enqueue(new Uint8Array(chunk)); + }); + nodeStream.on("end", () => controller.close()); + nodeStream.on("error", (err) => controller.error(err)); + }, + }); +} + +export function unreachable(value: never, logger: Logger = console) { + let valueAsString; + try { + valueAsString = JSON.stringify(value); + } catch { + valueAsString = value; + } + logger.error(`Unexpected case: ${valueAsString}`); +} + +export function sleep(time: number): Promise { + return new Promise((resolve) => setTimeout(resolve, time)); +} diff --git a/qiming-claude-code-acp-ts/tsconfig.json b/qiming-claude-code-acp-ts/tsconfig.json new file mode 100644 index 00000000..2d4ddd98 --- /dev/null +++ b/qiming-claude-code-acp-ts/tsconfig.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "target": "ES2021", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "lib": ["ES2021"], + "strict": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "skipLibCheck": true, + "outDir": "dist", + "rootDir": "src", + "declaration": true, + "declarationMap": true + }, + "include": ["src/**/*.ts"], + "exclude": ["node_modules", "dist"] +} diff --git a/qiming-claude-code-acp-ts/vitest.config.ts b/qiming-claude-code-acp-ts/vitest.config.ts new file mode 100644 index 00000000..c48e5921 --- /dev/null +++ b/qiming-claude-code-acp-ts/vitest.config.ts @@ -0,0 +1,19 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + watch: false, + globals: true, + environment: "node", + include: ["src/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}"], + coverage: { + provider: "v8", + reporter: ["text", "json", "html"], + }, + }, + resolve: { + alias: { + "@": "/src", + }, + }, +});