agent-yes 1.169.1 → 1.171.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 (61) hide show
  1. package/dist/SUPPORTED_CLIS-BMExykB1.js +8 -0
  2. package/dist/{SUPPORTED_CLIS-Bwg9VqLI.js → SUPPORTED_CLIS-D6MArV7A.js} +2 -2
  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-BlGV0y4j.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-mYbyV0SD.js +228 -0
  21. package/dist/{schedule-CtVvSyaN.js → schedule-C5nUV0GO.js} +7 -7
  22. package/dist/{serve-CLmk5ujF.js → serve-ChcayCC3.js} +122 -26
  23. package/dist/{setup-CYbXOcpH.js → setup-CCQZ9tPB.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-CIk1SSdW.js +9 -0
  28. package/dist/{subcommands--EUush_X.js → subcommands-CtWxwydR.js} +1049 -44
  29. package/dist/{tray-CWUpaZF4.js → tray-DXr7iK3E.js} +2 -2
  30. package/dist/{ts-Bbhf5JbN.js → ts-WHYKp13w.js} +8 -8
  31. package/dist/{versionChecker-BmWkWhcJ.js → versionChecker-BoPKPL9B.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 +125 -10
  36. package/lab/ui/index.html +340 -11
  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/notifyDaemon.spec.ts +276 -0
  43. package/ts/notifyDaemon.ts +698 -0
  44. package/ts/notifyInbox.spec.ts +192 -0
  45. package/ts/notifyInbox.ts +294 -0
  46. package/ts/notifyRouter.spec.ts +355 -0
  47. package/ts/notifyRouter.ts +288 -0
  48. package/ts/notifyStore.spec.ts +308 -0
  49. package/ts/notifyStore.ts +439 -0
  50. package/ts/reaper.spec.ts +18 -1
  51. package/ts/reaper.ts +33 -1
  52. package/ts/rustBinary.ts +30 -0
  53. package/ts/serve.ts +194 -14
  54. package/ts/subcommands.spec.ts +341 -0
  55. package/ts/subcommands.ts +764 -29
  56. package/ts/workspaceConfig.spec.ts +48 -0
  57. package/ts/workspaceConfig.ts +46 -3
  58. package/dist/SUPPORTED_CLIS-DiL99G3j.js +0 -8
  59. package/dist/pidStore-5qyLX3Fh.js +0 -5
  60. package/dist/spawnGate-CQ1Il3Xk.js +0 -5
  61. package/dist/subcommands-1ZcaHYkb.js +0 -9
@@ -0,0 +1,591 @@
1
+ import { n as logger } from "./logger-CDIsZ-Pp.js";
2
+ import "./globalPidIndex-CoNr7tS8.js";
3
+ import "./configShared-0MnIQ652.js";
4
+ import { D as renderLogTailLines, G as hostId, J as readInbox, K as listInboxParents, Q as notifyDir, S as listRecords, U as appendEvent, W as gcInboxes, X as daemonLockDir, Y as shouldStealLock, Z as daemonLockOwnerPath, _ as isPidAlive, c as deriveLiveState, q as liveWatchers } from "./subcommands-CtWxwydR.js";
5
+ import "./e2e-jb0Hp43q.js";
6
+ import "./webrtcLink-B7REGtK2.js";
7
+ import "./remotes-CC-4GuJb.js";
8
+ import { mkdir, readFile, rename, rm, stat, writeFile } from "fs/promises";
9
+ import { randomUUID } from "node:crypto";
10
+ import { execFile } from "node:child_process";
11
+
12
+ //#region ts/notifyRouter.ts
13
+ const DEFAULT_IDLE_CONFIRM_MS = 3e4;
14
+ /**
15
+ * Advance the router by one tick. Returns the notifications to push plus the
16
+ * next RouterState. Only children with a numeric `parent_pid` are considered —
17
+ * the caller is responsible for including only opted-in children.
18
+ *
19
+ * First-observation (baseline / startup-reconcile) semantics: a child we've
20
+ * never seen that is ALREADY terminal emits immediately for `needs_input` /
21
+ * `stopped`, and starts its idle timer at `now` for `idle` (so a child parked
22
+ * idle when the daemon starts still notifies after the confirm window). To avoid
23
+ * re-emitting the same baseline on every daemon restart, the caller SEEDS the
24
+ * prior state from each inbox's already-written edges (see cmdNotifyd).
25
+ */
26
+ function stepRouter(prev, observations, now, config = {}) {
27
+ const idleConfirmMs = config.idleConfirmMs ?? DEFAULT_IDLE_CONFIRM_MS;
28
+ const events = [];
29
+ const next = /* @__PURE__ */ new Map();
30
+ const seen = /* @__PURE__ */ new Set();
31
+ for (const obs of observations) {
32
+ if (typeof obs.parent_pid !== "number" || obs.parent_pid <= 0) continue;
33
+ seen.add(obs.pid);
34
+ let p = prev.get(obs.pid);
35
+ if (p && obs.started_at != null && p.started_at != null && p.started_at !== obs.started_at) {
36
+ if (!p.exitedEmitted) events.push(syntheticExited(p, obs.pid));
37
+ p = void 0;
38
+ } else if (p && obs.started_at != null && p.started_at == null) p = void 0;
39
+ const cs = {
40
+ parent_pid: obs.parent_pid,
41
+ wrapper_pid: obs.wrapper_pid,
42
+ started_at: obs.started_at,
43
+ parent_started_at: obs.parent_started_at,
44
+ cli: obs.cli,
45
+ cwd: obs.cwd,
46
+ state: obs.state,
47
+ idleSince: p?.idleSince ?? null,
48
+ idleEmitted: p?.idleEmitted ?? false,
49
+ inNeedsInput: p?.inNeedsInput ?? false,
50
+ needsInputQuestion: p?.needsInputQuestion ?? null,
51
+ exitedEmitted: p?.exitedEmitted ?? false
52
+ };
53
+ const prevState = p?.state ?? null;
54
+ const emit = (edge) => events.push({
55
+ parent_pid: obs.parent_pid,
56
+ child_pid: obs.pid,
57
+ child_wrapper_pid: obs.wrapper_pid,
58
+ child_started_at: obs.started_at,
59
+ parent_started_at: obs.parent_started_at,
60
+ cli: obs.cli,
61
+ cwd: obs.cwd,
62
+ edge,
63
+ prev_state: prevState,
64
+ state: obs.state,
65
+ question: edge === "needs_input" ? obs.question : null
66
+ });
67
+ switch (obs.state) {
68
+ case "needs_input":
69
+ cs.idleSince = null;
70
+ cs.idleEmitted = false;
71
+ if (!cs.inNeedsInput || cs.needsInputQuestion !== obs.question) {
72
+ emit("needs_input");
73
+ cs.inNeedsInput = true;
74
+ cs.needsInputQuestion = obs.question;
75
+ }
76
+ break;
77
+ case "idle":
78
+ cs.inNeedsInput = false;
79
+ cs.needsInputQuestion = null;
80
+ if (p?.state === "idle" && p.idleSince != null) {
81
+ cs.idleSince = p.idleSince;
82
+ cs.idleEmitted = p.idleEmitted;
83
+ } else {
84
+ cs.idleSince = now;
85
+ cs.idleEmitted = false;
86
+ }
87
+ if (!cs.idleEmitted && now - (cs.idleSince ?? now) >= idleConfirmMs) {
88
+ emit("idle");
89
+ cs.idleEmitted = true;
90
+ }
91
+ break;
92
+ case "stopped":
93
+ cs.idleSince = null;
94
+ cs.idleEmitted = false;
95
+ cs.inNeedsInput = false;
96
+ cs.needsInputQuestion = null;
97
+ if (!cs.exitedEmitted) {
98
+ emit("exited");
99
+ cs.exitedEmitted = true;
100
+ }
101
+ break;
102
+ default:
103
+ cs.idleSince = null;
104
+ cs.idleEmitted = false;
105
+ cs.inNeedsInput = false;
106
+ cs.needsInputQuestion = null;
107
+ break;
108
+ }
109
+ next.set(obs.pid, cs);
110
+ }
111
+ for (const [pid, cs] of prev) {
112
+ if (seen.has(pid)) continue;
113
+ if (config.aliveChildStartedAt) {
114
+ const liveStart = config.aliveChildStartedAt.get(pid);
115
+ if (liveStart !== void 0 && (cs.started_at == null || liveStart === cs.started_at)) {
116
+ next.set(pid, cs);
117
+ continue;
118
+ }
119
+ }
120
+ if (!cs.exitedEmitted) events.push(syntheticExited(cs, pid));
121
+ }
122
+ return {
123
+ events,
124
+ next
125
+ };
126
+ }
127
+ /**
128
+ * A synthetic `exited` for a child that left the live set without our observing
129
+ * its stop — either reaped between ticks, or displaced by a pid-reuse. Built from
130
+ * the last-known router state so it still carries the child's identity.
131
+ */
132
+ function syntheticExited(cs, pid) {
133
+ return {
134
+ parent_pid: cs.parent_pid,
135
+ child_pid: pid,
136
+ child_wrapper_pid: cs.wrapper_pid,
137
+ child_started_at: cs.started_at,
138
+ parent_started_at: cs.parent_started_at,
139
+ cli: cs.cli,
140
+ cwd: cs.cwd,
141
+ edge: "exited",
142
+ prev_state: cs.state,
143
+ state: "stopped",
144
+ question: null
145
+ };
146
+ }
147
+
148
+ //#endregion
149
+ //#region ts/notifyDaemon.ts
150
+ /**
151
+ * `ay notifyd` — the always-on-while-watched detection engine. Polls every
152
+ * WATCHED agent's live state (the runtime-agnostic query layer, so BOTH Rust and
153
+ * TS children are covered), runs the pure debounce router (`notifyRouter.ts`),
154
+ * and appends the decided edges — enriched with a payload the parent can act on
155
+ * without tailing — into each parent's inbox (`notifyStore.ts`).
156
+ *
157
+ * Lifecycle: a host singleton, guarded by an mkdir lock that records its owner
158
+ * pid so a crashed daemon's stale lock is detected and STOLEN (never a permanent
159
+ * deadlock). Scope + liveness are driven by the WATCHER REGISTRY: the daemon only
160
+ * processes children whose parent is currently running `ay notify watch` (so an
161
+ * unrelated agent never gets an inbox), and it stays alive as long as any watcher
162
+ * heartbeat is live, self-exiting only after a grace window with no watchers.
163
+ * `ay notify watch` re-ensures the daemon every poll, so a parent that watches
164
+ * BEFORE spawning children (or across a fan-out gap) always has a live daemon.
165
+ */
166
+ const POLL_MS = 2e3;
167
+ const GC_EVERY_TICKS = 30;
168
+ const IDLE_EXIT_GRACE_MS = 6e4;
169
+ const LS_OPTS = {
170
+ all: true,
171
+ active: false,
172
+ json: false,
173
+ latest: false,
174
+ cwdScope: null
175
+ };
176
+ /** Short git HEAD of a cwd, best-effort, never blocks emission. */
177
+ function gitHead(cwd) {
178
+ return new Promise((resolve) => {
179
+ execFile("git", [
180
+ "-C",
181
+ cwd,
182
+ "rev-parse",
183
+ "--short",
184
+ "HEAD"
185
+ ], {
186
+ timeout: 1500,
187
+ windowsHide: true
188
+ }, (err, stdout) => resolve(err ? null : stdout.trim() || null));
189
+ });
190
+ }
191
+ /** Last few rendered log lines, compacted, so the parent needn't tail. */
192
+ async function recentTail(logFile) {
193
+ if (!logFile) return null;
194
+ const lines = await renderLogTailLines(logFile, 12).catch(() => null);
195
+ if (!lines) return null;
196
+ const compact = lines.map((l) => l.replace(/\s+$/, "")).filter((l) => l.trim()).slice(-8);
197
+ return compact.length ? compact.join("\n").slice(0, 1200) : null;
198
+ }
199
+ /**
200
+ * Seed the router's prior state from what each inbox has ALREADY recorded, so a
201
+ * daemon restart does not re-emit a baseline the parent already saw.
202
+ *
203
+ * pid-reuse guard: seed a child ONLY if it is still in the registry with the
204
+ * SAME start time as the inbox recorded (`liveChildren`: pid → started_at). A pid
205
+ * whose live start time differs is a NEW child recycling the pid — seeding it
206
+ * would suppress its first notification, so we skip it (it starts fresh). A pid
207
+ * absent from the registry (reaped) is skipped too: it won't be observed again,
208
+ * and if the pid is later reused, that new child was never seeded.
209
+ */
210
+ async function reconcileFromInboxes(host, liveChildren, watchedParents) {
211
+ const state = /* @__PURE__ */ new Map();
212
+ for (const parent of await listInboxParents(host)) {
213
+ if (!watchedParents.has(parent)) continue;
214
+ const events = await readInbox(host, parent);
215
+ const lastByChild = /* @__PURE__ */ new Map();
216
+ for (const e of events) lastByChild.set(e.child_pid, e);
217
+ for (const [childPid, last] of lastByChild) {
218
+ const liveStarted = liveChildren.get(childPid);
219
+ if (liveStarted === void 0) continue;
220
+ if (!last.child_started_at || liveStarted !== last.child_started_at) continue;
221
+ const seededState = last.edge === "exited" ? "stopped" : last.edge === "needs_input" ? "needs_input" : last.edge === "idle" ? "idle" : "active";
222
+ state.set(childPid, {
223
+ parent_pid: last.parent_pid,
224
+ wrapper_pid: last.child_wrapper_pid,
225
+ started_at: last.child_started_at,
226
+ parent_started_at: last.parent_started_at,
227
+ cli: last.cli,
228
+ cwd: last.cwd,
229
+ state: seededState,
230
+ idleSince: null,
231
+ idleEmitted: false,
232
+ inNeedsInput: false,
233
+ needsInputQuestion: null,
234
+ exitedEmitted: last.edge === "exited"
235
+ });
236
+ }
237
+ }
238
+ return state;
239
+ }
240
+ /** Observe every child WHOSE PARENT IS WATCHING. Nothing else gets an inbox. */
241
+ async function observeChildren() {
242
+ const watching = await liveWatchers();
243
+ const records = await listRecords(void 0, LS_OPTS);
244
+ const startedAtByWrapper = /* @__PURE__ */ new Map();
245
+ for (const r of records) if (typeof r.wrapper_pid === "number" && r.wrapper_pid > 0) startedAtByWrapper.set(r.wrapper_pid, r.started_at);
246
+ const obs = [];
247
+ const parentStartedAt = /* @__PURE__ */ new Map();
248
+ const childStartedAt = /* @__PURE__ */ new Map();
249
+ const logFiles = /* @__PURE__ */ new Map();
250
+ const aliveChildStartedAt = /* @__PURE__ */ new Map();
251
+ for (const r of records) if (typeof r.parent_pid === "number" && r.parent_pid > 0 && r.status !== "exited" && isPidAlive(r.pid)) aliveChildStartedAt.set(r.pid, r.started_at);
252
+ for (const r of records) {
253
+ const parent = r.parent_pid;
254
+ if (typeof parent !== "number" || parent <= 0) continue;
255
+ if (!watching.has(parent)) continue;
256
+ const watcherStart = watching.get(parent) ?? 0;
257
+ if (watcherStart > 0 && r.started_at < watcherStart) continue;
258
+ const { state, question } = await deriveLiveState(r);
259
+ const parentStart = watching.get(parent) || startedAtByWrapper.get(parent) || 0;
260
+ obs.push({
261
+ pid: r.pid,
262
+ wrapper_pid: r.wrapper_pid ?? void 0,
263
+ started_at: r.started_at,
264
+ parent_pid: parent,
265
+ parent_started_at: parentStart,
266
+ cli: r.cli,
267
+ cwd: r.cwd,
268
+ state,
269
+ question
270
+ });
271
+ parentStartedAt.set(r.pid, parentStart);
272
+ childStartedAt.set(r.pid, r.started_at);
273
+ logFiles.set(r.pid, r.log_file ?? null);
274
+ }
275
+ return {
276
+ obs,
277
+ parentStartedAt,
278
+ childStartedAt,
279
+ logFiles,
280
+ aliveChildStartedAt,
281
+ watcherCount: watching.size
282
+ };
283
+ }
284
+ /** Registry snapshot: every record's pid → its started_at (for reconcile guard). */
285
+ async function liveChildrenSnapshot() {
286
+ const records = await listRecords(void 0, LS_OPTS).catch(() => []);
287
+ const m = /* @__PURE__ */ new Map();
288
+ for (const r of records) m.set(r.pid, r.started_at);
289
+ return m;
290
+ }
291
+ const OWNER_TTL_MS = 3e4;
292
+ let daemonStartedAt = 0;
293
+ let daemonToken = "";
294
+ /** The token currently written in the daemon lock owner file, or null. */
295
+ async function readDaemonOwnerToken() {
296
+ try {
297
+ return JSON.parse(await readFile(daemonLockOwnerPath(), "utf8")).token ?? null;
298
+ } catch {
299
+ return null;
300
+ }
301
+ }
302
+ /**
303
+ * Stamp the lock owner file with our identity + a fresh heartbeat ts + token,
304
+ * ATOMICALLY (temp + rename) so a reader (status/stop/another daemon) never sees
305
+ * a torn owner mid-write — a null read then reliably means "no owner file yet".
306
+ */
307
+ async function writeOwner() {
308
+ const tmp = `${daemonLockOwnerPath()}.${daemonToken}.tmp`;
309
+ try {
310
+ await writeFile(tmp, JSON.stringify({
311
+ pid: process.pid,
312
+ started_at: daemonStartedAt,
313
+ ts: Date.now(),
314
+ token: daemonToken
315
+ }));
316
+ await rename(tmp, daemonLockOwnerPath());
317
+ return true;
318
+ } catch {
319
+ await rm(tmp, { force: true }).catch(() => {});
320
+ return false;
321
+ }
322
+ }
323
+ /**
324
+ * Acquire the singleton lock. Returns true if WE now hold it, false if a LIVE
325
+ * daemon already does. Shares the inbox lock's invariant: ownership is proven by
326
+ * `mkdir(recursive:false)`, and a lock is stolen only when its owner is dead or
327
+ * its heartbeat is stale — never on a torn/empty owner WITHIN the grace (the
328
+ * mkdir→writeOwner window of another daemon coming up), so two concurrent starts
329
+ * can't both "win". The liveness predicate is injectable for tests.
330
+ */
331
+ async function acquireDaemonLock(isAlive = isPidAlive) {
332
+ await mkdir(notifyDir(), { recursive: true }).catch(() => {});
333
+ for (;;) try {
334
+ await mkdir(daemonLockDir(), { recursive: false });
335
+ daemonStartedAt = Date.now();
336
+ daemonToken = randomUUID();
337
+ if (!await writeOwner()) {
338
+ await rm(daemonLockDir(), {
339
+ recursive: true,
340
+ force: true
341
+ }).catch(() => {});
342
+ await new Promise((r) => setTimeout(r, 15));
343
+ continue;
344
+ }
345
+ return true;
346
+ } catch (e) {
347
+ if (e.code !== "EEXIST") throw e;
348
+ const raw = await readFile(daemonLockOwnerPath(), "utf8").catch(() => "");
349
+ const now = Date.now();
350
+ let owner = null;
351
+ try {
352
+ owner = JSON.parse(raw);
353
+ } catch {}
354
+ if (owner && typeof owner.pid === "number" && owner.pid > 0 && owner.pid !== process.pid && typeof owner.ts === "number" && now - owner.ts <= OWNER_TTL_MS && isAlive(owner.pid)) return false;
355
+ if (shouldStealLock(raw, now, {
356
+ staleMs: OWNER_TTL_MS,
357
+ lockAgeMs: now - await stat(daemonLockDir()).then((s) => s.mtimeMs).catch(() => now),
358
+ selfPid: process.pid,
359
+ isAlive
360
+ })) {
361
+ await rm(daemonLockDir(), {
362
+ recursive: true,
363
+ force: true
364
+ }).catch(() => {});
365
+ continue;
366
+ }
367
+ await new Promise((r) => setTimeout(r, 15));
368
+ }
369
+ }
370
+ /**
371
+ * Run the daemon loop in the FOREGROUND. `ay notifyd start` spawns this detached;
372
+ * `ay notifyd run` runs it inline. Holds the host singleton lock for its lifetime.
373
+ */
374
+ async function runDaemon(opts = {}) {
375
+ const host = hostId();
376
+ const intervalMs = opts.intervalMs ?? POLL_MS;
377
+ if (opts.once) {
378
+ if (await daemonStatus()) return 0;
379
+ const watched = new Set((await liveWatchers()).keys());
380
+ await tickState(host, await reconcileFromInboxes(host, await liveChildrenSnapshot(), watched));
381
+ return 0;
382
+ }
383
+ if (!await acquireDaemonLock()) {
384
+ logger.debug("[notifyd] another live daemon holds the lock — exiting");
385
+ return 0;
386
+ }
387
+ let running = true;
388
+ const ownerBeat = setInterval(() => {
389
+ (async () => {
390
+ if (await readDaemonOwnerToken() !== daemonToken) {
391
+ clearInterval(ownerBeat);
392
+ return;
393
+ }
394
+ await writeOwner();
395
+ })();
396
+ }, Math.max(1e3, Math.floor(OWNER_TTL_MS / 3)));
397
+ if (typeof ownerBeat.unref === "function") ownerBeat.unref();
398
+ const cleanup = async () => {
399
+ running = false;
400
+ clearInterval(ownerBeat);
401
+ if (await readDaemonOwnerToken() === daemonToken) await rm(daemonLockDir(), {
402
+ recursive: true,
403
+ force: true
404
+ }).catch(() => {});
405
+ };
406
+ process.on("SIGINT", () => void cleanup().then(() => process.exit(0)));
407
+ process.on("SIGTERM", () => void cleanup().then(() => process.exit(0)));
408
+ const watchedAtStart = new Set((await liveWatchers()).keys());
409
+ let prev = await reconcileFromInboxes(host, await liveChildrenSnapshot(), watchedAtStart);
410
+ let ticks = 0;
411
+ let emptySince = null;
412
+ const pendingRetry = /* @__PURE__ */ new Map();
413
+ while (running) {
414
+ if (await readDaemonOwnerToken() !== daemonToken) {
415
+ logger.debug("[notifyd] lock lost/removed — exiting");
416
+ break;
417
+ }
418
+ const { next, watcherCount } = await tickState(host, prev, pendingRetry);
419
+ prev = next;
420
+ ticks++;
421
+ if (ticks % GC_EVERY_TICKS === 0) await gcTick(host).catch(() => {});
422
+ if (watcherCount > 0) emptySince = null;
423
+ else if (emptySince == null) emptySince = Date.now();
424
+ if (emptySince != null && Date.now() - emptySince > IDLE_EXIT_GRACE_MS) {
425
+ logger.debug("[notifyd] no watchers — exiting");
426
+ break;
427
+ }
428
+ await new Promise((r) => setTimeout(r, intervalMs));
429
+ }
430
+ await cleanup();
431
+ return 0;
432
+ }
433
+ /**
434
+ * A durable-in-memory retry queue for edges whose append failed. Keyed by the
435
+ * event's identity so a pid reused by a different child never collides (the key
436
+ * includes child_started_at). The router state is NOT rolled back on failure —
437
+ * re-delivery is owned entirely by this queue, so a failed OLD-child exited and
438
+ * the NEW same-pid child's edges are retried independently (at-least-once per
439
+ * event). Bounded so a permanently-unwritable inbox can't grow it without bound.
440
+ */
441
+ const RETRY_CAP = 1e3;
442
+ function retryKey(e) {
443
+ return `${e.parent_pid}:${e.child_pid}:${e.child_started_at ?? 0}:${e.edge}:${e.ts}`;
444
+ }
445
+ async function tickState(host, prev, pendingRetry = /* @__PURE__ */ new Map()) {
446
+ for (const [k, stored] of pendingRetry) if (await appendWithRetry(stored)) pendingRetry.delete(k);
447
+ const { obs, parentStartedAt, childStartedAt, logFiles, aliveChildStartedAt, watcherCount } = await observeChildren();
448
+ const { events, next } = stepRouter(prev, obs, Date.now(), { aliveChildStartedAt });
449
+ const enriched = await Promise.all(events.map(async (ev) => {
450
+ let tail = null;
451
+ let git_head = null;
452
+ if (ev.edge !== "exited") [tail, git_head] = await Promise.all([recentTail(logFiles.get(ev.child_pid)).catch(() => null), gitHead(ev.cwd).catch(() => null)]);
453
+ return {
454
+ ts: Date.now(),
455
+ host,
456
+ parent_pid: ev.parent_pid,
457
+ parent_started_at: ev.parent_started_at ?? parentStartedAt.get(ev.child_pid) ?? 0,
458
+ child_pid: ev.child_pid,
459
+ child_wrapper_pid: ev.child_wrapper_pid,
460
+ child_started_at: ev.child_started_at ?? childStartedAt.get(ev.child_pid) ?? 0,
461
+ cli: ev.cli,
462
+ cwd: ev.cwd,
463
+ edge: ev.edge,
464
+ prev_state: ev.prev_state,
465
+ state: ev.state,
466
+ question: ev.question,
467
+ tail,
468
+ git_head
469
+ };
470
+ }));
471
+ for (const stored of enriched) {
472
+ if (await appendWithRetry(stored)) continue;
473
+ if (pendingRetry.size < RETRY_CAP) {
474
+ pendingRetry.set(retryKey(stored), stored);
475
+ logger.warn(`[notifyd] append failed for parent ${stored.parent_pid} — queued for retry`);
476
+ } else logger.warn(`[notifyd] retry queue full (${RETRY_CAP}) — dropping edge for parent ${stored.parent_pid}`);
477
+ }
478
+ return {
479
+ next,
480
+ watcherCount
481
+ };
482
+ }
483
+ /** Append with a few quick retries — a transient lock/FS blip shouldn't lose an edge. */
484
+ async function appendWithRetry(stored, attempts = 3) {
485
+ for (let a = 0; a < attempts; a++) try {
486
+ await appendEvent(stored.parent_pid, stored);
487
+ return true;
488
+ } catch {
489
+ await new Promise((r) => setTimeout(r, 25 * (a + 1)));
490
+ }
491
+ return false;
492
+ }
493
+ async function gcTick(host) {
494
+ const records = await listRecords(void 0, {
495
+ ...LS_OPTS,
496
+ all: true
497
+ }).catch(() => []);
498
+ const livePids = /* @__PURE__ */ new Set();
499
+ const childParentPids = /* @__PURE__ */ new Set();
500
+ for (const r of records) {
501
+ if (isPidAlive(r.pid)) livePids.add(r.wrapper_pid ?? r.pid);
502
+ if (typeof r.parent_pid === "number" && r.parent_pid > 0 && isPidAlive(r.pid)) childParentPids.add(r.parent_pid);
503
+ }
504
+ await gcInboxes(host, livePids, childParentPids);
505
+ }
506
+ /**
507
+ * The running daemon's full identity, or null. The lock owner file is the SINGLE
508
+ * source of truth: trusted only if it is COMPLETE (pid + started_at + ts), its
509
+ * pid is alive, AND its heartbeat is fresh. A recycled pid isn't refreshing this
510
+ * file, so its stale ts (or a torn/partial owner) yields null — we never trust or
511
+ * kill an unrelated process. `stop` re-reads and re-checks this identity right
512
+ * before signalling, so it can't SIGTERM a pid recycled between status and stop.
513
+ *
514
+ * Residual edge (documented, not yet closed): a pid recycled onto an UNRELATED
515
+ * live process WITHIN the tight OWNER_TTL window (a few ticks) could be briefly
516
+ * trusted, since we compare our recorded `started_at` against the owner file, not
517
+ * against the OS's actual process start time (which is non-portable to read:
518
+ * `/proc/<pid>/stat` on Linux vs `proc_pidinfo`/`ps -o lstart` on macOS). The
519
+ * heartbeat freshness + tight TTL keep the window to seconds; a real OS
520
+ * start-time cross-check is deferred to a future issue.
521
+ */
522
+ async function daemonIdentity(now = Date.now()) {
523
+ const raw = await readFile(daemonLockOwnerPath(), "utf8").catch(() => "");
524
+ try {
525
+ const o = JSON.parse(raw);
526
+ if (typeof o?.pid === "number" && o.pid > 0 && typeof o.started_at === "number" && o.started_at > 0 && typeof o.ts === "number" && now - o.ts <= OWNER_TTL_MS && isPidAlive(o.pid)) return {
527
+ pid: o.pid,
528
+ started_at: o.started_at,
529
+ ts: o.ts,
530
+ token: typeof o.token === "string" ? o.token : null
531
+ };
532
+ } catch {}
533
+ return null;
534
+ }
535
+ /** The running daemon's pid, or null (see daemonIdentity for the trust rules). */
536
+ async function daemonStatus(now = Date.now()) {
537
+ return (await daemonIdentity(now))?.pid ?? null;
538
+ }
539
+ /**
540
+ * Cooperatively request the running daemon to stop, NON-DESTRUCTIVELY: remove its
541
+ * lock rather than SIGTERM-ing the pid. The daemon checks its owner token every
542
+ * tick and exits when the lock is gone, so a pid recycled onto an unrelated
543
+ * process is never signalled. Returns the daemon's pid if one was running, else
544
+ * null. (A wedged daemon that isn't ticking relies on OS/self-exit — a residual
545
+ * tracked in the follow-up issue.)
546
+ */
547
+ async function requestDaemonStop() {
548
+ const id = await daemonIdentity();
549
+ if (!id || id.token === null) return null;
550
+ if (await readDaemonOwnerToken() !== id.token) return null;
551
+ await rm(daemonLockDir(), {
552
+ recursive: true,
553
+ force: true
554
+ }).catch(() => {});
555
+ return id.pid;
556
+ }
557
+ /** Ensure a daemon is running; best-effort spawn detached. Returns its pid or null. */
558
+ async function ensureDaemon() {
559
+ const existing = await daemonStatus();
560
+ if (existing) return existing;
561
+ const { spawn } = await import("node:child_process");
562
+ const ayBin = Bun.which("ay") ?? process.argv[1];
563
+ if (!ayBin) return null;
564
+ const [cmd, ...pre] = process.platform === "win32" ? [ayBin] : [process.execPath, ayBin];
565
+ const child = spawn(cmd, [
566
+ ...pre,
567
+ "notifyd",
568
+ "run"
569
+ ], {
570
+ detached: true,
571
+ stdio: "ignore"
572
+ });
573
+ child.on("error", () => {});
574
+ child.unref();
575
+ const PHI = 1.618;
576
+ let delay = 50;
577
+ let waited = 0;
578
+ const BUDGET_MS = 3e3;
579
+ for (;;) {
580
+ await new Promise((r) => setTimeout(r, delay));
581
+ waited += delay;
582
+ const pid = await daemonStatus();
583
+ if (pid) return pid;
584
+ if (waited >= BUDGET_MS) return null;
585
+ delay = Math.min(Math.round(delay * PHI), 800);
586
+ }
587
+ }
588
+
589
+ //#endregion
590
+ export { daemonStatus, ensureDaemon, requestDaemonStop, runDaemon };
591
+ //# sourceMappingURL=notifyDaemon-BlGV0y4j.js.map
@@ -68,4 +68,4 @@ async function offerOpenInBrowser(url, prompt = "Open in browser now? [Y/n] ") {
68
68
 
69
69
  //#endregion
70
70
  export { offerOpenInBrowser };
71
- //# sourceMappingURL=openBrowser-CCF1iuQK.js.map
71
+ //# sourceMappingURL=openBrowser-ChR4llYa.js.map
@@ -1,6 +1,6 @@
1
1
  import { n as logger } from "./logger-CDIsZ-Pp.js";
2
- import { t as agentYesHome } from "./agentYesHome-_eJa3DaX.js";
3
- import { a as updateGlobalPidStatus, n as maybeCompactGlobalPids, r as pruneOldLogs, t as appendGlobalPid } from "./globalPidIndex-DlmmJlO8.js";
2
+ import { t as agentYesHome } from "./agentYesHome-CtHb5b71.js";
3
+ import { a as updateGlobalPidStatus, n as maybeCompactGlobalPids, r as pruneOldLogs, t as appendGlobalPid } from "./globalPidIndex-CoNr7tS8.js";
4
4
  import { closeSync, existsSync, fsyncSync, openSync } from "fs";
5
5
  import { appendFile, mkdir, readFile, rename, writeFile } from "fs/promises";
6
6
  import path from "path";
@@ -368,4 +368,4 @@ pid-db/
368
368
 
369
369
  //#endregion
370
370
  export { PidStore as t };
371
- //# sourceMappingURL=pidStore-BfoBWUjc.js.map
371
+ //# sourceMappingURL=pidStore-BIvsBQ8X.js.map
@@ -0,0 +1,5 @@
1
+ import "./logger-CDIsZ-Pp.js";
2
+ import { t as PidStore } from "./pidStore-BIvsBQ8X.js";
3
+ import "./globalPidIndex-CoNr7tS8.js";
4
+
5
+ export { PidStore };
@@ -1,5 +1,5 @@
1
- import { t as agentYesHome } from "./agentYesHome-_eJa3DaX.js";
2
- import { appendFile, mkdir, readFile, rename, writeFile } from "fs/promises";
1
+ import { t as agentYesHome } from "./agentYesHome-CtHb5b71.js";
2
+ import { appendFile, mkdir, readFile, readdir, rename, unlink, writeFile } from "fs/promises";
3
3
  import path from "path";
4
4
 
5
5
  //#region ts/reaper.ts
@@ -49,6 +49,7 @@ async function register(wrapperPid, pgid) {
49
49
  /** SIGKILL the recorded group of every agent whose wrapper has exited, and
50
50
  * rewrite the registry keeping only still-running agents. Best-effort. */
51
51
  async function sweep() {
52
+ await pruneStaleActivityMarkers();
52
53
  let content;
53
54
  try {
54
55
  content = await readFile(registryPath(), "utf8");
@@ -80,7 +81,30 @@ async function sweep() {
80
81
  await rename(tmp, registryPath());
81
82
  } catch {}
82
83
  }
84
+ /** Prune typing-activity markers (`activity/<pid>.stdin`) left by hard-killed
85
+ * agents whose own exit cleanup never ran. Independent of the reaper registry
86
+ * above, so it catches every dead pid. A leaked marker is harmless (it ages out
87
+ * of the typing window and stopped agents never render the chip); this just
88
+ * keeps the dir from accumulating dead entries. Mirrors rs/src/fifo.rs
89
+ * `prune_stale_activity_markers`. Best-effort. */
90
+ async function pruneStaleActivityMarkers() {
91
+ const dir = path.join(agentYesHome(), "activity");
92
+ let names;
93
+ try {
94
+ names = await readdir(dir);
95
+ } catch {
96
+ return;
97
+ }
98
+ await Promise.all(names.map(async (name) => {
99
+ if (!name.endsWith(".stdin")) return;
100
+ const pid = Number(name.slice(0, -6));
101
+ if (!Number.isInteger(pid) || isAlive(pid)) return;
102
+ try {
103
+ await unlink(path.join(dir, name));
104
+ } catch {}
105
+ }));
106
+ }
83
107
 
84
108
  //#endregion
85
109
  export { register as n, sweep as r, pgidForWrapper as t };
86
- //# sourceMappingURL=reaper-C-eWAxIj.js.map
110
+ //# sourceMappingURL=reaper-CWF2_ATd.js.map
@@ -1,3 +1,3 @@
1
- import { n as register, r as sweep, t as pgidForWrapper } from "./reaper-C-eWAxIj.js";
1
+ import { n as register, r as sweep, t as pgidForWrapper } from "./reaper-CWF2_ATd.js";
2
2
 
3
3
  export { sweep };
@@ -1,5 +1,5 @@
1
- import "./e2e-BeKjLhmO.js";
2
- import "./webrtcLink-BG0Xc4-W.js";
3
- import { a as resolveRemoteSpec, i as readRemotes, n as deleteRemoteAlias, o as writeRemoteAlias, r as parseDirectRemoteSpec, t as cmdRemote } from "./remotes-BmAPylU_.js";
1
+ import "./e2e-jb0Hp43q.js";
2
+ import "./webrtcLink-B7REGtK2.js";
3
+ import { a as resolveRemoteSpec, i as readRemotes, n as deleteRemoteAlias, o as writeRemoteAlias, r as parseDirectRemoteSpec, t as cmdRemote } from "./remotes-CC-4GuJb.js";
4
4
 
5
5
  export { cmdRemote };