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.
@@ -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
- listSessionFilesAsync(workspacePath?: string): Promise<SessionFileInfo[]>;
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
- async listSessionFilesAsync(workspacePath) {
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
- let projectEntries = [];
370
- try {
371
- projectEntries = await fs.promises.readdir(projectsRoot, { withFileTypes: true });
405
+ if (options.stats) {
406
+ options.stats.directoriesListed ??= 0;
407
+ options.stats.directoriesStatted ??= 0;
408
+ options.stats.filesStatted ??= 0;
372
409
  }
373
- catch {
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 encodedWorkspace = workspacePath ? (0, sessionPathResolver_1.encodeWorkspacePath)(workspacePath).toLowerCase() : null;
378
- const workspaceBasename = workspacePath
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
- const directories = [];
385
- for (const entry of projectEntries) {
386
- const directory = path.join(projectsRoot, entry.name);
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 = entry.isDirectory() ? null : await fs.promises.stat(directory);
389
- if (!entry.isDirectory() && !stat?.isDirectory())
390
- continue;
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
- const name = entry.name.toLowerCase();
396
- if (encodedWorkspace &&
397
- name !== encodedWorkspace &&
398
- !name.startsWith(`${encodedWorkspace}-`) &&
399
- name !== workspaceBasename &&
400
- !name.endsWith(`-${workspaceBasename}`)) {
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
- const results = [];
407
- for (const directory of directories) {
408
- let entries = [];
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
- entries = await fs.promises.readdir(directory, { withFileTypes: true });
504
+ const stat = await fs.promises.stat(fullPath);
505
+ return stat.isDirectory() ? { status: 'unknown' } : { status: 'ignored' };
411
506
  }
412
507
  catch {
413
- continue;
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
- this.recordHomeStatus('listSessionFilesAsync');
437
- return results.sort((left, right) => right.mtime.getTime() - left.mtime.getTime());
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;
@@ -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
- async function readSessionMetaAsync(rolloutPath) {
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
- if (!firstLine)
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 meta = await readSessionMetaAsync(rolloutPath);
261
- const cwd = meta?.cwd || null;
262
- rememberCwd(rolloutPath, cwd);
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
- // One capped walk across the homes (deduplicated, newest first).
808
- for (const file of await (0, rolloutWalker_1.walkRolloutFilesAsync)(getSessionsDirs())) {
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;