@agentclientprotocol/claude-agent-acp 0.88.1-preview.6 → 0.89.0
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,740 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The session list of a `sessionIndex` client.
|
|
3
|
+
*
|
|
4
|
+
* Each list enumerates the transcripts of the requested cwd and of every
|
|
5
|
+
* worktree of its repository: one `readdir` per project directory and one
|
|
6
|
+
* `stat` per transcript, no long-lived cache. The metadata of a transcript is
|
|
7
|
+
* cached by `(path, mtime, size)` in an LRU and read on a miss with the SDK
|
|
8
|
+
* `getSessionInfo` (titles, sidecar) plus one head and tail read for
|
|
9
|
+
* what the SDK does not report (see {@link scanTranscript}). Misses are read in
|
|
10
|
+
* parallel batches.
|
|
11
|
+
*
|
|
12
|
+
* Order is the last user activity descending, then session id: the time of
|
|
13
|
+
* the last real user prompt (`lastPromptAt`), else `updatedAt`. `updatedAt`
|
|
14
|
+
* is the time of the last message, capped at the transcript mtime, so a
|
|
15
|
+
* rename or another metadata record does not move a session up, and the
|
|
16
|
+
* order key is never later than it. Candidates are read in mtime order: a
|
|
17
|
+
* candidate's order key is at most its mtime, so the scan stops as soon as
|
|
18
|
+
* the page is full and the next mtime is older than the key of the last row.
|
|
19
|
+
*/
|
|
20
|
+
import * as fs from "node:fs/promises";
|
|
21
|
+
import * as path from "node:path";
|
|
22
|
+
import { sanitizeTitle } from "../session-titles.js";
|
|
23
|
+
import { effectiveTitle, visibleTitle } from "./archive-title.js";
|
|
24
|
+
import { canonicalPath, encodeProjectPath, isExactProjectDir, isSessionId, normalizePath, pathAndAncestors, projectDirMatches, projectDirsOf, projectsRoot, sameProjectPath, } from "./project-dirs.js";
|
|
25
|
+
import { continuedInSessionId, hasHistory, isSidechainTranscript, lastTimestamp, readHeadTail, relocatedCwd, scanTranscriptFile, sdkTitles, tailCustomTitle, transcriptAgentName, transcriptProjectCwd, } from "./transcript-scan.js";
|
|
26
|
+
import { DirListings, statFiles } from "./dir-listing.js";
|
|
27
|
+
import { worktreeCounterparts } from "./worktrees.js";
|
|
28
|
+
export const DEFAULT_LIST_LIMIT = 50;
|
|
29
|
+
export const MAX_LIST_LIMIT = 200;
|
|
30
|
+
/** Entries of the metadata cache: a few hundred bytes each, so the
|
|
31
|
+
* transcripts of a large history (tens of thousands) stay cached and deep
|
|
32
|
+
* pages do not read them again. */
|
|
33
|
+
const METADATA_CACHE_SIZE = 50_000;
|
|
34
|
+
const READ_BATCH_SIZE = 16;
|
|
35
|
+
/** Transcripts read at most per directory to recover a sibling's cwd. */
|
|
36
|
+
const MAX_CWD_PROBES_PER_DIR = 64;
|
|
37
|
+
/** How long a directory that gave no cwd is not probed again. */
|
|
38
|
+
const NO_CWD_RETRY_MS = 60_000;
|
|
39
|
+
export const ARCHIVED_FILTERS = ["unarchived", "archived", "all"];
|
|
40
|
+
/** Whether `filter` keeps a session of that archive state. */
|
|
41
|
+
export function archivedFilterKeeps(filter, archived) {
|
|
42
|
+
return filter === "all" || (filter === "archived") === archived;
|
|
43
|
+
}
|
|
44
|
+
/** A small LRU: a `Map` keeps insertion order, and a hit is re-inserted. */
|
|
45
|
+
class Lru {
|
|
46
|
+
capacity;
|
|
47
|
+
entries = new Map();
|
|
48
|
+
constructor(capacity) {
|
|
49
|
+
this.capacity = capacity;
|
|
50
|
+
}
|
|
51
|
+
get(key) {
|
|
52
|
+
const value = this.entries.get(key);
|
|
53
|
+
if (value !== undefined) {
|
|
54
|
+
this.entries.delete(key);
|
|
55
|
+
this.entries.set(key, value);
|
|
56
|
+
}
|
|
57
|
+
return value;
|
|
58
|
+
}
|
|
59
|
+
set(key, value) {
|
|
60
|
+
this.entries.delete(key);
|
|
61
|
+
this.entries.set(key, value);
|
|
62
|
+
if (this.entries.size > this.capacity) {
|
|
63
|
+
this.entries.delete(this.entries.keys().next().value);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
delete(key) {
|
|
67
|
+
this.entries.delete(key);
|
|
68
|
+
}
|
|
69
|
+
/** The value of `key`, without making it recent. */
|
|
70
|
+
peek(key) {
|
|
71
|
+
return this.entries.get(key);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
function compareRows(a, b) {
|
|
75
|
+
if (a.orderAtMs !== b.orderAtMs)
|
|
76
|
+
return b.orderAtMs - a.orderAtMs;
|
|
77
|
+
return a.sessionId < b.sessionId ? -1 : a.sessionId > b.sessionId ? 1 : 0;
|
|
78
|
+
}
|
|
79
|
+
function toRow({ candidate, metadata }, cwd) {
|
|
80
|
+
return {
|
|
81
|
+
archived: metadata.archived,
|
|
82
|
+
sessionId: candidate.sessionId,
|
|
83
|
+
cwd,
|
|
84
|
+
title: metadata.title,
|
|
85
|
+
updatedAtMs: metadata.updatedAtMs,
|
|
86
|
+
orderAtMs: Math.min(metadata.facts.lastPromptAt ?? metadata.updatedAtMs, metadata.updatedAtMs),
|
|
87
|
+
facts: metadata.facts,
|
|
88
|
+
mtimeMs: candidate.mtimeMs,
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
function isAfter(row, cursor) {
|
|
92
|
+
return compareRows(row, cursor) > 0;
|
|
93
|
+
}
|
|
94
|
+
function byMtimeDescending(a, b) {
|
|
95
|
+
if (a.mtimeMs !== b.mtimeMs)
|
|
96
|
+
return b.mtimeMs - a.mtimeMs;
|
|
97
|
+
return a.sessionId < b.sessionId ? -1 : a.sessionId > b.sessionId ? 1 : 0;
|
|
98
|
+
}
|
|
99
|
+
/** The title of the CLI's `custom-title.json` sidecar of a transcript. */
|
|
100
|
+
export async function readSidecarTitle(filePath, sessionId) {
|
|
101
|
+
try {
|
|
102
|
+
const text = await fs.readFile(path.join(path.dirname(filePath), sessionId, "custom-title.json"), "utf8");
|
|
103
|
+
const title = JSON.parse(text).customTitle;
|
|
104
|
+
return typeof title === "string" && title.trim() ? title : undefined;
|
|
105
|
+
}
|
|
106
|
+
catch {
|
|
107
|
+
return undefined;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* On macOS the SDK opens `<projects>/<encoded cwd>` through the file system,
|
|
112
|
+
* which ignores case on the usual volumes: a directory whose name differs in
|
|
113
|
+
* case only (a repository renamed in case) is the project directory then.
|
|
114
|
+
* Returns that entry of `rootEntries` when the file system resolves the
|
|
115
|
+
* exact name to it.
|
|
116
|
+
*/
|
|
117
|
+
async function caseInsensitiveProjectDir(projectPath, rootEntries) {
|
|
118
|
+
if (process.platform !== "darwin")
|
|
119
|
+
return undefined;
|
|
120
|
+
const exact = encodeProjectPath(projectPath);
|
|
121
|
+
if (rootEntries.includes(exact))
|
|
122
|
+
return undefined;
|
|
123
|
+
const lower = exact.toLowerCase();
|
|
124
|
+
const variant = rootEntries.find((name) => name.toLowerCase() === lower);
|
|
125
|
+
if (!variant)
|
|
126
|
+
return undefined;
|
|
127
|
+
try {
|
|
128
|
+
return (await fs.stat(path.join(projectsRoot(), exact))).isDirectory() ? variant : undefined;
|
|
129
|
+
}
|
|
130
|
+
catch {
|
|
131
|
+
return undefined;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
/** Whether the encoding of `cwd` names the directory `dirName` on disk. */
|
|
135
|
+
async function sameDirOnDisk(dirName, cwd) {
|
|
136
|
+
const root = projectsRoot();
|
|
137
|
+
try {
|
|
138
|
+
const [listed, resolved] = await Promise.all([
|
|
139
|
+
fs.stat(path.join(root, dirName)),
|
|
140
|
+
fs.stat(path.join(root, encodeProjectPath(cwd))),
|
|
141
|
+
]);
|
|
142
|
+
return listed.isDirectory() && listed.ino === resolved.ino && listed.dev === resolved.dev;
|
|
143
|
+
}
|
|
144
|
+
catch {
|
|
145
|
+
return false;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
/** Whether paths are compared ignoring case, as the SDK does on macOS and
|
|
149
|
+
* Windows. */
|
|
150
|
+
const CASE_INSENSITIVE_PATHS = process.platform === "darwin" || process.platform === "win32";
|
|
151
|
+
/** A path as the SDK compares it: forward slashes, in any case where the
|
|
152
|
+
* file system ignores it. */
|
|
153
|
+
function comparable(value) {
|
|
154
|
+
const slashed = normalizePath(value).replaceAll("\\", "/");
|
|
155
|
+
return CASE_INSENSITIVE_PATHS ? slashed.toLowerCase() : slashed;
|
|
156
|
+
}
|
|
157
|
+
/** Whether `a` and `b` are different paths that encode to the same project
|
|
158
|
+
* directory name (`/ws/app.v2` and `/ws/app-v2`). */
|
|
159
|
+
function collides(a, b) {
|
|
160
|
+
return sameProjectPath(a, b) && comparable(a) !== comparable(b);
|
|
161
|
+
}
|
|
162
|
+
/** A path the SDK does not resolve: a `..` segment, or a UNC or device path. */
|
|
163
|
+
function unresolvable(value) {
|
|
164
|
+
return /(^|[\\/])\.\.([\\/]|$)/.test(value) || /^[\\/]{2}/.test(value);
|
|
165
|
+
}
|
|
166
|
+
/**
|
|
167
|
+
* Which transcripts of the project directories of `paths` (every one
|
|
168
|
+
* without `paths`) the SDK `listSessions` of those paths lists. It leaves out
|
|
169
|
+
* only a transcript whose cwd (see {@link TranscriptMetadata.projectCwd}) is
|
|
170
|
+
* another path that encodes to the same directory and still resolves on disk
|
|
171
|
+
* to such another path; one whose cwd is gone, or lies in one of `paths`, is
|
|
172
|
+
* listed.
|
|
173
|
+
*/
|
|
174
|
+
function scopeOf(paths) {
|
|
175
|
+
if (!paths)
|
|
176
|
+
return async () => true;
|
|
177
|
+
const own = paths.map(comparable);
|
|
178
|
+
const resolved = new Map();
|
|
179
|
+
const hidden = async (cwd, projectPath) => {
|
|
180
|
+
if (!collides(cwd, projectPath) || unresolvable(cwd) || unresolvable(projectPath))
|
|
181
|
+
return false;
|
|
182
|
+
let real;
|
|
183
|
+
try {
|
|
184
|
+
real = normalizePath(await fs.realpath(cwd));
|
|
185
|
+
}
|
|
186
|
+
catch {
|
|
187
|
+
return false;
|
|
188
|
+
}
|
|
189
|
+
return collides(real, await canonicalPath(projectPath));
|
|
190
|
+
};
|
|
191
|
+
return async ({ projectPath }, { projectCwd }) => {
|
|
192
|
+
if (projectPath === undefined || projectCwd === undefined)
|
|
193
|
+
return true;
|
|
194
|
+
const cwd = comparable(projectCwd);
|
|
195
|
+
if (own.some((ownPath) => cwd === ownPath || cwd.startsWith(ownPath.endsWith("/") ? ownPath : `${ownPath}/`))) {
|
|
196
|
+
return true;
|
|
197
|
+
}
|
|
198
|
+
const key = `${projectCwd}\0${projectPath}`;
|
|
199
|
+
let result = resolved.get(key);
|
|
200
|
+
if (!result) {
|
|
201
|
+
result = hidden(projectCwd, projectPath).then((isHidden) => !isHidden);
|
|
202
|
+
resolved.set(key, result);
|
|
203
|
+
}
|
|
204
|
+
return result;
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
export class SessionIndex {
|
|
208
|
+
getSessionInfo;
|
|
209
|
+
listings = new DirListings();
|
|
210
|
+
metadata = new Lru(METADATA_CACHE_SIZE);
|
|
211
|
+
/** The metadata reads in flight, by path: a list and a subscription that
|
|
212
|
+
* want the same transcript at the same stat read it once. */
|
|
213
|
+
reading = new Map();
|
|
214
|
+
/** A cwd that encodes to each project directory name, learned from its
|
|
215
|
+
* transcripts; it recovers the cwd of a sibling that has none. */
|
|
216
|
+
dirCwds = new Map();
|
|
217
|
+
/** The project cwd of the transcripts of prefix-matched long directories. */
|
|
218
|
+
transcriptCwds = new Lru(METADATA_CACHE_SIZE);
|
|
219
|
+
/** Directories whose transcripts gave no cwd, by the directory mtime and
|
|
220
|
+
* when that was learned. */
|
|
221
|
+
dirsWithoutCwd = new Map();
|
|
222
|
+
constructor(getSessionInfo) {
|
|
223
|
+
this.getSessionInfo = getSessionInfo;
|
|
224
|
+
}
|
|
225
|
+
/** The paths whose sessions a list of `cwd` shows: `cwd`, and with
|
|
226
|
+
* `includeWorktrees` the same subdirectory of every other existing
|
|
227
|
+
* worktree of its repository (the worktree roots for a cwd at the root). */
|
|
228
|
+
async listedPaths(cwd, includeWorktrees) {
|
|
229
|
+
const canonical = await canonicalPath(cwd);
|
|
230
|
+
if (!includeWorktrees)
|
|
231
|
+
return [canonical];
|
|
232
|
+
return [...new Set([canonical, ...(await worktreeCounterparts(canonical))])];
|
|
233
|
+
}
|
|
234
|
+
/** The project directories (names under the projects root) of `paths`.
|
|
235
|
+
* A long path is matched by its cut prefix, which other long paths may
|
|
236
|
+
* share: such a directory counts only when one of its transcripts belongs
|
|
237
|
+
* to the path, as the SDK checks. */
|
|
238
|
+
async projectDirs(paths) {
|
|
239
|
+
const rootEntries = await this.listings.names(projectsRoot());
|
|
240
|
+
const seen = new Set();
|
|
241
|
+
const result = [];
|
|
242
|
+
for (const projectPath of paths) {
|
|
243
|
+
const names = projectDirsOf(projectPath, rootEntries);
|
|
244
|
+
const caseVariant = await caseInsensitiveProjectDir(projectPath, rootEntries);
|
|
245
|
+
if (caseVariant && !names.includes(caseVariant))
|
|
246
|
+
names.unshift(caseVariant);
|
|
247
|
+
for (const dirName of names) {
|
|
248
|
+
if (seen.has(dirName))
|
|
249
|
+
continue;
|
|
250
|
+
if (dirName !== caseVariant &&
|
|
251
|
+
dirName !== encodeProjectPath(projectPath) &&
|
|
252
|
+
!(await this.longDirBelongsTo(dirName, projectPath))) {
|
|
253
|
+
continue;
|
|
254
|
+
}
|
|
255
|
+
seen.add(dirName);
|
|
256
|
+
result.push({ dirName, projectPath });
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
return result;
|
|
260
|
+
}
|
|
261
|
+
/** Whether a transcript of the prefix-matched `dirName` belongs to
|
|
262
|
+
* `projectPath`. The cwd of each transcript is cached by its
|
|
263
|
+
* `(path, mtime, size)`, so a transcript that changes is read again. */
|
|
264
|
+
async longDirBelongsTo(dirName, projectPath) {
|
|
265
|
+
const dir = path.join(projectsRoot(), dirName);
|
|
266
|
+
for (const name of await this.listings.names(dir)) {
|
|
267
|
+
if (!name.endsWith(".jsonl"))
|
|
268
|
+
continue;
|
|
269
|
+
const filePath = path.join(dir, name);
|
|
270
|
+
try {
|
|
271
|
+
const stats = await fs.stat(filePath);
|
|
272
|
+
if (!stats.isFile())
|
|
273
|
+
continue;
|
|
274
|
+
let cached = this.transcriptCwds.get(filePath);
|
|
275
|
+
if (!cached || cached.mtimeMs !== stats.mtimeMs || cached.size !== stats.size) {
|
|
276
|
+
cached = {
|
|
277
|
+
mtimeMs: stats.mtimeMs,
|
|
278
|
+
size: stats.size,
|
|
279
|
+
cwd: transcriptProjectCwd(await readHeadTail(filePath, stats.size)),
|
|
280
|
+
};
|
|
281
|
+
this.transcriptCwds.set(filePath, cached);
|
|
282
|
+
}
|
|
283
|
+
if (cached.cwd && sameProjectPath(cached.cwd, projectPath))
|
|
284
|
+
return true;
|
|
285
|
+
}
|
|
286
|
+
catch {
|
|
287
|
+
// Gone or unreadable: look at the next one.
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
return false;
|
|
291
|
+
}
|
|
292
|
+
/** Every non-empty transcript file of `paths` (without, of all projects),
|
|
293
|
+
* every copy of a session: a list shows the newest one that has a row
|
|
294
|
+
* (see {@link collectRows}), as the SDK does. */
|
|
295
|
+
async enumerateFiles(paths) {
|
|
296
|
+
const root = projectsRoot();
|
|
297
|
+
const dirs = paths
|
|
298
|
+
? await this.projectDirs(paths)
|
|
299
|
+
: (await this.listings.names(root)).map((dirName) => ({ dirName }));
|
|
300
|
+
// All directories' names first, then one bounded run of stats.
|
|
301
|
+
const listed = await this.listings.namesOfAll(dirs.map(({ dirName }) => path.join(root, dirName)));
|
|
302
|
+
const files = dirs.flatMap(({ dirName, projectPath }, i) => listed[i].filter((name) => name.endsWith(".jsonl") && isSessionId(name.slice(0, -6))).map((name) => ({ name, filePath: path.join(root, dirName, name), dirName, projectPath })));
|
|
303
|
+
const stats = await statFiles(files.map(({ filePath }) => filePath));
|
|
304
|
+
const all = [];
|
|
305
|
+
files.forEach(({ name, filePath, dirName, projectPath }, i) => {
|
|
306
|
+
const fileStats = stats[i];
|
|
307
|
+
if (!fileStats?.isFile() || fileStats.size === 0)
|
|
308
|
+
return;
|
|
309
|
+
all.push({
|
|
310
|
+
sessionId: name.slice(0, -6),
|
|
311
|
+
filePath,
|
|
312
|
+
dirName,
|
|
313
|
+
projectPath,
|
|
314
|
+
mtimeMs: fileStats.mtimeMs,
|
|
315
|
+
size: fileStats.size,
|
|
316
|
+
ino: fileStats.ino,
|
|
317
|
+
});
|
|
318
|
+
});
|
|
319
|
+
return all;
|
|
320
|
+
}
|
|
321
|
+
/**
|
|
322
|
+
* Every transcript (with its size) and every `<sessionId>/` directory
|
|
323
|
+
* (sidecar, subagent transcripts) of the session, in any project
|
|
324
|
+
* directory, by the exact id. One pass over the (cached) project directory
|
|
325
|
+
* listings.
|
|
326
|
+
*/
|
|
327
|
+
async scanSession(sessionId) {
|
|
328
|
+
if (!isSessionId(sessionId))
|
|
329
|
+
return { transcripts: [], sessionDirs: [] };
|
|
330
|
+
const root = projectsRoot();
|
|
331
|
+
const dirs = (await this.listings.names(root)).map((dirName) => path.join(root, dirName));
|
|
332
|
+
const listed = await this.listings.namesOfAll(dirs);
|
|
333
|
+
const transcriptName = `${sessionId}.jsonl`;
|
|
334
|
+
const transcriptPaths = dirs.flatMap((dir, i) => listed[i].includes(transcriptName) ? [path.join(dir, transcriptName)] : []);
|
|
335
|
+
const dirPaths = dirs.flatMap((dir, i) => listed[i].includes(sessionId) ? [path.join(dir, sessionId)] : []);
|
|
336
|
+
const [transcriptStats, dirStats] = await Promise.all([
|
|
337
|
+
statFiles(transcriptPaths),
|
|
338
|
+
Promise.all(dirPaths.map((dir) => fs.lstat(dir).catch(() => undefined))),
|
|
339
|
+
]);
|
|
340
|
+
return {
|
|
341
|
+
transcripts: transcriptPaths.flatMap((filePath, i) => {
|
|
342
|
+
const stats = transcriptStats[i];
|
|
343
|
+
return stats?.isFile() ? [{ filePath, size: stats.size }] : [];
|
|
344
|
+
}),
|
|
345
|
+
sessionDirs: dirPaths.filter((_, i) => dirStats[i]?.isDirectory()),
|
|
346
|
+
};
|
|
347
|
+
}
|
|
348
|
+
/** Every transcript file of `sessionId` (see {@link scanSession}). Empty
|
|
349
|
+
* files only with `includeEmpty`. */
|
|
350
|
+
async findTranscripts(sessionId, options = {}) {
|
|
351
|
+
const { transcripts } = await this.scanSession(sessionId);
|
|
352
|
+
return transcripts
|
|
353
|
+
.filter(({ size }) => size > 0 || options.includeEmpty)
|
|
354
|
+
.map(({ filePath }) => filePath);
|
|
355
|
+
}
|
|
356
|
+
/** Every `<sessionId>/` directory of the session (see {@link scanSession}). */
|
|
357
|
+
async findSessionDirs(sessionId) {
|
|
358
|
+
return (await this.scanSession(sessionId)).sessionDirs;
|
|
359
|
+
}
|
|
360
|
+
/** Whether `candidate` was read at its stat: its metadata is cached, also
|
|
361
|
+
* when it holds no row. False after a failed read. */
|
|
362
|
+
isRead(candidate) {
|
|
363
|
+
const cached = this.metadata.peek(candidate.filePath);
|
|
364
|
+
return (cached !== undefined &&
|
|
365
|
+
cached.mtimeMs === candidate.mtimeMs &&
|
|
366
|
+
cached.size === candidate.size &&
|
|
367
|
+
cached.ino === candidate.ino);
|
|
368
|
+
}
|
|
369
|
+
/** The session that the cached metadata of `filePath` says it was
|
|
370
|
+
* continued in. */
|
|
371
|
+
continuedIn(filePath) {
|
|
372
|
+
return this.metadata.peek(filePath)?.metadata?.continuedIn;
|
|
373
|
+
}
|
|
374
|
+
/** Drops the cached metadata of `filePaths`. */
|
|
375
|
+
invalidate(filePaths) {
|
|
376
|
+
for (const filePath of filePaths)
|
|
377
|
+
this.metadata.delete(filePath);
|
|
378
|
+
}
|
|
379
|
+
/** One page of rows, plus whether more rows follow the page. */
|
|
380
|
+
async list(query) {
|
|
381
|
+
const paths = query.cwd
|
|
382
|
+
? await this.listedPaths(query.cwd, query.includeWorktrees ?? false)
|
|
383
|
+
: undefined;
|
|
384
|
+
const after = query.after;
|
|
385
|
+
const enumerated = (await this.enumerateFiles(paths)).sort(byMtimeDescending);
|
|
386
|
+
// A page after a cursor skips, without reading them, the sessions whose
|
|
387
|
+
// newest transcript has a cached order key before the cursor. The
|
|
388
|
+
// archive state comes from the title, so the archive filter applies once
|
|
389
|
+
// a transcript is read.
|
|
390
|
+
const skipped = new Set();
|
|
391
|
+
const seen = new Set();
|
|
392
|
+
for (const candidate of enumerated) {
|
|
393
|
+
if (seen.has(candidate.sessionId))
|
|
394
|
+
continue;
|
|
395
|
+
seen.add(candidate.sessionId);
|
|
396
|
+
if (after && this.cachedBefore(candidate, after))
|
|
397
|
+
skipped.add(candidate.sessionId);
|
|
398
|
+
}
|
|
399
|
+
const candidates = enumerated.filter((candidate) => !skipped.has(candidate.sessionId));
|
|
400
|
+
// One row more than the page tells whether a next page exists, so a
|
|
401
|
+
// cursor never leads to an empty page.
|
|
402
|
+
const rows = await this.collectRows(candidates, {
|
|
403
|
+
inScope: scopeOf(paths),
|
|
404
|
+
archived: query.archived,
|
|
405
|
+
after,
|
|
406
|
+
wanted: query.limit + 1,
|
|
407
|
+
unread: (read) => enumerated.filter((candidate) => !read.has(candidate)),
|
|
408
|
+
});
|
|
409
|
+
return { rows: rows.slice(0, query.limit), hasMore: rows.length > query.limit };
|
|
410
|
+
}
|
|
411
|
+
/**
|
|
412
|
+
* The rows of `candidates`, one per session (its newest transcript that has
|
|
413
|
+
* a row), any archive state, as a list of `paths` shows them: a candidate
|
|
414
|
+
* without a row (no title, a sidechain, continued elsewhere, another path
|
|
415
|
+
* that shares the project directory) is left out. `siblings` are the other
|
|
416
|
+
* transcripts of the candidates' directories, read only when a candidate
|
|
417
|
+
* has no cwd of its own. The metadata cache makes an unchanged transcript
|
|
418
|
+
* free.
|
|
419
|
+
*/
|
|
420
|
+
async rowsOf(paths, candidates, siblings) {
|
|
421
|
+
return this.collectRows(candidates, {
|
|
422
|
+
inScope: scopeOf(paths),
|
|
423
|
+
archived: "all",
|
|
424
|
+
wanted: Infinity,
|
|
425
|
+
unread: (read) => siblings().filter((candidate) => !read.has(candidate)),
|
|
426
|
+
});
|
|
427
|
+
}
|
|
428
|
+
/**
|
|
429
|
+
* The rows of `candidates` (as {@link rowsOf}) that the metadata cache
|
|
430
|
+
* alone gives, read from it at once, and the ids of the sessions it gives
|
|
431
|
+
* no answer for: a transcript of it has nothing cached at its stat, no cwd
|
|
432
|
+
* known, or is continued in another session (whether the successor hides
|
|
433
|
+
* it depends on the successor now). Reads no transcript.
|
|
434
|
+
*/
|
|
435
|
+
async cachedRowsOf(paths, candidates) {
|
|
436
|
+
const unknown = new Set();
|
|
437
|
+
const resolved = [];
|
|
438
|
+
for (const candidate of candidates) {
|
|
439
|
+
const id = candidate.sessionId;
|
|
440
|
+
const cached = this.metadata.peek(candidate.filePath);
|
|
441
|
+
if (!cached ||
|
|
442
|
+
cached.mtimeMs !== candidate.mtimeMs ||
|
|
443
|
+
cached.size !== candidate.size ||
|
|
444
|
+
cached.ino !== candidate.ino ||
|
|
445
|
+
cached.metadata?.continuedIn) {
|
|
446
|
+
unknown.add(id);
|
|
447
|
+
continue;
|
|
448
|
+
}
|
|
449
|
+
if (!cached.metadata)
|
|
450
|
+
continue;
|
|
451
|
+
const cwd = cached.metadata.fileCwd ?? this.fallbackCwd(candidate);
|
|
452
|
+
if (cwd)
|
|
453
|
+
resolved.push({ item: { candidate, metadata: cached.metadata }, cwd });
|
|
454
|
+
else
|
|
455
|
+
unknown.add(id);
|
|
456
|
+
}
|
|
457
|
+
const inScope = scopeOf(paths);
|
|
458
|
+
const newest = new Map();
|
|
459
|
+
for (const { item, cwd } of resolved) {
|
|
460
|
+
const id = item.candidate.sessionId;
|
|
461
|
+
if (unknown.has(id) || !(await inScope(item.candidate, item.metadata)))
|
|
462
|
+
continue;
|
|
463
|
+
const previous = newest.get(id);
|
|
464
|
+
if (previous && previous.mtimeMs >= item.candidate.mtimeMs)
|
|
465
|
+
continue;
|
|
466
|
+
newest.set(id, { row: toRow(item, cwd), mtimeMs: item.candidate.mtimeMs });
|
|
467
|
+
}
|
|
468
|
+
return { rows: [...newest.values()].map(({ row }) => row), unknown };
|
|
469
|
+
}
|
|
470
|
+
/**
|
|
471
|
+
* Reads `candidates` in order into rows, one per session, sorted. A
|
|
472
|
+
* session copied to several transcripts shows its newest one that has a
|
|
473
|
+
* row, as the SDK list does. With a finite `wanted`, stops once `wanted`
|
|
474
|
+
* rows are certain to come first (a candidate's order key is at most its
|
|
475
|
+
* mtime, so `candidates` must be in mtime order then).
|
|
476
|
+
*/
|
|
477
|
+
async collectRows(candidates, options) {
|
|
478
|
+
const { inScope, archived, after, wanted } = options;
|
|
479
|
+
/** The row of each session (null: left out by the archive filter or
|
|
480
|
+
* the cursor), and the mtime of the transcript it is from. */
|
|
481
|
+
const bySession = new Map();
|
|
482
|
+
const rows = () => [...bySession.values()].flatMap(({ row }) => (row ? [row] : [])).sort(compareRows);
|
|
483
|
+
// Read transcripts without a cwd of their own: a sibling of the same
|
|
484
|
+
// directory may supply it, whichever batch it is read in.
|
|
485
|
+
let pending = [];
|
|
486
|
+
const accept = async (resolved, cwd) => {
|
|
487
|
+
// The SDK leaves out another path that encodes to the same project
|
|
488
|
+
// directory (see scopeOf).
|
|
489
|
+
if (!(await inScope(resolved.candidate, resolved.metadata)))
|
|
490
|
+
return;
|
|
491
|
+
const { sessionId, mtimeMs } = resolved.candidate;
|
|
492
|
+
const previous = bySession.get(sessionId);
|
|
493
|
+
if (previous && previous.mtimeMs >= mtimeMs)
|
|
494
|
+
return;
|
|
495
|
+
const row = toRow(resolved, cwd);
|
|
496
|
+
// A newer copy that the filter or the cursor leaves out still hides
|
|
497
|
+
// an older one.
|
|
498
|
+
const kept = archivedFilterKeeps(archived, row.archived) && (!after || isAfter(row, after));
|
|
499
|
+
bySession.set(sessionId, { row: kept ? row : null, mtimeMs });
|
|
500
|
+
};
|
|
501
|
+
const settlePending = async () => {
|
|
502
|
+
const left = [];
|
|
503
|
+
for (const resolved of pending) {
|
|
504
|
+
const cwd = this.fallbackCwd(resolved.candidate);
|
|
505
|
+
if (cwd)
|
|
506
|
+
await accept(resolved, cwd);
|
|
507
|
+
else
|
|
508
|
+
left.push(resolved);
|
|
509
|
+
}
|
|
510
|
+
pending = left;
|
|
511
|
+
};
|
|
512
|
+
const read = new Set();
|
|
513
|
+
let index = 0;
|
|
514
|
+
while (index < candidates.length) {
|
|
515
|
+
if (bySession.size >= wanted) {
|
|
516
|
+
const sorted = rows();
|
|
517
|
+
// A candidate's order key is at most its mtime.
|
|
518
|
+
if (sorted.length >= wanted && candidates[index].mtimeMs < sorted[wanted - 1].orderAtMs) {
|
|
519
|
+
break;
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
const batch = candidates.slice(index, index + READ_BATCH_SIZE);
|
|
523
|
+
index += batch.length;
|
|
524
|
+
const resolved = await Promise.all(batch.map(async (candidate) => {
|
|
525
|
+
read.add(candidate);
|
|
526
|
+
const metadata = await this.metadataOf(candidate);
|
|
527
|
+
if (!metadata || (await this.continuedElsewhere(candidate, metadata)))
|
|
528
|
+
return undefined;
|
|
529
|
+
return { candidate, metadata };
|
|
530
|
+
}));
|
|
531
|
+
for (const item of resolved) {
|
|
532
|
+
if (!item)
|
|
533
|
+
continue;
|
|
534
|
+
const cwd = item.metadata.fileCwd ?? this.fallbackCwd(item.candidate);
|
|
535
|
+
if (cwd)
|
|
536
|
+
await accept(item, cwd);
|
|
537
|
+
else
|
|
538
|
+
pending.push(item);
|
|
539
|
+
}
|
|
540
|
+
await settlePending();
|
|
541
|
+
}
|
|
542
|
+
if (pending.length > 0) {
|
|
543
|
+
// A row recovered after the scan stopped still sorts into the page: it
|
|
544
|
+
// was read before the stop, and more rows only raise the bound. A
|
|
545
|
+
// sibling that the archive filter or the cursor leaves out may still
|
|
546
|
+
// supply the cwd.
|
|
547
|
+
await this.learnDirCwds(new Set(pending.map(({ candidate }) => candidate.dirName)), options.unread(read));
|
|
548
|
+
await settlePending();
|
|
549
|
+
}
|
|
550
|
+
return rows();
|
|
551
|
+
}
|
|
552
|
+
/** Whether the cached metadata of `candidate`, still current, places it
|
|
553
|
+
* at or before `cursor`. */
|
|
554
|
+
cachedBefore(candidate, cursor) {
|
|
555
|
+
const cached = this.metadata.peek(candidate.filePath);
|
|
556
|
+
if (!cached?.metadata ||
|
|
557
|
+
cached.mtimeMs !== candidate.mtimeMs ||
|
|
558
|
+
cached.size !== candidate.size ||
|
|
559
|
+
cached.ino !== candidate.ino) {
|
|
560
|
+
return false;
|
|
561
|
+
}
|
|
562
|
+
const { metadata } = cached;
|
|
563
|
+
const orderAtMs = Math.min(metadata.facts.lastPromptAt ?? metadata.updatedAtMs, metadata.updatedAtMs);
|
|
564
|
+
return !isAfter({ orderAtMs, sessionId: candidate.sessionId }, cursor);
|
|
565
|
+
}
|
|
566
|
+
/** Reads the unread transcripts of `dirNames` until each directory has a
|
|
567
|
+
* known cwd, at most {@link MAX_CWD_PROBES_PER_DIR} per directory. A
|
|
568
|
+
* directory that gives none is not read again for a while, unless it
|
|
569
|
+
* changes. */
|
|
570
|
+
async learnDirCwds(dirNames, unread) {
|
|
571
|
+
const root = projectsRoot();
|
|
572
|
+
const now = Date.now();
|
|
573
|
+
const mtimes = new Map();
|
|
574
|
+
for (const dirName of dirNames) {
|
|
575
|
+
const mtimeMs = await fs.stat(path.join(root, dirName)).then((stats) => stats.mtimeMs, () => undefined);
|
|
576
|
+
if (mtimeMs === undefined)
|
|
577
|
+
continue;
|
|
578
|
+
const known = this.dirsWithoutCwd.get(dirName);
|
|
579
|
+
if (known && known.mtimeMs === mtimeMs && now - known.at < NO_CWD_RETRY_MS)
|
|
580
|
+
continue;
|
|
581
|
+
mtimes.set(dirName, mtimeMs);
|
|
582
|
+
}
|
|
583
|
+
const probes = new Map();
|
|
584
|
+
let remaining = unread.filter((candidate) => {
|
|
585
|
+
if (!mtimes.has(candidate.dirName))
|
|
586
|
+
return false;
|
|
587
|
+
const count = probes.get(candidate.dirName) ?? 0;
|
|
588
|
+
if (count >= MAX_CWD_PROBES_PER_DIR)
|
|
589
|
+
return false;
|
|
590
|
+
probes.set(candidate.dirName, count + 1);
|
|
591
|
+
return true;
|
|
592
|
+
});
|
|
593
|
+
while (remaining.length > 0) {
|
|
594
|
+
remaining = remaining.filter((candidate) => !this.dirCwds.has(candidate.dirName));
|
|
595
|
+
const batch = remaining.slice(0, READ_BATCH_SIZE);
|
|
596
|
+
remaining = remaining.slice(batch.length);
|
|
597
|
+
await Promise.all(batch.map((candidate) => this.metadataOf(candidate)));
|
|
598
|
+
}
|
|
599
|
+
for (const [dirName, mtimeMs] of mtimes) {
|
|
600
|
+
if (!this.dirCwds.has(dirName))
|
|
601
|
+
this.dirsWithoutCwd.set(dirName, { mtimeMs, at: now });
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
/** The requested path of the directory, else a sibling's cwd. */
|
|
605
|
+
fallbackCwd(candidate) {
|
|
606
|
+
// A directory found for the requested path belongs to it, also one that
|
|
607
|
+
// differs in case only (see caseInsensitiveProjectDir).
|
|
608
|
+
if (candidate.projectPath &&
|
|
609
|
+
(projectDirMatches(candidate.dirName, candidate.projectPath) ||
|
|
610
|
+
isExactProjectDir(candidate.dirName, candidate.projectPath))) {
|
|
611
|
+
return candidate.projectPath;
|
|
612
|
+
}
|
|
613
|
+
return this.dirCwds.get(candidate.dirName);
|
|
614
|
+
}
|
|
615
|
+
async metadataOf(candidate) {
|
|
616
|
+
const cached = this.metadata.get(candidate.filePath);
|
|
617
|
+
if (cached &&
|
|
618
|
+
cached.mtimeMs === candidate.mtimeMs &&
|
|
619
|
+
cached.size === candidate.size &&
|
|
620
|
+
cached.ino === candidate.ino) {
|
|
621
|
+
if (cached.metadata?.fileCwd)
|
|
622
|
+
this.dirCwds.set(candidate.dirName, cached.metadata.fileCwd);
|
|
623
|
+
return cached.metadata;
|
|
624
|
+
}
|
|
625
|
+
const key = `${candidate.mtimeMs}:${candidate.size}:${candidate.ino}`;
|
|
626
|
+
const inFlight = this.reading.get(candidate.filePath);
|
|
627
|
+
if (inFlight?.key === key)
|
|
628
|
+
return inFlight.promise;
|
|
629
|
+
const promise = this.readAndCache(candidate, cached);
|
|
630
|
+
this.reading.set(candidate.filePath, { key, promise });
|
|
631
|
+
void promise.finally(() => {
|
|
632
|
+
if (this.reading.get(candidate.filePath)?.promise === promise) {
|
|
633
|
+
this.reading.delete(candidate.filePath);
|
|
634
|
+
}
|
|
635
|
+
});
|
|
636
|
+
return promise;
|
|
637
|
+
}
|
|
638
|
+
async readAndCache(candidate, cached) {
|
|
639
|
+
let metadata;
|
|
640
|
+
try {
|
|
641
|
+
// The transcript grew since: an earlier scan may still know its last
|
|
642
|
+
// prompt.
|
|
643
|
+
metadata = await this.readMetadata(candidate, cached && cached.ino === candidate.ino
|
|
644
|
+
? { ...cached.metadata?.facts, size: cached.size }
|
|
645
|
+
: undefined);
|
|
646
|
+
}
|
|
647
|
+
catch {
|
|
648
|
+
// Unreadable now (deleted, permissions): skip it and retry next time.
|
|
649
|
+
return null;
|
|
650
|
+
}
|
|
651
|
+
this.metadata.set(candidate.filePath, {
|
|
652
|
+
mtimeMs: candidate.mtimeMs,
|
|
653
|
+
size: candidate.size,
|
|
654
|
+
ino: candidate.ino,
|
|
655
|
+
metadata,
|
|
656
|
+
});
|
|
657
|
+
return metadata;
|
|
658
|
+
}
|
|
659
|
+
async readMetadata(candidate, previous) {
|
|
660
|
+
const headTail = await readHeadTail(candidate.filePath, candidate.size);
|
|
661
|
+
if (isSidechainTranscript(headTail.head))
|
|
662
|
+
return null;
|
|
663
|
+
const facts = await scanTranscriptFile(candidate.filePath, candidate.size, headTail, previous, candidate.ino);
|
|
664
|
+
if (!facts.hasMessages)
|
|
665
|
+
return null;
|
|
666
|
+
// The last relocation names the session's cwd, as the SDK reads it; the
|
|
667
|
+
// messages before it keep the old one.
|
|
668
|
+
const fileCwd = await this.recoverCwd(candidate.dirName, [
|
|
669
|
+
relocatedCwd(headTail.tail),
|
|
670
|
+
facts.headCwd,
|
|
671
|
+
...(facts.tailCwd ? pathAndAncestors(facts.tailCwd) : []),
|
|
672
|
+
]);
|
|
673
|
+
if (fileCwd)
|
|
674
|
+
this.dirCwds.set(candidate.dirName, fileCwd);
|
|
675
|
+
const sdk = await this.titlesOf(candidate, headTail, fileCwd);
|
|
676
|
+
// No title at all: the SDK does not list it either.
|
|
677
|
+
if (!sdk.summary)
|
|
678
|
+
return null;
|
|
679
|
+
// Archived by its name alone: the agent name, else the custom title.
|
|
680
|
+
const { title = sdk.summary, archived } = effectiveTitle(transcriptAgentName(headTail), sdk);
|
|
681
|
+
// A last message longer than the tail search still ends with its
|
|
682
|
+
// timestamp; the mtime moves with every metadata record.
|
|
683
|
+
const lastMessageAt = facts.lastMessageAt ??
|
|
684
|
+
(await lastTimestamp(candidate.filePath, candidate.size)) ??
|
|
685
|
+
candidate.mtimeMs;
|
|
686
|
+
const continuedIn = continuedInSessionId(headTail.tail);
|
|
687
|
+
const projectCwd = transcriptProjectCwd(headTail);
|
|
688
|
+
return {
|
|
689
|
+
title: sanitizeTitle(archived ? visibleTitle(title) : title),
|
|
690
|
+
archived,
|
|
691
|
+
...(fileCwd && { fileCwd }),
|
|
692
|
+
...(projectCwd && { projectCwd }),
|
|
693
|
+
...(continuedIn && { continuedIn }),
|
|
694
|
+
updatedAtMs: Math.min(lastMessageAt, candidate.mtimeMs),
|
|
695
|
+
facts,
|
|
696
|
+
};
|
|
697
|
+
}
|
|
698
|
+
/**
|
|
699
|
+
* The titles the SDK reports for the listed transcript (`customTitle` and
|
|
700
|
+
* `summary` of `getSessionInfo`). The SDK reads the first copy of the
|
|
701
|
+
* session that its search finds; its answer is used only when that copy is
|
|
702
|
+
* the listed file (same size and mtime), else the same titles are taken
|
|
703
|
+
* from the listed file itself (see {@link sdkTitles}).
|
|
704
|
+
*/
|
|
705
|
+
async titlesOf(candidate, headTail, fileCwd) {
|
|
706
|
+
const dir = [fileCwd, candidate.projectPath].find((cwd) => cwd !== undefined && isExactProjectDir(candidate.dirName, cwd));
|
|
707
|
+
const info = await this.getSessionInfo(candidate.sessionId, dir ? { dir } : {}).catch(() => undefined);
|
|
708
|
+
if (info &&
|
|
709
|
+
info.fileSize === candidate.size &&
|
|
710
|
+
info.lastModified === Math.trunc(candidate.mtimeMs)) {
|
|
711
|
+
return { customTitle: info.customTitle, summary: info.summary };
|
|
712
|
+
}
|
|
713
|
+
const sidecar = tailCustomTitle(headTail.tail) === undefined
|
|
714
|
+
? await readSidecarTitle(candidate.filePath, candidate.sessionId)
|
|
715
|
+
: undefined;
|
|
716
|
+
return sdkTitles(headTail, sidecar);
|
|
717
|
+
}
|
|
718
|
+
/** Whether a successor of a continued transcript holds history: the SDK
|
|
719
|
+
* list then hides the predecessor. */
|
|
720
|
+
async continuedElsewhere(candidate, metadata) {
|
|
721
|
+
if (!metadata.continuedIn)
|
|
722
|
+
return false;
|
|
723
|
+
return hasHistory(path.join(path.dirname(candidate.filePath), `${metadata.continuedIn}.jsonl`));
|
|
724
|
+
}
|
|
725
|
+
/** The first candidate that encodes to `dirName`, or, on macOS, that
|
|
726
|
+
* encodes to a name the file system resolves to `dirName` (it differs in
|
|
727
|
+
* case only). A directory name is never decoded. */
|
|
728
|
+
async recoverCwd(dirName, candidates) {
|
|
729
|
+
for (const candidate of candidates) {
|
|
730
|
+
if (!candidate || !path.isAbsolute(candidate))
|
|
731
|
+
continue;
|
|
732
|
+
if (projectDirMatches(dirName, candidate))
|
|
733
|
+
return candidate;
|
|
734
|
+
if (isExactProjectDir(dirName, candidate) && (await sameDirOnDisk(dirName, candidate))) {
|
|
735
|
+
return candidate;
|
|
736
|
+
}
|
|
737
|
+
}
|
|
738
|
+
return undefined;
|
|
739
|
+
}
|
|
740
|
+
}
|