@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,546 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `sessionIndex` AIR extension: the session list, rename, archive and
|
|
3
|
+
* delete of a client that declared the capability.
|
|
4
|
+
*
|
|
5
|
+
* Wire contract: docs/air-extensions.md, "Session index". Everything here is
|
|
6
|
+
* reached only for a `sessionIndex` client, except the archive that an AIR
|
|
7
|
+
* client without the capability gets in place of a delete (see
|
|
8
|
+
* `ClaudeAcpAgent.deleteSession`).
|
|
9
|
+
*/
|
|
10
|
+
import { randomBytes } from "node:crypto";
|
|
11
|
+
import * as fs from "node:fs/promises";
|
|
12
|
+
import * as path from "node:path";
|
|
13
|
+
import { RequestError, } from "@agentclientprotocol/sdk";
|
|
14
|
+
import { deleteSession as sdkDeleteSession, getSessionInfo as sdkGetSessionInfo, } from "@anthropic-ai/claude-agent-sdk";
|
|
15
|
+
import { airExtensionMeta } from "../air-extension.js";
|
|
16
|
+
import { sanitizeTitle } from "../session-titles.js";
|
|
17
|
+
import { effectiveTitle, isArchivedTitle, storedTitle, titleRecords, visibleTitle, } from "./archive-title.js";
|
|
18
|
+
import { ListSubscriptions, } from "./list-subscriptions.js";
|
|
19
|
+
import { LiveSessionRegistry } from "./live-registry.js";
|
|
20
|
+
import { canonicalPath, errorCode, isExactProjectDir, isSessionId, projectDirMatches, sameProjectPath, } from "./project-dirs.js";
|
|
21
|
+
import { readHeadTail, readHeadTailOf, sdkTitles, tailCustomTitle, transcriptAgentName, transcriptProjectCwd, } from "./transcript-scan.js";
|
|
22
|
+
import { DEFAULT_LIST_LIMIT, MAX_LIST_LIMIT, ARCHIVED_FILTERS, readSidecarTitle, SessionIndex, } from "./session-index.js";
|
|
23
|
+
import { sessionInfoOf } from "./session-info.js";
|
|
24
|
+
export const SESSION_RENAME_METHOD = "_session/rename";
|
|
25
|
+
export const SESSION_ARCHIVE_METHOD = "_session/archive";
|
|
26
|
+
export const SESSION_UNARCHIVE_METHOD = "_session/unarchive";
|
|
27
|
+
/** The JSON-RPC code of an unknown session (ACP `ResourceNotFound`). */
|
|
28
|
+
const RESOURCE_NOT_FOUND = -32002;
|
|
29
|
+
/** The cursor format. A cursor of another version is rejected. */
|
|
30
|
+
const CURSOR_VERSION = 5;
|
|
31
|
+
export function sessionNotFound(sessionId) {
|
|
32
|
+
return new RequestError(RESOURCE_NOT_FOUND, `Session not found: ${sessionId}`, { sessionId });
|
|
33
|
+
}
|
|
34
|
+
function asRecord(value) {
|
|
35
|
+
return value && typeof value === "object" && !Array.isArray(value)
|
|
36
|
+
? value
|
|
37
|
+
: {};
|
|
38
|
+
}
|
|
39
|
+
export function parseSessionIdRequest(value) {
|
|
40
|
+
const sessionId = asRecord(value).sessionId;
|
|
41
|
+
if (typeof sessionId !== "string" || !sessionId.trim()) {
|
|
42
|
+
throw RequestError.invalidParams(undefined, "params require a non-empty sessionId");
|
|
43
|
+
}
|
|
44
|
+
return { sessionId: sessionId.trim() };
|
|
45
|
+
}
|
|
46
|
+
export function parseRenameSessionRequest(value) {
|
|
47
|
+
const { sessionId } = parseSessionIdRequest(value);
|
|
48
|
+
const raw = asRecord(value).title;
|
|
49
|
+
const title = typeof raw === "string" ? sanitizeTitle(raw) : "";
|
|
50
|
+
if (!title)
|
|
51
|
+
throw RequestError.invalidParams(undefined, "title must be a non-empty string");
|
|
52
|
+
return { sessionId, title };
|
|
53
|
+
}
|
|
54
|
+
function archivedFilter(list) {
|
|
55
|
+
// Omitted or null is `unarchived`.
|
|
56
|
+
const value = list.archived ?? "unarchived";
|
|
57
|
+
if (!ARCHIVED_FILTERS.includes(value)) {
|
|
58
|
+
throw RequestError.invalidParams({ archived: value }, '`_meta.jetbrains.air.list.archived` must be "unarchived", "archived" or "all"');
|
|
59
|
+
}
|
|
60
|
+
return value;
|
|
61
|
+
}
|
|
62
|
+
function optionalBoolean(list, key) {
|
|
63
|
+
// Omitted or null is false.
|
|
64
|
+
const value = list[key] ?? false;
|
|
65
|
+
if (typeof value !== "boolean") {
|
|
66
|
+
throw RequestError.invalidParams({ [key]: value }, `\`_meta.jetbrains.air.list.${key}\` must be a boolean`);
|
|
67
|
+
}
|
|
68
|
+
return value;
|
|
69
|
+
}
|
|
70
|
+
/** `_meta.jetbrains.air.list` of a list request. */
|
|
71
|
+
export function parseListOptions(meta) {
|
|
72
|
+
const list = asRecord(airExtensionMeta(meta)?.list);
|
|
73
|
+
// Omitted or null is the default; an integer of at least 1 is clamped;
|
|
74
|
+
// anything else is invalid.
|
|
75
|
+
const raw = list.limit ?? DEFAULT_LIST_LIMIT;
|
|
76
|
+
if (typeof raw !== "number" || !Number.isInteger(raw) || raw < 1) {
|
|
77
|
+
throw RequestError.invalidParams({ limit: raw }, "`_meta.jetbrains.air.list.limit` must be an integer of at least 1");
|
|
78
|
+
}
|
|
79
|
+
return {
|
|
80
|
+
limit: Math.min(MAX_LIST_LIMIT, raw),
|
|
81
|
+
archived: archivedFilter(list),
|
|
82
|
+
includeWorktrees: optionalBoolean(list, "includeWorktrees"),
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
export function encodeListCursor(cursor, scope) {
|
|
86
|
+
const payload = {
|
|
87
|
+
v: CURSOR_VERSION,
|
|
88
|
+
u: cursor.orderAtMs,
|
|
89
|
+
id: cursor.sessionId,
|
|
90
|
+
cwd: scope.cwd,
|
|
91
|
+
archived: scope.archived,
|
|
92
|
+
worktrees: scope.includeWorktrees,
|
|
93
|
+
};
|
|
94
|
+
return Buffer.from(JSON.stringify(payload)).toString("base64url");
|
|
95
|
+
}
|
|
96
|
+
/** The position a cursor names. A cursor of another cwd or filter, or one
|
|
97
|
+
* this adapter did not issue, is rejected. */
|
|
98
|
+
export function decodeListCursor(cursor, scope) {
|
|
99
|
+
let payload;
|
|
100
|
+
try {
|
|
101
|
+
payload = JSON.parse(Buffer.from(cursor, "base64url").toString("utf8"));
|
|
102
|
+
}
|
|
103
|
+
catch {
|
|
104
|
+
payload = undefined;
|
|
105
|
+
}
|
|
106
|
+
if (!payload ||
|
|
107
|
+
payload.v !== CURSOR_VERSION ||
|
|
108
|
+
typeof payload.u !== "number" ||
|
|
109
|
+
typeof payload.id !== "string") {
|
|
110
|
+
throw RequestError.invalidParams(undefined, `Unknown session/list cursor: ${cursor}`);
|
|
111
|
+
}
|
|
112
|
+
if (payload.cwd !== scope.cwd ||
|
|
113
|
+
payload.archived !== scope.archived ||
|
|
114
|
+
payload.worktrees !== scope.includeWorktrees) {
|
|
115
|
+
throw RequestError.invalidParams(undefined, "The session/list cursor belongs to another cwd or filter");
|
|
116
|
+
}
|
|
117
|
+
return { orderAtMs: payload.u, sessionId: payload.id };
|
|
118
|
+
}
|
|
119
|
+
/** `<projectDir>/<sessionId>/custom-title.json` of a transcript. */
|
|
120
|
+
function sidecarPath(transcriptPath) {
|
|
121
|
+
const sessionId = path.basename(transcriptPath, ".jsonl");
|
|
122
|
+
return path.join(path.dirname(transcriptPath), sessionId, "custom-title.json");
|
|
123
|
+
}
|
|
124
|
+
/** Writes `<projectDir>/<sessionId>/custom-title.json` the way the CLI's
|
|
125
|
+
* `/rename` does: file 0600 in a 0700 directory, replaced atomically. The
|
|
126
|
+
* temporary file has a random name of the CLI's `custom-title.json.tmp.*`
|
|
127
|
+
* pattern, and only a temporary file this call created is removed. */
|
|
128
|
+
export async function writeCustomTitleSidecar(transcriptPath, title) {
|
|
129
|
+
const target = sidecarPath(transcriptPath);
|
|
130
|
+
await fs.mkdir(path.dirname(target), { recursive: true, mode: 0o700 });
|
|
131
|
+
const temporary = `${target}.tmp.${randomBytes(8).toString("hex")}`;
|
|
132
|
+
const handle = await fs.open(temporary, "wx", 0o600);
|
|
133
|
+
try {
|
|
134
|
+
try {
|
|
135
|
+
await handle.writeFile(JSON.stringify({ customTitle: title }));
|
|
136
|
+
}
|
|
137
|
+
finally {
|
|
138
|
+
await handle.close();
|
|
139
|
+
}
|
|
140
|
+
await fs.rename(temporary, target);
|
|
141
|
+
}
|
|
142
|
+
catch (error) {
|
|
143
|
+
await fs.rm(temporary, { force: true });
|
|
144
|
+
throw error;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
/** The last two non-empty lines of a file, from its last 64 KB, and
|
|
148
|
+
* whether the file ends with a newline. */
|
|
149
|
+
async function lastLines(filePath) {
|
|
150
|
+
const handle = await fs.open(filePath, "r");
|
|
151
|
+
try {
|
|
152
|
+
const { size } = await handle.stat();
|
|
153
|
+
const length = Math.min(size, 64 * 1024);
|
|
154
|
+
const buffer = Buffer.alloc(length);
|
|
155
|
+
await handle.read(buffer, 0, length, size - length);
|
|
156
|
+
const text = buffer.toString("utf8");
|
|
157
|
+
const lines = text.split("\n").filter((line) => line.trim());
|
|
158
|
+
return {
|
|
159
|
+
lines: lines.slice(-2),
|
|
160
|
+
last: lines.at(-1) ?? "",
|
|
161
|
+
endsWithNewline: text.endsWith("\n"),
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
finally {
|
|
165
|
+
await handle.close();
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
/** Appends the title records of `title` (see archive-title.ts) to a
|
|
169
|
+
* transcript whose last two records are not those already, whoever else
|
|
170
|
+
* has the session open, as the CLI's `/rename` does. A torn last line is
|
|
171
|
+
* closed first. */
|
|
172
|
+
async function ensureTitleRecords(filePath, sessionId, title) {
|
|
173
|
+
const records = titleRecords(sessionId, title);
|
|
174
|
+
const { lines, last, endsWithNewline } = await lastLines(filePath);
|
|
175
|
+
if (lines.join("\n") + "\n" === records)
|
|
176
|
+
return;
|
|
177
|
+
const complete = endsWithNewline || last === "";
|
|
178
|
+
await fs.appendFile(filePath, `${complete ? "" : "\n"}${records}`);
|
|
179
|
+
}
|
|
180
|
+
/** Appends the `agent-name` record of `title` when the copy's agent name
|
|
181
|
+
* is another one, so its archive state follows `title`. Reads and appends
|
|
182
|
+
* through one handle. */
|
|
183
|
+
async function ensureAgentName(filePath, sessionId, title) {
|
|
184
|
+
const handle = await fs.open(filePath, "a+");
|
|
185
|
+
try {
|
|
186
|
+
const { size } = await handle.stat();
|
|
187
|
+
const headTail = await readHeadTailOf(handle, size);
|
|
188
|
+
const agentName = transcriptAgentName(headTail);
|
|
189
|
+
if (agentName === undefined || agentName === title)
|
|
190
|
+
return;
|
|
191
|
+
const complete = size === 0 || headTail.tail.endsWith("\n");
|
|
192
|
+
const record = JSON.stringify({ type: "agent-name", agentName: title, sessionId });
|
|
193
|
+
await handle.appendFile(`${complete ? "" : "\n"}${record}\n`);
|
|
194
|
+
}
|
|
195
|
+
finally {
|
|
196
|
+
await handle.close();
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
/** The title of one transcript copy as the list reads it (see
|
|
200
|
+
* {@link effectiveTitle}): its agent name, else the title the SDK reports.
|
|
201
|
+
* It carries the archive prefix only when the copy is archived: a generated
|
|
202
|
+
* title never archives a session. */
|
|
203
|
+
async function copyTitle(filePath, sessionId) {
|
|
204
|
+
const { size } = await fs.stat(filePath);
|
|
205
|
+
const headTail = await readHeadTail(filePath, size);
|
|
206
|
+
const sidecar = tailCustomTitle(headTail.tail) === undefined
|
|
207
|
+
? await readSidecarTitle(filePath, sessionId)
|
|
208
|
+
: undefined;
|
|
209
|
+
const { title, archived } = effectiveTitle(transcriptAgentName(headTail), sdkTitles(headTail, sidecar));
|
|
210
|
+
return title !== undefined && !archived && isArchivedTitle(title) ? visibleTitle(title) : title;
|
|
211
|
+
}
|
|
212
|
+
/** Rewrites the sidecar of a copy whose title is `current` when the
|
|
213
|
+
* sidecar says the other archive state (a sidecar write that failed after
|
|
214
|
+
* the records were appended). */
|
|
215
|
+
async function alignSidecar(transcript, current, sessionId) {
|
|
216
|
+
if (current === undefined)
|
|
217
|
+
return;
|
|
218
|
+
const sidecar = await readSidecarTitle(transcript, sessionId);
|
|
219
|
+
if (sidecar === undefined || isArchivedTitle(sidecar) === isArchivedTitle(current))
|
|
220
|
+
return;
|
|
221
|
+
await writeCustomTitleSidecar(transcript, storedTitle(current, isArchivedTitle(current), sessionId));
|
|
222
|
+
}
|
|
223
|
+
/** `_session/rename`: the new title, with the archive prefix on a copy
|
|
224
|
+
* that is archived, so a rename keeps the archive state. */
|
|
225
|
+
export function renameTo(title) {
|
|
226
|
+
return (current, sessionId) => isArchivedTitle(current) ? storedTitle(title, true, sessionId) : title;
|
|
227
|
+
}
|
|
228
|
+
/** `_session/archive` and `_session/unarchive`: the current title with or
|
|
229
|
+
* without the archive prefix; a copy already in that state is left alone. */
|
|
230
|
+
export function archiveTo(archived) {
|
|
231
|
+
return (current, sessionId) => isArchivedTitle(current) === archived
|
|
232
|
+
? undefined
|
|
233
|
+
: storedTitle(current ?? "", archived, sessionId);
|
|
234
|
+
}
|
|
235
|
+
/**
|
|
236
|
+
* Whether `transcript` lies in the project directory of one of `paths`, the
|
|
237
|
+
* one the CLI of that cwd writes: the exact encoding, or, for a long path
|
|
238
|
+
* whose name the CLI hashes differently, a directory with the cut prefix
|
|
239
|
+
* whose transcript belongs to the path. Another long path that shares the
|
|
240
|
+
* prefix does not count.
|
|
241
|
+
*/
|
|
242
|
+
async function isTranscriptOf(transcript, paths) {
|
|
243
|
+
const dirName = path.basename(path.dirname(transcript));
|
|
244
|
+
if (paths.some((cwd) => isExactProjectDir(dirName, cwd)))
|
|
245
|
+
return true;
|
|
246
|
+
if (!paths.some((cwd) => projectDirMatches(dirName, cwd)))
|
|
247
|
+
return false;
|
|
248
|
+
try {
|
|
249
|
+
const { size } = await fs.stat(transcript);
|
|
250
|
+
const cwd = transcriptProjectCwd(await readHeadTail(transcript, size));
|
|
251
|
+
return cwd !== undefined && paths.some((projectPath) => sameProjectPath(cwd, projectPath));
|
|
252
|
+
}
|
|
253
|
+
catch {
|
|
254
|
+
// Unreadable: not known to be the CLI's own copy.
|
|
255
|
+
return false;
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
function isSdkNotFound(error) {
|
|
259
|
+
return error instanceof Error && /^Session \S+ not found in /.test(error.message);
|
|
260
|
+
}
|
|
261
|
+
async function exists(target) {
|
|
262
|
+
try {
|
|
263
|
+
await fs.lstat(target);
|
|
264
|
+
return true;
|
|
265
|
+
}
|
|
266
|
+
catch (error) {
|
|
267
|
+
if (errorCode(error) === "ENOENT" || errorCode(error) === "ENOTDIR")
|
|
268
|
+
return false;
|
|
269
|
+
throw error;
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
/** The error the SDK `deleteSession` gives for a session id it rejects or
|
|
273
|
+
* does not find, which a client without `sessionIndex` got before. */
|
|
274
|
+
function sdkDeleteError(sessionId) {
|
|
275
|
+
return new Error(isSessionId(sessionId)
|
|
276
|
+
? `Session ${sessionId} not found in any project directory`
|
|
277
|
+
: `Invalid sessionId: ${sessionId}`);
|
|
278
|
+
}
|
|
279
|
+
/**
|
|
280
|
+
* `session/delete` of an AIR client without `sessionIndex`, which uses delete
|
|
281
|
+
* to mark a session done: archives it instead, so the transcript survives. A
|
|
282
|
+
* session without a transcript fails as the SDK delete did.
|
|
283
|
+
*/
|
|
284
|
+
export async function archiveInsteadOfDelete(sessionId, service) {
|
|
285
|
+
if (!isSessionId(sessionId))
|
|
286
|
+
throw sdkDeleteError(sessionId);
|
|
287
|
+
// The SDK delete skips empty transcripts, so they do not count.
|
|
288
|
+
const found = await service.index.findTranscripts(sessionId);
|
|
289
|
+
if (found.length === 0)
|
|
290
|
+
throw sdkDeleteError(sessionId);
|
|
291
|
+
await service.retitle(sessionId, archiveTo(true), { sidecar: "existing" });
|
|
292
|
+
}
|
|
293
|
+
export class SessionIndexService {
|
|
294
|
+
deps;
|
|
295
|
+
index;
|
|
296
|
+
registry;
|
|
297
|
+
subscriptions;
|
|
298
|
+
/** Set for good by {@link dispose}: no subscription is made after it. */
|
|
299
|
+
disposed = false;
|
|
300
|
+
/** The mutation in flight per session, which the next one waits for. */
|
|
301
|
+
mutations = new Map();
|
|
302
|
+
now;
|
|
303
|
+
deleteSession;
|
|
304
|
+
constructor(deps) {
|
|
305
|
+
this.deps = deps;
|
|
306
|
+
this.index = new SessionIndex(deps.getSessionInfo ?? sdkGetSessionInfo);
|
|
307
|
+
this.registry = deps.registry ?? new LiveSessionRegistry();
|
|
308
|
+
this.now = deps.now ?? Date.now;
|
|
309
|
+
this.deleteSession = deps.deleteSession ?? ((id) => sdkDeleteSession(id));
|
|
310
|
+
}
|
|
311
|
+
/** `session/list` of a `sessionIndex` client. `own` reports the sessions
|
|
312
|
+
* that this connection runs. */
|
|
313
|
+
async list(params, own) {
|
|
314
|
+
const { limit, archived, includeWorktrees } = parseListOptions(params._meta);
|
|
315
|
+
const cwd = params.cwd ?? null;
|
|
316
|
+
const scope = { cwd, archived, includeWorktrees };
|
|
317
|
+
const after = params.cursor === null || params.cursor === undefined
|
|
318
|
+
? undefined
|
|
319
|
+
: decodeListCursor(params.cursor, scope);
|
|
320
|
+
// The live registry is read while the transcripts are.
|
|
321
|
+
const livePromise = this.registry.snapshot();
|
|
322
|
+
const { rows, hasMore } = await this.index.list({
|
|
323
|
+
cwd,
|
|
324
|
+
includeWorktrees,
|
|
325
|
+
limit,
|
|
326
|
+
archived,
|
|
327
|
+
after,
|
|
328
|
+
});
|
|
329
|
+
const live = await livePromise;
|
|
330
|
+
const now = this.now();
|
|
331
|
+
const sessions = rows.map((row) => sessionInfoOf(row, own(row.sessionId), live.get(row.sessionId), now));
|
|
332
|
+
const last = rows[rows.length - 1];
|
|
333
|
+
return hasMore && last ? { sessions, nextCursor: encodeListCursor(last, scope) } : { sessions };
|
|
334
|
+
}
|
|
335
|
+
/** Runs `mutation` after the previous mutation of the session ended. */
|
|
336
|
+
exclusive(sessionId, mutation) {
|
|
337
|
+
const previous = this.mutations.get(sessionId) ?? Promise.resolve();
|
|
338
|
+
const next = previous.catch(() => undefined).then(mutation);
|
|
339
|
+
const settled = next.catch(() => undefined);
|
|
340
|
+
this.mutations.set(sessionId, settled);
|
|
341
|
+
void settled.then(() => {
|
|
342
|
+
if (this.mutations.get(sessionId) === settled)
|
|
343
|
+
this.mutations.delete(sessionId);
|
|
344
|
+
});
|
|
345
|
+
return next;
|
|
346
|
+
}
|
|
347
|
+
/**
|
|
348
|
+
* Retitles every transcript of a session with `change`: rename, archive
|
|
349
|
+
* and unarchive. Returns the title stored through the CLI that runs here,
|
|
350
|
+
* else the title of the first copy that got one; undefined when nothing
|
|
351
|
+
* was stored.
|
|
352
|
+
*
|
|
353
|
+
* - The CLI runs here (`live`): it titles its own transcript and sidecar
|
|
354
|
+
* (`rename_session`), the only writer that cannot be overtaken by the
|
|
355
|
+
* title the CLI holds. The other copies get the records and the sidecar
|
|
356
|
+
* here, as a best effort once the CLI has the title: a failure is
|
|
357
|
+
* logged, not returned.
|
|
358
|
+
* - Otherwise every transcript gets the records (and the sidecar, see
|
|
359
|
+
* {@link RetitleOptions.sidecar}).
|
|
360
|
+
*
|
|
361
|
+
* As the CLI's `/rename`, the records are written whether or not another
|
|
362
|
+
* process has the session open.
|
|
363
|
+
*
|
|
364
|
+
* A session without a transcript is unknown (`-32002`), unless
|
|
365
|
+
* {@link RetitleOptions.mayBeUnwritten}.
|
|
366
|
+
*/
|
|
367
|
+
async retitle(sessionId, change, options) {
|
|
368
|
+
const { live } = options;
|
|
369
|
+
if (!isSessionId(sessionId)) {
|
|
370
|
+
if (!live || !options.mayBeUnwritten)
|
|
371
|
+
throw sessionNotFound(sessionId);
|
|
372
|
+
return this.exclusive(sessionId, async () => {
|
|
373
|
+
const title = change(live.stored() ?? live.shown, sessionId);
|
|
374
|
+
if (title !== undefined) {
|
|
375
|
+
await live.rename(title);
|
|
376
|
+
live.remember(title);
|
|
377
|
+
}
|
|
378
|
+
return title;
|
|
379
|
+
});
|
|
380
|
+
}
|
|
381
|
+
return this.exclusive(sessionId, async () => {
|
|
382
|
+
const transcripts = await this.index.findTranscripts(sessionId);
|
|
383
|
+
if (transcripts.length === 0 && !options.mayBeUnwritten)
|
|
384
|
+
throw sessionNotFound(sessionId);
|
|
385
|
+
let stored;
|
|
386
|
+
// The cache is left as it is: the appended records change the size,
|
|
387
|
+
// and the next read keeps what the earlier scans found (a last prompt
|
|
388
|
+
// beyond the tail search).
|
|
389
|
+
if (live) {
|
|
390
|
+
const canonical = await canonicalPath(live.cwd);
|
|
391
|
+
const paths = [...new Set([live.cwd, canonical])];
|
|
392
|
+
const own = [];
|
|
393
|
+
for (const transcript of transcripts) {
|
|
394
|
+
if (await isTranscriptOf(transcript, paths))
|
|
395
|
+
own.push(transcript);
|
|
396
|
+
}
|
|
397
|
+
// The CLI writes the transcript of the resolved cwd.
|
|
398
|
+
const cli = own.find((transcript) => isExactProjectDir(path.basename(path.dirname(transcript)), canonical)) ?? own[0];
|
|
399
|
+
const others = transcripts.filter((transcript) => transcript !== cli);
|
|
400
|
+
// The transcript holds the current title, also one changed otherwise
|
|
401
|
+
// (a `/rename`); without one, the title last given to the CLI.
|
|
402
|
+
const primary = cli ?? others[0];
|
|
403
|
+
const read = primary ? await copyTitle(primary, sessionId) : undefined;
|
|
404
|
+
const current = read ?? live.stored() ?? live.shown;
|
|
405
|
+
stored = change(current, sessionId);
|
|
406
|
+
if (stored !== undefined) {
|
|
407
|
+
await live.rename(stored);
|
|
408
|
+
live.remember(stored);
|
|
409
|
+
// The CLI writes the custom title before it answers, the agent name
|
|
410
|
+
// only later. An older agent name decides the archive state first,
|
|
411
|
+
// so the copy gets the new one now.
|
|
412
|
+
// A best effort: the CLI took the title, and writes the agent name
|
|
413
|
+
// itself too.
|
|
414
|
+
if (cli) {
|
|
415
|
+
try {
|
|
416
|
+
await ensureAgentName(cli, sessionId, stored);
|
|
417
|
+
}
|
|
418
|
+
catch (error) {
|
|
419
|
+
this.deps.logError(`writing the agent name of ${sessionId} failed`, error);
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
else if (cli) {
|
|
424
|
+
await alignSidecar(cli, current, sessionId);
|
|
425
|
+
}
|
|
426
|
+
try {
|
|
427
|
+
await this.titleCopies(sessionId, others, change, options.sidecar);
|
|
428
|
+
}
|
|
429
|
+
catch (error) {
|
|
430
|
+
this.deps.logError(`titling the other transcripts of ${sessionId} failed`, error);
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
else {
|
|
434
|
+
stored = await this.titleCopies(sessionId, transcripts, change, options.sidecar);
|
|
435
|
+
}
|
|
436
|
+
return stored;
|
|
437
|
+
});
|
|
438
|
+
}
|
|
439
|
+
/** Appends the title records `change` gives each copy, then its sidecar.
|
|
440
|
+
* A copy already in the requested state gets nothing, but its sidecar is
|
|
441
|
+
* brought in line with it. Returns the title the first titled copy got. */
|
|
442
|
+
async titleCopies(sessionId, transcripts, change, sidecar) {
|
|
443
|
+
let first;
|
|
444
|
+
for (const transcript of transcripts) {
|
|
445
|
+
const current = await copyTitle(transcript, sessionId);
|
|
446
|
+
const title = change(current, sessionId);
|
|
447
|
+
if (title === undefined) {
|
|
448
|
+
await alignSidecar(transcript, current, sessionId);
|
|
449
|
+
continue;
|
|
450
|
+
}
|
|
451
|
+
await ensureTitleRecords(transcript, sessionId, title);
|
|
452
|
+
first ??= title;
|
|
453
|
+
if (sidecar === "always" || (await exists(sidecarPath(transcript)))) {
|
|
454
|
+
await writeCustomTitleSidecar(transcript, title);
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
return first;
|
|
458
|
+
}
|
|
459
|
+
/** Deletes every transcript of the session, whoever else has it open, as
|
|
460
|
+
* the CLI does. `known`: the session was loaded here (and is torn down
|
|
461
|
+
* already), so a missing transcript is no error.
|
|
462
|
+
*
|
|
463
|
+
* The SDK deletes the first non-empty transcript it finds and its
|
|
464
|
+
* `<sessionId>/` directory, one copy per call; empty transcripts, which it
|
|
465
|
+
* skips, are removed here. */
|
|
466
|
+
async delete(sessionId, known) {
|
|
467
|
+
if (!isSessionId(sessionId))
|
|
468
|
+
throw sessionNotFound(sessionId);
|
|
469
|
+
await this.exclusive(sessionId, async () => {
|
|
470
|
+
// A session directory can outlive its transcript: a delete that removed
|
|
471
|
+
// the transcript and then failed on the directory.
|
|
472
|
+
const found = await this.index.scanSession(sessionId);
|
|
473
|
+
const all = found.transcripts.map(({ filePath }) => filePath);
|
|
474
|
+
if (all.length === 0 && found.sessionDirs.length === 0) {
|
|
475
|
+
// Unknown, unless it was loaded here.
|
|
476
|
+
if (known)
|
|
477
|
+
return;
|
|
478
|
+
throw sessionNotFound(sessionId);
|
|
479
|
+
}
|
|
480
|
+
try {
|
|
481
|
+
// The SDK deletes one non-empty copy per call.
|
|
482
|
+
for (const { filePath } of found.transcripts.filter(({ size }) => size > 0)) {
|
|
483
|
+
try {
|
|
484
|
+
await this.deleteSession(sessionId);
|
|
485
|
+
}
|
|
486
|
+
catch (error) {
|
|
487
|
+
// Only "not found" with that copy gone is a copy removed meanwhile.
|
|
488
|
+
if (isSdkNotFound(error) && !(await exists(filePath)))
|
|
489
|
+
continue;
|
|
490
|
+
throw error;
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
// Empty transcripts, which the SDK skips, and session directories
|
|
494
|
+
// without a transcript.
|
|
495
|
+
for (const { filePath, size } of found.transcripts) {
|
|
496
|
+
if (size === 0)
|
|
497
|
+
await fs.rm(filePath, { force: true });
|
|
498
|
+
}
|
|
499
|
+
for (const sessionDir of found.sessionDirs) {
|
|
500
|
+
if (await exists(`${sessionDir}.jsonl`))
|
|
501
|
+
continue;
|
|
502
|
+
await fs.rm(sessionDir, { recursive: true, force: true });
|
|
503
|
+
}
|
|
504
|
+
const left = await this.index.scanSession(sessionId);
|
|
505
|
+
const remaining = [
|
|
506
|
+
...left.transcripts.map(({ filePath }) => filePath),
|
|
507
|
+
...left.sessionDirs,
|
|
508
|
+
];
|
|
509
|
+
if (remaining.length > 0) {
|
|
510
|
+
throw new Error(`Session ${sessionId} was not deleted: ${remaining.join(", ")} remain`);
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
finally {
|
|
514
|
+
this.index.invalidate(all);
|
|
515
|
+
}
|
|
516
|
+
});
|
|
517
|
+
}
|
|
518
|
+
/** `_session/list/subscribe`: pushes the changed rows of `cwd` (an
|
|
519
|
+
* absolute path) until {@link unsubscribeList} or {@link dispose}. */
|
|
520
|
+
subscribeList(cwd) {
|
|
521
|
+
if (this.disposed) {
|
|
522
|
+
return Promise.reject(RequestError.internalError(undefined, "The connection is closed"));
|
|
523
|
+
}
|
|
524
|
+
this.subscriptions ??= new ListSubscriptions({
|
|
525
|
+
index: this.index,
|
|
526
|
+
registry: this.registry,
|
|
527
|
+
own: (sessionId) => this.deps.ownSessionState?.(sessionId),
|
|
528
|
+
notify: (changes) => this.deps.notifyListChanges?.(changes) ?? Promise.resolve(),
|
|
529
|
+
logError: this.deps.logError,
|
|
530
|
+
});
|
|
531
|
+
return this.subscriptions.subscribe(cwd);
|
|
532
|
+
}
|
|
533
|
+
/** `_session/list/unsubscribe`. Idempotent. */
|
|
534
|
+
unsubscribeList(subscriptionId) {
|
|
535
|
+
this.subscriptions?.unsubscribe(subscriptionId);
|
|
536
|
+
}
|
|
537
|
+
/** A session this connection runs changed what its row shows. */
|
|
538
|
+
ownSessionChanged(sessionId) {
|
|
539
|
+
this.subscriptions?.ownSessionChanged(sessionId);
|
|
540
|
+
}
|
|
541
|
+
dispose() {
|
|
542
|
+
this.disposed = true;
|
|
543
|
+
this.subscriptions?.dispose();
|
|
544
|
+
this.subscriptions = undefined;
|
|
545
|
+
}
|
|
546
|
+
}
|