@agentclientprotocol/claude-agent-acp 0.88.1-preview.6 → 0.88.1-preview.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/acp-agent.d.ts +26 -1
- package/dist/acp-agent.d.ts.map +1 -1
- package/dist/acp-agent.js +91 -17
- package/dist/air-extension.d.ts +23 -0
- package/dist/air-extension.d.ts.map +1 -1
- package/dist/air-extension.js +23 -0
- package/dist/diff.d.ts +2 -0
- package/dist/diff.d.ts.map +1 -1
- package/dist/diff.js +37 -1
- package/dist/mcp-command.d.ts.map +1 -1
- package/dist/mcp-command.js +5 -0
- package/dist/session-index/activity.d.ts +56 -0
- package/dist/session-index/activity.d.ts.map +1 -0
- package/dist/session-index/activity.js +96 -0
- package/dist/session-index/archive-title.d.ts +52 -0
- package/dist/session-index/archive-title.d.ts.map +1 -0
- package/dist/session-index/archive-title.js +77 -0
- package/dist/session-index/connection.d.ts +152 -0
- package/dist/session-index/connection.d.ts.map +1 -0
- package/dist/session-index/connection.js +310 -0
- package/dist/session-index/dir-listing.d.ts +30 -0
- package/dist/session-index/dir-listing.d.ts.map +1 -0
- package/dist/session-index/dir-listing.js +110 -0
- package/dist/session-index/explicit-title.d.ts +34 -0
- package/dist/session-index/explicit-title.d.ts.map +1 -0
- package/dist/session-index/explicit-title.js +65 -0
- package/dist/session-index/first-prompt.d.ts +37 -0
- package/dist/session-index/first-prompt.d.ts.map +1 -0
- package/dist/session-index/first-prompt.js +195 -0
- package/dist/session-index/list-subscriptions.d.ts +190 -0
- package/dist/session-index/list-subscriptions.d.ts.map +1 -0
- package/dist/session-index/list-subscriptions.js +1163 -0
- package/dist/session-index/live-registry.d.ts +72 -0
- package/dist/session-index/live-registry.d.ts.map +1 -0
- package/dist/session-index/live-registry.js +240 -0
- package/dist/session-index/project-dirs.d.ts +45 -0
- package/dist/session-index/project-dirs.d.ts.map +1 -0
- package/dist/session-index/project-dirs.js +122 -0
- package/dist/session-index/service.d.ts +158 -0
- package/dist/session-index/service.d.ts.map +1 -0
- package/dist/session-index/service.js +546 -0
- package/dist/session-index/session-index.d.ts +201 -0
- package/dist/session-index/session-index.d.ts.map +1 -0
- package/dist/session-index/session-index.js +740 -0
- package/dist/session-index/session-info.d.ts +16 -0
- package/dist/session-index/session-info.d.ts.map +1 -0
- package/dist/session-index/session-info.js +51 -0
- package/dist/session-index/transcript-scan.d.ts +137 -0
- package/dist/session-index/transcript-scan.d.ts.map +1 -0
- package/dist/session-index/transcript-scan.js +545 -0
- package/dist/session-index/worktrees.d.ts +25 -0
- package/dist/session-index/worktrees.d.ts.map +1 -0
- package/dist/session-index/worktrees.js +128 -0
- package/dist/session-titles.d.ts +21 -0
- package/dist/session-titles.d.ts.map +1 -1
- package/dist/session-titles.js +101 -5
- package/dist/utils.d.ts +31 -0
- package/dist/utils.d.ts.map +1 -1
- package/dist/utils.js +60 -2
- package/package.json +1 -1
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The session index of one ACP connection: what `ClaudeAcpAgent` does for a
|
|
3
|
+
* client that declared `sessionIndex`, and the archive of an AIR client
|
|
4
|
+
* without it. The agent keeps thin call sites; everything else
|
|
5
|
+
* lives here.
|
|
6
|
+
*
|
|
7
|
+
* Wire contract: docs/air-extensions.md, "Session index".
|
|
8
|
+
*/
|
|
9
|
+
import { RequestError, } from "@agentclientprotocol/sdk";
|
|
10
|
+
import { deleteSession as sdkDeleteSession } from "@anthropic-ai/claude-agent-sdk";
|
|
11
|
+
import { AIR_SESSION_ARCHIVE_CAPABILITY, AIR_SESSION_INDEX_CAPABILITY, AIR_SESSION_LIST_SUBSCRIBE_CAPABILITY, AIR_SESSION_RENAME_CAPABILITY, clientSupportsAirCapability, withAirMeta, } from "../air-extension.js";
|
|
12
|
+
import { isArchivedTitle, storedTitle, visibleTitle } from "./archive-title.js";
|
|
13
|
+
import { LIST_CHANGES_METHOD, LIST_SUBSCRIBE_METHOD, LIST_UNSUBSCRIBE_METHOD, parseListSubscribeRequest, parseListUnsubscribeRequest, } from "./list-subscriptions.js";
|
|
14
|
+
import { archiveInsteadOfDelete, archiveTo, renameTo, parseRenameSessionRequest, parseSessionIdRequest, SESSION_ARCHIVE_METHOD, SESSION_RENAME_METHOD, SESSION_UNARCHIVE_METHOD, SessionIndexService, } from "./service.js";
|
|
15
|
+
/** Records the end of a turn at `session_state_changed`. */
|
|
16
|
+
export function noteSessionState(session, previous, state) {
|
|
17
|
+
if (state === "idle" && previous !== "idle")
|
|
18
|
+
session.lastTurnEndedAt = Date.now();
|
|
19
|
+
if (state === "running" && previous !== "running")
|
|
20
|
+
session.lastTurnFailed = false;
|
|
21
|
+
}
|
|
22
|
+
export class SessionIndexConnection {
|
|
23
|
+
host;
|
|
24
|
+
/**
|
|
25
|
+
* The session index of a client that declared `sessionIndex`. Undefined for
|
|
26
|
+
* every other client, which keeps the session list, delete and the watchers
|
|
27
|
+
* exactly as before.
|
|
28
|
+
*/
|
|
29
|
+
service;
|
|
30
|
+
/** The archive behind `session/delete` of an AIR client without
|
|
31
|
+
* `sessionIndex`: the same writes, without a list or a watcher. */
|
|
32
|
+
archiver;
|
|
33
|
+
/** When the query of a session failed here, by session id. */
|
|
34
|
+
failedQueries = new Map();
|
|
35
|
+
constructor(host) {
|
|
36
|
+
this.host = host;
|
|
37
|
+
}
|
|
38
|
+
/** Sets the index up for the client of `initialize`. ACP v2 does not route
|
|
39
|
+
* the session index methods yet. */
|
|
40
|
+
negotiate(request, options) {
|
|
41
|
+
this.service?.dispose();
|
|
42
|
+
this.service = undefined;
|
|
43
|
+
if (!options.v2 &&
|
|
44
|
+
this.host.isAirClient() &&
|
|
45
|
+
clientSupportsAirCapability(request.clientCapabilities, AIR_SESSION_INDEX_CAPABILITY)) {
|
|
46
|
+
this.service = new SessionIndexService({
|
|
47
|
+
ownSessionState: (sessionId) => this.ownSessionState(sessionId),
|
|
48
|
+
notifyListChanges: (changes) => this.host.agent.client.extNotification(LIST_CHANGES_METHOD, changes),
|
|
49
|
+
logError: (message, error) => this.host.agent.logger.error(`[session-index] ${message}:`, error),
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
/** A stored title as the client shows it: an AIR client gets the title
|
|
54
|
+
* of an archived session without the archive prefix, as in its list;
|
|
55
|
+
* every other client the title as stored. */
|
|
56
|
+
clientTitle(title) {
|
|
57
|
+
return this.host.isAirClient() && isArchivedTitle(title) ? visibleTitle(title) : title;
|
|
58
|
+
}
|
|
59
|
+
/** The AIR capabilities the agent advertises for the index: the index
|
|
60
|
+
* itself, archive, rename and the list subscription, all only to a client
|
|
61
|
+
* that declared `sessionIndex`. */
|
|
62
|
+
capabilities() {
|
|
63
|
+
return this.service
|
|
64
|
+
? [
|
|
65
|
+
AIR_SESSION_INDEX_CAPABILITY,
|
|
66
|
+
AIR_SESSION_ARCHIVE_CAPABILITY,
|
|
67
|
+
AIR_SESSION_RENAME_CAPABILITY,
|
|
68
|
+
AIR_SESSION_LIST_SUBSCRIBE_CAPABILITY,
|
|
69
|
+
]
|
|
70
|
+
: [];
|
|
71
|
+
}
|
|
72
|
+
dispose() {
|
|
73
|
+
this.service?.dispose();
|
|
74
|
+
}
|
|
75
|
+
/** The query of a session that ran here failed (not a cancel): its row
|
|
76
|
+
* shows `error` until its transcript changes. */
|
|
77
|
+
onQueryFailed(sessionId) {
|
|
78
|
+
this.failedQueries.set(sessionId, Date.now());
|
|
79
|
+
if (this.failedQueries.size > 1024) {
|
|
80
|
+
this.failedQueries.delete(this.failedQueries.keys().next().value);
|
|
81
|
+
}
|
|
82
|
+
this.service?.ownSessionChanged(sessionId);
|
|
83
|
+
}
|
|
84
|
+
/** A turn of a session started here: an earlier failed query of it no
|
|
85
|
+
* longer counts. */
|
|
86
|
+
onTurnStarted(sessionId) {
|
|
87
|
+
this.failedQueries.delete(sessionId);
|
|
88
|
+
}
|
|
89
|
+
onTeardown(sessionId) {
|
|
90
|
+
// Its row now shows what the registry and the transcript tell.
|
|
91
|
+
this.service?.ownSessionChanged(sessionId);
|
|
92
|
+
}
|
|
93
|
+
/** A session this connection runs changed its state, turn end or cost:
|
|
94
|
+
* the list subscriptions send it at once. */
|
|
95
|
+
onOwnSessionChanged(sessionId) {
|
|
96
|
+
this.service?.ownSessionChanged(sessionId);
|
|
97
|
+
}
|
|
98
|
+
/** `_session/list/subscribe`. */
|
|
99
|
+
subscribeList(params) {
|
|
100
|
+
return this.requireService(LIST_SUBSCRIBE_METHOD).subscribeList(params.cwd);
|
|
101
|
+
}
|
|
102
|
+
/** `_session/list/unsubscribe`. Idempotent. */
|
|
103
|
+
unsubscribeList(params) {
|
|
104
|
+
this.requireService(LIST_UNSUBSCRIBE_METHOD).unsubscribeList(params.subscriptionId);
|
|
105
|
+
return {};
|
|
106
|
+
}
|
|
107
|
+
/** `session/list` of a `sessionIndex` client; undefined for another one. */
|
|
108
|
+
list(params) {
|
|
109
|
+
return this.service?.list(params, (sessionId) => this.ownSessionState(sessionId));
|
|
110
|
+
}
|
|
111
|
+
/** An AIR client archives with session/delete (see {@link deleteSession}):
|
|
112
|
+
* its archived sessions (an archived custom title) stay hidden from the
|
|
113
|
+
* old list, as when the delete removed them. */
|
|
114
|
+
hideArchived(sessions) {
|
|
115
|
+
if (!this.host.isAirClient())
|
|
116
|
+
return sessions;
|
|
117
|
+
return sessions.filter((session) => !isArchivedTitle(session.customTitle));
|
|
118
|
+
}
|
|
119
|
+
/** `_session/rename`: names a session; no generated title replaces it.
|
|
120
|
+
* An archived session stays archived. */
|
|
121
|
+
async rename(request) {
|
|
122
|
+
const index = this.requireService(SESSION_RENAME_METHOD);
|
|
123
|
+
const { sessionId } = request;
|
|
124
|
+
// As the CLI keeps it, and never with the archive prefix: a rename does
|
|
125
|
+
// not change the archive state.
|
|
126
|
+
const title = storedTitle(request.title, false, sessionId);
|
|
127
|
+
// The title as stored: an archived one is cut with its prefix.
|
|
128
|
+
const shown = (stored) => stored === undefined ? title : visibleTitle(stored);
|
|
129
|
+
await this.retitle(index, sessionId, renameTo(title), shown, {
|
|
130
|
+
mayBeUnwritten: true,
|
|
131
|
+
sidecar: "always",
|
|
132
|
+
});
|
|
133
|
+
return {};
|
|
134
|
+
}
|
|
135
|
+
/** `_session/archive`: hides a session from the default list and stops
|
|
136
|
+
* it. Idempotent; the session need not be loaded. A session loaded here
|
|
137
|
+
* is interrupted, retitled through its CLI while that still runs, then
|
|
138
|
+
* closed as `session/close` closes it. */
|
|
139
|
+
async archive(params) {
|
|
140
|
+
const index = this.requireService(SESSION_ARCHIVE_METHOD);
|
|
141
|
+
const { sessionId } = params;
|
|
142
|
+
const loaded = this.host.agent.sessions[sessionId] !== undefined;
|
|
143
|
+
// No turn goes on after the archive: the interrupt goes to the CLI ahead
|
|
144
|
+
// of the rename.
|
|
145
|
+
if (loaded)
|
|
146
|
+
await this.host.interruptSession(sessionId);
|
|
147
|
+
await this.retitle(index, sessionId, archiveTo(true), undefined, {
|
|
148
|
+
mayBeUnwritten: this.isUnwrittenSession(sessionId),
|
|
149
|
+
sidecar: "existing",
|
|
150
|
+
});
|
|
151
|
+
if (loaded) {
|
|
152
|
+
await this.host.teardownSession(sessionId);
|
|
153
|
+
// The closed CLI may have lost or overwritten the agent name it was
|
|
154
|
+
// still to write: the transcripts are archived without it once more,
|
|
155
|
+
// as a best effort, since the CLI took the archived title.
|
|
156
|
+
try {
|
|
157
|
+
await index.retitle(sessionId, archiveTo(true), {
|
|
158
|
+
mayBeUnwritten: true,
|
|
159
|
+
sidecar: "existing",
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
catch (error) {
|
|
163
|
+
this.host.agent.logger.error(`[session-index] archiving ${sessionId} after its close failed:`, error);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
await this.reportArchived(sessionId, true, loaded);
|
|
167
|
+
return {};
|
|
168
|
+
}
|
|
169
|
+
/** `_session/unarchive`. Idempotent; it does not load the session. */
|
|
170
|
+
async unarchive(params) {
|
|
171
|
+
const index = this.requireService(SESSION_UNARCHIVE_METHOD);
|
|
172
|
+
const { sessionId } = params;
|
|
173
|
+
await this.retitle(index, sessionId, archiveTo(false), undefined, {
|
|
174
|
+
mayBeUnwritten: this.isUnwrittenSession(sessionId),
|
|
175
|
+
sidecar: "existing",
|
|
176
|
+
});
|
|
177
|
+
await this.reportArchived(sessionId, false, this.host.agent.sessions[sessionId] !== undefined);
|
|
178
|
+
return {};
|
|
179
|
+
}
|
|
180
|
+
/**
|
|
181
|
+
* Retitles a session (see {@link SessionIndexService.retitle}). A session
|
|
182
|
+
* this connection runs is titled through its CLI (`rename_session`). A
|
|
183
|
+
* loaded session's title state takes the change as a client title, so no
|
|
184
|
+
* generated title replaces it; `publish` is the title it then publishes.
|
|
185
|
+
*/
|
|
186
|
+
async retitle(index, sessionId, change, publish, options) {
|
|
187
|
+
const session = this.host.agent.sessions[sessionId];
|
|
188
|
+
// Decided once a title generation in flight has ended: the query may
|
|
189
|
+
// have closed meanwhile.
|
|
190
|
+
const persist = () => {
|
|
191
|
+
const query = session?.query;
|
|
192
|
+
// A closed session's CLI is gone here: only a running query titles it.
|
|
193
|
+
const running = session !== undefined && !session.queryClosed;
|
|
194
|
+
if (running && typeof query?.renameSession !== "function") {
|
|
195
|
+
// Its CLI holds the title and would write it back over ours.
|
|
196
|
+
throw RequestError.invalidRequest({ sessionId }, "The Claude Code CLI of this session cannot change its title while it runs.");
|
|
197
|
+
}
|
|
198
|
+
const live = running
|
|
199
|
+
? {
|
|
200
|
+
cwd: session.cwd,
|
|
201
|
+
rename: (title) => query.renameSession(title, sessionId),
|
|
202
|
+
stored: () => session.titles.storedTitle,
|
|
203
|
+
remember: (title) => session.titles.rememberStoredTitle(title),
|
|
204
|
+
shown: session.titles.shownTitle,
|
|
205
|
+
}
|
|
206
|
+
: undefined;
|
|
207
|
+
return index.retitle(sessionId, change, {
|
|
208
|
+
...options,
|
|
209
|
+
// Without its CLI, a session needs its transcript.
|
|
210
|
+
mayBeUnwritten: live !== undefined && options.mayBeUnwritten,
|
|
211
|
+
live,
|
|
212
|
+
});
|
|
213
|
+
};
|
|
214
|
+
if (session) {
|
|
215
|
+
await session.titles.setExplicitTitle(publish, persist);
|
|
216
|
+
}
|
|
217
|
+
else {
|
|
218
|
+
await persist();
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
/**
|
|
222
|
+
* `session/delete`:
|
|
223
|
+
* - A `sessionIndex` client deletes for real: every transcript, whoever
|
|
224
|
+
* else has the session open, as the CLI does.
|
|
225
|
+
* - Another AIR client uses delete to mark a session done, and may reopen
|
|
226
|
+
* it later: the adapter archives it instead (as `_session/archive` does,
|
|
227
|
+
* after the CLI closed), so the transcript survives.
|
|
228
|
+
* - Every other client: the SDK delete, as before.
|
|
229
|
+
*/
|
|
230
|
+
async deleteSession(params) {
|
|
231
|
+
const loaded = this.host.agent.sessions[params.sessionId] !== undefined;
|
|
232
|
+
// Tear down any active in-memory state first so the on-disk file isn't
|
|
233
|
+
// recreated by an outstanding query writing to it.
|
|
234
|
+
if (loaded) {
|
|
235
|
+
await this.host.teardownSession(params.sessionId);
|
|
236
|
+
}
|
|
237
|
+
if (this.service) {
|
|
238
|
+
await this.service.delete(params.sessionId, loaded);
|
|
239
|
+
}
|
|
240
|
+
else if (this.host.isAirClient()) {
|
|
241
|
+
await archiveInsteadOfDelete(params.sessionId, this.airArchiver());
|
|
242
|
+
}
|
|
243
|
+
else {
|
|
244
|
+
await sdkDeleteSession(params.sessionId);
|
|
245
|
+
}
|
|
246
|
+
return {};
|
|
247
|
+
}
|
|
248
|
+
airArchiver() {
|
|
249
|
+
this.archiver ??= new SessionIndexService({
|
|
250
|
+
logError: (message, error) => this.host.agent.logger.error(`[session-index] ${message}:`, error),
|
|
251
|
+
});
|
|
252
|
+
return this.archiver;
|
|
253
|
+
}
|
|
254
|
+
requireService(method) {
|
|
255
|
+
if (!this.service)
|
|
256
|
+
throw RequestError.methodNotFound(method);
|
|
257
|
+
return this.service;
|
|
258
|
+
}
|
|
259
|
+
/** What the session index reports for a session this connection runs. */
|
|
260
|
+
ownSessionState(sessionId) {
|
|
261
|
+
const session = this.host.agent.sessions[sessionId];
|
|
262
|
+
if (!session || session.queryClosed) {
|
|
263
|
+
const failedAt = this.failedQueries.get(sessionId);
|
|
264
|
+
return failedAt === undefined ? undefined : { queryFailedAt: failedAt };
|
|
265
|
+
}
|
|
266
|
+
return {
|
|
267
|
+
state: session.lastSessionState,
|
|
268
|
+
lastTurnEndedAt: session.lastTurnEndedAt,
|
|
269
|
+
// A session loaded again after its query failed here shows the failure
|
|
270
|
+
// until a turn of it starts.
|
|
271
|
+
lastTurnFailed: session.lastTurnFailed || this.failedQueries.has(sessionId),
|
|
272
|
+
costUsd: session.lastTotalCostUsd,
|
|
273
|
+
};
|
|
274
|
+
}
|
|
275
|
+
/** A new session this connection runs that may have no transcript yet: it
|
|
276
|
+
* resumed no stored conversation and no turn of it has ended. A session
|
|
277
|
+
* whose query ended, or that has history, needs its transcript. */
|
|
278
|
+
isUnwrittenSession(sessionId) {
|
|
279
|
+
const session = this.host.agent.sessions[sessionId];
|
|
280
|
+
return (session !== undefined &&
|
|
281
|
+
!session.queryClosed &&
|
|
282
|
+
!session.resumedFromHistory &&
|
|
283
|
+
session.lastTurnEndedAt === undefined);
|
|
284
|
+
}
|
|
285
|
+
/** Tells the client the archive state of a session that was loaded on
|
|
286
|
+
* this connection (`session_info_update` with
|
|
287
|
+
* `_meta.jetbrains.air.archived`, RFD #2161's `archived` field), also
|
|
288
|
+
* once the archive closed it. */
|
|
289
|
+
async reportArchived(sessionId, archived, loaded) {
|
|
290
|
+
if (!loaded)
|
|
291
|
+
return;
|
|
292
|
+
await this.host.agent.client.sessionUpdate({
|
|
293
|
+
sessionId,
|
|
294
|
+
update: {
|
|
295
|
+
sessionUpdate: "session_info_update",
|
|
296
|
+
_meta: withAirMeta(undefined, "archived", archived),
|
|
297
|
+
},
|
|
298
|
+
});
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
/** Routes `_session/list/subscribe`, `_session/list/unsubscribe`,
|
|
302
|
+
* `_session/rename`, `_session/archive` and `_session/unarchive`. */
|
|
303
|
+
export function onSessionIndexRequests(app, agent) {
|
|
304
|
+
return app
|
|
305
|
+
.onRequest(LIST_SUBSCRIBE_METHOD, { parse: parseListSubscribeRequest }, (ctx) => agent().subscribeSessionList(ctx.params))
|
|
306
|
+
.onRequest(LIST_UNSUBSCRIBE_METHOD, { parse: parseListUnsubscribeRequest }, (ctx) => agent().unsubscribeSessionList(ctx.params))
|
|
307
|
+
.onRequest(SESSION_RENAME_METHOD, { parse: parseRenameSessionRequest }, (ctx) => agent().renameSessionTitle(ctx.params))
|
|
308
|
+
.onRequest(SESSION_ARCHIVE_METHOD, { parse: parseSessionIdRequest }, (ctx) => agent().archiveSession(ctx.params))
|
|
309
|
+
.onRequest(SESSION_UNARCHIVE_METHOD, { parse: parseSessionIdRequest }, (ctx) => agent().unarchiveSession(ctx.params));
|
|
310
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* File system reads of the session index that stay cheap with thousands of
|
|
3
|
+
* transcripts: bounded batches of stats, and directory listings that are
|
|
4
|
+
* read again only when the directory changed.
|
|
5
|
+
*/
|
|
6
|
+
import * as fs from "node:fs";
|
|
7
|
+
/**
|
|
8
|
+
* The stats of `filePaths` (undefined for one that is gone). A project
|
|
9
|
+
* directory holds thousands of transcripts: the callback API costs about
|
|
10
|
+
* half of one promise per file, and a bounded number in flight, shared by
|
|
11
|
+
* all callers, keeps each turn of the event loop short (dispatching
|
|
12
|
+
* thousands at once holds it for 100 ms and more). Unlike synchronous
|
|
13
|
+
* stats, a file system stall never holds the event loop.
|
|
14
|
+
*/
|
|
15
|
+
export declare function statFiles(filePaths: readonly string[]): Promise<(fs.Stats | undefined)[]>;
|
|
16
|
+
/**
|
|
17
|
+
* Directory listings, read again only when the directory's mtime changed
|
|
18
|
+
* (an entry was added, removed or renamed; an append to a file does not
|
|
19
|
+
* change it). A project directory with thousands of transcripts is then one
|
|
20
|
+
* stat instead of one `readdir` per list or mutation.
|
|
21
|
+
*/
|
|
22
|
+
export declare class DirListings {
|
|
23
|
+
private readonly listings;
|
|
24
|
+
/** The entry names of `dir`, empty when it cannot be read. */
|
|
25
|
+
names(dir: string): Promise<string[]>;
|
|
26
|
+
/** The entry names of each of `dirs`, one bounded batch of stats. */
|
|
27
|
+
namesOfAll(dirs: readonly string[]): Promise<string[][]>;
|
|
28
|
+
private listing;
|
|
29
|
+
}
|
|
30
|
+
//# sourceMappingURL=dir-listing.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"dir-listing.d.ts","sourceRoot":"","sources":["../../src/session-index/dir-listing.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAEH,OAAO,KAAK,EAAE,MAAM,SAAS,CAAC;AAkC9B;;;;;;;GAOG;AACH,wBAAgB,SAAS,CAAC,SAAS,EAAE,SAAS,MAAM,EAAE,GAAG,OAAO,CAAC,CAAC,EAAE,CAAC,KAAK,GAAG,SAAS,CAAC,EAAE,CAAC,CAYzF;AAgBD;;;;;GAKG;AACH,qBAAa,WAAW;IACtB,OAAO,CAAC,QAAQ,CAAC,QAAQ,CAA8B;IAEvD,8DAA8D;IACxD,KAAK,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,EAAE,CAAC;IAI3C,qEAAqE;IAC/D,UAAU,CAAC,IAAI,EAAE,SAAS,MAAM,EAAE,GAAG,OAAO,CAAC,MAAM,EAAE,EAAE,CAAC;YAKhD,OAAO;CAyBtB"}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* File system reads of the session index that stay cheap with thousands of
|
|
3
|
+
* transcripts: bounded batches of stats, and directory listings that are
|
|
4
|
+
* read again only when the directory changed.
|
|
5
|
+
*/
|
|
6
|
+
import * as fs from "node:fs";
|
|
7
|
+
/** Stats in flight at once, across all callers of {@link statFiles}. */
|
|
8
|
+
const STAT_CONCURRENCY = 512;
|
|
9
|
+
let statsInFlight = 0;
|
|
10
|
+
/** Queued stats, a FIFO read from `waitingHead` (no `shift()`: the queue
|
|
11
|
+
* can hold tens of thousands). */
|
|
12
|
+
let waitingStats = [];
|
|
13
|
+
let waitingHead = 0;
|
|
14
|
+
function nextWaitingStat() {
|
|
15
|
+
if (waitingHead >= waitingStats.length)
|
|
16
|
+
return undefined;
|
|
17
|
+
const next = waitingStats[waitingHead++];
|
|
18
|
+
if (waitingHead > 1024 && waitingHead * 2 > waitingStats.length) {
|
|
19
|
+
waitingStats = waitingStats.slice(waitingHead);
|
|
20
|
+
waitingHead = 0;
|
|
21
|
+
}
|
|
22
|
+
return next;
|
|
23
|
+
}
|
|
24
|
+
/** Runs `stat` once fewer than {@link STAT_CONCURRENCY} are in flight. */
|
|
25
|
+
function statBounded(filePath, done) {
|
|
26
|
+
const run = () => {
|
|
27
|
+
statsInFlight++;
|
|
28
|
+
fs.stat(filePath, (error, stats) => {
|
|
29
|
+
statsInFlight--;
|
|
30
|
+
nextWaitingStat()?.();
|
|
31
|
+
done(error ? undefined : stats);
|
|
32
|
+
});
|
|
33
|
+
};
|
|
34
|
+
if (statsInFlight < STAT_CONCURRENCY)
|
|
35
|
+
run();
|
|
36
|
+
else
|
|
37
|
+
waitingStats.push(run);
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* The stats of `filePaths` (undefined for one that is gone). A project
|
|
41
|
+
* directory holds thousands of transcripts: the callback API costs about
|
|
42
|
+
* half of one promise per file, and a bounded number in flight, shared by
|
|
43
|
+
* all callers, keeps each turn of the event loop short (dispatching
|
|
44
|
+
* thousands at once holds it for 100 ms and more). Unlike synchronous
|
|
45
|
+
* stats, a file system stall never holds the event loop.
|
|
46
|
+
*/
|
|
47
|
+
export function statFiles(filePaths) {
|
|
48
|
+
const result = new Array(filePaths.length);
|
|
49
|
+
if (filePaths.length === 0)
|
|
50
|
+
return Promise.resolve(result);
|
|
51
|
+
return new Promise((resolve) => {
|
|
52
|
+
let left = filePaths.length;
|
|
53
|
+
filePaths.forEach((filePath, i) => {
|
|
54
|
+
statBounded(filePath, (stats) => {
|
|
55
|
+
result[i] = stats;
|
|
56
|
+
if (--left === 0)
|
|
57
|
+
resolve(result);
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
/** A listing read this soon after the directory changed is not trusted:
|
|
63
|
+
* some file systems keep the mtime in whole seconds, so another change in
|
|
64
|
+
* the same second would leave it unchanged. */
|
|
65
|
+
const MTIME_RESOLUTION_MS = 2000;
|
|
66
|
+
/**
|
|
67
|
+
* Directory listings, read again only when the directory's mtime changed
|
|
68
|
+
* (an entry was added, removed or renamed; an append to a file does not
|
|
69
|
+
* change it). A project directory with thousands of transcripts is then one
|
|
70
|
+
* stat instead of one `readdir` per list or mutation.
|
|
71
|
+
*/
|
|
72
|
+
export class DirListings {
|
|
73
|
+
listings = new Map();
|
|
74
|
+
/** The entry names of `dir`, empty when it cannot be read. */
|
|
75
|
+
async names(dir) {
|
|
76
|
+
return (await this.listing(dir, await statOrUndefined(dir))).names;
|
|
77
|
+
}
|
|
78
|
+
/** The entry names of each of `dirs`, one bounded batch of stats. */
|
|
79
|
+
async namesOfAll(dirs) {
|
|
80
|
+
const stats = await statFiles(dirs);
|
|
81
|
+
return Promise.all(dirs.map(async (dir, i) => (await this.listing(dir, stats[i])).names));
|
|
82
|
+
}
|
|
83
|
+
async listing(dir, stats) {
|
|
84
|
+
if (!stats?.isDirectory()) {
|
|
85
|
+
this.listings.delete(dir);
|
|
86
|
+
return { mtimeMs: 0, ino: 0, readAt: 0, names: [] };
|
|
87
|
+
}
|
|
88
|
+
const cached = this.listings.get(dir);
|
|
89
|
+
if (cached &&
|
|
90
|
+
cached.mtimeMs === stats.mtimeMs &&
|
|
91
|
+
cached.ino === stats.ino &&
|
|
92
|
+
cached.readAt - cached.mtimeMs > MTIME_RESOLUTION_MS) {
|
|
93
|
+
return cached;
|
|
94
|
+
}
|
|
95
|
+
const readAt = Date.now();
|
|
96
|
+
let names;
|
|
97
|
+
try {
|
|
98
|
+
names = await fs.promises.readdir(dir);
|
|
99
|
+
}
|
|
100
|
+
catch {
|
|
101
|
+
return { mtimeMs: 0, ino: 0, readAt, names: [] };
|
|
102
|
+
}
|
|
103
|
+
const listing = { mtimeMs: stats.mtimeMs, ino: stats.ino, readAt, names };
|
|
104
|
+
this.listings.set(dir, listing);
|
|
105
|
+
return listing;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
function statOrUndefined(target) {
|
|
109
|
+
return fs.promises.stat(target).then((stats) => stats, () => undefined);
|
|
110
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The client-set title of a session (`_session/rename`), as `SessionTitles`
|
|
3
|
+
* guards it against generated titles: once a client named the session, or
|
|
4
|
+
* while a rename is in flight, no generated title is adopted, and a rename
|
|
5
|
+
* waits for a generation in flight, whose persisted title would otherwise
|
|
6
|
+
* land after it.
|
|
7
|
+
*/
|
|
8
|
+
export declare class ExplicitTitle {
|
|
9
|
+
/** Set once a client named the session. Released by {@link reset}. */
|
|
10
|
+
private named;
|
|
11
|
+
/** Renames in flight. */
|
|
12
|
+
private pending;
|
|
13
|
+
/** Renames started so far, to tell that one ran between two reads. */
|
|
14
|
+
private started;
|
|
15
|
+
/** The title generation in flight. */
|
|
16
|
+
private generation?;
|
|
17
|
+
/** Whether a generated title must not be adopted. */
|
|
18
|
+
get active(): boolean;
|
|
19
|
+
/** Whether a client named the session and no rename is in flight: a title
|
|
20
|
+
* stored since by someone else (a `/rename`) may still be adopted. */
|
|
21
|
+
get settledByClient(): boolean;
|
|
22
|
+
/** Changes with every rename that starts. */
|
|
23
|
+
get renames(): number;
|
|
24
|
+
reset(): void;
|
|
25
|
+
/** Tracks a title generation until it ends. */
|
|
26
|
+
track(generation: Promise<void>): void;
|
|
27
|
+
/** Persists a client title after the generation in flight. `persist`
|
|
28
|
+
* tells whether the session is named now. When it fails or names nothing
|
|
29
|
+
* (an archive of an archived session), and no other rename named the
|
|
30
|
+
* session, `restore` puts the title state back, so a later turn may still
|
|
31
|
+
* generate a title. */
|
|
32
|
+
apply(persist: () => Promise<boolean>, restore: () => void): Promise<void>;
|
|
33
|
+
}
|
|
34
|
+
//# sourceMappingURL=explicit-title.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"explicit-title.d.ts","sourceRoot":"","sources":["../../src/session-index/explicit-title.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AACH,qBAAa,aAAa;IACxB,sEAAsE;IACtE,OAAO,CAAC,KAAK,CAAS;IACtB,yBAAyB;IACzB,OAAO,CAAC,OAAO,CAAK;IACpB,sEAAsE;IACtE,OAAO,CAAC,OAAO,CAAK;IACpB,sCAAsC;IACtC,OAAO,CAAC,UAAU,CAAC,CAAgB;IAEnC,qDAAqD;IACrD,IAAI,MAAM,IAAI,OAAO,CAEpB;IAED;2EACuE;IACvE,IAAI,eAAe,IAAI,OAAO,CAE7B;IAED,6CAA6C;IAC7C,IAAI,OAAO,IAAI,MAAM,CAEpB;IAED,KAAK,IAAI,IAAI;IAIb,+CAA+C;IAC/C,KAAK,CAAC,UAAU,EAAE,OAAO,CAAC,IAAI,CAAC,GAAG,IAAI;IAOtC;;;;4BAIwB;IAClB,KAAK,CAAC,OAAO,EAAE,MAAM,OAAO,CAAC,OAAO,CAAC,EAAE,OAAO,EAAE,MAAM,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC;CAcjF"}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The client-set title of a session (`_session/rename`), as `SessionTitles`
|
|
3
|
+
* guards it against generated titles: once a client named the session, or
|
|
4
|
+
* while a rename is in flight, no generated title is adopted, and a rename
|
|
5
|
+
* waits for a generation in flight, whose persisted title would otherwise
|
|
6
|
+
* land after it.
|
|
7
|
+
*/
|
|
8
|
+
export class ExplicitTitle {
|
|
9
|
+
/** Set once a client named the session. Released by {@link reset}. */
|
|
10
|
+
named = false;
|
|
11
|
+
/** Renames in flight. */
|
|
12
|
+
pending = 0;
|
|
13
|
+
/** Renames started so far, to tell that one ran between two reads. */
|
|
14
|
+
started = 0;
|
|
15
|
+
/** The title generation in flight. */
|
|
16
|
+
generation;
|
|
17
|
+
/** Whether a generated title must not be adopted. */
|
|
18
|
+
get active() {
|
|
19
|
+
return this.named || this.pending > 0;
|
|
20
|
+
}
|
|
21
|
+
/** Whether a client named the session and no rename is in flight: a title
|
|
22
|
+
* stored since by someone else (a `/rename`) may still be adopted. */
|
|
23
|
+
get settledByClient() {
|
|
24
|
+
return this.named && this.pending === 0;
|
|
25
|
+
}
|
|
26
|
+
/** Changes with every rename that starts. */
|
|
27
|
+
get renames() {
|
|
28
|
+
return this.started;
|
|
29
|
+
}
|
|
30
|
+
reset() {
|
|
31
|
+
this.named = false;
|
|
32
|
+
}
|
|
33
|
+
/** Tracks a title generation until it ends. */
|
|
34
|
+
track(generation) {
|
|
35
|
+
this.generation = generation;
|
|
36
|
+
void generation.finally(() => {
|
|
37
|
+
if (this.generation === generation)
|
|
38
|
+
this.generation = undefined;
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
/** Persists a client title after the generation in flight. `persist`
|
|
42
|
+
* tells whether the session is named now. When it fails or names nothing
|
|
43
|
+
* (an archive of an archived session), and no other rename named the
|
|
44
|
+
* session, `restore` puts the title state back, so a later turn may still
|
|
45
|
+
* generate a title. */
|
|
46
|
+
async apply(persist, restore) {
|
|
47
|
+
this.pending++;
|
|
48
|
+
this.started++;
|
|
49
|
+
try {
|
|
50
|
+
await this.generation;
|
|
51
|
+
if (await persist())
|
|
52
|
+
this.named = true;
|
|
53
|
+
else if (!this.named && this.pending === 1)
|
|
54
|
+
restore();
|
|
55
|
+
}
|
|
56
|
+
catch (error) {
|
|
57
|
+
if (!this.named && this.pending === 1)
|
|
58
|
+
restore();
|
|
59
|
+
throw error;
|
|
60
|
+
}
|
|
61
|
+
finally {
|
|
62
|
+
this.pending--;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The first prompt of a transcript, extracted the way the SDK does for a
|
|
3
|
+
* session title (its `rf`, `ti` and `jP` in `@anthropic-ai/claude-agent-sdk`).
|
|
4
|
+
* Used when the SDK `getSessionInfo` read another copy of the session.
|
|
5
|
+
*
|
|
6
|
+
* - Only main-chain user records count: no tool result, meta or compact
|
|
7
|
+
* summary record.
|
|
8
|
+
* - Each text (the string content, or every text block) is expanded from its
|
|
9
|
+
* `<pasted_content>` blocks and flattened to one line.
|
|
10
|
+
* - A slash command (`<command-name>/init</command-name>`) is only a fallback
|
|
11
|
+
* while a real prompt may follow; `<bash-input>x</bash-input>` is `! x`;
|
|
12
|
+
* a text that starts with another tag or with an interrupt marker is
|
|
13
|
+
* skipped.
|
|
14
|
+
* - A prompt longer than 200 characters is cut and gets an ellipsis.
|
|
15
|
+
* - Without any text, an image or a document prompt is `Image` or `Document`.
|
|
16
|
+
*/
|
|
17
|
+
type Entry = Record<string, unknown>;
|
|
18
|
+
/**
|
|
19
|
+
* The prompt of one user record, or undefined when it has none. A slash
|
|
20
|
+
* command is recorded in `state.commandFallback` instead.
|
|
21
|
+
*/
|
|
22
|
+
export declare function promptOf(entry: Entry, state: {
|
|
23
|
+
commandFallback: string;
|
|
24
|
+
}): string | undefined;
|
|
25
|
+
/**
|
|
26
|
+
* Whether a user record is a prompt the user sent: one with prompt text
|
|
27
|
+
* ({@link promptOf}), or one with an image or a document, which the SDK
|
|
28
|
+
* titles `Image` or `Document` when it has no prompt text. A tool result, a meta or compact summary
|
|
29
|
+
* record, an interrupt and a slash command are none.
|
|
30
|
+
*/
|
|
31
|
+
export declare function isUserPrompt(entry: Entry): boolean;
|
|
32
|
+
/** The first prompt of the head, else the first slash command, else "". */
|
|
33
|
+
export declare function firstPrompt(head: string): string;
|
|
34
|
+
/** `Image` or `Document` for a first user record that holds one, else "". */
|
|
35
|
+
export declare function mediaPrompt(head: string): string;
|
|
36
|
+
export {};
|
|
37
|
+
//# sourceMappingURL=first-prompt.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"first-prompt.d.ts","sourceRoot":"","sources":["../../src/session-index/first-prompt.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;GAeG;AAQH,KAAK,KAAK,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;AA+CrC;;;GAGG;AACH,wBAAgB,QAAQ,CAAC,KAAK,EAAE,KAAK,EAAE,KAAK,EAAE;IAAE,eAAe,EAAE,MAAM,CAAA;CAAE,GAAG,MAAM,GAAG,SAAS,CAgC7F;AAED;;;;;GAKG;AACH,wBAAgB,YAAY,CAAC,KAAK,EAAE,KAAK,GAAG,OAAO,CAelD;AAMD,2EAA2E;AAC3E,wBAAgB,WAAW,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAoBhD;AAED,6EAA6E;AAC7E,wBAAgB,WAAW,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAWhD"}
|