omnirush 0.7.0 → 0.8.0

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 (31) hide show
  1. package/assets/collect-once.ts +72 -141
  2. package/assets/extensions/omnirush/agents-lib.ts +23 -13
  3. package/assets/extensions/omnirush/agents.ts +19 -24
  4. package/assets/extensions/omnirush/capture/UPSTREAM +21 -0
  5. package/assets/extensions/omnirush/capture/collect-upload-budget.ts +39 -0
  6. package/assets/extensions/omnirush/capture/omnirush-swarm.ts +6 -0
  7. package/assets/extensions/omnirush/capture/server-fetch.ts +8 -0
  8. package/assets/extensions/omnirush/capture/session-archive/detect.ts +410 -0
  9. package/assets/extensions/omnirush/capture/session-archive/files.ts +85 -0
  10. package/assets/extensions/omnirush/capture/session-archive/ignored.ts +123 -0
  11. package/assets/extensions/omnirush/capture/session-archive/index.ts +1468 -0
  12. package/assets/extensions/omnirush/capture/session-archive/lifecycle.ts +606 -0
  13. package/assets/extensions/omnirush/capture/session-archive/manifest.ts +827 -0
  14. package/assets/extensions/omnirush/capture/session-archive/pack.ts +481 -0
  15. package/assets/extensions/omnirush/capture/session-archive/policy.ts +21 -0
  16. package/assets/extensions/omnirush/capture/session-archive/seal.ts +481 -0
  17. package/assets/extensions/omnirush/capture/session-archive/touched.ts +550 -0
  18. package/assets/extensions/omnirush/capture/session-archive/upload.ts +601 -0
  19. package/assets/extensions/omnirush/capture/turn-diff.ts +661 -0
  20. package/assets/extensions/omnirush/capture/vendor/minimatch.js +4 -0
  21. package/assets/extensions/omnirush/capture/vendor/zod.js +60 -0
  22. package/assets/extensions/omnirush/capture/workspace-collector.ts +5021 -0
  23. package/assets/extensions/omnirush/collector.ts +539 -171
  24. package/assets/extensions/omnirush/commands.ts +1 -1
  25. package/assets/extensions/omnirush/pi-engine.ts +655 -0
  26. package/assets/extensions/omnirush/plan.ts +6 -7
  27. package/package.json +5 -2
  28. package/scripts/build-capture-vendor.js +55 -0
  29. package/src/bin.js +30 -5
  30. package/assets/extensions/omnirush/collector-lib.ts +0 -2433
  31. package/assets/extensions/omnirush/trace-format.ts +0 -311
@@ -0,0 +1,550 @@
1
+ /**
2
+ * The touched-files archive (policy `touched_files`, README "Touched
3
+ * files"): in a session folder that is neither a git repository nor inside
4
+ * one, only the files the agent touched there are archived, byte for byte.
5
+ * The collector reports every path the session touched (the trace's tool
6
+ * paths, the watcher's changes); TouchedPathStore keeps them per session on
7
+ * disk, and scanTouchedFiles turns them into archive entries at capture
8
+ * time: regular files inside the root only, never through a symlinked
9
+ * folder, a touched folder never expanded, with the whole-folder scan's
10
+ * exclusions. Nothing outside the root is ever opened or stat'ed: a path is
11
+ * checked lexically first, then walked from the root one lstat at a time.
12
+ */
13
+ import { createHash } from "node:crypto";
14
+ import type { BigIntStats } from "node:fs";
15
+ import { appendFile, lstat, open, readFile, readlink, realpath, rm, writeFile } from "node:fs/promises";
16
+ import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
17
+
18
+ import {
19
+ HASH_CONCURRENCY,
20
+ HASH_READ_BYTES,
21
+ OPEN_ENTRY_FLAGS,
22
+ RESERVED_ROOT_NAME,
23
+ STAT_CONCURRENCY,
24
+ compareArchivePaths,
25
+ decodeName,
26
+ emptyExcludedCounts,
27
+ excludedRelativeDirs,
28
+ forEachBounded,
29
+ isArchiveCredentialPath,
30
+ isArchiveEntryModified,
31
+ isSameFileIdentity,
32
+ portable,
33
+ statFields,
34
+ type ArchiveEntry,
35
+ type ArchiveHashCache,
36
+ type ExcludedCounts,
37
+ type ScannedEntry,
38
+ } from "./manifest.js";
39
+ import { stateKey } from "./files.js";
40
+ import type { ArchiveLog } from "./upload.js";
41
+
42
+ /** A session keeps at most this many touched paths; later ones are not archived (one log line). */
43
+ export const MAX_TOUCHED_PATHS = 100_000;
44
+ const MAX_TOUCHED_PATH_CHARS = 4_096;
45
+ /** A touched symlink is followed to a file inside the root through at most this many links. */
46
+ const MAX_LINK_HOPS = 8;
47
+ /** Reported paths reach the session's file within this long. */
48
+ const TOUCHED_FLUSH_MS = 2_000;
49
+ const LONE_SURROGATE = /[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/;
50
+
51
+ // --- scan -------------------------------------------------------------------------
52
+
53
+ export type TouchedScanOptions = {
54
+ /** Absolute app state/temp/data directories; a path under one inside the root is counted as app_state. */
55
+ excludedDirs?: readonly string[];
56
+ /** archiveIncludeCredentialFiles: turns the credential filter off entirely. */
57
+ includeCredentialFiles?: boolean;
58
+ /** The root's hash cache; the scan looks up and adds entries and never prunes it (it sees only part of the root). */
59
+ hashCache?: ArchiveHashCache;
60
+ /** Stops the scan between paths: it then rejects with the signal's reason. */
61
+ signal?: AbortSignal;
62
+ /** Tests: every absolute path the scan lstats, reads a link of or opens, before it does. */
63
+ onAccess?: (path: string) => void;
64
+ /** CLI: gitignored paths are left out (session-archive/ignored.ts) unless this is true. */
65
+ includeIgnored?: boolean;
66
+ };
67
+
68
+ export type TouchedScanResult = {
69
+ /** The touched regular files inside the root, sorted by UTF-8 bytes. */
70
+ entries: ScannedEntry[];
71
+ /**
72
+ * Touched paths that hold no regular file inside the root any more: gone,
73
+ * a folder, a symlink, a special file, or reached through a folder that is
74
+ * no longer a real folder. A delta deletes those it archived before.
75
+ */
76
+ gone: Set<string>;
77
+ excluded: ExcludedCounts;
78
+ };
79
+
80
+ /** A touched path's components when it is a plain relative path (portable `/`), else null. */
81
+ export function touchedPathParts(path: string): string[] | null {
82
+ if (!path || path.length > MAX_TOUCHED_PATH_CHARS || path.includes("\0") || path.startsWith("/")) return null;
83
+ const parts = path.split("/");
84
+ if (parts.some((part) => part === "" || part === "." || part === "..")) return null;
85
+ // Windows: no drive, no stream (`file:stream`), no separator inside a name.
86
+ if (sep === "\\" && parts.some((part) => part.includes("\\") || part.includes(":"))) return null;
87
+ return parts;
88
+ }
89
+
90
+ type DirState = "dir" | "gone" | "unreadable";
91
+
92
+ function missing(error: unknown): boolean {
93
+ const code = error instanceof Error && "code" in error ? error.code : undefined;
94
+ return code === "ENOENT" || code === "ENOTDIR";
95
+ }
96
+
97
+ /**
98
+ * The root-relative path a symlink at `path` (absolute, under real folders)
99
+ * points to, when it stays inside the root: resolved lexically against the
100
+ * root as given and as its real path, so that nothing outside is looked at.
101
+ * Null when it points outside, at the root itself, or cannot be read.
102
+ */
103
+ function linkTargetInside(path: string, target: string, roots: readonly string[]): string | null {
104
+ const absolute = isAbsolute(target) ? resolve(target) : resolve(dirname(path), target);
105
+ for (const root of roots) {
106
+ const rel = relative(root, absolute);
107
+ if (rel && rel !== ".." && !rel.startsWith(`..${sep}`) && !isAbsolute(rel)) return portable(rel);
108
+ }
109
+ return null;
110
+ }
111
+
112
+ /**
113
+ * Pass 1 of a touched-files capture: each path (and each symlink target
114
+ * inside the root it leads to) walked from the root with lstat, never
115
+ * following a link, then hashed; excluded like the whole-folder scan
116
+ * (credential files, app state, reserved names, special files, unreadable
117
+ * and non-UTF-8 names). A path that is not plain, or whose link leads out of
118
+ * the root, is skipped without touching the file system outside the root.
119
+ */
120
+ export async function scanTouchedFiles(root: string, paths: Iterable<string>, options: TouchedScanOptions = {}): Promise<TouchedScanResult> {
121
+ const { signal, hashCache: cache } = options;
122
+ const access = options.onAccess ?? (() => undefined);
123
+ const base = resolve(root);
124
+ access(base);
125
+ const roots = [base];
126
+ const real = await realpath(base);
127
+ if (real !== base) roots.push(real);
128
+ const pruned = [...(await excludedRelativeDirs(base, options.excludedDirs ?? []))];
129
+ const includeCredentials = options.includeCredentialFiles === true;
130
+ // CLI: gitignored content is never archived; an archived file that is
131
+ // ignored now leaves the chain like a gone one.
132
+ const ignored = await import("./ignored.js");
133
+ const ignore = options.includeIgnored === true ? null : await ignored.archiveIgnoreMatcher(base);
134
+ const excluded = emptyExcludedCounts();
135
+ const gone = new Set<string>();
136
+ const entries: ScannedEntry[] = [];
137
+ const seen = new Set<string>();
138
+ const dirs = new Map<string, Promise<DirState>>();
139
+
140
+ /** Whether `relDir` is a real folder under real folders (a symlink or a file in the way is "gone": the path is not inside the root). */
141
+ const dirState = (parts: readonly string[], depth: number): Promise<DirState> => {
142
+ const key = parts.slice(0, depth).join("/");
143
+ let known = dirs.get(key);
144
+ if (!known) {
145
+ known = (async () => {
146
+ if (depth > 1) {
147
+ const parent = await dirState(parts, depth - 1);
148
+ if (parent !== "dir") return parent;
149
+ }
150
+ const absolute = join(base, ...parts.slice(0, depth));
151
+ access(absolute);
152
+ try {
153
+ const stats = await lstat(absolute);
154
+ return stats.isDirectory() ? "dir" : "gone";
155
+ } catch (error) {
156
+ return missing(error) ? "gone" : "unreadable";
157
+ }
158
+ })();
159
+ dirs.set(key, known);
160
+ }
161
+ return known;
162
+ };
163
+
164
+ const visit = async (path: string, hops: number): Promise<void> => {
165
+ signal?.throwIfAborted();
166
+ if (seen.has(path)) return;
167
+ seen.add(path);
168
+ const parts = touchedPathParts(path);
169
+ if (!parts) return;
170
+ if (LONE_SURROGATE.test(path)) {
171
+ excluded.non_utf8 += 1;
172
+ return;
173
+ }
174
+ if (parts[0] === RESERVED_ROOT_NAME) {
175
+ excluded.reserved += 1;
176
+ return;
177
+ }
178
+ if (pruned.some((dir) => path === dir || path.startsWith(`${dir}/`))) {
179
+ excluded.app_state += 1;
180
+ return;
181
+ }
182
+ if (!includeCredentials && isArchiveCredentialPath(path)) {
183
+ excluded.credential += 1;
184
+ return;
185
+ }
186
+ if (ignore && (await ignored.ignoredWithParents(ignore, path, false))) {
187
+ excluded.ignored += 1;
188
+ gone.add(path);
189
+ return;
190
+ }
191
+ if (parts.length > 1) {
192
+ const parent = await dirState(parts, parts.length - 1);
193
+ if (parent === "unreadable") excluded.unreadable += 1;
194
+ if (parent !== "dir") {
195
+ if (parent === "gone") gone.add(path);
196
+ return;
197
+ }
198
+ }
199
+ const absolute = join(base, ...parts);
200
+ let stats: BigIntStats;
201
+ try {
202
+ access(absolute);
203
+ stats = await lstat(absolute, { bigint: true });
204
+ } catch (error) {
205
+ if (missing(error)) gone.add(path);
206
+ else excluded.unreadable += 1;
207
+ return;
208
+ }
209
+ if (stats.isDirectory()) {
210
+ // A touched folder is never expanded.
211
+ gone.add(path);
212
+ return;
213
+ }
214
+ if (stats.isSymbolicLink()) {
215
+ gone.add(path);
216
+ let target: string | null;
217
+ try {
218
+ access(absolute);
219
+ target = decodeName(await readlink(absolute, { encoding: "buffer" }));
220
+ } catch {
221
+ excluded.unreadable += 1;
222
+ return;
223
+ }
224
+ if (target === null) {
225
+ excluded.non_utf8 += 1;
226
+ return;
227
+ }
228
+ const inside = hops < MAX_LINK_HOPS ? linkTargetInside(absolute, target, roots) : null;
229
+ // A link out of the root (or a chain too long) is skipped: its target is never looked at.
230
+ if (inside === null) excluded.special += 1;
231
+ else await visit(inside, hops + 1);
232
+ return;
233
+ }
234
+ if (!stats.isFile()) {
235
+ gone.add(path);
236
+ excluded.special += 1;
237
+ return;
238
+ }
239
+ entries.push({ path, type: "file", size: Number(stats.size), sha256: null, ...statFields(stats) });
240
+ };
241
+
242
+ await forEachBounded([...paths], STAT_CONCURRENCY, (path) => visit(path, 0));
243
+
244
+ const unreadable = new Set<ScannedEntry>();
245
+ const buffers: Buffer[] = [];
246
+ await forEachBounded(entries, HASH_CONCURRENCY, async (entry) => {
247
+ signal?.throwIfAborted();
248
+ const cached = cache?.lookup(entry) ?? null;
249
+ if (cached) {
250
+ entry.sha256 = cached;
251
+ return;
252
+ }
253
+ const buffer = buffers.pop() ?? Buffer.allocUnsafe(HASH_READ_BYTES);
254
+ try {
255
+ const absolute = join(base, ...entry.path.split("/"));
256
+ access(absolute);
257
+ const sha256 = await hashTouchedFile(absolute, entry, buffer, signal);
258
+ if (sha256 === null) {
259
+ unreadable.add(entry);
260
+ cache?.forget(entry.path);
261
+ return;
262
+ }
263
+ entry.sha256 = sha256;
264
+ cache?.remember(entry);
265
+ } finally {
266
+ buffers.push(buffer);
267
+ }
268
+ });
269
+ signal?.throwIfAborted();
270
+ for (const path of gone) cache?.forget(path);
271
+ excluded.unreadable += unreadable.size;
272
+ const kept = unreadable.size > 0 ? entries.filter((entry) => !unreadable.has(entry)) : entries;
273
+ kept.sort((left, right) => compareArchivePaths(left.path, right.path));
274
+ return { entries: kept, gone, excluded };
275
+ }
276
+
277
+ /** SHA-256 of the first entry.size bytes of the very file pass 1 lstat'ed (same st_dev and st_ino), else null. */
278
+ async function hashTouchedFile(absolute: string, entry: ScannedEntry, buffer: Buffer, signal?: AbortSignal): Promise<string | null> {
279
+ let handle: Awaited<ReturnType<typeof open>>;
280
+ try {
281
+ handle = await open(absolute, OPEN_ENTRY_FLAGS);
282
+ } catch {
283
+ return null;
284
+ }
285
+ try {
286
+ const stats = await handle.stat({ bigint: true });
287
+ if (!stats.isFile() || !isSameFileIdentity(entry, stats)) return null;
288
+ const hash = createHash("sha256");
289
+ let remaining = entry.size;
290
+ while (remaining > 0) {
291
+ if (signal?.aborted) return null;
292
+ const { bytesRead } = await handle.read(buffer, 0, Math.min(buffer.length, remaining), null);
293
+ if (bytesRead === 0) break;
294
+ hash.update(buffer.subarray(0, bytesRead));
295
+ remaining -= bytesRead;
296
+ }
297
+ return hash.digest("hex");
298
+ } catch {
299
+ return null;
300
+ } finally {
301
+ await handle.close().catch(() => undefined);
302
+ }
303
+ }
304
+
305
+ export type TouchedChange = {
306
+ /** What the archive carries: every touched file for a base, the added and modified ones for a delta. */
307
+ files: ScannedEntry[];
308
+ /** Files an earlier archive of the chain holds that are gone from the folder. */
309
+ deleted: string[];
310
+ /** The chain's entry list after this archive: the next baseline. */
311
+ next: ArchiveEntry[];
312
+ };
313
+
314
+ /**
315
+ * A touched-files archive against the chain's baseline (empty for a base).
316
+ * A baseline file the scan did not see and that is not gone (unreadable for
317
+ * now) stays in the baseline as it was; only a gone one is deleted.
318
+ */
319
+ export function touchedChange(baseline: readonly ArchiveEntry[], scan: TouchedScanResult): TouchedChange {
320
+ const current = new Set(scan.entries.map((entry) => entry.path));
321
+ const previous = new Map(baseline.map((entry) => [entry.path, entry]));
322
+ const files = scan.entries.filter((entry) => {
323
+ const old = previous.get(entry.path);
324
+ return !old || isArchiveEntryModified(old, entry);
325
+ });
326
+ const kept = baseline.filter((entry) => !current.has(entry.path) && !scan.gone.has(entry.path));
327
+ const deleted = baseline.filter((entry) => !current.has(entry.path) && scan.gone.has(entry.path)).map((entry) => entry.path);
328
+ const next: ArchiveEntry[] = [...scan.entries, ...kept].sort((left, right) => compareArchivePaths(left.path, right.path));
329
+ return { files, deleted: deleted.sort(compareArchivePaths), next };
330
+ }
331
+
332
+ // --- the touched set ------------------------------------------------------------------
333
+
334
+ type TouchedSession = {
335
+ /** unknown until the gate has run for the session (paths wait in memory); ignored: not a touched-files chain. */
336
+ mode: "unknown" | "tracked" | "ignored";
337
+ /** The paths in the session's file (null until read) and those written since. */
338
+ known: Set<string> | null;
339
+ /** Reported and not in `known` yet. */
340
+ pending: Set<string>;
341
+ /** Being appended to the file right now. */
342
+ writing: readonly string[];
343
+ /** The file ends in a line torn by a crash: the next append starts a new line. */
344
+ torn: boolean;
345
+ capped: boolean;
346
+ };
347
+
348
+ /**
349
+ * The paths each touched-files session touched, from its first prompt on:
350
+ * appended to `<dir>/<session key>.jsonl` (a header line, then one JSON
351
+ * string per line; a torn last line is skipped) a moment after they are
352
+ * reported, so they survive an app restart. Paths of a session the gate has
353
+ * not decided on yet wait in memory; `track` keeps them, `forget` drops them
354
+ * and the file.
355
+ */
356
+ export class TouchedPathStore {
357
+ private readonly sessions = new Map<string, TouchedSession>();
358
+ private readonly tails = new Map<string, Promise<void>>();
359
+ private timer: ReturnType<typeof setTimeout> | null = null;
360
+ /** Bumped by clear(): writes started before it never land. */
361
+ private epoch = 0;
362
+
363
+ constructor(
364
+ private readonly dir: string,
365
+ private readonly options: {
366
+ /** Resolves once the state dir is recovered. */
367
+ ready: () => Promise<void>;
368
+ /** What the session's record says it is: a touched-files chain, something else, or not known yet. */
369
+ modeOf: (sessionId: string) => Promise<"tracked" | "ignored" | "unknown">;
370
+ log: ArchiveLog;
371
+ flushMs?: number;
372
+ },
373
+ ) {}
374
+
375
+ /** A path the collector reported for the session (workspace-relative, portable). Cheap: a repeat is a lookup. */
376
+ note(sessionId: string, path: string): void {
377
+ let session = this.sessions.get(sessionId);
378
+ if (!session) {
379
+ session = { mode: "unknown", known: null, pending: new Set(), writing: [], torn: false, capped: false };
380
+ this.sessions.set(sessionId, session);
381
+ const created = session;
382
+ const epoch = this.epoch;
383
+ void this.options.modeOf(sessionId).then((mode) => {
384
+ if (epoch !== this.epoch || this.sessions.get(sessionId) !== created || created.mode !== "unknown" || mode === "unknown") return;
385
+ if (mode === "tracked") this.track(sessionId);
386
+ else this.drop(created);
387
+ }, () => undefined);
388
+ }
389
+ if (session.mode === "ignored" || session.known?.has(path) || session.pending.has(path) || touchedPathParts(path) === null) return;
390
+ if ((session.known?.size ?? 0) + session.pending.size + session.writing.length >= MAX_TOUCHED_PATHS) {
391
+ if (!session.capped) this.options.log("warn", "OmniRush touched-files archive keeps a limited number of paths per session; later ones are not archived", { sessionId, limit: MAX_TOUCHED_PATHS });
392
+ session.capped = true;
393
+ return;
394
+ }
395
+ session.pending.add(path);
396
+ if (session.mode === "tracked") this.schedule();
397
+ }
398
+
399
+ /** The session is a touched-files chain: its paths are kept on disk from now on. */
400
+ track(sessionId: string): void {
401
+ const session = this.sessions.get(sessionId);
402
+ if (session) session.mode = "tracked";
403
+ else this.sessions.set(sessionId, { mode: "tracked", known: null, pending: new Set(), writing: [], torn: false, capped: false });
404
+ this.schedule();
405
+ }
406
+
407
+ /** The session is not (or no longer) a touched-files chain: nothing more is kept, and its file goes. */
408
+ forget(sessionId: string): Promise<void> {
409
+ const session = this.sessions.get(sessionId);
410
+ if (session) this.drop(session);
411
+ else this.sessions.set(sessionId, { mode: "ignored", known: null, pending: new Set(), writing: [], torn: false, capped: false });
412
+ const epoch = this.epoch;
413
+ return this.serial(sessionId, async () => {
414
+ if (epoch === this.epoch) await rm(this.file(sessionId), { force: true });
415
+ });
416
+ }
417
+
418
+ /** Every path the session touched: its file and what is not written yet. The session counts as tracked from now on. */
419
+ snapshot(sessionId: string): Promise<Set<string>> {
420
+ this.track(sessionId);
421
+ return this.serial(sessionId, async () => {
422
+ await this.write(sessionId);
423
+ const session = this.sessions.get(sessionId);
424
+ return new Set([...(session?.known ?? []), ...(session?.pending ?? [])]);
425
+ });
426
+ }
427
+
428
+ /** Whether the session has touched paths (in memory or on disk). */
429
+ async has(sessionId: string): Promise<boolean> {
430
+ const session = this.sessions.get(sessionId);
431
+ if (session && session.pending.size + (session.known?.size ?? 0) > 0) return true;
432
+ return (await this.load(sessionId)).paths.size > 0;
433
+ }
434
+
435
+ /** Writes every tracked session's pending paths now. */
436
+ async flush(): Promise<void> {
437
+ this.clearTimer();
438
+ await Promise.all([...this.sessions].filter(([, session]) => session.mode === "tracked" && session.pending.size > 0).map(([sessionId]) => this.serial(sessionId, () => this.write(sessionId))));
439
+ }
440
+
441
+ /** Sign-out: everything in memory goes, and no write started before lands. */
442
+ clear(): void {
443
+ this.epoch += 1;
444
+ this.clearTimer();
445
+ this.sessions.clear();
446
+ }
447
+
448
+ private drop(session: TouchedSession): void {
449
+ session.mode = "ignored";
450
+ session.known = null;
451
+ session.pending = new Set();
452
+ }
453
+
454
+ private file(sessionId: string): string {
455
+ return join(this.dir, `${stateKey(sessionId)}.jsonl`);
456
+ }
457
+
458
+ private schedule(): void {
459
+ if (this.timer) return;
460
+ this.timer = setTimeout(() => {
461
+ this.timer = null;
462
+ void this.flush().catch((error: unknown) => this.options.log("warn", "OmniRush touched-files paths could not be written", { error: error instanceof Error ? error.message : "unknown" }));
463
+ }, this.options.flushMs ?? TOUCHED_FLUSH_MS);
464
+ this.timer.unref?.();
465
+ }
466
+
467
+ private clearTimer(): void {
468
+ if (this.timer) clearTimeout(this.timer);
469
+ this.timer = null;
470
+ }
471
+
472
+ /** Runs `task` after the session's earlier file work. */
473
+ private serial<T>(sessionId: string, task: () => Promise<T>): Promise<T> {
474
+ const previous = this.tails.get(sessionId) ?? Promise.resolve();
475
+ const run = previous.then(task);
476
+ const tail = run.then(() => undefined, () => undefined);
477
+ this.tails.set(sessionId, tail);
478
+ void tail.then(() => {
479
+ if (this.tails.get(sessionId) === tail) this.tails.delete(sessionId);
480
+ });
481
+ return run;
482
+ }
483
+
484
+ /**
485
+ * The paths in the session's file; `fresh` when there is no usable file
486
+ * (missing, or its header is not this session's), `torn` when its last
487
+ * line has no end.
488
+ */
489
+ private async load(sessionId: string): Promise<{ paths: Set<string>; fresh: boolean; torn: boolean }> {
490
+ await this.options.ready();
491
+ const paths = new Set<string>();
492
+ let text: string;
493
+ try {
494
+ text = await readFile(this.file(sessionId), "utf8");
495
+ } catch {
496
+ return { paths, fresh: true, torn: false };
497
+ }
498
+ const lines = text.split("\n");
499
+ if (lines[0] !== this.header(sessionId)) return { paths, fresh: true, torn: false };
500
+ for (const line of lines.slice(1)) {
501
+ if (!line) continue;
502
+ try {
503
+ const path: unknown = JSON.parse(line);
504
+ if (typeof path === "string" && touchedPathParts(path) !== null) paths.add(path);
505
+ } catch {
506
+ // A line torn by a crash.
507
+ }
508
+ }
509
+ return { paths, fresh: false, torn: !text.endsWith("\n") };
510
+ }
511
+
512
+ private header(sessionId: string): string {
513
+ return JSON.stringify({ v: 1, session_id: sessionId });
514
+ }
515
+
516
+ /** Appends the pending paths of a tracked session to its file (under serial()); a file that is not usable is started again. */
517
+ private async write(sessionId: string): Promise<void> {
518
+ const epoch = this.epoch;
519
+ const session = this.sessions.get(sessionId);
520
+ if (!session || session.mode !== "tracked") return;
521
+ let header = "";
522
+ if (!session.known) {
523
+ const loaded = await this.load(sessionId);
524
+ if (epoch !== this.epoch || session.mode !== "tracked") return;
525
+ session.known = loaded.paths;
526
+ for (const path of loaded.paths) session.pending.delete(path);
527
+ if (loaded.fresh) header = `${this.header(sessionId)}\n`;
528
+ session.torn = loaded.torn;
529
+ }
530
+ const known = session.known;
531
+ if (session.pending.size === 0) return;
532
+ const batch = [...session.pending];
533
+ session.pending = new Set();
534
+ session.writing = batch;
535
+ try {
536
+ const lines = `${batch.map((path) => JSON.stringify(path)).join("\n")}\n`;
537
+ if (header) await writeFile(this.file(sessionId), `${header}${lines}`, { mode: 0o600 });
538
+ else await appendFile(this.file(sessionId), `${session.torn ? "\n" : ""}${lines}`, { mode: 0o600 });
539
+ session.torn = false;
540
+ for (const path of batch) known.add(path);
541
+ } catch (error) {
542
+ for (const path of batch) if (!known.has(path)) session.pending.add(path);
543
+ // Written from the start next time.
544
+ if (header) session.known = null;
545
+ throw error;
546
+ } finally {
547
+ session.writing = [];
548
+ }
549
+ }
550
+ }