mirror of
https://github.com/agentclientprotocol/claude-agent-acp.git
synced 2026-11-13 12:51:35 +00:00
Compare commits
3 Commits
v0.97.0
...
v0.97.1-pr
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0ce42f2c31 | ||
|
|
782e463ff4 | ||
|
|
c9374abfc7 |
@@ -141,8 +141,8 @@ The `initialize` response of an AIR client carries the agent side of the extensi
|
||||
}
|
||||
```
|
||||
|
||||
The agent list does not depend on the capabilities that AIR declares, except `sessionIndex`: the agent lists it, together with `sessionArchive`, `sessionRename`, `sessionListSubscribe` and `sessionWorktrees`, only for a client that declared `sessionIndex`.
|
||||
An extension is active only when the client declared its capability. The client does not declare `sessionArchive`, `sessionRename`, `sessionListSubscribe` and `sessionWorktrees`.
|
||||
The agent list does not depend on the capabilities that AIR declares, except `sessionIndex`: the agent lists it, together with `sessionBatchGet`, `sessionArchive`, `sessionRename`, `sessionListSubscribe` and `sessionWorktrees`, only for a client that declared `sessionIndex`.
|
||||
An extension is active only when the client declared its capability. The client does not declare `sessionBatchGet`, `sessionArchive`, `sessionRename`, `sessionListSubscribe` and `sessionWorktrees`.
|
||||
The response to a client that is not AIR has no `_meta.jetbrains` key.
|
||||
|
||||
### Capabilities
|
||||
@@ -158,6 +158,7 @@ The response to a client that is not AIR has no `_meta.jetbrains` key.
|
||||
| `planFile` | yes | Sends the path of the plan file in place of the plan text of an ExitPlanMode. | [Plan file](#plan-file) |
|
||||
| `backgroundSubagents` | yes | Ends a prompt turn at its result while background subagents still run. | [Background subagents](#background-subagents) |
|
||||
| `sessionIndex` | on request | Pages, orders and annotates `session/list`; adds rename, archive and a list subscription. | [Session index](#session-index) |
|
||||
| `sessionBatchGet` | with `sessionIndex` | Supports `_session/batch_get` for metadata of known session IDs. | [Batch get](#batch-get) |
|
||||
| `sessionArchive` | with `sessionIndex` | Nothing on its own: the agent lists it to tell that it supports `_session/archive` and `_session/unarchive`. | [Archive](#archive) |
|
||||
| `sessionRename` | with `sessionIndex` | Nothing on its own: the agent lists it to tell that it supports `_session/rename`. | [Rename](#rename) |
|
||||
| `sessionListSubscribe` | with `sessionIndex` | Nothing on its own: the agent lists it to tell that it supports `_session/list/subscribe`. | [List subscription](#list-subscription) |
|
||||
@@ -1282,8 +1283,8 @@ The session index makes `session/list` a bounded, ordered page with row metadata
|
||||
|
||||
AIR declares `sessionIndex` in `_meta.jetbrains.air.capabilities`.
|
||||
Unlike the other capabilities, the agent advertises `sessionIndex` only to a client that declared it, and never under ACP v2.
|
||||
Exactly when it advertises `sessionIndex`, the agent also advertises `sessionArchive` (it supports `_session/archive` and `_session/unarchive`), `sessionRename` (it supports `_session/rename`), `sessionListSubscribe` (it supports `_session/list/subscribe` and `_session/list/unsubscribe`) and `sessionWorktrees` (rows carry `agentWorktree`, see [List response](#list-response)).
|
||||
The client does not declare these four; declaring them without `sessionIndex` enables nothing.
|
||||
Exactly when it advertises `sessionIndex`, the agent also advertises `sessionBatchGet` (it supports `_session/batch_get`), `sessionArchive` (it supports `_session/archive` and `_session/unarchive`), `sessionRename` (it supports `_session/rename`), `sessionListSubscribe` (it supports `_session/list/subscribe` and `_session/list/unsubscribe`) and `sessionWorktrees` (rows carry `agentWorktree`, see [List response](#list-response)).
|
||||
The client does not declare these five; declaring them without `sessionIndex` enables nothing.
|
||||
Everything in this section applies only to such a client, except where [Delete and close](#delete-and-close) says otherwise.
|
||||
A client without the capability gets the `session/list` of before: the SDK `listSessions` call, pages of 1000 by file mtime, `offset:N` cursors, and no row `_meta`.
|
||||
It gets no new notification, and the adapter starts no watcher for it.
|
||||
@@ -1442,6 +1443,71 @@ In all of the session index methods and in `session/delete`, a session id matche
|
||||
Loading an archived session does not unarchive it.
|
||||
The CLI cleanup still deletes old transcripts (`cleanupPeriodDays`), archived or not.
|
||||
|
||||
### Batch get
|
||||
|
||||
`_session/batch_get` refreshes metadata for retained session IDs, such as pinned
|
||||
sessions and folder members outside the recent list page. Check for
|
||||
`sessionBatchGet` in `initialize.result._meta.jetbrains.air.capabilities` before
|
||||
calling it.
|
||||
|
||||
The params object must contain only `sessionIds`: an array of distinct,
|
||||
non-blank strings. IDs match exactly, without trimming or case normalization.
|
||||
Malformed params, duplicates or extra fields return `Invalid params` (`-32602`).
|
||||
The agent sets no limit on the number of IDs; the client chooses the batch size
|
||||
for the latency it accepts. Archived sessions and sessions in other projects
|
||||
or worktrees are eligible.
|
||||
|
||||
```json
|
||||
{
|
||||
"jsonrpc": "2.0",
|
||||
"id": 7,
|
||||
"method": "_session/batch_get",
|
||||
"params": { "sessionIds": ["oldA", "oldB", "oldC"] }
|
||||
}
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"jsonrpc": "2.0",
|
||||
"id": 7,
|
||||
"result": {
|
||||
"sessions": [
|
||||
{
|
||||
"sessionId": "oldA",
|
||||
"cwd": "/project",
|
||||
"title": "Fix the build",
|
||||
"updatedAt": "2026-01-01T00:00:00.000Z",
|
||||
"_meta": { "jetbrains": { "air": { "version": 1, "archived": false } } }
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The example IDs are placeholders for stored Claude session UUIDs. `sessions`
|
||||
contains the same full rows as `session/list` without a project filter, including
|
||||
archived sessions and `_meta.jetbrains.air`. Copies use the same
|
||||
newest-readable-copy selection as the list. Returned sessions preserve their
|
||||
relative request order. Non-UUID IDs and IDs without a listable transcript are
|
||||
omitted: absent, empty, unreadable or malformed transcripts, sidechains, sessions
|
||||
whose continuation has history, and transcripts whose cwd cannot be recovered.
|
||||
A sidecar alone does not supply a row.
|
||||
|
||||
`{ "sessionIds": [] }` returns `{ "sessions": [] }` without reading the store.
|
||||
A store-wide failure, such as an unreadable projects root, rejects the request.
|
||||
Individual results reflect the store as each ID is read.
|
||||
|
||||
Lookup normally addresses only the requested transcript filenames in each project
|
||||
and reads their metadata. A transcript without a cwd can use a sibling's cwd,
|
||||
as the list does. This fallback enumerates and stats sibling transcripts only
|
||||
in the directories of rows still missing a cwd, then reads at most 64 siblings
|
||||
per directory, newest mtime first. A failed recovery is cached for 60 seconds
|
||||
unless the directory mtime changes. Other project histories are not enumerated.
|
||||
The agent looks up 100 IDs at a time; the client may request any number of IDs.
|
||||
Merge returned rows with the recent page by `sessionId`. A batch read does not
|
||||
expand an existing list subscription's scope; refresh retained IDs outside that
|
||||
scope explicitly.
|
||||
|
||||
### List subscription
|
||||
|
||||
`_session/list/subscribe { "cwd": "…" }` returns `{ "subscriptionId": "…" }`.
|
||||
@@ -1465,6 +1531,7 @@ From then on the agent sends `_session/list/changes` notifications for the sessi
|
||||
|
||||
- `cwd` is required and must be an absolute path; a missing, non-string or relative `cwd` is `-32602`. Other parameters are ignored.
|
||||
- The scope is the sessions of `cwd` and of the same subdirectory in every existing linked worktree of its repository, as a list with `includeWorktrees: true` resolves it, in any archive state. Sidechain and subagent transcripts are left out, as in the list.
|
||||
- There is no page or recent-session limit: changes to older pinned or folder sessions within that scope are reported even outside the page AIR loaded. Pinned IDs outside the subscribed project and worktrees are not tracked; `_session/batch_get` does not add them to the subscription. AIR must subscribe to their project scope or refresh them explicitly.
|
||||
- `sessions` holds the full current row of each session in scope that appeared, or whose `title`, `lastPromptAt`, `state`, `lastTurnEndedAt`, `cost`, `model`, `forkedFrom`, `agentWorktree` or `archived` changed since the last row sent for it on this subscription. A row is exactly the row of `session/list`, `_meta.jetbrains.air` included.
|
||||
- After subscribe, the first change of each session sends its row in full, even when only `updatedAt` changed. From then on, a change of `updatedAt` alone sends nothing; the new `updatedAt` comes with the next change.
|
||||
- `removed` holds the ids of sessions that left the scope: deleted (the transcript is gone), or moved to a path outside it. It may name a session the client does not list (one it never listed, one the list hides, such as a session continued in another one when that one changes); the client ignores such an id.
|
||||
@@ -1517,6 +1584,7 @@ Names and semantics follow them; the transport differs, and so do the `archived`
|
||||
| capability `sessionIndex` (`_meta.jetbrains.air`) | `sessionCapabilities.list.limit` |
|
||||
| capability `sessionArchive` (`_meta.jetbrains.air`) | `sessionCapabilities.archive` (#2161) |
|
||||
| capability `sessionRename` (`_meta.jetbrains.air`) | `sessionCapabilities.setTitle` (#1987) |
|
||||
| capability `sessionBatchGet`, `_session/batch_get` | none |
|
||||
| `_meta.jetbrains.air.list.limit` | `session/list` `limit` |
|
||||
| `_meta.jetbrains.air.list.includeWorktrees` | `session/list` `includeWorktrees` |
|
||||
| `_meta.jetbrains.air.list.archived` (`"unarchived"`, `"archived"`, `"all"`) | `session/list` `archived` (#2161), a boolean there |
|
||||
|
||||
174
package-lock.json
generated
174
package-lock.json
generated
@@ -527,9 +527,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@oxc-project/types": {
|
||||
"version": "0.154.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.154.0.tgz",
|
||||
"integrity": "sha512-KYOUGQFlLWR+s4QMV2Q9ko38TZm7EdO87Qm7rvZLqk/webse/cDjtyxKhg/47l0byCbs/k46LT8qc4PKjAikzA==",
|
||||
"version": "0.155.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.155.0.tgz",
|
||||
"integrity": "sha512-SeE8JPwW1xs1tHeNycjUtUBdhorRa8o4jlvjwk49R7/Z291i+4DQa7JJIgOm/TaYQZx+QrPQbVJ/9c8dMIZN8Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
@@ -538,9 +538,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-android-arm-eabi": {
|
||||
"version": "1.2.14",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.14.tgz",
|
||||
"integrity": "sha512-aWR+XKH9+rW7m5fWq/5360J3mHycrx/o6xZM2DQFdJnTMdyP7/WzxPjhtLbVWdKo2y612/4U1bWqb3HQhiF7vg==",
|
||||
"version": "1.2.15",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.15.tgz",
|
||||
"integrity": "sha512-aLcgK9gIJ3YRvrrtfSBbRa27F5WrpiTaPulnFpsHPxpUHgFlSgnU4854MHwoRGEBhfRJDWCS2WQ7lIjskE9Olw==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
@@ -556,9 +556,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-android-arm64": {
|
||||
"version": "1.2.14",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.14.tgz",
|
||||
"integrity": "sha512-QiVxHofl6A8lQU7WAtOUY4QMVZXohxwz7WJJoeh8NBEUyKfIkMRKhYyq8az/1RypNiIXzNTtiS6KwHz0erxYRQ==",
|
||||
"version": "1.2.15",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.15.tgz",
|
||||
"integrity": "sha512-xD7DdZCrbThKuxNg7ooQmvp07ys4J/pnSEMTG3/OdOWl6daC67m/Q4fL78sRHMxTH/N4fGUbYhcPM0MF5WFHkg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -574,9 +574,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-darwin-arm64": {
|
||||
"version": "1.2.14",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.14.tgz",
|
||||
"integrity": "sha512-lTQOzHuBIooh3jIr5XINQkjPGEuGy+Dsy0meXDr6Sjty0b99kYgGh4uBC/8mJX1T0kC8d6S0aPP1Y+bHkh6aBw==",
|
||||
"version": "1.2.15",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.15.tgz",
|
||||
"integrity": "sha512-m2WuQ6r0B4OTYxyPpNLtXaMRbRDUbG8YQV2Q3KTdSSK8Th6KHCnC8jVCJt8bvYeSB5WivkkNbc85mKadaSdOxA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -592,9 +592,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-darwin-x64": {
|
||||
"version": "1.2.14",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.14.tgz",
|
||||
"integrity": "sha512-hyMKEGatjmBYCs/Q4sbIcL/GqpFEqsX3wc+HYVQ/85A4rxc1o7rPm4DUI4GV4k/h0ITddwFSGpqQunI14GXjTA==",
|
||||
"version": "1.2.15",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.15.tgz",
|
||||
"integrity": "sha512-5RtnsHM36nodM9WbMy+zip0LGS/G+y5wro5RHrpOlr4ac24+1zSi9kiV2MP1myZeZ/ifEHfulQcWjnAXzc2C2A==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -610,9 +610,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-freebsd-x64": {
|
||||
"version": "1.2.14",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.14.tgz",
|
||||
"integrity": "sha512-u371GbXoVXrEgP20PwzoGNOdAOqAMhzo19JzzY+V4YmmnC+DTOoqdbMu+O+54emM59Mik/UdrtWgcuEDKWRPKA==",
|
||||
"version": "1.2.15",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.15.tgz",
|
||||
"integrity": "sha512-g3l09pyFqhu+135JKkbkwiKGQmwU5hUMUPJ4VdBADvuqR8d33L6H578rh8m7rtxbdp+VmX/+nzI7nvhSZlzpUQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -628,9 +628,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-arm-gnueabihf": {
|
||||
"version": "1.2.14",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.14.tgz",
|
||||
"integrity": "sha512-BqwKsdZQf6EqVWr8fv2MrDYzAvOZ7+NYFSJESRGs87fkseEhs0TU9TXgRJTkZoRRkxTizyEg1LKUXfU3/78uqg==",
|
||||
"version": "1.2.15",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.15.tgz",
|
||||
"integrity": "sha512-nIe3VugI97nGQtspPxDqA4sXMUpeiocKIP/CqpY/u/vgbT+alX+cVxu1C/oVBPKg4feNTmYovE91v4DgXvXNwQ==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
@@ -646,13 +646,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-arm64-gnu": {
|
||||
"version": "1.2.14",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.14.tgz",
|
||||
"integrity": "sha512-SD9kY8M009x/7s57Z1dgUVlP5+uYBgKQK0Tkr1WIWngrtUJmRCW1RJFMY7QDla2khXhmg2i5Io7MUjcmZNZ/KQ==",
|
||||
"version": "1.2.15",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.15.tgz",
|
||||
"integrity": "sha512-iTBjXx+B5QSvgI89lJfmqh4wTh8NabR1YZIIN3hNx4zSG0EVfVqkOvLulNpG4251pbd6/IAmZ/LefVk1Y4rPhw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -664,13 +667,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-arm64-musl": {
|
||||
"version": "1.2.14",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.14.tgz",
|
||||
"integrity": "sha512-BJ2CL19M6uuWesMgnqJzgxoI+N+eV7b0A9O9vLV42y+gitC41fNa8KElau1tHVzuLWX5GzEuxMPhAO0tQXQMkg==",
|
||||
"version": "1.2.15",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.15.tgz",
|
||||
"integrity": "sha512-uN26XOnVR7rqQupG3V9oAG2EGGatWjcJHOeDOHUFCh4TBWmP2tHTFoVN13aYbwfm0aHqJAEmcwhAu6sooIjfqQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -682,13 +688,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-ppc64-gnu": {
|
||||
"version": "1.2.14",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.14.tgz",
|
||||
"integrity": "sha512-4+Kl8daPV8NDHaD7mfCcc1pRM+Qg7ab8DyMm5FG5WoZgv4h2i3KFN1CybYBNbQqmuYm7ftJLvmMI1eHYJ22S/Q==",
|
||||
"version": "1.2.15",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.15.tgz",
|
||||
"integrity": "sha512-9nWkcdd+H+XhaA3fQIwpHnKIrfx/PQ6rO0pivr78Og07tXlUlctBBJoEYLPfhTmCR/QxEVdn6uPjHV5SymPhdw==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -700,13 +709,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-s390x-gnu": {
|
||||
"version": "1.2.14",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.14.tgz",
|
||||
"integrity": "sha512-V/h8ePq1fKDrmtFMz6xcCN3DKvuvMRrwwD1q0PkBeeo4DpK64biaKvqWGVxqCeYKqeRk7gE7/iKPxLCn08O97A==",
|
||||
"version": "1.2.15",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.15.tgz",
|
||||
"integrity": "sha512-Ggh0ciFjLwIusLSLz7LZYEaGdxOAUJXI+Qu5JCbhPJJHEt8vP+IjRATTJheiJsdzzTYgUiHUiw3X1Ea3ZpMBdA==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -718,13 +730,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-x64-gnu": {
|
||||
"version": "1.2.14",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.14.tgz",
|
||||
"integrity": "sha512-WxalXzgrLVpKC93xfpaJ3gCXUWzeCr+FRCbDVD2mUAy4L6Vo3lNVz0epUEUpxPWJkLNYUwM4OSIikv36OJ9mLg==",
|
||||
"version": "1.2.15",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.15.tgz",
|
||||
"integrity": "sha512-FHg60rS6DBePAYZ9X9F67x+JObFdmgn6tupA1C/a3B+CufFmTD4mX+e8M75tCiAO3foR/6xwVBI0KQiI7sn/rw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -736,13 +751,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-x64-musl": {
|
||||
"version": "1.2.14",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.14.tgz",
|
||||
"integrity": "sha512-3G3tnAmTpdk9jO3GQtyHXqeFD2SlecfTws/Y3M2mmj9nMBwwuLT4kur8sZKeu7ENUHSpVO3cIsm5A52LEWWwlg==",
|
||||
"version": "1.2.15",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.15.tgz",
|
||||
"integrity": "sha512-/cvV/7v5VGz/moiz0XKRj2dE8DzsKyhIu1KpOvJGPjRywg3yscON889UGvAe8zfiJLHup7VLjBduoGWi3HusWQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -754,9 +772,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-openharmony-arm64": {
|
||||
"version": "1.2.14",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.14.tgz",
|
||||
"integrity": "sha512-qyr5XiNGxm2ytx17vvtcJ9nXn6v1gENETi503bJx3OGwZ1tSonj7BosxbMa/xeAfSl5iS9X6KjEWYXLu6v1L7Q==",
|
||||
"version": "1.2.15",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.15.tgz",
|
||||
"integrity": "sha512-MyzC5Hrv/sm+Z7TSGKP/j8001RO2xQm1zOpCNVL4j0cY/VhBZviludA2YFQ7i4lH6ZAXxQm0lcMdBzM+99mE7w==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -772,9 +790,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-win32-arm64-msvc": {
|
||||
"version": "1.2.14",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.14.tgz",
|
||||
"integrity": "sha512-oA4o5o0S2yB199YyYXDzKUv1Z56Rc+mgOjwm7Z/vJ9Z8igiDPYvoOuDyo6g7AK/ZaF09nPhDKVnGBcYHO11GXA==",
|
||||
"version": "1.2.15",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.15.tgz",
|
||||
"integrity": "sha512-+PgYiS+AH5omy6L45ZLxybasauJaj3M8buoT83D+nzPmCayXlxUV9hydhciCKVyIg0B/v0BtXPDE1uGqNwCPpg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -790,9 +808,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-win32-x64-msvc": {
|
||||
"version": "1.2.14",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.14.tgz",
|
||||
"integrity": "sha512-9mE94bf2GZUaR/fUSXCMpBoWkctu421Mi0ORpm4zqfbkjKep7RxzPFa5etcGs5h+2KApoWBOA/hAJa/7saGgEQ==",
|
||||
"version": "1.2.15",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.15.tgz",
|
||||
"integrity": "sha512-HFZd2QNQYn1JbIi+Hpd7H+r/lkWxcfIu28nirZ6eCn2LafuvmExMlb/P96Sfx5tf7pPArw39IdhmCF8vmV+NhQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -2927,9 +2945,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/on-finished": {
|
||||
"version": "2.4.1",
|
||||
"resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
|
||||
"integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==",
|
||||
"version": "2.4.2",
|
||||
"resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.2.tgz",
|
||||
"integrity": "sha512-ATWUv387i+nLD49EiDoOft5scf32Vsdz3ktrQJG9bi7jRFmaXTssvyvj1EmeKVVgvesbqUtvQQvaS2TpTukAZQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
@@ -2937,6 +2955,10 @@
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/once": {
|
||||
@@ -3231,14 +3253,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/rolldown": {
|
||||
"version": "1.2.14",
|
||||
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.14.tgz",
|
||||
"integrity": "sha512-eTBTTqMR6uHKLgcUqpJP8ntzDI6V4LYA8negAMBEI7gkgVvOdrx6VsvS2Ym+83KhOPdLCHWJu0w7UQFJZhHKNw==",
|
||||
"version": "1.2.15",
|
||||
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.15.tgz",
|
||||
"integrity": "sha512-6odND+fel5zVw3I4Yc2e69gvguDnX0CAAGiNDZSq4ik5WUcaLrkD2GLuog0XcODbor31hZ5ayEMgVDno+KPV5g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@oxc-project/types": "=0.154.0",
|
||||
"@oxc-project/types": "=0.155.0",
|
||||
"@rolldown/pluginutils": "^1.0.0"
|
||||
},
|
||||
"bin": {
|
||||
@@ -3248,21 +3270,21 @@
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@rolldown/binding-android-arm-eabi": "1.2.14",
|
||||
"@rolldown/binding-android-arm64": "1.2.14",
|
||||
"@rolldown/binding-darwin-arm64": "1.2.14",
|
||||
"@rolldown/binding-darwin-x64": "1.2.14",
|
||||
"@rolldown/binding-freebsd-x64": "1.2.14",
|
||||
"@rolldown/binding-linux-arm-gnueabihf": "1.2.14",
|
||||
"@rolldown/binding-linux-arm64-gnu": "1.2.14",
|
||||
"@rolldown/binding-linux-arm64-musl": "1.2.14",
|
||||
"@rolldown/binding-linux-ppc64-gnu": "1.2.14",
|
||||
"@rolldown/binding-linux-s390x-gnu": "1.2.14",
|
||||
"@rolldown/binding-linux-x64-gnu": "1.2.14",
|
||||
"@rolldown/binding-linux-x64-musl": "1.2.14",
|
||||
"@rolldown/binding-openharmony-arm64": "1.2.14",
|
||||
"@rolldown/binding-win32-arm64-msvc": "1.2.14",
|
||||
"@rolldown/binding-win32-x64-msvc": "1.2.14"
|
||||
"@rolldown/binding-android-arm-eabi": "1.2.15",
|
||||
"@rolldown/binding-android-arm64": "1.2.15",
|
||||
"@rolldown/binding-darwin-arm64": "1.2.15",
|
||||
"@rolldown/binding-darwin-x64": "1.2.15",
|
||||
"@rolldown/binding-freebsd-x64": "1.2.15",
|
||||
"@rolldown/binding-linux-arm-gnueabihf": "1.2.15",
|
||||
"@rolldown/binding-linux-arm64-gnu": "1.2.15",
|
||||
"@rolldown/binding-linux-arm64-musl": "1.2.15",
|
||||
"@rolldown/binding-linux-ppc64-gnu": "1.2.15",
|
||||
"@rolldown/binding-linux-s390x-gnu": "1.2.15",
|
||||
"@rolldown/binding-linux-x64-gnu": "1.2.15",
|
||||
"@rolldown/binding-linux-x64-musl": "1.2.15",
|
||||
"@rolldown/binding-openharmony-arm64": "1.2.15",
|
||||
"@rolldown/binding-win32-arm64-msvc": "1.2.15",
|
||||
"@rolldown/binding-win32-x64-msvc": "1.2.15"
|
||||
}
|
||||
},
|
||||
"node_modules/router": {
|
||||
@@ -3655,17 +3677,17 @@
|
||||
}
|
||||
},
|
||||
"node_modules/vite": {
|
||||
"version": "8.3.4",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-8.3.4.tgz",
|
||||
"integrity": "sha512-IftLE8phJhdNRIGNEV4JCCvfNScoZQVfcXaN8TQropWGJtOHcu+iAB9pHcuGvmEb/kddw0LBe/jS8ztdC6xdiQ==",
|
||||
"version": "8.3.5",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-8.3.5.tgz",
|
||||
"integrity": "sha512-z1YpjieJ9g0ebTmQpLBv/oq9E3F9EIlOe+7LahW8SrE3LTiG3udBRUXQOlYXP9b+IC2q2phHeVxj9xGlw34OEQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"lightningcss": "^1.33.0",
|
||||
"picomatch": "^4.0.7",
|
||||
"postcss": "^8.5.29",
|
||||
"rolldown": "~1.2.12",
|
||||
"postcss": "^8.5.30",
|
||||
"rolldown": "~1.2.14",
|
||||
"tinyglobby": "^0.2.17"
|
||||
},
|
||||
"bin": {
|
||||
@@ -3682,7 +3704,7 @@
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/node": "^20.19.0 || >=22.12.0",
|
||||
"@vitejs/devtools": "^0.7.1",
|
||||
"@vitejs/devtools": "^0.7.1 || ^0.8.0",
|
||||
"esbuild": "^0.27.0 || ^0.28.0",
|
||||
"jiti": ">=1.21.0",
|
||||
"less": "^4.0.0",
|
||||
|
||||
@@ -147,7 +147,11 @@ import {
|
||||
withAirMeta,
|
||||
} from "./air-extension.js";
|
||||
import * as sessionIndex from "./session-index/connection.js";
|
||||
import type { RenameSessionRequest, SessionIdRequest } from "./session-index/service.js";
|
||||
import type {
|
||||
BatchGetSessionsRequest,
|
||||
RenameSessionRequest,
|
||||
SessionIdRequest,
|
||||
} from "./session-index/service.js";
|
||||
import type {
|
||||
ListSubscribeRequest,
|
||||
ListUnsubscribeRequest,
|
||||
@@ -3249,7 +3253,7 @@ export class ClaudeAcpAgent {
|
||||
return this.sessionIndex.clientTitle(title);
|
||||
}
|
||||
|
||||
/** `_session/list/subscribe`, `_session/list/unsubscribe`, `_session/rename`,
|
||||
/** `_session/batch_get`, `_session/list/subscribe`, `_session/list/unsubscribe`, `_session/rename`,
|
||||
* `_session/archive`, `_session/unarchive`. */
|
||||
async subscribeSessionList(params: ListSubscribeRequest) {
|
||||
return this.sessionIndex.subscribeList(params);
|
||||
@@ -3257,6 +3261,9 @@ export class ClaudeAcpAgent {
|
||||
async unsubscribeSessionList(params: ListUnsubscribeRequest) {
|
||||
return this.sessionIndex.unsubscribeList(params);
|
||||
}
|
||||
async batchGetSessions(params: BatchGetSessionsRequest) {
|
||||
return this.sessionIndex.batchGet(params);
|
||||
}
|
||||
renameSessionTitle(params: RenameSessionRequest) {
|
||||
return this.sessionIndex.rename(params);
|
||||
}
|
||||
|
||||
@@ -22,6 +22,9 @@ export const AIR_PLAN_FILE_CAPABILITY = "planFile";
|
||||
* subscription. Advertised only to a client that declares it.
|
||||
*/
|
||||
export const AIR_SESSION_INDEX_CAPABILITY = "sessionIndex";
|
||||
/** Metadata lookup of known ids with `_session/batch_get`. Advertised
|
||||
* exactly when {@link AIR_SESSION_INDEX_CAPABILITY} is negotiated. */
|
||||
export const AIR_SESSION_BATCH_GET_CAPABILITY = "sessionBatchGet";
|
||||
/**
|
||||
* The agent supports `_session/archive` and `_session/unarchive`. Advertised
|
||||
* exactly when {@link AIR_SESSION_INDEX_CAPABILITY} is; the client does not
|
||||
|
||||
@@ -20,6 +20,7 @@ import type { Query } from "@anthropic-ai/claude-agent-sdk";
|
||||
import type { AcpClient, Logger, Session } from "../acp-agent.js";
|
||||
import {
|
||||
AIR_SESSION_ARCHIVE_CAPABILITY,
|
||||
AIR_SESSION_BATCH_GET_CAPABILITY,
|
||||
AIR_SESSION_INDEX_CAPABILITY,
|
||||
AIR_SESSION_LIST_SUBSCRIBE_CAPABILITY,
|
||||
AIR_SESSION_RENAME_CAPABILITY,
|
||||
@@ -46,6 +47,8 @@ import {
|
||||
renameTo,
|
||||
parseRenameSessionRequest,
|
||||
parseSessionIdRequest,
|
||||
parseBatchGetSessionsRequest,
|
||||
SESSION_BATCH_GET_METHOD,
|
||||
SESSION_ARCHIVE_METHOD,
|
||||
SESSION_RENAME_METHOD,
|
||||
SESSION_UNARCHIVE_METHOD,
|
||||
@@ -54,6 +57,8 @@ import {
|
||||
type RetitleOptions,
|
||||
type SessionIdRequest,
|
||||
type TitleChange,
|
||||
type BatchGetSessionsRequest,
|
||||
type BatchGetSessionsResponse,
|
||||
} from "./service.js";
|
||||
|
||||
/** What the session index keeps on each `Session`. */
|
||||
@@ -144,12 +149,13 @@ export class SessionIndexConnection {
|
||||
}
|
||||
|
||||
/** The AIR capabilities the agent advertises for the index: the index
|
||||
* itself, archive, rename, the list subscription and the `agentWorktree`
|
||||
* itself, batch get, archive, rename, the list subscription and the `agentWorktree`
|
||||
* row field, all only to a client that declared `sessionIndex`. */
|
||||
capabilities(): string[] {
|
||||
return this.service
|
||||
? [
|
||||
AIR_SESSION_INDEX_CAPABILITY,
|
||||
AIR_SESSION_BATCH_GET_CAPABILITY,
|
||||
AIR_SESSION_ARCHIVE_CAPABILITY,
|
||||
AIR_SESSION_RENAME_CAPABILITY,
|
||||
AIR_SESSION_LIST_SUBSCRIBE_CAPABILITY,
|
||||
@@ -230,6 +236,12 @@ export class SessionIndexConnection {
|
||||
return this.service?.list(params, (sessionId) => this.ownSessionState(sessionId));
|
||||
}
|
||||
|
||||
batchGet(params: BatchGetSessionsRequest): Promise<BatchGetSessionsResponse> {
|
||||
return this.requireService(SESSION_BATCH_GET_METHOD).batchGet(params, (sessionId) =>
|
||||
this.ownSessionState(sessionId),
|
||||
);
|
||||
}
|
||||
|
||||
/** An AIR client archives with session/delete (see {@link deleteSession}):
|
||||
* its archived sessions (an archived custom title) stay hidden from the
|
||||
* old list, as when the delete removed them. */
|
||||
@@ -448,6 +460,7 @@ export class SessionIndexConnection {
|
||||
|
||||
/** The agent methods behind the session index extension methods. */
|
||||
type SessionIndexMethods = {
|
||||
batchGetSessions(params: BatchGetSessionsRequest): Promise<BatchGetSessionsResponse>;
|
||||
subscribeSessionList(params: ListSubscribeRequest): Promise<ListSubscribeResponse>;
|
||||
unsubscribeSessionList(params: ListUnsubscribeRequest): Promise<EmptyResponse>;
|
||||
renameSessionTitle(params: RenameSessionRequest): Promise<EmptyResponse>;
|
||||
@@ -455,10 +468,15 @@ type SessionIndexMethods = {
|
||||
unarchiveSession(params: SessionIdRequest): Promise<EmptyResponse>;
|
||||
};
|
||||
|
||||
/** Routes `_session/list/subscribe`, `_session/list/unsubscribe`,
|
||||
/** Routes `_session/batch_get`, `_session/list/subscribe`, `_session/list/unsubscribe`,
|
||||
* `_session/rename`, `_session/archive` and `_session/unarchive`. */
|
||||
export function onSessionIndexRequests(app: AgentApp, agent: () => SessionIndexMethods): AgentApp {
|
||||
return app
|
||||
.onRequest<BatchGetSessionsRequest, BatchGetSessionsResponse>(
|
||||
SESSION_BATCH_GET_METHOD,
|
||||
{ parse: parseBatchGetSessionsRequest },
|
||||
(ctx) => agent().batchGetSessions(ctx.params),
|
||||
)
|
||||
.onRequest<ListSubscribeRequest, ListSubscribeResponse>(
|
||||
LIST_SUBSCRIBE_METHOD,
|
||||
{ parse: parseListSubscribeRequest },
|
||||
|
||||
@@ -104,6 +104,17 @@ export async function canonicalPath(cwd: string): Promise<string> {
|
||||
}
|
||||
}
|
||||
|
||||
/** Whether the path exists, including a symlink whose target is missing. */
|
||||
export async function exists(target: string): Promise<boolean> {
|
||||
try {
|
||||
await fs.lstat(target);
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (errorCode(error) === "ENOENT" || errorCode(error) === "ENOTDIR") return false;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/** The names of the project directories of `cwd` among `rootEntries`. */
|
||||
export function projectDirsOf(cwd: string, rootEntries: readonly string[]): string[] {
|
||||
const exact = encodeProjectPath(cwd);
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
RequestError,
|
||||
type ListSessionsRequest,
|
||||
type ListSessionsResponse,
|
||||
type SessionInfo,
|
||||
} from "@agentclientprotocol/sdk/unstable";
|
||||
import { deleteSession as sdkDeleteSession } from "@anthropic-ai/claude-agent-sdk";
|
||||
import { airExtensionMeta } from "../air-extension.js";
|
||||
@@ -37,7 +38,7 @@ import {
|
||||
import { LiveSessionRegistry } from "./live-registry.js";
|
||||
import {
|
||||
canonicalPath,
|
||||
errorCode,
|
||||
exists,
|
||||
isExactProjectDir,
|
||||
isSessionId,
|
||||
projectDirMatches,
|
||||
@@ -65,6 +66,7 @@ import { sessionInfoOf } from "./session-info.js";
|
||||
export const SESSION_RENAME_METHOD = "_session/rename";
|
||||
export const SESSION_ARCHIVE_METHOD = "_session/archive";
|
||||
export const SESSION_UNARCHIVE_METHOD = "_session/unarchive";
|
||||
export const SESSION_BATCH_GET_METHOD = "_session/batch_get";
|
||||
|
||||
/** The JSON-RPC code of an unknown session (ACP `ResourceNotFound`). */
|
||||
const RESOURCE_NOT_FOUND = -32002;
|
||||
@@ -73,6 +75,27 @@ const CURSOR_VERSION = 5;
|
||||
|
||||
export type SessionIdRequest = { sessionId: string };
|
||||
export type RenameSessionRequest = { sessionId: string; title: string };
|
||||
export type BatchGetSessionsRequest = { sessionIds: string[] };
|
||||
export type BatchGetSessionsResponse = {
|
||||
sessions: SessionInfo[];
|
||||
};
|
||||
|
||||
export function parseBatchGetSessionsRequest(value: unknown): BatchGetSessionsRequest {
|
||||
const record = asRecord(value);
|
||||
const ids = record.sessionIds;
|
||||
if (
|
||||
Object.keys(record).length !== 1 ||
|
||||
!Array.isArray(ids) ||
|
||||
ids.some((id) => typeof id !== "string" || !id.trim()) ||
|
||||
new Set(ids).size !== ids.length
|
||||
) {
|
||||
throw RequestError.invalidParams(
|
||||
undefined,
|
||||
"params require only sessionIds: distinct, non-blank strings",
|
||||
);
|
||||
}
|
||||
return { sessionIds: ids };
|
||||
}
|
||||
|
||||
export function sessionNotFound(sessionId: string): RequestError {
|
||||
return new RequestError(RESOURCE_NOT_FOUND, `Session not found: ${sessionId}`, { sessionId });
|
||||
@@ -364,16 +387,6 @@ function isSdkNotFound(error: unknown): boolean {
|
||||
return error instanceof Error && /^Session \S+ not found in /.test(error.message);
|
||||
}
|
||||
|
||||
async function exists(target: string): Promise<boolean> {
|
||||
try {
|
||||
await fs.lstat(target);
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (errorCode(error) === "ENOENT" || errorCode(error) === "ENOTDIR") return false;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/** The error the SDK `deleteSession` gives for a session id it rejects or
|
||||
* does not find, which a client without `sessionIndex` got before. */
|
||||
function sdkDeleteError(sessionId: string): Error {
|
||||
@@ -520,6 +533,22 @@ export class SessionIndexService {
|
||||
return hasMore && last ? { sessions, nextCursor: encodeListCursor(last, scope) } : { sessions };
|
||||
}
|
||||
|
||||
/** Metadata for known ids, independent of list pages and project filters. */
|
||||
async batchGet(
|
||||
{ sessionIds }: BatchGetSessionsRequest,
|
||||
own: (sessionId: string) => OwnSessionState | undefined,
|
||||
): Promise<BatchGetSessionsResponse> {
|
||||
if (sessionIds.length === 0) return { sessions: [] };
|
||||
const live = await this.registry.snapshot();
|
||||
const now = this.now();
|
||||
const rows = await this.index.getSessions(sessionIds);
|
||||
return {
|
||||
sessions: rows.map((row) =>
|
||||
sessionInfoOf(row, own(row.sessionId), live.get(row.sessionId), now),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
/** Runs `mutation` after the previous mutation of the session ended. */
|
||||
private exclusive<T>(sessionId: string, mutation: () => Promise<T>): Promise<T> {
|
||||
const previous = this.mutations.get(sessionId) ?? Promise.resolve();
|
||||
|
||||
@@ -26,6 +26,8 @@ import { effectiveTitle, visibleTitle } from "./archive-title.js";
|
||||
import {
|
||||
canonicalPath,
|
||||
encodeProjectPath,
|
||||
errorCode,
|
||||
exists,
|
||||
isExactProjectDir,
|
||||
isSessionId,
|
||||
normalizePath,
|
||||
@@ -58,11 +60,15 @@ import { isInLinkedWorktree, worktreeCounterparts } from "./worktrees.js";
|
||||
|
||||
export const DEFAULT_LIST_LIMIT = 50;
|
||||
export const MAX_LIST_LIMIT = 200;
|
||||
/** Discovery could not establish which sessions the store contains. */
|
||||
export class SessionStoreUnavailableError extends Error {}
|
||||
/** Entries of the metadata cache: a few hundred bytes each, so the
|
||||
* transcripts of a large history (tens of thousands) stay cached and deep
|
||||
* pages do not read them again. */
|
||||
const METADATA_CACHE_SIZE = 50_000;
|
||||
const READ_BATCH_SIZE = 16;
|
||||
/** Session ids looked up together by {@link SessionIndex.getSessions}. */
|
||||
const LOOKUP_CHUNK_SIZE = 100;
|
||||
/** Transcripts read at most per directory to recover a sibling's cwd. */
|
||||
const MAX_CWD_PROBES_PER_DIR = 64;
|
||||
/** How long a directory that gave no cwd is not probed again. */
|
||||
@@ -97,6 +103,24 @@ export type TranscriptCandidate = {
|
||||
ino: number;
|
||||
};
|
||||
|
||||
type TranscriptFile = Pick<
|
||||
TranscriptCandidate,
|
||||
"sessionId" | "filePath" | "dirName" | "projectPath"
|
||||
>;
|
||||
|
||||
/** Non-empty transcript files, with bounded stats shared by lists and id lookups. */
|
||||
async function transcriptCandidates(
|
||||
files: readonly TranscriptFile[],
|
||||
): Promise<TranscriptCandidate[]> {
|
||||
const stats = await statFiles(files.map(({ filePath }) => filePath));
|
||||
return files.flatMap((file, i) => {
|
||||
const current = stats[i];
|
||||
return current?.isFile() && current.size > 0
|
||||
? [{ ...file, mtimeMs: current.mtimeMs, size: current.size, ino: current.ino }]
|
||||
: [];
|
||||
});
|
||||
}
|
||||
|
||||
/** The cached metadata of one transcript. */
|
||||
type TranscriptMetadata = {
|
||||
/** The visible title: without the archive prefix. */
|
||||
@@ -526,25 +550,16 @@ export class SessionIndex {
|
||||
const listed = await this.listings.namesOfAll(dirPaths);
|
||||
const files = dirs.flatMap(({ dirName, projectPath }, i) =>
|
||||
listed[i]!.filter((name) => name.endsWith(".jsonl") && isSessionId(name.slice(0, -6))).map(
|
||||
(name) => ({ name, filePath: path.join(root, dirName, name), dirName, projectPath }),
|
||||
(name) => ({
|
||||
sessionId: name.slice(0, -6),
|
||||
filePath: path.join(root, dirName, name),
|
||||
dirName,
|
||||
projectPath,
|
||||
}),
|
||||
),
|
||||
);
|
||||
const stats = await statFiles(files.map(({ filePath }) => filePath));
|
||||
const all: TranscriptCandidate[] = [];
|
||||
files.forEach(({ name, filePath, dirName, projectPath }, i) => {
|
||||
const fileStats = stats[i];
|
||||
if (!fileStats?.isFile() || fileStats.size === 0) return;
|
||||
all.push({
|
||||
sessionId: name.slice(0, -6),
|
||||
filePath,
|
||||
dirName,
|
||||
projectPath,
|
||||
mtimeMs: fileStats.mtimeMs,
|
||||
size: fileStats.size,
|
||||
ino: fileStats.ino,
|
||||
});
|
||||
});
|
||||
return { dirs: [root, ...dirPaths], names: [rootNames, ...listed], candidates: all };
|
||||
const candidates = await transcriptCandidates(files);
|
||||
return { dirs: [root, ...dirPaths], names: [rootNames, ...listed], candidates };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -580,6 +595,90 @@ export class SessionIndex {
|
||||
};
|
||||
}
|
||||
|
||||
/** Known ids in request order, using the same rows as the list.
|
||||
* Addresses exact filenames; only rows without a cwd need sibling discovery. */
|
||||
async getSessions(sessionIds: readonly string[]): Promise<IndexRow[]> {
|
||||
if (sessionIds.length === 0) return [];
|
||||
const root = projectsRoot();
|
||||
let projectEntries: string[];
|
||||
try {
|
||||
projectEntries = await fs.readdir(root);
|
||||
} catch (cause) {
|
||||
if (errorCode(cause) === "ENOENT" && !(await exists(root))) {
|
||||
return [];
|
||||
}
|
||||
throw new SessionStoreUnavailableError("Session store could not be read", { cause });
|
||||
}
|
||||
const ids = sessionIds.filter(isSessionId);
|
||||
const byId = new Map<string, IndexRow>();
|
||||
// Paths are project directories × ids: bound them per chunk, not per request.
|
||||
for (let start = 0; start < ids.length; start += LOOKUP_CHUNK_SIZE) {
|
||||
const chunk = ids.slice(start, start + LOOKUP_CHUNK_SIZE);
|
||||
for (const row of await this.lookupRows(root, projectEntries, chunk)) {
|
||||
byId.set(row.sessionId, row);
|
||||
}
|
||||
}
|
||||
return sessionIds.flatMap((id) => {
|
||||
const row = byId.get(id);
|
||||
return row ? [row] : [];
|
||||
});
|
||||
}
|
||||
|
||||
/** The rows of `ids` found under `projectEntries`. All copies of an id are
|
||||
* in the same call, so copy selection is unchanged by chunking. */
|
||||
private async lookupRows(
|
||||
root: string,
|
||||
projectEntries: readonly string[],
|
||||
ids: readonly string[],
|
||||
): Promise<IndexRow[]> {
|
||||
let candidates = await transcriptCandidates(
|
||||
projectEntries.flatMap((dirName) =>
|
||||
ids.map((sessionId) => ({
|
||||
sessionId,
|
||||
filePath: path.join(root, dirName, `${sessionId}.jsonl`),
|
||||
dirName,
|
||||
})),
|
||||
),
|
||||
);
|
||||
if (CASE_INSENSITIVE_PATHS) {
|
||||
// On a case-insensitive file system, stat of `ABC.jsonl` also finds
|
||||
// `abc.jsonl`. Keep a file only when its real name is the requested one,
|
||||
// or differs by more than case (a symlink, as the list would read it).
|
||||
// A failed realpath means the file is unreadable, so the id is omitted.
|
||||
const exact = await Promise.all(
|
||||
candidates.map(async ({ filePath }) => {
|
||||
try {
|
||||
const actual = path.basename(await fs.realpath(filePath));
|
||||
const expected = path.basename(filePath);
|
||||
return actual === expected || actual.toLowerCase() !== expected.toLowerCase();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}),
|
||||
);
|
||||
candidates = candidates.filter((_, i) => exact[i]);
|
||||
}
|
||||
return this.collectRows(candidates.sort(byMtimeDescending), {
|
||||
inScope: scopeOf(undefined),
|
||||
archived: "all",
|
||||
wanted: Infinity,
|
||||
unread: async (read, dirNames) => {
|
||||
const dirs = [...dirNames];
|
||||
const listed = await this.listings.namesOfAll(dirs.map((dir) => path.join(root, dir)));
|
||||
const siblings = dirs.flatMap((dirName, i) =>
|
||||
listed[i]!.filter((name) => name.endsWith(".jsonl") && isSessionId(name.slice(0, -6)))
|
||||
.map((name) => ({
|
||||
sessionId: name.slice(0, -6),
|
||||
filePath: path.join(root, dirName, name),
|
||||
dirName,
|
||||
}))
|
||||
.filter(({ filePath }) => !read.has(filePath)),
|
||||
);
|
||||
return (await transcriptCandidates(siblings)).sort(byMtimeDescending);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** Every transcript file of `sessionId` (see {@link scanSession}). Empty
|
||||
* files only with `includeEmpty`. */
|
||||
async findTranscripts(
|
||||
@@ -778,9 +877,12 @@ export class SessionIndex {
|
||||
archived: ArchivedFilter;
|
||||
after?: ListCursor;
|
||||
wanted: number;
|
||||
/** The transcripts not read (by path), which may supply a sibling's
|
||||
* cwd. */
|
||||
unread: (read: ReadonlySet<string>) => readonly TranscriptCandidate[];
|
||||
/** The unread transcripts of the directories still eligible for cwd
|
||||
* recovery. Called only after checking the negative cache. */
|
||||
unread?: (
|
||||
read: ReadonlySet<string>,
|
||||
dirNames: ReadonlySet<string>,
|
||||
) => readonly TranscriptCandidate[] | Promise<readonly TranscriptCandidate[]>;
|
||||
restat?: boolean;
|
||||
},
|
||||
): Promise<IndexRow[]> {
|
||||
@@ -852,14 +954,15 @@ export class SessionIndex {
|
||||
}
|
||||
await settlePending();
|
||||
}
|
||||
if (pending.length > 0) {
|
||||
const unread = options.unread;
|
||||
if (pending.length > 0 && unread) {
|
||||
// A row recovered after the scan stopped still sorts into the page: it
|
||||
// was read before the stop, and more rows only raise the bound. A
|
||||
// sibling that the archive filter or the cursor leaves out may still
|
||||
// supply the cwd.
|
||||
await this.learnDirCwds(
|
||||
new Set(pending.map(({ candidate }) => candidate.dirName)),
|
||||
options.unread(read),
|
||||
(dirNames) => unread(read, dirNames),
|
||||
options.restat ?? false,
|
||||
);
|
||||
await settlePending();
|
||||
@@ -893,7 +996,9 @@ export class SessionIndex {
|
||||
* changes. `restat` as in {@link collectRows}. */
|
||||
private async learnDirCwds(
|
||||
dirNames: ReadonlySet<string>,
|
||||
unread: readonly TranscriptCandidate[],
|
||||
unread: (
|
||||
dirNames: ReadonlySet<string>,
|
||||
) => readonly TranscriptCandidate[] | Promise<readonly TranscriptCandidate[]>,
|
||||
restatFirst: boolean,
|
||||
): Promise<void> {
|
||||
const root = projectsRoot();
|
||||
@@ -909,8 +1014,9 @@ export class SessionIndex {
|
||||
if (known && known.mtimeMs === mtimeMs && now - known.at < NO_CWD_RETRY_MS) continue;
|
||||
mtimes.set(dirName, mtimeMs);
|
||||
}
|
||||
if (mtimes.size === 0) return;
|
||||
const probes = new Map<string, number>();
|
||||
let remaining = unread.filter((candidate) => {
|
||||
let remaining = (await unread(new Set(mtimes.keys()))).filter((candidate) => {
|
||||
if (!mtimes.has(candidate.dirName)) return false;
|
||||
const count = probes.get(candidate.dirName) ?? 0;
|
||||
if (count >= MAX_CWD_PROBES_PER_DIR) return false;
|
||||
|
||||
@@ -238,19 +238,18 @@ it.skipIf(!process.env.RUN_INTEGRATION_TESTS)(
|
||||
const q = query({ prompt: input(), options: { persistSession: false, abortController } });
|
||||
try {
|
||||
const { models } = await q.initializationResult();
|
||||
const opusModels = models.filter(
|
||||
(model) =>
|
||||
model.value !== "default" &&
|
||||
/\bopus\b/i.test(`${model.value} ${model.resolvedModel} ${model.displayName}`),
|
||||
);
|
||||
// Only alias rows ("opus", "opus[1m]"). The catalogue also lists pinned
|
||||
// older versions such as "claude-opus-4-8"; those are separate models.
|
||||
const opusModels = models.filter((model) => /^opus\b/i.test(model.value));
|
||||
expect(opusModels).toHaveLength(1);
|
||||
// CLI 2.1.283 dropped the "[1m]" / "(1M context)" spelling from the Opus
|
||||
// row: Opus is natively 1M, so the row names the plain model.
|
||||
expect(opusModels[0]).toMatchObject({
|
||||
value: "opus",
|
||||
displayName: "Opus",
|
||||
displayName: expect.stringMatching(/^Opus\b/),
|
||||
resolvedModel: expect.stringMatching(/^claude-opus-[\d-]+$/),
|
||||
});
|
||||
expect(opusModels[0].displayName).not.toMatch(/\b1m\b/i);
|
||||
expect(opusModels[0].description).not.toMatch(/\b1m\b/i);
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
|
||||
@@ -16,13 +16,18 @@ import {
|
||||
listSessions,
|
||||
} from "@anthropic-ai/claude-agent-sdk";
|
||||
import { spawn } from "node:child_process";
|
||||
import { ClaudeAcpAgent, type AcpClient } from "../acp-agent.js";
|
||||
import type { SessionInfo } from "@agentclientprotocol/sdk/unstable";
|
||||
import { ClaudeAcpAgent, v1AgentApp, type AcpClient } from "../acp-agent.js";
|
||||
import {
|
||||
client as acpClient,
|
||||
type InitializeResponse,
|
||||
type SessionInfo,
|
||||
} from "@agentclientprotocol/sdk/unstable";
|
||||
import { encodeProjectPath } from "../session-index/project-dirs.js";
|
||||
import {
|
||||
archiveInsteadOfDelete,
|
||||
archiveTo,
|
||||
renameTo,
|
||||
parseBatchGetSessionsRequest,
|
||||
SessionIndexService,
|
||||
writeCustomTitleSidecar,
|
||||
} from "../session-index/service.js";
|
||||
@@ -45,6 +50,11 @@ import {
|
||||
} from "./session-doubles.js";
|
||||
import { Pushable } from "../utils.js";
|
||||
|
||||
vi.mock("node:fs/promises", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof fs>();
|
||||
return { ...actual, readdir: vi.fn(actual.readdir) };
|
||||
});
|
||||
|
||||
vi.mock("@anthropic-ai/claude-agent-sdk", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("@anthropic-ai/claude-agent-sdk")>();
|
||||
return {
|
||||
@@ -69,10 +79,15 @@ beforeEach(async () => {
|
||||
configDir = fsSync.realpathSync(await fs.mkdtemp(path.join(os.tmpdir(), "session-index-cfg-")));
|
||||
workspace = fsSync.realpathSync(await fs.mkdtemp(path.join(os.tmpdir(), "session-index-ws-")));
|
||||
process.env.CLAUDE_CONFIG_DIR = configDir;
|
||||
// Integration runs leave CLAUDE_CODE_EXECUTABLE unset, so initialize would run
|
||||
// the real `claude auth status` in the background. It writes into configDir
|
||||
// while afterEach removes it, and the rm fails with ENOTEMPTY.
|
||||
vi.stubEnv("CLAUDE_CODE_EXECUTABLE", "/usr/bin/false");
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
vi.unstubAllEnvs();
|
||||
if (previousConfigDir === undefined) delete process.env.CLAUDE_CONFIG_DIR;
|
||||
else process.env.CLAUDE_CONFIG_DIR = previousConfigDir;
|
||||
await fs.rm(configDir, { recursive: true, force: true });
|
||||
@@ -257,6 +272,7 @@ const airCapabilities = (response: { _meta?: Record<string, unknown> | null }) =
|
||||
describe("sessionIndex negotiation", () => {
|
||||
const indexCapabilities = [
|
||||
"sessionIndex",
|
||||
"sessionBatchGet",
|
||||
"sessionArchive",
|
||||
"sessionRename",
|
||||
"sessionListSubscribe",
|
||||
@@ -290,7 +306,7 @@ describe("sessionIndex negotiation", () => {
|
||||
// Declaring archive or rename alone enables nothing.
|
||||
const withoutIndex = await createAgent().agent.initialize({
|
||||
protocolVersion: 1,
|
||||
clientCapabilities: air("sessionArchive", "sessionRename"),
|
||||
clientCapabilities: air("sessionArchive", "sessionRename", "sessionBatchGet"),
|
||||
});
|
||||
expect(airCapabilities(withoutIndex)).toEqual(baseline);
|
||||
|
||||
@@ -314,7 +330,7 @@ describe("sessionIndex negotiation", () => {
|
||||
it("answers method-not-found to the new methods without the capability", async () => {
|
||||
// Declaring sessionArchive or sessionRename without sessionIndex enables
|
||||
// nothing either.
|
||||
for (const capabilities of [[], ["sessionArchive", "sessionRename"]]) {
|
||||
for (const capabilities of [[], ["sessionArchive", "sessionRename", "sessionBatchGet"]]) {
|
||||
const { agent } = createAgent();
|
||||
await initializeClient(agent, air(...capabilities));
|
||||
const sessionId = randomUUID();
|
||||
@@ -323,10 +339,423 @@ describe("sessionIndex negotiation", () => {
|
||||
});
|
||||
await expect(agent.archiveSession({ sessionId })).rejects.toMatchObject({ code: -32601 });
|
||||
await expect(agent.unarchiveSession({ sessionId })).rejects.toMatchObject({ code: -32601 });
|
||||
await expect(agent.batchGetSessions({ sessionIds: [sessionId] })).rejects.toMatchObject({
|
||||
code: -32601,
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("_session/batch_get", () => {
|
||||
it("routes and validates the extension through the ACP connection", async () => {
|
||||
let agent!: ClaudeAcpAgent;
|
||||
const connection = acpClient({ name: "batch-get-test" }).connect(
|
||||
v1AgentApp({ log: () => {}, error: () => {} }, (created) => {
|
||||
agent = created;
|
||||
}),
|
||||
);
|
||||
try {
|
||||
const response = await connection.agent.request<InitializeResponse>("initialize", {
|
||||
protocolVersion: 1,
|
||||
clientCapabilities: air("sessionIndex"),
|
||||
});
|
||||
expect(airCapabilities(response)).toContain("sessionBatchGet");
|
||||
expect(await connection.agent.request("_session/batch_get", { sessionIds: [] })).toEqual({
|
||||
sessions: [],
|
||||
});
|
||||
await expect(
|
||||
connection.agent.request("_session/batch_get", { sessionIds: [], cwd: "/repo" }),
|
||||
).rejects.toMatchObject({ code: -32602 });
|
||||
} finally {
|
||||
connection.close();
|
||||
await agent.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
it("accepts only distinct, non-blank string ids and no other fields", () => {
|
||||
for (const params of [
|
||||
null,
|
||||
[],
|
||||
{},
|
||||
{ sessionIds: null },
|
||||
{ sessionIds: "id" },
|
||||
{ sessionIds: [""] },
|
||||
{ sessionIds: [" "] },
|
||||
{ sessionIds: [1] },
|
||||
{ sessionIds: ["id", "id"] },
|
||||
{ sessionIds: [], cwd: null },
|
||||
]) {
|
||||
expect(() => parseBatchGetSessionsRequest(params)).toThrow(
|
||||
expect.objectContaining({ code: -32602 }),
|
||||
);
|
||||
}
|
||||
const sessionIds = Array.from({ length: 1000 }, (_, i) => `id-${i}`);
|
||||
expect(parseBatchGetSessionsRequest({ sessionIds })).toEqual({ sessionIds });
|
||||
// Ids are exact, not trimmed or otherwise normalized.
|
||||
expect(parseBatchGetSessionsRequest({ sessionIds: [" id ", "id"] })).toEqual({
|
||||
sessionIds: [" id ", "id"],
|
||||
});
|
||||
});
|
||||
|
||||
it("returns empty results without touching the store", async () => {
|
||||
const { agent } = await indexAgent();
|
||||
const index = (agent as any).sessionIndex.service.index;
|
||||
const lookup = vi.spyOn(index, "getSessions").mockRejectedValue(new Error("store down"));
|
||||
expect(await agent.batchGetSessions({ sessionIds: [] })).toEqual({
|
||||
sessions: [],
|
||||
});
|
||||
expect(lookup).not.toHaveBeenCalled();
|
||||
await agent.dispose();
|
||||
});
|
||||
|
||||
it("returns no sessions when the store is absent", async () => {
|
||||
const { agent } = await indexAgent();
|
||||
const sessionIds = [randomUUID(), "retained-id"];
|
||||
expect(await agent.batchGetSessions({ sessionIds })).toEqual({
|
||||
sessions: [],
|
||||
});
|
||||
await agent.dispose();
|
||||
});
|
||||
|
||||
it("finds ids older than the recent 50 without listing or reading unrelated history", async () => {
|
||||
const base = Date.parse("2026-03-01T00:00:00Z");
|
||||
const ids: string[] = [];
|
||||
for (let i = 0; i < 55; i++) {
|
||||
ids.push(
|
||||
(
|
||||
await writeTranscript({
|
||||
cwd: path.join(workspace, `project-${i % 2}`),
|
||||
lastMessageAt: base - i * 60_000,
|
||||
})
|
||||
).id,
|
||||
);
|
||||
}
|
||||
const recent = await indexAgent();
|
||||
const page = await recent.agent.listSessions({});
|
||||
expect(page.sessions).toHaveLength(50);
|
||||
expect(page.sessions.map((s) => s.sessionId)).not.toContain(ids[54]);
|
||||
await recent.agent.dispose();
|
||||
const { agent } = await indexAgent();
|
||||
const index = (agent as any).sessionIndex.service.index;
|
||||
const list = vi.spyOn(index, "list").mockRejectedValue(new Error("must not list"));
|
||||
const names = vi.spyOn(index.listings, "namesOfAll").mockRejectedValue(new Error("history"));
|
||||
const wanted = [ids[54]!, ids[53]!, ids[0]!];
|
||||
const readdir = vi.mocked(fs.readdir);
|
||||
readdir.mockClear();
|
||||
try {
|
||||
const result = await agent.batchGetSessions({ sessionIds: wanted });
|
||||
expect(result.sessions.map((s) => s.sessionId)).toEqual(wanted);
|
||||
expect(transcriptsRead(agent)).toBe(wanted.length);
|
||||
const root = path.join(configDir, "projects");
|
||||
const storeReads = readdir.mock.calls
|
||||
.map(([dir]) => String(dir))
|
||||
.filter((dir) => dir === root || dir.startsWith(root + path.sep));
|
||||
expect(storeReads).toEqual([root]);
|
||||
expect(list).not.toHaveBeenCalled();
|
||||
expect(names).not.toHaveBeenCalled();
|
||||
expect(listSessions).not.toHaveBeenCalled();
|
||||
expect(getSessionInfo).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
readdir.mockClear();
|
||||
await agent.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps request order and copy selection across lookup chunks", async () => {
|
||||
const base = Date.parse("2026-03-01T00:00:00Z");
|
||||
const first = await writeTranscript({ lastMessageAt: base });
|
||||
const last = await writeTranscript({ lastMessageAt: base - 60_000 });
|
||||
const copyId = randomUUID();
|
||||
await writeTranscript({ sessionId: copyId, prompt: "Old copy", lastMessageAt: base - 120_000 });
|
||||
await writeTranscript({
|
||||
sessionId: copyId,
|
||||
cwd: path.join(workspace, "other"),
|
||||
prompt: "New copy",
|
||||
lastMessageAt: base - 30_000,
|
||||
});
|
||||
// 250 ids: three lookup chunks of 100, with found ids in the first, second and third.
|
||||
const sessionIds: string[] = Array.from({ length: 250 }, () => randomUUID());
|
||||
sessionIds[0] = last.id;
|
||||
sessionIds[120] = copyId;
|
||||
sessionIds[249] = first.id;
|
||||
const { agent } = await indexAgent();
|
||||
try {
|
||||
const result = await agent.batchGetSessions({ sessionIds });
|
||||
expect(result.sessions.map((s) => s.sessionId)).toEqual([last.id, copyId, first.id]);
|
||||
expect(result.sessions[1]!.title).toBe("New copy");
|
||||
} finally {
|
||||
await agent.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
it("recovers a cold batch row from a sibling and keeps it in the next list", async () => {
|
||||
const base = Date.parse("2026-04-01T00:00:00Z");
|
||||
const other = path.join(workspace, "other");
|
||||
const noCwd = await writeTranscript({ cwd: other, recordCwd: null, lastMessageAt: base });
|
||||
const fillers: string[] = [];
|
||||
for (let i = 1; i <= 16; i++) {
|
||||
fillers.push((await writeTranscript({ lastMessageAt: base - i * 1000 })).id);
|
||||
}
|
||||
// This sibling is older than the transcripts needed to fill the page.
|
||||
await writeTranscript({ cwd: other, lastMessageAt: base - 3600_000 });
|
||||
const { agent } = await indexAgent();
|
||||
const index = (agent as any).sessionIndex.service.index;
|
||||
const names = vi.spyOn(index.listings, "namesOfAll");
|
||||
const now = vi.spyOn(Date, "now").mockReturnValue(Date.now());
|
||||
try {
|
||||
const result = await agent.batchGetSessions({ sessionIds: [noCwd.id] });
|
||||
expect(result.sessions.map((s) => [s.sessionId, s.cwd])).toEqual([[noCwd.id, other]]);
|
||||
expect(transcriptsRead(agent)).toBe(2);
|
||||
expect(names.mock.calls).toEqual([[[path.dirname(noCwd.file)]]]);
|
||||
names.mockRestore();
|
||||
|
||||
// Use the same index without changing files or letting the negative cache expire.
|
||||
const page = await agent.listSessions({ _meta: listMeta({ limit: 2 }) });
|
||||
expect(page.sessions.map((s) => [s.sessionId, s.cwd])).toEqual([
|
||||
[noCwd.id, other],
|
||||
[fillers[0], workspace],
|
||||
]);
|
||||
expect(page.sessions[0]).toEqual(result.sessions[0]);
|
||||
} finally {
|
||||
names.mockRestore();
|
||||
now.mockRestore();
|
||||
await agent.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
it("bounds sibling cwd probes and retries a negative result after its cache expires", async () => {
|
||||
const base = Date.parse("2026-04-01T00:00:00Z");
|
||||
const other = path.join(workspace, "other");
|
||||
const noCwd = await writeTranscript({ cwd: other, recordCwd: null, lastMessageAt: base });
|
||||
const siblings: { id: string; file: string }[] = [];
|
||||
for (let i = 1; i <= 70; i++) {
|
||||
siblings.push(
|
||||
await writeTranscript({ cwd: other, recordCwd: null, lastMessageAt: base - i * 1000 }),
|
||||
);
|
||||
}
|
||||
await writeTranscript({ lastMessageAt: base });
|
||||
const { agent } = await indexAgent();
|
||||
const index = (agent as any).sessionIndex.service.index;
|
||||
const names = vi.spyOn(index.listings, "namesOfAll");
|
||||
const at = Date.now();
|
||||
const now = vi.spyOn(Date, "now").mockReturnValue(at);
|
||||
try {
|
||||
expect(await agent.batchGetSessions({ sessionIds: [noCwd.id] })).toEqual({ sessions: [] });
|
||||
expect(transcriptsRead(agent)).toBe(1 + 64);
|
||||
expect(names.mock.calls).toEqual([[[path.dirname(noCwd.file)]]]);
|
||||
|
||||
// Rewriting an existing sibling does not change the directory mtime.
|
||||
await writeTranscript({ sessionId: siblings[0]!.id, cwd: other, lastMessageAt: base });
|
||||
expect(await agent.batchGetSessions({ sessionIds: [noCwd.id] })).toEqual({ sessions: [] });
|
||||
expect(transcriptsRead(agent)).toBe(1 + 64);
|
||||
expect(names).toHaveBeenCalledTimes(1);
|
||||
|
||||
now.mockReturnValue(at + 60_001);
|
||||
const result = await agent.batchGetSessions({ sessionIds: [noCwd.id] });
|
||||
expect(result.sessions.map((s) => [s.sessionId, s.cwd])).toEqual([[noCwd.id, other]]);
|
||||
expect(transcriptsRead(agent)).toBe(1 + 64 + 1);
|
||||
expect(names.mock.calls).toEqual([
|
||||
[[path.dirname(noCwd.file)]],
|
||||
[[path.dirname(noCwd.file)]],
|
||||
]);
|
||||
} finally {
|
||||
names.mockRestore();
|
||||
now.mockRestore();
|
||||
await agent.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
it("returns the same unscoped list rows for archived, worktree and duplicate copies in request order", async () => {
|
||||
const repo = path.join(workspace, "repo");
|
||||
const worktree = path.join(workspace, "worktree");
|
||||
await linkWorktrees(repo, worktree);
|
||||
const archivedId = randomUUID();
|
||||
const archived = await writeTranscript({
|
||||
sessionId: archivedId,
|
||||
cwd: repo,
|
||||
trailer: titleRecords(archivedId, "[archived] Done"),
|
||||
});
|
||||
const worktreeId = randomUUID();
|
||||
await writeTranscript({
|
||||
sessionId: worktreeId,
|
||||
cwd: worktree,
|
||||
trailer: [worktreeState(worktreeId, { path: worktree })],
|
||||
});
|
||||
const copyId = randomUUID();
|
||||
await writeTranscript({ sessionId: copyId, cwd: repo, prompt: "Old copy" });
|
||||
await writeTranscript({
|
||||
sessionId: copyId,
|
||||
cwd: worktree,
|
||||
prompt: "New copy",
|
||||
mtimeMs: Date.parse("2026-02-01"),
|
||||
});
|
||||
const { agent } = await indexAgent();
|
||||
const requested = [worktreeId, archived.id, copyId];
|
||||
const result = await agent.batchGetSessions({ sessionIds: requested });
|
||||
const read = transcriptsRead(agent);
|
||||
const listed = await agent.listSessions({ _meta: listMeta({ archived: "all" }) });
|
||||
expect(transcriptsRead(agent)).toBe(read);
|
||||
expect(result.sessions).toEqual(
|
||||
requested.map((id) => listed.sessions.find((s) => s.sessionId === id)),
|
||||
);
|
||||
expect(airRow(result.sessions[1]!).archived).toBe(true);
|
||||
expect(agentWorktreeOfRow(result.sessions[0])).toBe(true);
|
||||
expect(result.sessions[2]).toMatchObject({ title: "New copy", cwd: worktree });
|
||||
await agent.dispose();
|
||||
});
|
||||
|
||||
it.skipIf(process.platform === "win32" || process.getuid?.() === 0)(
|
||||
"returns readable sessions in request order and omits unavailable ids",
|
||||
async () => {
|
||||
const first = await writeTranscript({});
|
||||
const found = await writeTranscript({});
|
||||
const unreadable = await writeTranscript({});
|
||||
await fs.chmod(unreadable.file, 0);
|
||||
const absent = randomUUID();
|
||||
const { agent } = await indexAgent();
|
||||
try {
|
||||
const result = await agent.batchGetSessions({
|
||||
sessionIds: [absent, first.id, unreadable.id, found.id],
|
||||
});
|
||||
expect(result.sessions.map((s) => s.sessionId)).toEqual([first.id, found.id]);
|
||||
expect(Object.keys(result)).toEqual(["sessions"]);
|
||||
} finally {
|
||||
await fs.chmod(unreadable.file, 0o600);
|
||||
await agent.dispose();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it.skipIf(process.platform === "win32" || process.getuid?.() === 0)(
|
||||
"uses a readable copy when another copy cannot be read or stat'ed, as the unscoped list does",
|
||||
async () => {
|
||||
const sessionId = randomUUID();
|
||||
await writeTranscript({ sessionId, prompt: "Readable copy" });
|
||||
const hiddenProject = path.join(workspace, "inaccessible");
|
||||
const unreadable = await writeTranscript({
|
||||
sessionId,
|
||||
cwd: hiddenProject,
|
||||
prompt: "Unreadable newer copy",
|
||||
mtimeMs: Date.parse("2026-02-01"),
|
||||
});
|
||||
const { agent } = await indexAgent();
|
||||
try {
|
||||
// A failed metadata read, then a failed stat of the same copy.
|
||||
for (const target of [unreadable.file, path.dirname(unreadable.file)]) {
|
||||
await fs.chmod(target, 0);
|
||||
const result = await agent.batchGetSessions({ sessionIds: [sessionId] });
|
||||
const listed = await agent.listSessions({});
|
||||
expect(result).toEqual({ sessions: listed.sessions });
|
||||
expect(result.sessions[0]?.title).toBe("Readable copy");
|
||||
await fs.chmod(target, 0o700);
|
||||
}
|
||||
} finally {
|
||||
await fs.chmod(path.dirname(unreadable.file), 0o700);
|
||||
await fs.chmod(unreadable.file, 0o600);
|
||||
await agent.dispose();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it.skipIf(process.platform === "win32" || process.getuid?.() === 0)(
|
||||
"omits failed stats, dangling links and non-file transcripts",
|
||||
async () => {
|
||||
const unreadable = await writeTranscript({ cwd: path.join(workspace, "inaccessible") });
|
||||
const dangling = await writeTranscript({});
|
||||
await fs.rm(dangling.file);
|
||||
await fs.symlink(path.join(workspace, "not-there"), dangling.file);
|
||||
const nonFile = await writeTranscript({});
|
||||
await fs.rm(nonFile.file);
|
||||
await fs.mkdir(nonFile.file);
|
||||
const { agent } = await indexAgent();
|
||||
const projectDir = path.dirname(unreadable.file);
|
||||
await fs.chmod(projectDir, 0);
|
||||
try {
|
||||
const result = await agent.batchGetSessions({
|
||||
sessionIds: [unreadable.id, dangling.id, nonFile.id],
|
||||
});
|
||||
expect(result).toEqual({ sessions: [] });
|
||||
} finally {
|
||||
await fs.chmod(projectDir, 0o700);
|
||||
await agent.dispose();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it("matches UUIDs exactly without case or whitespace normalization", async () => {
|
||||
const sessionId = "aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee";
|
||||
await writeTranscript({ sessionId });
|
||||
const { agent } = await indexAgent();
|
||||
const result = await agent.batchGetSessions({
|
||||
sessionIds: [sessionId.toUpperCase(), ` ${sessionId} `, sessionId],
|
||||
});
|
||||
expect(result.sessions.map((row) => row.sessionId)).toEqual([sessionId]);
|
||||
await agent.dispose();
|
||||
});
|
||||
|
||||
it("omits empty, malformed, sidecar-only and unresolved-cwd records", async () => {
|
||||
const empty = await writeTranscript({});
|
||||
await fs.writeFile(empty.file, "");
|
||||
const malformed = await writeTranscript({});
|
||||
await fs.writeFile(malformed.file, "not json\n");
|
||||
const sidecar = randomUUID();
|
||||
const sidecarDir = path.join(configDir, "projects", encodeProjectPath(workspace), sidecar);
|
||||
await fs.mkdir(sidecarDir, { recursive: true });
|
||||
await fs.writeFile(path.join(sidecarDir, "custom-title.json"), '{"customTitle":"Saved"}');
|
||||
const unresolved = await writeTranscript({
|
||||
cwd: path.join(workspace, "unknown"),
|
||||
recordCwd: null,
|
||||
});
|
||||
const { agent } = await indexAgent();
|
||||
const ids = [empty.id, malformed.id, sidecar, unresolved.id];
|
||||
const result = await agent.batchGetSessions({ sessionIds: ids });
|
||||
expect(result).toEqual({ sessions: [] });
|
||||
await agent.dispose();
|
||||
});
|
||||
|
||||
it
|
||||
.skipIf(process.platform === "win32" || process.getuid?.() === 0)
|
||||
.each(["unreadable", "unreadable after a cached list", "a file", "a dangling symlink"])(
|
||||
"rejects a projects root that is %s",
|
||||
async (kind) => {
|
||||
const found = await writeTranscript({});
|
||||
const root = path.join(configDir, "projects");
|
||||
const { agent } = await indexAgent();
|
||||
try {
|
||||
if (kind === "unreadable after a cached list") {
|
||||
const old = new Date(Date.now() - 60_000);
|
||||
await fs.utimes(root, old, old);
|
||||
await agent.listSessions({});
|
||||
}
|
||||
if (kind.startsWith("unreadable")) {
|
||||
await fs.chmod(root, 0);
|
||||
} else {
|
||||
await fs.rm(root, { recursive: true });
|
||||
if (kind === "a file") await fs.writeFile(root, "not a store");
|
||||
else await fs.symlink(path.join(workspace, "missing-store"), root);
|
||||
}
|
||||
for (const sessionId of [found.id, "retained-id"]) {
|
||||
await expect(agent.batchGetSessions({ sessionIds: [sessionId] })).rejects.toThrow(
|
||||
"Session store could not be read",
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
if (kind.startsWith("unreadable")) await fs.chmod(root, 0o700);
|
||||
await agent.dispose();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it("rejects under ACP v2 even when sessionIndex was declared", async () => {
|
||||
const { agent } = createAgent({ v2: true });
|
||||
await initializeClient(agent, air("sessionIndex"));
|
||||
await expect(agent.batchGetSessions({ sessionIds: [] })).rejects.toMatchObject({
|
||||
code: -32601,
|
||||
});
|
||||
await agent.dispose();
|
||||
});
|
||||
});
|
||||
|
||||
describe("session/list of a sessionIndex client", () => {
|
||||
const base = Date.parse("2026-03-01T00:00:00Z");
|
||||
|
||||
@@ -3528,7 +3957,8 @@ describe("paths that share a project directory", () => {
|
||||
.filter((s) => s.sessionId === mine.id);
|
||||
const removed = () =>
|
||||
changesFor(notifications, fromDotted.subscriptionId).flatMap((c) => c.removed);
|
||||
expect(await waitFor(() => added().length > 0 && removed().length > 0)).toBe(true);
|
||||
// Wait for mine.id itself: the other path's session can be removed first.
|
||||
expect(await waitFor(() => added().length > 0 && removed().includes(mine.id))).toBe(true);
|
||||
expect(added().map((s) => [s.sessionId, s.cwd])).toEqual([[mine.id, dashed]]);
|
||||
// It may also name the other path's session, never listed here.
|
||||
expect(removed()).toContain(mine.id);
|
||||
|
||||
@@ -14,8 +14,9 @@ export default defineConfig({
|
||||
// tests off the machine's real CLI and Claude config, and stops a run
|
||||
// from outliving its test (it wrote into temp config dirs being deleted).
|
||||
env: usesRealCli ? {} : { CLAUDE_CODE_EXECUTABLE: "/usr/bin/false" },
|
||||
// Real-CLI runs may want a real gateway listing.
|
||||
setupFiles: usesRealCli ? [] : ["src/tests/setup-no-network.ts"],
|
||||
// Applies to real-CLI runs too: the gateway listing runs in the test process,
|
||||
// and tests point `providers/set` at fake URLs like gateway.example.com.
|
||||
setupFiles: ["src/tests/setup-no-network.ts"],
|
||||
include: [
|
||||
"src/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}",
|
||||
// Release tooling lives in scripts/ rather than src/ so it stays outside tsc's
|
||||
|
||||
Reference in New Issue
Block a user