@agentclientprotocol/claude-agent-acp 0.88.1-preview.5 → 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.
Files changed (62) hide show
  1. package/dist/acp-agent.d.ts +39 -2
  2. package/dist/acp-agent.d.ts.map +1 -1
  3. package/dist/acp-agent.js +120 -35
  4. package/dist/air-extension.d.ts +23 -0
  5. package/dist/air-extension.d.ts.map +1 -1
  6. package/dist/air-extension.js +23 -0
  7. package/dist/diff.d.ts +2 -0
  8. package/dist/diff.d.ts.map +1 -1
  9. package/dist/diff.js +37 -1
  10. package/dist/mcp-command.d.ts.map +1 -1
  11. package/dist/mcp-command.js +5 -0
  12. package/dist/session-index/activity.d.ts +56 -0
  13. package/dist/session-index/activity.d.ts.map +1 -0
  14. package/dist/session-index/activity.js +96 -0
  15. package/dist/session-index/archive-title.d.ts +52 -0
  16. package/dist/session-index/archive-title.d.ts.map +1 -0
  17. package/dist/session-index/archive-title.js +77 -0
  18. package/dist/session-index/connection.d.ts +152 -0
  19. package/dist/session-index/connection.d.ts.map +1 -0
  20. package/dist/session-index/connection.js +310 -0
  21. package/dist/session-index/dir-listing.d.ts +30 -0
  22. package/dist/session-index/dir-listing.d.ts.map +1 -0
  23. package/dist/session-index/dir-listing.js +110 -0
  24. package/dist/session-index/explicit-title.d.ts +34 -0
  25. package/dist/session-index/explicit-title.d.ts.map +1 -0
  26. package/dist/session-index/explicit-title.js +65 -0
  27. package/dist/session-index/first-prompt.d.ts +37 -0
  28. package/dist/session-index/first-prompt.d.ts.map +1 -0
  29. package/dist/session-index/first-prompt.js +195 -0
  30. package/dist/session-index/list-subscriptions.d.ts +190 -0
  31. package/dist/session-index/list-subscriptions.d.ts.map +1 -0
  32. package/dist/session-index/list-subscriptions.js +1163 -0
  33. package/dist/session-index/live-registry.d.ts +72 -0
  34. package/dist/session-index/live-registry.d.ts.map +1 -0
  35. package/dist/session-index/live-registry.js +240 -0
  36. package/dist/session-index/project-dirs.d.ts +45 -0
  37. package/dist/session-index/project-dirs.d.ts.map +1 -0
  38. package/dist/session-index/project-dirs.js +122 -0
  39. package/dist/session-index/service.d.ts +158 -0
  40. package/dist/session-index/service.d.ts.map +1 -0
  41. package/dist/session-index/service.js +546 -0
  42. package/dist/session-index/session-index.d.ts +201 -0
  43. package/dist/session-index/session-index.d.ts.map +1 -0
  44. package/dist/session-index/session-index.js +740 -0
  45. package/dist/session-index/session-info.d.ts +16 -0
  46. package/dist/session-index/session-info.d.ts.map +1 -0
  47. package/dist/session-index/session-info.js +51 -0
  48. package/dist/session-index/transcript-scan.d.ts +137 -0
  49. package/dist/session-index/transcript-scan.d.ts.map +1 -0
  50. package/dist/session-index/transcript-scan.js +545 -0
  51. package/dist/session-index/worktrees.d.ts +25 -0
  52. package/dist/session-index/worktrees.d.ts.map +1 -0
  53. package/dist/session-index/worktrees.js +128 -0
  54. package/dist/session-titles.d.ts +21 -0
  55. package/dist/session-titles.d.ts.map +1 -1
  56. package/dist/session-titles.js +101 -5
  57. package/dist/utils.d.ts +31 -0
  58. package/dist/utils.d.ts.map +1 -1
  59. package/dist/utils.js +60 -2
  60. package/dist/v2/agent.d.ts.map +1 -1
  61. package/dist/v2/agent.js +33 -4
  62. 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"}