mirror of
https://github.com/agentclientprotocol/claude-agent-acp.git
synced 2026-11-04 16:31:56 +00:00
Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
59a7e9367b | ||
|
|
450d6b19dc | ||
|
|
5de5d4a2c8 | ||
|
|
0581b9cf39 | ||
|
|
11dd73b871 | ||
|
|
e4dba808ea | ||
|
|
de0d0e2b7d | ||
|
|
35aaddb3d5 | ||
|
|
1f09e9a3ca | ||
|
|
ec73cd8560 | ||
|
|
8157ee113e | ||
|
|
3df1ede89f | ||
|
|
1afa940a2c |
@@ -1,3 +1,3 @@
|
||||
{
|
||||
".": "0.66.0"
|
||||
".": "0.69.0"
|
||||
}
|
||||
|
||||
31
CHANGELOG.md
31
CHANGELOG.md
@@ -1,5 +1,36 @@
|
||||
# Changelog
|
||||
|
||||
## [0.69.0](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.68.0...v0.69.0) (2026-08-16)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* report changed files to AIR ([#1001](https://github.com/agentclientprotocol/claude-agent-acp/issues/1001)) ([450d6b1](https://github.com/agentclientprotocol/claude-agent-acp/commit/450d6b19dc46a041128356a6fa3cfa3ce6a5a382))
|
||||
|
||||
## [0.68.0](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.67.0...v0.68.0) (2026-08-14)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* align typed session failures with AIR protocol ([#992](https://github.com/agentclientprotocol/claude-agent-acp/issues/992)) ([0581b9c](https://github.com/agentclientprotocol/claude-agent-acp/commit/0581b9cf397ffd88f2830db721c2d8e3689045e4))
|
||||
|
||||
## [0.67.0](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.66.0...v0.67.0) (2026-08-14)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **deps:** Update to @anthropic-ai/claude-agent-sdk v0.3.232 ([#993](https://github.com/agentclientprotocol/claude-agent-acp/issues/993)) ([de0d0e2](https://github.com/agentclientprotocol/claude-agent-acp/commit/de0d0e2b7d185c521c3d1ac7b86e4312c919abfe))
|
||||
* expose typed session failures for AIR ([#979](https://github.com/agentclientprotocol/claude-agent-acp/issues/979)) ([8157ee1](https://github.com/agentclientprotocol/claude-agent-acp/commit/8157ee113e705750be4eb6cc787bdd12f1db84ff))
|
||||
* publish the model fallback as a warning advisory ([#990](https://github.com/agentclientprotocol/claude-agent-acp/issues/990)) ([35aaddb](https://github.com/agentclientprotocol/claude-agent-acp/commit/35aaddb3d5b14eca316582ab285af8a660150dae))
|
||||
* surface resolved model name in default model option description ([#982](https://github.com/agentclientprotocol/claude-agent-acp/issues/982)) ([ec73cd8](https://github.com/agentclientprotocol/claude-agent-acp/commit/ec73cd8560be7d5e8b9741e404d7d45e17336996))
|
||||
* surface Skill tool calls with name and kind in _meta ([#986](https://github.com/agentclientprotocol/claude-agent-acp/issues/986)) ([1f09e9a](https://github.com/agentclientprotocol/claude-agent-acp/commit/1f09e9a3cae787b0effdfae0e7205ef7fe22b9dc))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* preserve task plans across prompts ([#974](https://github.com/agentclientprotocol/claude-agent-acp/issues/974)) ([1afa940](https://github.com/agentclientprotocol/claude-agent-acp/commit/1afa940a2c8c0f4c610f4f64d30c0961642907b0))
|
||||
* show a pending title while Claude prepares a file ([#978](https://github.com/agentclientprotocol/claude-agent-acp/issues/978)) ([3df1ede](https://github.com/agentclientprotocol/claude-agent-acp/commit/3df1ede89f217312bc237124dc1eccc10c860f99))
|
||||
|
||||
## [0.66.0](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.65.0...v0.66.0) (2026-08-07)
|
||||
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ This tool implements an ACP agent by using the official [Claude Agent SDK](https
|
||||
- Custom [Slash commands](https://docs.anthropic.com/en/docs/claude-code/slash-commands)
|
||||
- Client MCP servers
|
||||
- Session-scoped long-running goals through the provider-neutral [goal extension](docs/goal-extension.md)
|
||||
- Structured errors, recovery, and warnings through the opt-in [session failure extension](docs/session-failure-extension.md)
|
||||
|
||||
Learn more about the [Agent Client Protocol](https://agentclientprotocol.com/).
|
||||
|
||||
|
||||
208
docs/session-failure-extension.md
Normal file
208
docs/session-failure-extension.md
Normal file
@@ -0,0 +1,208 @@
|
||||
# Session failure extension
|
||||
|
||||
This document defines the provider-neutral experimental `sessionFailure` extension implemented by
|
||||
`claude-agent-acp`. Warnings and errors are durable transcript entries shown in order beside user,
|
||||
agent, and tool messages. They are not assistant-authored text and not ephemeral chat banners.
|
||||
|
||||
## Capability negotiation
|
||||
|
||||
The extension is opt-in. A client advertises it in `initialize`:
|
||||
|
||||
```json
|
||||
{
|
||||
"clientCapabilities": {
|
||||
"_meta": {
|
||||
"jetbrains": {
|
||||
"air": {
|
||||
"version": 1,
|
||||
"capabilities": ["sessionFailure"]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The adapter enables the extension only when `version` is a finite integer greater than or equal to
|
||||
`1` and `capabilities` contains `sessionFailure`. Otherwise it preserves the legacy ACP behavior.
|
||||
|
||||
## Wire record
|
||||
|
||||
The record is placed under `_meta.jetbrains.air.sessionFailure`:
|
||||
|
||||
```json
|
||||
{
|
||||
"_meta": {
|
||||
"jetbrains": {
|
||||
"air": {
|
||||
"version": 1,
|
||||
"sessionFailure": {
|
||||
"id": "prompt-uuid:error",
|
||||
"revision": 1,
|
||||
"category": "limit",
|
||||
"severity": "error",
|
||||
"title": "You've hit your individual spend limit · run /usage-credits to ask your admin for a higher limit",
|
||||
"actions": []
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Required | Type | Meaning |
|
||||
| ---------- | -------: | -------------------- | ----------------------------------------------------------------------- |
|
||||
| `id` | yes | non-empty string | Stable identity of one logical incident. |
|
||||
| `revision` | yes | positive integer | Monotonically increasing version of that incident. |
|
||||
| `category` | yes | category enum | Broad machine-readable visual group. |
|
||||
| `severity` | yes | `warning` or `error` | Inline warning or error presentation. |
|
||||
| `title` | yes | string | Complete normal user-facing presentation. |
|
||||
| `details` | no | string | Long explanation used only when required text is too large for `title`. |
|
||||
| `actions` | yes | ordered string array | Recovery actions recommended by the adapter. |
|
||||
|
||||
There are no `phase`, `source`, `safeMessage`, `retryable`, `retryAfterMs`, `turnId`, retry-counter,
|
||||
or provider-code fields. Retry progress belongs in `title` when Claude supplies it. Transcript order
|
||||
and persistence time come from the receiving ACP event.
|
||||
|
||||
## Identity, revisions, and history
|
||||
|
||||
`id` identifies one occurrence, not an error type.
|
||||
|
||||
- The first record for an incident creates one transcript entry at the current stream position.
|
||||
- The same `id` with a higher `revision` updates that entry in place without moving it.
|
||||
- The same or a lower revision is idempotently ignored by the client.
|
||||
- A later independent occurrence uses a new `id`, even when its category and text are identical.
|
||||
- Consecutive updates for the same notice may reuse one id and increment its revision.
|
||||
- Different ids are never deduplicated by comparing title or category.
|
||||
|
||||
Live turn failures use `<turnId>:error`. Session-scoped incidents use an adapter-session epoch plus an
|
||||
incident sequence. Notices have their own `:notice:` namespace. Replayed usage-limit failures recover
|
||||
the persisted user-message UUID used as the live turn id, so the same occurrence keeps the same id.
|
||||
Malformed history without a preceding user message falls back to
|
||||
`<sessionId>:history-error:<messageUuid>`.
|
||||
|
||||
Resolved records remain in transcript history. Recovery removes only the adapter's internal active
|
||||
state; it does not publish a clear tombstone and does not delete or rewrite the historical entry. A
|
||||
producer may publish a higher revision only when it has a real new user-facing status to show.
|
||||
|
||||
## Delivery surfaces
|
||||
|
||||
A turn-terminal failure is attached to the successful ACP `PromptResponse` in `_meta`; the response
|
||||
uses `stopReason: end_turn`. Session-scoped, replay-restored, warning, and background incidents use a
|
||||
`session_info_update`. The schema is identical on both carriers.
|
||||
|
||||
A warning does not end a turn. An error record does not independently invent ACP lifecycle state;
|
||||
the prompt response or transport lifecycle remains authoritative. If no capable client negotiated
|
||||
the extension, existing JSON-RPC errors and transcript text remain unchanged.
|
||||
|
||||
## Categories
|
||||
|
||||
Categories are deliberately broad and drive only iconography and generic accessibility labels.
|
||||
|
||||
| Category | Covers |
|
||||
| ------------ | --------------------------------------------------------------------- |
|
||||
| `connection` | Lost transport, stopped or shutting-down worker, unavailable runtime. |
|
||||
| `access` | Authentication required, rejected credentials, denied access. |
|
||||
| `limit` | Rate, quota, token, context, turn, or configured budget limit. |
|
||||
| `request` | Invalid input, unsupported model or operation, rejected request. |
|
||||
| `service` | Provider overload, provider failure, adapter/internal failure. |
|
||||
| `unknown` | A warning or failure that does not fit another group. |
|
||||
|
||||
Claude SDK conditions map to these groups:
|
||||
|
||||
| Claude condition | Category |
|
||||
| ---------------------------------------------------------------------------------------- | ---------------------------------- |
|
||||
| `authentication_failed`, `oauth_org_not_allowed`, synthetic login message | `access` |
|
||||
| `billing_error`, `rate_limit`, `max_output_tokens`, usage/spend limit, budget/turn limit | `limit` |
|
||||
| `invalid_request`, `model_not_found` | `request` |
|
||||
| `overloaded`, `server_error`, unknown provider error, adapter internal error | `service` |
|
||||
| API retry without an HTTP response, query transport loss, worker shutdown | `connection` |
|
||||
| model fallback notice | `unknown` with `severity: warning` |
|
||||
|
||||
Unknown SDK error kinds degrade to `service`; they never become success. Category does not determine
|
||||
message text or client behavior beyond presentation.
|
||||
|
||||
## Severity
|
||||
|
||||
- `warning` means the operation may still succeed and normally has no actions while recovery is in
|
||||
progress. It does not terminate the turn. SDK `api_retry` progress reuses the active turn failure id;
|
||||
a later terminal failure updates that record with a higher revision.
|
||||
- `error` means the operation cannot continue without user action or another request.
|
||||
|
||||
Severity is always explicit.
|
||||
|
||||
## Title and details
|
||||
|
||||
`title` is the complete normal presentation: what happened, retry progress when present, and a short
|
||||
next step. The adapter must not replace a real Claude message with category-specific canned wording.
|
||||
|
||||
For Claude SDK failures the title is copied from the user-facing text Claude already emitted:
|
||||
|
||||
- top-level assistant error text, including synthetic usage-limit and login messages;
|
||||
- otherwise the terminal SDK result or error text;
|
||||
- the exact model-fallback notice for warning records.
|
||||
|
||||
Adapter-authored fallback text is allowed only when no Claude user-facing message exists, such as a
|
||||
query iterator throwing, worker-shutdown EOF, or an internal missing-result invariant. Raw thrown
|
||||
exceptions, stack traces, transport URLs, tokens, headers, environment values, and private paths are
|
||||
never used as titles.
|
||||
|
||||
`details` is reserved exclusively for required explanations too large to fit reasonably in `title`.
|
||||
If the complete message fits in `title`, `details` must be omitted. It is not a place for status text,
|
||||
retry counters, provider payloads, or diagnostics.
|
||||
|
||||
## Actions
|
||||
|
||||
Version 1 defines only:
|
||||
|
||||
| Action | Meaning |
|
||||
| ------------- | -------------------------------------------------- |
|
||||
| `retry` | Retry the operation associated with this incident. |
|
||||
| `login` | Start authentication. |
|
||||
| `new_session` | Start a fresh agent session/runtime. |
|
||||
|
||||
The adapter supplies action ordering. The client filters actions it cannot safely execute and ignores
|
||||
unknown or duplicate values. The client must not infer actions from category.
|
||||
|
||||
Current Claude policies are:
|
||||
|
||||
- authentication: `login`;
|
||||
- provider overload/error and recoverable internal error: `retry`;
|
||||
- transport loss or worker shutdown: `new_session`;
|
||||
- context or configured session-budget exhaustion: `new_session`;
|
||||
- account quota and invalid request: no action.
|
||||
|
||||
## Internal recovery
|
||||
|
||||
Recovery policy is internal adapter state and is not serialized:
|
||||
|
||||
- restored quota remains active until a real model answer;
|
||||
- authentication remains active until successful `auth_status`;
|
||||
- transport loss and worker shutdown remain active until runtime replacement;
|
||||
- other turn-scoped failures stop being active at a later confirmed attempt boundary;
|
||||
- notices are not cleared merely by generic success.
|
||||
|
||||
Recovery never removes the transcript record. Publishing another incident is not evidence that an
|
||||
older one recovered.
|
||||
|
||||
## History replay
|
||||
|
||||
`session/load` scans top-level assistant history for SDK synthetic usage-limit messages. It recognizes
|
||||
only `<synthetic>` messages beginning with SDK-owned stable prefixes, not arbitrary model prose. Every
|
||||
recognized occurrence is replayed as a typed record at its original transcript position. The latest
|
||||
matching message stays active until a later real-model answer proves recovery; older and recovered
|
||||
records remain historical typed entries.
|
||||
|
||||
For a capable client, replay suppresses duplicate assistant prose and restores typed errors using the
|
||||
exact stored Claude text as `title`. Legacy clients receive the original transcript and no typed record.
|
||||
|
||||
## Verification
|
||||
|
||||
The executable contract lives primarily in `src/tests/acp-agent.test.ts`, under `usage-limit failure
|
||||
replay`, `stop reason propagation`, and `model refusal fallback handling`.
|
||||
|
||||
```sh
|
||||
npm run build
|
||||
npm run check
|
||||
npm run test:run
|
||||
```
|
||||
406
package-lock.json
generated
406
package-lock.json
generated
@@ -1,31 +1,31 @@
|
||||
{
|
||||
"name": "@agentclientprotocol/claude-agent-acp",
|
||||
"version": "0.66.0",
|
||||
"version": "0.69.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@agentclientprotocol/claude-agent-acp",
|
||||
"version": "0.66.0",
|
||||
"version": "0.69.0",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "1.3.0",
|
||||
"@anthropic-ai/claude-agent-sdk": "0.3.220",
|
||||
"@anthropic-ai/claude-agent-sdk": "0.3.232",
|
||||
"zod": "^3.25.0 || ^4.0.0"
|
||||
},
|
||||
"bin": {
|
||||
"claude-agent-acp": "dist/index.js"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@anthropic-ai/sdk": "0.115.0",
|
||||
"@anthropic-ai/sdk": "0.117.1",
|
||||
"@eslint/js": "10.0.1",
|
||||
"@tsconfig/node22": "22.0.5",
|
||||
"@types/node": "26.1.2",
|
||||
"@typescript-eslint/eslint-plugin": "8.65.0",
|
||||
"@typescript-eslint/parser": "8.65.0",
|
||||
"eslint": "10.8.0",
|
||||
"@types/node": "26.2.0",
|
||||
"@typescript-eslint/eslint-plugin": "8.67.0",
|
||||
"@typescript-eslint/parser": "8.67.0",
|
||||
"eslint": "10.8.1",
|
||||
"eslint-config-prettier": "10.1.8",
|
||||
"globals": "17.9.0",
|
||||
"globals": "17.11.0",
|
||||
"prettier": "3.9.6",
|
||||
"ts-node": "10.9.2",
|
||||
"typescript": "6.0.3",
|
||||
@@ -45,22 +45,22 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@anthropic-ai/claude-agent-sdk": {
|
||||
"version": "0.3.220",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk/-/claude-agent-sdk-0.3.220.tgz",
|
||||
"integrity": "sha512-glc7SdwPkOkLw8oxwLo9PKTdLJGqW/PIR4urWXFoRtX9YllwozsEVc5Tc1+EvLSkfrsxPJqQWqOgpjUOQXf1oA==",
|
||||
"version": "0.3.232",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk/-/claude-agent-sdk-0.3.232.tgz",
|
||||
"integrity": "sha512-8od7hJk9fZnF1/oYYiR9PvroGbZRQrpmNgKirjHNGoj5ur5YcAZLohI70XVUAUe3KvjB1msLxtkvmlAT9sqFAg==",
|
||||
"license": "SEE LICENSE IN README.md",
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@anthropic-ai/claude-agent-sdk-darwin-arm64": "0.3.220",
|
||||
"@anthropic-ai/claude-agent-sdk-darwin-x64": "0.3.220",
|
||||
"@anthropic-ai/claude-agent-sdk-linux-arm64": "0.3.220",
|
||||
"@anthropic-ai/claude-agent-sdk-linux-arm64-musl": "0.3.220",
|
||||
"@anthropic-ai/claude-agent-sdk-linux-x64": "0.3.220",
|
||||
"@anthropic-ai/claude-agent-sdk-linux-x64-musl": "0.3.220",
|
||||
"@anthropic-ai/claude-agent-sdk-win32-arm64": "0.3.220",
|
||||
"@anthropic-ai/claude-agent-sdk-win32-x64": "0.3.220"
|
||||
"@anthropic-ai/claude-agent-sdk-darwin-arm64": "0.3.232",
|
||||
"@anthropic-ai/claude-agent-sdk-darwin-x64": "0.3.232",
|
||||
"@anthropic-ai/claude-agent-sdk-linux-arm64": "0.3.232",
|
||||
"@anthropic-ai/claude-agent-sdk-linux-arm64-musl": "0.3.232",
|
||||
"@anthropic-ai/claude-agent-sdk-linux-x64": "0.3.232",
|
||||
"@anthropic-ai/claude-agent-sdk-linux-x64-musl": "0.3.232",
|
||||
"@anthropic-ai/claude-agent-sdk-win32-arm64": "0.3.232",
|
||||
"@anthropic-ai/claude-agent-sdk-win32-x64": "0.3.232"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@anthropic-ai/sdk": ">=0.93.0",
|
||||
@@ -69,9 +69,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@anthropic-ai/claude-agent-sdk-darwin-arm64": {
|
||||
"version": "0.3.220",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-darwin-arm64/-/claude-agent-sdk-darwin-arm64-0.3.220.tgz",
|
||||
"integrity": "sha512-7VxlbEosK7DODiOnsjoVd0DSJzbnaPrM2jelMHI0y8zx1UnLS3WC6EFUXbvy74F2sXqEznh2tzn7EKWInaRN6Q==",
|
||||
"version": "0.3.232",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-darwin-arm64/-/claude-agent-sdk-darwin-arm64-0.3.232.tgz",
|
||||
"integrity": "sha512-+/4PX+dwmQAjlOlooocwa3kClulZfMo133xQH3LYDlK7D5bzze16lwlDGPVAYGEarpvXg7G5JK8QjfWAJ2HYbg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -82,9 +82,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@anthropic-ai/claude-agent-sdk-darwin-x64": {
|
||||
"version": "0.3.220",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-darwin-x64/-/claude-agent-sdk-darwin-x64-0.3.220.tgz",
|
||||
"integrity": "sha512-X9RwDsSmbF6ultKZroaip+DL8WRgC64gHbrAwrRlAFSPNZV7zmJyP2ur8rW7KrxqmtuehdMMkw8+SAC/6hD2PA==",
|
||||
"version": "0.3.232",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-darwin-x64/-/claude-agent-sdk-darwin-x64-0.3.232.tgz",
|
||||
"integrity": "sha512-EHZ1Y3aGyZ2mFZ6QLR1bM3/HiIn2cLrPjU+k3/oCIW6omJFodfzf410aWjDPSnMj7CE4d6t7MSjBWekMUbcv0g==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -95,9 +95,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@anthropic-ai/claude-agent-sdk-linux-arm64": {
|
||||
"version": "0.3.220",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-arm64/-/claude-agent-sdk-linux-arm64-0.3.220.tgz",
|
||||
"integrity": "sha512-WkROPwWskqhKR9XgnmseHQ6rLi9zM9qt57IWoToIjL/eXOqDWipp7JXZ1L5ud+LrA42dunHPZfBwD/vXZ+A7LA==",
|
||||
"version": "0.3.232",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-arm64/-/claude-agent-sdk-linux-arm64-0.3.232.tgz",
|
||||
"integrity": "sha512-wW2opwA5s7gLghjU6B2ADMAtoc7bAZMevUzi4g+1PXMJ8MGcPvbnx92EDYJrVDcZmAl1+fz19XyPsaShlisTzw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -111,9 +111,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@anthropic-ai/claude-agent-sdk-linux-arm64-musl": {
|
||||
"version": "0.3.220",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-arm64-musl/-/claude-agent-sdk-linux-arm64-musl-0.3.220.tgz",
|
||||
"integrity": "sha512-OHoZOZ8Cf2TBr6oXIXPwyvUxj9jrq2w8E4poA8dMpacXszcPSPiCQCMuuOh4aWJzfeJE1+TtWxhKMVb2csXyZQ==",
|
||||
"version": "0.3.232",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-arm64-musl/-/claude-agent-sdk-linux-arm64-musl-0.3.232.tgz",
|
||||
"integrity": "sha512-XkLcb9UT/l42Rtw7KBApzgxUe/kwoWJ9KCPcVEnYojzIvVR+AwBCl/QReNU5+6c72w48dYBMmLebI64gQbN0tg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -127,9 +127,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@anthropic-ai/claude-agent-sdk-linux-x64": {
|
||||
"version": "0.3.220",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-x64/-/claude-agent-sdk-linux-x64-0.3.220.tgz",
|
||||
"integrity": "sha512-tkTJFnpR9VifvWX2fmkCAPkT6+8Wk/gVu8B5jsVekKZPiZoWRHmMXO30BnZn+f0TZhgYP+82PSX3S8crH1kn+w==",
|
||||
"version": "0.3.232",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-x64/-/claude-agent-sdk-linux-x64-0.3.232.tgz",
|
||||
"integrity": "sha512-6Px1xDiwQyLkSxwRQ34/kPA8WMXQ2rHYGkwockND7+9yMw+ShI3AfLkyi9G7JtJHObWFT6B82eSHjDS/Fy+9hQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -143,9 +143,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@anthropic-ai/claude-agent-sdk-linux-x64-musl": {
|
||||
"version": "0.3.220",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-x64-musl/-/claude-agent-sdk-linux-x64-musl-0.3.220.tgz",
|
||||
"integrity": "sha512-K+FWj+LcGhC1Z7wqeWoLxm1iemcba5xKpLLFVwYm4V6HyMx3ruYd/2r2TiQtjT+JWeNFWIys0ScHiItR6vWAiA==",
|
||||
"version": "0.3.232",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-x64-musl/-/claude-agent-sdk-linux-x64-musl-0.3.232.tgz",
|
||||
"integrity": "sha512-L1x2ge9NpXMLTczmT44TKPQ88PHE+gsCakQMVEOa8rXFvfBoqvcpxM0DT8wrqYWUHhQEYR8NXxQTP9a1NVRj8Q==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -159,9 +159,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@anthropic-ai/claude-agent-sdk-win32-arm64": {
|
||||
"version": "0.3.220",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-win32-arm64/-/claude-agent-sdk-win32-arm64-0.3.220.tgz",
|
||||
"integrity": "sha512-rIwgq0UwQExWl6KrHUyC4w5KwpL9l6nd95aUTx6RitexaAuEw//xtfTVLnuE4hDDQZFkzEwpdKc3nxDWoGcUbA==",
|
||||
"version": "0.3.232",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-win32-arm64/-/claude-agent-sdk-win32-arm64-0.3.232.tgz",
|
||||
"integrity": "sha512-fDiuwL5dm1elOy7fNp4Qmdor8so4R8npj2pUGQA1G/xKfJhaK236aTWezvZid3L/O7cRdFeN9IVofOAlWLQcsw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -172,9 +172,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@anthropic-ai/claude-agent-sdk-win32-x64": {
|
||||
"version": "0.3.220",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-win32-x64/-/claude-agent-sdk-win32-x64-0.3.220.tgz",
|
||||
"integrity": "sha512-MuOuXhbr66HlGaWXD2f3w0k2PsvmnbkwcUZ0dAe2poFLdl72GC2dapwwOBefxm9QmoNqk9+jmv/dSKGOVWyvLw==",
|
||||
"version": "0.3.232",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-win32-x64/-/claude-agent-sdk-win32-x64-0.3.232.tgz",
|
||||
"integrity": "sha512-Hc/9uy1BI9mqKVyB1b/zoUnm3MFgtVNzQY6p5zgaq9DaIjCKDpR4a4L1aDZM4lMqUcWFMZM+u7juOhh+m39NBQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -185,9 +185,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@anthropic-ai/sdk": {
|
||||
"version": "0.115.0",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.115.0.tgz",
|
||||
"integrity": "sha512-BJrFIVyjNuU8lfDyIJTvlRYzgQg+zEl78BxE7fq8esULsGz9IRQvGtW5spq3tydmtjQb/GFdooKGdGsetpx+lQ==",
|
||||
"version": "0.117.1",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.117.1.tgz",
|
||||
"integrity": "sha512-Yn2QlXfyCiKJ5YGCOOay7ZE78ISvII2XY621WMCiflmG8IYgwx59IBwPExxki3Xk9jKUtnD/Sj6UvplWr0rZxg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"json-schema-to-ts": "^3.1.1",
|
||||
@@ -491,9 +491,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@oxc-project/types": {
|
||||
"version": "0.142.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.142.0.tgz",
|
||||
"integrity": "sha512-7W+2q5AKQVU36fkaryontrHn3YDt1RyUYXatw9i5H8ocYe2sPKSFB6eS8WNPeRKiN1qAWWZUPm7gwFzJGrccqQ==",
|
||||
"version": "0.144.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.144.0.tgz",
|
||||
"integrity": "sha512-nuhZIOLuI6TFQ32I/WnUx+SCPY7SdSKwgnFHydAuoS1+Z4BRcaP+RRJmGzl9lw+0OFF7UmaESf7KQRXaNLHypg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
@@ -501,9 +501,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-android-arm64": {
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.2.tgz",
|
||||
"integrity": "sha512-l7x215OGvo1s52JWmR8U/DAVzEDWBCIbTm28aeJV/WDTSHgcKXaZTuBT0hJMs5NggilfJTW3clZVvd24yfKJxA==",
|
||||
"version": "1.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.4.tgz",
|
||||
"integrity": "sha512-jHC2cnyKz5xU2fhECtFl8OZ83cYNt13GZQD+0uMJ/X3o+ijmd56okHhTUwxVSHPx1IRVIJEZ1/1pPzeLCU6XKA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -518,9 +518,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-darwin-arm64": {
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.2.tgz",
|
||||
"integrity": "sha512-9u9Xv6c1AJZT0FfwH5vrMG5Jjcwhc1MlyrPu0XfTqkzsmqfks2M6W/o5XwAJgVVN/jHpqqngC1WevHKKTIUtIA==",
|
||||
"version": "1.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.4.tgz",
|
||||
"integrity": "sha512-Dc5mPD8F5F/FS8i01syd7FTF6yB2fVthH/TRkjwJkzUK6EpoxHtqvZQP5Zwq80/5z19TWYHIg1KOHboCgVx/aQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -535,9 +535,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-darwin-x64": {
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.2.tgz",
|
||||
"integrity": "sha512-9W1mbGZAfW3oqd85bhBkmpyHCCzL1TeG/zFFP3vg7b0rlly8cxOcre5nXwz+LHazCwac2MNWgPdPCHndABjpWQ==",
|
||||
"version": "1.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.4.tgz",
|
||||
"integrity": "sha512-fpDm4oBo6SqLvWUYCmFhdde3U9KH2fRNNMeAnAPAIwxRL345xutL0EtEUcuoxsoazdJGv/MuDBQHlCDrtbvqOg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -552,9 +552,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-freebsd-x64": {
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.2.tgz",
|
||||
"integrity": "sha512-0p1lhiCSCyaerFwtrdZQUx7NqGk6LQnaRKWX7tFQqwQgvX0rjM15cIkm3pax1UpEakK14C4mOxx/jSqCBdBRqQ==",
|
||||
"version": "1.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.4.tgz",
|
||||
"integrity": "sha512-rSJoreDE/HoIzoaib6MTp5jQtCTdMHKIvItAKT/ImS6Y6Ww76oUaeMyp4Vc/fAgd/ehji068IxetHXAnqUwN9A==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -569,9 +569,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-arm-gnueabihf": {
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.2.tgz",
|
||||
"integrity": "sha512-e+cOJXrJ2L3zx6YzqPg+f6Wbk3V1cKB8bOhbaYdVYN3DdquzNdRAmrbETz1qnt5yp/c7JNlNjmITiA2cVneQ7w==",
|
||||
"version": "1.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.4.tgz",
|
||||
"integrity": "sha512-/jm8OGHgn7oGaJu3i/qZI9spUGcJ+y/lk43ttQ/iO1tOd9NissG6o97bighBCiL+BKRngmcDuR6ikfwYdJmVuQ==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
@@ -586,9 +586,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-arm64-gnu": {
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.2.tgz",
|
||||
"integrity": "sha512-JsSMsj6sNat/MuhG5fnBD7QgbtpHKVe30x5/bAVirDHdhoQRXJkF6xc0Jqk8O4fiCUQAzMOoH9wZi3m60c8wtg==",
|
||||
"version": "1.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.4.tgz",
|
||||
"integrity": "sha512-tIP06BeD9EqvECBrPZ+sqdPlYrT+aYaAiu1wYziVx5elRK/ftm33JxVDy2bXGbr6J0CrtirCkR87/X5a2euEng==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -606,9 +606,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-arm64-musl": {
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.2.tgz",
|
||||
"integrity": "sha512-B5G/zJdHaoJn9vD50eGHWkiWfmq8Uhi3IiLPJTzmZTrAalk1bztUikSXo0qga18ibE0IXboyeMUnhPjhAJ45wQ==",
|
||||
"version": "1.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.4.tgz",
|
||||
"integrity": "sha512-Ql1Q0EQqVThvn9VAVlwNzsUvbSFtCMGjLpRRi4pk5i7NZZ4n5ISiLMjHYtus4VQ2PvkSw24zyaCVsiS+sXPj1w==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -626,9 +626,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-ppc64-gnu": {
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.2.tgz",
|
||||
"integrity": "sha512-6mC/awzKka8W6EoekjegpfGkjz8jXWDX63pqu/HYVpyKtZfu65Jsh4QAH3Kej3CAv/c1oGX7psTmFEbr0mDxLA==",
|
||||
"version": "1.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.4.tgz",
|
||||
"integrity": "sha512-GjbjXD4XXfN19D0LZNbmiCBUoDiRACsYHr0yaIbbn8aFsXjHZifcYqu/W5Er5X2X990WjHXFrxarn5chzItorQ==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
@@ -646,9 +646,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-s390x-gnu": {
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.2.tgz",
|
||||
"integrity": "sha512-412MX9fJLdA1IK28EZnc8jYv2HRTleOZgfLQumJ5zy7OeJLZlg/CETwFaXjNmGVxG51cFHpKLqb5LKvBC+HsHA==",
|
||||
"version": "1.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.4.tgz",
|
||||
"integrity": "sha512-p5WR0NOwaRmJ/B1b6IjEFLLivwEsf3PrdBIhRbhTCQisbo2SvHHpG4ELB/+FgQNnB88LTOF86upmJmbvZdQ2lw==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
@@ -666,9 +666,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-x64-gnu": {
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.2.tgz",
|
||||
"integrity": "sha512-Q/+HI/ToJafZ1iCqGgVQXUEkIjufHCTF0gBQ2a5o3cg7GJ2h0qyq3nvvSmU+bGda2/7ygXpTY4TM6gO9OhQ0ZA==",
|
||||
"version": "1.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.4.tgz",
|
||||
"integrity": "sha512-4/GyVjmhR+Tc6HLJvwc1sOhPqAZtySiSMesOZyX6JQ5XBxoTDEMKQzvo07NIK6nTon/SivlZqvhzvuVBNQhObQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -686,9 +686,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-x64-musl": {
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.2.tgz",
|
||||
"integrity": "sha512-ZKp/w41n6wCvxzxQHtQSbuphfX3Y4cCvbjkKHusrLx4lh+JWLTU7StSltO/DKARISzbj368d+qUaCjI8K2wzXw==",
|
||||
"version": "1.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.4.tgz",
|
||||
"integrity": "sha512-l9eeLsCNvPpmSXUej0etw/J1eqV0Jj1D5G/xG6YTijmE6dkv6E2QezgWbTfQk63v952DPqrjOCoiqxq7Bw0YUQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -706,9 +706,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-openharmony-arm64": {
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.2.tgz",
|
||||
"integrity": "sha512-pxE6xD4KS3eAROkKK5yrhB9/3+vhlhVGMvlQLbdpzrBGDbKrnzx3RLwPaHvasLo6jgaiBLn7e04Df9C4tYhjmA==",
|
||||
"version": "1.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.4.tgz",
|
||||
"integrity": "sha512-e0F355MSTMm3+UOqtV3L24gFUp2N5m1f8L/7d56deik6va+AXdrt9F8LbzGpeWGWRbZEDq4m8NVnJDeBtf9DZg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -723,9 +723,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-win32-arm64-msvc": {
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.2.tgz",
|
||||
"integrity": "sha512-4MqEue5re+xIZzAWsB8sj0P1kqZySWqIuN4t6QaIO/YA6SFwySOLruvWQFzfmqk8LBK2P30KCSJwf6mJCZZ5/A==",
|
||||
"version": "1.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.4.tgz",
|
||||
"integrity": "sha512-AWLi0uBRYh6QlE7OKhiz+phZC0qwtij2QZmhmOdsLdFn64m7oMpooE9ICE3lhm9xMb4SpDo2WbHcxX1iFLFtqw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -740,9 +740,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-win32-x64-msvc": {
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.2.tgz",
|
||||
"integrity": "sha512-NweNxxD0Nf9t8v7kodun45Ijp3EIwYY+uydPP6qBEYvfBqhIjN6dZMzlQja3tqX/aLs3F3Uz+AxDpKgRhpOZQg==",
|
||||
"version": "1.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.4.tgz",
|
||||
"integrity": "sha512-UwSDJOg3dqCAejWdxclJjCsh3Qq4vLYMDxmyHqo1btz3stK2VqgwNd3mm5tuIwzSlGIQ/1H9Hr+Zn09mrezNqQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -851,9 +851,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "26.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-26.1.2.tgz",
|
||||
"integrity": "sha512-Vu4a5UFA9rIIFJ7rB/Vaafh9lrCQszopTCx6KjFboXTGQbPNasehVR5TEiithSDGyd1DEiUByggTZsg8jukeIg==",
|
||||
"version": "26.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-26.2.0.tgz",
|
||||
"integrity": "sha512-5IviulTZeRNp2vAJ514cc/HUlY5nZ9fCbq9DMyC52BrhFZACo3nI0R7qBxhQmo/d27NFe96ur/b7Wwxklda+kg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -861,17 +861,17 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/eslint-plugin": {
|
||||
"version": "8.65.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.65.0.tgz",
|
||||
"integrity": "sha512-IEgob78X12rHpUmtcwFsXhZdVGJtwTVP8FiCLZkR6GlYVrl2PcuB+KhCE5BlVC/eQpQnu8WXRtkHZuPar+gCRA==",
|
||||
"version": "8.67.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.67.0.tgz",
|
||||
"integrity": "sha512-Un7Heoyj65NREbKAyIrFxeM143NZpExWmy1Nep4DLeQOeLlTeumPjoNKnBrU5D5moWXbPJgRa5Uwcdu0faVNGQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@eslint-community/regexpp": "^4.12.2",
|
||||
"@typescript-eslint/scope-manager": "8.65.0",
|
||||
"@typescript-eslint/type-utils": "8.65.0",
|
||||
"@typescript-eslint/utils": "8.65.0",
|
||||
"@typescript-eslint/visitor-keys": "8.65.0",
|
||||
"@typescript-eslint/scope-manager": "8.67.0",
|
||||
"@typescript-eslint/type-utils": "8.67.0",
|
||||
"@typescript-eslint/utils": "8.67.0",
|
||||
"@typescript-eslint/visitor-keys": "8.67.0",
|
||||
"ignore": "^7.0.5",
|
||||
"natural-compare": "^1.4.0",
|
||||
"ts-api-utils": "^2.5.0"
|
||||
@@ -884,22 +884,22 @@
|
||||
"url": "https://opencollective.com/typescript-eslint"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@typescript-eslint/parser": "^8.65.0",
|
||||
"@typescript-eslint/parser": "^8.67.0",
|
||||
"eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
|
||||
"typescript": ">=4.8.4 <6.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/parser": {
|
||||
"version": "8.65.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.65.0.tgz",
|
||||
"integrity": "sha512-CZ4nMxWwgu1HEEFNkeaCptra9QCtkmKdgf3sWh1rl1trIhmxLilgTV4cwcbQ4wemnT4sWQN8CaKOmdYx+g2gMA==",
|
||||
"version": "8.67.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.67.0.tgz",
|
||||
"integrity": "sha512-fUBfTuuEulWqX6V8+O3PtScV01tzYYRUDTAirHFKoRAt7nOzoGiPt0M/bB47wWNy0coOOcgEwAMUtBpykMxl6w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/scope-manager": "8.65.0",
|
||||
"@typescript-eslint/types": "8.65.0",
|
||||
"@typescript-eslint/typescript-estree": "8.65.0",
|
||||
"@typescript-eslint/visitor-keys": "8.65.0",
|
||||
"@typescript-eslint/scope-manager": "8.67.0",
|
||||
"@typescript-eslint/types": "8.67.0",
|
||||
"@typescript-eslint/typescript-estree": "8.67.0",
|
||||
"@typescript-eslint/visitor-keys": "8.67.0",
|
||||
"debug": "^4.4.3"
|
||||
},
|
||||
"engines": {
|
||||
@@ -915,14 +915,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/project-service": {
|
||||
"version": "8.65.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.65.0.tgz",
|
||||
"integrity": "sha512-SxnPhbTsGahizDgbu7oqFH/xVtzIqMd/s+WtnSxNxJZJpLbdT5IPdzg8EZxO3+PoKahXmwJLeNQOpKJb3/bi7Q==",
|
||||
"version": "8.67.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.67.0.tgz",
|
||||
"integrity": "sha512-cvE8c7ulYeXN9fYuszhCeCsbzyVEXuhrRCybnBre7TUmqb5nRmBfQAwCj0O3WJFDeyAZt4VYv51vMCC9LHSdYw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/tsconfig-utils": "^8.65.0",
|
||||
"@typescript-eslint/types": "^8.65.0",
|
||||
"@typescript-eslint/tsconfig-utils": "^8.67.0",
|
||||
"@typescript-eslint/types": "^8.67.0",
|
||||
"debug": "^4.4.3"
|
||||
},
|
||||
"engines": {
|
||||
@@ -937,14 +937,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/scope-manager": {
|
||||
"version": "8.65.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.65.0.tgz",
|
||||
"integrity": "sha512-Esbl8OSYiVxBokYgWPf7VVWg/BE798wXhimnn9ML9Pt5qoDf8bfQlgjlKXR/k98+AcNzlLKYrpCcrcuZ9DZLgg==",
|
||||
"version": "8.67.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.67.0.tgz",
|
||||
"integrity": "sha512-EgvsleTwS4E+WzzSvem8fAUubLwatMNF1B5hHSLQxcvs7q2dtRhGyujHwLJSYlG41niJ7GP24Aha2+0mb1b2kg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/types": "8.65.0",
|
||||
"@typescript-eslint/visitor-keys": "8.65.0"
|
||||
"@typescript-eslint/types": "8.67.0",
|
||||
"@typescript-eslint/visitor-keys": "8.67.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
@@ -955,9 +955,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/tsconfig-utils": {
|
||||
"version": "8.65.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.65.0.tgz",
|
||||
"integrity": "sha512-j6GzGqCiRdA7Qhur2VVmKZAkBLfnHFQfx4TaJGL9RMveZqCo48jSHHO0DTgizEnGhtWnqmbtCUSrqSkdiY/0Hg==",
|
||||
"version": "8.67.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.67.0.tgz",
|
||||
"integrity": "sha512-vV+LUSv5njUWsknE71fqKTlXUva+R76SaeORd6Zojcunk/6DvKFXONU3BrAs2H49mbygUXt6gbYunzwqNwlhdg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -972,15 +972,15 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/type-utils": {
|
||||
"version": "8.65.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.65.0.tgz",
|
||||
"integrity": "sha512-YjaZ7PRI5qY7ax2L3PbvX0rRyGtipAReCWs0mhhDBHjH/vl0g0BonaGXrKdKpMbIIsMIwDgbk/xzkBTyAltS5g==",
|
||||
"version": "8.67.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.67.0.tgz",
|
||||
"integrity": "sha512-aVWDXbRmdXO9siTfX4ditQI1T9+zVcNazT48EJCD0v40/9RIFoUgZ05CmGEq9H2gixRpjUn/iplwvlcvutJW/Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/types": "8.65.0",
|
||||
"@typescript-eslint/typescript-estree": "8.65.0",
|
||||
"@typescript-eslint/utils": "8.65.0",
|
||||
"@typescript-eslint/types": "8.67.0",
|
||||
"@typescript-eslint/typescript-estree": "8.67.0",
|
||||
"@typescript-eslint/utils": "8.67.0",
|
||||
"debug": "^4.4.3",
|
||||
"ts-api-utils": "^2.5.0"
|
||||
},
|
||||
@@ -997,9 +997,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/types": {
|
||||
"version": "8.65.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.65.0.tgz",
|
||||
"integrity": "sha512-JSSwWNy+H0E/01jJEM+hrX6N0OFDzFzeIhHFSAS01tlVaevpG8cFyYRPhS5yjGOvBUx3sqQHVMjCL1CAZZMxBg==",
|
||||
"version": "8.67.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.67.0.tgz",
|
||||
"integrity": "sha512-sBtgslww8nsMYUjhdPBiSyUqSzT8uR6g93A2QXnQC8+cGdjz0CyaOdqHDRJb1AtORbZCNUJBBeFA/tNR2uQmww==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -1011,16 +1011,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/typescript-estree": {
|
||||
"version": "8.65.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.65.0.tgz",
|
||||
"integrity": "sha512-JboAE2swaYt4tb1fHhHTABE2K+OLy09XfcTbhnk4Pw96f9dd2e9iYsJ28gBggHlo5z5x1rkyWvcPoTuNTd4oGg==",
|
||||
"version": "8.67.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.67.0.tgz",
|
||||
"integrity": "sha512-EKQBCE9yNlRJYm7jdTW5AhDacDUmSwQb0FAJAmK2EKYrNXIsa2vxcSZx6PvJ/dEdI6lS+Y9W+EXckLj0iPFGcw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/project-service": "8.65.0",
|
||||
"@typescript-eslint/tsconfig-utils": "8.65.0",
|
||||
"@typescript-eslint/types": "8.65.0",
|
||||
"@typescript-eslint/visitor-keys": "8.65.0",
|
||||
"@typescript-eslint/project-service": "8.67.0",
|
||||
"@typescript-eslint/tsconfig-utils": "8.67.0",
|
||||
"@typescript-eslint/types": "8.67.0",
|
||||
"@typescript-eslint/visitor-keys": "8.67.0",
|
||||
"debug": "^4.4.3",
|
||||
"minimatch": "^10.2.2",
|
||||
"semver": "^7.7.3",
|
||||
@@ -1039,16 +1039,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/utils": {
|
||||
"version": "8.65.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.65.0.tgz",
|
||||
"integrity": "sha512-gXiwIHsYreboxeJucHKPvgwl7dXt50mF8s1/c00cP/WoVTyWKFdtfhRWwZiXYFU5H2O8vVoSLNrexFZjYS/SGA==",
|
||||
"version": "8.67.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.67.0.tgz",
|
||||
"integrity": "sha512-U9D1FdwEWBwok3hxxSdhclMb0twvt9QnjIQ0VfQ1AiX2epnpSgv2ubVDsayOFyY8K6FX+AQ7E0FKWVG3iKsj1A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@eslint-community/eslint-utils": "^4.9.1",
|
||||
"@typescript-eslint/scope-manager": "8.65.0",
|
||||
"@typescript-eslint/types": "8.65.0",
|
||||
"@typescript-eslint/typescript-estree": "8.65.0"
|
||||
"@typescript-eslint/scope-manager": "8.67.0",
|
||||
"@typescript-eslint/types": "8.67.0",
|
||||
"@typescript-eslint/typescript-estree": "8.67.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
@@ -1063,13 +1063,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/visitor-keys": {
|
||||
"version": "8.65.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.65.0.tgz",
|
||||
"integrity": "sha512-8C71BQkGjiMmXtop7pHVJu1l2NNShFdkCyD6a2ezzs5vU/L3LRtb69EtcteFwz0mYMPzIgOw0n6OV4VBUWZd7A==",
|
||||
"version": "8.67.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.67.0.tgz",
|
||||
"integrity": "sha512-fkv8dHRDqfGtTHuJeebdrQ7cX6Ad4WAS00rgHh9UGvMycF1mjBfsxry1XsLIFhWZ6Judlh6UdzK+TYlbpCXgnA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/types": "8.65.0",
|
||||
"@typescript-eslint/types": "8.67.0",
|
||||
"eslint-visitor-keys": "^5.0.0"
|
||||
},
|
||||
"engines": {
|
||||
@@ -1344,9 +1344,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/body-parser/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==",
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz",
|
||||
"integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
@@ -1658,9 +1658,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/eslint": {
|
||||
"version": "10.8.0",
|
||||
"resolved": "https://registry.npmjs.org/eslint/-/eslint-10.8.0.tgz",
|
||||
"integrity": "sha512-nuKKvN+oIBO0koN7Tm7dlkmnkc21mtt0QJLwAKzjLq14y6lRTdVG36MZHJ8eQHwdJMwZbQNMlPOYedMq/oVJvQ==",
|
||||
"version": "10.8.1",
|
||||
"resolved": "https://registry.npmjs.org/eslint/-/eslint-10.8.1.tgz",
|
||||
"integrity": "sha512-wqA7W2jbsC/BnV9Iv1UZpKVFkO1AdNoSmYW8NWG4HNOBbkAMvIqDZ27pI2f07dqn583NcIC44ckjAcOXDL1QbQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"workspaces": [
|
||||
@@ -1922,9 +1922,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/eventsource-parser": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.1.0.tgz",
|
||||
"integrity": "sha512-kJezFj9YFAMLeORyi7aCLxLbD5/qWMQnoMVlVPyHIll7lgRJCc3JVln9Vgl9nwQi0YkMnhdGTMNn7CkRRAptMg==",
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.1.1.tgz",
|
||||
"integrity": "sha512-EKN1vKAMcZ8MlYMpaNuxN6R9yakzH6uajHcHVTqWJzvu5pWw9DyhbP35HH8MVBQ+dZjAfDxk+A8NiR9KWaXiyQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
@@ -1986,9 +1986,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/express-rate-limit": {
|
||||
"version": "8.6.1",
|
||||
"resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.6.1.tgz",
|
||||
"integrity": "sha512-0D493aP61w0TJ2A0wy27riRsO7FMQ7FK+KUHOKCSfPvYo0R55aiC6emCVgFUeShH0fq0ICPVzNcgoS+BsbXQCA==",
|
||||
"version": "8.6.2",
|
||||
"resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.6.2.tgz",
|
||||
"integrity": "sha512-YH4ru+eOJxQABscKFfRCy9R7x9QFGdezclVMwwgFFndzS2Xnm0uo6B0ABZsLhcpeptGv2qvuJVWlQr9gQZoC3A==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
@@ -2237,9 +2237,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/globals": {
|
||||
"version": "17.9.0",
|
||||
"resolved": "https://registry.npmjs.org/globals/-/globals-17.9.0.tgz",
|
||||
"integrity": "sha512-m/MvAW61QVU5VDNF1Vj8axt016h8w7L5TU1e9zlab7XIttAT2YAlCwl75K1fOqvMM9apmD7lbCIRhpfkhmxhCg==",
|
||||
"version": "17.11.0",
|
||||
"resolved": "https://registry.npmjs.org/globals/-/globals-17.11.0.tgz",
|
||||
"integrity": "sha512-Z2I8hM+PbJDXQDq3Icgpzv+mPdwr68iZUU9d5WW4FuXfDUQfkZaZuvjMv42/5crNyw154+9+VWXbYrUgDXbxNw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -2289,9 +2289,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/hono": {
|
||||
"version": "4.13.0",
|
||||
"resolved": "https://registry.npmjs.org/hono/-/hono-4.13.0.tgz",
|
||||
"integrity": "sha512-jhunvfHWxd7J5EFfSgH4xsYJzSe/lfqbUCxiyyeaQasUsXeEHXtzVid+7EOGByc5JnFa23SSFL3Y2RV/z1T+eQ==",
|
||||
"version": "4.13.2",
|
||||
"resolved": "https://registry.npmjs.org/hono/-/hono-4.13.2.tgz",
|
||||
"integrity": "sha512-JydRilDRkYBQMt9qR9U92mXxmbGqsqSn/IKOrh4e7/gEbn+0zSr8igTu0obwJoNGN4sez28DIql7FBHWydoJpA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
@@ -2364,9 +2364,9 @@
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/ip-address": {
|
||||
"version": "10.4.0",
|
||||
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.4.0.tgz",
|
||||
"integrity": "sha512-oSK96Grm3aP6OrS263xVxbNDGVL7rzBtYdpGqlDG8iQdoenDoTs/nkki+DflYbAEE8Xl6o5YxhxlrKvI3nqKXQ==",
|
||||
"version": "10.5.0",
|
||||
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.5.0.tgz",
|
||||
"integrity": "sha512-R5SnVLJmgYYvf2F2ZgwSBnelz5G4q5AxIC277GDfUaNbrZKNANcBC7RHqYYePlszf4kBolVkJauG0ZjHHFh55g==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
@@ -2887,9 +2887,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/nanoid": {
|
||||
"version": "3.3.17",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.17.tgz",
|
||||
"integrity": "sha512-xQLf0A3HOMlgHq0n247/LRuAOYmB7dXJ/DvAxGvsSBij45XtBSmQycu+F8ODbHwns/XyFZagyL1+J0Offw1E0g==",
|
||||
"version": "3.3.18",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz",
|
||||
"integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
@@ -3110,9 +3110,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/postcss": {
|
||||
"version": "8.5.25",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.25.tgz",
|
||||
"integrity": "sha512-DTPx3RWSSnWyzLxQnlH0rJP+EW5ekl16ZU4/psbIhA0e53kJfdgaN5vKM+xP7yJtXVu+nfdVFmlgFDEKAe4Pyw==",
|
||||
"version": "8.5.26",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz",
|
||||
"integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
@@ -3130,7 +3130,7 @@
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"nanoid": "^3.3.16",
|
||||
"nanoid": "^3.3.17",
|
||||
"picocolors": "^1.1.1",
|
||||
"source-map-js": "^1.2.1"
|
||||
},
|
||||
@@ -3246,13 +3246,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/rolldown": {
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.2.tgz",
|
||||
"integrity": "sha512-opwpo1tQBAcpSUJDt94B7hhLNGOKjCdE//XXjeLrnx9b83bjnw45tXdg1b09yEw/VLFBJGZpwRULMmOZo7ol+A==",
|
||||
"version": "1.2.4",
|
||||
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.4.tgz",
|
||||
"integrity": "sha512-rSr7irW0K7QRWzjdJXqZowkcRdDtjRduh43rBltnVKd0VFq839l1lJoDvGJb6gl7+4rTTCrPWu+YfujUL8Ug7w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@oxc-project/types": "=0.142.0",
|
||||
"@oxc-project/types": "=0.144.0",
|
||||
"@rolldown/pluginutils": "^1.0.0"
|
||||
},
|
||||
"bin": {
|
||||
@@ -3262,20 +3262,20 @@
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@rolldown/binding-android-arm64": "1.2.2",
|
||||
"@rolldown/binding-darwin-arm64": "1.2.2",
|
||||
"@rolldown/binding-darwin-x64": "1.2.2",
|
||||
"@rolldown/binding-freebsd-x64": "1.2.2",
|
||||
"@rolldown/binding-linux-arm-gnueabihf": "1.2.2",
|
||||
"@rolldown/binding-linux-arm64-gnu": "1.2.2",
|
||||
"@rolldown/binding-linux-arm64-musl": "1.2.2",
|
||||
"@rolldown/binding-linux-ppc64-gnu": "1.2.2",
|
||||
"@rolldown/binding-linux-s390x-gnu": "1.2.2",
|
||||
"@rolldown/binding-linux-x64-gnu": "1.2.2",
|
||||
"@rolldown/binding-linux-x64-musl": "1.2.2",
|
||||
"@rolldown/binding-openharmony-arm64": "1.2.2",
|
||||
"@rolldown/binding-win32-arm64-msvc": "1.2.2",
|
||||
"@rolldown/binding-win32-x64-msvc": "1.2.2"
|
||||
"@rolldown/binding-android-arm64": "1.2.4",
|
||||
"@rolldown/binding-darwin-arm64": "1.2.4",
|
||||
"@rolldown/binding-darwin-x64": "1.2.4",
|
||||
"@rolldown/binding-freebsd-x64": "1.2.4",
|
||||
"@rolldown/binding-linux-arm-gnueabihf": "1.2.4",
|
||||
"@rolldown/binding-linux-arm64-gnu": "1.2.4",
|
||||
"@rolldown/binding-linux-arm64-musl": "1.2.4",
|
||||
"@rolldown/binding-linux-ppc64-gnu": "1.2.4",
|
||||
"@rolldown/binding-linux-s390x-gnu": "1.2.4",
|
||||
"@rolldown/binding-linux-x64-gnu": "1.2.4",
|
||||
"@rolldown/binding-linux-x64-musl": "1.2.4",
|
||||
"@rolldown/binding-openharmony-arm64": "1.2.4",
|
||||
"@rolldown/binding-win32-arm64-msvc": "1.2.4",
|
||||
"@rolldown/binding-win32-x64-msvc": "1.2.4"
|
||||
}
|
||||
},
|
||||
"node_modules/router": {
|
||||
@@ -3667,9 +3667,9 @@
|
||||
}
|
||||
},
|
||||
"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==",
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz",
|
||||
"integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
@@ -3739,16 +3739,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/vite": {
|
||||
"version": "8.2.0",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-8.2.0.tgz",
|
||||
"integrity": "sha512-pn+CFpM0lwDeKwmOq1ZaBK/9sjorZcgqxki6MbY/jPEVd9vichIlmlD4HmQ5wdP5EgqQCFRaACBxMC7uEGc6lQ==",
|
||||
"version": "8.2.1",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-8.2.1.tgz",
|
||||
"integrity": "sha512-EU/eS7BH3XROHh2YnBefjM6DBKA6ZeMZEYQbj7NLWg5wHYlhB8B/Mayd5XsgWq+NFYccDOTemRpdETWR6Ka/lw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"lightningcss": "^1.33.0",
|
||||
"picomatch": "^4.0.5",
|
||||
"postcss": "^8.5.23",
|
||||
"rolldown": "~1.2.0",
|
||||
"postcss": "^8.5.25",
|
||||
"rolldown": "~1.2.1",
|
||||
"tinyglobby": "^0.2.17"
|
||||
},
|
||||
"bin": {
|
||||
|
||||
16
package.json
16
package.json
@@ -3,7 +3,7 @@
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
"version": "0.66.0",
|
||||
"version": "0.69.0",
|
||||
"description": "An ACP-compatible coding agent powered by the Claude Agent SDK (TypeScript)",
|
||||
"main": "dist/lib.js",
|
||||
"types": "dist/lib.d.ts",
|
||||
@@ -66,19 +66,19 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "1.3.0",
|
||||
"@anthropic-ai/claude-agent-sdk": "0.3.220",
|
||||
"@anthropic-ai/claude-agent-sdk": "0.3.232",
|
||||
"zod": "^3.25.0 || ^4.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@anthropic-ai/sdk": "0.115.0",
|
||||
"@anthropic-ai/sdk": "0.117.1",
|
||||
"@eslint/js": "10.0.1",
|
||||
"@tsconfig/node22": "22.0.5",
|
||||
"@types/node": "26.1.2",
|
||||
"@typescript-eslint/eslint-plugin": "8.65.0",
|
||||
"@typescript-eslint/parser": "8.65.0",
|
||||
"eslint": "10.8.0",
|
||||
"@types/node": "26.2.0",
|
||||
"@typescript-eslint/eslint-plugin": "8.67.0",
|
||||
"@typescript-eslint/parser": "8.67.0",
|
||||
"eslint": "10.8.1",
|
||||
"eslint-config-prettier": "10.1.8",
|
||||
"globals": "17.9.0",
|
||||
"globals": "17.11.0",
|
||||
"prettier": "3.9.6",
|
||||
"ts-node": "10.9.2",
|
||||
"typescript": "6.0.3",
|
||||
|
||||
971
src/acp-agent.ts
971
src/acp-agent.ts
File diff suppressed because it is too large
Load Diff
482
src/file-change-audit.ts
Normal file
482
src/file-change-audit.ts
Normal file
@@ -0,0 +1,482 @@
|
||||
import {
|
||||
createSdkMcpServer,
|
||||
type HookCallback,
|
||||
type McpSdkServerConfigWithInstance,
|
||||
type SdkMcpToolDefinition,
|
||||
} from "@anthropic-ai/claude-agent-sdk";
|
||||
import * as fs from "node:fs";
|
||||
import * as path from "node:path";
|
||||
import { z } from "zod";
|
||||
|
||||
export const AGENT_FILE_CHANGE_REPORT_CAPABILITY = "agentFileChangeReport";
|
||||
export const FILE_CHANGE_AUDIT_SERVER_NAME = "claude_agent_acp";
|
||||
export const FILE_CHANGE_AUDIT_TOOL_NAME = "report_changed_files";
|
||||
export const FILE_CHANGE_AUDIT_WIRE_TOOL_NAME = `mcp__${FILE_CHANGE_AUDIT_SERVER_NAME}__${FILE_CHANGE_AUDIT_TOOL_NAME}`;
|
||||
|
||||
const FILE_CHANGE_AUDIT_MARKER = "claude-agent-acp-file-change-audit";
|
||||
const MAX_REPORTED_PATHS = 1024;
|
||||
const MAX_REPORTED_PATH_LENGTH = 4096;
|
||||
export const AGENT_FILE_CHANGE_REPORT_MAX_BYTES = 256 * 1024;
|
||||
const REQUEST_ID_PATTERN = /^[A-Za-z0-9._:-]{1,128}$/;
|
||||
|
||||
export type FileChangeAuditTurnState = {
|
||||
requestId: string;
|
||||
phase: "requested" | "collecting" | "finished";
|
||||
};
|
||||
|
||||
export type FileChangeAuditWorkspace = {
|
||||
cwd: string;
|
||||
additionalDirectories: string[];
|
||||
};
|
||||
|
||||
export type AgentFileChangeReport = {
|
||||
paths: string[];
|
||||
complete: boolean;
|
||||
uncertainty?: string;
|
||||
};
|
||||
|
||||
export type AgentFileChangeReportResult = {
|
||||
version: 1;
|
||||
requestId: string;
|
||||
} & (
|
||||
| {
|
||||
status: "reported";
|
||||
paths: string[];
|
||||
declaredComplete: boolean;
|
||||
truncated: boolean;
|
||||
uncertainty?: string;
|
||||
}
|
||||
| {
|
||||
status: "unavailable";
|
||||
reason: FileChangeReportUnavailableReason;
|
||||
}
|
||||
);
|
||||
|
||||
export type FileChangeReportUnavailableReason =
|
||||
"cancelled" | "timeout" | "invalidOutput" | "notReported" | "providerError";
|
||||
|
||||
const JETBRAINS_META_KEY = "jetbrains";
|
||||
const AIR_META_KEY = "air";
|
||||
const AIR_EXTENSION_VERSION = 1;
|
||||
const AIR_EXTENSION_CAPABILITIES_KEY = "capabilities";
|
||||
const AIR_AGENT_FILE_CHANGE_REPORT_KEY = "agentFileChangeReport";
|
||||
|
||||
type FileChangeAuditSupportOptions = {
|
||||
cwd: string;
|
||||
additionalDirectories: string[];
|
||||
getActiveState: () => FileChangeAuditTurnState | undefined;
|
||||
publish: (result: AgentFileChangeReportResult) => Promise<void>;
|
||||
logError: (message: string) => void;
|
||||
};
|
||||
|
||||
export type FileChangeAuditSupport = {
|
||||
mcpServer: McpSdkServerConfigWithInstance;
|
||||
preToolUseHook: HookCallback;
|
||||
stopHook: HookCallback;
|
||||
finishUnavailable: (
|
||||
state: FileChangeAuditTurnState,
|
||||
reason: FileChangeReportUnavailableReason,
|
||||
) => Promise<void>;
|
||||
};
|
||||
|
||||
const reportInputSchema = {
|
||||
paths: z
|
||||
.array(z.string())
|
||||
.describe(
|
||||
"Workspace file paths changed during this turn. Use absolute paths or paths relative to cwd.",
|
||||
),
|
||||
complete: z
|
||||
.boolean()
|
||||
.describe("True only when paths contains every workspace file changed during this turn."),
|
||||
uncertainty: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1)
|
||||
.max(2000)
|
||||
.optional()
|
||||
.describe("Why the report may be incomplete or uncertain."),
|
||||
};
|
||||
|
||||
export function agentFileChangeReportRequestId(meta: unknown): string | undefined {
|
||||
const air = airExtension(meta);
|
||||
const request = air?.agentFileChangeReportRequest;
|
||||
if (!request || typeof request !== "object" || Array.isArray(request)) return undefined;
|
||||
const requestRecord = request as Record<string, unknown>;
|
||||
const requestKeys = Object.keys(requestRecord);
|
||||
if (
|
||||
requestKeys.length !== 2 ||
|
||||
!requestKeys.includes("version") ||
|
||||
!requestKeys.includes("requestId") ||
|
||||
requestRecord.version !== 1
|
||||
)
|
||||
return undefined;
|
||||
const requestId = requestRecord.requestId;
|
||||
return typeof requestId === "string" && REQUEST_ID_PATTERN.test(requestId)
|
||||
? requestId
|
||||
: undefined;
|
||||
}
|
||||
|
||||
export function supportsAgentFileChangeReport(capabilities: unknown): boolean {
|
||||
if (!capabilities || typeof capabilities !== "object" || Array.isArray(capabilities)) {
|
||||
return false;
|
||||
}
|
||||
const air = airExtension((capabilities as Record<string, unknown>)._meta);
|
||||
const advertised = air?.[AIR_EXTENSION_CAPABILITIES_KEY];
|
||||
return (
|
||||
air?.version === AIR_EXTENSION_VERSION &&
|
||||
Array.isArray(advertised) &&
|
||||
advertised.includes(AGENT_FILE_CHANGE_REPORT_CAPABILITY)
|
||||
);
|
||||
}
|
||||
|
||||
export function agentFileChangeReportMeta(
|
||||
result: AgentFileChangeReportResult,
|
||||
): Record<string, unknown> {
|
||||
return {
|
||||
[JETBRAINS_META_KEY]: {
|
||||
[AIR_META_KEY]: {
|
||||
version: AIR_EXTENSION_VERSION,
|
||||
[AIR_AGENT_FILE_CHANGE_REPORT_KEY]: result,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function airExtension(meta: unknown): Record<string, unknown> | undefined {
|
||||
if (!meta || typeof meta !== "object" || Array.isArray(meta)) return undefined;
|
||||
const jetbrains = (meta as Record<string, unknown>)[JETBRAINS_META_KEY];
|
||||
if (!jetbrains || typeof jetbrains !== "object" || Array.isArray(jetbrains)) return undefined;
|
||||
const air = (jetbrains as Record<string, unknown>)[AIR_META_KEY];
|
||||
return air && typeof air === "object" && !Array.isArray(air)
|
||||
? (air as Record<string, unknown>)
|
||||
: undefined;
|
||||
}
|
||||
|
||||
export function createFileChangeAuditTurnState(requestId: string): FileChangeAuditTurnState {
|
||||
return {
|
||||
requestId,
|
||||
phase: "requested",
|
||||
};
|
||||
}
|
||||
|
||||
export function isFileChangeAuditReportPhase(state: FileChangeAuditTurnState | undefined): boolean {
|
||||
return state?.phase === "collecting" || state?.phase === "finished";
|
||||
}
|
||||
|
||||
export function isFileChangeAuditTool(toolName: string): boolean {
|
||||
return toolName === FILE_CHANGE_AUDIT_WIRE_TOOL_NAME;
|
||||
}
|
||||
|
||||
export function containsFileChangeAuditMarker(text: string): boolean {
|
||||
return text.includes(`<${FILE_CHANGE_AUDIT_MARKER}>`);
|
||||
}
|
||||
|
||||
export function createFileChangeAuditSupport(
|
||||
options: FileChangeAuditSupportOptions,
|
||||
): FileChangeAuditSupport {
|
||||
const workspace = normalizeWorkspace(options.cwd, options.additionalDirectories);
|
||||
|
||||
const finishUnavailable = async (
|
||||
state: FileChangeAuditTurnState,
|
||||
reason: FileChangeReportUnavailableReason,
|
||||
) => {
|
||||
// Claim the terminal outcome before the first await. The report tool, a
|
||||
// Stop hook, cancellation, and stream teardown can race, but only the
|
||||
// winner may publish for this request id. Publication remains fail-open:
|
||||
// an audit transport failure must never hold the user prompt open.
|
||||
if (state.phase === "finished") return;
|
||||
state.phase = "finished";
|
||||
try {
|
||||
await options.publish({
|
||||
version: 1,
|
||||
requestId: state.requestId,
|
||||
status: "unavailable",
|
||||
reason,
|
||||
});
|
||||
} catch (error) {
|
||||
options.logError(`Failed to publish unavailable report ${state.requestId}: ${error}`);
|
||||
}
|
||||
};
|
||||
|
||||
const tool: SdkMcpToolDefinition<typeof reportInputSchema> = {
|
||||
name: FILE_CHANGE_AUDIT_TOOL_NAME,
|
||||
description:
|
||||
"Internal audit tool. Call it only when a stop-hook message explicitly requests the file-change audit.",
|
||||
inputSchema: reportInputSchema,
|
||||
_meta: {
|
||||
"anthropic/alwaysLoad": true,
|
||||
"claude/endTurn": true,
|
||||
},
|
||||
handler: async (report) => {
|
||||
const state = options.getActiveState();
|
||||
if (!state || state.phase !== "collecting") {
|
||||
return {
|
||||
isError: true,
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: `<${FILE_CHANGE_AUDIT_MARKER}>No file-change audit is active.</${FILE_CHANGE_AUDIT_MARKER}>`,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
// Flip before publishing: a client transport failure must not make the
|
||||
// model retry the report and produce duplicates. Delivery is audit-only
|
||||
// and fail-open; the normal turn must still finish.
|
||||
state.phase = "finished";
|
||||
const normalized = normalizeReportedPaths(report.paths, workspace);
|
||||
const result = fitReportedAgentFileChangeReport({
|
||||
version: 1,
|
||||
requestId: state.requestId,
|
||||
status: "reported",
|
||||
paths: normalized.paths,
|
||||
declaredComplete: report.complete && !normalized.truncated,
|
||||
truncated: normalized.truncated,
|
||||
...(report.uncertainty ? { uncertainty: report.uncertainty } : {}),
|
||||
});
|
||||
try {
|
||||
await options.publish(result);
|
||||
} catch (error) {
|
||||
options.logError(`Failed to publish file-change report ${state.requestId}: ${error}`);
|
||||
}
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: `<${FILE_CHANGE_AUDIT_MARKER}>File-change audit recorded.</${FILE_CHANGE_AUDIT_MARKER}>`,
|
||||
},
|
||||
],
|
||||
structuredContent: result,
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
// canUseTool is not guaranteed to run for tools that the current permission
|
||||
// mode auto-allows (notably bypassPermissions). A PreToolUse hook is the
|
||||
// enforcement boundary that keeps the hidden continuation read-only in every
|
||||
// mode: it can submit the report, but cannot inspect or mutate more files.
|
||||
const preToolUseHook: HookCallback = async (input) => {
|
||||
if (input.hook_event_name !== "PreToolUse") return {};
|
||||
const state = options.getActiveState();
|
||||
const isReportTool = isFileChangeAuditTool(input.tool_name);
|
||||
if (!isReportTool && !isFileChangeAuditReportPhase(state)) return {};
|
||||
|
||||
const allowReport = state?.phase === "collecting" && isReportTool;
|
||||
return {
|
||||
hookSpecificOutput: {
|
||||
hookEventName: "PreToolUse",
|
||||
permissionDecision: allowReport ? "allow" : "deny",
|
||||
...(!allowReport
|
||||
? {
|
||||
permissionDecisionReason:
|
||||
"Only the internal file-change report is allowed during the audit.",
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const stopHook: HookCallback = async (input) => {
|
||||
if (input.hook_event_name !== "Stop") return {};
|
||||
const state = options.getActiveState();
|
||||
if (!state || state.phase === "finished") return {};
|
||||
// A Stop can be only a pause while a background generator is still
|
||||
// running. Keep the request pending so a later final Stop can include its
|
||||
// writes instead of declaring a prematurely complete path set.
|
||||
if (state.phase === "requested" && (input.background_tasks?.length ?? 0) > 0) return {};
|
||||
if (state.phase === "collecting" || input.stop_hook_active) {
|
||||
await finishUnavailable(state, "notReported");
|
||||
return {};
|
||||
}
|
||||
|
||||
// Set the phase before returning the continuation so every following SDK
|
||||
// message is hidden even if the next stream starts immediately.
|
||||
state.phase = "collecting";
|
||||
return {
|
||||
hookSpecificOutput: {
|
||||
hookEventName: "Stop",
|
||||
additionalContext:
|
||||
`<${FILE_CHANGE_AUDIT_MARKER}>` +
|
||||
`Before stopping, call ${FILE_CHANGE_AUDIT_WIRE_TOOL_NAME} exactly once. ` +
|
||||
"Report every workspace file changed during this user turn, including files changed " +
|
||||
"by commands, generators, version-control operations, and child processes. " +
|
||||
"Do not call any other tool and do not emit user-facing prose. " +
|
||||
"Set complete to false and explain uncertainty when you are not sure the list is complete." +
|
||||
`</${FILE_CHANGE_AUDIT_MARKER}>`,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
return {
|
||||
mcpServer: createSdkMcpServer({
|
||||
name: FILE_CHANGE_AUDIT_SERVER_NAME,
|
||||
version: "1.0.0",
|
||||
tools: [tool],
|
||||
alwaysLoad: true,
|
||||
}),
|
||||
preToolUseHook,
|
||||
stopHook,
|
||||
finishUnavailable,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeWorkspace(
|
||||
cwd: string,
|
||||
additionalDirectories: string[],
|
||||
): FileChangeAuditWorkspace {
|
||||
const normalizedCwd = canonicalizeWorkspaceRoot(path.resolve(cwd));
|
||||
const seen = new Set([pathKey(normalizedCwd)]);
|
||||
const normalizedAdditionalDirectories: string[] = [];
|
||||
for (const directory of additionalDirectories) {
|
||||
const normalized = canonicalizeWorkspaceRoot(path.resolve(normalizedCwd, directory));
|
||||
const key = pathKey(normalized);
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
normalizedAdditionalDirectories.push(normalized);
|
||||
}
|
||||
return {
|
||||
cwd: normalizedCwd,
|
||||
additionalDirectories: normalizedAdditionalDirectories,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeReportedPaths(
|
||||
reportedPaths: string[],
|
||||
workspace: FileChangeAuditWorkspace,
|
||||
): { paths: string[]; truncated: boolean } {
|
||||
const roots = [workspace.cwd, ...workspace.additionalDirectories];
|
||||
const seen = new Set<string>();
|
||||
const result: string[] = [];
|
||||
let totalBytes = 0;
|
||||
let truncated = false;
|
||||
for (const reportedPath of reportedPaths) {
|
||||
if (
|
||||
reportedPath.trim().length === 0 ||
|
||||
reportedPath.length > MAX_REPORTED_PATH_LENGTH ||
|
||||
hasControlCharacter(reportedPath)
|
||||
) {
|
||||
truncated = true;
|
||||
continue;
|
||||
}
|
||||
let normalized: string;
|
||||
try {
|
||||
normalized = canonicalizeReportedPath(path.resolve(workspace.cwd, reportedPath));
|
||||
} catch {
|
||||
truncated = true;
|
||||
continue;
|
||||
}
|
||||
if (normalized.length > MAX_REPORTED_PATH_LENGTH || hasControlCharacter(normalized)) {
|
||||
truncated = true;
|
||||
continue;
|
||||
}
|
||||
if (!roots.some((root) => isWithinRoot(normalized, root))) {
|
||||
truncated = true;
|
||||
continue;
|
||||
}
|
||||
const key = pathKey(normalized);
|
||||
if (roots.some((root) => pathKey(root) === key)) {
|
||||
truncated = true;
|
||||
continue;
|
||||
}
|
||||
if (seen.has(key)) {
|
||||
continue;
|
||||
}
|
||||
const pathBytes = Buffer.byteLength(normalized, "utf8");
|
||||
if (
|
||||
result.length >= MAX_REPORTED_PATHS ||
|
||||
totalBytes + pathBytes > AGENT_FILE_CHANGE_REPORT_MAX_BYTES
|
||||
) {
|
||||
truncated = true;
|
||||
continue;
|
||||
}
|
||||
seen.add(key);
|
||||
result.push(normalized);
|
||||
totalBytes += pathBytes;
|
||||
}
|
||||
return { paths: result, truncated };
|
||||
}
|
||||
|
||||
/** Keep the complete serialized report within AIR's wire limit. */
|
||||
function fitReportedAgentFileChangeReport(
|
||||
report: Extract<AgentFileChangeReportResult, { status: "reported" }>,
|
||||
): Extract<AgentFileChangeReportResult, { status: "reported" }> {
|
||||
const paths = [...report.paths];
|
||||
let fitted = report;
|
||||
while (Buffer.byteLength(JSON.stringify(fitted), "utf8") > AGENT_FILE_CHANGE_REPORT_MAX_BYTES) {
|
||||
if (paths.length === 0) {
|
||||
// The fixed fields and bounded uncertainty are far below the cap. Keep
|
||||
// this loud if the wire contract changes without updating this helper.
|
||||
throw new Error("Agent file-change report exceeds the wire limit without paths");
|
||||
}
|
||||
paths.pop();
|
||||
fitted = {
|
||||
...report,
|
||||
paths: [...paths],
|
||||
declaredComplete: false,
|
||||
truncated: true,
|
||||
};
|
||||
}
|
||||
return fitted;
|
||||
}
|
||||
|
||||
function isWithinRoot(candidate: string, root: string): boolean {
|
||||
const relative = path.relative(root, candidate);
|
||||
return !path.isAbsolute(relative) && !relative.startsWith(`..${path.sep}`) && relative !== "..";
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve filesystem aliases in a workspace root, including macOS' /tmp ->
|
||||
* /private/tmp alias. For a root that does not exist yet, resolve its nearest
|
||||
* existing ancestor and retain the missing suffix.
|
||||
*/
|
||||
function canonicalizeWorkspaceRoot(value: string): string {
|
||||
return canonicalizeFromExistingAncestor(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Canonicalize the parent but not the leaf itself. A changed path may be
|
||||
* deleted, or it may be a symlink whose node (rather than target) changed.
|
||||
*/
|
||||
function canonicalizeReportedPath(value: string): string {
|
||||
const parent = canonicalizeFromExistingAncestor(path.dirname(value));
|
||||
return path.resolve(parent, path.basename(value));
|
||||
}
|
||||
|
||||
function canonicalizeFromExistingAncestor(value: string): string {
|
||||
const original = path.resolve(value);
|
||||
let current = original;
|
||||
const missingSegments: string[] = [];
|
||||
|
||||
while (true) {
|
||||
try {
|
||||
const canonical = fs.realpathSync.native(current);
|
||||
return path.resolve(canonical, ...missingSegments.reverse());
|
||||
} catch (error) {
|
||||
if (!isMissingPathError(error)) return original;
|
||||
}
|
||||
|
||||
const parent = path.dirname(current);
|
||||
if (parent === current) return original;
|
||||
missingSegments.push(path.basename(current));
|
||||
current = parent;
|
||||
}
|
||||
}
|
||||
|
||||
function isMissingPathError(error: unknown): boolean {
|
||||
if (typeof error !== "object" || error === null || !("code" in error)) return false;
|
||||
return error.code === "ENOENT" || error.code === "ENOTDIR";
|
||||
}
|
||||
|
||||
function pathKey(value: string): string {
|
||||
return process.platform === "win32" ? value.toLowerCase() : value;
|
||||
}
|
||||
|
||||
function hasControlCharacter(value: string): boolean {
|
||||
for (let index = 0; index < value.length; index++) {
|
||||
const code = value.charCodeAt(index);
|
||||
if (code <= 0x1f || (code >= 0x7f && code <= 0x9f)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
463
src/session-failure-extension.ts
Normal file
463
src/session-failure-extension.ts
Normal file
@@ -0,0 +1,463 @@
|
||||
import type { ClientCapabilities, SessionNotification } from "@agentclientprotocol/sdk";
|
||||
import {
|
||||
getSessionMessages,
|
||||
type SDKAssistantMessageError,
|
||||
USAGE_LIMIT_ERROR_PREFIXES,
|
||||
} from "@anthropic-ai/claude-agent-sdk";
|
||||
import { randomUUID } from "node:crypto";
|
||||
|
||||
const JETBRAINS_META_KEY = "jetbrains";
|
||||
const AIR_META_KEY = "air";
|
||||
const AIR_EXTENSION_VERSION_KEY = "version";
|
||||
const AIR_EXTENSION_CAPABILITIES_KEY = "capabilities";
|
||||
const AIR_SESSION_FAILURE_KEY = "sessionFailure";
|
||||
const AIR_EXTENSION_VERSION = 1;
|
||||
|
||||
export function airSessionFailureCapabilityMeta(...additionalCapabilities: string[]) {
|
||||
return {
|
||||
[JETBRAINS_META_KEY]: {
|
||||
[AIR_META_KEY]: {
|
||||
[AIR_EXTENSION_VERSION_KEY]: AIR_EXTENSION_VERSION,
|
||||
[AIR_EXTENSION_CAPABILITIES_KEY]: [AIR_SESSION_FAILURE_KEY, ...additionalCapabilities],
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export type ClaudeFailureKind =
|
||||
| "advisory"
|
||||
| "auth_required"
|
||||
| "bad_request"
|
||||
| "budget_exhausted"
|
||||
| "context_exhausted"
|
||||
| "internal_error"
|
||||
| "overloaded"
|
||||
| "provider_error"
|
||||
| "quota_exhausted"
|
||||
| "rate_limited"
|
||||
| "transport_lost"
|
||||
| "worker_shutdown";
|
||||
|
||||
type AirSessionFailureCategory =
|
||||
"connection" | "access" | "limit" | "request" | "service" | "unknown";
|
||||
|
||||
type AirSessionFailureSeverity = "warning" | "error";
|
||||
type AirSessionFailureAction = "retry" | "login" | "new_session";
|
||||
|
||||
type SessionFailureRecoveryPolicy =
|
||||
"never" | "next_attempt" | "real_model_success" | "auth_status" | "runtime_rebind";
|
||||
|
||||
export type PublishedSessionFailure = {
|
||||
id: string;
|
||||
revision: number;
|
||||
kind: ClaudeFailureKind;
|
||||
category: AirSessionFailureCategory;
|
||||
turnId?: string;
|
||||
severity: AirSessionFailureSeverity;
|
||||
title: string;
|
||||
details?: string;
|
||||
actions: AirSessionFailureAction[];
|
||||
recoveryPolicy: SessionFailureRecoveryPolicy;
|
||||
};
|
||||
|
||||
type SessionFailureOptions = {
|
||||
turnId?: string;
|
||||
sessionScoped?: boolean;
|
||||
title?: string;
|
||||
details?: string;
|
||||
severity?: AirSessionFailureSeverity;
|
||||
};
|
||||
|
||||
export type SessionFailureState = {
|
||||
epoch: string;
|
||||
revisions: Map<string, number>;
|
||||
active: Map<string, PublishedSessionFailure>;
|
||||
nextIncident?: number;
|
||||
lastNotice?: { title: string; id: string };
|
||||
};
|
||||
|
||||
type Logger = {
|
||||
error: (...args: unknown[]) => void;
|
||||
};
|
||||
|
||||
export function createSessionFailureState(): SessionFailureState {
|
||||
return {
|
||||
epoch: randomUUID(),
|
||||
revisions: new Map(),
|
||||
active: new Map(),
|
||||
};
|
||||
}
|
||||
|
||||
const AIR_FAILURE_POLICY: Record<
|
||||
ClaudeFailureKind,
|
||||
{
|
||||
category: AirSessionFailureCategory;
|
||||
actions: AirSessionFailureAction[];
|
||||
fallbackTitle: string;
|
||||
}
|
||||
> = {
|
||||
advisory: {
|
||||
category: "unknown",
|
||||
actions: [],
|
||||
fallbackTitle: "Claude reported a notice.",
|
||||
},
|
||||
auth_required: {
|
||||
category: "access",
|
||||
actions: ["login"],
|
||||
fallbackTitle: "Sign in to continue using Claude.",
|
||||
},
|
||||
bad_request: {
|
||||
category: "request",
|
||||
actions: [],
|
||||
fallbackTitle: "Claude could not process this request.",
|
||||
},
|
||||
budget_exhausted: {
|
||||
category: "limit",
|
||||
actions: ["new_session"],
|
||||
fallbackTitle: "This Claude session reached its configured budget.",
|
||||
},
|
||||
context_exhausted: {
|
||||
category: "limit",
|
||||
actions: ["new_session"],
|
||||
fallbackTitle: "This Claude turn reached its configured limit.",
|
||||
},
|
||||
internal_error: {
|
||||
category: "service",
|
||||
actions: ["retry", "new_session"],
|
||||
fallbackTitle: "Claude Agent encountered an internal error.",
|
||||
},
|
||||
overloaded: {
|
||||
category: "service",
|
||||
actions: ["retry"],
|
||||
fallbackTitle: "Claude is temporarily overloaded.",
|
||||
},
|
||||
provider_error: {
|
||||
category: "service",
|
||||
actions: ["retry"],
|
||||
fallbackTitle: "The model provider reported an error.",
|
||||
},
|
||||
quota_exhausted: {
|
||||
category: "limit",
|
||||
actions: [],
|
||||
fallbackTitle: "The Claude account has no available quota.",
|
||||
},
|
||||
rate_limited: {
|
||||
category: "limit",
|
||||
actions: ["retry"],
|
||||
fallbackTitle: "Claude is temporarily rate limited.",
|
||||
},
|
||||
transport_lost: {
|
||||
category: "connection",
|
||||
actions: ["new_session"],
|
||||
fallbackTitle: "The connection to Claude was lost.",
|
||||
},
|
||||
worker_shutdown: {
|
||||
category: "connection",
|
||||
actions: ["new_session"],
|
||||
fallbackTitle: "The Claude worker is shutting down.",
|
||||
},
|
||||
};
|
||||
|
||||
export function sessionFailureMeta(failure: PublishedSessionFailure) {
|
||||
return {
|
||||
[JETBRAINS_META_KEY]: {
|
||||
[AIR_META_KEY]: {
|
||||
[AIR_EXTENSION_VERSION_KEY]: AIR_EXTENSION_VERSION,
|
||||
[AIR_SESSION_FAILURE_KEY]: {
|
||||
id: failure.id,
|
||||
revision: failure.revision,
|
||||
category: failure.category,
|
||||
severity: failure.severity,
|
||||
title: failure.title,
|
||||
...(failure.details ? { details: failure.details } : {}),
|
||||
actions: failure.actions,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** `getSessionMessages` deliberately exposes only the API message and strips
|
||||
* transcript-level `error` / `isApiErrorMessage` fields. The SDK exports the
|
||||
* exact stable prefixes used by its synthetic usage-limit errors, so replay
|
||||
* can recover this one typed failure without matching arbitrary model prose. */
|
||||
export function assistantMessageText(apiMessage: unknown): string | undefined {
|
||||
if (!apiMessage || typeof apiMessage !== "object") return undefined;
|
||||
const { content } = apiMessage as { content?: unknown };
|
||||
if (typeof content === "string") return content || undefined;
|
||||
if (!Array.isArray(content)) return undefined;
|
||||
const text = content
|
||||
.map((block) =>
|
||||
block &&
|
||||
typeof block === "object" &&
|
||||
"type" in block &&
|
||||
block.type === "text" &&
|
||||
"text" in block &&
|
||||
typeof block.text === "string"
|
||||
? block.text
|
||||
: "",
|
||||
)
|
||||
.join("");
|
||||
return text || undefined;
|
||||
}
|
||||
|
||||
export function isSyntheticUsageLimitMessage(apiMessage: unknown): boolean {
|
||||
if (!apiMessage || typeof apiMessage !== "object") return false;
|
||||
const { model } = apiMessage as { model?: unknown };
|
||||
if (model !== "<synthetic>") return false;
|
||||
const text = assistantMessageText(apiMessage);
|
||||
if (!text) return false;
|
||||
return USAGE_LIMIT_ERROR_PREFIXES.some((prefix) => text.startsWith(prefix));
|
||||
}
|
||||
|
||||
export function activeUsageLimitMessage(
|
||||
messages: Awaited<ReturnType<typeof getSessionMessages>>,
|
||||
): { uuid: string; title: string } | undefined {
|
||||
let active: { uuid: string; title: string } | undefined;
|
||||
for (const message of messages) {
|
||||
if (message.type !== "assistant" || message.parent_tool_use_id !== null) continue;
|
||||
if (isSyntheticUsageLimitMessage(message.message)) {
|
||||
const title = assistantMessageText(message.message);
|
||||
if (title) active = { uuid: message.uuid, title };
|
||||
continue;
|
||||
}
|
||||
const model =
|
||||
message.message && typeof message.message === "object" && "model" in message.message
|
||||
? message.message.model
|
||||
: undefined;
|
||||
// A later real model answer proves the account recovered. Synthetic
|
||||
// local-command/interruption messages do not: they can be appended while
|
||||
// the provider remains unavailable.
|
||||
if (model !== undefined && model !== "<synthetic>") active = undefined;
|
||||
}
|
||||
return active;
|
||||
}
|
||||
|
||||
export function supportsAirSessionFailures(capabilities?: ClientCapabilities): boolean {
|
||||
const jetbrains = capabilities?._meta?.[JETBRAINS_META_KEY] as
|
||||
Record<string, unknown> | undefined;
|
||||
const air = jetbrains?.[AIR_META_KEY] as Record<string, unknown> | undefined;
|
||||
const version = air?.[AIR_EXTENSION_VERSION_KEY];
|
||||
const advertised = air?.[AIR_EXTENSION_CAPABILITIES_KEY];
|
||||
return (
|
||||
typeof version === "number" &&
|
||||
Number.isFinite(version) &&
|
||||
Number.isInteger(version) &&
|
||||
version >= AIR_EXTENSION_VERSION &&
|
||||
Array.isArray(advertised) &&
|
||||
advertised.some((capability) => capability === AIR_SESSION_FAILURE_KEY)
|
||||
);
|
||||
}
|
||||
|
||||
function sessionFailureRecoveryPolicy(
|
||||
kind: ClaudeFailureKind,
|
||||
turnId?: string,
|
||||
): SessionFailureRecoveryPolicy {
|
||||
switch (kind) {
|
||||
case "advisory":
|
||||
return "never";
|
||||
case "auth_required":
|
||||
return "auth_status";
|
||||
case "transport_lost":
|
||||
case "worker_shutdown":
|
||||
return "runtime_rebind";
|
||||
case "quota_exhausted":
|
||||
return "real_model_success";
|
||||
default:
|
||||
return turnId ? "next_attempt" : "real_model_success";
|
||||
}
|
||||
}
|
||||
|
||||
/** Owns the AIR failure lifecycle for both the live consumer and history replay.
|
||||
* Published revisions become active only after delivery succeeds; recovery only
|
||||
* removes internal active state because transcript records are historical. */
|
||||
export class SessionFailureController {
|
||||
private readonly sessionId: string;
|
||||
private readonly state: SessionFailureState;
|
||||
private readonly capabilities?: ClientCapabilities;
|
||||
private readonly isCurrent: () => boolean;
|
||||
private readonly sendUpdate: (notification: SessionNotification) => Promise<void>;
|
||||
private readonly logger: Logger;
|
||||
|
||||
constructor(options: {
|
||||
sessionId: string;
|
||||
state: SessionFailureState;
|
||||
capabilities?: ClientCapabilities;
|
||||
isCurrent: () => boolean;
|
||||
sendUpdate: (notification: SessionNotification) => Promise<void>;
|
||||
logger: Logger;
|
||||
}) {
|
||||
this.sessionId = options.sessionId;
|
||||
this.state = options.state;
|
||||
this.capabilities = options.capabilities;
|
||||
this.isCurrent = options.isCurrent;
|
||||
this.sendUpdate = options.sendUpdate;
|
||||
this.logger = options.logger;
|
||||
}
|
||||
|
||||
private isSupported(): boolean {
|
||||
return supportsAirSessionFailures(this.capabilities);
|
||||
}
|
||||
|
||||
private async emit(failure: PublishedSessionFailure): Promise<boolean> {
|
||||
if (!this.isCurrent()) {
|
||||
this.logger.error(
|
||||
`Session ${this.sessionId}: ignored AIR session failure from a stale consumer`,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
await this.sendUpdate({
|
||||
sessionId: this.sessionId,
|
||||
update: {
|
||||
sessionUpdate: "session_info_update",
|
||||
_meta: sessionFailureMeta(failure),
|
||||
},
|
||||
});
|
||||
return true;
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
`Session ${this.sessionId}: failed to publish AIR session failure: ${error}`,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
recordActive(failure: PublishedSessionFailure): void {
|
||||
this.state.revisions.set(failure.id, failure.revision);
|
||||
this.state.active.set(failure.id, failure);
|
||||
this.state.lastNotice =
|
||||
failure.kind === "advisory" ? { title: failure.title, id: failure.id } : undefined;
|
||||
}
|
||||
|
||||
async clear(
|
||||
shouldClear: (failure: PublishedSessionFailure) => boolean = () => true,
|
||||
): Promise<boolean> {
|
||||
if (!this.isSupported()) return true;
|
||||
this.state.lastNotice = undefined;
|
||||
for (const failure of [...this.state.active.values()]) {
|
||||
if (!shouldClear(failure)) continue;
|
||||
this.state.active.delete(failure.id);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
async prepare(
|
||||
kind: ClaudeFailureKind,
|
||||
failureOptions: SessionFailureOptions = {},
|
||||
): Promise<PublishedSessionFailure | undefined> {
|
||||
if (!this.isSupported() || !this.isCurrent()) return undefined;
|
||||
const policy = AIR_FAILURE_POLICY[kind];
|
||||
const title = failureOptions.title || policy.fallbackTitle;
|
||||
if (kind === "advisory") {
|
||||
const id =
|
||||
this.state.lastNotice?.title === title
|
||||
? this.state.lastNotice.id
|
||||
: `${this.sessionId}:notice:${this.state.epoch}:${this.state.nextIncident ?? 1}`;
|
||||
if (this.state.lastNotice?.title !== title) {
|
||||
this.state.nextIncident = (this.state.nextIncident ?? 1) + 1;
|
||||
}
|
||||
return {
|
||||
id,
|
||||
revision: (this.state.revisions.get(id) ?? 0) + 1,
|
||||
kind,
|
||||
category: policy.category,
|
||||
severity: "warning",
|
||||
title,
|
||||
...(failureOptions.details ? { details: failureOptions.details } : {}),
|
||||
actions: policy.actions,
|
||||
recoveryPolicy: sessionFailureRecoveryPolicy(kind),
|
||||
};
|
||||
}
|
||||
const id =
|
||||
failureOptions.turnId && !failureOptions.sessionScoped
|
||||
? `${failureOptions.turnId}:error`
|
||||
: `${this.sessionId}:session-error:${this.state.epoch}:${this.state.nextIncident ?? 1}`;
|
||||
if (!failureOptions.turnId || failureOptions.sessionScoped) {
|
||||
this.state.nextIncident = (this.state.nextIncident ?? 1) + 1;
|
||||
}
|
||||
return {
|
||||
id,
|
||||
revision: (this.state.revisions.get(id) ?? 0) + 1,
|
||||
kind,
|
||||
category: policy.category,
|
||||
severity: failureOptions.severity ?? "error",
|
||||
title,
|
||||
...(failureOptions.details ? { details: failureOptions.details } : {}),
|
||||
actions: failureOptions.severity === "warning" ? [] : policy.actions,
|
||||
recoveryPolicy: sessionFailureRecoveryPolicy(
|
||||
kind,
|
||||
failureOptions.turnId && !failureOptions.sessionScoped ? failureOptions.turnId : undefined,
|
||||
),
|
||||
...(failureOptions.turnId && !failureOptions.sessionScoped
|
||||
? { turnId: failureOptions.turnId }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
async publish(
|
||||
kind: ClaudeFailureKind,
|
||||
failureOptions: SessionFailureOptions = {},
|
||||
): Promise<void> {
|
||||
const failure = await this.prepare(kind, failureOptions);
|
||||
if (!failure) return;
|
||||
if (await this.emit(failure)) this.recordActive(failure);
|
||||
}
|
||||
|
||||
async restore(
|
||||
id: string,
|
||||
kind: ClaudeFailureKind,
|
||||
title: string,
|
||||
active = true,
|
||||
): Promise<boolean> {
|
||||
if (!this.isSupported() || !this.isCurrent()) return false;
|
||||
const policy = AIR_FAILURE_POLICY[kind];
|
||||
const failure: PublishedSessionFailure = {
|
||||
id,
|
||||
revision: (this.state.revisions.get(id) ?? 0) + 1,
|
||||
kind,
|
||||
category: policy.category,
|
||||
severity: "error",
|
||||
title,
|
||||
actions: policy.actions,
|
||||
recoveryPolicy: sessionFailureRecoveryPolicy(kind),
|
||||
};
|
||||
if (!(await this.emit(failure))) return false;
|
||||
this.state.revisions.set(failure.id, failure.revision);
|
||||
if (active) {
|
||||
this.state.active.set(failure.id, failure);
|
||||
} else {
|
||||
this.state.active.delete(failure.id);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
export function providerFailureCategory(
|
||||
errorKind?: SDKAssistantMessageError,
|
||||
isUsageLimit = false,
|
||||
): ClaudeFailureKind {
|
||||
if (isUsageLimit) return "quota_exhausted";
|
||||
switch (errorKind) {
|
||||
case "authentication_failed":
|
||||
case "oauth_org_not_allowed":
|
||||
return "auth_required";
|
||||
case "billing_error":
|
||||
return "quota_exhausted";
|
||||
case "rate_limit":
|
||||
return "rate_limited";
|
||||
case "overloaded":
|
||||
return "overloaded";
|
||||
case "invalid_request":
|
||||
case "model_not_found":
|
||||
return "bad_request";
|
||||
case "max_output_tokens":
|
||||
return "context_exhausted";
|
||||
case "server_error":
|
||||
case "unknown":
|
||||
case undefined:
|
||||
return "provider_error";
|
||||
default:
|
||||
return "provider_error";
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -795,7 +795,7 @@ describe("createSession options merging", () => {
|
||||
return { onUserDialog: capturedOptions!.onUserDialog!, createElicitation };
|
||||
}
|
||||
|
||||
const signal = () => ({ signal: new AbortController().signal });
|
||||
const signal = () => ({ signal: new AbortController().signal, requestId: "1" });
|
||||
|
||||
it("renders the prompt as a form elicitation and maps the retry choice", async () => {
|
||||
const { onUserDialog, createElicitation } = await setupDialog();
|
||||
|
||||
604
src/tests/file-change-audit-integration.test.ts
Normal file
604
src/tests/file-change-audit-integration.test.ts
Normal file
@@ -0,0 +1,604 @@
|
||||
import type {
|
||||
HookCallback,
|
||||
Options,
|
||||
PermissionResult,
|
||||
SDKUserMessage,
|
||||
} from "@anthropic-ai/claude-agent-sdk";
|
||||
import type { SessionNotification } from "@agentclientprotocol/sdk";
|
||||
import * as path from "node:path";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { AcpClient, ClaudeAcpAgent as ClaudeAcpAgentType } from "../acp-agent.js";
|
||||
import {
|
||||
AGENT_FILE_CHANGE_REPORT_CAPABILITY,
|
||||
FILE_CHANGE_AUDIT_SERVER_NAME,
|
||||
FILE_CHANGE_AUDIT_TOOL_NAME,
|
||||
FILE_CHANGE_AUDIT_WIRE_TOOL_NAME,
|
||||
type AgentFileChangeReport,
|
||||
type AgentFileChangeReportResult,
|
||||
} from "../file-change-audit.js";
|
||||
import type { Pushable } from "../utils.js";
|
||||
|
||||
let observedStopOutputs: unknown[] = [];
|
||||
let observedPreToolDecision: unknown;
|
||||
let observedCanUseToolDecision: PermissionResult | null | undefined;
|
||||
let replayMessages: unknown[] = [];
|
||||
type AuditScenario =
|
||||
"reported" | "successWithoutReport" | "providerError" | "localOnly" | "waitForCancel";
|
||||
let auditScenario: AuditScenario = "reported";
|
||||
let auditTurnActivated = Promise.resolve();
|
||||
let resolveAuditTurnActivated = () => {};
|
||||
|
||||
vi.mock("@anthropic-ai/claude-agent-sdk", async () => {
|
||||
const actual = await vi.importActual<typeof import("@anthropic-ai/claude-agent-sdk")>(
|
||||
"@anthropic-ai/claude-agent-sdk",
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
getSessionMessages: vi.fn(async () => replayMessages),
|
||||
query: ({ prompt, options }: { prompt: Pushable<SDKUserMessage>; options: Options }) => {
|
||||
let resolveInterrupt = () => {};
|
||||
const interrupted = new Promise<void>((resolve) => {
|
||||
resolveInterrupt = resolve;
|
||||
});
|
||||
return Object.assign(runAuditedTurn(prompt, options, interrupted), {
|
||||
initializationResult: async () => ({
|
||||
models: [
|
||||
{
|
||||
value: "claude-sonnet-4-6",
|
||||
displayName: "Claude Sonnet",
|
||||
description: "Fast",
|
||||
supportsAutoMode: true,
|
||||
},
|
||||
],
|
||||
}),
|
||||
setModel: vi.fn(async () => {}),
|
||||
setPermissionMode: vi.fn(async () => {}),
|
||||
supportedAgents: vi.fn(async () => []),
|
||||
supportedCommands: vi.fn(async () => []),
|
||||
getContextUsage: vi.fn(async () => ({ totalTokens: 0, rawMaxTokens: 200000 })),
|
||||
interrupt: vi.fn(async () => {
|
||||
resolveInterrupt();
|
||||
return undefined;
|
||||
}),
|
||||
close: vi.fn(),
|
||||
});
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../tools.js", async () => ({
|
||||
...(await vi.importActual<typeof import("../tools.js")>("../tools.js")),
|
||||
registerHookCallback: vi.fn(),
|
||||
}));
|
||||
|
||||
type RegisteredAuditTool = {
|
||||
handler: (
|
||||
report: AgentFileChangeReport,
|
||||
) => Promise<{ content: unknown; structuredContent?: AgentFileChangeReportResult }>;
|
||||
};
|
||||
|
||||
function hookFrom(options: Options, event: "PreToolUse" | "Stop"): HookCallback {
|
||||
const matcher = options.hooks?.[event]?.at(-1);
|
||||
const hook = matcher?.hooks.at(-1);
|
||||
if (!hook) throw new Error(`Missing ${event} audit hook`);
|
||||
return hook;
|
||||
}
|
||||
|
||||
function auditToolFrom(options: Options): RegisteredAuditTool {
|
||||
const server = options.mcpServers?.[FILE_CHANGE_AUDIT_SERVER_NAME] as unknown as {
|
||||
instance: { _registeredTools: Record<string, RegisteredAuditTool> };
|
||||
};
|
||||
const tool = server.instance._registeredTools[FILE_CHANGE_AUDIT_TOOL_NAME];
|
||||
if (!tool) throw new Error("Missing internal audit tool");
|
||||
return tool;
|
||||
}
|
||||
|
||||
function assistantMessage(content: unknown[]) {
|
||||
return {
|
||||
type: "assistant" as const,
|
||||
parent_tool_use_id: null,
|
||||
uuid: randomUUID(),
|
||||
session_id: "sdk-session",
|
||||
message: {
|
||||
role: "assistant" as const,
|
||||
model: "claude-sonnet-4-6",
|
||||
stop_reason: "end_turn",
|
||||
usage: {
|
||||
input_tokens: 1,
|
||||
output_tokens: 1,
|
||||
cache_read_input_tokens: 0,
|
||||
cache_creation_input_tokens: 0,
|
||||
},
|
||||
content,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function successResult() {
|
||||
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: 2,
|
||||
total_cost_usd: 0,
|
||||
usage: {
|
||||
input_tokens: 2,
|
||||
output_tokens: 2,
|
||||
cache_read_input_tokens: 0,
|
||||
cache_creation_input_tokens: 0,
|
||||
},
|
||||
modelUsage: {},
|
||||
permission_denials: [],
|
||||
uuid: randomUUID(),
|
||||
session_id: "sdk-session",
|
||||
};
|
||||
}
|
||||
|
||||
async function* runAuditedTurn(
|
||||
input: Pushable<SDKUserMessage>,
|
||||
options: Options,
|
||||
interrupted: Promise<void>,
|
||||
) {
|
||||
const iterator = input[Symbol.asyncIterator]();
|
||||
const { value: userMessage } = await iterator.next();
|
||||
if (!userMessage) return;
|
||||
|
||||
if (auditScenario === "localOnly") {
|
||||
yield { ...successResult(), result: "Local command output" };
|
||||
return;
|
||||
}
|
||||
|
||||
yield {
|
||||
type: "user" as const,
|
||||
message: userMessage.message,
|
||||
parent_tool_use_id: null,
|
||||
uuid: userMessage.uuid,
|
||||
session_id: "sdk-session",
|
||||
isReplay: true,
|
||||
};
|
||||
resolveAuditTurnActivated();
|
||||
|
||||
if (auditScenario === "successWithoutReport") {
|
||||
yield assistantMessage([{ type: "text", text: "Visible answer" }]);
|
||||
yield successResult();
|
||||
return;
|
||||
}
|
||||
if (auditScenario === "providerError") {
|
||||
yield {
|
||||
...successResult(),
|
||||
is_error: true,
|
||||
result: "Provider failed",
|
||||
errors: ["Provider failed"],
|
||||
};
|
||||
return;
|
||||
}
|
||||
if (auditScenario === "waitForCancel") {
|
||||
await interrupted;
|
||||
yield {
|
||||
type: "system" as const,
|
||||
subtype: "session_state_changed" as const,
|
||||
state: "idle" as const,
|
||||
uuid: randomUUID(),
|
||||
session_id: "sdk-session",
|
||||
};
|
||||
return;
|
||||
}
|
||||
|
||||
yield assistantMessage([{ type: "text", text: "Visible answer" }]);
|
||||
|
||||
const hookOptions = { signal: new AbortController().signal };
|
||||
const stopHook = hookFrom(options, "Stop");
|
||||
observedStopOutputs.push(
|
||||
await stopHook(
|
||||
{ hook_event_name: "Stop", stop_hook_active: false } as Parameters<HookCallback>[0],
|
||||
undefined,
|
||||
hookOptions,
|
||||
),
|
||||
);
|
||||
|
||||
const preToolUseHook = hookFrom(options, "PreToolUse");
|
||||
observedPreToolDecision = await preToolUseHook(
|
||||
{
|
||||
hook_event_name: "PreToolUse",
|
||||
tool_name: "Bash",
|
||||
tool_input: { command: "git status" },
|
||||
tool_use_id: "blocked-tool",
|
||||
} as Parameters<HookCallback>[0],
|
||||
"blocked-tool",
|
||||
hookOptions,
|
||||
);
|
||||
observedCanUseToolDecision = await options.canUseTool?.(
|
||||
"Bash",
|
||||
{ command: "git status" },
|
||||
{
|
||||
signal: hookOptions.signal,
|
||||
toolUseID: "blocked-tool",
|
||||
requestId: "permission-request",
|
||||
},
|
||||
);
|
||||
|
||||
const toolUseId = "audit-tool-use";
|
||||
yield assistantMessage([
|
||||
{ type: "text", text: "Audit prose must stay hidden" },
|
||||
{
|
||||
type: "tool_use",
|
||||
id: toolUseId,
|
||||
name: FILE_CHANGE_AUDIT_WIRE_TOOL_NAME,
|
||||
input: { paths: ["src/changed.ts"], complete: true },
|
||||
},
|
||||
]);
|
||||
|
||||
const toolResult = await auditToolFrom(options).handler({
|
||||
paths: ["src/changed.ts"],
|
||||
complete: true,
|
||||
});
|
||||
yield {
|
||||
type: "user" as const,
|
||||
message: {
|
||||
role: "user" as const,
|
||||
content: [
|
||||
{
|
||||
type: "tool_result",
|
||||
tool_use_id: toolUseId,
|
||||
content: toolResult.content,
|
||||
},
|
||||
],
|
||||
},
|
||||
parent_tool_use_id: null,
|
||||
uuid: randomUUID(),
|
||||
session_id: "sdk-session",
|
||||
};
|
||||
|
||||
observedStopOutputs.push(
|
||||
await stopHook(
|
||||
{ hook_event_name: "Stop", stop_hook_active: true } as Parameters<HookCallback>[0],
|
||||
undefined,
|
||||
hookOptions,
|
||||
),
|
||||
);
|
||||
yield successResult();
|
||||
}
|
||||
|
||||
describe("agent file-change audit integration", () => {
|
||||
beforeEach(() => {
|
||||
observedStopOutputs = [];
|
||||
observedPreToolDecision = undefined;
|
||||
observedCanUseToolDecision = undefined;
|
||||
replayMessages = [];
|
||||
auditScenario = "reported";
|
||||
auditTurnActivated = new Promise<void>((resolve) => {
|
||||
resolveAuditTurnActivated = resolve;
|
||||
});
|
||||
});
|
||||
|
||||
const negotiatedCapabilities = {
|
||||
_meta: {
|
||||
jetbrains: {
|
||||
air: {
|
||||
version: 1,
|
||||
capabilities: [AGENT_FILE_CHANGE_REPORT_CAPABILITY, "sessionFailure"],
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
async function createAuditAgent(updates: SessionNotification[]) {
|
||||
const client = {
|
||||
sessionUpdate: async (notification: SessionNotification) => void updates.push(notification),
|
||||
requestPermission: async () => ({ outcome: { outcome: "cancelled" } }),
|
||||
readTextFile: async () => ({ content: "" }),
|
||||
writeTextFile: async () => ({}),
|
||||
} as unknown as AcpClient;
|
||||
const { ClaudeAcpAgent } = await import("../acp-agent.js");
|
||||
const agent: ClaudeAcpAgentType = new ClaudeAcpAgent(client, {
|
||||
log: () => {},
|
||||
error: () => {},
|
||||
});
|
||||
await agent.initialize({
|
||||
protocolVersion: 1,
|
||||
clientCapabilities: negotiatedCapabilities,
|
||||
});
|
||||
const { sessionId } = await agent.newSession({ cwd: process.cwd(), mcpServers: [] });
|
||||
return { agent, sessionId };
|
||||
}
|
||||
|
||||
function fileChangeTerminals(updates: SessionNotification[]): AgentFileChangeReportResult[] {
|
||||
return updates.flatMap((notification) => {
|
||||
if (notification.update.sessionUpdate !== "session_info_update") return [];
|
||||
const meta = notification.update._meta as
|
||||
| {
|
||||
jetbrains?: {
|
||||
air?: { agentFileChangeReport?: AgentFileChangeReportResult };
|
||||
};
|
||||
}
|
||||
| undefined;
|
||||
const report = meta?.jetbrains?.air?.agentFileChangeReport;
|
||||
return report ? [report] : [];
|
||||
});
|
||||
}
|
||||
|
||||
function auditedPrompt(sessionId: string, requestId: string, text: string) {
|
||||
return {
|
||||
sessionId,
|
||||
prompt: [{ type: "text" as const, text }],
|
||||
_meta: {
|
||||
jetbrains: {
|
||||
air: {
|
||||
agentFileChangeReportRequest: { version: 1, requestId },
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
it.each([
|
||||
{
|
||||
name: "normal success without a Stop report",
|
||||
scenario: "successWithoutReport" as const,
|
||||
prompt: "Finish normally",
|
||||
reason: "notReported" as const,
|
||||
rejects: false,
|
||||
},
|
||||
{
|
||||
name: "a local-only command",
|
||||
scenario: "localOnly" as const,
|
||||
prompt: "/context",
|
||||
reason: "notReported" as const,
|
||||
rejects: false,
|
||||
},
|
||||
{
|
||||
name: "a provider error",
|
||||
scenario: "providerError" as const,
|
||||
prompt: "Call the provider",
|
||||
reason: "providerError" as const,
|
||||
rejects: false,
|
||||
},
|
||||
])("publishes one terminal for $name", async ({ scenario, prompt, reason, rejects }) => {
|
||||
auditScenario = scenario;
|
||||
const updates: SessionNotification[] = [];
|
||||
const { agent, sessionId } = await createAuditAgent(updates);
|
||||
const requestId = `request-${scenario}`;
|
||||
const result = agent.prompt(auditedPrompt(sessionId, requestId, prompt));
|
||||
|
||||
if (rejects) {
|
||||
await expect(result).rejects.toBeDefined();
|
||||
} else {
|
||||
await expect(result).resolves.toBeDefined();
|
||||
}
|
||||
expect(fileChangeTerminals(updates)).toEqual([
|
||||
{
|
||||
version: 1,
|
||||
requestId,
|
||||
status: "unavailable",
|
||||
reason,
|
||||
},
|
||||
]);
|
||||
|
||||
await agent.dispose();
|
||||
expect(fileChangeTerminals(updates)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ name: "cancel", close: false },
|
||||
{ name: "session close", close: true },
|
||||
])("publishes one cancelled terminal and settles the prompt on $name", async ({ close }) => {
|
||||
auditScenario = "waitForCancel";
|
||||
const updates: SessionNotification[] = [];
|
||||
const { agent, sessionId } = await createAuditAgent(updates);
|
||||
const requestId = close ? "request-close" : "request-cancel";
|
||||
const result = agent.prompt(auditedPrompt(sessionId, requestId, "Wait"));
|
||||
await auditTurnActivated;
|
||||
|
||||
if (close) {
|
||||
await agent.closeSession({ sessionId });
|
||||
} else {
|
||||
await agent.cancel({ sessionId });
|
||||
}
|
||||
await expect(result).resolves.toMatchObject({ stopReason: "cancelled" });
|
||||
expect(fileChangeTerminals(updates)).toEqual([
|
||||
{
|
||||
version: 1,
|
||||
requestId,
|
||||
status: "unavailable",
|
||||
reason: "cancelled",
|
||||
},
|
||||
]);
|
||||
|
||||
await agent.dispose();
|
||||
expect(fileChangeTerminals(updates)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("negotiates an opt-in turn, publishes one report, and hides the audit continuation", async () => {
|
||||
const updates: SessionNotification[] = [];
|
||||
const client = {
|
||||
sessionUpdate: async (notification: SessionNotification) => void updates.push(notification),
|
||||
requestPermission: async () => ({ outcome: { outcome: "cancelled" } }),
|
||||
readTextFile: async () => ({ content: "" }),
|
||||
writeTextFile: async () => ({}),
|
||||
} as unknown as AcpClient;
|
||||
const { ClaudeAcpAgent } = await import("../acp-agent.js");
|
||||
const agent: ClaudeAcpAgentType = new ClaudeAcpAgent(client, {
|
||||
log: () => {},
|
||||
error: () => {},
|
||||
});
|
||||
|
||||
const capabilities = {
|
||||
_meta: {
|
||||
jetbrains: {
|
||||
air: {
|
||||
version: 1,
|
||||
capabilities: [AGENT_FILE_CHANGE_REPORT_CAPABILITY],
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
const initialize = await agent.initialize({
|
||||
protocolVersion: 1,
|
||||
clientCapabilities: capabilities,
|
||||
});
|
||||
expect(initialize._meta).toMatchObject({
|
||||
jetbrains: {
|
||||
air: {
|
||||
version: 1,
|
||||
capabilities: expect.arrayContaining([AGENT_FILE_CHANGE_REPORT_CAPABILITY]),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const cwd = process.cwd();
|
||||
const { sessionId } = await agent.newSession({ cwd, mcpServers: [] });
|
||||
const response = await agent.prompt({
|
||||
sessionId,
|
||||
prompt: [{ type: "text", text: "Change the file" }],
|
||||
_meta: {
|
||||
jetbrains: {
|
||||
air: {
|
||||
agentFileChangeReportRequest: { version: 1, requestId: "request-integration" },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.stopReason).toBe("end_turn");
|
||||
expect(observedStopOutputs).toHaveLength(2);
|
||||
expect(observedStopOutputs[0]).toMatchObject({
|
||||
hookSpecificOutput: {
|
||||
hookEventName: "Stop",
|
||||
additionalContext: expect.stringContaining(FILE_CHANGE_AUDIT_WIRE_TOOL_NAME),
|
||||
},
|
||||
});
|
||||
expect(observedStopOutputs[1]).toEqual({});
|
||||
expect(observedPreToolDecision).toMatchObject({
|
||||
hookSpecificOutput: { permissionDecision: "deny" },
|
||||
});
|
||||
expect(observedCanUseToolDecision).toMatchObject({ behavior: "deny" });
|
||||
|
||||
const reportUpdates = updates.filter((notification) => {
|
||||
if (notification.update.sessionUpdate !== "session_info_update") return false;
|
||||
const meta = notification.update._meta as
|
||||
{ jetbrains?: { air?: { agentFileChangeReport?: unknown } } } | undefined;
|
||||
return meta?.jetbrains?.air?.agentFileChangeReport !== undefined;
|
||||
});
|
||||
expect(reportUpdates).toEqual([
|
||||
{
|
||||
sessionId,
|
||||
update: {
|
||||
sessionUpdate: "session_info_update",
|
||||
_meta: {
|
||||
jetbrains: {
|
||||
air: {
|
||||
version: 1,
|
||||
agentFileChangeReport: {
|
||||
version: 1,
|
||||
requestId: "request-integration",
|
||||
status: "reported",
|
||||
paths: [path.join(cwd, "src/changed.ts")],
|
||||
declaredComplete: true,
|
||||
truncated: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
const serializedUpdates = JSON.stringify(updates);
|
||||
expect(serializedUpdates).toContain("Visible answer");
|
||||
expect(serializedUpdates).not.toContain("Audit prose must stay hidden");
|
||||
expect(serializedUpdates).not.toContain(FILE_CHANGE_AUDIT_WIRE_TOOL_NAME);
|
||||
|
||||
await agent.dispose();
|
||||
|
||||
const firstStop = observedStopOutputs[0] as {
|
||||
hookSpecificOutput?: { additionalContext?: string };
|
||||
};
|
||||
const auditContext = firstStop.hookSpecificOutput?.additionalContext;
|
||||
if (!auditContext) throw new Error("Missing audit continuation context");
|
||||
const replayToolUseId = "replay-audit-tool";
|
||||
const deniedReplayToolUseId = "replay-denied-tool";
|
||||
const replayUserMessage = (content: unknown) => ({
|
||||
type: "user" as const,
|
||||
message: { role: "user" as const, content },
|
||||
parent_tool_use_id: null,
|
||||
uuid: randomUUID(),
|
||||
session_id: "replay-session",
|
||||
});
|
||||
replayMessages = [
|
||||
replayUserMessage("Original prompt"),
|
||||
assistantMessage([{ type: "text", text: "Visible persisted answer" }]),
|
||||
replayUserMessage(auditContext),
|
||||
assistantMessage([{ type: "text", text: "Separate audit prose must stay hidden" }]),
|
||||
assistantMessage([
|
||||
{
|
||||
type: "tool_use",
|
||||
id: deniedReplayToolUseId,
|
||||
name: "Bash",
|
||||
input: { command: "git status" },
|
||||
},
|
||||
]),
|
||||
replayUserMessage([
|
||||
{
|
||||
type: "tool_result",
|
||||
tool_use_id: deniedReplayToolUseId,
|
||||
content: "denied",
|
||||
is_error: true,
|
||||
},
|
||||
]),
|
||||
assistantMessage([{ type: "text", text: "Prose after denial must stay hidden" }]),
|
||||
assistantMessage([
|
||||
{
|
||||
type: "tool_use",
|
||||
id: replayToolUseId,
|
||||
name: FILE_CHANGE_AUDIT_WIRE_TOOL_NAME,
|
||||
input: { paths: ["src/changed.ts"], complete: true },
|
||||
},
|
||||
]),
|
||||
replayUserMessage([
|
||||
{
|
||||
type: "tool_result",
|
||||
tool_use_id: replayToolUseId,
|
||||
content: "recorded",
|
||||
},
|
||||
]),
|
||||
replayUserMessage("Follow-up prompt"),
|
||||
assistantMessage([{ type: "text", text: "Follow-up answer" }]),
|
||||
];
|
||||
|
||||
const replayUpdates: SessionNotification[] = [];
|
||||
const replayAgent = new ClaudeAcpAgent(
|
||||
{
|
||||
...client,
|
||||
sessionUpdate: async (notification: SessionNotification) =>
|
||||
void replayUpdates.push(notification),
|
||||
} as AcpClient,
|
||||
{ log: () => {}, error: () => {} },
|
||||
);
|
||||
await replayAgent.initialize({
|
||||
protocolVersion: 1,
|
||||
clientCapabilities: capabilities,
|
||||
});
|
||||
await replayAgent.loadSession({
|
||||
sessionId: "replay-session",
|
||||
cwd,
|
||||
mcpServers: [],
|
||||
});
|
||||
|
||||
const serializedReplay = JSON.stringify(replayUpdates);
|
||||
expect(serializedReplay).toContain("Original prompt");
|
||||
expect(serializedReplay).toContain("Visible persisted answer");
|
||||
expect(serializedReplay).toContain("Follow-up prompt");
|
||||
expect(serializedReplay).toContain("Follow-up answer");
|
||||
expect(serializedReplay).not.toContain("Separate audit prose must stay hidden");
|
||||
expect(serializedReplay).not.toContain("Prose after denial must stay hidden");
|
||||
expect(serializedReplay).not.toContain(FILE_CHANGE_AUDIT_WIRE_TOOL_NAME);
|
||||
expect(serializedReplay).not.toContain("claude-agent-acp-file-change-audit");
|
||||
await replayAgent.dispose();
|
||||
});
|
||||
});
|
||||
367
src/tests/file-change-audit.test.ts
Normal file
367
src/tests/file-change-audit.test.ts
Normal file
@@ -0,0 +1,367 @@
|
||||
import type { HookCallback } from "@anthropic-ai/claude-agent-sdk";
|
||||
import * as fs from "node:fs";
|
||||
import * as os from "node:os";
|
||||
import * as path from "node:path";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
AGENT_FILE_CHANGE_REPORT_MAX_BYTES,
|
||||
agentFileChangeReportRequestId,
|
||||
createFileChangeAuditSupport,
|
||||
createFileChangeAuditTurnState,
|
||||
FILE_CHANGE_AUDIT_TOOL_NAME,
|
||||
FILE_CHANGE_AUDIT_WIRE_TOOL_NAME,
|
||||
type AgentFileChangeReport,
|
||||
type AgentFileChangeReportResult,
|
||||
type FileChangeAuditTurnState,
|
||||
} from "../file-change-audit.js";
|
||||
|
||||
type RegisteredAuditTool = {
|
||||
_meta?: Record<string, unknown>;
|
||||
handler: (report: AgentFileChangeReport) => Promise<{
|
||||
isError?: boolean;
|
||||
structuredContent?: AgentFileChangeReportResult;
|
||||
}>;
|
||||
};
|
||||
|
||||
function auditToolHandler(
|
||||
support: ReturnType<typeof createFileChangeAuditSupport>,
|
||||
): RegisteredAuditTool {
|
||||
const server = support.mcpServer as unknown as {
|
||||
instance: { _registeredTools: Record<string, RegisteredAuditTool> };
|
||||
};
|
||||
return server.instance._registeredTools[FILE_CHANGE_AUDIT_TOOL_NAME];
|
||||
}
|
||||
|
||||
function stopInput(stopHookActive = false): Parameters<HookCallback>[0] {
|
||||
return {
|
||||
hook_event_name: "Stop",
|
||||
stop_hook_active: stopHookActive,
|
||||
} as Parameters<HookCallback>[0];
|
||||
}
|
||||
|
||||
function createSupport(options: {
|
||||
state: FileChangeAuditTurnState;
|
||||
cwd?: string;
|
||||
additionalDirectories?: string[];
|
||||
publish?: (result: AgentFileChangeReportResult) => Promise<void>;
|
||||
logError?: (message: string) => void;
|
||||
}) {
|
||||
return createFileChangeAuditSupport({
|
||||
cwd: options.cwd ?? process.cwd(),
|
||||
additionalDirectories: options.additionalDirectories ?? [],
|
||||
getActiveState: () => options.state,
|
||||
publish: options.publish ?? (async () => {}),
|
||||
logError: options.logError ?? (() => {}),
|
||||
});
|
||||
}
|
||||
|
||||
describe("agent file-change audit", () => {
|
||||
it("accepts only the versioned prompt request shape", () => {
|
||||
expect(
|
||||
agentFileChangeReportRequestId({
|
||||
jetbrains: {
|
||||
air: {
|
||||
agentFileChangeReportRequest: { version: 1, requestId: "turn:42_a-b.c" },
|
||||
},
|
||||
},
|
||||
}),
|
||||
).toBe("turn:42_a-b.c");
|
||||
|
||||
for (const request of [
|
||||
{ version: 2, requestId: "turn-42" },
|
||||
{ version: 1, requestId: "contains spaces" },
|
||||
{ version: 1, requestId: "x".repeat(129) },
|
||||
{ version: 1 },
|
||||
{ version: 1, requestId: "turn-42", extra: true },
|
||||
null,
|
||||
]) {
|
||||
expect(
|
||||
agentFileChangeReportRequestId({
|
||||
jetbrains: { air: { agentFileChangeReportRequest: request } },
|
||||
}),
|
||||
).toBeUndefined();
|
||||
}
|
||||
});
|
||||
|
||||
it("continues Stop at most once and reports a missing tool call once", async () => {
|
||||
const state = createFileChangeAuditTurnState("request-1");
|
||||
const publish = vi.fn(async (_result: AgentFileChangeReportResult) => {});
|
||||
const support = createSupport({ state, publish });
|
||||
const hookOptions = { signal: new AbortController().signal };
|
||||
const preToolInput = (toolName: string) =>
|
||||
({
|
||||
hook_event_name: "PreToolUse",
|
||||
tool_name: toolName,
|
||||
tool_input: {},
|
||||
tool_use_id: "tool-1",
|
||||
}) as Parameters<HookCallback>[0];
|
||||
|
||||
await expect(
|
||||
support.preToolUseHook(preToolInput(FILE_CHANGE_AUDIT_WIRE_TOOL_NAME), "tool-1", hookOptions),
|
||||
).resolves.toMatchObject({
|
||||
hookSpecificOutput: { permissionDecision: "deny" },
|
||||
});
|
||||
|
||||
const pausedStop = {
|
||||
...stopInput(),
|
||||
background_tasks: [{ id: "generator", type: "shell", status: "running", description: "" }],
|
||||
} as Parameters<HookCallback>[0];
|
||||
expect(await support.stopHook(pausedStop, undefined, hookOptions)).toEqual({});
|
||||
expect(state.phase).toBe("requested");
|
||||
|
||||
const first = await support.stopHook(stopInput(), undefined, hookOptions);
|
||||
expect(state.phase).toBe("collecting");
|
||||
expect(first).toMatchObject({
|
||||
hookSpecificOutput: {
|
||||
hookEventName: "Stop",
|
||||
additionalContext: expect.stringContaining(FILE_CHANGE_AUDIT_WIRE_TOOL_NAME),
|
||||
},
|
||||
});
|
||||
|
||||
await expect(
|
||||
support.preToolUseHook(preToolInput("Bash"), "tool-1", hookOptions),
|
||||
).resolves.toMatchObject({
|
||||
hookSpecificOutput: { permissionDecision: "deny" },
|
||||
});
|
||||
await expect(
|
||||
support.preToolUseHook(preToolInput(FILE_CHANGE_AUDIT_WIRE_TOOL_NAME), "tool-1", hookOptions),
|
||||
).resolves.toMatchObject({
|
||||
hookSpecificOutput: { permissionDecision: "allow" },
|
||||
});
|
||||
|
||||
expect(await support.stopHook(stopInput(true), undefined, hookOptions)).toEqual({});
|
||||
expect(state.phase).toBe("finished");
|
||||
expect(publish).toHaveBeenCalledWith({
|
||||
version: 1,
|
||||
requestId: "request-1",
|
||||
status: "unavailable",
|
||||
reason: "notReported",
|
||||
});
|
||||
|
||||
await support.stopHook(stopInput(true), undefined, hookOptions);
|
||||
expect(publish).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("publishes only the first unavailable terminal and rejects a late report", async () => {
|
||||
const state = createFileChangeAuditTurnState("request-terminal-race");
|
||||
state.phase = "collecting";
|
||||
const publish = vi.fn(async (_result: AgentFileChangeReportResult) => {});
|
||||
const support = createSupport({ state, publish });
|
||||
|
||||
await Promise.all([
|
||||
support.finishUnavailable(state, "cancelled"),
|
||||
support.finishUnavailable(state, "providerError"),
|
||||
]);
|
||||
|
||||
expect(publish).toHaveBeenCalledTimes(1);
|
||||
expect(publish).toHaveBeenCalledWith({
|
||||
version: 1,
|
||||
requestId: "request-terminal-race",
|
||||
status: "unavailable",
|
||||
reason: "cancelled",
|
||||
});
|
||||
await expect(
|
||||
auditToolHandler(support).handler({ paths: ["src/late.ts"], complete: true }),
|
||||
).resolves.toMatchObject({ isError: true });
|
||||
expect(publish).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("normalizes roots, drops invalid paths, and deduplicates without truncation", async () => {
|
||||
const cwd = path.join(os.tmpdir(), "file-audit-project");
|
||||
const additionalRoot = path.join(os.tmpdir(), "file-audit-shared");
|
||||
const canonicalTempRoot = fs.realpathSync.native(os.tmpdir());
|
||||
const canonicalCwd = path.join(canonicalTempRoot, "file-audit-project");
|
||||
const canonicalAdditionalRoot = path.join(canonicalTempRoot, "file-audit-shared");
|
||||
const outside = path.join(os.tmpdir(), "file-audit-outside", "outside.txt");
|
||||
const state = createFileChangeAuditTurnState("request-2");
|
||||
state.phase = "collecting";
|
||||
const published: AgentFileChangeReportResult[] = [];
|
||||
const support = createSupport({
|
||||
state,
|
||||
cwd,
|
||||
additionalDirectories: [additionalRoot],
|
||||
publish: async (result) => void published.push(result),
|
||||
});
|
||||
const tool = auditToolHandler(support);
|
||||
|
||||
expect(tool._meta).toMatchObject({
|
||||
"anthropic/alwaysLoad": true,
|
||||
"claude/endTurn": true,
|
||||
});
|
||||
const result = await tool.handler({
|
||||
paths: [
|
||||
"src/a.ts",
|
||||
"src/a.ts",
|
||||
cwd,
|
||||
additionalRoot,
|
||||
path.join(additionalRoot, "generated.ts"),
|
||||
outside,
|
||||
"bad\u0000path",
|
||||
"bad\u0085path",
|
||||
"x".repeat(4090),
|
||||
],
|
||||
complete: true,
|
||||
uncertainty: "Generated files may be missing",
|
||||
});
|
||||
|
||||
expect(result.isError).not.toBe(true);
|
||||
expect(published).toEqual([
|
||||
{
|
||||
version: 1,
|
||||
requestId: "request-2",
|
||||
status: "reported",
|
||||
paths: [
|
||||
path.join(canonicalCwd, "src/a.ts"),
|
||||
path.join(canonicalAdditionalRoot, "generated.ts"),
|
||||
],
|
||||
declaredComplete: false,
|
||||
truncated: true,
|
||||
uncertainty: "Generated files may be missing",
|
||||
},
|
||||
]);
|
||||
|
||||
expect(await tool.handler({ paths: ["src/late.ts"], complete: true })).toMatchObject({
|
||||
isError: true,
|
||||
});
|
||||
expect(published).toHaveLength(1);
|
||||
|
||||
const duplicateOnlyState = createFileChangeAuditTurnState("request-3");
|
||||
duplicateOnlyState.phase = "collecting";
|
||||
const duplicateOnlyReports: AgentFileChangeReportResult[] = [];
|
||||
const duplicateOnlyTool = auditToolHandler(
|
||||
createSupport({
|
||||
state: duplicateOnlyState,
|
||||
cwd,
|
||||
publish: async (report) => void duplicateOnlyReports.push(report),
|
||||
}),
|
||||
);
|
||||
await duplicateOnlyTool.handler({ paths: ["src/a.ts", "src/a.ts"], complete: true });
|
||||
expect(duplicateOnlyReports[0]).toMatchObject({
|
||||
declaredComplete: true,
|
||||
truncated: false,
|
||||
paths: [path.join(canonicalCwd, "src/a.ts")],
|
||||
});
|
||||
});
|
||||
|
||||
it("accepts canonical paths under a symlinked workspace root", async () => {
|
||||
if (process.platform === "win32") return;
|
||||
|
||||
const realRoot = fs.mkdtempSync(path.join(os.tmpdir(), "file-audit-real-"));
|
||||
const linkedRoot = `${realRoot}-link`;
|
||||
fs.symlinkSync(realRoot, linkedRoot, "dir");
|
||||
try {
|
||||
const state = createFileChangeAuditTurnState("request-symlink-root");
|
||||
state.phase = "collecting";
|
||||
const reports: AgentFileChangeReportResult[] = [];
|
||||
const tool = auditToolHandler(
|
||||
createSupport({
|
||||
state,
|
||||
cwd: linkedRoot,
|
||||
publish: async (report) => void reports.push(report),
|
||||
}),
|
||||
);
|
||||
const canonicalPath = path.join(fs.realpathSync.native(realRoot), "generated.ts");
|
||||
|
||||
await tool.handler({
|
||||
paths: [canonicalPath, path.join(linkedRoot, "generated.ts")],
|
||||
complete: true,
|
||||
});
|
||||
|
||||
expect(reports[0]).toMatchObject({
|
||||
paths: [canonicalPath],
|
||||
declaredComplete: true,
|
||||
truncated: false,
|
||||
});
|
||||
} finally {
|
||||
fs.unlinkSync(linkedRoot);
|
||||
fs.rmSync(realRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("caps path count and total UTF-8 bytes", async () => {
|
||||
const cwd = path.join(os.tmpdir(), "file-audit-caps");
|
||||
|
||||
const countState = createFileChangeAuditTurnState("request-count");
|
||||
countState.phase = "collecting";
|
||||
const countReports: AgentFileChangeReportResult[] = [];
|
||||
await auditToolHandler(
|
||||
createSupport({
|
||||
state: countState,
|
||||
cwd,
|
||||
publish: async (report) => void countReports.push(report),
|
||||
}),
|
||||
).handler({
|
||||
paths: Array.from({ length: 1030 }, (_, index) => `src/file-${index}.ts`),
|
||||
complete: true,
|
||||
});
|
||||
expect(countReports[0]).toMatchObject({
|
||||
declaredComplete: false,
|
||||
truncated: true,
|
||||
});
|
||||
expect(countReports[0].status).toBe("reported");
|
||||
if (countReports[0].status !== "reported") throw new Error("Expected a reported result");
|
||||
expect(countReports[0].paths).toHaveLength(1024);
|
||||
|
||||
const byteState = createFileChangeAuditTurnState("request-bytes");
|
||||
byteState.phase = "collecting";
|
||||
const byteReports: AgentFileChangeReportResult[] = [];
|
||||
await auditToolHandler(
|
||||
createSupport({
|
||||
state: byteState,
|
||||
cwd,
|
||||
publish: async (report) => void byteReports.push(report),
|
||||
}),
|
||||
).handler({
|
||||
paths: Array.from(
|
||||
{ length: 1000 },
|
||||
(_, index) => `generated/${index}-${"x".repeat(280)}.txt`,
|
||||
),
|
||||
complete: true,
|
||||
});
|
||||
expect(byteReports[0]).toMatchObject({
|
||||
declaredComplete: false,
|
||||
truncated: true,
|
||||
});
|
||||
expect(byteReports[0].status).toBe("reported");
|
||||
if (byteReports[0].status !== "reported") throw new Error("Expected a reported result");
|
||||
expect(byteReports[0].paths.length).toBeLessThan(1000);
|
||||
expect(Buffer.byteLength(JSON.stringify(byteReports[0]), "utf8")).toBeLessThanOrEqual(
|
||||
AGENT_FILE_CHANGE_REPORT_MAX_BYTES,
|
||||
);
|
||||
});
|
||||
|
||||
it("fails open when report publication fails", async () => {
|
||||
const state = createFileChangeAuditTurnState("request-4");
|
||||
state.phase = "collecting";
|
||||
const logError = vi.fn();
|
||||
const tool = auditToolHandler(
|
||||
createSupport({
|
||||
state,
|
||||
publish: async () => {
|
||||
throw new Error("transport closed");
|
||||
},
|
||||
logError,
|
||||
}),
|
||||
);
|
||||
|
||||
await expect(tool.handler({ paths: ["src/a.ts"], complete: true })).resolves.toMatchObject({
|
||||
structuredContent: { requestId: "request-4", status: "reported" },
|
||||
});
|
||||
expect(state.phase).toBe("finished");
|
||||
expect(logError).toHaveBeenCalledWith(expect.stringContaining("transport closed"));
|
||||
|
||||
const unavailableState = createFileChangeAuditTurnState("request-unavailable-failure");
|
||||
const unavailableSupport = createSupport({
|
||||
state: unavailableState,
|
||||
publish: async () => {
|
||||
throw new Error("transport closed");
|
||||
},
|
||||
logError,
|
||||
});
|
||||
await expect(
|
||||
unavailableSupport.finishUnavailable(unavailableState, "providerError"),
|
||||
).resolves.toBeUndefined();
|
||||
expect(unavailableState.phase).toBe("finished");
|
||||
expect(logError).toHaveBeenCalledWith(expect.stringContaining("request-unavailable-failure"));
|
||||
});
|
||||
});
|
||||
3
src/tests/fixtures/usage-limit-session.jsonl
vendored
Normal file
3
src/tests/fixtures/usage-limit-session.jsonl
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
{"type":"user","uuid":"11111111-1111-4111-8111-111111111111","parentUuid":null,"sessionId":"aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa","timestamp":"2026-01-01T00:00:00.000Z","message":{"role":"user","content":[{"type":"text","text":"Create a short HTML page"}]}}
|
||||
{"type":"assistant","uuid":"22222222-2222-4222-8222-222222222222","parentUuid":"11111111-1111-4111-8111-111111111111","sessionId":"aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa","timestamp":"2026-01-01T00:00:01.000Z","error":"rate_limit","isApiErrorMessage":true,"message":{"id":"msg_usage_limit","type":"message","role":"assistant","model":"<synthetic>","content":[{"type":"text","text":"You've hit your individual spend limit · run /usage-credits to ask your admin for a higher limit"}],"stop_reason":"stop_sequence","stop_sequence":null,"usage":{"input_tokens":0,"output_tokens":0,"cache_read_input_tokens":0,"cache_creation_input_tokens":0}}}
|
||||
{"type":"assistant","uuid":"33333333-3333-4333-8333-333333333333","parentUuid":"22222222-2222-4222-8222-222222222222","sessionId":"aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa","timestamp":"2026-01-01T00:00:02.000Z","message":{"id":"msg_local_command","type":"message","role":"assistant","model":"<synthetic>","content":[{"type":"text","text":"No response requested."}],"stop_reason":"stop_sequence","stop_sequence":null,"usage":{"input_tokens":0,"output_tokens":0,"cache_read_input_tokens":0,"cache_creation_input_tokens":0}}}
|
||||
@@ -59,7 +59,7 @@ describe("providers", () => {
|
||||
expect(response.agentCapabilities?.providers).toEqual({});
|
||||
});
|
||||
|
||||
it("lists a single optional 'main' provider, unconfigured by default", async () => {
|
||||
it("lists one mutually exclusive provider slot with native routing", async () => {
|
||||
const [agent] = await createAgentMock();
|
||||
const response = await agent.unstable_listProviders({});
|
||||
expect(response.providers).toEqual([
|
||||
@@ -67,7 +67,7 @@ describe("providers", () => {
|
||||
providerId: "main",
|
||||
supported: ["anthropic", "bedrock", "vertex"],
|
||||
required: false,
|
||||
current: null,
|
||||
current: { apiType: "anthropic", baseUrl: "https://api.anthropic.com" },
|
||||
},
|
||||
]);
|
||||
});
|
||||
@@ -126,7 +126,7 @@ describe("providers", () => {
|
||||
).rejects.toMatchObject({ code: -32602 });
|
||||
});
|
||||
|
||||
it("disables the 'main' provider by clearing config and reporting current: null", async () => {
|
||||
it("restores native routing after the last override is disabled", async () => {
|
||||
const [agent] = await createAgentMock();
|
||||
await agent.unstable_setProvider({
|
||||
providerId: "main",
|
||||
@@ -137,7 +137,36 @@ describe("providers", () => {
|
||||
|
||||
await expect(agent.unstable_disableProvider({ providerId: "main" })).resolves.toEqual({});
|
||||
|
||||
expect((await agent.unstable_listProviders({})).providers[0].current).toBeNull();
|
||||
expect((await agent.unstable_listProviders({})).providers[0].current).toEqual({
|
||||
apiType: "anthropic",
|
||||
baseUrl: "https://api.anthropic.com",
|
||||
});
|
||||
});
|
||||
|
||||
it("replaces the active api type when the single slot is set again", async () => {
|
||||
const [agent] = await createAgentMock();
|
||||
await agent.unstable_setProvider({
|
||||
providerId: "main",
|
||||
apiType: "anthropic",
|
||||
baseUrl: "https://anthropic.example",
|
||||
});
|
||||
await agent.unstable_setProvider({
|
||||
providerId: "main",
|
||||
apiType: "bedrock",
|
||||
baseUrl: "https://bedrock.example",
|
||||
});
|
||||
|
||||
const overridden = await agent.unstable_listProviders({});
|
||||
expect(overridden.providers[0].current).toEqual({
|
||||
apiType: "bedrock",
|
||||
baseUrl: "https://bedrock.example",
|
||||
});
|
||||
|
||||
await agent.unstable_disableProvider({ providerId: "main" });
|
||||
expect((await agent.unstable_listProviders({})).providers[0].current).toEqual({
|
||||
apiType: "anthropic",
|
||||
baseUrl: "https://api.anthropic.com",
|
||||
});
|
||||
});
|
||||
|
||||
it("treats disabling an unknown provider as an idempotent no-op", async () => {
|
||||
@@ -298,6 +327,9 @@ describe("providers", () => {
|
||||
|
||||
await agent.logout({});
|
||||
|
||||
expect((await agent.unstable_listProviders({})).providers[0].current).toBeNull();
|
||||
expect((await agent.unstable_listProviders({})).providers[0].current).toEqual({
|
||||
apiType: "anthropic",
|
||||
baseUrl: "https://api.anthropic.com",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||
import { describe, it, expect, afterEach, beforeEach, vi } from "vitest";
|
||||
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import * as path from "node:path";
|
||||
import { ClientCapabilities } from "@agentclientprotocol/sdk";
|
||||
import { ImageBlockParam, ToolResultBlockParam } from "@anthropic-ai/sdk/resources";
|
||||
import {
|
||||
@@ -18,8 +21,10 @@ import {
|
||||
toolInfoFromToolUse,
|
||||
planEntries,
|
||||
applyTaskCreate,
|
||||
applyTaskList,
|
||||
applyTaskUpdate,
|
||||
parseTaskCreateOutput,
|
||||
parseTaskListOutput,
|
||||
taskStateToPlanEntries,
|
||||
TaskState,
|
||||
} from "../tools.js";
|
||||
@@ -1817,6 +1822,14 @@ describe("planEntries - undefined input regression", () => {
|
||||
{ content: "Task 2", status: "completed", priority: "medium" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("uses activeForm while a todo is in progress", () => {
|
||||
expect(
|
||||
planEntries({
|
||||
todos: [{ content: "Run tests", status: "in_progress", activeForm: "Running tests" }],
|
||||
}),
|
||||
).toEqual([{ content: "Running tests", status: "in_progress", priority: "medium" }]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("toAcpNotifications - TodoWrite with undefined input regression", () => {
|
||||
@@ -1884,6 +1897,20 @@ describe("parseTaskCreateOutput", () => {
|
||||
expect(parsed).toEqual({ task: { id: "2", subject: "Y" } });
|
||||
});
|
||||
|
||||
it("continues past unrelated JSON blocks", () => {
|
||||
const parsed = parseTaskCreateOutput([
|
||||
{ type: "text", text: JSON.stringify({ metadata: "unrelated" }) },
|
||||
{ type: "text", text: JSON.stringify({ task: { id: "2", subject: "Y" } }) },
|
||||
]);
|
||||
expect(parsed).toEqual({ task: { id: "2", subject: "Y" } });
|
||||
});
|
||||
|
||||
it("parses the human-readable TaskCreate format used in session history", () => {
|
||||
expect(parseTaskCreateOutput("Task #42 created successfully: Run tests")).toEqual({
|
||||
task: { id: "42", subject: "Run tests" },
|
||||
});
|
||||
});
|
||||
|
||||
it("returns undefined for non-JSON content", () => {
|
||||
expect(parseTaskCreateOutput("not json")).toBeUndefined();
|
||||
expect(parseTaskCreateOutput([{ type: "text", text: "not json" }])).toBeUndefined();
|
||||
@@ -1918,11 +1945,15 @@ describe("applyTaskCreate / applyTaskUpdate", () => {
|
||||
|
||||
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: "A", description: "", activeForm: "Working on A" },
|
||||
{ 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: "Working on A", status: "in_progress", priority: "medium" },
|
||||
{ content: "B", status: "pending", priority: "medium" },
|
||||
]);
|
||||
});
|
||||
@@ -1952,6 +1983,84 @@ describe("applyTaskCreate / applyTaskUpdate", () => {
|
||||
// update is dropped instead of synthesizing a blank placeholder.
|
||||
expect(state.has("5")).toBe(false);
|
||||
});
|
||||
|
||||
it("rebuilds task state from TaskList while preserving richer local fields", () => {
|
||||
const state: TaskState = new Map([
|
||||
[
|
||||
"1",
|
||||
{
|
||||
subject: "Old subject",
|
||||
status: "pending",
|
||||
activeForm: "Running the task",
|
||||
description: "Details",
|
||||
},
|
||||
],
|
||||
["deleted", { subject: "Stale", status: "pending" }],
|
||||
]);
|
||||
const output = parseTaskListOutput(
|
||||
JSON.stringify({
|
||||
tasks: [
|
||||
{ id: "1", subject: "Current subject", status: "in_progress", blockedBy: [] },
|
||||
{ id: "2", subject: "New task", status: "pending", blockedBy: [] },
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
expect(output).toBeDefined();
|
||||
applyTaskList(state, output!);
|
||||
|
||||
expect([...state.entries()]).toEqual([
|
||||
[
|
||||
"1",
|
||||
{
|
||||
subject: "Current subject",
|
||||
status: "in_progress",
|
||||
activeForm: "Running the task",
|
||||
description: "Details",
|
||||
},
|
||||
],
|
||||
[
|
||||
"2",
|
||||
{
|
||||
subject: "New task",
|
||||
status: "pending",
|
||||
activeForm: undefined,
|
||||
description: undefined,
|
||||
},
|
||||
],
|
||||
]);
|
||||
});
|
||||
|
||||
it("finds a TaskList snapshot after an unrelated JSON block", () => {
|
||||
expect(
|
||||
parseTaskListOutput([
|
||||
{ type: "text", text: JSON.stringify({ metadata: "unrelated" }) },
|
||||
{
|
||||
type: "text",
|
||||
text: JSON.stringify({
|
||||
tasks: [{ id: "1", subject: "Recovered", status: "pending", blockedBy: [] }],
|
||||
}),
|
||||
},
|
||||
]),
|
||||
).toEqual({
|
||||
tasks: [{ id: "1", subject: "Recovered", status: "pending", blockedBy: [] }],
|
||||
});
|
||||
});
|
||||
|
||||
it("parses the human-readable TaskList format used in session history", () => {
|
||||
expect(
|
||||
parseTaskListOutput(
|
||||
"#1 [in_progress] Run tests\n#2 [pending] Write release notes [blocked by #1]",
|
||||
),
|
||||
).toEqual({
|
||||
tasks: [
|
||||
{ id: "1", subject: "Run tests", status: "in_progress", blockedBy: [] },
|
||||
{ id: "2", subject: "Write release notes", status: "pending", blockedBy: ["1"] },
|
||||
],
|
||||
});
|
||||
|
||||
expect(parseTaskListOutput("No tasks found")).toEqual({ tasks: [] });
|
||||
});
|
||||
});
|
||||
|
||||
describe("toAcpNotifications - Task* tools", () => {
|
||||
@@ -2112,10 +2221,9 @@ describe("toAcpNotifications - Task* tools", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("suppresses TaskList and TaskGet tool_result without touching task state", () => {
|
||||
it("uses the structured TaskList result as an authoritative plan snapshot", () => {
|
||||
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 }],
|
||||
@@ -2123,8 +2231,93 @@ describe("toAcpNotifications - Task* tools", () => {
|
||||
|
||||
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 },
|
||||
{
|
||||
type: "tool_result",
|
||||
tool_use_id: "list-1",
|
||||
content: "#2 [pending] Recovered",
|
||||
is_error: false,
|
||||
},
|
||||
] as any,
|
||||
"user",
|
||||
"test-session",
|
||||
toolUseCache,
|
||||
mockClient,
|
||||
mockLogger,
|
||||
{
|
||||
taskState,
|
||||
toolUseResult: {
|
||||
tasks: [{ id: "2", subject: "Recovered", status: "pending", blockedBy: [] }],
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
expect(notifications).toHaveLength(1);
|
||||
expect(notifications[0].update).toMatchObject({
|
||||
sessionUpdate: "plan",
|
||||
entries: [{ content: "Recovered", status: "pending", priority: "medium" }],
|
||||
});
|
||||
expect([...taskState.keys()]).toEqual(["2"]);
|
||||
});
|
||||
|
||||
it("does not apply a logically failed TaskUpdate", () => {
|
||||
const toolUseCache: ToolUseCache = {
|
||||
"update-1": {
|
||||
type: "tool_use",
|
||||
id: "update-1",
|
||||
name: "TaskUpdate",
|
||||
input: { taskId: "1", status: "completed" },
|
||||
},
|
||||
};
|
||||
const taskState: TaskState = new Map([["1", { subject: "Existing", status: "pending" }]]);
|
||||
|
||||
const notifications = toAcpNotifications(
|
||||
[
|
||||
{
|
||||
type: "tool_result",
|
||||
tool_use_id: "update-1",
|
||||
content: "Task #1 not found",
|
||||
is_error: false,
|
||||
},
|
||||
] as any,
|
||||
"user",
|
||||
"test-session",
|
||||
toolUseCache,
|
||||
mockClient,
|
||||
mockLogger,
|
||||
{
|
||||
taskState,
|
||||
toolUseResult: {
|
||||
success: false,
|
||||
taskId: "1",
|
||||
updatedFields: [],
|
||||
error: "Task not found",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
expect(notifications).toHaveLength(0);
|
||||
expect(taskState.get("1")).toEqual({ subject: "Existing", status: "pending" });
|
||||
});
|
||||
|
||||
it("does not apply a replayed TaskUpdate failure without structured output", () => {
|
||||
const toolUseCache: ToolUseCache = {
|
||||
"update-1": {
|
||||
type: "tool_use",
|
||||
id: "update-1",
|
||||
name: "TaskUpdate",
|
||||
input: { taskId: "1", status: "completed" },
|
||||
},
|
||||
};
|
||||
const taskState: TaskState = new Map([["1", { subject: "Existing", status: "pending" }]]);
|
||||
|
||||
const notifications = toAcpNotifications(
|
||||
[
|
||||
{
|
||||
type: "tool_result",
|
||||
tool_use_id: "update-1",
|
||||
content: "Task #1 not found",
|
||||
is_error: false,
|
||||
},
|
||||
] as any,
|
||||
"user",
|
||||
"test-session",
|
||||
@@ -2135,7 +2328,7 @@ describe("toAcpNotifications - Task* tools", () => {
|
||||
);
|
||||
|
||||
expect(notifications).toHaveLength(0);
|
||||
expect(taskState.get("1")).toEqual({ subject: "Existing", status: "in_progress" });
|
||||
expect(taskState.get("1")).toEqual({ subject: "Existing", status: "pending" });
|
||||
});
|
||||
|
||||
it("does not apply TaskCreate/TaskUpdate when the tool_result reports an error", () => {
|
||||
@@ -2984,3 +3177,147 @@ describe("structured tool_use_result rendering (Read/Bash/WebSearch)", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Skill tool rendering", () => {
|
||||
const mockLogger: Logger = { log: () => {}, error: () => {} };
|
||||
|
||||
describe("toolInfoFromToolUse", () => {
|
||||
it("sets title to 'Load skill: <name>' and returns empty content", () => {
|
||||
const info = toolInfoFromToolUse(
|
||||
{ name: "Skill", id: "toolu_1", input: { skill: "commits" } },
|
||||
false,
|
||||
);
|
||||
expect(info.title).toBe("Load skill: commits");
|
||||
expect(info.kind).toBe("other");
|
||||
expect(info.content).toEqual([]);
|
||||
});
|
||||
|
||||
it("falls back to 'Load skill' when skill name is absent", () => {
|
||||
const info = toolInfoFromToolUse({ name: "Skill", id: "toolu_2", input: {} }, false);
|
||||
expect(info.title).toBe("Load skill");
|
||||
expect(info.content).toEqual([]);
|
||||
});
|
||||
|
||||
it("does not throw when input is undefined", () => {
|
||||
const info = toolInfoFromToolUse({ name: "Skill", id: "toolu_3", input: undefined }, false);
|
||||
expect(info.title).toBe("Load skill");
|
||||
expect(info.content).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("toolUpdateFromToolResult", () => {
|
||||
it("suppresses the raw 'Launching skill' result text", () => {
|
||||
const toolUse = {
|
||||
type: "tool_use",
|
||||
id: "toolu_4",
|
||||
name: "Skill",
|
||||
input: { skill: "commits" },
|
||||
};
|
||||
const toolResult = {
|
||||
type: "tool_result" as const,
|
||||
tool_use_id: "toolu_4",
|
||||
content: "Launching skill: commits",
|
||||
is_error: false,
|
||||
};
|
||||
const update = toolUpdateFromToolResult(toolResult, toolUse, false);
|
||||
expect(update).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
describe("_meta.claudeCode.skill in tool_call notification", () => {
|
||||
it("includes skill name in _meta.claudeCode when Skill tool is invoked", () => {
|
||||
const notifications = toAcpNotifications(
|
||||
[
|
||||
{ type: "tool_use", id: "toolu_5", name: "Skill", input: { skill: "commits", args: "" } },
|
||||
] as any,
|
||||
"assistant",
|
||||
"test-session",
|
||||
{},
|
||||
{} as AcpClient,
|
||||
mockLogger,
|
||||
);
|
||||
expect(notifications[0]?.update).toMatchObject({
|
||||
sessionUpdate: "tool_call",
|
||||
_meta: { claudeCode: { toolName: "Skill", skill: "commits" } },
|
||||
});
|
||||
});
|
||||
|
||||
it("omits skill from _meta.claudeCode when skill name is missing", () => {
|
||||
const notifications = toAcpNotifications(
|
||||
[{ type: "tool_use", id: "toolu_6", name: "Skill", input: {} }] as any,
|
||||
"assistant",
|
||||
"test-session",
|
||||
{},
|
||||
{} as AcpClient,
|
||||
mockLogger,
|
||||
);
|
||||
const meta = (notifications[0]?.update as any)?._meta?.claudeCode;
|
||||
expect(meta).toBeDefined();
|
||||
expect(meta.skill).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("_meta.claudeCode.skillPath", () => {
|
||||
const skillMeta = (skill: string, cwd?: string) =>
|
||||
(
|
||||
toAcpNotifications(
|
||||
[{ type: "tool_use", id: "toolu_skill_path", name: "Skill", input: { skill } }] as any,
|
||||
"assistant",
|
||||
"test-session",
|
||||
{},
|
||||
{} as AcpClient,
|
||||
mockLogger,
|
||||
cwd ? { cwd } : undefined,
|
||||
)[0]?.update as any
|
||||
)?._meta?.claudeCode;
|
||||
|
||||
let root: string;
|
||||
|
||||
beforeEach(() => {
|
||||
root = mkdtempSync(path.join(tmpdir(), "acp-skill-path-"));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
const writeSkill = (relativeDir: string) => {
|
||||
const dir = path.join(root, relativeDir);
|
||||
mkdirSync(dir, { recursive: true });
|
||||
const file = path.join(dir, "SKILL.md");
|
||||
writeFileSync(file, "# skill\n");
|
||||
return file;
|
||||
};
|
||||
|
||||
it("resolves a project-level .claude/skills skill", () => {
|
||||
const file = writeSkill(".claude/skills/commits");
|
||||
expect(skillMeta("commits", root).skillPath).toBe(file);
|
||||
});
|
||||
|
||||
it("resolves a project-level .agents/skills skill", () => {
|
||||
const file = writeSkill(".agents/skills/commits");
|
||||
expect(skillMeta("commits", root).skillPath).toBe(file);
|
||||
});
|
||||
|
||||
it("resolves a directory-scoped skill spelled prefix:name", () => {
|
||||
const file = writeSkill("apps/web/.claude/skills/deploy");
|
||||
expect(skillMeta("apps/web:deploy", root).skillPath).toBe(file);
|
||||
});
|
||||
|
||||
it("resolves a plugin skill spelled plugin:name", () => {
|
||||
const file = writeSkill(".claude/plugins/reviewer/skills/audit");
|
||||
expect(skillMeta("reviewer:audit", root).skillPath).toBe(file);
|
||||
});
|
||||
|
||||
it("omits skillPath when no known layout holds the skill", () => {
|
||||
const meta = skillMeta("nonexistent", root);
|
||||
expect(meta.skill).toBe("nonexistent");
|
||||
expect(meta.skillPath).toBeUndefined();
|
||||
});
|
||||
|
||||
it("omits skillPath when the session has no cwd", () => {
|
||||
writeSkill(".claude/skills/commits");
|
||||
expect(skillMeta("commits").skillPath).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
179
src/tools.ts
179
src/tools.ts
@@ -21,7 +21,9 @@ import {
|
||||
ReportFindingsInput,
|
||||
TaskCreateInput,
|
||||
TaskCreateOutput,
|
||||
TaskListOutput,
|
||||
TaskUpdateInput,
|
||||
TaskUpdateOutput,
|
||||
TodoWriteInput,
|
||||
WebFetchInput,
|
||||
WebSearchInput,
|
||||
@@ -216,7 +218,7 @@ export function toolInfoFromToolUse(
|
||||
}
|
||||
const displayPath = input?.file_path ? toDisplayPath(input.file_path, cwd) : undefined;
|
||||
return {
|
||||
title: displayPath ? `Write ${displayPath}` : "Write",
|
||||
title: displayPath ? `Write ${displayPath}` : "Preparing file…",
|
||||
kind: "edit",
|
||||
content,
|
||||
locations: input?.file_path ? [{ path: input.file_path }] : [],
|
||||
@@ -439,6 +441,16 @@ export function toolInfoFromToolUse(
|
||||
};
|
||||
}
|
||||
|
||||
case "Skill": {
|
||||
const input = toolUse.input as { skill?: string; args?: string } | undefined;
|
||||
const skillName = input?.skill;
|
||||
return {
|
||||
title: skillName ? `Load skill: ${skillName}` : "Load skill",
|
||||
kind: "other",
|
||||
content: [],
|
||||
};
|
||||
}
|
||||
|
||||
case "AskUserQuestion": {
|
||||
const input = toolUse.input as Partial<AskUserQuestionInput> | undefined;
|
||||
const questions = Array.isArray(input?.questions) ? input.questions : [];
|
||||
@@ -861,6 +873,10 @@ export function toolUpdateFromToolResult(
|
||||
);
|
||||
}
|
||||
|
||||
case "Skill": {
|
||||
return {};
|
||||
}
|
||||
|
||||
case "Edit": // Edit is handled in hooks
|
||||
case "Write": {
|
||||
return {};
|
||||
@@ -1030,7 +1046,7 @@ export type ClaudePlanEntry = {
|
||||
|
||||
export function planEntries(input: { todos: ClaudePlanEntry[] } | undefined): PlanEntry[] {
|
||||
return (input?.todos ?? []).map((todo) => ({
|
||||
content: todo.content,
|
||||
content: todo.status === "in_progress" && todo.activeForm ? todo.activeForm : todo.content,
|
||||
status: todo.status,
|
||||
priority: "medium",
|
||||
}));
|
||||
@@ -1052,31 +1068,29 @@ export type TaskEntry = {
|
||||
export type TaskState = Map<string, TaskEntry>;
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* Best-effort parse of a structured Task* tool_result. 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 => {
|
||||
function parseJsonToolOutput<T>(
|
||||
content: unknown,
|
||||
isExpectedOutput: (value: unknown) => value is T,
|
||||
): T | undefined {
|
||||
const tryParse = (text: string): T | undefined => {
|
||||
try {
|
||||
const parsed = JSON.parse(text);
|
||||
if (
|
||||
parsed &&
|
||||
typeof parsed === "object" &&
|
||||
parsed.task &&
|
||||
typeof parsed.task.id === "string"
|
||||
) {
|
||||
return parsed as TaskCreateOutput;
|
||||
}
|
||||
const parsed: unknown = JSON.parse(text);
|
||||
return isExpectedOutput(parsed) ? parsed : undefined;
|
||||
} catch {
|
||||
// ignore
|
||||
return undefined;
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
if (typeof content === "string") {
|
||||
return tryParse(content);
|
||||
}
|
||||
if (content && typeof content === "object" && !Array.isArray(content)) {
|
||||
return isExpectedOutput(content) ? content : undefined;
|
||||
}
|
||||
if (Array.isArray(content)) {
|
||||
for (const block of content) {
|
||||
if (block && typeof block === "object" && "type" in block && block.type === "text") {
|
||||
@@ -1091,6 +1105,119 @@ export function parseTaskCreateOutput(content: unknown): TaskCreateOutput | unde
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function toolOutputTexts(content: unknown): string[] {
|
||||
if (typeof content === "string") return [content];
|
||||
if (!Array.isArray(content)) return [];
|
||||
return content.flatMap((block) =>
|
||||
block &&
|
||||
typeof block === "object" &&
|
||||
"type" in block &&
|
||||
block.type === "text" &&
|
||||
"text" in block &&
|
||||
typeof block.text === "string"
|
||||
? [block.text]
|
||||
: [],
|
||||
);
|
||||
}
|
||||
|
||||
export function parseTaskCreateOutput(content: unknown): TaskCreateOutput | undefined {
|
||||
const structured = parseJsonToolOutput(content, (parsed): parsed is TaskCreateOutput =>
|
||||
Boolean(
|
||||
parsed &&
|
||||
typeof parsed === "object" &&
|
||||
"task" in parsed &&
|
||||
parsed.task &&
|
||||
typeof parsed.task === "object" &&
|
||||
"id" in parsed.task &&
|
||||
typeof parsed.task.id === "string",
|
||||
),
|
||||
);
|
||||
if (structured) return structured;
|
||||
|
||||
for (const text of toolOutputTexts(content)) {
|
||||
const match = /^Task #(\S+) created successfully: (.+)$/.exec(text.trim());
|
||||
if (match) return { task: { id: match[1], subject: match[2] } };
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function parseTaskListOutput(content: unknown): TaskListOutput | undefined {
|
||||
const validStatuses = new Set(["pending", "in_progress", "completed"]);
|
||||
const structured = parseJsonToolOutput(content, (parsed): parsed is TaskListOutput =>
|
||||
Boolean(
|
||||
parsed &&
|
||||
typeof parsed === "object" &&
|
||||
"tasks" in parsed &&
|
||||
Array.isArray(parsed.tasks) &&
|
||||
parsed.tasks.every(
|
||||
(task) =>
|
||||
task &&
|
||||
typeof task === "object" &&
|
||||
typeof task.id === "string" &&
|
||||
typeof task.subject === "string" &&
|
||||
typeof task.status === "string" &&
|
||||
validStatuses.has(task.status),
|
||||
),
|
||||
),
|
||||
);
|
||||
if (structured) return structured;
|
||||
|
||||
for (const text of toolOutputTexts(content)) {
|
||||
if (text.trim() === "No tasks found") return { tasks: [] };
|
||||
|
||||
const tasks: TaskListOutput["tasks"] = [];
|
||||
const lines = text.trim().split("\n");
|
||||
for (const line of lines) {
|
||||
const match =
|
||||
/^#(\S+) \[(pending|in_progress|completed)\] (.+?)(?: \(([^()]*)\))?(?: \[blocked by ((?:#[^,\]]+(?:, )?)+)\])?$/.exec(
|
||||
line,
|
||||
);
|
||||
if (!match) {
|
||||
tasks.length = 0;
|
||||
break;
|
||||
}
|
||||
tasks.push({
|
||||
id: match[1],
|
||||
subject: match[3],
|
||||
status: match[2] as TaskListOutput["tasks"][number]["status"],
|
||||
...(match[4] ? { owner: match[4] } : {}),
|
||||
blockedBy: match[5] ? match[5].split(", ").map((id) => id.slice(1)) : [],
|
||||
});
|
||||
}
|
||||
if (tasks.length > 0) return { tasks };
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function parseTaskUpdateOutput(
|
||||
content: unknown,
|
||||
expectedTaskId?: string,
|
||||
): TaskUpdateOutput | undefined {
|
||||
const structured = parseJsonToolOutput(content, (parsed): parsed is TaskUpdateOutput =>
|
||||
Boolean(
|
||||
parsed &&
|
||||
typeof parsed === "object" &&
|
||||
"success" in parsed &&
|
||||
typeof parsed.success === "boolean" &&
|
||||
"taskId" in parsed &&
|
||||
typeof parsed.taskId === "string" &&
|
||||
"updatedFields" in parsed &&
|
||||
Array.isArray(parsed.updatedFields) &&
|
||||
parsed.updatedFields.every((field) => typeof field === "string"),
|
||||
),
|
||||
);
|
||||
if (structured) return structured;
|
||||
|
||||
for (const text of toolOutputTexts(content)) {
|
||||
const notFound = /^Task #(\S+) not found$/.exec(text.trim());
|
||||
const taskId = notFound?.[1] ?? expectedTaskId;
|
||||
if (taskId && (notFound || text.trim() === "Failed to delete task")) {
|
||||
return { success: false, taskId, updatedFields: [], error: text.trim() };
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function applyTaskCreate(
|
||||
state: TaskState,
|
||||
input: TaskCreateInput | undefined,
|
||||
@@ -1125,9 +1252,23 @@ export function applyTaskUpdate(state: TaskState, input: TaskUpdateInput | undef
|
||||
});
|
||||
}
|
||||
|
||||
export function applyTaskList(state: TaskState, output: TaskListOutput): void {
|
||||
const previous = new Map(state);
|
||||
state.clear();
|
||||
for (const task of output.tasks) {
|
||||
const existing = previous.get(task.id);
|
||||
state.set(task.id, {
|
||||
subject: task.subject,
|
||||
status: task.status,
|
||||
activeForm: existing?.activeForm,
|
||||
description: existing?.description,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export function taskStateToPlanEntries(state: TaskState): PlanEntry[] {
|
||||
return Array.from(state.values()).map((task) => ({
|
||||
content: task.subject,
|
||||
content: task.status === "in_progress" && task.activeForm ? task.activeForm : task.subject,
|
||||
status: task.status,
|
||||
priority: "medium",
|
||||
}));
|
||||
|
||||
Reference in New Issue
Block a user