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
@@ -46,6 +46,12 @@ const DEFAULT_MAX_CONCURRENT_READS = 4;
46
46
  const MAX_CONCURRENT_READS = 16;
47
47
  const DEFAULT_DEBOUNCE_MS = 100;
48
48
  const DEFAULT_POLL_INTERVAL_MS = 30_000;
49
+ const DEFAULT_MIN_RECONCILE_GAP_MS = 2_000;
50
+ const DEFAULT_MAX_CACHE_ENTRIES = 20_000;
51
+ const DEFAULT_MAX_CACHE_BYTES = 128 * 1024 * 1024;
52
+ const FALLBACK_VALUE_BYTES = 1_024;
53
+ /** A full discovery may recur once it has been idle this many times its own duration. */
54
+ const GAP_COST_MULTIPLIER = 4;
49
55
  /**
50
56
  * Fail-soft observed-session collection with fingerprint-keyed parse caching,
51
57
  * cooperative reads, and optional provider-level subscriptions.
@@ -53,12 +59,22 @@ const DEFAULT_POLL_INTERVAL_MS = 30_000;
53
59
  class ObservedSessionCollector {
54
60
  options;
55
61
  failures = new Map();
56
- cache = new Map();
62
+ cache;
57
63
  subscriptions = new Set();
64
+ /** Latest complete reference set per provider, kept current by scoped reconciles. */
65
+ lastDiscovery = new Map();
66
+ /** One full discovery per provider at a time; concurrent callers share it. */
67
+ inflightDiscovery = new Map();
68
+ fullPassState = new Map();
69
+ /** Scoped results newer than an in-flight walk, so the walk cannot undo them. */
70
+ scopedReferences = new Map();
71
+ generation = 0;
72
+ passId = 0;
58
73
  initialBackoffMs;
59
74
  maxBackoffMs;
60
75
  clock;
61
76
  maxConcurrentReads;
77
+ minReconcileGapMs;
62
78
  yieldBetweenReads;
63
79
  constructor(options) {
64
80
  this.options = options;
@@ -66,6 +82,17 @@ class ObservedSessionCollector {
66
82
  this.maxBackoffMs = Math.max(this.initialBackoffMs, options.maxBackoffMs ?? 5 * 60_000);
67
83
  this.clock = options.clock ?? { now: () => Date.now() };
68
84
  this.maxConcurrentReads = Math.min(MAX_CONCURRENT_READS, Math.max(1, Math.floor(options.maxConcurrentReads ?? DEFAULT_MAX_CONCURRENT_READS)));
85
+ this.minReconcileGapMs = Math.max(0, options.minReconcileGapMs ?? DEFAULT_MIN_RECONCILE_GAP_MS);
86
+ const estimate = options.approximateValueBytes ?? defaultValueBytes;
87
+ this.cache = new BoundedCache(Math.max(1, Math.floor(options.maxCacheEntries ?? DEFAULT_MAX_CACHE_ENTRIES)), Math.max(1, Math.floor(options.maxCacheBytes ?? DEFAULT_MAX_CACHE_BYTES)), (value) => {
88
+ try {
89
+ const bytes = estimate(value);
90
+ return Number.isFinite(bytes) && bytes >= 0 ? bytes : FALLBACK_VALUE_BYTES;
91
+ }
92
+ catch {
93
+ return FALLBACK_VALUE_BYTES;
94
+ }
95
+ });
69
96
  this.yieldBetweenReads = async () => {
70
97
  if (options.yieldBetweenReads) {
71
98
  await options.yieldBetweenReads();
@@ -74,17 +101,28 @@ class ObservedSessionCollector {
74
101
  await new Promise((resolve) => setImmediate(resolve));
75
102
  };
76
103
  }
77
- async collect() {
78
- const nested = await Promise.all(this.options.sources.map((source) => this.collectSource(source)));
104
+ async collect(options = {}) {
105
+ this.passId++;
106
+ const nested = await Promise.all(this.options.sources.map((source) => this.collectSource(source, options)));
79
107
  return nested.flat();
80
108
  }
109
+ /** Number of parsed sessions currently cached. */
110
+ get cacheSize() {
111
+ return this.cache.size;
112
+ }
81
113
  /**
82
114
  * Subscribe to debounced provider changes. The listener receives fingerprints
83
115
  * only; call collect() to consume changed sessions through the parse cache.
116
+ *
117
+ * A signal that names a file is reconciled with one stat. Signals without a
118
+ * filename, catch-up polls, and the initial pass run a full discovery, at
119
+ * most once per gap per provider; bursts inside the debounce window join a
120
+ * single trailing pass.
84
121
  */
85
122
  subscribe(listener, options = {}) {
86
123
  const debounceMs = Math.max(0, options.debounceMs ?? DEFAULT_DEBOUNCE_MS);
87
124
  const pollIntervalMs = Math.max(0, options.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS);
125
+ const gapCeilingMs = Math.max(0, options.minReconcileGapMs ?? this.minReconcileGapMs);
88
126
  const known = new Map();
89
127
  for (const item of options.knownFingerprints ?? []) {
90
128
  known.set(this.key(item.providerId, item.sessionId), {
@@ -96,60 +134,137 @@ class ObservedSessionCollector {
96
134
  known.set(key, { fingerprint: entry.fingerprint, parts: entry.fingerprintParts });
97
135
  }
98
136
  let disposed = false;
99
- let debounceTimer = null;
100
- let reconciling = false;
101
- let pending = false;
137
+ let timer = null;
138
+ let timerFireAt = 0;
139
+ let running = false;
140
+ let pendingScoped = new Map();
141
+ let pendingFull = new Map();
102
142
  const sourceDisposables = [];
103
- const reconcile = async () => {
143
+ const gapRemainingMs = (source, trigger) => {
144
+ if (trigger === 'initial')
145
+ return 0;
146
+ const state = this.fullPassState.get(source.providerId);
147
+ if (!state)
148
+ return 0;
149
+ return Math.max(0, state.endedAt + this.effectiveGapMs(source.providerId, gapCeilingMs) - Date.now());
150
+ };
151
+ const emit = (batch) => {
152
+ if (batch.changes.length === 0)
153
+ return;
154
+ try {
155
+ listener(batch);
156
+ }
157
+ catch {
158
+ // Subscriber failures cannot stop reconciliation.
159
+ }
160
+ };
161
+ // Arms the trailing timer; a later request can only pull it earlier,
162
+ // never push it out, so a busy live session cannot starve the pass.
163
+ const arm = (delayMs) => {
104
164
  if (disposed)
105
165
  return;
106
- if (reconciling) {
107
- pending = true;
166
+ const fireAt = Date.now() + delayMs;
167
+ if (timer && timerFireAt <= fireAt)
168
+ return;
169
+ if (timer)
170
+ clearTimeout(timer);
171
+ timerFireAt = fireAt;
172
+ timer = setTimeout(() => {
173
+ timer = null;
174
+ void run();
175
+ }, delayMs);
176
+ timer.unref?.();
177
+ };
178
+ const requestFull = (source, trigger) => {
179
+ const existing = pendingFull.get(source);
180
+ if (!existing || existing === 'event')
181
+ pendingFull.set(source, trigger);
182
+ };
183
+ const onSignal = (source, signal) => {
184
+ if (disposed)
185
+ return;
186
+ const parsed = typeof signal === 'object' && signal !== null ? signal : undefined;
187
+ if (parsed &&
188
+ typeof parsed.filename === 'string' &&
189
+ parsed.filename.length > 0 &&
190
+ source.resolveReference) {
191
+ pendingScoped.set(`${source.providerId}\0${parsed.root ?? ''}\0${parsed.filename}`, {
192
+ source,
193
+ signal: parsed,
194
+ });
195
+ arm(debounceMs);
108
196
  return;
109
197
  }
110
- reconciling = true;
198
+ const trigger = parsed?.trigger === 'poll' ? 'poll' : 'event';
199
+ requestFull(source, trigger);
200
+ arm(Math.max(debounceMs, gapRemainingMs(source, trigger)));
201
+ };
202
+ const run = async () => {
203
+ if (disposed || running)
204
+ return;
205
+ running = true;
206
+ // Swap before the first await so signals landing mid-run are kept.
207
+ const scoped = pendingScoped;
208
+ pendingScoped = new Map();
209
+ const full = pendingFull;
210
+ pendingFull = new Map();
111
211
  try {
112
- do {
113
- pending = false;
114
- for (const source of this.options.sources) {
115
- if (disposed)
116
- break;
117
- const batch = await this.reconcileSource(source, known);
118
- // Re-check after the await: disposal during the reconcile must
119
- // not deliver the pre-disposal batch to the listener.
120
- if (disposed)
121
- break;
122
- if (batch && batch.changes.length > 0) {
123
- try {
124
- listener(batch);
125
- }
126
- catch {
127
- // Subscriber failures cannot stop reconciliation.
128
- }
129
- }
212
+ const scopedChanges = new Map();
213
+ for (const { source, signal } of scoped.values()) {
214
+ if (disposed)
215
+ return;
216
+ const result = await this.reconcileSignal(source, signal, known, gapCeilingMs);
217
+ if (disposed)
218
+ return;
219
+ if (result === 'unknown') {
220
+ requestFull(source, 'event');
221
+ continue;
222
+ }
223
+ if (!result)
224
+ continue;
225
+ const list = scopedChanges.get(source) ?? [];
226
+ list.push(result);
227
+ scopedChanges.set(source, list);
228
+ }
229
+ for (const [source, changes] of scopedChanges) {
230
+ emit({
231
+ providerId: source.providerId,
232
+ changes,
233
+ observedAt: new Date(this.clock.now()).toISOString(),
234
+ });
235
+ }
236
+ for (const [source, trigger] of full) {
237
+ if (disposed)
238
+ return;
239
+ if (gapRemainingMs(source, trigger) > 0) {
240
+ requestFull(source, trigger);
241
+ continue;
130
242
  }
131
- } while (pending && !disposed);
243
+ const batch = await this.reconcileSource(source, known, trigger, gapCeilingMs);
244
+ // Re-check after the await: disposal during the reconcile must
245
+ // not deliver the pre-disposal batch to the listener.
246
+ if (disposed)
247
+ return;
248
+ if (batch)
249
+ emit(batch);
250
+ }
132
251
  }
133
252
  finally {
134
- reconciling = false;
253
+ running = false;
254
+ if (!disposed && (pendingScoped.size > 0 || pendingFull.size > 0)) {
255
+ let delayMs = pendingScoped.size > 0 ? debounceMs : Infinity;
256
+ for (const [source, trigger] of pendingFull) {
257
+ delayMs = Math.min(delayMs, Math.max(debounceMs, gapRemainingMs(source, trigger)));
258
+ }
259
+ arm(delayMs);
260
+ }
135
261
  }
136
262
  };
137
- const schedule = () => {
138
- if (disposed)
139
- return;
140
- if (debounceTimer)
141
- clearTimeout(debounceTimer);
142
- debounceTimer = setTimeout(() => {
143
- debounceTimer = null;
144
- void reconcile();
145
- }, debounceMs);
146
- debounceTimer.unref?.();
147
- };
148
263
  let needsCollectorPoll = false;
149
264
  for (const source of this.options.sources) {
150
265
  if (source.subscribe) {
151
266
  try {
152
- sourceDisposables.push(source.subscribe(schedule, { pollIntervalMs }));
267
+ sourceDisposables.push(source.subscribe((signal) => onSignal(source, signal), { pollIntervalMs }));
153
268
  }
154
269
  catch {
155
270
  // The collector-level poll below remains the correctness fallback.
@@ -162,17 +277,22 @@ class ObservedSessionCollector {
162
277
  }
163
278
  let pollTimer = null;
164
279
  if (needsCollectorPoll && pollIntervalMs > 0) {
165
- pollTimer = setInterval(schedule, pollIntervalMs);
280
+ pollTimer = setInterval(() => {
281
+ for (const source of this.options.sources)
282
+ onSignal(source, { trigger: 'poll' });
283
+ }, pollIntervalMs);
166
284
  pollTimer.unref?.();
167
285
  }
168
- schedule();
286
+ for (const source of this.options.sources)
287
+ requestFull(source, 'initial');
288
+ arm(debounceMs);
169
289
  const disposable = {
170
290
  dispose: () => {
171
291
  if (disposed)
172
292
  return;
173
293
  disposed = true;
174
- if (debounceTimer)
175
- clearTimeout(debounceTimer);
294
+ if (timer)
295
+ clearTimeout(timer);
176
296
  if (pollTimer)
177
297
  clearInterval(pollTimer);
178
298
  for (const item of sourceDisposables) {
@@ -192,6 +312,8 @@ class ObservedSessionCollector {
192
312
  reset() {
193
313
  this.failures.clear();
194
314
  this.cache.clear();
315
+ this.lastDiscovery.clear();
316
+ this.scopedReferences.clear();
195
317
  }
196
318
  dispose() {
197
319
  for (const subscription of [...this.subscriptions])
@@ -206,22 +328,35 @@ class ObservedSessionCollector {
206
328
  }
207
329
  this.reset();
208
330
  }
209
- async collectSource(source) {
331
+ async collectSource(source, options) {
210
332
  const discoveryKey = `${source.providerId}\0$discovery`;
211
333
  const discoveryFailure = this.failures.get(discoveryKey);
212
334
  if (discoveryFailure && this.clock.now() < discoveryFailure.retryAt)
213
335
  return [];
336
+ const partial = options.limit !== undefined || options.since !== undefined;
214
337
  let references;
215
- try {
216
- references = await source.discover();
217
- this.recover(discoveryKey, 'provider-recovered', source.providerId);
338
+ // A reconcile pass within the gap already produced the references; a
339
+ // collect() issued from the listener must not walk the provider again.
340
+ const snapshot = !partial && this.subscriptions.size > 0
341
+ ? this.lastDiscovery.get(source.providerId)
342
+ : undefined;
343
+ if (snapshot && Date.now() < snapshot.reuseUntil) {
344
+ references = [...snapshot.references.values()];
218
345
  }
219
- catch (error) {
220
- this.fail(discoveryKey, 'provider-discovery-failed', source.providerId, undefined, null, error);
221
- return [];
346
+ else {
347
+ try {
348
+ references = await this.runDiscovery(source, 'collect', options);
349
+ this.recover(discoveryKey, 'provider-recovered', source.providerId);
350
+ }
351
+ catch (error) {
352
+ this.fail(discoveryKey, 'provider-discovery-failed', source.providerId, undefined, null, error);
353
+ return [];
354
+ }
355
+ }
356
+ if (!partial) {
357
+ const activeKeys = new Set(references.map((ref) => this.key(source.providerId, ref.sessionId)));
358
+ this.evictMissing(source.providerId, activeKeys);
222
359
  }
223
- const activeKeys = new Set(references.map((ref) => this.key(source.providerId, ref.sessionId)));
224
- this.evictMissing(source.providerId, activeKeys);
225
360
  const rows = await this.mapWithConcurrency(references, async (reference) => {
226
361
  const result = await this.collectSession(source, reference);
227
362
  await this.yieldBetweenReads();
@@ -239,7 +374,7 @@ class ObservedSessionCollector {
239
374
  return null;
240
375
  }
241
376
  const observedAt = new Date(this.clock.now()).toISOString();
242
- const cached = fingerprint.value === null ? undefined : this.cache.get(key);
377
+ const cached = fingerprint.value === null ? undefined : this.cache.get(key, this.passId);
243
378
  if (cached && cached.fingerprint === fingerprint.value) {
244
379
  let value = cached.value;
245
380
  try {
@@ -272,7 +407,7 @@ class ObservedSessionCollector {
272
407
  fingerprint: fingerprint.value,
273
408
  fingerprintParts: fingerprint.parts,
274
409
  contentObservedAt,
275
- });
410
+ }, this.passId);
276
411
  }
277
412
  const observation = this.makeObservation(source, reference, value, fingerprint, false, observedAt, contentObservedAt);
278
413
  this.emitObservation(observation);
@@ -298,22 +433,169 @@ class ObservedSessionCollector {
298
433
  // Host callbacks cannot turn a healthy provider read into a collection failure.
299
434
  }
300
435
  }
301
- async reconcileSource(source, known) {
436
+ /**
437
+ * Runs `discover()` once per provider at a time. Unbounded discoveries are
438
+ * shared with concurrent callers and update the gap bookkeeping; every
439
+ * discovery emits one payload-free cost diagnostic.
440
+ */
441
+ runDiscovery(source, trigger, options = {}) {
442
+ return this.startDiscovery(source, trigger, options).promise;
443
+ }
444
+ /**
445
+ * Starts (or joins) a discovery and reports the generation it started at,
446
+ * so a reconcile that joins a walk begun earlier by `collect()` compares
447
+ * scoped results against the walk's start rather than its own entry.
448
+ */
449
+ startDiscovery(source, trigger, options = {}) {
450
+ const providerId = source.providerId;
451
+ const partial = options.limit !== undefined || options.since !== undefined;
452
+ if (!partial) {
453
+ const inflight = this.inflightDiscovery.get(providerId);
454
+ if (inflight)
455
+ return inflight;
456
+ }
457
+ const generation = this.generation;
458
+ const stats = {};
459
+ const startedAt = Date.now();
460
+ const promise = (async () => {
461
+ const references = await source.discover({
462
+ limit: options.limit,
463
+ since: options.since,
464
+ stats,
465
+ });
466
+ const endedAt = Date.now();
467
+ const durationMs = endedAt - startedAt;
468
+ if (!partial)
469
+ this.fullPassState.set(providerId, { endedAt, durationMs });
470
+ this.emitDiagnostic(compact({
471
+ kind: 'provider-discovery-completed',
472
+ severity: 'info',
473
+ phase: 'discover',
474
+ providerId,
475
+ trigger,
476
+ durationMs,
477
+ referenceCount: references.length,
478
+ directoriesListed: stats.directoriesListed,
479
+ directoriesStatted: stats.directoriesStatted,
480
+ filesStatted: stats.filesStatted,
481
+ partial: partial ? true : undefined,
482
+ }));
483
+ return references;
484
+ })();
485
+ const handle = { promise, generation };
486
+ if (!partial) {
487
+ this.inflightDiscovery.set(providerId, handle);
488
+ const settle = () => {
489
+ if (this.inflightDiscovery.get(providerId) === handle) {
490
+ this.inflightDiscovery.delete(providerId);
491
+ }
492
+ };
493
+ promise.then(settle, settle);
494
+ }
495
+ return handle;
496
+ }
497
+ effectiveGapMs(providerId, ceilingMs) {
498
+ const state = this.fullPassState.get(providerId);
499
+ if (!state)
500
+ return 0;
501
+ return Math.min(ceilingMs, state.durationMs * GAP_COST_MULTIPLIER);
502
+ }
503
+ /** Reconcile one watch signal against `known` with at most one stat. */
504
+ async reconcileSignal(source, signal, known, gapCeilingMs) {
505
+ if (!source.resolveReference)
506
+ return 'unknown';
507
+ let resolution;
508
+ try {
509
+ resolution = await source.resolveReference(signal);
510
+ }
511
+ catch {
512
+ return 'unknown';
513
+ }
514
+ if (!resolution || typeof resolution !== 'object')
515
+ return 'unknown';
516
+ const providerId = source.providerId;
517
+ switch (resolution.status) {
518
+ case 'ignored':
519
+ return null;
520
+ case 'missing': {
521
+ const key = this.key(providerId, resolution.sessionId);
522
+ this.scopedReferences.set(key, { reference: null, generation: ++this.generation });
523
+ this.lastDiscovery.get(providerId)?.references.delete(resolution.sessionId);
524
+ const previous = known.get(key);
525
+ if (!previous)
526
+ return null;
527
+ known.delete(key);
528
+ return {
529
+ type: 'removed',
530
+ reference: { sessionId: resolution.sessionId },
531
+ previousFingerprint: previous.fingerprint,
532
+ previousFingerprintParts: previous.parts,
533
+ fingerprint: null,
534
+ fingerprintParts: null,
535
+ };
536
+ }
537
+ case 'present': {
538
+ const reference = resolution.reference;
539
+ const key = this.key(providerId, reference.sessionId);
540
+ const current = await this.fingerprint(source, reference);
541
+ if (current.value === null)
542
+ return null;
543
+ this.scopedReferences.set(key, { reference, generation: ++this.generation });
544
+ const snapshot = this.lastDiscovery.get(providerId);
545
+ if (snapshot) {
546
+ snapshot.references.set(reference.sessionId, reference);
547
+ snapshot.reuseUntil = Date.now() + this.effectiveGapMs(providerId, gapCeilingMs);
548
+ }
549
+ const previous = known.get(key);
550
+ known.set(key, { fingerprint: current.value, parts: current.parts });
551
+ if (previous && previous.fingerprint === current.value)
552
+ return null;
553
+ return {
554
+ type: previous ? 'changed' : 'added',
555
+ reference,
556
+ previousFingerprint: previous?.fingerprint ?? null,
557
+ previousFingerprintParts: previous?.parts ?? null,
558
+ fingerprint: current.value,
559
+ fingerprintParts: current.parts,
560
+ };
561
+ }
562
+ default:
563
+ return 'unknown';
564
+ }
565
+ }
566
+ async reconcileSource(source, known, trigger, gapCeilingMs) {
567
+ const providerId = source.providerId;
568
+ const prefix = `${providerId}\0`;
569
+ // The walk may have been started earlier by a concurrent collect(); its
570
+ // start generation, not this pass's entry, decides which scoped results
571
+ // are newer than the references it returns.
572
+ let startGeneration;
302
573
  let references;
303
574
  try {
304
- references = await source.discover();
575
+ const discovery = this.startDiscovery(source, trigger);
576
+ startGeneration = discovery.generation;
577
+ references = await discovery.promise;
305
578
  }
306
579
  catch {
307
580
  return null;
308
581
  }
309
582
  const changes = [];
310
583
  const seen = new Set();
311
- for (const reference of references) {
312
- const key = this.key(source.providerId, reference.sessionId);
584
+ const snapshotReferences = new Map();
585
+ for (const discovered of references) {
586
+ const key = this.key(providerId, discovered.sessionId);
313
587
  seen.add(key);
588
+ // A scoped result that landed while the walk was running is newer than
589
+ // the walk's copy; use it so the walk cannot flip the fingerprint back.
590
+ const scoped = this.scopedReferences.get(key);
591
+ const newer = scoped !== undefined && scoped.generation > startGeneration;
592
+ if (newer && scoped.reference === null)
593
+ continue;
594
+ const reference = newer && scoped.reference ? scoped.reference : discovered;
314
595
  const current = await this.fingerprint(source, reference);
315
596
  if (current.value === null)
316
597
  continue;
598
+ snapshotReferences.set(reference.sessionId, reference);
317
599
  const previous = known.get(key);
318
600
  if (!previous || previous.fingerprint !== current.value) {
319
601
  changes.push({
@@ -328,9 +610,15 @@ class ObservedSessionCollector {
328
610
  known.set(key, { fingerprint: current.value, parts: current.parts });
329
611
  }
330
612
  for (const [key, previous] of [...known]) {
331
- if (!key.startsWith(`${source.providerId}\0`) || seen.has(key))
613
+ if (!key.startsWith(prefix) || seen.has(key))
614
+ continue;
615
+ const scoped = this.scopedReferences.get(key);
616
+ if (scoped && scoped.generation > startGeneration && scoped.reference) {
617
+ // Created after the walk began; the scoped pass already reported it.
618
+ snapshotReferences.set(scoped.reference.sessionId, scoped.reference);
332
619
  continue;
333
- const sessionId = key.slice(source.providerId.length + 1);
620
+ }
621
+ const sessionId = key.slice(prefix.length);
334
622
  changes.push({
335
623
  type: 'removed',
336
624
  reference: { sessionId },
@@ -341,8 +629,17 @@ class ObservedSessionCollector {
341
629
  });
342
630
  known.delete(key);
343
631
  }
632
+ for (const [key, entry] of this.scopedReferences) {
633
+ if (key.startsWith(prefix) && entry.generation <= startGeneration) {
634
+ this.scopedReferences.delete(key);
635
+ }
636
+ }
637
+ this.lastDiscovery.set(providerId, {
638
+ references: snapshotReferences,
639
+ reuseUntil: Date.now() + this.effectiveGapMs(providerId, gapCeilingMs),
640
+ });
344
641
  return {
345
- providerId: source.providerId,
642
+ providerId,
346
643
  changes,
347
644
  observedAt: new Date(this.clock.now()).toISOString(),
348
645
  };
@@ -367,7 +664,7 @@ class ObservedSessionCollector {
367
664
  this.failures.delete(key);
368
665
  }
369
666
  }
370
- for (const key of this.cache.keys()) {
667
+ for (const key of [...this.cache.keys()]) {
371
668
  if (key.startsWith(`${providerId}\0`) && !activeKeys.has(key))
372
669
  this.cache.delete(key);
373
670
  }
@@ -434,6 +731,82 @@ class ObservedSessionCollector {
434
731
  }
435
732
  }
436
733
  exports.ObservedSessionCollector = ObservedSessionCollector;
734
+ /**
735
+ * Parse cache bounded by entry count and approximate bytes. Insertion order
736
+ * doubles as recency: a hit re-inserts, so the first entry is always the
737
+ * least recently observed. Entries observed in the current pass are never
738
+ * evicted by that pass, so one oversized pass cannot thrash itself.
739
+ */
740
+ class BoundedCache {
741
+ maxEntries;
742
+ maxBytes;
743
+ estimate;
744
+ entries = new Map();
745
+ totalBytes = 0;
746
+ constructor(maxEntries, maxBytes, estimate) {
747
+ this.maxEntries = maxEntries;
748
+ this.maxBytes = maxBytes;
749
+ this.estimate = estimate;
750
+ }
751
+ get size() {
752
+ return this.entries.size;
753
+ }
754
+ get(key, passId) {
755
+ const entry = this.entries.get(key);
756
+ if (!entry)
757
+ return undefined;
758
+ this.entries.delete(key);
759
+ this.entries.set(key, entry);
760
+ entry.passId = passId;
761
+ return entry;
762
+ }
763
+ set(key, entry, passId) {
764
+ this.delete(key);
765
+ const bytes = this.estimate(entry.value);
766
+ this.entries.set(key, { ...entry, bytes, passId });
767
+ this.totalBytes += bytes;
768
+ this.evict(passId);
769
+ }
770
+ delete(key) {
771
+ const entry = this.entries.get(key);
772
+ if (!entry)
773
+ return;
774
+ this.totalBytes -= entry.bytes;
775
+ this.entries.delete(key);
776
+ }
777
+ keys() {
778
+ return this.entries.keys();
779
+ }
780
+ [Symbol.iterator]() {
781
+ return this.entries.entries();
782
+ }
783
+ clear() {
784
+ this.entries.clear();
785
+ this.totalBytes = 0;
786
+ }
787
+ evict(passId) {
788
+ while (this.entries.size > this.maxEntries || this.totalBytes > this.maxBytes) {
789
+ // Untouched entries always precede entries touched in this pass, so
790
+ // a protected first entry means every remaining entry is protected.
791
+ const first = this.entries.entries().next();
792
+ if (first.done || first.value[1].passId === passId)
793
+ return;
794
+ this.delete(first.value[0]);
795
+ }
796
+ }
797
+ }
798
+ function defaultValueBytes(value) {
799
+ const json = JSON.stringify(value);
800
+ return typeof json === 'string' ? json.length : FALLBACK_VALUE_BYTES;
801
+ }
802
+ function compact(diagnostic) {
803
+ const result = {};
804
+ for (const [key, value] of Object.entries(diagnostic)) {
805
+ if (value !== undefined)
806
+ result[key] = value;
807
+ }
808
+ return result;
809
+ }
437
810
  /**
438
811
  * Adapt a provider using path-only discovery and independent adapter reads, so
439
812
  * one malformed session cannot reject discovery of its healthy siblings.
@@ -454,17 +827,26 @@ function observedSessionSourceFromProvider(provider, cwd, options = {}) {
454
827
  fingerprintParts: parts ?? undefined,
455
828
  };
456
829
  };
457
- const discover = async () => {
830
+ const referenceFromInfo = (info) => {
831
+ const mtimeMs = info.mtime.getTime();
832
+ const suppliedParts = info.sizeBytes !== undefined && Number.isFinite(mtimeMs)
833
+ ? { sizeBytes: info.sizeBytes, mtimeMs }
834
+ : undefined;
835
+ return referenceFromPath(info.path, info.sessionId ?? provider.getSessionId(info.path), suppliedParts);
836
+ };
837
+ const discover = async (discoverOptions = {}) => {
458
838
  if (provider.listSessionFilesAsync) {
459
839
  try {
460
- const files = await provider.listSessionFilesAsync(cwd);
461
- return files.map((info) => {
462
- const mtimeMs = info.mtime.getTime();
463
- const suppliedParts = info.sizeBytes !== undefined && Number.isFinite(mtimeMs)
464
- ? { sizeBytes: info.sizeBytes, mtimeMs }
465
- : undefined;
466
- return referenceFromPath(info.path, info.sessionId ?? provider.getSessionId(info.path), suppliedParts);
840
+ const files = await provider.listSessionFilesAsync(cwd, {
841
+ limit: discoverOptions.limit,
842
+ since: discoverOptions.since,
843
+ stats: discoverOptions.stats,
844
+ // Appends do not bump a directory's mtime; a full pass re-checks
845
+ // files modified recently so a missed watch event cannot hide a
846
+ // live session's growth from a cached listing.
847
+ revalidateRecent: true,
467
848
  });
849
+ return files.map(referenceFromInfo);
468
850
  }
469
851
  catch {
470
852
  // Retain compatibility with third-party providers whose optional async
@@ -483,7 +865,7 @@ function observedSessionSourceFromProvider(provider, cwd, options = {}) {
483
865
  };
484
866
  });
485
867
  };
486
- return {
868
+ const source = {
487
869
  providerId: provider.id,
488
870
  capabilities: adapter.capabilities,
489
871
  discover,
@@ -506,6 +888,26 @@ function observedSessionSourceFromProvider(provider, cwd, options = {}) {
506
888
  },
507
889
  dispose: () => adapter.dispose(),
508
890
  };
891
+ if (provider.statWatchedSessionFile) {
892
+ const statWatchedSessionFile = provider.statWatchedSessionFile.bind(provider);
893
+ source.resolveReference = async (signal) => {
894
+ if (!signal.root || typeof signal.filename !== 'string' || signal.filename.length === 0) {
895
+ return { status: 'unknown' };
896
+ }
897
+ const resolved = await statWatchedSessionFile(signal.root, signal.filename, cwd || undefined);
898
+ switch (resolved.status) {
899
+ case 'present':
900
+ return { status: 'present', reference: referenceFromInfo(resolved.file) };
901
+ case 'missing':
902
+ return { status: 'missing', sessionId: resolved.sessionId };
903
+ case 'ignored':
904
+ return { status: 'ignored' };
905
+ default:
906
+ return { status: 'unknown' };
907
+ }
908
+ };
909
+ }
910
+ return source;
509
911
  }
510
912
  function parseFingerprintParts(value) {
511
913
  if (!value)
@@ -535,7 +937,12 @@ function subscribeToProviderRoot(provider, listener, pollIntervalMs) {
535
937
  }
536
938
  for (const root of new Set(roots)) {
537
939
  try {
538
- const watcher = fs.watch(root, { persistent: false, recursive: true }, listener);
940
+ const watcher = fs.watch(root, { persistent: false, recursive: true }, (eventType, filename) => listener({
941
+ trigger: 'event',
942
+ root,
943
+ eventType,
944
+ filename: filename === null || filename === undefined ? null : String(filename),
945
+ }));
539
946
  watchers.add(watcher);
540
947
  watcher.on('error', () => {
541
948
  watchers.delete(watcher);
@@ -552,7 +959,7 @@ function subscribeToProviderRoot(provider, listener, pollIntervalMs) {
552
959
  }
553
960
  }
554
961
  if (pollIntervalMs > 0) {
555
- pollTimer = setInterval(listener, pollIntervalMs);
962
+ pollTimer = setInterval(() => listener({ trigger: 'poll' }), pollIntervalMs);
556
963
  pollTimer.unref?.();
557
964
  }
558
965
  return {