sidekick-shared 0.26.4 → 0.26.5
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/CHANGELOG.md +17 -0
- package/README.md +5 -2
- package/dist/index.d.ts +4 -2
- package/dist/index.js +12 -9
- package/dist/node.d.ts +4 -1
- package/dist/node.js +4 -1
- package/dist/observedSessionCollector.d.ts +117 -5
- package/dist/observedSessionCollector.js +483 -76
- package/dist/providers/claudeCode.d.ts +23 -2
- package/dist/providers/claudeCode.js +143 -51
- package/dist/providers/codex.d.ts +10 -2
- package/dist/providers/codex.js +95 -12
- package/dist/providers/directoryListingCache.d.ts +85 -0
- package/dist/providers/directoryListingCache.js +244 -0
- package/dist/providers/rolloutWalker.d.ts +28 -5
- package/dist/providers/rolloutWalker.js +100 -16
- package/dist/providers/types.d.ts +48 -1
- package/dist/sessionMonitor.js +24 -2
- package/package.json +1 -1
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
*/
|
|
10
10
|
import type { ReadSessionContextSnapshotOptions, SessionContextSnapshot } from '../context/sessionContext';
|
|
11
11
|
import type { SubagentStats, TokenUsage } from '../types/sessionEvent';
|
|
12
|
-
import type { SessionProviderBase, SessionReader, SessionFileInfo, SessionFileStats, SearchHit, ProjectFolderInfo, ProviderId, ProviderOperationStatus, ProviderRuntimeStatus, SessionProviderOptions } from './types';
|
|
12
|
+
import type { ListSessionFilesOptions, SessionProviderBase, SessionReader, SessionFileInfo, SessionFileStats, SearchHit, ProjectFolderInfo, ProviderId, ProviderOperationStatus, ProviderRuntimeStatus, SessionProviderOptions, WatchedSessionFile } from './types';
|
|
13
13
|
/**
|
|
14
14
|
* Session provider for Claude Code CLI.
|
|
15
15
|
*
|
|
@@ -23,6 +23,8 @@ export declare class ClaudeCodeProvider implements SessionProviderBase {
|
|
|
23
23
|
private readonly diagnostics;
|
|
24
24
|
/** Runtime-reported context window limit (overrides static map when set). */
|
|
25
25
|
private dynamicContextWindowLimit;
|
|
26
|
+
/** Project-directory listings reused across async enumerations. */
|
|
27
|
+
private readonly directoryCache;
|
|
26
28
|
constructor(options?: SessionProviderOptions);
|
|
27
29
|
getSessionDirectory(workspacePath: string): string;
|
|
28
30
|
discoverSessionDirectory(workspacePath: string): string | null;
|
|
@@ -34,7 +36,26 @@ export declare class ClaudeCodeProvider implements SessionProviderBase {
|
|
|
34
36
|
findSessionsInDirectory(dir: string): string[];
|
|
35
37
|
getAllProjectFolders(workspacePath?: string): ProjectFolderInfo[];
|
|
36
38
|
listAllSessionFiles(): SessionFileInfo[];
|
|
37
|
-
|
|
39
|
+
/**
|
|
40
|
+
* Enumerate session files across project directories through the
|
|
41
|
+
* directory cache: one stat per project directory, a readdir only for
|
|
42
|
+
* directories whose mtime changed, and a stat only for files new to a
|
|
43
|
+
* changed directory (or recently modified, with `revalidateRecent`).
|
|
44
|
+
*
|
|
45
|
+
* With `limit`, directories are visited newest-mtime first and the walk
|
|
46
|
+
* stops once the remaining directories are older than the newest `limit`
|
|
47
|
+
* files found. A directory's mtime ignores appends, so a cold limited walk
|
|
48
|
+
* can miss a long-running session in an old directory until its next watch
|
|
49
|
+
* event; `since` filters files and shares the caveat.
|
|
50
|
+
*/
|
|
51
|
+
listSessionFilesAsync(workspacePath?: string, options?: ListSessionFilesOptions): Promise<SessionFileInfo[]>;
|
|
52
|
+
/**
|
|
53
|
+
* Resolve a recursive-watch event under the projects root with one stat.
|
|
54
|
+
* Sessions live exactly one directory below the root; deeper paths are
|
|
55
|
+
* subagent transcripts and are ignored. A path at the root itself is a
|
|
56
|
+
* project directory appearing or vanishing, which needs a full listing.
|
|
57
|
+
*/
|
|
58
|
+
statWatchedSessionFile(root: string, relativePath: string, workspacePath?: string): Promise<WatchedSessionFile>;
|
|
38
59
|
isSessionFile(filename: string): boolean;
|
|
39
60
|
getSessionId(sessionPath: string): string;
|
|
40
61
|
encodeWorkspacePath(workspacePath: string): string;
|
|
@@ -51,6 +51,7 @@ const sessionContext_1 = require("../context/sessionContext");
|
|
|
51
51
|
const sessionStats_1 = require("../sessionStats");
|
|
52
52
|
const jsonl_1 = require("../parsers/jsonl");
|
|
53
53
|
const diagnostics_1 = require("./diagnostics");
|
|
54
|
+
const directoryListingCache_1 = require("./directoryListingCache");
|
|
54
55
|
const sessionPathResolver_1 = require("../parsers/sessionPathResolver");
|
|
55
56
|
const subagentScanner_1 = require("../parsers/subagentScanner");
|
|
56
57
|
const modelContext_1 = require("../modelContext");
|
|
@@ -90,6 +91,27 @@ function extractSearchableText(event) {
|
|
|
90
91
|
}
|
|
91
92
|
return '';
|
|
92
93
|
}
|
|
94
|
+
/**
|
|
95
|
+
* Project-directory filter for a workspace: the encoded workspace path, a
|
|
96
|
+
* worktree suffix of it, or a directory ending in the workspace's basename.
|
|
97
|
+
* Without a workspace every directory matches.
|
|
98
|
+
*/
|
|
99
|
+
function workspaceDirectoryFilter(workspacePath) {
|
|
100
|
+
if (!workspacePath)
|
|
101
|
+
return () => true;
|
|
102
|
+
const encodedWorkspace = (0, sessionPathResolver_1.encodeWorkspacePath)(workspacePath).toLowerCase();
|
|
103
|
+
const workspaceBasename = path
|
|
104
|
+
.basename(workspacePath)
|
|
105
|
+
.replace(/[^a-zA-Z0-9]/g, '-')
|
|
106
|
+
.toLowerCase();
|
|
107
|
+
return (directoryName) => {
|
|
108
|
+
const name = directoryName.toLowerCase();
|
|
109
|
+
return (name === encodedWorkspace ||
|
|
110
|
+
name.startsWith(`${encodedWorkspace}-`) ||
|
|
111
|
+
name === workspaceBasename ||
|
|
112
|
+
name.endsWith(`-${workspaceBasename}`));
|
|
113
|
+
};
|
|
114
|
+
}
|
|
93
115
|
function extractClaudeLabelFromPrefix(chunk) {
|
|
94
116
|
for (const line of chunk.split('\n')) {
|
|
95
117
|
const trimmed = line.trim();
|
|
@@ -284,6 +306,8 @@ class ClaudeCodeProvider {
|
|
|
284
306
|
diagnostics;
|
|
285
307
|
/** Runtime-reported context window limit (overrides static map when set). */
|
|
286
308
|
dynamicContextWindowLimit = null;
|
|
309
|
+
/** Project-directory listings reused across async enumerations. */
|
|
310
|
+
directoryCache = new directoryListingCache_1.DirectoryListingCache();
|
|
287
311
|
constructor(options = {}) {
|
|
288
312
|
this.diagnostics = new diagnostics_1.ProviderDiagnosticTracker(this.id, options);
|
|
289
313
|
}
|
|
@@ -364,77 +388,144 @@ class ClaudeCodeProvider {
|
|
|
364
388
|
this.recordHomeStatus('listAllSessionFiles');
|
|
365
389
|
return results;
|
|
366
390
|
}
|
|
367
|
-
|
|
391
|
+
/**
|
|
392
|
+
* Enumerate session files across project directories through the
|
|
393
|
+
* directory cache: one stat per project directory, a readdir only for
|
|
394
|
+
* directories whose mtime changed, and a stat only for files new to a
|
|
395
|
+
* changed directory (or recently modified, with `revalidateRecent`).
|
|
396
|
+
*
|
|
397
|
+
* With `limit`, directories are visited newest-mtime first and the walk
|
|
398
|
+
* stops once the remaining directories are older than the newest `limit`
|
|
399
|
+
* files found. A directory's mtime ignores appends, so a cold limited walk
|
|
400
|
+
* can miss a long-running session in an old directory until its next watch
|
|
401
|
+
* event; `since` filters files and shares the caveat.
|
|
402
|
+
*/
|
|
403
|
+
async listSessionFilesAsync(workspacePath, options = {}) {
|
|
368
404
|
const projectsRoot = this.getProjectsBaseDir();
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
405
|
+
if (options.stats) {
|
|
406
|
+
options.stats.directoriesListed ??= 0;
|
|
407
|
+
options.stats.directoriesStatted ??= 0;
|
|
408
|
+
options.stats.filesStatted ??= 0;
|
|
372
409
|
}
|
|
373
|
-
|
|
410
|
+
const root = await this.directoryCache.listDirectory(projectsRoot, () => false, {
|
|
411
|
+
revalidateRecent: options.revalidateRecent,
|
|
412
|
+
stats: options.stats,
|
|
413
|
+
});
|
|
414
|
+
if (!root) {
|
|
374
415
|
this.recordHomeStatus('listSessionFilesAsync');
|
|
375
416
|
return [];
|
|
376
417
|
}
|
|
377
|
-
const
|
|
378
|
-
|
|
379
|
-
? path
|
|
380
|
-
.basename(workspacePath)
|
|
381
|
-
.replace(/[^a-zA-Z0-9]/g, '-')
|
|
382
|
-
.toLowerCase()
|
|
418
|
+
const limit = options.limit !== undefined && Number.isFinite(options.limit)
|
|
419
|
+
? Math.max(0, Math.floor(options.limit))
|
|
383
420
|
: null;
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
421
|
+
if (limit === 0) {
|
|
422
|
+
this.recordHomeStatus('listSessionFilesAsync');
|
|
423
|
+
return [];
|
|
424
|
+
}
|
|
425
|
+
const since = options.since !== undefined && Number.isFinite(options.since) ? options.since : null;
|
|
426
|
+
const matchesWorkspace = workspaceDirectoryFilter(workspacePath);
|
|
427
|
+
const candidates = [];
|
|
428
|
+
let sinceYield = 0;
|
|
429
|
+
for (const name of root.subdirectories) {
|
|
430
|
+
if (!matchesWorkspace(name))
|
|
431
|
+
continue;
|
|
432
|
+
const directory = path.join(projectsRoot, name);
|
|
387
433
|
try {
|
|
388
|
-
const stat =
|
|
389
|
-
if (
|
|
390
|
-
|
|
434
|
+
const stat = await fs.promises.stat(directory);
|
|
435
|
+
if (options.stats) {
|
|
436
|
+
options.stats.directoriesStatted = (options.stats.directoriesStatted ?? 0) + 1;
|
|
437
|
+
}
|
|
438
|
+
if (stat.isDirectory())
|
|
439
|
+
candidates.push({ directory, stat });
|
|
391
440
|
}
|
|
392
441
|
catch {
|
|
393
442
|
continue;
|
|
394
443
|
}
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
444
|
+
if (++sinceYield >= 50) {
|
|
445
|
+
sinceYield = 0;
|
|
446
|
+
await new Promise((resolve) => setImmediate(resolve));
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
if (limit !== null)
|
|
450
|
+
candidates.sort((left, right) => right.stat.mtimeMs - left.stat.mtimeMs);
|
|
451
|
+
const byNewest = (left, right) => right.mtime.getTime() - left.mtime.getTime();
|
|
452
|
+
const results = [];
|
|
453
|
+
let cutoffMs = null;
|
|
454
|
+
for (const candidate of candidates) {
|
|
455
|
+
if (limit !== null && cutoffMs !== null && candidate.stat.mtimeMs < cutoffMs)
|
|
456
|
+
break;
|
|
457
|
+
const listing = await this.directoryCache.listDirectory(candidate.directory, (name) => this.isSessionFile(name), {
|
|
458
|
+
dirStat: candidate.stat,
|
|
459
|
+
revalidateRecent: options.revalidateRecent,
|
|
460
|
+
stats: options.stats,
|
|
461
|
+
});
|
|
462
|
+
if (!listing)
|
|
401
463
|
continue;
|
|
464
|
+
for (const [name, stat] of listing.files) {
|
|
465
|
+
if (stat.sizeBytes <= 0)
|
|
466
|
+
continue;
|
|
467
|
+
if (since !== null && stat.mtimeMs < since)
|
|
468
|
+
continue;
|
|
469
|
+
const sessionPath = path.join(candidate.directory, name);
|
|
470
|
+
results.push({
|
|
471
|
+
path: sessionPath,
|
|
472
|
+
mtime: new Date(stat.mtimeMs),
|
|
473
|
+
sizeBytes: stat.sizeBytes,
|
|
474
|
+
sessionId: this.getSessionId(sessionPath),
|
|
475
|
+
});
|
|
476
|
+
}
|
|
477
|
+
if (limit !== null && results.length >= limit) {
|
|
478
|
+
results.sort(byNewest);
|
|
479
|
+
results.length = limit;
|
|
480
|
+
cutoffMs = results[limit - 1].mtime.getTime();
|
|
402
481
|
}
|
|
403
|
-
directories.push(directory);
|
|
404
482
|
await new Promise((resolve) => setImmediate(resolve));
|
|
405
483
|
}
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
484
|
+
this.recordHomeStatus('listSessionFilesAsync');
|
|
485
|
+
results.sort(byNewest);
|
|
486
|
+
return limit !== null && results.length > limit ? results.slice(0, limit) : results;
|
|
487
|
+
}
|
|
488
|
+
/**
|
|
489
|
+
* Resolve a recursive-watch event under the projects root with one stat.
|
|
490
|
+
* Sessions live exactly one directory below the root; deeper paths are
|
|
491
|
+
* subagent transcripts and are ignored. A path at the root itself is a
|
|
492
|
+
* project directory appearing or vanishing, which needs a full listing.
|
|
493
|
+
*/
|
|
494
|
+
async statWatchedSessionFile(root, relativePath, workspacePath) {
|
|
495
|
+
const segments = (0, directoryListingCache_1.splitWatchedPath)(relativePath);
|
|
496
|
+
if (!segments)
|
|
497
|
+
return { status: 'unknown' };
|
|
498
|
+
if (segments.length > 2)
|
|
499
|
+
return { status: 'ignored' };
|
|
500
|
+
const fullPath = path.join(root, ...segments);
|
|
501
|
+
if (segments.length === 1) {
|
|
502
|
+
this.directoryCache.invalidate(fullPath);
|
|
409
503
|
try {
|
|
410
|
-
|
|
504
|
+
const stat = await fs.promises.stat(fullPath);
|
|
505
|
+
return stat.isDirectory() ? { status: 'unknown' } : { status: 'ignored' };
|
|
411
506
|
}
|
|
412
507
|
catch {
|
|
413
|
-
|
|
414
|
-
}
|
|
415
|
-
for (const entry of entries) {
|
|
416
|
-
if (!entry.isFile() || !this.isSessionFile(entry.name))
|
|
417
|
-
continue;
|
|
418
|
-
const sessionPath = path.join(directory, entry.name);
|
|
419
|
-
try {
|
|
420
|
-
const stat = await fs.promises.stat(sessionPath);
|
|
421
|
-
if (stat.size > 0) {
|
|
422
|
-
results.push({
|
|
423
|
-
path: sessionPath,
|
|
424
|
-
mtime: stat.mtime,
|
|
425
|
-
sizeBytes: stat.size,
|
|
426
|
-
sessionId: this.getSessionId(sessionPath),
|
|
427
|
-
});
|
|
428
|
-
}
|
|
429
|
-
}
|
|
430
|
-
catch {
|
|
431
|
-
// Skip files that vanish between discovery and stat.
|
|
432
|
-
}
|
|
433
|
-
await new Promise((resolve) => setImmediate(resolve));
|
|
508
|
+
return { status: 'unknown' };
|
|
434
509
|
}
|
|
435
510
|
}
|
|
436
|
-
|
|
437
|
-
|
|
511
|
+
const [directoryName, fileName] = segments;
|
|
512
|
+
if (!this.isSessionFile(fileName))
|
|
513
|
+
return { status: 'ignored' };
|
|
514
|
+
if (!workspaceDirectoryFilter(workspacePath)(directoryName))
|
|
515
|
+
return { status: 'ignored' };
|
|
516
|
+
const sessionId = this.getSessionId(fullPath);
|
|
517
|
+
const stat = await this.directoryCache.refreshFile(fullPath);
|
|
518
|
+
if (!stat || stat.sizeBytes <= 0)
|
|
519
|
+
return { status: 'missing', path: fullPath, sessionId };
|
|
520
|
+
return {
|
|
521
|
+
status: 'present',
|
|
522
|
+
file: {
|
|
523
|
+
path: fullPath,
|
|
524
|
+
mtime: new Date(stat.mtimeMs),
|
|
525
|
+
sizeBytes: stat.sizeBytes,
|
|
526
|
+
sessionId,
|
|
527
|
+
},
|
|
528
|
+
};
|
|
438
529
|
}
|
|
439
530
|
// --- File identification ---
|
|
440
531
|
isSessionFile(filename) {
|
|
@@ -627,6 +718,7 @@ class ClaudeCodeProvider {
|
|
|
627
718
|
// --- Lifecycle ---
|
|
628
719
|
dispose() {
|
|
629
720
|
this.dynamicContextWindowLimit = null;
|
|
721
|
+
this.directoryCache.invalidate();
|
|
630
722
|
}
|
|
631
723
|
recordHomeStatus(operation) {
|
|
632
724
|
const status = this.getRuntimeStatus();
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
* Ported from sidekick-vscode/src/services/providers/CodexSessionProvider.ts
|
|
10
10
|
*/
|
|
11
11
|
import type { ReadSessionContextSnapshotOptions, SessionContextSnapshot } from '../context/sessionContext';
|
|
12
|
-
import type { SessionProviderBase, SessionReader, ProjectFolderInfo, SearchHit, SessionFileInfo, SessionFileStats, ProviderId, ProviderOperationStatus, ProviderRuntimeStatus, SessionProviderOptions } from './types';
|
|
12
|
+
import type { ListSessionFilesOptions, SessionProviderBase, SessionReader, ProjectFolderInfo, SearchHit, SessionFileInfo, SessionFileStats, ProviderId, ProviderOperationStatus, ProviderRuntimeStatus, SessionProviderOptions, WatchedSessionFile } from './types';
|
|
13
13
|
import type { SubagentStats, TokenUsage } from '../types/sessionEvent';
|
|
14
14
|
import type { CodexRateLimits } from '../types/codex';
|
|
15
15
|
export interface FindCodexRolloutFileOptions {
|
|
@@ -48,6 +48,8 @@ export declare class CodexProvider implements SessionProviderBase {
|
|
|
48
48
|
private dynamicContextWindowLimit;
|
|
49
49
|
private lastRateLimitsData;
|
|
50
50
|
private lastTokenUsageData;
|
|
51
|
+
/** Dated rollout-directory listings reused across async enumerations. */
|
|
52
|
+
private readonly directoryCache;
|
|
51
53
|
constructor(options?: SessionProviderOptions);
|
|
52
54
|
/** Lazy-initialize database connection. */
|
|
53
55
|
private ensureDb;
|
|
@@ -69,7 +71,13 @@ export declare class CodexProvider implements SessionProviderBase {
|
|
|
69
71
|
findSessionFiles(workspacePath: string): string[];
|
|
70
72
|
findSessionsInDirectory(dir: string): string[];
|
|
71
73
|
listAllSessionFiles(): SessionFileInfo[];
|
|
72
|
-
listSessionFilesAsync(workspacePath?: string): Promise<SessionFileInfo[]>;
|
|
74
|
+
listSessionFilesAsync(workspacePath?: string, options?: ListSessionFilesOptions): Promise<SessionFileInfo[]>;
|
|
75
|
+
/**
|
|
76
|
+
* Resolve a recursive-watch event under a sessions root with one stat.
|
|
77
|
+
* Rollouts are matched by name at any depth the walker would visit; a
|
|
78
|
+
* dated directory appearing or vanishing needs a full walk.
|
|
79
|
+
*/
|
|
80
|
+
statWatchedSessionFile(root: string, relativePath: string, workspacePath?: string): Promise<WatchedSessionFile>;
|
|
73
81
|
getAllProjectFolders(workspacePath?: string): ProjectFolderInfo[];
|
|
74
82
|
isSessionFile(filename: string): boolean;
|
|
75
83
|
getSessionId(sessionPath: string): string;
|
package/dist/providers/codex.js
CHANGED
|
@@ -54,6 +54,7 @@ const codexParser_1 = require("../parsers/codexParser");
|
|
|
54
54
|
const codexDatabase_1 = require("./codexDatabase");
|
|
55
55
|
const codexProfiles_1 = require("../codexProfiles");
|
|
56
56
|
const rolloutWalker_1 = require("./rolloutWalker");
|
|
57
|
+
const directoryListingCache_1 = require("./directoryListingCache");
|
|
57
58
|
const diagnostics_1 = require("./diagnostics");
|
|
58
59
|
const modelContext_1 = require("../modelContext");
|
|
59
60
|
const observedContextWindows_1 = require("../observedContextWindows");
|
|
@@ -193,7 +194,13 @@ function readSessionMeta(rolloutPath) {
|
|
|
193
194
|
}
|
|
194
195
|
return null;
|
|
195
196
|
}
|
|
196
|
-
|
|
197
|
+
/**
|
|
198
|
+
* First line of a rollout, chunk-read under the same 256 KiB cap as
|
|
199
|
+
* readFirstLines(). `complete` is false while the line has no newline yet
|
|
200
|
+
* (a rollout that was just created) or the file could not be read, so
|
|
201
|
+
* callers know not to cache the answer.
|
|
202
|
+
*/
|
|
203
|
+
async function readFirstLineAsync(rolloutPath) {
|
|
197
204
|
// Mirrors readSessionMeta()/readFirstLines(): chunk-read until the first
|
|
198
205
|
// line is complete under the same 256 KiB cap — session_meta carries
|
|
199
206
|
// `instructions` and can far exceed a single chunk.
|
|
@@ -216,18 +223,26 @@ async function readSessionMetaAsync(rolloutPath) {
|
|
|
216
223
|
newlineFound = chunk.includes(0x0a);
|
|
217
224
|
}
|
|
218
225
|
const firstLine = Buffer.concat(chunks).toString('utf8').split('\n', 1)[0]?.trim();
|
|
219
|
-
|
|
220
|
-
return null;
|
|
221
|
-
const parsed = JSON.parse(firstLine);
|
|
222
|
-
return parsed.type === 'session_meta' ? parsed.payload : null;
|
|
226
|
+
return { line: firstLine || null, complete: newlineFound || totalRead >= maxBytes };
|
|
223
227
|
}
|
|
224
228
|
catch {
|
|
225
|
-
return null;
|
|
229
|
+
return { line: null, complete: false };
|
|
226
230
|
}
|
|
227
231
|
finally {
|
|
228
232
|
await handle?.close().catch(() => undefined);
|
|
229
233
|
}
|
|
230
234
|
}
|
|
235
|
+
function parseSessionMetaLine(line) {
|
|
236
|
+
if (!line)
|
|
237
|
+
return null;
|
|
238
|
+
try {
|
|
239
|
+
const parsed = JSON.parse(line);
|
|
240
|
+
return parsed.type === 'session_meta' ? parsed.payload : null;
|
|
241
|
+
}
|
|
242
|
+
catch {
|
|
243
|
+
return null;
|
|
244
|
+
}
|
|
245
|
+
}
|
|
231
246
|
/**
|
|
232
247
|
* Read the CWD from a rollout file's session_meta. Cached per path, bounded
|
|
233
248
|
* so a long-lived host watching a large history cannot grow it without limit
|
|
@@ -257,11 +272,23 @@ async function readSessionCwdAsync(rolloutPath) {
|
|
|
257
272
|
const cached = cwdCache.get(rolloutPath);
|
|
258
273
|
if (cached !== undefined)
|
|
259
274
|
return cached;
|
|
260
|
-
const
|
|
261
|
-
const cwd =
|
|
262
|
-
|
|
275
|
+
const { line, complete } = await readFirstLineAsync(rolloutPath);
|
|
276
|
+
const cwd = parseSessionMetaLine(line)?.cwd || null;
|
|
277
|
+
// A rollout observed before its session_meta line is flushed has no cwd
|
|
278
|
+
// yet; caching that null would hide the session from its workspace until
|
|
279
|
+
// the entry aged out, so only conclusive answers are remembered.
|
|
280
|
+
if (cwd !== null || complete)
|
|
281
|
+
rememberCwd(rolloutPath, cwd);
|
|
263
282
|
return cwd;
|
|
264
283
|
}
|
|
284
|
+
/** Built-in enumerators report explicit zeros rather than leaving a counter unset. */
|
|
285
|
+
function initializeCounters(stats) {
|
|
286
|
+
if (!stats)
|
|
287
|
+
return;
|
|
288
|
+
stats.directoriesListed ??= 0;
|
|
289
|
+
stats.directoriesStatted ??= 0;
|
|
290
|
+
stats.filesStatted ??= 0;
|
|
291
|
+
}
|
|
265
292
|
/** Check if a session's CWD matches a workspace path. */
|
|
266
293
|
function cwdMatches(sessionCwd, workspacePath) {
|
|
267
294
|
const normalizedSession = normalizePath(sessionCwd);
|
|
@@ -646,6 +673,8 @@ class CodexProvider {
|
|
|
646
673
|
dynamicContextWindowLimit = null;
|
|
647
674
|
lastRateLimitsData = null;
|
|
648
675
|
lastTokenUsageData = null;
|
|
676
|
+
/** Dated rollout-directory listings reused across async enumerations. */
|
|
677
|
+
directoryCache = new directoryListingCache_1.DirectoryListingCache();
|
|
649
678
|
constructor(options = {}) {
|
|
650
679
|
this.diagnostics = new diagnostics_1.ProviderDiagnosticTracker(this.id, options);
|
|
651
680
|
}
|
|
@@ -802,10 +831,22 @@ class CodexProvider {
|
|
|
802
831
|
this.recordDatabaseFallback('listAllSessionFiles', true);
|
|
803
832
|
return files;
|
|
804
833
|
}
|
|
805
|
-
async listSessionFilesAsync(workspacePath) {
|
|
834
|
+
async listSessionFilesAsync(workspacePath, options = {}) {
|
|
806
835
|
const results = [];
|
|
807
|
-
|
|
808
|
-
|
|
836
|
+
initializeCounters(options.stats);
|
|
837
|
+
// One capped walk across the homes (deduplicated, newest first) through
|
|
838
|
+
// the directory cache. With a workspace filter the limit applies after
|
|
839
|
+
// filtering, so the walk itself is bounded only by its file cap.
|
|
840
|
+
const files = await (0, rolloutWalker_1.walkRolloutFilesAsync)(getSessionsDirs(), {
|
|
841
|
+
limit: workspacePath ? undefined : options.limit,
|
|
842
|
+
since: options.since,
|
|
843
|
+
cache: this.directoryCache,
|
|
844
|
+
revalidateRecent: options.revalidateRecent,
|
|
845
|
+
stats: options.stats,
|
|
846
|
+
});
|
|
847
|
+
for (const file of files) {
|
|
848
|
+
if (options.limit !== undefined && results.length >= options.limit)
|
|
849
|
+
break;
|
|
809
850
|
const info = { ...file };
|
|
810
851
|
if (workspacePath) {
|
|
811
852
|
const cwd = await readSessionCwdAsync(file.path);
|
|
@@ -821,6 +862,47 @@ class CodexProvider {
|
|
|
821
862
|
this.recordDatabaseFallback('listSessionFilesAsync', true);
|
|
822
863
|
return results;
|
|
823
864
|
}
|
|
865
|
+
/**
|
|
866
|
+
* Resolve a recursive-watch event under a sessions root with one stat.
|
|
867
|
+
* Rollouts are matched by name at any depth the walker would visit; a
|
|
868
|
+
* dated directory appearing or vanishing needs a full walk.
|
|
869
|
+
*/
|
|
870
|
+
async statWatchedSessionFile(root, relativePath, workspacePath) {
|
|
871
|
+
const segments = (0, directoryListingCache_1.splitWatchedPath)(relativePath);
|
|
872
|
+
if (!segments)
|
|
873
|
+
return { status: 'unknown' };
|
|
874
|
+
const fullPath = path.join(root, ...segments);
|
|
875
|
+
const name = segments[segments.length - 1];
|
|
876
|
+
if (segments.length - 1 > rolloutWalker_1.DEFAULT_ROLLOUT_WALK_MAX_DEPTH)
|
|
877
|
+
return { status: 'ignored' };
|
|
878
|
+
if (!(0, rolloutWalker_1.isRolloutFile)(name)) {
|
|
879
|
+
this.directoryCache.invalidate(fullPath);
|
|
880
|
+
try {
|
|
881
|
+
const stat = await fs.promises.stat(fullPath);
|
|
882
|
+
return stat.isDirectory() ? { status: 'unknown' } : { status: 'ignored' };
|
|
883
|
+
}
|
|
884
|
+
catch {
|
|
885
|
+
return { status: 'unknown' };
|
|
886
|
+
}
|
|
887
|
+
}
|
|
888
|
+
const sessionId = (0, rolloutWalker_1.extractRolloutSessionId)(name);
|
|
889
|
+
const stat = await this.directoryCache.refreshFile(fullPath);
|
|
890
|
+
if (!stat || stat.sizeBytes <= 0)
|
|
891
|
+
return { status: 'missing', path: fullPath, sessionId };
|
|
892
|
+
const file = {
|
|
893
|
+
path: fullPath,
|
|
894
|
+
mtime: new Date(stat.mtimeMs),
|
|
895
|
+
sizeBytes: stat.sizeBytes,
|
|
896
|
+
sessionId,
|
|
897
|
+
};
|
|
898
|
+
if (workspacePath) {
|
|
899
|
+
const cwd = await readSessionCwdAsync(fullPath);
|
|
900
|
+
if (!cwd || !cwdMatches(cwd, workspacePath))
|
|
901
|
+
return { status: 'ignored' };
|
|
902
|
+
file.workspacePath = cwd;
|
|
903
|
+
}
|
|
904
|
+
return { status: 'present', file };
|
|
905
|
+
}
|
|
824
906
|
getAllProjectFolders(workspacePath) {
|
|
825
907
|
const folders = [];
|
|
826
908
|
const seenCwds = new Map();
|
|
@@ -1153,6 +1235,7 @@ class CodexProvider {
|
|
|
1153
1235
|
this.dynamicContextWindowLimit = null;
|
|
1154
1236
|
this.lastRateLimitsData = null;
|
|
1155
1237
|
this.lastTokenUsageData = null;
|
|
1238
|
+
this.directoryCache.invalidate();
|
|
1156
1239
|
}
|
|
1157
1240
|
recordDatabaseFallback(operation, usable) {
|
|
1158
1241
|
if (!this.dbInitialized) {
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Directory listings keyed by path and validated by the directory's own
|
|
3
|
+
* mtime and inode.
|
|
4
|
+
*
|
|
5
|
+
* A create, delete, or rename inside a directory bumps that directory's
|
|
6
|
+
* mtime; an append to an existing file does not. So a listing can be reused
|
|
7
|
+
* for as long as the directory's mtime and inode are unchanged, and the per
|
|
8
|
+
* file size/mtime it carries is only refreshed by `refreshFile()` (the watch
|
|
9
|
+
* event path) or by `revalidateRecent` on catch-up passes. Full discovery is
|
|
10
|
+
* then one stat per directory instead of one readdir per directory plus one
|
|
11
|
+
* stat per file.
|
|
12
|
+
*
|
|
13
|
+
* @module providers/directoryListingCache
|
|
14
|
+
*/
|
|
15
|
+
import * as fs from 'fs';
|
|
16
|
+
export interface CachedFileStat {
|
|
17
|
+
sizeBytes: number;
|
|
18
|
+
mtimeMs: number;
|
|
19
|
+
}
|
|
20
|
+
export interface CachedDirectoryListing {
|
|
21
|
+
/** Directory mtime when the listing was taken. */
|
|
22
|
+
mtimeMs: number;
|
|
23
|
+
ino: number;
|
|
24
|
+
/** Wall-clock time the listing was taken (coarse-mtime guard). */
|
|
25
|
+
listedAtMs: number;
|
|
26
|
+
/** Files accepted by the caller's filter, with the stat seen last. */
|
|
27
|
+
files: Map<string, CachedFileStat>;
|
|
28
|
+
/** Subdirectory names, unsorted. */
|
|
29
|
+
subdirectories: string[];
|
|
30
|
+
}
|
|
31
|
+
/** Payload-free counters a caller can pass to measure what a pass cost. */
|
|
32
|
+
export interface DirectoryListingCounters {
|
|
33
|
+
directoriesListed?: number;
|
|
34
|
+
directoriesStatted?: number;
|
|
35
|
+
filesStatted?: number;
|
|
36
|
+
}
|
|
37
|
+
export interface DirectoryListingCacheOptions {
|
|
38
|
+
/** Bound on cached directories, least-recently-used first (default 10 000). */
|
|
39
|
+
maxDirectories?: number;
|
|
40
|
+
/**
|
|
41
|
+
* Files whose cached mtime is within this window are re-stat'ed when a
|
|
42
|
+
* caller asks for `revalidateRecent` (default 30 minutes).
|
|
43
|
+
*/
|
|
44
|
+
recentWindowMs?: number;
|
|
45
|
+
}
|
|
46
|
+
export interface ListDirectoryOptions {
|
|
47
|
+
/** Re-stat files modified within the recent window even if the directory is unchanged. */
|
|
48
|
+
revalidateRecent?: boolean;
|
|
49
|
+
/** A stat of the directory the caller already took; saves one syscall. */
|
|
50
|
+
dirStat?: fs.Stats;
|
|
51
|
+
stats?: DirectoryListingCounters;
|
|
52
|
+
}
|
|
53
|
+
export declare const DEFAULT_DIRECTORY_CACHE_MAX_DIRECTORIES = 10000;
|
|
54
|
+
export declare const DEFAULT_DIRECTORY_CACHE_RECENT_WINDOW_MS: number;
|
|
55
|
+
export declare class DirectoryListingCache {
|
|
56
|
+
private readonly listings;
|
|
57
|
+
private readonly maxDirectories;
|
|
58
|
+
private readonly recentWindowMs;
|
|
59
|
+
constructor(options?: DirectoryListingCacheOptions);
|
|
60
|
+
get size(): number;
|
|
61
|
+
/** The cached listing without touching the filesystem, if any. */
|
|
62
|
+
peek(dir: string): CachedDirectoryListing | undefined;
|
|
63
|
+
/**
|
|
64
|
+
* List `dir`, reusing the cached listing when the directory is unchanged.
|
|
65
|
+
* Returns null when `dir` is missing, unreadable, or not a directory.
|
|
66
|
+
*/
|
|
67
|
+
listDirectory(dir: string, accept: (name: string) => boolean, options?: ListDirectoryOptions): Promise<CachedDirectoryListing | null>;
|
|
68
|
+
/**
|
|
69
|
+
* Stat one file and reflect the result in its parent's cached listing when
|
|
70
|
+
* that listing exists. Returns null when the path is missing or not a file.
|
|
71
|
+
* Never creates a listing: a partial listing must not pass for a full one.
|
|
72
|
+
*/
|
|
73
|
+
refreshFile(fullPath: string, stats?: DirectoryListingCounters): Promise<CachedFileStat | null>;
|
|
74
|
+
/** Drop one directory's listing, or every listing. */
|
|
75
|
+
invalidate(dir?: string): void;
|
|
76
|
+
private revalidateRecent;
|
|
77
|
+
private isRecent;
|
|
78
|
+
private touch;
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Split a watcher-reported root-relative path into segments. Both separators
|
|
82
|
+
* are accepted (Windows watchers report backslashes). Returns null for an
|
|
83
|
+
* empty path or one that escapes the root.
|
|
84
|
+
*/
|
|
85
|
+
export declare function splitWatchedPath(relativePath: string): string[] | null;
|