ace-wake 0.1.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.
@@ -0,0 +1,984 @@
1
+ import { createHash } from "node:crypto";
2
+ import * as nodeFs from "node:fs";
3
+ import * as nodePath from "node:path";
4
+ import { WakeError } from "./types.js";
5
+ import { expandInboxWatchTargets } from "./inbox-discovery.js";
6
+
7
+ /**
8
+ * Resolve a configured watch path to the filesystem target: relative paths
9
+ * resolve against `baseDir`, and an existing symlinked path resolves to its
10
+ * target so directory events match the watched file's real name.
11
+ *
12
+ * @param {string} baseDir
13
+ * @param {string} path
14
+ * @returns {string}
15
+ */
16
+ export function canonicalizeWatchPath(baseDir, path) {
17
+ const resolved = nodePath.resolve(baseDir, path);
18
+ try {
19
+ return nodeFs.realpathSync(resolved);
20
+ } catch {
21
+ // A nonexistent path cannot be canonicalized; stat validation in start()
22
+ // reports the visible error instead.
23
+ return resolved;
24
+ }
25
+ }
26
+
27
+ /**
28
+ * Watch subscription port owning native filesystem watcher handles.
29
+ *
30
+ * start() resolves the configured path to a canonical absolute target (relative
31
+ * paths resolve against `baseDir`; symlinks resolve to their target), validates
32
+ * readable metadata, and records a baseline fingerprint. An injected
33
+ * `resolveTargets` port may expand the configured path to several targets —
34
+ * the ace-hitl inbox watch expands `.ace-local/hitl` to every linked
35
+ * worktree's inbox, because `ace-hitl create` writes reports inside the
36
+ * coworker's worktree, not the main checkout. Discovery re-runs on every
37
+ * change/reconcile pass, so worktrees linked AFTER registration are
38
+ * picked up without a re-registration or session reload. The fingerprint
39
+ * aggregates all
40
+ * targets; a target that does not exist yet is a stable "absent" state, so
41
+ * the first report into a freshly created inbox still wakes. Only later fingerprint
42
+ * changes wake the agent, so registering a watch never produces an initial
43
+ * wake. When a wake coalesces into one already queued, the fingerprint stays
44
+ * at the previously delivered state: the queued wake covers the change, and a
45
+ * further change queues a fresh follow-up after settlement.
46
+ *
47
+ * A readable target's signature is its content hash, so a rewrite with
48
+ * identical bytes (fresh mtime, same size) is not a change and never wakes;
49
+ * targets whose content is not readable (directories, denied files) keep the
50
+ * `mtimeMs:size` stat-only signature. Every wake callback receives a delta of
51
+ * the changed targets — watched path, change kind (created/modified/deleted),
52
+ * BEFORE→AFTER signatures, and a short capped content excerpt when the file is
53
+ * readable — so the woken agent can identify the case without a full reload.
54
+ *
55
+ * A watcher runtime error on the configured (primary) path closes the
56
+ * handle, marks the subscription inactive with a visible error via the
57
+ * injected `onDeactivate` hook, and never retries: the user removes and
58
+ * re-adds the watch explicitly. A runtime error on a DISCOVERED extra
59
+ * target (e.g. `ace-overseer prune` removed a watched worktree) drops only
60
+ * that target's watcher and keeps the subscription alive for every other
61
+ * worktree; discovery re-attaches the target if it reappears. `fail()`
62
+ * records the same inactive-error state without a native handle, which
63
+ * reconciliation uses for subscriptions that could not be restarted.
64
+ *
65
+ * Mount-aware poll fallback: on mounts where inotify events are unreliable
66
+ * (FUSE/virtiofs — the HITL transport folder mounts report no child events,
67
+ * so a registered watch would never fire), the port classifies each target
68
+ * through the injected `statfsFn` port and runs a 2.5 s reconcile timer
69
+ * that re-stats the fingerprint and wakes on change. fs.watch stays primary
70
+ * everywhere else; a failing or unsupported statfs keeps fs.watch primary
71
+ * rather than failing the watch.
72
+ *
73
+ * @param {object} ports
74
+ * @param {(path: string, handlers: {onChange: () => void, onError: (error: Error) => void}) => {close: () => void}} ports.watchFactory
75
+ * @param {(path: string) => {mtimeMs: number, size: number}} ports.statFn
76
+ * @param {(path: string) => Buffer | undefined} [ports.readFn] Content reader for
77
+ * signatures and excerpts; returns undefined when the content is not readable
78
+ * (missing target, directory, denied content) and never throws. Defaults to
79
+ * a node:fs read of the whole file.
80
+ * @param {string} [ports.baseDir] Resolution base for relative watch paths.
81
+ * @param {(baseDir: string, path: string) => string} [ports.canonicalizeFn] Path canonicalization; defaults to resolve + realpath.
82
+ * @param {(reconcile: () => void) => unknown} [ports.scheduleReconcile] Schedules
83
+ * the post-registration reconcile pass. Native watcher initialization is
84
+ * asynchronous on some platforms, so a change landing in the registration
85
+ * gap may produce no callback; the reconcile re-stats once and wakes on a
86
+ * missed change. Defaults to a delayed macrotask.
87
+ * @param {(path: string) => {type: string | number}} [ports.statfsFn] Mount
88
+ * classifier for the poll fallback; `type` is the filesystem type (string
89
+ * where the platform reports one, Linux f_type magic number otherwise).
90
+ * Throws when the mount type is unavailable; the watch then keeps fs.watch
91
+ * primary. Defaults to node:fs.statfsSync.
92
+ * @param {number} [ports.pollIntervalMs] Reconcile cadence for targets on
93
+ * mounts with unreliable inotify events. Defaults to 2500.
94
+ * @param {() => void} [ports.onDeactivate] Invoked when a subscription leaves
95
+ * the active state so callers can refresh visible status.
96
+ * @returns {{
97
+ * start: (definition: import("./types.js").WatchDefinition, onWake: (definition: import("./types.js").WatchDefinition, delta: Array<import("./types.js").WatchDelta>) => {delivered: boolean, reason?: string} | undefined) => string,
98
+ * stop: (name: string) => void,
99
+ * stopAll: () => void,
100
+ * fail: (name: string, message: string) => void,
101
+ * errorOf: (name: string) => string | undefined,
102
+ * activeCount: () => number,
103
+ * }}
104
+ */
105
+ const RECONCILE_DELAY_MS = 500;
106
+ // Reconcile cadence for targets on mounts whose inotify events are
107
+ // unreliable: fast enough that an ask+wait turn feels responsive, slow
108
+ // enough that a fingerprint re-stat per subscription stays cheap.
109
+ const POLL_INTERVAL_MS = 2500;
110
+ // Filesystem types whose event delivery cannot be trusted (the HITL
111
+ // transport folder is a virtiofs mount and is deaf to fs.watch).
112
+ const UNRELIABLE_FSTYPES = new Set(["virtiofs", "fuse", "fuseblk", "fusectl"]);
113
+ // Linux f_type magic numbers for the same unreliable family (node:fs.statfs
114
+ // reports the numeric f_type on Linux): VIRTIOFS, FUSE, FUSECTL.
115
+ const UNRELIABLE_FSTYPE_MAGIC = new Set([0x49475649, 0x65735546, 0x65735543]);
116
+ // How far above a not-yet-existing target an ancestor watch may climb
117
+ // (enough for <worktree>/.ace-local/hitl inside any sane worktree layout).
118
+ const MAX_ANCESTOR_CLIMB = 6;
119
+ // Content excerpt capped into a wake delta, in bytes.
120
+ const EXCERPT_BYTES = 200;
121
+ // Fingerprint segment of a target that does not (yet) exist.
122
+ const ABSENT_SEGMENT = "absent";
123
+
124
+ export function createWatchPort({ watchFactory, statFn, readFn = nodeReadFn, baseDir = process.cwd(), canonicalizeFn = canonicalizeWatchPath, resolveTargets = expandInboxWatchTargets, scheduleReconcile = defaultScheduleReconcile, statfsFn = nodeStatfsFn, pollIntervalMs = POLL_INTERVAL_MS, onDeactivate }) {
125
+ /** @type {Map<string, {watchers: Array<{path: string, watchPath: string, watcher: {close: () => void}}>, paths: string[], primaryPath: string, definition: import("./types.js").WatchDefinition, fingerprint: string, previousFingerprint: string, dirty: boolean, poll: boolean, onWake: Function}>} */
126
+ const active = new Map();
127
+ /** @type {Map<string, string>} */
128
+ const errors = new Map();
129
+ /** Cached mount classification per target path (mount types do not flip
130
+ * at interactive timescales; a failed classification is not cached). */
131
+ const mountClassCache = new Map();
132
+ /** @type {import("node:timers").NodeJS.Timeout | null} */
133
+ let pollTimer = null;
134
+
135
+ return {
136
+ /**
137
+ * @returns {string} The canonical absolute path being watched.
138
+ */
139
+ start(definition, onWake) {
140
+ stopExisting(definition.name);
141
+ errors.delete(definition.name);
142
+
143
+ const canonicalPath = canonicalizeFn(baseDir, definition.path);
144
+ // The configured path itself must be readable; expansion targets may
145
+ // legitimately not exist yet (an inbox in a worktree that never
146
+ // received a report), so only the primary is validated strictly.
147
+ readBaseline(canonicalPath);
148
+ const targetPaths = resolveTargetsFor(resolveTargets, baseDir, definition.path, canonicalPath);
149
+ const watchers = targetPaths.flatMap((target) => attachWatcher(target, definition, onWake));
150
+ const paths = watchers.map((entry) => entry.path);
151
+ // A target watched through an ancestor was PROVEN absent just now;
152
+ // its baseline records that fact rather than a re-stat, so a report
153
+ // created between the proof and this recomputation stays a change
154
+ // (the scheduled reconcile below delivers it).
155
+ const provenAbsent = new Set(watchers.filter((each) => each.watchPath !== each.path).map((each) => each.path));
156
+ const fingerprint = aggregateFingerprint(paths, canonicalPath, provenAbsent);
157
+
158
+ active.set(definition.name, {
159
+ watchers,
160
+ paths,
161
+ primaryPath: canonicalPath,
162
+ definition,
163
+ fingerprint,
164
+ previousFingerprint: fingerprint,
165
+ dirty: false,
166
+ poll: paths.some((path) => mountIsUnreliable(path)),
167
+ onWake,
168
+ });
169
+ // Close the registration gap: a change between baseline capture and
170
+ // watcher effectiveness produces no callback, so re-stat once.
171
+ scheduleReconcile(() => handleChange(definition, onWake));
172
+ ensurePollTimer();
173
+ return canonicalPath;
174
+ },
175
+
176
+ stop(name) {
177
+ stopExisting(name);
178
+ errors.delete(name);
179
+ },
180
+
181
+ stopAll() {
182
+ for (const name of [...active.keys()]) {
183
+ stopExisting(name);
184
+ }
185
+ errors.clear();
186
+ },
187
+
188
+ /**
189
+ * Re-check subscriptions after the agent settles (pending markers
190
+ * cleared). Target discovery refreshes for EVERY active subscription:
191
+ * a worktree linked while the session was watching whose first report
192
+ * landed in its own (then-unwatched) inbox produced no watched-path
193
+ * event, so this settlement boundary is the channel that notices it.
194
+ * A subscription whose current state still differs from its last
195
+ * delivered fingerprint fires a fresh wake — no new filesystem event
196
+ * required.
197
+ *
198
+ * @param {(definition: import("./types.js").WatchDefinition) => {delivered: boolean, reason?: string} | undefined} onWake
199
+ */
200
+ flushDirty(onWake) {
201
+ for (const entry of active.values()) {
202
+ // Settlement boundaries refresh target discovery for EVERY active
203
+ // subscription, not only dirty ones: a worktree linked long after
204
+ // start whose first report lands in its own (not yet watched)
205
+ // inbox produces no watched-path event, so this pass is the only
206
+ // channel that can notice the new target and wake.
207
+ refreshAttachments(entry, entry.definition, onWake);
208
+ if (active.get(entry.definition.name)?.definition !== entry.definition) {
209
+ continue;
210
+ }
211
+ const next = currentFingerprint(entry, entry.definition.name);
212
+ if (next === undefined) {
213
+ continue;
214
+ }
215
+ if (next === entry.fingerprint) {
216
+ entry.dirty = false;
217
+ continue;
218
+ }
219
+ const outcome = onWake(entry.definition, deltaOf(entry.fingerprint, next));
220
+ // The fingerprint advances only when the wake actually crossed —
221
+ // a deferred outcome stays dirty for the next settlement boundary.
222
+ if (outcome && outcome.delivered === false) {
223
+ entry.dirty = true;
224
+ continue;
225
+ }
226
+ entry.previousFingerprint = entry.fingerprint;
227
+ entry.fingerprint = next;
228
+ entry.dirty = false;
229
+ }
230
+ },
231
+
232
+ /**
233
+ * Mark a subscription's latest change as undelivered so flushDirty()
234
+ * re-checks it at the next settlement boundary. Used when a wake is
235
+ * deferred by retention or the dispatch serialization window.
236
+ *
237
+ * @param {string} name
238
+ */
239
+ markDirty(name) {
240
+ const entry = active.get(name);
241
+ if (entry) {
242
+ entry.dirty = true;
243
+ }
244
+ },
245
+
246
+ /**
247
+ * A dispatch attempt was never acknowledged (Pi rejected it in async
248
+ * preflight without any lifecycle event): revert each subscription to
249
+ * its last delivered state and mark it dirty, so flushDirty() re-fires
250
+ * the change at the next settlement — no loss, and no duplicate for
251
+ * wakes that were genuinely consumed.
252
+ */
253
+ revertToDelivered(names) {
254
+ for (const name of names) {
255
+ const entry = active.get(name);
256
+ if (!entry) {
257
+ continue;
258
+ }
259
+ entry.fingerprint = entry.previousFingerprint;
260
+ entry.dirty = true;
261
+ }
262
+ },
263
+
264
+ /**
265
+ * Record a visible inactive-error state for a subscription without a
266
+ * native handle (used by reconciliation for watches that cannot restart).
267
+ *
268
+ * @param {string} name
269
+ * @param {string} message
270
+ */
271
+ fail(name, message) {
272
+ stopExisting(name);
273
+ errors.set(name, message);
274
+ },
275
+
276
+ errorOf(name) {
277
+ return errors.get(name);
278
+ },
279
+
280
+ activeCount() {
281
+ return active.size;
282
+ },
283
+ };
284
+
285
+ function stopExisting(name) {
286
+ const entry = active.get(name);
287
+ if (entry !== undefined) {
288
+ for (const { watcher } of entry.watchers) {
289
+ try {
290
+ watcher.close();
291
+ } catch {
292
+ // Closing an already-broken watcher must not mask the original error.
293
+ }
294
+ }
295
+ active.delete(name);
296
+ }
297
+ if (pollTimer !== null && !anyUnreliableActive()) {
298
+ stopPollTimer();
299
+ }
300
+ }
301
+
302
+ /** @returns {boolean} true when any active subscription watches an unreliable mount. */
303
+ function anyUnreliableActive() {
304
+ for (const entry of active.values()) {
305
+ if (entry.poll) {
306
+ return true;
307
+ }
308
+ }
309
+ return false;
310
+ }
311
+
312
+ /** Arm the reconcile timer exactly while an unreliable-mount subscription exists. */
313
+ function ensurePollTimer() {
314
+ if (pollTimer !== null || !anyUnreliableActive()) {
315
+ return;
316
+ }
317
+ pollTimer = setInterval(() => {
318
+ // One reconcile pass re-stats every active subscription's fingerprint;
319
+ // reliable-mount subscriptions compare identical and never wake.
320
+ for (const entry of [...active.values()]) {
321
+ handleChange(entry.definition, entry.onWake);
322
+ }
323
+ if (!anyUnreliableActive()) {
324
+ stopPollTimer();
325
+ }
326
+ }, pollIntervalMs);
327
+ // Never hold the host process open on the extension's behalf.
328
+ pollTimer.unref?.();
329
+ }
330
+
331
+ function stopPollTimer() {
332
+ if (pollTimer !== null) {
333
+ clearInterval(pollTimer);
334
+ pollTimer = null;
335
+ }
336
+ }
337
+
338
+ /**
339
+ * Classify one target path's mount for the poll fallback. Best-effort:
340
+ * an unsupported or failing statfs keeps fs.watch primary instead of
341
+ * failing the watch, and such failures are not cached so a transient
342
+ * error retries on the next pass.
343
+ *
344
+ * @param {string} path
345
+ * @returns {boolean}
346
+ */
347
+ function mountIsUnreliable(path) {
348
+ const cached = mountClassCache.get(path);
349
+ if (cached !== undefined) {
350
+ return cached;
351
+ }
352
+ let unreliable = false;
353
+ try {
354
+ unreliable = mountTypeIsUnreliable(statfsFn(path));
355
+ } catch {
356
+ unreliable = false;
357
+ }
358
+ mountClassCache.set(path, unreliable);
359
+ return unreliable;
360
+ }
361
+
362
+ /** Shared change path for watcher callbacks and the reconcile pass. */
363
+ function handleChange(definition, onWake) {
364
+ const entry = active.get(definition.name);
365
+ // A callback from a removed registration must never touch its
366
+ // replacement: ignore obsolete subscriptions entirely.
367
+ if (entry === undefined || entry.definition !== definition) {
368
+ return;
369
+ }
370
+ refreshAttachments(entry, definition, onWake);
371
+ // Re-attachment failure deactivates the subscription; a deactivated
372
+ // definition must not deliver a wake from the same pass.
373
+ if (active.get(definition.name)?.definition !== definition) {
374
+ return;
375
+ }
376
+ const next = currentFingerprint(entry, definition.name);
377
+ if (next === undefined) {
378
+ return;
379
+ }
380
+ if (next === entry.fingerprint) {
381
+ return;
382
+ }
383
+ const outcome = onWake(definition, deltaOf(entry.fingerprint, next));
384
+ if (outcome && outcome.delivered === false) {
385
+ // Coalesced (a queued wake covers this change) or serialized (held back
386
+ // by the dispatch transition window): the change itself is undelivered,
387
+ // so mark the subscription dirty — flushDirty() re-checks it once the
388
+ // agent settles, without needing another filesystem event.
389
+ entry.dirty = true;
390
+ return;
391
+ }
392
+ entry.previousFingerprint = entry.fingerprint;
393
+ entry.fingerprint = next;
394
+ entry.dirty = false;
395
+ }
396
+
397
+ function deactivate(name, message) {
398
+ stopExisting(name);
399
+ errors.set(name, message);
400
+ try {
401
+ onDeactivate?.();
402
+ } catch {
403
+ // Status rendering must never break the watcher callback.
404
+ }
405
+ }
406
+
407
+ function readBaseline(path) {
408
+ let stats;
409
+ try {
410
+ stats = statFn(path);
411
+ } catch (error) {
412
+ throw new WakeError(`path is not readable: ${path} (${describeError(error)})`);
413
+ }
414
+ return stats;
415
+ }
416
+
417
+ function readBaselineOrError(path, name) {
418
+ try {
419
+ return statFn(path);
420
+ } catch (error) {
421
+ deactivate(name, describeError(error));
422
+ return undefined;
423
+ }
424
+ }
425
+
426
+ /**
427
+ * Resolve the watch targets for one configured path. An injected
428
+ * `resolveTargets` port (the ace-hitl inbox expansion) may return several
429
+ * directories; the validated primary canonical path is always watched,
430
+ * whatever the port returns.
431
+ *
432
+ * @param {((baseDir: string, path: string) => string[]) | undefined} resolveTargets
433
+ * @param {string} baseDir
434
+ * @param {string} configuredPath
435
+ * @param {string} canonicalPath
436
+ * @returns {string[]}
437
+ */
438
+ function resolveTargetsFor(resolveTargets, baseDir, configuredPath, canonicalPath) {
439
+ if (!resolveTargets) {
440
+ return [canonicalPath];
441
+ }
442
+ const expanded = resolveTargets(baseDir, configuredPath)
443
+ .filter((path) => typeof path === "string" && path.length > 0)
444
+ .map((path) => nodePath.resolve(path));
445
+ return [...new Set([canonicalPath, ...expanded])];
446
+ }
447
+
448
+ /**
449
+ * Attach one native watcher for a target. A target that does not exist
450
+ * yet (a worktree inbox with no report so far) is watched through its
451
+ * nearest EXISTING ancestor directory: the ancestor's events re-stat the
452
+ * target, so the first report — which creates the inbox — still wakes.
453
+ * When no ancestor is readable the target is skipped until the next
454
+ * re-registration.
455
+ *
456
+ * @param {string} target
457
+ * @param {import("./types.js").WatchDefinition} definition
458
+ * @param {(definition: import("./types.js").WatchDefinition) => unknown} onWake
459
+ * @returns {Array<{path: string, watchPath: string, watcher: {close: () => void}}>}
460
+ */
461
+ function attachWatcher(target, definition, onWake) {
462
+ const handlers = {
463
+ onChange: () => handleChange(definition, onWake),
464
+ onError: (error) => {
465
+ // A callback from a removed registration must never touch its
466
+ // replacement.
467
+ const entry = active.get(definition.name);
468
+ if (entry === undefined || entry.definition !== definition) {
469
+ return;
470
+ }
471
+ if (target !== entry.primaryPath) {
472
+ // A discovered extra target died (the worktree was pruned):
473
+ // drop only this target's watcher, keep the subscription.
474
+ dropTarget(entry, target);
475
+ return;
476
+ }
477
+ deactivate(definition.name, describeError(error));
478
+ },
479
+ };
480
+ let watchPath = target;
481
+ let attempts = 0;
482
+ for (;;) {
483
+ try {
484
+ statFn(watchPath);
485
+ break;
486
+ } catch {
487
+ if (attempts >= MAX_ANCESTOR_CLIMB || watchPath === nodePath.dirname(watchPath)) {
488
+ return [];
489
+ }
490
+ attempts += 1;
491
+ watchPath = nodePath.dirname(watchPath);
492
+ }
493
+ }
494
+ return [{ path: target, watchPath, watcher: watchFactory(watchPath, handlers) }];
495
+ }
496
+
497
+ /**
498
+ * Re-run target discovery and re-attach targets that were watched
499
+ * through an ancestor because they did not exist yet. Runs on every
500
+ * change/reconcile pass — exactly when a formerly-absent target may
501
+ * have appeared, and when a worktree linked AFTER registration may have
502
+ * become discoverable: discovery (the ace-hitl inbox expansion over
503
+ * `git worktree list`) no longer runs only at registration, so a task
504
+ * worktree created while the session is already watching gets its
505
+ * inbox watched without any re-registration or reload.
506
+ *
507
+ * Linux inotify is non-recursive, so an ancestor's watch never sees a
508
+ * SECOND report created inside the now-existing target; once the target
509
+ * exists, close the ancestor watch and watch the real target directly.
510
+ *
511
+ * A newly discovered target that does not exist yet joins the baseline
512
+ * fingerprint as its PROVEN "absent" state (never a re-stat, so a report
513
+ * created between the discovery stat and the baseline recomputation stays
514
+ * a pending change) and a follow-up reconcile pass — mirroring start() —
515
+ * delivers any change that landed in the attach gap; its later appearance
516
+ * (the first report) is the change that wakes, not its discovery.
517
+ *
518
+ * @param {{watchers: Array<{path: string, watchPath: string, watcher: {close: () => void}}>, paths: string[], primaryPath: string, fingerprint: string, previousFingerprint: string}} entry
519
+ * @param {import("./types.js").WatchDefinition} definition
520
+ * @param {(definition: import("./types.js").WatchDefinition) => unknown} onWake
521
+ */
522
+ function refreshAttachments(entry, definition, onWake) {
523
+ refreshDiscoveredTargets(entry, definition, onWake);
524
+ if (active.get(definition.name)?.definition !== definition) {
525
+ return;
526
+ }
527
+ for (let index = 0; index < entry.watchers.length; index += 1) {
528
+ const attached = entry.watchers[index];
529
+ if (attached.watchPath === attached.path) {
530
+ continue;
531
+ }
532
+ try {
533
+ statFn(attached.path);
534
+ } catch {
535
+ // Still absent: the ancestor watch remains the only channel.
536
+ continue;
537
+ }
538
+ try {
539
+ // Attach the replacement BEFORE closing the old watcher:
540
+ // watchFactory can throw synchronously (EMFILE, EPERM, stat race),
541
+ // and handleChange runs inside native watcher callbacks — an
542
+ // uncaught throw here would crash the extension host. On failure
543
+ // the documented runtime-error posture applies: deactivate and
544
+ // never retry.
545
+ const replacement = attachWatcher(attached.path, definition, onWake);
546
+ if (replacement.length > 0) {
547
+ try {
548
+ attached.watcher.close();
549
+ } catch {
550
+ // Closing an already-broken watcher must not block re-attachment.
551
+ }
552
+ entry.watchers[index] = replacement[0];
553
+ }
554
+ } catch (error) {
555
+ deactivate(definition.name, `re-attachment failed: ${describeError(error)}`);
556
+ return;
557
+ }
558
+ }
559
+ }
560
+
561
+ /**
562
+ * Re-run target discovery for one subscription and attach watchers for
563
+ * targets that appeared since the last pass (a worktree linked after
564
+ * registration, whose inbox the registration-time expansion could not
565
+ * have seen). A new target that already exists keeps the OLD baseline
566
+ * fingerprint, so the pass's fingerprint comparison delivers the wake
567
+ * the missed event never could; a new target that is still absent joins
568
+ * the baseline as its stable "absent" state, so only its later
569
+ * appearance (the first report) wakes.
570
+ *
571
+ * @param {{watchers: Array<{path: string, watchPath: string, watcher: {close: () => void}}>, paths: string[], primaryPath: string, fingerprint: string, previousFingerprint: string}} entry
572
+ * @param {import("./types.js").WatchDefinition} definition
573
+ * @param {(definition: import("./types.js").WatchDefinition) => unknown} onWake
574
+ */
575
+ function refreshDiscoveredTargets(entry, definition, onWake) {
576
+ if (!resolveTargets) {
577
+ return;
578
+ }
579
+ const targetPaths = resolveTargetsFor(resolveTargets, baseDir, definition.path, entry.primaryPath);
580
+ let attachedAny = false;
581
+ for (const target of targetPaths) {
582
+ if (entry.watchers.some((attached) => attached.path === target)) {
583
+ continue;
584
+ }
585
+ let attachedWatcher;
586
+ try {
587
+ attachedWatcher = attachWatcher(target, definition, onWake);
588
+ } catch (error) {
589
+ deactivate(definition.name, `re-attachment failed: ${describeError(error)}`);
590
+ return;
591
+ }
592
+ if (attachedWatcher.length === 0) {
593
+ // No readable ancestor yet: retried on the next pass.
594
+ continue;
595
+ }
596
+ entry.watchers.push(attachedWatcher[0]);
597
+ entry.paths = entry.watchers.map((each) => each.path);
598
+ attachedAny = true;
599
+ if (mountIsUnreliable(target) && !entry.poll) {
600
+ // A newly discovered target sits on an unreliable mount: the whole
601
+ // subscription joins the poll fallback.
602
+ entry.poll = true;
603
+ ensurePollTimer();
604
+ }
605
+ if (attachedWatcher[0].watchPath !== target) {
606
+ // The baseline records the "absent" state PROVEN by the ancestor
607
+ // climb, never a re-stat: a report created between the two must
608
+ // remain a pending change. The follow-up reconcile scheduled
609
+ // below delivers it without waiting for another event.
610
+ // A primary deleted moments earlier (the throwaway project of a
611
+ // finished session torn down, a pruned worktree root) makes this
612
+ // computation throw: this pass runs inside watcher event
613
+ // callbacks, so the throw is routed through the documented
614
+ // deactivation path instead of escaping into the event loop as
615
+ // an uncaught ENOENT.
616
+ try {
617
+ const baseline = aggregateFingerprint(entry.paths, entry.primaryPath, new Set([target]));
618
+ entry.previousFingerprint = baseline;
619
+ entry.fingerprint = baseline;
620
+ } catch (error) {
621
+ deactivate(definition.name, `re-attachment failed: ${describeError(error)}`);
622
+ return;
623
+ }
624
+ }
625
+ }
626
+ if (attachedAny) {
627
+ // Mirror start(): a change landing in the attach gap (the race
628
+ // above, or watcher initialization latency on the new target)
629
+ // produces no callback, so re-stat once.
630
+ scheduleReconcile(() => handleChange(definition, onWake));
631
+ }
632
+ }
633
+
634
+ /**
635
+ * Drop the watcher of one discovered extra target after a runtime error
636
+ * (typically `ace-overseer prune` removing the worktree): only that
637
+ * target's handle closes; the subscription stays active for the primary
638
+ * and every other worktree. The target's disappearance is absorbed into
639
+ * the baseline — a pruned worktree is not a report and must not wake.
640
+ *
641
+ * The baselines are adjusted by removing ONLY the dropped target's
642
+ * segment from the recorded fingerprint strings. Re-statting every
643
+ * remaining target would fold an undelivered change (a deferred wake
644
+ * left the fingerprint at its pre-change state) into the baseline,
645
+ * silently losing the pending report; the segment removal keeps every
646
+ * remaining target at its recorded state, so the settlement boundary
647
+ * still delivers it. It also never touches the filesystem at all, so a
648
+ * primary deleted moments earlier cannot throw from inside the native
649
+ * onError callback (the primary's own watcher error deactivates the
650
+ * subscription through the documented path).
651
+ *
652
+ * @param {{watchers: Array<{path: string, watchPath: string, watcher: {close: () => void}}>, paths: string[], primaryPath: string, fingerprint: string, previousFingerprint: string}} entry
653
+ * @param {string} target
654
+ */
655
+ function dropTarget(entry, target) {
656
+ const remaining = [];
657
+ for (const attached of entry.watchers) {
658
+ if (attached.path === target) {
659
+ try {
660
+ attached.watcher.close();
661
+ } catch {
662
+ // Closing an already-broken watcher must not mask the original error.
663
+ }
664
+ } else {
665
+ remaining.push(attached);
666
+ }
667
+ }
668
+ if (remaining.length === entry.watchers.length) {
669
+ return;
670
+ }
671
+ entry.watchers = remaining;
672
+ entry.paths = remaining.map((each) => each.path);
673
+ // The `=` terminator makes the prefix match the segment of exactly this
674
+ // target, never a target whose path merely extends it.
675
+ const droppedPrefix = `${target}=`;
676
+ entry.fingerprint = withoutSegment(entry.fingerprint, droppedPrefix);
677
+ entry.previousFingerprint = withoutSegment(entry.previousFingerprint, droppedPrefix);
678
+ }
679
+
680
+ /**
681
+ * Remove one target's `path=fingerprint` segment from an aggregate
682
+ * fingerprint string, keeping every other segment verbatim.
683
+ *
684
+ * @param {string} fingerprint
685
+ * @param {string} segmentPrefix The exact `path=` prefix of the removed target.
686
+ * @returns {string}
687
+ */
688
+ function withoutSegment(fingerprint, segmentPrefix) {
689
+ return fingerprint
690
+ .split("|")
691
+ .filter((segment) => !segment.startsWith(segmentPrefix))
692
+ .join("|");
693
+ }
694
+
695
+ /**
696
+ * Current aggregate fingerprint of a subscription. An unreadable
697
+ * PRIMARY deactivates the subscription (the configured path itself is
698
+ * the contract); an unreadable extra target is a stable "absent" state
699
+ * that changes to a real fingerprint once the target appears.
700
+ *
701
+ * @param {{paths: string[], primaryPath: string}} entry
702
+ * @param {string} name
703
+ * @returns {string | undefined}
704
+ */
705
+ function currentFingerprint(entry, name) {
706
+ try {
707
+ return aggregateFingerprint(entry.paths, entry.primaryPath);
708
+ } catch (error) {
709
+ deactivate(name, describeError(error));
710
+ return undefined;
711
+ }
712
+ }
713
+
714
+ /**
715
+ * @param {string[]} paths sorted target paths
716
+ * @param {string} primaryPath
717
+ * @param {Set<string>} [forcedAbsent] Targets recorded as "absent" without
718
+ * re-statting — the proven-at-discovery baseline of a newly attached
719
+ * target, so a report created in the stat/recompute gap stays a change.
720
+ * @returns {string}
721
+ */
722
+ function aggregateFingerprint(paths, primaryPath, forcedAbsent) {
723
+ return [...paths]
724
+ .sort()
725
+ .map((path) => {
726
+ if (forcedAbsent?.has(path)) {
727
+ return `${path}=${ABSENT_SEGMENT}`;
728
+ }
729
+ if (path === primaryPath) {
730
+ return `${path}=${fingerprintOf(statFn(path), readFn(path))}`;
731
+ }
732
+ try {
733
+ return `${path}=${fingerprintOf(statFn(path), readFn(path))}`;
734
+ } catch {
735
+ return `${path}=${ABSENT_SEGMENT}`;
736
+ }
737
+ })
738
+ .join("|");
739
+ }
740
+
741
+ /**
742
+ * Build the wake delta for one delivered-fingerprint transition: every
743
+ * target whose signature changed, described by path, change kind, and
744
+ * BEFORE→AFTER signatures, plus a capped sanitized content excerpt when the
745
+ * target's content is readable.
746
+ *
747
+ * @param {string} before aggregate fingerprint of the last delivered state
748
+ * @param {string} after current aggregate fingerprint
749
+ * @returns {Array<import("./types.js").WatchDelta>}
750
+ */
751
+ function deltaOf(before, after) {
752
+ const previous = segmentMap(before);
753
+ const deltas = [];
754
+ for (const [path, next] of segmentMap(after)) {
755
+ const prior = previous.get(path);
756
+ if (prior === next) {
757
+ continue;
758
+ }
759
+ const delta = {
760
+ path,
761
+ kind: changeKind(prior, next),
762
+ before: prior ?? ABSENT_SEGMENT,
763
+ after: next,
764
+ };
765
+ if (next !== ABSENT_SEGMENT) {
766
+ const content = readFn(path);
767
+ if (content !== undefined) {
768
+ delta.excerpt = excerptOf(content);
769
+ }
770
+ }
771
+ deltas.push(delta);
772
+ }
773
+ return deltas;
774
+ }
775
+ }
776
+
777
+ /**
778
+ * Default reconcile scheduler: a delayed macrotask after registration, timed
779
+ * to land once the native watcher is effective. Never holds the process open.
780
+ *
781
+ * @param {() => void} reconcile
782
+ */
783
+ export function defaultScheduleReconcile(reconcile) {
784
+ const timer = setTimeout(reconcile, RECONCILE_DELAY_MS);
785
+ timer.unref?.();
786
+ return timer;
787
+ }
788
+
789
+ /**
790
+ * @param {{type: string | number}} statsfs
791
+ * @returns {boolean}
792
+ */
793
+ function mountTypeIsUnreliable(statsfs) {
794
+ const type = statsfs?.type;
795
+ if (typeof type === "string") {
796
+ return UNRELIABLE_FSTYPES.has(type.toLowerCase());
797
+ }
798
+ if (typeof type === "number") {
799
+ return UNRELIABLE_FSTYPE_MAGIC.has(type);
800
+ }
801
+ return false;
802
+ }
803
+
804
+ /**
805
+ * Default statfsFn over node:fs. Reports the mount's filesystem type for the
806
+ * poll-fallback classification; throws when the platform has no statfs (the
807
+ * watch then keeps fs.watch primary).
808
+ *
809
+ * @param {string} path
810
+ * @returns {{type: string | number}}
811
+ */
812
+ export function nodeStatfsFn(path) {
813
+ const stats = nodeFs.statfsSync(path);
814
+ return { type: stats.type };
815
+ }
816
+
817
+ /**
818
+ * Split an aggregate fingerprint into its per-target `path=signature` map.
819
+ *
820
+ * @param {string} fingerprint
821
+ * @returns {Map<string, string>}
822
+ */
823
+ function segmentMap(fingerprint) {
824
+ return new Map(fingerprint.split("|").map((segment) => {
825
+ const at = segment.indexOf("=");
826
+ return [segment.slice(0, at), segment.slice(at + 1)];
827
+ }));
828
+ }
829
+
830
+ /**
831
+ * @param {string | undefined} before previous signature (undefined = never seen)
832
+ * @param {string} after current signature
833
+ * @returns {"created" | "modified" | "deleted"}
834
+ */
835
+ function changeKind(before, after) {
836
+ if (after === ABSENT_SEGMENT) {
837
+ return "deleted";
838
+ }
839
+ return before === undefined || before === ABSENT_SEGMENT ? "created" : "modified";
840
+ }
841
+
842
+ /**
843
+ * First bytes of the content as one sanitized single line: control characters
844
+ * are dropped and whitespace runs (newlines, tabs) collapse to single spaces.
845
+ *
846
+ * @param {Buffer} content
847
+ * @returns {string}
848
+ */
849
+ function excerptOf(content) {
850
+ const answer = answerExcerpt(content);
851
+ if (answer !== undefined) {
852
+ return answer;
853
+ }
854
+ const head = content.subarray(0, EXCERPT_BYTES).toString("utf8");
855
+ return head
856
+ .replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F]/g, "")
857
+ .replace(/\s+/g, " ")
858
+ .trim();
859
+ }
860
+
861
+ /**
862
+ * The `## Answer` section of an answered HITL case, sanitized to one line:
863
+ * an ask wake must carry the answer content, not the file's frontmatter
864
+ * head. Returns undefined when the content carries no answer section (the
865
+ * head excerpt applies).
866
+ *
867
+ * @param {Buffer} content
868
+ * @returns {string | undefined}
869
+ */
870
+ function answerExcerpt(content) {
871
+ const match = content.toString("utf8").match(/^## Answer\s*\n([\s\S]*?)(?=\n## |\s*$)/m);
872
+ const text = match?.[1]?.trim();
873
+ if (!text) {
874
+ return undefined;
875
+ }
876
+ const head = text.slice(0, EXCERPT_BYTES);
877
+ return `answer: ${head
878
+ .replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F]/g, "")
879
+ .replace(/\s+/g, " ")
880
+ .trim()}`;
881
+ }
882
+
883
+ /**
884
+ * Change signature of one target. Readable content signs a hash of the bytes,
885
+ * so a content-only rewrite (identical bytes, fresh mtime) is not a change;
886
+ * when the content is not readable (directory, denied file) the stat pair
887
+ * remains the signature.
888
+ *
889
+ * @param {{mtimeMs: number, size: number}} stats
890
+ * @param {Buffer | undefined} content
891
+ * @returns {string}
892
+ */
893
+ function fingerprintOf(stats, content) {
894
+ if (content !== undefined) {
895
+ return `sha256:${createHash("sha256").update(content).digest("hex")}`;
896
+ }
897
+ return `${stats.mtimeMs}:${stats.size}`;
898
+ }
899
+
900
+ /**
901
+ * @param {unknown} error
902
+ * @returns {string}
903
+ */
904
+ function describeError(error) {
905
+ return error instanceof Error ? error.message : String(error);
906
+ }
907
+
908
+ /**
909
+ * Default watchFactory over node:fs.watch.
910
+ *
911
+ * Directory targets are watched directly and accept any child event: on
912
+ * Linux (inotify) a child file's creation is delivered only to a watch on
913
+ * the directory itself, never to a watch on its parent. The mtime/size
914
+ * fingerprint comparison filters noise, so accepting every event is safe.
915
+ *
916
+ * File targets keep watching the parent directory and filter events to the
917
+ * target's basename: watching the file itself keeps following the original
918
+ * inode, so atomic replacement (write-to-temporary + rename) would silently
919
+ * detach the subscription after one change.
920
+ *
921
+ * @param {string} path Canonical path of the watched file or directory.
922
+ * @param {{onChange: () => void, onError: (error: Error) => void}} handlers
923
+ * @returns {{close: () => void}}
924
+ */
925
+ export function nodeWatchFactory(path, handlers) {
926
+ if (nodeFs.statSync(path).isDirectory()) {
927
+ const watcher = nodeFs.watch(path, { persistent: false }, () => {
928
+ // Any child event is checked: the fingerprint comparison filters
929
+ // non-changes, including nested relative paths some platforms report.
930
+ handlers.onChange();
931
+ });
932
+ watcher.on("error", (error) => handlers.onError(error));
933
+ watcher.unref?.();
934
+ return watcher;
935
+ }
936
+ const directory = nodePath.dirname(path);
937
+ const target = nodePath.basename(path);
938
+ const watcher = nodeFs.watch(
939
+ directory,
940
+ { persistent: false },
941
+ (_eventType, filename) => {
942
+ // filename is null on some platforms; the fingerprint comparison
943
+ // filters non-changes, so a null event is checked rather than trusted.
944
+ if (filename !== null && filename !== target) {
945
+ return;
946
+ }
947
+ handlers.onChange();
948
+ },
949
+ );
950
+ watcher.on("error", (error) => handlers.onError(error));
951
+ // Never hold the host process open on the extension's behalf.
952
+ watcher.unref?.();
953
+ return watcher;
954
+ }
955
+
956
+ /**
957
+ * Default statFn over node:fs. Checks read access explicitly: stat metadata
958
+ * alone is readable for files whose contents are not (mode 000), and a watch
959
+ * on an unreadable file could never deliver meaningful wakes.
960
+ *
961
+ * @param {string} path
962
+ * @returns {{mtimeMs: number, size: number}}
963
+ */
964
+ export function nodeStatFn(path) {
965
+ nodeFs.accessSync(path, nodeFs.constants.R_OK);
966
+ const stats = nodeFs.statSync(path);
967
+ return { mtimeMs: stats.mtimeMs, size: stats.size };
968
+ }
969
+
970
+ /**
971
+ * Default readFn over node:fs. Returns the file's bytes, or undefined when
972
+ * the content is not readable (missing target, directory, denied content):
973
+ * signature and excerpt reads must never fail a watch that stat validates.
974
+ *
975
+ * @param {string} path
976
+ * @returns {Buffer | undefined}
977
+ */
978
+ export function nodeReadFn(path) {
979
+ try {
980
+ return nodeFs.readFileSync(path);
981
+ } catch {
982
+ return undefined;
983
+ }
984
+ }