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.
@@ -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.
@@ -9,11 +9,16 @@
9
9
  * `limit` can stop early, stats each file exactly once, and returns results
10
10
  * newest first with the size and session id already attached.
11
11
  *
12
- * With `limit`, the early exit is by directory date: a rollout that was
13
- * created days ago but is still being written lands in its creation day's
14
- * directory, so a very small limit can miss it in favour of newer-dated,
15
- * older-modified files. Callers that need exact mtime order over the whole
16
- * history omit `limit` (the `maxFiles` cap still applies).
12
+ * With `limit` or `since`, the early exit is by directory date: a rollout
13
+ * that was created days ago but is still being written lands in its creation
14
+ * day's directory, so a very small limit (or a recent `since`) can miss it in
15
+ * favour of newer-dated, older-modified files. Callers that need exact mtime
16
+ * order over the whole history omit both (the `maxFiles` cap still applies);
17
+ * a live session missed this way is picked up by its next watch event.
18
+ *
19
+ * The async walker accepts a {@link DirectoryListingCache}: unchanged
20
+ * directories are then one stat each instead of a readdir plus a stat per
21
+ * file, which is what makes repeated discovery over a large history cheap.
17
22
  *
18
23
  * @module providers/rolloutWalker
19
24
  */
@@ -54,6 +59,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
54
59
  exports.DEFAULT_ROLLOUT_WALK_MAX_FILES = exports.DEFAULT_ROLLOUT_WALK_MAX_DEPTH = void 0;
55
60
  exports.isRolloutFile = isRolloutFile;
56
61
  exports.extractRolloutSessionId = extractRolloutSessionId;
62
+ exports.isDatedDirectoryBefore = isDatedDirectoryBefore;
57
63
  exports.walkRolloutFiles = walkRolloutFiles;
58
64
  exports.walkRolloutFilesAsync = walkRolloutFilesAsync;
59
65
  const fs = __importStar(require("fs"));
@@ -90,8 +96,12 @@ function walkState(options) {
90
96
  seen: new Set(),
91
97
  cap: Math.min(maxFiles, limit),
92
98
  maxDepth: options.maxDepth ?? exports.DEFAULT_ROLLOUT_WALK_MAX_DEPTH,
99
+ since: options.since !== undefined && Number.isFinite(options.since) ? options.since : null,
93
100
  sessionId: options.sessionId ? options.sessionId.trim().toLowerCase() : null,
94
101
  includeEmpty: options.includeEmpty ?? false,
102
+ cache: options.cache ?? null,
103
+ revalidateRecent: options.revalidateRecent ?? false,
104
+ stats: options.stats,
95
105
  };
96
106
  }
97
107
  /** Newest-dated first: directory and file names sort descending. */
@@ -106,12 +116,14 @@ function matchesFilter(state, name) {
106
116
  function record(state, fullPath, name, stat) {
107
117
  if (!state.includeEmpty && stat.size <= 0)
108
118
  return;
119
+ if (state.since !== null && stat.mtimeMs < state.since)
120
+ return;
109
121
  if (state.seen.has(fullPath))
110
122
  return;
111
123
  state.seen.add(fullPath);
112
124
  state.results.push({
113
125
  path: fullPath,
114
- mtime: stat.mtime,
126
+ mtime: new Date(stat.mtimeMs),
115
127
  sizeBytes: stat.size,
116
128
  sessionId: extractRolloutSessionId(name),
117
129
  });
@@ -119,9 +131,38 @@ function record(state, fullPath, name, stat) {
119
131
  function finish(state) {
120
132
  return state.results.sort((a, b) => b.mtime.getTime() - a.mtime.getTime());
121
133
  }
122
- function visitSync(state, dir, depth) {
134
+ /** Slack added to a dated directory's range so a timezone skew cannot prune a live day. */
135
+ const DATE_DIR_SLACK_MS = 24 * 60 * 60_000;
136
+ /**
137
+ * True when `segments` (the directory names below the root, e.g. `['2026',
138
+ * '09', '04']`) are all numeric and describe a year, month, or day that ended
139
+ * more than a day before `since`. Non-numeric names are never pruned.
140
+ */
141
+ function isDatedDirectoryBefore(segments, since) {
142
+ if (segments.length === 0 || segments.length > 3)
143
+ return false;
144
+ const numbers = segments.map((segment) => (/^\d+$/.test(segment) ? Number(segment) : NaN));
145
+ if (numbers.some((value) => !Number.isFinite(value)))
146
+ return false;
147
+ const [year, month, day] = numbers;
148
+ if (year < 1970 || year > 9999)
149
+ return false;
150
+ if (segments.length >= 2 && (month < 1 || month > 12))
151
+ return false;
152
+ if (segments.length === 3 && (day < 1 || day > 31))
153
+ return false;
154
+ const rangeEnd = segments.length === 1
155
+ ? new Date(year + 1, 0, 1).getTime()
156
+ : segments.length === 2
157
+ ? new Date(year, month, 1).getTime()
158
+ : new Date(year, month - 1, day + 1).getTime();
159
+ return rangeEnd + DATE_DIR_SLACK_MS < since;
160
+ }
161
+ function visitSync(state, dir, depth, segments) {
123
162
  if (depth > state.maxDepth || state.results.length >= state.cap)
124
163
  return;
164
+ if (state.since !== null && isDatedDirectoryBefore(segments, state.since))
165
+ return;
125
166
  let entries;
126
167
  try {
127
168
  entries = fs.readdirSync(dir, { withFileTypes: true });
@@ -146,8 +187,9 @@ function visitSync(state, dir, depth) {
146
187
  for (const entry of entries) {
147
188
  if (state.results.length >= state.cap)
148
189
  return;
149
- if (entry.isDirectory())
150
- visitSync(state, path.join(dir, entry.name), depth + 1);
190
+ if (entry.isDirectory()) {
191
+ visitSync(state, path.join(dir, entry.name), depth + 1, [...segments, entry.name]);
192
+ }
151
193
  }
152
194
  }
153
195
  /**
@@ -157,18 +199,52 @@ function visitSync(state, dir, depth) {
157
199
  function walkRolloutFiles(roots, options = {}) {
158
200
  const state = walkState(options);
159
201
  for (const root of roots)
160
- visitSync(state, root, 0);
202
+ visitSync(state, root, 0, []);
161
203
  return finish(state);
162
204
  }
163
205
  function yieldToEventLoop() {
164
206
  return new Promise((resolve) => setImmediate(resolve));
165
207
  }
166
- async function visitAsync(state, dir, depth) {
208
+ function byStringDescending(a, b) {
209
+ return b.localeCompare(a);
210
+ }
211
+ async function visitCached(state, cache, dir, depth, segments) {
167
212
  if (depth > state.maxDepth || state.results.length >= state.cap)
168
213
  return;
214
+ if (state.since !== null && isDatedDirectoryBefore(segments, state.since))
215
+ return;
216
+ const listing = await cache.listDirectory(dir, (name) => matchesFilter(state, name), {
217
+ revalidateRecent: state.revalidateRecent,
218
+ stats: state.stats,
219
+ });
220
+ if (!listing)
221
+ return;
222
+ const names = [...listing.files.keys()].sort(byStringDescending);
223
+ for (const name of names) {
224
+ if (state.results.length >= state.cap)
225
+ return;
226
+ const stat = listing.files.get(name);
227
+ if (!stat)
228
+ continue;
229
+ record(state, path.join(dir, name), name, { size: stat.sizeBytes, mtimeMs: stat.mtimeMs });
230
+ }
231
+ await yieldToEventLoop();
232
+ for (const name of [...listing.subdirectories].sort(byStringDescending)) {
233
+ if (state.results.length >= state.cap)
234
+ return;
235
+ await visitCached(state, cache, path.join(dir, name), depth + 1, [...segments, name]);
236
+ }
237
+ }
238
+ async function visitAsync(state, dir, depth, segments) {
239
+ if (depth > state.maxDepth || state.results.length >= state.cap)
240
+ return;
241
+ if (state.since !== null && isDatedDirectoryBefore(segments, state.since))
242
+ return;
169
243
  let entries;
170
244
  try {
171
245
  entries = await fs.promises.readdir(dir, { withFileTypes: true });
246
+ if (state.stats)
247
+ state.stats.directoriesListed = (state.stats.directoriesListed ?? 0) + 1;
172
248
  }
173
249
  catch {
174
250
  return;
@@ -182,7 +258,10 @@ async function visitAsync(state, dir, depth) {
182
258
  continue;
183
259
  const fullPath = path.join(dir, entry.name);
184
260
  try {
185
- record(state, fullPath, entry.name, await fs.promises.stat(fullPath));
261
+ const stat = await fs.promises.stat(fullPath);
262
+ if (state.stats)
263
+ state.stats.filesStatted = (state.stats.filesStatted ?? 0) + 1;
264
+ record(state, fullPath, entry.name, stat);
186
265
  }
187
266
  catch {
188
267
  // Skip files that vanish between readdir and stat.
@@ -196,14 +275,19 @@ async function visitAsync(state, dir, depth) {
196
275
  for (const entry of entries) {
197
276
  if (state.results.length >= state.cap)
198
277
  return;
199
- if (entry.isDirectory())
200
- await visitAsync(state, path.join(dir, entry.name), depth + 1);
278
+ if (entry.isDirectory()) {
279
+ await visitAsync(state, path.join(dir, entry.name), depth + 1, [...segments, entry.name]);
280
+ }
201
281
  }
202
282
  }
203
283
  /** Async twin of {@link walkRolloutFiles} that yields between directories. */
204
284
  async function walkRolloutFilesAsync(roots, options = {}) {
205
285
  const state = walkState(options);
206
- for (const root of roots)
207
- await visitAsync(state, root, 0);
286
+ for (const root of roots) {
287
+ if (state.cache)
288
+ await visitCached(state, state.cache, root, 0, []);
289
+ else
290
+ await visitAsync(state, root, 0, []);
291
+ }
208
292
  return finish(state);
209
293
  }
@@ -59,6 +59,46 @@ export interface SessionFileInfo {
59
59
  /** Optional provider-recorded creation timestamp. */
60
60
  createdAt?: Date;
61
61
  }
62
+ /** Payload-free counters of what an enumeration cost. */
63
+ export interface SessionEnumerationCounters {
64
+ directoriesListed?: number;
65
+ directoriesStatted?: number;
66
+ filesStatted?: number;
67
+ }
68
+ /** Options for `listSessionFilesAsync()`; every field is optional and additive. */
69
+ export interface ListSessionFilesOptions {
70
+ /** Return at most this many files, newest first (may skip an old-directory live session; see rolloutWalker). */
71
+ limit?: number;
72
+ /** Only files modified at or after this epoch-ms. */
73
+ since?: number;
74
+ /** Re-stat recently modified files even when their directory is unchanged. */
75
+ revalidateRecent?: boolean;
76
+ /** Counters the enumerator increments for real readdir/stat calls. */
77
+ stats?: SessionEnumerationCounters;
78
+ }
79
+ /**
80
+ * What a recursive-watch event under one of the provider's watch roots maps
81
+ * to, resolved with at most one stat.
82
+ *
83
+ * - `ignored`: structurally not a session of this provider (wrong depth or
84
+ * extension, another workspace); nothing to do.
85
+ * - `unknown`: a directory or something the provider cannot classify; the
86
+ * caller should fall back to a full enumeration.
87
+ * - `missing`: the path names a session that is gone or still empty.
88
+ * - `present`: the session file with its current stat.
89
+ */
90
+ export type WatchedSessionFile = {
91
+ status: 'ignored';
92
+ } | {
93
+ status: 'unknown';
94
+ } | {
95
+ status: 'missing';
96
+ path: string;
97
+ sessionId: string;
98
+ } | {
99
+ status: 'present';
100
+ file: SessionFileInfo;
101
+ };
62
102
  /** How much of a session `readSessionStats()` could read. */
63
103
  export type SessionFileStatsAvailability = 'full' | 'partial' | 'unavailable';
64
104
  /**
@@ -225,7 +265,14 @@ export interface SessionProviderBase {
225
265
  */
226
266
  listAllSessionFiles?(): SessionFileInfo[];
227
267
  /** Event-loop-safe enumeration used by async preview and subscription APIs. */
228
- listSessionFilesAsync?(workspacePath?: string): Promise<SessionFileInfo[]>;
268
+ listSessionFilesAsync?(workspacePath?: string, options?: ListSessionFilesOptions): Promise<SessionFileInfo[]>;
269
+ /**
270
+ * Resolve a recursive-watch event (`root` from `getWatchRoots()`, the
271
+ * root-relative `relativePath` the watcher reported) to a session file with
272
+ * at most one stat, applying the same workspace filter as
273
+ * `listSessionFilesAsync(workspacePath)`.
274
+ */
275
+ statWatchedSessionFile?(root: string, relativePath: string, workspacePath?: string): Promise<WatchedSessionFile>;
229
276
  /** Batch label extraction. DB-backed implementations use at most one query. */
230
277
  extractSessionLabelsAsync?(sessionPaths: readonly string[]): Promise<Map<string, string | null>>;
231
278
  /** Tests whether a provider can monitor a directory path, including synthetic DB-backed paths. */
@@ -35,6 +35,7 @@ var __importStar = (this && this.__importStar) || (function () {
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
36
  exports.SessionMonitor = void 0;
37
37
  const fs = __importStar(require("node:fs"));
38
+ const path = __importStar(require("node:path"));
38
39
  const EventAggregator_1 = require("./aggregation/EventAggregator");
39
40
  const observedSessionCollector_1 = require("./observedSessionCollector");
40
41
  /**
@@ -119,6 +120,22 @@ class SessionMonitor {
119
120
  ];
120
121
  },
121
122
  read: () => null,
123
+ // One session is watched, so an event naming any other file costs
124
+ // nothing; an event naming this file costs one stat.
125
+ resolveReference: async (signal) => {
126
+ if (!signal.root || typeof signal.filename !== 'string' || !signal.filename) {
127
+ return { status: 'unknown' };
128
+ }
129
+ if (!this.sessionPath)
130
+ return { status: 'ignored' };
131
+ const eventPath = path.resolve(signal.root, signal.filename);
132
+ if (eventPath !== path.resolve(this.sessionPath))
133
+ return { status: 'ignored' };
134
+ const [reference] = await source.discover();
135
+ return reference
136
+ ? { status: 'present', reference }
137
+ : { status: 'missing', sessionId: this.provider.getSessionId(this.sessionPath) };
138
+ },
122
139
  subscribe: (listener, sourceOptions) => {
123
140
  const watchers = new Set();
124
141
  let pollTimer = null;
@@ -131,7 +148,12 @@ class SessionMonitor {
131
148
  }
132
149
  for (const root of new Set(roots)) {
133
150
  try {
134
- const watcher = fs.watch(root, { persistent: false, recursive: true }, listener);
151
+ const watcher = fs.watch(root, { persistent: false, recursive: true }, (eventType, filename) => listener({
152
+ trigger: 'event',
153
+ root,
154
+ eventType,
155
+ filename: filename === null || filename === undefined ? null : String(filename),
156
+ }));
135
157
  watchers.add(watcher);
136
158
  watcher.on('error', () => {
137
159
  watchers.delete(watcher);
@@ -149,7 +171,7 @@ class SessionMonitor {
149
171
  }
150
172
  const pollIntervalMs = sourceOptions?.pollIntervalMs ?? 0;
151
173
  if (pollIntervalMs > 0) {
152
- pollTimer = setInterval(listener, pollIntervalMs);
174
+ pollTimer = setInterval(() => listener({ trigger: 'poll' }), pollIntervalMs);
153
175
  pollTimer.unref?.();
154
176
  }
155
177
  return {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sidekick-shared",
3
- "version": "0.26.4",
3
+ "version": "0.26.5",
4
4
  "description": "Shared data access layer for Sidekick — readers, types, providers, credentials, quota",
5
5
  "keywords": [
6
6
  "claude-code",