@agentclientprotocol/claude-agent-acp 0.88.1-preview.5 → 0.88.1-preview.7

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 (62) hide show
  1. package/dist/acp-agent.d.ts +39 -2
  2. package/dist/acp-agent.d.ts.map +1 -1
  3. package/dist/acp-agent.js +120 -35
  4. package/dist/air-extension.d.ts +23 -0
  5. package/dist/air-extension.d.ts.map +1 -1
  6. package/dist/air-extension.js +23 -0
  7. package/dist/diff.d.ts +2 -0
  8. package/dist/diff.d.ts.map +1 -1
  9. package/dist/diff.js +37 -1
  10. package/dist/mcp-command.d.ts.map +1 -1
  11. package/dist/mcp-command.js +5 -0
  12. package/dist/session-index/activity.d.ts +56 -0
  13. package/dist/session-index/activity.d.ts.map +1 -0
  14. package/dist/session-index/activity.js +96 -0
  15. package/dist/session-index/archive-title.d.ts +52 -0
  16. package/dist/session-index/archive-title.d.ts.map +1 -0
  17. package/dist/session-index/archive-title.js +77 -0
  18. package/dist/session-index/connection.d.ts +152 -0
  19. package/dist/session-index/connection.d.ts.map +1 -0
  20. package/dist/session-index/connection.js +310 -0
  21. package/dist/session-index/dir-listing.d.ts +30 -0
  22. package/dist/session-index/dir-listing.d.ts.map +1 -0
  23. package/dist/session-index/dir-listing.js +110 -0
  24. package/dist/session-index/explicit-title.d.ts +34 -0
  25. package/dist/session-index/explicit-title.d.ts.map +1 -0
  26. package/dist/session-index/explicit-title.js +65 -0
  27. package/dist/session-index/first-prompt.d.ts +37 -0
  28. package/dist/session-index/first-prompt.d.ts.map +1 -0
  29. package/dist/session-index/first-prompt.js +195 -0
  30. package/dist/session-index/list-subscriptions.d.ts +190 -0
  31. package/dist/session-index/list-subscriptions.d.ts.map +1 -0
  32. package/dist/session-index/list-subscriptions.js +1163 -0
  33. package/dist/session-index/live-registry.d.ts +72 -0
  34. package/dist/session-index/live-registry.d.ts.map +1 -0
  35. package/dist/session-index/live-registry.js +240 -0
  36. package/dist/session-index/project-dirs.d.ts +45 -0
  37. package/dist/session-index/project-dirs.d.ts.map +1 -0
  38. package/dist/session-index/project-dirs.js +122 -0
  39. package/dist/session-index/service.d.ts +158 -0
  40. package/dist/session-index/service.d.ts.map +1 -0
  41. package/dist/session-index/service.js +546 -0
  42. package/dist/session-index/session-index.d.ts +201 -0
  43. package/dist/session-index/session-index.d.ts.map +1 -0
  44. package/dist/session-index/session-index.js +740 -0
  45. package/dist/session-index/session-info.d.ts +16 -0
  46. package/dist/session-index/session-info.d.ts.map +1 -0
  47. package/dist/session-index/session-info.js +51 -0
  48. package/dist/session-index/transcript-scan.d.ts +137 -0
  49. package/dist/session-index/transcript-scan.d.ts.map +1 -0
  50. package/dist/session-index/transcript-scan.js +545 -0
  51. package/dist/session-index/worktrees.d.ts +25 -0
  52. package/dist/session-index/worktrees.d.ts.map +1 -0
  53. package/dist/session-index/worktrees.js +128 -0
  54. package/dist/session-titles.d.ts +21 -0
  55. package/dist/session-titles.d.ts.map +1 -1
  56. package/dist/session-titles.js +101 -5
  57. package/dist/utils.d.ts +31 -0
  58. package/dist/utils.d.ts.map +1 -1
  59. package/dist/utils.js +60 -2
  60. package/dist/v2/agent.d.ts.map +1 -1
  61. package/dist/v2/agent.js +33 -4
  62. package/package.json +1 -1
@@ -0,0 +1,1163 @@
1
+ /**
2
+ * `_session/list/subscribe`: pushes the changed rows of a cwd's session list
3
+ * to a `sessionIndex` client.
4
+ *
5
+ * A subscription covers the sessions of its cwd and of the same subdirectory
6
+ * in every linked worktree (the `includeWorktrees` scope of the list), in any
7
+ * archive state. All subscriptions of one cwd share one {@link ScopeWatch}:
8
+ * a non-recursive `fs.watch` on each project directory of the scope, the
9
+ * stats of its transcripts, and the row of each session read since. The
10
+ * registry and the projects root are watched once per connection.
11
+ *
12
+ * Subscribe reads no row: it opens the watchers, stats the transcripts and
13
+ * reads the registry. A row is read on the first
14
+ * event of its session and sent unconditionally; later rows are compared
15
+ * with it.
16
+ *
17
+ * - A transcript event names the file: only that file is stat'ed and, when
18
+ * it changed, its metadata read again (the index's metadata cache and tail
19
+ * scan).
20
+ * - A registry event names the record: only that record is read, and only
21
+ * the sessions whose record changed get their `state` recomputed.
22
+ * - A session this connection runs reports its SDK state at once.
23
+ *
24
+ * Events are coalesced: 150 ms after the last one, at most 1 s after the
25
+ * first. Each subscription compares a recomputed row with the last row it
26
+ * sent; a change of `updatedAt` alone is no change. A session is sent at most
27
+ * once a second per subscription. A rescan every 10 s stats the scope again
28
+ * and reads the transcripts whose stat changed (or whose last read
29
+ * failed), which covers events `fs.watch` missed;
30
+ * the state of a live session ages from the metadata the index holds.
31
+ *
32
+ * Delivery is best effort: there is no resync and no sequence number; the
33
+ * client also reads the list now and then.
34
+ */
35
+ import { randomUUID } from "node:crypto";
36
+ import * as fs from "node:fs";
37
+ import * as fsp from "node:fs/promises";
38
+ import * as path from "node:path";
39
+ import { RequestError } from "@agentclientprotocol/sdk";
40
+ import { bySession, liveRegistryDir, } from "./live-registry.js";
41
+ import { isExactProjectDir, isSessionId, projectDirMatches, projectsRoot } from "./project-dirs.js";
42
+ import { changeSignature, sessionInfoOf } from "./session-info.js";
43
+ export const LIST_SUBSCRIBE_METHOD = "_session/list/subscribe";
44
+ export const LIST_UNSUBSCRIBE_METHOD = "_session/list/unsubscribe";
45
+ export const LIST_CHANGES_METHOD = "_session/list/changes";
46
+ /** Subscriptions per connection. */
47
+ export const MAX_SUBSCRIPTIONS = 128;
48
+ const DEBOUNCE_MS = 150;
49
+ const MAX_WAIT_MS = 1_000;
50
+ const RESCAN_MS = 10_000;
51
+ /** When a rescan follows the opening of a directory watcher. */
52
+ const SETTLE_MS = 1_000;
53
+ /** The least time between two changes of one session to one subscription. */
54
+ const MIN_SESSION_INTERVAL_MS = 1_000;
55
+ function asRecord(value) {
56
+ return value && typeof value === "object" && !Array.isArray(value)
57
+ ? value
58
+ : {};
59
+ }
60
+ /** `{ cwd }`: an absolute path. Other parameters are ignored. */
61
+ export function parseListSubscribeRequest(value) {
62
+ const cwd = asRecord(value).cwd;
63
+ if (typeof cwd !== "string" || !path.isAbsolute(cwd)) {
64
+ throw RequestError.invalidParams({ cwd }, "params require an absolute cwd");
65
+ }
66
+ return { cwd };
67
+ }
68
+ /** `{ subscriptionId }`. */
69
+ export function parseListUnsubscribeRequest(value) {
70
+ const subscriptionId = asRecord(value).subscriptionId;
71
+ if (typeof subscriptionId !== "string") {
72
+ throw RequestError.invalidParams(undefined, "params require a subscriptionId");
73
+ }
74
+ return { subscriptionId };
75
+ }
76
+ /** A non-recursive watch of `dir`, or null when it cannot be watched (it
77
+ * does not exist yet). `onError` runs when the watch fails later (the
78
+ * directory was removed): the watcher is closed then. */
79
+ function watchDir(dir, onEvent, onError) {
80
+ try {
81
+ const watcher = fs.watch(dir, { persistent: false }, (_event, filename) => onEvent(filename === null || filename === undefined ? null : String(filename)));
82
+ watcher.on("error", () => {
83
+ watcher.close();
84
+ onError();
85
+ });
86
+ return watcher;
87
+ }
88
+ catch {
89
+ // A directory that does not exist yet is covered by the rescan.
90
+ return null;
91
+ }
92
+ }
93
+ const RECORD_FILE = /^\d+\.json$/;
94
+ /** Which directory a path is now (device and inode), or undefined. A
95
+ * directory replaced by another one keeps no watcher of the old one. */
96
+ function dirIdentity(dir) {
97
+ try {
98
+ const stats = fs.statSync(dir);
99
+ return stats.isDirectory() ? `${stats.dev}:${stats.ino}` : undefined;
100
+ }
101
+ catch {
102
+ return undefined;
103
+ }
104
+ }
105
+ /** The scope paths as one comparable value. */
106
+ function scopeKey(paths) {
107
+ return [...paths].sort().join("\0");
108
+ }
109
+ /** The session id of a transcript file name, else undefined. */
110
+ function transcriptId(filename) {
111
+ if (!filename.endsWith(".jsonl"))
112
+ return undefined;
113
+ const id = filename.slice(0, -6);
114
+ return isSessionId(id) ? id : undefined;
115
+ }
116
+ export class ListSubscriptions {
117
+ deps;
118
+ subscriptions = new Map();
119
+ /** Sessions whose row shows this connection's SDK state: the rescan
120
+ * presents them again, in case a change of it was not reported. */
121
+ ownShown = new Set();
122
+ watches = new Map();
123
+ shared = new Map();
124
+ /** The directory each shared watcher watches (see {@link dirIdentity}). */
125
+ sharedIdentity = new Map();
126
+ /** The nearest existing ancestor of each shared directory that does not
127
+ * exist yet, watched for its creation. */
128
+ ancestors = new Map();
129
+ rescanTimer;
130
+ /** The live records by registry file name. */
131
+ liveFiles = new Map();
132
+ live = new Map();
133
+ registryLoaded;
134
+ registryNames = new Set();
135
+ registryFull = false;
136
+ registryDebounce;
137
+ registryMaxWait;
138
+ registryWorking;
139
+ disposed = false;
140
+ /** Orders subscriptions and session births (see {@link ScopeWatch.born}). */
141
+ seq = 0;
142
+ now;
143
+ debounceMs;
144
+ maxWaitMs;
145
+ rescanMs;
146
+ minIntervalMs;
147
+ constructor(deps) {
148
+ this.deps = deps;
149
+ this.now = deps.now ?? Date.now;
150
+ this.debounceMs = deps.debounceMs ?? DEBOUNCE_MS;
151
+ this.maxWaitMs = deps.maxWaitMs ?? MAX_WAIT_MS;
152
+ this.rescanMs = deps.rescanMs ?? RESCAN_MS;
153
+ this.minIntervalMs = deps.minSessionIntervalMs ?? MIN_SESSION_INTERVAL_MS;
154
+ }
155
+ /** Subscribes to the sessions of `cwd`. Changes are tracked from the
156
+ * return on. Reads no row (see {@link createWatch}). */
157
+ async subscribe(cwd) {
158
+ if (this.disposed)
159
+ throw RequestError.internalError(undefined, "The connection is closed");
160
+ if (this.subscriptions.size >= MAX_SUBSCRIPTIONS) {
161
+ throw RequestError.invalidParams({ reason: "too_many_subscriptions", max: MAX_SUBSCRIPTIONS }, `At most ${MAX_SUBSCRIPTIONS} session list subscriptions per connection`);
162
+ }
163
+ const key = path.resolve(cwd);
164
+ let watch = this.watches.get(key);
165
+ if (!watch) {
166
+ watch = this.createWatch(key);
167
+ this.watches.set(key, watch);
168
+ this.ensureShared();
169
+ }
170
+ const subscription = {
171
+ id: randomUUID(),
172
+ watch,
173
+ since: ++this.seq,
174
+ sent: new Map(),
175
+ sentAt: new Map(),
176
+ pending: new Set(),
177
+ };
178
+ // Counted at once, so concurrent subscribes cannot pass the limit.
179
+ this.subscriptions.set(subscription.id, subscription);
180
+ watch.subscriptions.add(subscription);
181
+ try {
182
+ await watch.started;
183
+ }
184
+ catch (error) {
185
+ this.unsubscribe(subscription.id);
186
+ throw error;
187
+ }
188
+ // The connection closed meanwhile.
189
+ if (!this.subscriptions.has(subscription.id)) {
190
+ throw RequestError.internalError(undefined, "The connection is closed");
191
+ }
192
+ return { subscriptionId: subscription.id };
193
+ }
194
+ /** Idempotent: an unknown id is no error. */
195
+ unsubscribe(subscriptionId) {
196
+ const subscription = this.subscriptions.get(subscriptionId);
197
+ if (!subscription)
198
+ return;
199
+ this.subscriptions.delete(subscriptionId);
200
+ if (subscription.timer)
201
+ clearTimeout(subscription.timer);
202
+ subscription.timer = undefined;
203
+ const { watch } = subscription;
204
+ watch.subscriptions.delete(subscription);
205
+ if (watch.subscriptions.size === 0)
206
+ this.closeWatch(watch);
207
+ }
208
+ /** A session this connection runs changed its state, turn end or cost. */
209
+ ownSessionChanged(id) {
210
+ for (const watch of this.watches.values()) {
211
+ if (watch.ready && watch.rows.has(id)) {
212
+ watch.dirtyStates.add(id);
213
+ this.schedule(watch, "states");
214
+ }
215
+ else if (!watch.ready || watch.filesById.has(id)) {
216
+ // Its first event here: the row is read.
217
+ watch.dirtyRows.add(id);
218
+ this.schedule(watch, "now");
219
+ }
220
+ }
221
+ }
222
+ dispose() {
223
+ this.disposed = true;
224
+ for (const id of [...this.subscriptions.keys()])
225
+ this.unsubscribe(id);
226
+ this.stopShared();
227
+ }
228
+ /** The open watchers and armed timers, for tests and diagnostics. */
229
+ openHandles() {
230
+ let watchers = this.shared.size + this.ancestors.size;
231
+ let timers = [this.rescanTimer, this.registryDebounce, this.registryMaxWait].filter(Boolean).length;
232
+ for (const watch of this.watches.values()) {
233
+ for (const { watcher } of watch.dirs.values())
234
+ if (watcher)
235
+ watchers++;
236
+ if (watch.debounce)
237
+ timers++;
238
+ if (watch.maxWait)
239
+ timers++;
240
+ if (watch.settle)
241
+ timers++;
242
+ for (const subscription of watch.subscriptions)
243
+ if (subscription.timer)
244
+ timers++;
245
+ }
246
+ return { watches: this.watches.size, watchers, timers };
247
+ }
248
+ createWatch(key) {
249
+ const watch = {
250
+ key,
251
+ cwd: key,
252
+ paths: [],
253
+ dirs: new Map(),
254
+ files: new Map(),
255
+ filesById: new Map(),
256
+ born: new Map(),
257
+ rows: new Map(),
258
+ subscriptions: new Set(),
259
+ started: Promise.resolve(),
260
+ ready: false,
261
+ readyAt: 0,
262
+ closed: false,
263
+ dirtyFiles: new Set(),
264
+ dirtyRows: new Set(),
265
+ dirtyStates: new Set(),
266
+ rescan: false,
267
+ successorOf: new Map(),
268
+ hiddenSeeds: new Map(),
269
+ unreadable: new Set(),
270
+ resolvedPaths: "",
271
+ again: false,
272
+ againFiles: false,
273
+ };
274
+ // When subscribe returns, the watchers are open; no row is read. What it
275
+ // keeps, all without reading a transcript:
276
+ // - the stats of the transcripts, opened after the watchers: a deletion
277
+ // or a move out of the scope is a stat that went, the rescan reads only
278
+ // a transcript whose stat changed, and a session's other copies are
279
+ // known when its row is read;
280
+ // - the registry: a later change of it is an event of the sessions whose
281
+ // record changed.
282
+ watch.started = this.refreshDirs(watch).then(async () => {
283
+ const files = await this.deps.index.enumerateFiles(watch.paths);
284
+ await this.registryLoaded;
285
+ if (watch.closed)
286
+ return;
287
+ this.setFiles(watch, files);
288
+ this.learnSuccessors(watch);
289
+ watch.resolvedPaths = scopeKey(watch.paths);
290
+ // The rows of the live sessions a list read: their state ages from now.
291
+ const seeded = await this.liveSeeds(watch, new Set());
292
+ if (watch.closed)
293
+ return;
294
+ watch.ready = true;
295
+ watch.readyAt = this.now();
296
+ for (const row of seeded) {
297
+ watch.rows.set(row.sessionId, this.present(row, watch.readyAt));
298
+ }
299
+ // Events of the start, held until now.
300
+ if (watch.dirtyFiles.size > 0 ||
301
+ watch.dirtyRows.size > 0 ||
302
+ watch.dirtyStates.size > 0 ||
303
+ watch.rescan) {
304
+ this.schedule(watch, "debounced");
305
+ }
306
+ });
307
+ void watch.started.catch(() => this.closeWatch(watch));
308
+ return watch;
309
+ }
310
+ closeWatch(watch) {
311
+ if (watch.closed)
312
+ return;
313
+ watch.closed = true;
314
+ for (const subscription of watch.subscriptions) {
315
+ this.subscriptions.delete(subscription.id);
316
+ if (subscription.timer)
317
+ clearTimeout(subscription.timer);
318
+ }
319
+ watch.subscriptions.clear();
320
+ for (const { watcher } of watch.dirs.values())
321
+ watcher?.close();
322
+ watch.dirs.clear();
323
+ if (watch.debounce)
324
+ clearTimeout(watch.debounce);
325
+ if (watch.maxWait)
326
+ clearTimeout(watch.maxWait);
327
+ if (watch.settle)
328
+ clearTimeout(watch.settle);
329
+ watch.debounce = watch.maxWait = watch.settle = undefined;
330
+ watch.rows.clear();
331
+ watch.files.clear();
332
+ watch.filesById.clear();
333
+ watch.born.clear();
334
+ watch.successorOf.clear();
335
+ watch.hiddenSeeds.clear();
336
+ if (this.watches.get(watch.key) === watch)
337
+ this.watches.delete(watch.key);
338
+ if (this.watches.size === 0)
339
+ this.stopShared();
340
+ }
341
+ /** The registry and the projects root, watched once for all cwds, and the
342
+ * rescan. */
343
+ ensureShared() {
344
+ if (this.disposed || this.watches.size === 0)
345
+ return;
346
+ const watchers = [
347
+ [liveRegistryDir(), (filename) => this.onRegistryEvent(filename)],
348
+ [projectsRoot(), (filename) => this.onProjectsRootEvent(filename)],
349
+ ];
350
+ const missing = [];
351
+ let created = false;
352
+ for (const [dir, onEvent] of watchers) {
353
+ const identity = dirIdentity(dir);
354
+ const existing = this.shared.get(dir);
355
+ if (existing && this.sharedIdentity.get(dir) === identity)
356
+ continue;
357
+ if (existing) {
358
+ // Replaced or gone: watch what is there now.
359
+ existing.close();
360
+ this.shared.delete(dir);
361
+ }
362
+ this.sharedIdentity.set(dir, identity);
363
+ const watcher = watchDir(dir, onEvent, () => {
364
+ // Watched again, or its ancestor, by the next rescan.
365
+ if (this.shared.get(dir) === watcher)
366
+ this.shared.delete(dir);
367
+ });
368
+ if (watcher) {
369
+ this.shared.set(dir, watcher);
370
+ created = true;
371
+ }
372
+ else {
373
+ missing.push(dir);
374
+ }
375
+ }
376
+ this.watchAncestors(missing);
377
+ if (created && this.registryLoaded) {
378
+ // What the new directory got before its watcher opened.
379
+ this.registryFull = true;
380
+ this.scheduleRegistry(true);
381
+ for (const watch of this.watches.values()) {
382
+ if (!watch.ready)
383
+ continue;
384
+ watch.rescan = true;
385
+ this.schedule(watch, "now");
386
+ }
387
+ }
388
+ this.registryLoaded ??= this.readRegistry(true).then(() => undefined, (error) => this.deps.logError("session list registry read failed", error));
389
+ if (!this.rescanTimer) {
390
+ this.rescanTimer = setInterval(() => this.rescanAll(), this.rescanMs);
391
+ this.rescanTimer.unref?.();
392
+ }
393
+ }
394
+ /** Watches the nearest existing ancestor of each of `missing` (a config
395
+ * directory without `projects` or `sessions` yet): any event there checks
396
+ * again. */
397
+ watchAncestors(missing) {
398
+ const wanted = new Set();
399
+ for (const dir of missing) {
400
+ let ancestor = path.dirname(dir);
401
+ while (!fs.existsSync(ancestor) && path.dirname(ancestor) !== ancestor) {
402
+ ancestor = path.dirname(ancestor);
403
+ }
404
+ wanted.add(ancestor);
405
+ }
406
+ for (const [ancestor, watcher] of this.ancestors) {
407
+ if (wanted.has(ancestor))
408
+ continue;
409
+ watcher.close();
410
+ this.ancestors.delete(ancestor);
411
+ }
412
+ for (const ancestor of wanted) {
413
+ if (this.ancestors.has(ancestor))
414
+ continue;
415
+ const watcher = watchDir(ancestor, () => this.ensureShared(), () => {
416
+ if (this.ancestors.get(ancestor) === watcher)
417
+ this.ancestors.delete(ancestor);
418
+ });
419
+ if (watcher)
420
+ this.ancestors.set(ancestor, watcher);
421
+ }
422
+ }
423
+ stopShared() {
424
+ for (const watcher of this.shared.values())
425
+ watcher.close();
426
+ this.shared.clear();
427
+ this.sharedIdentity.clear();
428
+ this.ownShown.clear();
429
+ for (const watcher of this.ancestors.values())
430
+ watcher.close();
431
+ this.ancestors.clear();
432
+ if (this.rescanTimer)
433
+ clearInterval(this.rescanTimer);
434
+ if (this.registryDebounce)
435
+ clearTimeout(this.registryDebounce);
436
+ if (this.registryMaxWait)
437
+ clearTimeout(this.registryMaxWait);
438
+ this.rescanTimer = this.registryDebounce = this.registryMaxWait = undefined;
439
+ this.registryLoaded = undefined;
440
+ this.registryNames.clear();
441
+ this.registryFull = false;
442
+ this.liveFiles = new Map();
443
+ this.live = new Map();
444
+ }
445
+ rescanAll() {
446
+ // Directories that did not exist before may now.
447
+ this.ensureShared();
448
+ this.registryFull = true;
449
+ this.scheduleRegistry(true);
450
+ for (const watch of this.watches.values()) {
451
+ if (!watch.ready)
452
+ continue;
453
+ watch.rescan = true;
454
+ this.schedule(watch, "now");
455
+ }
456
+ }
457
+ // --- Events -------------------------------------------------------------
458
+ onTranscriptEvent(watch, dirName, filename) {
459
+ if (watch.closed)
460
+ return;
461
+ if (filename === null) {
462
+ watch.rescan = true;
463
+ }
464
+ else {
465
+ const id = transcriptId(filename);
466
+ if (id === undefined)
467
+ return;
468
+ // Before the watch is ready, held until it is.
469
+ watch.dirtyFiles.add(path.join(projectsRoot(), dirName, filename));
470
+ }
471
+ this.schedule(watch, "debounced");
472
+ }
473
+ /** A project directory that a scope lacked may have been created. */
474
+ onProjectsRootEvent(filename) {
475
+ if (filename === null)
476
+ return;
477
+ // A project directory of a scope was made, removed or replaced: the
478
+ // rescan watches what is there now.
479
+ for (const watch of this.watches.values()) {
480
+ if (watch.paths.some((cwd) => isExactProjectDir(filename, cwd) || projectDirMatches(filename, cwd))) {
481
+ watch.rescan = true;
482
+ this.schedule(watch, "debounced");
483
+ }
484
+ }
485
+ }
486
+ onRegistryEvent(filename) {
487
+ if (filename === null)
488
+ this.registryFull = true;
489
+ else if (RECORD_FILE.test(filename))
490
+ this.registryNames.add(filename);
491
+ else
492
+ return;
493
+ this.scheduleRegistry(false);
494
+ }
495
+ // --- Scheduling ---------------------------------------------------------
496
+ /**
497
+ * Runs the work of `watch`: `debounced` after the quiet period, `now` at
498
+ * once, and `states` at once for the recomputed states only, leaving the
499
+ * transcript events to their quiet period (a registry that changes every
500
+ * 200 ms then does not read the transcripts that often).
501
+ */
502
+ schedule(watch, mode) {
503
+ if (watch.closed)
504
+ return;
505
+ if (mode === "states") {
506
+ void this.work(watch, false);
507
+ return;
508
+ }
509
+ if (mode === "now") {
510
+ this.clearWatchTimers(watch);
511
+ void this.work(watch, true);
512
+ return;
513
+ }
514
+ if (watch.debounce)
515
+ clearTimeout(watch.debounce);
516
+ watch.debounce = setTimeout(() => this.schedule(watch, "now"), this.debounceMs);
517
+ watch.debounce.unref?.();
518
+ if (!watch.maxWait) {
519
+ watch.maxWait = setTimeout(() => this.schedule(watch, "now"), this.maxWaitMs);
520
+ watch.maxWait.unref?.();
521
+ }
522
+ }
523
+ clearWatchTimers(watch) {
524
+ if (watch.debounce)
525
+ clearTimeout(watch.debounce);
526
+ if (watch.maxWait)
527
+ clearTimeout(watch.maxWait);
528
+ watch.debounce = watch.maxWait = undefined;
529
+ }
530
+ scheduleRegistry(now) {
531
+ if (this.disposed || this.watches.size === 0)
532
+ return;
533
+ const run = () => {
534
+ if (this.registryDebounce)
535
+ clearTimeout(this.registryDebounce);
536
+ if (this.registryMaxWait)
537
+ clearTimeout(this.registryMaxWait);
538
+ this.registryDebounce = this.registryMaxWait = undefined;
539
+ void this.registryWork();
540
+ };
541
+ if (now)
542
+ return run();
543
+ if (this.registryDebounce)
544
+ clearTimeout(this.registryDebounce);
545
+ this.registryDebounce = setTimeout(run, this.debounceMs);
546
+ this.registryDebounce.unref?.();
547
+ if (!this.registryMaxWait) {
548
+ this.registryMaxWait = setTimeout(run, this.maxWaitMs);
549
+ this.registryMaxWait.unref?.();
550
+ }
551
+ }
552
+ // --- Registry -----------------------------------------------------------
553
+ async registryWork() {
554
+ if (this.registryWorking) {
555
+ // The running read picks the new names up when it ends.
556
+ return;
557
+ }
558
+ this.registryWorking = (async () => {
559
+ try {
560
+ await this.registryLoaded;
561
+ while (this.registryFull || this.registryNames.size > 0) {
562
+ const full = this.registryFull;
563
+ this.registryFull = false;
564
+ const affected = await this.readRegistry(full);
565
+ this.onLiveChanged(affected);
566
+ }
567
+ }
568
+ catch (error) {
569
+ this.deps.logError("session list registry read failed", error);
570
+ }
571
+ finally {
572
+ this.registryWorking = undefined;
573
+ }
574
+ })();
575
+ await this.registryWorking;
576
+ }
577
+ /** Reads the changed registry records (all with `full`) and returns the
578
+ * ids of the sessions whose record changed. */
579
+ async readRegistry(full) {
580
+ let names;
581
+ if (full) {
582
+ this.registryNames.clear();
583
+ try {
584
+ names = await fsp.readdir(liveRegistryDir());
585
+ }
586
+ catch {
587
+ names = [];
588
+ }
589
+ }
590
+ else {
591
+ names = [...this.registryNames];
592
+ this.registryNames.clear();
593
+ }
594
+ const read = await this.deps.registry.readFiles(names, { recentStarts: true });
595
+ const affected = new Set();
596
+ const key = (record) => record
597
+ ? `${record.sessionId}\0${record.status}\0${record.statusUpdatedAt}\0${record.kind}\0${record.entrypoint}`
598
+ : "";
599
+ const next = full ? new Map() : new Map(this.liveFiles);
600
+ const before = this.liveFiles;
601
+ const changedNames = full ? new Set([...before.keys(), ...read.keys()]) : read.keys();
602
+ for (const name of changedNames) {
603
+ const previous = before.get(name);
604
+ const record = read.get(name);
605
+ if (record)
606
+ next.set(name, record);
607
+ else
608
+ next.delete(name);
609
+ if (key(previous) !== key(record)) {
610
+ if (previous)
611
+ affected.add(previous.sessionId);
612
+ if (record)
613
+ affected.add(record.sessionId);
614
+ }
615
+ }
616
+ this.liveFiles = next;
617
+ this.live = bySession(next.values());
618
+ return affected;
619
+ }
620
+ onLiveChanged(affected) {
621
+ if (affected.size === 0)
622
+ return;
623
+ for (const watch of this.watches.values()) {
624
+ let states = false;
625
+ let rows = false;
626
+ for (const id of affected) {
627
+ if (watch.ready && watch.rows.has(id)) {
628
+ watch.dirtyStates.add(id);
629
+ states = true;
630
+ }
631
+ else if (!watch.ready || watch.filesById.has(id)) {
632
+ // Its first event here: the row is read.
633
+ watch.dirtyRows.add(id);
634
+ rows = true;
635
+ }
636
+ }
637
+ if (states)
638
+ this.schedule(watch, "states");
639
+ if (rows)
640
+ this.schedule(watch, "debounced");
641
+ }
642
+ }
643
+ // --- Scope work ---------------------------------------------------------
644
+ /** Reads the scope paths and watches their project directories. */
645
+ async refreshDirs(watch) {
646
+ const paths = await this.deps.index.listedPaths(watch.cwd, true);
647
+ const dirs = await this.deps.index.projectDirs(paths);
648
+ if (watch.closed)
649
+ return;
650
+ watch.paths = paths;
651
+ const wanted = new Map(dirs.map(({ dirName, projectPath }) => [dirName, projectPath]));
652
+ let opened = false;
653
+ for (const [dirName, entry] of watch.dirs) {
654
+ if (wanted.has(dirName))
655
+ continue;
656
+ entry.watcher?.close();
657
+ watch.dirs.delete(dirName);
658
+ }
659
+ for (const [dirName, projectPath] of wanted) {
660
+ const dir = path.join(projectsRoot(), dirName);
661
+ const identity = dirIdentity(dir);
662
+ let entry = watch.dirs.get(dirName);
663
+ if (entry?.watcher && entry.identity === identity) {
664
+ entry.projectPath = projectPath;
665
+ continue;
666
+ }
667
+ if (entry?.watcher) {
668
+ // Replaced (renamed away and made again): watch the new one.
669
+ entry.watcher.close();
670
+ watch.dirs.delete(dirName);
671
+ entry = undefined;
672
+ }
673
+ const created = { projectPath, watcher: null, identity };
674
+ created.watcher = watchDir(dir, (filename) => this.onTranscriptEvent(watch, dirName, filename), () => {
675
+ // Opened again by the next rescan, when the directory is back.
676
+ created.watcher = null;
677
+ });
678
+ watch.dirs.set(dirName, created);
679
+ // Not for a watcher opened again after an error, which could repeat.
680
+ if (created.watcher && !entry)
681
+ opened = true;
682
+ }
683
+ if (opened && !watch.settle) {
684
+ watch.settle = setTimeout(() => {
685
+ watch.settle = undefined;
686
+ watch.rescan = true;
687
+ this.schedule(watch, "now");
688
+ }, SETTLE_MS);
689
+ watch.settle.unref?.();
690
+ }
691
+ }
692
+ setFiles(watch, files) {
693
+ const before = watch.filesById;
694
+ watch.files = new Map();
695
+ watch.filesById = new Map();
696
+ for (const file of files)
697
+ this.putFile(watch, file, before);
698
+ }
699
+ /** `known`: the sessions with a transcript before; another one is born. */
700
+ putFile(watch, file, known = watch.filesById) {
701
+ watch.files.set(file.filePath, file);
702
+ const id = file.sessionId;
703
+ if (watch.ready && !known.has(id))
704
+ watch.born.set(id, ++this.seq);
705
+ let paths = watch.filesById.get(id);
706
+ if (!paths)
707
+ watch.filesById.set(id, (paths = new Set()));
708
+ paths.add(file.filePath);
709
+ }
710
+ dropFile(watch, filePath, id) {
711
+ watch.files.delete(filePath);
712
+ const paths = watch.filesById.get(id);
713
+ paths?.delete(filePath);
714
+ if (paths?.size === 0)
715
+ watch.filesById.delete(id);
716
+ }
717
+ present(row, now) {
718
+ const own = this.deps.own(row.sessionId);
719
+ const id = row.sessionId;
720
+ if (own)
721
+ this.ownShown.add(id);
722
+ else
723
+ this.ownShown.delete(id);
724
+ const info = sessionInfoOf(row, own, this.live.get(row.sessionId), now);
725
+ return { row, info, signature: changeSignature(info) };
726
+ }
727
+ /** Runs one pass of the pending work of `watch`, one at a time. `files`:
728
+ * the transcript events and the rescan too, not only the states. */
729
+ async work(watch, files) {
730
+ // Before the transcripts are stat'ed, the events wait: the start runs a
731
+ // pass for them.
732
+ if (!watch.ready || watch.closed)
733
+ return;
734
+ if (watch.working) {
735
+ watch.again = true;
736
+ watch.againFiles ||= files;
737
+ return;
738
+ }
739
+ const working = (async () => {
740
+ let withFiles = files;
741
+ do {
742
+ withFiles ||= watch.againFiles;
743
+ watch.again = watch.againFiles = false;
744
+ try {
745
+ await this.pass(watch, withFiles);
746
+ }
747
+ catch (error) {
748
+ this.deps.logError(`session list change check of ${watch.cwd} failed`, error);
749
+ }
750
+ withFiles = false;
751
+ } while (watch.again && !watch.closed);
752
+ })();
753
+ watch.working = working;
754
+ try {
755
+ await working;
756
+ }
757
+ finally {
758
+ if (watch.working === working)
759
+ watch.working = undefined;
760
+ }
761
+ }
762
+ async pass(watch, files) {
763
+ const rescan = files && watch.rescan;
764
+ const dirtyFiles = files ? [...watch.dirtyFiles] : [];
765
+ const rowIds = new Set(files ? watch.dirtyRows : []);
766
+ const stateIds = new Set(watch.dirtyStates);
767
+ /** Sessions whose state may have aged: sent only when it changed. */
768
+ const agedIds = new Set();
769
+ /** The scope paths and the transcripts of each session read again, as
770
+ * they were before this pass. */
771
+ const priorPaths = watch.paths;
772
+ const priorFiles = new Map();
773
+ const remember = (id) => {
774
+ if (!priorFiles.has(id))
775
+ priorFiles.set(id, this.filesOf(watch, id));
776
+ };
777
+ if (files) {
778
+ watch.rescan = false;
779
+ watch.dirtyFiles.clear();
780
+ watch.dirtyRows.clear();
781
+ }
782
+ watch.dirtyStates.clear();
783
+ let resolved = new Map();
784
+ /** Rows of live sessions not read here, from the metadata the index
785
+ * holds: their state ages from now on. */
786
+ let seeded = [];
787
+ /** Sessions that may have had a row before this pass. */
788
+ let shownBefore = new Set();
789
+ try {
790
+ if (rescan) {
791
+ await this.refreshDirs(watch);
792
+ const files = await this.deps.index.enumerateFiles(watch.paths);
793
+ if (watch.closed)
794
+ return;
795
+ // The sessions of a transcript that is new, changed or gone are
796
+ // read again; one whose stat did not change is not read.
797
+ const before = new Map(watch.files);
798
+ for (const file of files) {
799
+ const old = before.get(file.filePath);
800
+ before.delete(file.filePath);
801
+ if (!old ||
802
+ old.mtimeMs !== file.mtimeMs ||
803
+ old.size !== file.size ||
804
+ old.ino !== file.ino) {
805
+ rowIds.add(file.sessionId);
806
+ }
807
+ }
808
+ for (const gone of before.values())
809
+ rowIds.add(gone.sessionId);
810
+ for (const id of rowIds)
811
+ remember(id);
812
+ this.setFiles(watch, files);
813
+ this.learnSuccessors(watch);
814
+ if (scopeKey(watch.paths) !== watch.resolvedPaths) {
815
+ // A worktree came or went: a transcript that did not change may now
816
+ // be in the scope or out of it (another path of the same project
817
+ // directory). Resolved again: the sessions read here, and those whose
818
+ // metadata the index holds (a list read them); both without reading
819
+ // a transcript.
820
+ for (const id of watch.rows.keys())
821
+ rowIds.add(id);
822
+ for (const file of files) {
823
+ if (this.deps.index.isRead(file))
824
+ rowIds.add(file.sessionId);
825
+ }
826
+ }
827
+ // A transcript that could not be read before.
828
+ for (const id of watch.unreadable)
829
+ rowIds.add(id);
830
+ watch.unreadable.clear();
831
+ // Only the state of a session that a live process holds depends on
832
+ // the time (an unfinished turn ages). One without a row read here
833
+ // gets it from the metadata the index holds (a list read it), not
834
+ // from its transcript; if it has none, the client did not list it.
835
+ for (const id of this.live.keys()) {
836
+ if (watch.rows.has(id))
837
+ agedIds.add(id);
838
+ }
839
+ seeded = await this.liveSeeds(watch, rowIds);
840
+ if (watch.closed)
841
+ return;
842
+ for (const id of this.ownShown)
843
+ if (watch.rows.has(id))
844
+ agedIds.add(id);
845
+ }
846
+ else if (dirtyFiles.length > 0) {
847
+ const stats = await Promise.all(dirtyFiles.map((filePath) => fsp.stat(filePath).catch(() => undefined)));
848
+ if (watch.closed)
849
+ return;
850
+ dirtyFiles.forEach((filePath, i) => {
851
+ const filename = path.basename(filePath);
852
+ const dirName = path.basename(path.dirname(filePath));
853
+ const id = transcriptId(filename);
854
+ rowIds.add(id);
855
+ remember(id);
856
+ const dir = watch.dirs.get(dirName);
857
+ const fileStats = stats[i];
858
+ if (!dir || !fileStats?.isFile() || fileStats.size === 0) {
859
+ this.dropFile(watch, filePath, id);
860
+ return;
861
+ }
862
+ this.putFile(watch, {
863
+ sessionId: filename.slice(0, -6),
864
+ filePath,
865
+ dirName,
866
+ projectPath: dir.projectPath,
867
+ mtimeMs: fileStats.mtimeMs,
868
+ size: fileStats.size,
869
+ ino: fileStats.ino,
870
+ });
871
+ });
872
+ }
873
+ if (rowIds.size > 0) {
874
+ // A session continued in a changed one may be shown or hidden now.
875
+ // Which sessions those are is known from the reads here and from
876
+ // the metadata the index holds: a session that no list or event read
877
+ // was not shown.
878
+ const successors = new Set(rowIds);
879
+ for (const [id, successor] of watch.successorOf) {
880
+ if (successors.has(successor))
881
+ rowIds.add(id);
882
+ }
883
+ for (const file of watch.files.values()) {
884
+ const successor = this.deps.index.continuedIn(file.filePath);
885
+ if (successor && successors.has(successor))
886
+ rowIds.add(file.sessionId);
887
+ }
888
+ for (const id of rowIds)
889
+ remember(id);
890
+ // Before the read below replaces the metadata the index holds.
891
+ shownBefore = await this.shownBefore(watch, priorPaths, rowIds, priorFiles);
892
+ if (watch.closed)
893
+ return;
894
+ const read = [];
895
+ for (const id of rowIds) {
896
+ for (const filePath of watch.filesById.get(id) ?? []) {
897
+ const file = watch.files.get(filePath);
898
+ if (file)
899
+ read.push(file);
900
+ }
901
+ }
902
+ const rows = await this.deps.index.rowsOf(watch.paths, read, () => [
903
+ ...watch.files.values(),
904
+ ]);
905
+ if (watch.closed)
906
+ return;
907
+ resolved = new Map(rows.map((row) => [row.sessionId, row]));
908
+ for (const file of read) {
909
+ const id = file.sessionId;
910
+ const successor = this.deps.index.continuedIn(file.filePath);
911
+ if (successor)
912
+ watch.successorOf.set(id, successor);
913
+ else if (this.deps.index.isRead(file))
914
+ watch.successorOf.delete(id);
915
+ }
916
+ // A transcript there whose read failed is no removal: it keeps its row
917
+ // until a rescan reads it.
918
+ for (const file of read) {
919
+ const id = file.sessionId;
920
+ if (!resolved.has(id) && !this.deps.index.isRead(file)) {
921
+ watch.unreadable.add(id);
922
+ rowIds.delete(id);
923
+ }
924
+ }
925
+ }
926
+ }
927
+ catch (error) {
928
+ // What this pass took is left for the next one: the file stats may
929
+ // have moved on, so the sessions are resolved again by id.
930
+ for (const filePath of dirtyFiles)
931
+ watch.dirtyFiles.add(filePath);
932
+ for (const id of rowIds)
933
+ watch.dirtyRows.add(id);
934
+ for (const id of stateIds)
935
+ watch.dirtyStates.add(id);
936
+ if (rescan)
937
+ watch.rescan = true;
938
+ throw error;
939
+ }
940
+ if (rescan)
941
+ watch.resolvedPaths = scopeKey(watch.paths);
942
+ // From here on synchronous: the rows change and are delivered at once.
943
+ const now = this.now();
944
+ /** The changed sessions. */
945
+ const changed = new Set();
946
+ /** Sessions without a row now. */
947
+ const absent = new Set();
948
+ for (const id of rowIds) {
949
+ const row = resolved.get(id);
950
+ if (row) {
951
+ watch.rows.set(id, this.present(row, now));
952
+ changed.add(id);
953
+ continue;
954
+ }
955
+ const previous = watch.rows.get(id);
956
+ watch.rows.delete(id);
957
+ if (!previous && !shownBefore.has(id))
958
+ continue;
959
+ changed.add(id);
960
+ absent.add(id);
961
+ }
962
+ for (const row of seeded) {
963
+ const id = row.sessionId;
964
+ if (rowIds.has(id) || watch.rows.has(id))
965
+ continue;
966
+ // As the client listed it, about when the watch became ready: a state
967
+ // that aged since is sent below.
968
+ watch.rows.set(id, this.present(row, watch.readyAt));
969
+ agedIds.add(id);
970
+ }
971
+ for (const id of new Set([...stateIds, ...agedIds])) {
972
+ if (rowIds.has(id))
973
+ continue;
974
+ const current = watch.rows.get(id);
975
+ if (!current)
976
+ continue;
977
+ const next = this.present(current.row, now);
978
+ watch.rows.set(id, next);
979
+ // An event of the session, or a state that aged.
980
+ if (stateIds.has(id) || next.signature !== current.signature) {
981
+ changed.add(id);
982
+ }
983
+ }
984
+ for (const subscription of watch.subscriptions) {
985
+ for (const id of changed) {
986
+ const born = watch.born.get(id);
987
+ if (absent.has(id) &&
988
+ !subscription.sent.has(id) &&
989
+ born !== undefined &&
990
+ born > subscription.since) {
991
+ // Born after the subscription and never sent: nothing to remove.
992
+ subscription.pending.delete(id);
993
+ continue;
994
+ }
995
+ subscription.pending.add(id);
996
+ }
997
+ this.deliver(subscription);
998
+ }
999
+ // A session without a transcript now, shown or not: a removal already
1000
+ // sent for it is forgotten too.
1001
+ for (const id of rowIds) {
1002
+ if (watch.filesById.has(id))
1003
+ continue;
1004
+ watch.born.delete(id);
1005
+ watch.successorOf.delete(id);
1006
+ watch.hiddenSeeds.delete(id);
1007
+ for (const subscription of watch.subscriptions) {
1008
+ if (subscription.sent.get(id) === null && !subscription.pending.has(id)) {
1009
+ subscription.sent.delete(id);
1010
+ }
1011
+ }
1012
+ }
1013
+ }
1014
+ /** Records which session each transcript is continued in, by the metadata
1015
+ * the index holds now (a list read it): kept when the index evicts it.
1016
+ * Reads nothing. */
1017
+ learnSuccessors(watch) {
1018
+ for (const file of watch.files.values()) {
1019
+ const successor = this.deps.index.continuedIn(file.filePath);
1020
+ if (successor)
1021
+ watch.successorOf.set(file.sessionId, successor);
1022
+ }
1023
+ }
1024
+ /**
1025
+ * The rows of the live sessions of the scope without a row here (and not
1026
+ * in `except`), from the metadata the index holds (a list read them), not
1027
+ * from their transcripts; one it holds nothing for the client did not
1028
+ * list. Reads no transcript: a continued one only checks its successor.
1029
+ */
1030
+ async liveSeeds(watch, except) {
1031
+ const read = [];
1032
+ for (const id of this.live.keys()) {
1033
+ if (!watch.rows.has(id) && !except.has(id))
1034
+ read.push(...this.filesOf(watch, id));
1035
+ }
1036
+ if (read.length === 0)
1037
+ return [];
1038
+ const cached = await this.deps.index.cachedRowsOf(watch.paths, read);
1039
+ // A continued one is shown while its successor has no history: its
1040
+ // cached metadata and that check, made again only when the stat of
1041
+ // either changed.
1042
+ const statOf = (file) => file ? `${file.mtimeMs}:${file.size}:${file.ino}` : "-";
1043
+ const continued = [];
1044
+ for (const file of read) {
1045
+ const id = file.sessionId;
1046
+ const successor = this.deps.index.continuedIn(file.filePath);
1047
+ if (!cached.unknown.has(id) || !this.deps.index.isRead(file) || !successor)
1048
+ continue;
1049
+ const successorFile = watch.files.get(path.join(path.dirname(file.filePath), `${successor}.jsonl`));
1050
+ const key = `${statOf(file)}|${statOf(successorFile)}`;
1051
+ if (watch.hiddenSeeds.get(id) !== key)
1052
+ continued.push({ file, key });
1053
+ }
1054
+ if (continued.length === 0)
1055
+ return cached.rows;
1056
+ const rows = await this.deps.index.rowsOf(watch.paths, continued.map(({ file }) => file), () => []);
1057
+ const shown = new Set(rows.map((row) => row.sessionId));
1058
+ for (const { file, key } of continued) {
1059
+ const id = file.sessionId;
1060
+ if (shown.has(id))
1061
+ watch.hiddenSeeds.delete(id);
1062
+ else
1063
+ watch.hiddenSeeds.set(id, key);
1064
+ }
1065
+ return [...cached.rows, ...rows];
1066
+ }
1067
+ /** The transcripts of a session the watch knows. */
1068
+ filesOf(watch, id) {
1069
+ const files = [];
1070
+ for (const filePath of watch.filesById.get(id) ?? []) {
1071
+ const file = watch.files.get(filePath);
1072
+ if (file)
1073
+ files.push(file);
1074
+ }
1075
+ return files;
1076
+ }
1077
+ /**
1078
+ * Which of `ids`, without a row read here, may have had a row in the scope
1079
+ * before: those that the metadata the index holds for their earlier
1080
+ * transcripts (a list read them) gives a row, and those it gives no answer
1081
+ * for (unknown: a removal is sent once). Not a session without a
1082
+ * transcript before, nor one known to have had no row (another path of
1083
+ * the project directory, a hidden session). Reads no transcript.
1084
+ */
1085
+ async shownBefore(watch, paths, ids, priorFiles) {
1086
+ const candidates = [];
1087
+ for (const id of ids) {
1088
+ if (!watch.rows.has(id))
1089
+ candidates.push(...(priorFiles.get(id) ?? []));
1090
+ }
1091
+ if (candidates.length === 0)
1092
+ return new Set();
1093
+ const { rows, unknown } = await this.deps.index.cachedRowsOf(paths, candidates);
1094
+ for (const row of rows)
1095
+ unknown.add(row.sessionId);
1096
+ return unknown;
1097
+ }
1098
+ /** Sends the pending sessions of `subscription` that differ from what it
1099
+ * last sent (a session never sent differs), each at most once per
1100
+ * interval, in one notification. */
1101
+ deliver(subscription) {
1102
+ const { watch } = subscription;
1103
+ if (watch.closed)
1104
+ return;
1105
+ if (subscription.timer)
1106
+ clearTimeout(subscription.timer);
1107
+ subscription.timer = undefined;
1108
+ const now = this.now();
1109
+ const sessions = [];
1110
+ const removed = [];
1111
+ let nextAt = Infinity;
1112
+ for (const id of subscription.pending) {
1113
+ const current = watch.rows.get(id);
1114
+ const sent = subscription.sent.get(id);
1115
+ const unchanged = current ? sent === current.signature : sent === null;
1116
+ if (unchanged) {
1117
+ subscription.pending.delete(id);
1118
+ continue;
1119
+ }
1120
+ const dueAt = (subscription.sentAt.get(id) ?? -Infinity) + this.minIntervalMs;
1121
+ if (dueAt > now) {
1122
+ nextAt = Math.min(nextAt, dueAt);
1123
+ continue;
1124
+ }
1125
+ subscription.pending.delete(id);
1126
+ subscription.sentAt.set(id, now);
1127
+ if (!current) {
1128
+ removed.push(id);
1129
+ // A session with a transcript but no row (hidden, or of another
1130
+ // path) is remembered as removed; one without a transcript is
1131
+ // forgotten.
1132
+ if (watch.filesById.has(id)) {
1133
+ subscription.sent.set(id, null);
1134
+ }
1135
+ else {
1136
+ subscription.sent.delete(id);
1137
+ }
1138
+ continue;
1139
+ }
1140
+ sessions.push(current.info);
1141
+ subscription.sent.set(id, current.signature);
1142
+ }
1143
+ this.forgetOldSends(subscription, now);
1144
+ if (nextAt !== Infinity) {
1145
+ subscription.timer = setTimeout(() => this.deliver(subscription), nextAt - now);
1146
+ subscription.timer.unref?.();
1147
+ }
1148
+ if (sessions.length === 0 && removed.length === 0)
1149
+ return;
1150
+ void this.deps
1151
+ .notify({ subscriptionId: subscription.id, sessions, removed })
1152
+ .catch((error) => this.deps.logError("session list change notification failed", error));
1153
+ }
1154
+ /** Drops send times older than the interval: they hold nothing back. */
1155
+ forgetOldSends(subscription, now) {
1156
+ if (subscription.sentAt.size < 1024)
1157
+ return;
1158
+ for (const [id, at] of subscription.sentAt) {
1159
+ if (now - at >= this.minIntervalMs)
1160
+ subscription.sentAt.delete(id);
1161
+ }
1162
+ }
1163
+ }