sidekick-shared 0.26.4 → 0.26.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (69) hide show
  1. package/CHANGELOG.md +52 -0
  2. package/README.md +59 -51
  3. package/dist/accountChanges.js +21 -2
  4. package/dist/accountHealth.d.ts +72 -0
  5. package/dist/accountHealth.js +286 -0
  6. package/dist/accountLaunch.d.ts +30 -0
  7. package/dist/accountLaunch.js +124 -0
  8. package/dist/accountManager.d.ts +33 -8
  9. package/dist/accountManager.js +183 -50
  10. package/dist/accountRegistry.d.ts +10 -0
  11. package/dist/accountRegistry.js +42 -22
  12. package/dist/accountSwitch.d.ts +43 -0
  13. package/dist/accountSwitch.js +115 -0
  14. package/dist/accountSync.d.ts +30 -0
  15. package/dist/accountSync.js +180 -0
  16. package/dist/accountSyncTypes.d.ts +25 -0
  17. package/dist/accountSyncTypes.js +7 -0
  18. package/dist/accounts.d.ts +58 -6
  19. package/dist/accounts.js +408 -189
  20. package/dist/claudeCredentials.d.ts +38 -0
  21. package/dist/claudeCredentials.js +74 -0
  22. package/dist/claudeProfiles.d.ts +40 -0
  23. package/dist/claudeProfiles.js +102 -10
  24. package/dist/codexAuth.d.ts +32 -0
  25. package/dist/codexAuth.js +127 -0
  26. package/dist/codexPaths.d.ts +21 -0
  27. package/dist/codexPaths.js +117 -0
  28. package/dist/codexProfiles.d.ts +58 -5
  29. package/dist/codexProfiles.js +574 -289
  30. package/dist/credentialIO.d.ts +12 -5
  31. package/dist/credentialIO.js +63 -29
  32. package/dist/credentials.d.ts +8 -0
  33. package/dist/credentials.js +28 -32
  34. package/dist/ensureDefaultAccounts.d.ts +8 -0
  35. package/dist/ensureDefaultAccounts.js +42 -82
  36. package/dist/index.d.ts +31 -12
  37. package/dist/index.js +68 -13
  38. package/dist/keepAlive.d.ts +44 -0
  39. package/dist/keepAlive.js +106 -0
  40. package/dist/node.d.ts +4 -1
  41. package/dist/node.js +4 -1
  42. package/dist/observedSessionCollector.d.ts +117 -5
  43. package/dist/observedSessionCollector.js +483 -76
  44. package/dist/processDetection.d.ts +32 -0
  45. package/dist/processDetection.js +210 -0
  46. package/dist/providers/claudeCode.d.ts +23 -2
  47. package/dist/providers/claudeCode.js +143 -51
  48. package/dist/providers/codex.d.ts +10 -2
  49. package/dist/providers/codex.js +95 -12
  50. package/dist/providers/directoryListingCache.d.ts +85 -0
  51. package/dist/providers/directoryListingCache.js +244 -0
  52. package/dist/providers/rolloutWalker.d.ts +28 -5
  53. package/dist/providers/rolloutWalker.js +100 -16
  54. package/dist/providers/types.d.ts +48 -1
  55. package/dist/schemas/accountManager.d.ts +283 -1
  56. package/dist/schemas/accountManager.js +122 -1
  57. package/dist/schemas/assistantTurn.d.ts +2 -2
  58. package/dist/schemas/index.d.ts +6 -1
  59. package/dist/schemas/index.js +14 -1
  60. package/dist/schemas/observedSessionV1.d.ts +21 -21
  61. package/dist/schemas/quota.d.ts +18 -18
  62. package/dist/schemas/quotaHistory.d.ts +1 -1
  63. package/dist/schemas/stateFile.d.ts +5 -5
  64. package/dist/sessionMonitor.js +24 -2
  65. package/dist/terminalSync.d.ts +5 -5
  66. package/dist/terminalSync.js +7 -112
  67. package/dist/writers/atomic.d.ts +2 -0
  68. package/dist/writers/atomic.js +49 -2
  69. package/package.json +1 -1
@@ -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;
@@ -0,0 +1,244 @@
1
+ "use strict";
2
+ /**
3
+ * Directory listings keyed by path and validated by the directory's own
4
+ * mtime and inode.
5
+ *
6
+ * A create, delete, or rename inside a directory bumps that directory's
7
+ * mtime; an append to an existing file does not. So a listing can be reused
8
+ * for as long as the directory's mtime and inode are unchanged, and the per
9
+ * file size/mtime it carries is only refreshed by `refreshFile()` (the watch
10
+ * event path) or by `revalidateRecent` on catch-up passes. Full discovery is
11
+ * then one stat per directory instead of one readdir per directory plus one
12
+ * stat per file.
13
+ *
14
+ * @module providers/directoryListingCache
15
+ */
16
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
17
+ if (k2 === undefined) k2 = k;
18
+ var desc = Object.getOwnPropertyDescriptor(m, k);
19
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
20
+ desc = { enumerable: true, get: function() { return m[k]; } };
21
+ }
22
+ Object.defineProperty(o, k2, desc);
23
+ }) : (function(o, m, k, k2) {
24
+ if (k2 === undefined) k2 = k;
25
+ o[k2] = m[k];
26
+ }));
27
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
28
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
29
+ }) : function(o, v) {
30
+ o["default"] = v;
31
+ });
32
+ var __importStar = (this && this.__importStar) || (function () {
33
+ var ownKeys = function(o) {
34
+ ownKeys = Object.getOwnPropertyNames || function (o) {
35
+ var ar = [];
36
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
37
+ return ar;
38
+ };
39
+ return ownKeys(o);
40
+ };
41
+ return function (mod) {
42
+ if (mod && mod.__esModule) return mod;
43
+ var result = {};
44
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
45
+ __setModuleDefault(result, mod);
46
+ return result;
47
+ };
48
+ })();
49
+ Object.defineProperty(exports, "__esModule", { value: true });
50
+ exports.DirectoryListingCache = exports.DEFAULT_DIRECTORY_CACHE_RECENT_WINDOW_MS = exports.DEFAULT_DIRECTORY_CACHE_MAX_DIRECTORIES = void 0;
51
+ exports.splitWatchedPath = splitWatchedPath;
52
+ const fs = __importStar(require("fs"));
53
+ const path = __importStar(require("path"));
54
+ exports.DEFAULT_DIRECTORY_CACHE_MAX_DIRECTORIES = 10_000;
55
+ exports.DEFAULT_DIRECTORY_CACHE_RECENT_WINDOW_MS = 30 * 60_000;
56
+ /**
57
+ * A directory whose mtime is within this many milliseconds of the moment it
58
+ * was listed may still receive entries that land on the same coarse mtime
59
+ * (HFS+, exFAT, SMB), so it is re-listed rather than trusted.
60
+ */
61
+ const COARSE_MTIME_GUARD_MS = 2_000;
62
+ class DirectoryListingCache {
63
+ listings = new Map();
64
+ maxDirectories;
65
+ recentWindowMs;
66
+ constructor(options = {}) {
67
+ this.maxDirectories = Math.max(1, Math.floor(options.maxDirectories ?? exports.DEFAULT_DIRECTORY_CACHE_MAX_DIRECTORIES));
68
+ this.recentWindowMs = Math.max(0, options.recentWindowMs ?? exports.DEFAULT_DIRECTORY_CACHE_RECENT_WINDOW_MS);
69
+ }
70
+ get size() {
71
+ return this.listings.size;
72
+ }
73
+ /** The cached listing without touching the filesystem, if any. */
74
+ peek(dir) {
75
+ return this.listings.get(dir);
76
+ }
77
+ /**
78
+ * List `dir`, reusing the cached listing when the directory is unchanged.
79
+ * Returns null when `dir` is missing, unreadable, or not a directory.
80
+ */
81
+ async listDirectory(dir, accept, options = {}) {
82
+ let dirStat = options.dirStat;
83
+ if (!dirStat) {
84
+ try {
85
+ dirStat = await fs.promises.stat(dir);
86
+ count(options.stats, 'directoriesStatted');
87
+ }
88
+ catch {
89
+ this.listings.delete(dir);
90
+ return null;
91
+ }
92
+ }
93
+ if (!dirStat.isDirectory()) {
94
+ this.listings.delete(dir);
95
+ return null;
96
+ }
97
+ const cached = this.listings.get(dir);
98
+ const unchanged = cached !== undefined &&
99
+ cached.mtimeMs === dirStat.mtimeMs &&
100
+ cached.ino === dirStat.ino &&
101
+ dirStat.mtimeMs < cached.listedAtMs - COARSE_MTIME_GUARD_MS;
102
+ if (cached && unchanged) {
103
+ this.touch(dir, cached);
104
+ if (options.revalidateRecent)
105
+ await this.revalidateRecent(dir, cached, options.stats);
106
+ return cached;
107
+ }
108
+ let entries;
109
+ try {
110
+ entries = await fs.promises.readdir(dir, { withFileTypes: true });
111
+ count(options.stats, 'directoriesListed');
112
+ }
113
+ catch {
114
+ this.listings.delete(dir);
115
+ return null;
116
+ }
117
+ const listedAtMs = Date.now();
118
+ const files = new Map();
119
+ const subdirectories = [];
120
+ for (const entry of entries) {
121
+ if (entry.isDirectory()) {
122
+ subdirectories.push(entry.name);
123
+ continue;
124
+ }
125
+ const isCandidateFile = entry.isFile() && accept(entry.name);
126
+ if (!isCandidateFile && !entry.isSymbolicLink())
127
+ continue;
128
+ const fullPath = path.join(dir, entry.name);
129
+ // A file already in the listing keeps its cached stat unless it is
130
+ // recent enough to be live; a directory change is a create, delete, or
131
+ // rename, and only the new names need a fresh stat.
132
+ const previous = cached?.files.get(entry.name);
133
+ if (isCandidateFile && previous && !this.isRecent(previous, listedAtMs)) {
134
+ files.set(entry.name, previous);
135
+ continue;
136
+ }
137
+ if (entry.isSymbolicLink() && !accept(entry.name)) {
138
+ // Symlinked directories are followed so linked project folders count.
139
+ try {
140
+ const target = await fs.promises.stat(fullPath);
141
+ count(options.stats, 'directoriesStatted');
142
+ if (target.isDirectory())
143
+ subdirectories.push(entry.name);
144
+ }
145
+ catch {
146
+ // Dangling link.
147
+ }
148
+ continue;
149
+ }
150
+ try {
151
+ const stat = await fs.promises.stat(fullPath);
152
+ count(options.stats, 'filesStatted');
153
+ if (stat.isFile())
154
+ files.set(entry.name, { sizeBytes: stat.size, mtimeMs: stat.mtimeMs });
155
+ else if (stat.isDirectory())
156
+ subdirectories.push(entry.name);
157
+ }
158
+ catch {
159
+ // Vanished between readdir and stat.
160
+ }
161
+ }
162
+ const listing = {
163
+ mtimeMs: dirStat.mtimeMs,
164
+ ino: dirStat.ino,
165
+ listedAtMs,
166
+ files,
167
+ subdirectories,
168
+ };
169
+ this.touch(dir, listing);
170
+ return listing;
171
+ }
172
+ /**
173
+ * Stat one file and reflect the result in its parent's cached listing when
174
+ * that listing exists. Returns null when the path is missing or not a file.
175
+ * Never creates a listing: a partial listing must not pass for a full one.
176
+ */
177
+ async refreshFile(fullPath, stats) {
178
+ const dir = path.dirname(fullPath);
179
+ const name = path.basename(fullPath);
180
+ const listing = this.listings.get(dir);
181
+ try {
182
+ const stat = await fs.promises.stat(fullPath);
183
+ count(stats, 'filesStatted');
184
+ if (!stat.isFile()) {
185
+ listing?.files.delete(name);
186
+ return null;
187
+ }
188
+ const fileStat = { sizeBytes: stat.size, mtimeMs: stat.mtimeMs };
189
+ listing?.files.set(name, fileStat);
190
+ return fileStat;
191
+ }
192
+ catch {
193
+ listing?.files.delete(name);
194
+ return null;
195
+ }
196
+ }
197
+ /** Drop one directory's listing, or every listing. */
198
+ invalidate(dir) {
199
+ if (dir === undefined)
200
+ this.listings.clear();
201
+ else
202
+ this.listings.delete(dir);
203
+ }
204
+ async revalidateRecent(dir, listing, stats) {
205
+ const now = Date.now();
206
+ for (const [name, cached] of [...listing.files]) {
207
+ if (!this.isRecent(cached, now))
208
+ continue;
209
+ await this.refreshFile(path.join(dir, name), stats);
210
+ }
211
+ }
212
+ isRecent(stat, now) {
213
+ return now - stat.mtimeMs <= this.recentWindowMs;
214
+ }
215
+ touch(dir, listing) {
216
+ this.listings.delete(dir);
217
+ this.listings.set(dir, listing);
218
+ while (this.listings.size > this.maxDirectories) {
219
+ const oldest = this.listings.keys().next().value;
220
+ if (oldest === undefined)
221
+ break;
222
+ this.listings.delete(oldest);
223
+ }
224
+ }
225
+ }
226
+ exports.DirectoryListingCache = DirectoryListingCache;
227
+ /**
228
+ * Split a watcher-reported root-relative path into segments. Both separators
229
+ * are accepted (Windows watchers report backslashes). Returns null for an
230
+ * empty path or one that escapes the root.
231
+ */
232
+ function splitWatchedPath(relativePath) {
233
+ const segments = relativePath.split(/[\\/]+/).filter((segment) => segment.length > 0);
234
+ if (segments.length === 0)
235
+ return null;
236
+ if (segments.some((segment) => segment === '.' || segment === '..'))
237
+ return null;
238
+ return segments;
239
+ }
240
+ function count(stats, field) {
241
+ if (!stats)
242
+ return;
243
+ stats[field] = (stats[field] ?? 0) + 1;
244
+ }
@@ -8,14 +8,20 @@
8
8
  * `limit` can stop early, stats each file exactly once, and returns results
9
9
  * newest first with the size and session id already attached.
10
10
  *
11
- * With `limit`, the early exit is by directory date: a rollout that was
12
- * created days ago but is still being written lands in its creation day's
13
- * directory, so a very small limit can miss it in favour of newer-dated,
14
- * older-modified files. Callers that need exact mtime order over the whole
15
- * history omit `limit` (the `maxFiles` cap still applies).
11
+ * With `limit` or `since`, the early exit is by directory date: a rollout
12
+ * that was created days ago but is still being written lands in its creation
13
+ * day's directory, so a very small limit (or a recent `since`) can miss it in
14
+ * favour of newer-dated, older-modified files. Callers that need exact mtime
15
+ * order over the whole history omit both (the `maxFiles` cap still applies);
16
+ * a live session missed this way is picked up by its next watch event.
17
+ *
18
+ * The async walker accepts a {@link DirectoryListingCache}: unchanged
19
+ * directories are then one stat each instead of a readdir plus a stat per
20
+ * file, which is what makes repeated discovery over a large history cheap.
16
21
  *
17
22
  * @module providers/rolloutWalker
18
23
  */
24
+ import type { DirectoryListingCache, DirectoryListingCounters } from './directoryListingCache';
19
25
  export interface RolloutFileInfo {
20
26
  path: string;
21
27
  mtime: Date;
@@ -30,10 +36,21 @@ export interface WalkRolloutFilesOptions {
30
36
  maxFiles?: number;
31
37
  /** Stop once this many files are collected, visiting newest-dated directories first. */
32
38
  limit?: number;
39
+ /**
40
+ * Skip files modified before this epoch-ms, and skip dated directories
41
+ * whose whole day, month, or year ended more than a day before it.
42
+ */
43
+ since?: number;
33
44
  /** Only files whose name carries this session id (case-insensitive). */
34
45
  sessionId?: string;
35
46
  /** Include zero-byte files (default false). */
36
47
  includeEmpty?: boolean;
48
+ /** Reuse directory listings across walks (async walker only). */
49
+ cache?: DirectoryListingCache;
50
+ /** With `cache`: re-stat recently modified files even in unchanged directories. */
51
+ revalidateRecent?: boolean;
52
+ /** Payload-free counters of what the walk cost. */
53
+ stats?: DirectoryListingCounters;
37
54
  }
38
55
  export declare const DEFAULT_ROLLOUT_WALK_MAX_DEPTH = 6;
39
56
  export declare const DEFAULT_ROLLOUT_WALK_MAX_FILES = 20000;
@@ -44,6 +61,12 @@ export declare function isRolloutFile(filename: string): boolean;
44
61
  * Format: rollout-<timestamp>-<uuid>.jsonl -> <uuid>
45
62
  */
46
63
  export declare function extractRolloutSessionId(filename: string): string;
64
+ /**
65
+ * True when `segments` (the directory names below the root, e.g. `['2026',
66
+ * '09', '04']`) are all numeric and describe a year, month, or day that ended
67
+ * more than a day before `since`. Non-numeric names are never pruned.
68
+ */
69
+ export declare function isDatedDirectoryBefore(segments: readonly string[], since: number): boolean;
47
70
  /**
48
71
  * Walk one or more `sessions` roots for rollout files, newest first.
49
72
  * Missing or unreadable directories are skipped; paths are deduplicated.