agent-yes 1.169.0 → 1.170.1

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/{SUPPORTED_CLIS-D1WUoZA9.js → SUPPORTED_CLIS-ByppVvWO.js} +2 -2
  2. package/dist/SUPPORTED_CLIS-C_c097RZ.js +8 -0
  3. package/dist/{agent-yes.config-Bl08kMpl.js → agent-yes.config-ugPYTPOd.js} +2 -2
  4. package/dist/{agentYesHome-_eJa3DaX.js → agentYesHome-CtHb5b71.js} +1 -1
  5. package/dist/cli.js +10 -212
  6. package/dist/{configShared-CEyhl0WH.js → configShared-0MnIQ652.js} +1 -1
  7. package/dist/{e2e-BeKjLhmO.js → e2e-jb0Hp43q.js} +1 -1
  8. package/dist/{forkNested-UCKPEgSI.js → forkNested-DhJxa4q4.js} +2 -2
  9. package/dist/{globalPidIndex-DlmmJlO8.js → globalPidIndex-CoNr7tS8.js} +1 -1
  10. package/dist/index.js +4 -4
  11. package/dist/notifyDaemon-D5jtpCAR.js +591 -0
  12. package/dist/{openBrowser-CCF1iuQK.js → openBrowser-ChR4llYa.js} +1 -1
  13. package/dist/{pidStore-BfoBWUjc.js → pidStore-BIvsBQ8X.js} +3 -3
  14. package/dist/pidStore-CLxwp0qI.js +5 -0
  15. package/dist/{reaper-C-eWAxIj.js → reaper-CWF2_ATd.js} +27 -3
  16. package/dist/{reaper-BQgDb85W.js → reaper-jfSmog15.js} +1 -1
  17. package/dist/{remotes-cx_GDFPj.js → remotes-BQMr4_En.js} +3 -3
  18. package/dist/{remotes-BmAPylU_.js → remotes-CC-4GuJb.js} +3 -3
  19. package/dist/{runningLock-BobVW1_A.js → runningLock-CNMl13dC.js} +1 -1
  20. package/dist/rustBinary-DsO0IlNq.js +228 -0
  21. package/dist/{schedule-DTHFMzDj.js → schedule-ChTPIcXf.js} +7 -7
  22. package/dist/{serve-D-joIHrL.js → serve-De1gAWNI.js} +120 -26
  23. package/dist/{setup-K6VTmirE.js → setup-nlMqyEPB.js} +3 -3
  24. package/dist/{share-D_e2Fwiy.js → share-QregR8a_.js} +2 -2
  25. package/dist/spawnGate-BFhva-2F.js +5 -0
  26. package/dist/{spawnGate-DzPfa1PZ.js → spawnGate-IJDByl2U.js} +3 -3
  27. package/dist/{subcommands-BZPW0UVn.js → subcommands-BFlpV23b.js} +1049 -44
  28. package/dist/subcommands-DLDpwrfG.js +9 -0
  29. package/dist/{tray-CWUpaZF4.js → tray-DXr7iK3E.js} +2 -2
  30. package/dist/{ts-DeahYDN3.js → ts-DsxejviJ.js} +10 -10
  31. package/dist/{versionChecker-C7KP-aoh.js → versionChecker-Czk3aHqy.js} +2 -2
  32. package/dist/{webrtcLink-BG0Xc4-W.js → webrtcLink-B7REGtK2.js} +2 -2
  33. package/dist/{webrtcRemote-SybKurg9.js → webrtcRemote-Bx-eD_0I.js} +3 -3
  34. package/dist/{workspaceConfig-BC03X4Y1.js → workspaceConfig-BgqK-31W.js} +37 -6
  35. package/lab/ui/console-logic.js +79 -10
  36. package/lab/ui/index.html +174 -8
  37. package/lab/ui/landing.html +11 -1
  38. package/package.json +1 -1
  39. package/scripts/build-rs.ts +9 -5
  40. package/ts/badges.spec.ts +51 -1
  41. package/ts/badges.ts +35 -1
  42. package/ts/index.ts +13 -4
  43. package/ts/notifyDaemon.spec.ts +276 -0
  44. package/ts/notifyDaemon.ts +698 -0
  45. package/ts/notifyInbox.spec.ts +192 -0
  46. package/ts/notifyInbox.ts +294 -0
  47. package/ts/notifyRouter.spec.ts +355 -0
  48. package/ts/notifyRouter.ts +288 -0
  49. package/ts/notifyStore.spec.ts +308 -0
  50. package/ts/notifyStore.ts +439 -0
  51. package/ts/reaper.spec.ts +18 -1
  52. package/ts/reaper.ts +33 -1
  53. package/ts/rustBinary.ts +30 -0
  54. package/ts/serve.ts +180 -14
  55. package/ts/subcommands.spec.ts +341 -0
  56. package/ts/subcommands.ts +764 -29
  57. package/ts/workspaceConfig.spec.ts +48 -0
  58. package/ts/workspaceConfig.ts +46 -3
  59. package/dist/SUPPORTED_CLIS-BFV10E3f.js +0 -8
  60. package/dist/pidStore-5qyLX3Fh.js +0 -5
  61. package/dist/spawnGate-CQ1Il3Xk.js +0 -5
  62. package/dist/subcommands-DuGwtmz5.js +0 -9
@@ -0,0 +1,698 @@
1
+ /**
2
+ * `ay notifyd` — the always-on-while-watched detection engine. Polls every
3
+ * WATCHED agent's live state (the runtime-agnostic query layer, so BOTH Rust and
4
+ * TS children are covered), runs the pure debounce router (`notifyRouter.ts`),
5
+ * and appends the decided edges — enriched with a payload the parent can act on
6
+ * without tailing — into each parent's inbox (`notifyStore.ts`).
7
+ *
8
+ * Lifecycle: a host singleton, guarded by an mkdir lock that records its owner
9
+ * pid so a crashed daemon's stale lock is detected and STOLEN (never a permanent
10
+ * deadlock). Scope + liveness are driven by the WATCHER REGISTRY: the daemon only
11
+ * processes children whose parent is currently running `ay notify watch` (so an
12
+ * unrelated agent never gets an inbox), and it stays alive as long as any watcher
13
+ * heartbeat is live, self-exiting only after a grace window with no watchers.
14
+ * `ay notify watch` re-ensures the daemon every poll, so a parent that watches
15
+ * BEFORE spawning children (or across a fan-out gap) always has a live daemon.
16
+ */
17
+
18
+ import { execFile } from "node:child_process";
19
+ import { randomUUID } from "node:crypto";
20
+ import { mkdir, readFile, rename, rm, stat, writeFile } from "fs/promises";
21
+ import {
22
+ type ChildObservation,
23
+ type RouterState,
24
+ stepRouter,
25
+ } from "./notifyRouter.ts";
26
+ import {
27
+ type NotifyEvent,
28
+ daemonLockDir,
29
+ daemonLockOwnerPath,
30
+ notifyDir,
31
+ } from "./notifyInbox.ts";
32
+ import {
33
+ appendEvent,
34
+ gcInboxes,
35
+ hostId,
36
+ listInboxParents,
37
+ liveWatchers,
38
+ readInbox,
39
+ shouldStealLock,
40
+ } from "./notifyStore.ts";
41
+ import { deriveLiveState, isPidAlive, listRecords, renderLogTailLines } from "./subcommands.ts";
42
+ import { logger } from "./logger.ts";
43
+
44
+ const POLL_MS = 2000;
45
+ const GC_EVERY_TICKS = 30; // ~every 60s
46
+ const IDLE_EXIT_GRACE_MS = 60_000; // exit if no watcher this long
47
+
48
+ const LS_OPTS = { all: true, active: false, json: false, latest: false, cwdScope: null } as const;
49
+
50
+ /** Short git HEAD of a cwd, best-effort, never blocks emission. */
51
+ function gitHead(cwd: string): Promise<string | null> {
52
+ return new Promise((resolve) => {
53
+ execFile(
54
+ "git",
55
+ ["-C", cwd, "rev-parse", "--short", "HEAD"],
56
+ { timeout: 1500, windowsHide: true },
57
+ (err, stdout) => resolve(err ? null : stdout.trim() || null),
58
+ );
59
+ });
60
+ }
61
+
62
+ /** Last few rendered log lines, compacted, so the parent needn't tail. */
63
+ async function recentTail(logFile: string | null | undefined): Promise<string | null> {
64
+ if (!logFile) return null;
65
+ const lines = await renderLogTailLines(logFile, 12).catch(() => null);
66
+ if (!lines) return null;
67
+ const compact = lines
68
+ .map((l) => l.replace(/\s+$/, ""))
69
+ .filter((l) => l.trim())
70
+ .slice(-8);
71
+ return compact.length ? compact.join("\n").slice(0, 1200) : null;
72
+ }
73
+
74
+ /**
75
+ * Seed the router's prior state from what each inbox has ALREADY recorded, so a
76
+ * daemon restart does not re-emit a baseline the parent already saw.
77
+ *
78
+ * pid-reuse guard: seed a child ONLY if it is still in the registry with the
79
+ * SAME start time as the inbox recorded (`liveChildren`: pid → started_at). A pid
80
+ * whose live start time differs is a NEW child recycling the pid — seeding it
81
+ * would suppress its first notification, so we skip it (it starts fresh). A pid
82
+ * absent from the registry (reaped) is skipped too: it won't be observed again,
83
+ * and if the pid is later reused, that new child was never seeded.
84
+ */
85
+ export async function reconcileFromInboxes(
86
+ host: string,
87
+ liveChildren: Map<number, number>,
88
+ watchedParents: Set<number>,
89
+ ): Promise<RouterState> {
90
+ const state: RouterState = new Map();
91
+ for (const parent of await listInboxParents(host)) {
92
+ // Scope to CURRENTLY-watched parents only. Seeding an unwatched parent's
93
+ // inbox would let this daemon carry its children forward and later write a
94
+ // synthetic exited into an inbox nobody is watching — violating "nothing
95
+ // happens unless you watch".
96
+ if (!watchedParents.has(parent)) continue;
97
+ const events = await readInbox(host, parent);
98
+ const lastByChild = new Map<number, NotifyEvent>();
99
+ for (const e of events) lastByChild.set(e.child_pid, e);
100
+ for (const [childPid, last] of lastByChild) {
101
+ const liveStarted = liveChildren.get(childPid);
102
+ if (liveStarted === undefined) continue; // reaped / gone — nothing to suppress
103
+ // Seed ONLY on a positively-matched identity. An event with no/zero
104
+ // child_started_at can't be verified, so we don't seed it — better to risk
105
+ // one duplicate edge than to suppress a recycled pid's first notification.
106
+ if (!last.child_started_at || liveStarted !== last.child_started_at) continue;
107
+ const seededState =
108
+ last.edge === "exited"
109
+ ? "stopped"
110
+ : last.edge === "needs_input"
111
+ ? "needs_input"
112
+ : last.edge === "idle"
113
+ ? "idle"
114
+ : "active";
115
+ state.set(childPid, {
116
+ parent_pid: last.parent_pid,
117
+ wrapper_pid: last.child_wrapper_pid,
118
+ // Carry BOTH start times into the seeded state, so the hot-path pid-reuse
119
+ // guard can fire on the next observation and a synthetic exited from this
120
+ // seed still stamps identity (else the guard is blind until re-observed).
121
+ started_at: last.child_started_at,
122
+ parent_started_at: last.parent_started_at,
123
+ cli: last.cli,
124
+ cwd: last.cwd,
125
+ state: seededState,
126
+ // "Never suppress, duplicate OK": do NOT seed the idle/needs_input emitted
127
+ // memory. If notifyd was down while a child went idle→active→idle, the new
128
+ // idle episode (the "probably done" signal — this feature's whole point)
129
+ // must NOT be swallowed as the "same" pre-restart episode. So a restart
130
+ // RE-CONFIRMS idle/needs_input as a fresh episode (a duplicate on the wire
131
+ // is acceptable; a lost edge is not). Only `exited` — a terminal state
132
+ // that can't recur for the same child — keeps its emitted flag to avoid a
133
+ // pointless duplicate. Identity (start times) is still seeded so the
134
+ // hot-path pid-reuse guard works on the next observation.
135
+ idleSince: null,
136
+ idleEmitted: false,
137
+ inNeedsInput: false,
138
+ needsInputQuestion: null,
139
+ // Identity-aware: `last` already matched this incarnation's start time, so
140
+ // exitedEmitted is true ONLY if THIS child's last recorded edge is exited.
141
+ // A pid reused by a NEW child (whose last edge is e.g. idle) does NOT
142
+ // inherit the prior incarnation's exit — its own exit still fires.
143
+ exitedEmitted: last.edge === "exited",
144
+ });
145
+ }
146
+ }
147
+ return state;
148
+ }
149
+
150
+ interface ObserveResult {
151
+ obs: ChildObservation[];
152
+ /** child pid → the parent's started_at, for the pid-reuse guard. */
153
+ parentStartedAt: Map<number, number>;
154
+ /** child pid → the child's OWN started_at, stamped into events (reuse guard). */
155
+ childStartedAt: Map<number, number>;
156
+ /** log file per child pid, for payload enrichment. */
157
+ logFiles: Map<number, string | null>;
158
+ /**
159
+ * pid → started_at for EVERY currently-alive child in the registry (regardless
160
+ * of whether its parent is watching) — so the router can tell a truly-dead (or
161
+ * pid-reused) child from one it merely stopped observing when a watcher lapsed,
162
+ * identity-aware.
163
+ */
164
+ aliveChildStartedAt: Map<number, number>;
165
+ watcherCount: number;
166
+ }
167
+
168
+ /** Observe every child WHOSE PARENT IS WATCHING. Nothing else gets an inbox. */
169
+ async function observeChildren(): Promise<ObserveResult> {
170
+ const watching = await liveWatchers();
171
+ const records = await listRecords(undefined, LS_OPTS);
172
+ // Parent wrapper pid → its own started_at (for the pid-reuse stamp).
173
+ const startedAtByWrapper = new Map<number, number>();
174
+ for (const r of records) {
175
+ if (typeof r.wrapper_pid === "number" && r.wrapper_pid > 0)
176
+ startedAtByWrapper.set(r.wrapper_pid, r.started_at);
177
+ }
178
+ const obs: ChildObservation[] = [];
179
+ const parentStartedAt = new Map<number, number>();
180
+ const childStartedAt = new Map<number, number>();
181
+ const logFiles = new Map<number, string | null>();
182
+ // Liveness of ALL children (any parent), pid → started_at — the router uses
183
+ // this to avoid false-exiting a child it merely stopped observing (watcher
184
+ // lapsed), while still exiting a pid reused by a different child. Only a CURRENT
185
+ // (non-exited) record whose pid is alive counts as liveness evidence: a stale
186
+ // `exited` record must not vouch for a pid that a different process now reuses
187
+ // (which would make the router suppress the old child's exited forever).
188
+ const aliveChildStartedAt = new Map<number, number>();
189
+ for (const r of records) {
190
+ if (
191
+ typeof r.parent_pid === "number" &&
192
+ r.parent_pid > 0 &&
193
+ r.status !== "exited" &&
194
+ isPidAlive(r.pid)
195
+ )
196
+ aliveChildStartedAt.set(r.pid, r.started_at);
197
+ }
198
+ for (const r of records) {
199
+ const parent = r.parent_pid;
200
+ if (typeof parent !== "number" || parent <= 0) continue;
201
+ if (!watching.has(parent)) continue; // scope: only watched parents' children
202
+ // Cross-session guard: a child cannot predate its parent, so a child of THIS
203
+ // watcher incarnation must have started at/after it. Exclude a stale orphan
204
+ // spawned under a PRIOR agent that held this pid (now recycled by the current
205
+ // watcher) — otherwise its state/tail/question would be mis-delivered to an
206
+ // unrelated session. (The child record carries parent_pid but not the parent's
207
+ // start time, so we rely on the invariant child.started_at >= parent.started_at;
208
+ // pids are unique among live processes, so pid-match + this bound pins the
209
+ // exact incarnation.)
210
+ const watcherStart = watching.get(parent) ?? 0;
211
+ if (watcherStart > 0 && r.started_at < watcherStart) continue;
212
+ const { state, question } = await deriveLiveState(r);
213
+ // Parent start time: prefer the WATCHER's self-reported value (authoritative,
214
+ // never 0) over a registry-wrapper lookup that can miss and stamp a 0 — a 0
215
+ // would slip past the reader's parent pid-reuse guard.
216
+ const parentStart = watching.get(parent) || startedAtByWrapper.get(parent) || 0;
217
+ obs.push({
218
+ pid: r.pid,
219
+ wrapper_pid: r.wrapper_pid ?? undefined,
220
+ started_at: r.started_at,
221
+ parent_pid: parent,
222
+ parent_started_at: parentStart,
223
+ cli: r.cli,
224
+ cwd: r.cwd,
225
+ state,
226
+ question,
227
+ });
228
+ parentStartedAt.set(r.pid, parentStart);
229
+ childStartedAt.set(r.pid, r.started_at);
230
+ logFiles.set(r.pid, r.log_file ?? null);
231
+ }
232
+ return {
233
+ obs,
234
+ parentStartedAt,
235
+ childStartedAt,
236
+ logFiles,
237
+ aliveChildStartedAt,
238
+ watcherCount: watching.size,
239
+ };
240
+ }
241
+
242
+ /** Registry snapshot: every record's pid → its started_at (for reconcile guard). */
243
+ async function liveChildrenSnapshot(): Promise<Map<number, number>> {
244
+ const records = await listRecords(undefined, LS_OPTS).catch(() => []);
245
+ const m = new Map<number, number>();
246
+ for (const r of records) m.set(r.pid, r.started_at);
247
+ return m;
248
+ }
249
+
250
+ export interface DaemonOptions {
251
+ intervalMs?: number;
252
+ /** Run a single tick and return (for tests / `--once`), no lock, no loop. */
253
+ once?: boolean;
254
+ }
255
+
256
+ // How long an owner heartbeat stays "trusted". A background timer refreshes it
257
+ // every OWNER_TTL/3 (decoupled from tick duration). Set comfortably ABOVE any
258
+ // realistic SYNCHRONOUS event-loop block, so a slow tick can't let the heartbeat
259
+ // go stale and have another `watch` steal the singleton lock → double daemon.
260
+ // The daemon loop is all `await`s (log render + git are async, reconcile runs
261
+ // once at startup), so it never blocks the loop for seconds — but a single-
262
+ // threaded JS process can only bound, not eliminate, an event-loop-block false
263
+ // steal (a worker-isolated heartbeat would be needed to close it; tracked as a
264
+ // follow-up). 30s also bounds the `stop` pid-reuse window (mitigated further by
265
+ // the pid+started_at re-check before SIGTERM).
266
+ const OWNER_TTL_MS = 30_000;
267
+
268
+ export interface DaemonIdentity {
269
+ pid: number;
270
+ started_at: number;
271
+ ts: number;
272
+ /** The owner fencing token at validation time — pins THIS incarnation. */
273
+ token: string | null;
274
+ }
275
+
276
+ // The daemon's start time, captured ONCE when it acquires the lock. The
277
+ // heartbeat updates only `ts` — never `started_at` — so `stop`'s two-read
278
+ // identity check (pid + started_at unchanged) can't see it move and mistake the
279
+ // running daemon for "not running".
280
+ let daemonStartedAt = 0;
281
+ // Fencing token for THIS daemon's lock ownership (see the inbox lock). We only
282
+ // overwrite the owner file or delete the lock while it still carries our token,
283
+ // so a stale-stolen daemon can't clobber or delete the new daemon's lock.
284
+ let daemonToken = "";
285
+
286
+ /** The token currently written in the daemon lock owner file, or null. */
287
+ async function readDaemonOwnerToken(): Promise<string | null> {
288
+ try {
289
+ const o = JSON.parse(await readFile(daemonLockOwnerPath(), "utf8")) as { token?: string };
290
+ return o.token ?? null;
291
+ } catch {
292
+ return null;
293
+ }
294
+ }
295
+
296
+ /**
297
+ * Stamp the lock owner file with our identity + a fresh heartbeat ts + token,
298
+ * ATOMICALLY (temp + rename) so a reader (status/stop/another daemon) never sees
299
+ * a torn owner mid-write — a null read then reliably means "no owner file yet".
300
+ */
301
+ async function writeOwner(): Promise<boolean> {
302
+ const tmp = `${daemonLockOwnerPath()}.${daemonToken}.tmp`;
303
+ try {
304
+ await writeFile(
305
+ tmp,
306
+ JSON.stringify({ pid: process.pid, started_at: daemonStartedAt, ts: Date.now(), token: daemonToken }),
307
+ );
308
+ await rename(tmp, daemonLockOwnerPath());
309
+ return true;
310
+ } catch {
311
+ await rm(tmp, { force: true }).catch(() => {});
312
+ return false;
313
+ }
314
+ }
315
+
316
+ /**
317
+ * Acquire the singleton lock. Returns true if WE now hold it, false if a LIVE
318
+ * daemon already does. Shares the inbox lock's invariant: ownership is proven by
319
+ * `mkdir(recursive:false)`, and a lock is stolen only when its owner is dead or
320
+ * its heartbeat is stale — never on a torn/empty owner WITHIN the grace (the
321
+ * mkdir→writeOwner window of another daemon coming up), so two concurrent starts
322
+ * can't both "win". The liveness predicate is injectable for tests.
323
+ */
324
+ export async function acquireDaemonLock(isAlive: (pid: number) => boolean = isPidAlive): Promise<boolean> {
325
+ // The lock dir lives under notify/ — ensure that exists first, else mkdir of
326
+ // the lock throws ENOENT which must NOT be mistaken for "someone holds it".
327
+ await mkdir(notifyDir(), { recursive: true }).catch(() => {});
328
+ for (;;) {
329
+ try {
330
+ await mkdir(daemonLockDir(), { recursive: false });
331
+ daemonStartedAt = Date.now(); // fixed for this daemon's lifetime
332
+ daemonToken = randomUUID(); // fencing token for this ownership
333
+ // If we can't write our owner file, we don't own the lock — drop and retry.
334
+ if (!(await writeOwner())) {
335
+ await rm(daemonLockDir(), { recursive: true, force: true }).catch(() => {});
336
+ await new Promise((r) => setTimeout(r, 15));
337
+ continue;
338
+ }
339
+ return true;
340
+ } catch (e) {
341
+ if ((e as NodeJS.ErrnoException).code !== "EEXIST") throw e; // real error — propagate
342
+ const raw = await readFile(daemonLockOwnerPath(), "utf8").catch(() => "");
343
+ const now = Date.now();
344
+ // A COMPLETE, live, fresh owner belonging to a DIFFERENT pid → another
345
+ // daemon is already running; we are not it.
346
+ let owner: { pid?: number; ts?: number } | null = null;
347
+ try {
348
+ owner = JSON.parse(raw) as { pid: number; ts: number };
349
+ } catch {
350
+ /* torn / not-yet-written */
351
+ }
352
+ if (
353
+ owner &&
354
+ typeof owner.pid === "number" &&
355
+ owner.pid > 0 &&
356
+ owner.pid !== process.pid &&
357
+ typeof owner.ts === "number" &&
358
+ now - owner.ts <= OWNER_TTL_MS &&
359
+ isAlive(owner.pid)
360
+ ) {
361
+ return false;
362
+ }
363
+ // Otherwise steal per the SHARED decision (dead / heartbeat-stale /
364
+ // torn-past-grace) — the same invariant the per-inbox lock uses, so a
365
+ // torn owner (the mkdir→writeOwner window of a concurrent daemon start) is
366
+ // respected within the grace and two starts can't both win. Else wait.
367
+ const lockAgeMs =
368
+ now -
369
+ (await stat(daemonLockDir())
370
+ .then((s) => s.mtimeMs)
371
+ .catch(() => now));
372
+ if (
373
+ shouldStealLock(raw, now, {
374
+ staleMs: OWNER_TTL_MS,
375
+ lockAgeMs,
376
+ selfPid: process.pid,
377
+ isAlive,
378
+ })
379
+ ) {
380
+ await rm(daemonLockDir(), { recursive: true, force: true }).catch(() => {});
381
+ continue;
382
+ }
383
+ await new Promise((r) => setTimeout(r, 15));
384
+ }
385
+ }
386
+ }
387
+
388
+ /**
389
+ * Run the daemon loop in the FOREGROUND. `ay notifyd start` spawns this detached;
390
+ * `ay notifyd run` runs it inline. Holds the host singleton lock for its lifetime.
391
+ */
392
+ export async function runDaemon(opts: DaemonOptions = {}): Promise<number> {
393
+ const host = hostId();
394
+ const intervalMs = opts.intervalMs ?? POLL_MS;
395
+
396
+ if (opts.once) {
397
+ // A single reconciled tick (test/debug). Skip entirely if a real daemon is
398
+ // already running — otherwise we'd re-emit baselines it has handled — and
399
+ // seed from the inbox so even standalone we don't duplicate prior edges.
400
+ if (await daemonStatus()) return 0;
401
+ const watched = new Set((await liveWatchers()).keys());
402
+ const prev = await reconcileFromInboxes(host, await liveChildrenSnapshot(), watched);
403
+ await tickState(host, prev);
404
+ return 0;
405
+ }
406
+
407
+ if (!(await acquireDaemonLock())) {
408
+ logger.debug("[notifyd] another live daemon holds the lock — exiting");
409
+ return 0;
410
+ }
411
+
412
+ let running = true;
413
+ // Heartbeat the owner ts on a BACKGROUND timer (not once per loop) so a slow
414
+ // tick — many watched children, slow log I/O — can't let the heartbeat cross
415
+ // OWNER_TTL and have another `watch` steal the lock as "stale" → double daemon.
416
+ // Refresh well inside the TTL; cleared on shutdown.
417
+ const ownerBeat = setInterval(() => {
418
+ void (async () => {
419
+ // Refresh ONLY while the owner still carries OUR token. Any other value — a
420
+ // different token (superseded) OR null/absent (a new daemon's mkdir→write
421
+ // window) — means we no longer own it, so stop rather than clobber an
422
+ // unknown/absent owner. Atomic writeOwner means a null read is never our own
423
+ // write in flight.
424
+ if ((await readDaemonOwnerToken()) !== daemonToken) {
425
+ clearInterval(ownerBeat);
426
+ return;
427
+ }
428
+ await writeOwner();
429
+ })();
430
+ }, Math.max(1000, Math.floor(OWNER_TTL_MS / 3)));
431
+ if (typeof ownerBeat.unref === "function") ownerBeat.unref();
432
+ const cleanup = async () => {
433
+ running = false;
434
+ clearInterval(ownerBeat);
435
+ // Delete the lock ONLY if it still carries our token — never remove a lock a
436
+ // newer daemon now owns.
437
+ if ((await readDaemonOwnerToken()) === daemonToken)
438
+ await rm(daemonLockDir(), { recursive: true, force: true }).catch(() => {});
439
+ };
440
+ process.on("SIGINT", () => void cleanup().then(() => process.exit(0)));
441
+ process.on("SIGTERM", () => void cleanup().then(() => process.exit(0)));
442
+
443
+ // Seed the router from prior inbox state — scoped to currently-watched parents
444
+ // and guarded against pid reuse by the current registry snapshot.
445
+ const watchedAtStart = new Set((await liveWatchers()).keys());
446
+ let prev = await reconcileFromInboxes(
447
+ host,
448
+ await liveChildrenSnapshot(),
449
+ watchedAtStart,
450
+ );
451
+ let ticks = 0;
452
+ let emptySince: number | null = null;
453
+ // Durable across ticks: edges whose append failed, retried until they land.
454
+ const pendingRetry = new Map<string, Omit<NotifyEvent, "seq">>();
455
+
456
+ while (running) {
457
+ // Cooperative stop: `notify notifyd stop` removes our lock rather than
458
+ // SIGTERM-ing a possibly-recycled pid. If our lock is gone (or taken over),
459
+ // exit gracefully.
460
+ if ((await readDaemonOwnerToken()) !== daemonToken) {
461
+ logger.debug("[notifyd] lock lost/removed — exiting");
462
+ break;
463
+ }
464
+ const { next, watcherCount } = await tickState(host, prev, pendingRetry);
465
+ prev = next;
466
+ ticks++;
467
+ if (ticks % GC_EVERY_TICKS === 0) await gcTick(host).catch(() => {});
468
+
469
+ // Self-exit when no parent is watching for a grace window.
470
+ if (watcherCount > 0) emptySince = null;
471
+ else if (emptySince == null) emptySince = Date.now();
472
+ if (emptySince != null && Date.now() - emptySince > IDLE_EXIT_GRACE_MS) {
473
+ logger.debug("[notifyd] no watchers — exiting");
474
+ break;
475
+ }
476
+ await new Promise((r) => setTimeout(r, intervalMs));
477
+ }
478
+ await cleanup();
479
+ return 0;
480
+ }
481
+
482
+ /**
483
+ * A durable-in-memory retry queue for edges whose append failed. Keyed by the
484
+ * event's identity so a pid reused by a different child never collides (the key
485
+ * includes child_started_at). The router state is NOT rolled back on failure —
486
+ * re-delivery is owned entirely by this queue, so a failed OLD-child exited and
487
+ * the NEW same-pid child's edges are retried independently (at-least-once per
488
+ * event). Bounded so a permanently-unwritable inbox can't grow it without bound.
489
+ */
490
+ const RETRY_CAP = 1000;
491
+ function retryKey(e: Omit<NotifyEvent, "seq">): string {
492
+ return `${e.parent_pid}:${e.child_pid}:${e.child_started_at ?? 0}:${e.edge}:${e.ts}`;
493
+ }
494
+
495
+ async function tickState(
496
+ host: string,
497
+ prev: RouterState,
498
+ pendingRetry: Map<string, Omit<NotifyEvent, "seq">> = new Map(),
499
+ ): Promise<{ next: RouterState; watcherCount: number }> {
500
+ // First, retry anything a previous tick couldn't append (transient FS/lock
501
+ // blip). Success removes it; a persistent failure stays queued for next tick.
502
+ for (const [k, stored] of pendingRetry) {
503
+ if (await appendWithRetry(stored)) pendingRetry.delete(k);
504
+ }
505
+
506
+ const { obs, parentStartedAt, childStartedAt, logFiles, aliveChildStartedAt, watcherCount } =
507
+ await observeChildren();
508
+ const { events, next } = stepRouter(prev, obs, Date.now(), { aliveChildStartedAt });
509
+
510
+ // Enrich all edges CONCURRENTLY (a burst of N edges must not serialize N git
511
+ // timeouts). Enrichment is best-effort and never throws; the append that
512
+ // follows stays serial so per-inbox seq allocation is unambiguous.
513
+ const enriched = await Promise.all(
514
+ events.map(async (ev) => {
515
+ let tail: string | null = null;
516
+ let git_head: string | null = null;
517
+ if (ev.edge !== "exited") {
518
+ [tail, git_head] = await Promise.all([
519
+ recentTail(logFiles.get(ev.child_pid)).catch(() => null),
520
+ gitHead(ev.cwd).catch(() => null),
521
+ ]);
522
+ }
523
+ const stored: Omit<NotifyEvent, "seq"> = {
524
+ ts: Date.now(),
525
+ host,
526
+ parent_pid: ev.parent_pid,
527
+ // The parent's/child's start times — the read side and the startup
528
+ // reconcile reject an event whose start time disagrees, guarding pid reuse.
529
+ // Carried by the router so a synthetic exited (child gone from the live
530
+ // set) still stamps the real parent start, not 0 (which would bypass the
531
+ // reader's parent guard). Fall back to the live map, then 0.
532
+ parent_started_at: ev.parent_started_at ?? parentStartedAt.get(ev.child_pid) ?? 0,
533
+ child_pid: ev.child_pid,
534
+ child_wrapper_pid: ev.child_wrapper_pid,
535
+ // Carried by the router (survives a synthetic exited for a vanished child,
536
+ // which is no longer in childStartedAt); fall back to the live map.
537
+ child_started_at: ev.child_started_at ?? childStartedAt.get(ev.child_pid) ?? 0,
538
+ cli: ev.cli,
539
+ cwd: ev.cwd,
540
+ edge: ev.edge,
541
+ prev_state: ev.prev_state,
542
+ state: ev.state,
543
+ question: ev.question,
544
+ tail,
545
+ git_head,
546
+ };
547
+ return stored;
548
+ }),
549
+ );
550
+ for (const stored of enriched) {
551
+ if (await appendWithRetry(stored)) continue;
552
+ // at-least-once: a transient FS/lock failure must NOT drop an edge. Queue it
553
+ // by IDENTITY for re-append on a later tick — never touch the router state
554
+ // (which is keyed by pid and can't hold both an old and a new same-pid child).
555
+ if (pendingRetry.size < RETRY_CAP) {
556
+ pendingRetry.set(retryKey(stored), stored);
557
+ logger.warn(`[notifyd] append failed for parent ${stored.parent_pid} — queued for retry`);
558
+ } else {
559
+ logger.warn(`[notifyd] retry queue full (${RETRY_CAP}) — dropping edge for parent ${stored.parent_pid}`);
560
+ }
561
+ }
562
+ return { next, watcherCount };
563
+ }
564
+
565
+ /** Append with a few quick retries — a transient lock/FS blip shouldn't lose an edge. */
566
+ async function appendWithRetry(stored: Omit<NotifyEvent, "seq">, attempts = 3): Promise<boolean> {
567
+ for (let a = 0; a < attempts; a++) {
568
+ try {
569
+ await appendEvent(stored.parent_pid, stored);
570
+ return true;
571
+ } catch {
572
+ await new Promise((r) => setTimeout(r, 25 * (a + 1)));
573
+ }
574
+ }
575
+ return false;
576
+ }
577
+
578
+ async function gcTick(host: string): Promise<void> {
579
+ const records = await listRecords(undefined, { ...LS_OPTS, all: true }).catch(() => []);
580
+ const livePids = new Set<number>();
581
+ const childParentPids = new Set<number>();
582
+ for (const r of records) {
583
+ if (isPidAlive(r.pid)) livePids.add(r.wrapper_pid ?? r.pid);
584
+ if (typeof r.parent_pid === "number" && r.parent_pid > 0 && isPidAlive(r.pid))
585
+ childParentPids.add(r.parent_pid);
586
+ }
587
+ await gcInboxes(host, livePids, childParentPids);
588
+ }
589
+
590
+ /**
591
+ * The running daemon's full identity, or null. The lock owner file is the SINGLE
592
+ * source of truth: trusted only if it is COMPLETE (pid + started_at + ts), its
593
+ * pid is alive, AND its heartbeat is fresh. A recycled pid isn't refreshing this
594
+ * file, so its stale ts (or a torn/partial owner) yields null — we never trust or
595
+ * kill an unrelated process. `stop` re-reads and re-checks this identity right
596
+ * before signalling, so it can't SIGTERM a pid recycled between status and stop.
597
+ *
598
+ * Residual edge (documented, not yet closed): a pid recycled onto an UNRELATED
599
+ * live process WITHIN the tight OWNER_TTL window (a few ticks) could be briefly
600
+ * trusted, since we compare our recorded `started_at` against the owner file, not
601
+ * against the OS's actual process start time (which is non-portable to read:
602
+ * `/proc/<pid>/stat` on Linux vs `proc_pidinfo`/`ps -o lstart` on macOS). The
603
+ * heartbeat freshness + tight TTL keep the window to seconds; a real OS
604
+ * start-time cross-check is deferred to a future issue.
605
+ */
606
+ export async function daemonIdentity(now = Date.now()): Promise<DaemonIdentity | null> {
607
+ const raw = await readFile(daemonLockOwnerPath(), "utf8").catch(() => "");
608
+ try {
609
+ const o = JSON.parse(raw) as Partial<DaemonIdentity>;
610
+ if (
611
+ typeof o?.pid === "number" &&
612
+ o.pid > 0 &&
613
+ typeof o.started_at === "number" &&
614
+ o.started_at > 0 &&
615
+ typeof o.ts === "number" &&
616
+ now - o.ts <= OWNER_TTL_MS &&
617
+ isPidAlive(o.pid)
618
+ ) {
619
+ return {
620
+ pid: o.pid,
621
+ started_at: o.started_at,
622
+ ts: o.ts,
623
+ token: typeof o.token === "string" ? o.token : null,
624
+ };
625
+ }
626
+ } catch {
627
+ /* missing / torn owner — no daemon */
628
+ }
629
+ return null;
630
+ }
631
+
632
+ /** The running daemon's pid, or null (see daemonIdentity for the trust rules). */
633
+ export async function daemonStatus(now = Date.now()): Promise<number | null> {
634
+ return (await daemonIdentity(now))?.pid ?? null;
635
+ }
636
+
637
+ /**
638
+ * Cooperatively request the running daemon to stop, NON-DESTRUCTIVELY: remove its
639
+ * lock rather than SIGTERM-ing the pid. The daemon checks its owner token every
640
+ * tick and exits when the lock is gone, so a pid recycled onto an unrelated
641
+ * process is never signalled. Returns the daemon's pid if one was running, else
642
+ * null. (A wedged daemon that isn't ticking relies on OS/self-exit — a residual
643
+ * tracked in the follow-up issue.)
644
+ */
645
+ export async function requestDaemonStop(): Promise<number | null> {
646
+ const id = await daemonIdentity();
647
+ if (!id || id.token === null) return null;
648
+ // Re-read the owner token right before removing the lock and compare it to the
649
+ // token we VALIDATED above (this incarnation's). If daemon A validated, then
650
+ // exited, and B took over before the rm, the current token is B's — different
651
+ // from A's — so we do NOT remove B's lock (which would stop the new daemon).
652
+ // The residual re-read→rm window is the unavoidable FS-single-CAS gap (bounded).
653
+ const cur = await readDaemonOwnerToken();
654
+ if (cur !== id.token) return null;
655
+ await rm(daemonLockDir(), { recursive: true, force: true }).catch(() => {});
656
+ return id.pid;
657
+ }
658
+
659
+ /** Ensure a daemon is running; best-effort spawn detached. Returns its pid or null. */
660
+ export async function ensureDaemon(): Promise<number | null> {
661
+ const existing = await daemonStatus();
662
+ if (existing) return existing;
663
+ const { spawn } = await import("node:child_process");
664
+ // Resolve our own launcher the way serve/schedule/restart do: on POSIX `ay` is
665
+ // a `#!/usr/bin/env bun` script (run via process.execPath), on Windows it's a
666
+ // self-contained ay.exe. Fall back to THIS process's own script path
667
+ // (process.argv[1]) — which is always present — rather than a bare `ay` that a
668
+ // minimal PATH / absolute-path launch wouldn't resolve.
669
+ const ayBin = Bun.which("ay") ?? process.argv[1];
670
+ if (!ayBin) return null;
671
+ const launcher = process.platform === "win32" ? [ayBin] : [process.execPath, ayBin];
672
+ const [cmd, ...pre] = launcher;
673
+ const child = spawn(cmd!, [...pre, "notifyd", "run"], {
674
+ detached: true,
675
+ stdio: "ignore",
676
+ });
677
+ // spawn's ENOENT surfaces ASYNC as an 'error' event — without a handler it
678
+ // crashes the process. Swallow it; the daemonStatus() check below reports the
679
+ // real outcome (null if the spawn didn't come up).
680
+ child.on("error", () => {});
681
+ child.unref();
682
+ // Poll for the daemon to come up with a bounded φ-backoff (golden-ratio, the
683
+ // repo convention) instead of a single fixed wait — a cold `bun` start or slow
684
+ // FS can take longer than any one fixed delay, so a fixed wait falsely reports
685
+ // "failed". Early-return the moment it's up; give up after a bounded total.
686
+ const PHI = 1.618;
687
+ let delay = 50;
688
+ let waited = 0;
689
+ const BUDGET_MS = 3000;
690
+ for (;;) {
691
+ await new Promise((r) => setTimeout(r, delay));
692
+ waited += delay;
693
+ const pid = await daemonStatus();
694
+ if (pid) return pid;
695
+ if (waited >= BUDGET_MS) return null;
696
+ delay = Math.min(Math.round(delay * PHI), 800);
697
+ }
698
+ }