agent-yes 1.265.4 → 1.266.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 (75) hide show
  1. package/dist/{SUPPORTED_CLIS-C3tcRetU.js → SUPPORTED_CLIS-AFV5pjSx.js} +2 -2
  2. package/dist/{SUPPORTED_CLIS-CY3SaV0D.js → SUPPORTED_CLIS-DRVuSo48.js} +3 -3
  3. package/dist/{agentShare-g6N1A6A_.js → agentShare-qdFSp9Uq.js} +6 -5
  4. package/dist/{callback-L4k-v2GG.js → callback-Da7MRLh6.js} +6 -5
  5. package/dist/{callback-z-w0ND5z.js → callback-DfNFdZin.js} +5 -5
  6. package/dist/{callbackCore-Cv4EOGjE.js → callbackCore-DcptaZwH.js} +1 -1
  7. package/dist/{callbackCore-Cg3Ye0ZS.js → callbackCore-K99x3C75.js} +1 -1
  8. package/dist/{channels-BwpN_8mu.js → channels-DQaZbare.js} +2 -2
  9. package/dist/cli.js +11 -11
  10. package/dist/{cwdConflictWarn-DZTxTGXq.js → cwdConflictWarn-CtQhl-vF.js} +1 -1
  11. package/dist/{cwdPassthroughHint-DDVkhBiD.js → cwdPassthroughHint-apaX_O7p.js} +1 -1
  12. package/dist/{expose-BG17GV7D.js → expose-BXn6My6A.js} +1 -1
  13. package/dist/{forkNested-VroZS-nk.js → forkNested-CDfWPiHw.js} +12 -2
  14. package/dist/{hist-CoXBBjJt.js → hist-BpocAe1N.js} +1 -1
  15. package/dist/index.js +2 -2
  16. package/dist/initMsg-BjRseolL.js +3 -0
  17. package/dist/initMsg-C-w4A_eF.js +85 -0
  18. package/dist/needsInput-103zhZ90.js +79 -0
  19. package/dist/{nodeRuntime-CT0w6zsB.js → nodeRuntime-CRoEMWkp.js} +1 -1
  20. package/dist/{notifyDaemon-BL1tmxUK.js → notifyDaemon-BFzqcdN1.js} +32 -5
  21. package/dist/notifyStore-CEDiVyis.js +4 -0
  22. package/dist/notifyStore-Cbw2ZJ0X.js +559 -0
  23. package/dist/{openBrowser-Cw8hg7ok.js → openBrowser-DhQ9pJRi.js} +1 -1
  24. package/dist/parentLink-B798ASCW.js +5 -0
  25. package/dist/parentLink-C0cIRjfL.js +53 -0
  26. package/dist/parentPingLoop-BIKxzbPH.js +469 -0
  27. package/dist/{remotes-DWVw-Q7V.js → remotes-Dg_JscKk.js} +2 -2
  28. package/dist/{remotes-iIvqiFWK.js → remotes-iMhdZytp.js} +3 -3
  29. package/dist/{rustBinary-DSVRhDzO.js → rustBinary-JGNROWfY.js} +2 -2
  30. package/dist/{schedule-i4o5SJ_h.js → schedule-DbLpjgl5.js} +6 -6
  31. package/dist/{serve-CpGJD2Kt.js → serve-ucVXOci1.js} +31 -30
  32. package/dist/{setup-B-iQKsQW.js → setup-De7uYRZb.js} +4 -4
  33. package/dist/{share-DOnynkf4.js → share-Db_zb6gH.js} +1 -1
  34. package/dist/{share-B-e_9WBx.js → share-NUhi-Wdd.js} +1 -1
  35. package/dist/{spawnGate-S6EoFdzX.js → spawnGate-BvaZeHE7.js} +1 -1
  36. package/dist/{spawnGate-BmsTv340.js → spawnGate-CEXVmTtv.js} +2 -2
  37. package/dist/subcommands-C04exkIM.js +11 -0
  38. package/dist/{subcommands-CQPnT5qu.js → subcommands-DtF31RoW.js} +41 -656
  39. package/dist/{systemPathLink-CjLmiPbB.js → systemPathLink-7TFPwJna.js} +1 -1
  40. package/dist/{terminal-CybRgMZu.js → terminal-Dm32SXU-.js} +2 -2
  41. package/dist/{todoCli-DZmOmcvd.js → todoCli-c_R7D7Y-.js} +1 -1
  42. package/dist/{tray-Cjm3a8JH.js → tray-DZnGjJBK.js} +1 -1
  43. package/dist/trayApp-CZbwLK9t.js +5 -0
  44. package/dist/{trayApp-UglHbk_m.js → trayApp-CfkPG0wy.js} +2 -2
  45. package/dist/{ts-nuUnrVcf.js → ts-CGtoPER5.js} +43 -10
  46. package/dist/{versionChecker-CMOLbEEi.js → versionChecker-CJgW9DHY.js} +2 -2
  47. package/dist/{webrtcLink-CS4D-3G1.js → webrtcLink-DyCj380i.js} +1 -1
  48. package/dist/{webrtcRemote-zQybN9IR.js → webrtcRemote-BDg0jjTs.js} +2 -2
  49. package/dist/{widget-y5Bpvwsa.js → widget-DXtaX3QR.js} +3 -3
  50. package/dist/{workspaceConfig-BDqp7fYl.js → workspaceConfig-HiWQzq6A.js} +1 -1
  51. package/dist/{ws-CH8GzcQn.js → ws-CqlS5JhN.js} +5 -4
  52. package/dist/{ws-BrLV-AlH.js → ws-aKRZpI9b.js} +9 -11
  53. package/package.json +1 -1
  54. package/ts/forkNested.spec.ts +22 -0
  55. package/ts/forkNested.ts +19 -0
  56. package/ts/index.ts +77 -12
  57. package/ts/initMsg.spec.ts +114 -0
  58. package/ts/initMsg.ts +107 -0
  59. package/ts/ownerHeartbeat.ts +40 -1
  60. package/ts/parentLink.spec.ts +64 -0
  61. package/ts/parentLink.ts +58 -0
  62. package/ts/parentPing.spec.ts +176 -0
  63. package/ts/parentPing.ts +195 -0
  64. package/ts/parentPingLoop.delivery.spec.ts +236 -0
  65. package/ts/parentPingLoop.spec.ts +79 -0
  66. package/ts/parentPingLoop.ts +196 -0
  67. package/ts/parentPingSend.spec.ts +195 -0
  68. package/ts/parentPingSend.ts +148 -0
  69. package/ts/parentWatching.spec.ts +61 -0
  70. package/ts/parentWatching.ts +89 -0
  71. package/ts/subcommands.ts +11 -2
  72. package/ts/trayApp.spec.ts +31 -4
  73. package/ts/ws.ts +17 -12
  74. package/dist/subcommands-Cgt2-kjg.js +0 -10
  75. package/dist/trayApp-CmwwBg8v.js +0 -5
@@ -0,0 +1,559 @@
1
+ import { n as logger } from "./logger-CDIsZ-Pp.js";
2
+ import { t as agentYesHome } from "./agentYesHome-CtmsY6I-.js";
3
+ import { appendFile, mkdir, readFile, readdir, rename, rm, stat, writeFile } from "fs/promises";
4
+ import path from "path";
5
+ import { randomUUID } from "node:crypto";
6
+ import os from "node:os";
7
+
8
+ //#region ts/notifyInbox.ts
9
+ /**
10
+ * Pure, fs-free core for the subagent→parent notification inbox — the storage
11
+ * and watermark layer under `ay notify` / `ay notifyd`.
12
+ *
13
+ * Motivation (real incident): a parent agent fanned out sub-agents that finished
14
+ * their work but sat at an idle `❯` prompt WITHOUT exiting (claude-yes does not
15
+ * exit on idle). Claude Code's background-task notification only fires on process
16
+ * EXIT, so the parent never learned the children went idle and left them parked
17
+ * 16 minutes. `ay ls --watch` already streams transitions, but it is PULL: the
18
+ * parent must run the watch loop. The whole point of this pain is the parent is
19
+ * NOT watching. So we accumulate qualifying edges into a per-parent append-only
20
+ * inbox that the parent drains on its own schedule (its Monitor loop), with a
21
+ * persisted cursor so a parent that restarts reads only the unread edges.
22
+ *
23
+ * This module is the pure part (path math, NDJSON (de)serialization, seq/cursor
24
+ * filtering, retention decisions) so it is trivially unit-testable, mirroring
25
+ * `lsWatch.ts` / `needsInput.ts` / `resultEnvelope.ts`. The fs read/write + lock
26
+ * + CLI live in `subcommands.ts`; the detection/debounce lives in
27
+ * `notifyRouter.ts`.
28
+ */
29
+ /** Root dir for all notification state. */
30
+ function notifyDir() {
31
+ return path.join(agentYesHome(), "notify");
32
+ }
33
+ /** Per-host inbox directory (pids are only unique within a host). */
34
+ function inboxDir(host) {
35
+ return path.join(notifyDir(), "inbox", sanitizeHost(host));
36
+ }
37
+ /** Absolute path of one parent's append-only NDJSON inbox. */
38
+ function inboxPath(host, parentPid) {
39
+ return path.join(inboxDir(host), `${parentPid}.ndjson`);
40
+ }
41
+ /** Sidecar seq counter for a parent inbox (last allocated seq). */
42
+ function seqPath(host, parentPid) {
43
+ return path.join(inboxDir(host), `${parentPid}.seq`);
44
+ }
45
+ /** Per-consumer cursor directory for a parent inbox. */
46
+ function cursorDir(host, parentPid) {
47
+ return path.join(notifyDir(), "cursors", sanitizeHost(host), String(parentPid));
48
+ }
49
+ /** Absolute path of one consumer's cursor file for a parent inbox. */
50
+ function cursorPath(host, parentPid, consumer = "parent") {
51
+ return path.join(cursorDir(host, parentPid), `${sanitizeConsumer(consumer)}.json`);
52
+ }
53
+ /** The daemon singleton lock dir (mkdir-based, like pidStore). */
54
+ function daemonLockDir() {
55
+ return path.join(notifyDir(), "notifyd.lock");
56
+ }
57
+ /**
58
+ * Owner metadata inside the lock dir ({pid, started_at, ts}) — the SINGLE source
59
+ * of truth for the running daemon's identity, used for stale-lock steal and for
60
+ * `notifyd status`/`stop` (a running daemon refreshes `ts` each tick).
61
+ */
62
+ function daemonLockOwnerPath() {
63
+ return path.join(daemonLockDir(), "owner.json");
64
+ }
65
+ /** Registry dir of parents currently running `ay notify watch` (heartbeats). */
66
+ function watchersDir() {
67
+ return path.join(notifyDir(), "watchers");
68
+ }
69
+ /** Heartbeat file for one watching parent. */
70
+ function watcherPath(parentPid) {
71
+ return path.join(watchersDir(), `${parentPid}.json`);
72
+ }
73
+ /** A watcher heartbeat is "live" if refreshed within this window. */
74
+ const WATCHER_TTL_MS = 15e3;
75
+ function sanitizeSegment(s, fallback) {
76
+ const cleaned = s.replace(/[^A-Za-z0-9._-]/g, "_");
77
+ return cleaned === "" || cleaned === "." || cleaned === ".." ? fallback : cleaned;
78
+ }
79
+ function sanitizeHost(host) {
80
+ return sanitizeSegment(host, "localhost");
81
+ }
82
+ function sanitizeConsumer(consumer) {
83
+ return sanitizeSegment(consumer, "parent");
84
+ }
85
+ /** Serialize one event to its NDJSON line (no trailing newline). */
86
+ function serializeEvent(ev) {
87
+ return JSON.stringify(ev);
88
+ }
89
+ /**
90
+ * Parse an inbox's raw text into events. Tolerant by design: a torn final line
91
+ * (a writer mid-append) or any unparseable line is skipped, never throws — a
92
+ * reader must survive a concurrent writer. Order is file order (= seq order,
93
+ * since a single locked writer appends in increasing seq).
94
+ */
95
+ function parseInboxText(text) {
96
+ const out = [];
97
+ for (const line of text.split("\n")) {
98
+ const t = line.trim();
99
+ if (!t) continue;
100
+ try {
101
+ const ev = JSON.parse(t);
102
+ if (isValidEvent(ev)) out.push(ev);
103
+ } catch {}
104
+ }
105
+ return out;
106
+ }
107
+ const EDGES = new Set([
108
+ "needs_input",
109
+ "idle",
110
+ "exited"
111
+ ]);
112
+ /**
113
+ * Full shape validation at the storage boundary — a torn/partial line missing
114
+ * the correlation fields must never flow through to a consumer's output. Every
115
+ * event we surface has the fields a reader relies on.
116
+ */
117
+ function isValidEvent(ev) {
118
+ if (!ev || typeof ev !== "object") return false;
119
+ const e = ev;
120
+ return typeof e.seq === "number" && typeof e.ts === "number" && typeof e.host === "string" && typeof e.parent_pid === "number" && typeof e.child_pid === "number" && typeof e.cli === "string" && typeof e.cwd === "string" && typeof e.edge === "string" && EDGES.has(e.edge) && typeof e.state === "string" && (e.question === null || typeof e.question === "string");
121
+ }
122
+ /** Next seq to allocate given the last stored seq counter (or the inbox max). */
123
+ function nextSeq(lastSeq) {
124
+ return (Number.isFinite(lastSeq) && lastSeq > 0 ? lastSeq : 0) + 1;
125
+ }
126
+ /** Highest seq present in a parsed inbox (0 when empty). */
127
+ function maxSeq(events) {
128
+ let m = 0;
129
+ for (const e of events) if (e.seq > m) m = e.seq;
130
+ return m;
131
+ }
132
+ /**
133
+ * Filter events for `--since <seq>`: strictly-greater than the given seq. A
134
+ * `since` of 0/undefined returns everything.
135
+ */
136
+ function filterSinceSeq(events, sinceSeq) {
137
+ if (!sinceSeq || sinceSeq <= 0) return events.slice();
138
+ return events.filter((e) => e.seq > sinceSeq);
139
+ }
140
+ /** Filter events after a wall-clock `--since <ts>` (inclusive lower bound). */
141
+ function filterSinceTs(events, sinceTs) {
142
+ if (!sinceTs || sinceTs <= 0) return events.slice();
143
+ return events.filter((e) => e.ts >= sinceTs);
144
+ }
145
+ /** Unread = seq strictly greater than the consumer's cursor. */
146
+ function filterUnread(events, cursorSeq) {
147
+ return events.filter((e) => e.seq > (cursorSeq > 0 ? cursorSeq : 0));
148
+ }
149
+ /** Parse a cursor file's text; a missing/garbage cursor reads as seq 0. */
150
+ function parseCursor(text) {
151
+ if (!text) return { seq: 0 };
152
+ try {
153
+ const c = JSON.parse(text);
154
+ return { seq: Number.isFinite(c?.seq) && c.seq > 0 ? c.seq : 0 };
155
+ } catch {
156
+ return { seq: 0 };
157
+ }
158
+ }
159
+ function serializeCursor(seq) {
160
+ return JSON.stringify({ seq: Math.max(0, Math.floor(seq)) });
161
+ }
162
+ /**
163
+ * Which incarnation of a parent pid a POSTMORTEM read should show (pure).
164
+ *
165
+ * The normal read path resolves the parent's `started_at` from the registry and
166
+ * fails closed when there is no live record — deliberately, so a recycled pid
167
+ * can't read a prior session's inbox. That also makes a FINISHED parent's inbox
168
+ * permanently uninspectable, which is exactly when an operator wants to see what
169
+ * the children reported. Postmortem keeps the identity guard but takes the
170
+ * identity from the inbox's own `parent_started_at` stamps instead.
171
+ *
172
+ * - An explicit choice always wins.
173
+ * - One distinct stamp is unambiguous — use it.
174
+ * - Several mean the pid was reused across sessions and the file holds more than
175
+ * one agent's edges: refuse and list them rather than guess, because guessing
176
+ * would show one agent's children under another's name.
177
+ * - None (a legacy, identity-less inbox) yields 0 = "no filter"; there is nothing
178
+ * to disambiguate and nothing to mis-attribute.
179
+ *
180
+ * Issue #169 item 6.
181
+ */
182
+ function postmortemStartedAt(events, explicit) {
183
+ if (Number.isFinite(explicit) && explicit > 0) return explicit;
184
+ const distinct = [...new Set(events.map((e) => e.parent_started_at ?? 0).filter((v) => v > 0))].sort((a, b) => a - b);
185
+ if (distinct.length === 0) return 0;
186
+ if (distinct.length === 1) return distinct[0];
187
+ throw new Error(`inbox holds edges from ${distinct.length} incarnations of this pid (started_at ${distinct.join(", ")}) — pass --started-at <ms> to pick one.`);
188
+ }
189
+ /** Byte size one event occupies in the inbox file (its line + newline). */
190
+ function eventBytes(events) {
191
+ let n = 0;
192
+ for (const e of events) n += serializeEvent(e).length + 1;
193
+ return n;
194
+ }
195
+ /**
196
+ * EMERGENCY rotation (pure): keep the newest events that fit `capBytes`,
197
+ * IGNORING the un-acked watermark that {@link rotateKeep} protects.
198
+ *
199
+ * This is the escape hatch for the one way an inbox can grow without bound: a
200
+ * parent that watches WITHOUT `--ack` (the default, at-least-once) never
201
+ * advances its cursor, so every event stays protected and normal rotation can
202
+ * trim nothing. That is the right default — an unacked edge must not be dropped
203
+ * — but "right" stops being right once the file threatens the disk. Past a hard
204
+ * cap well above the soft one, dropping the OLDEST unacked events (loudly) beats
205
+ * unbounded growth.
206
+ *
207
+ * `minKeep` still floors the result, so the newest edges — the ones a parent is
208
+ * most likely to still act on — are never the ones sacrificed.
209
+ *
210
+ * Issue #169 item 3.
211
+ */
212
+ function emergencyKeep(events, capBytes, minKeep = 100) {
213
+ if (events.length <= minKeep) return events.slice();
214
+ const kept = [];
215
+ let bytes = 0;
216
+ for (let i = events.length - 1; i >= 0; i--) {
217
+ const e = events[i];
218
+ bytes += serializeEvent(e).length + 1;
219
+ if (kept.length < minKeep || bytes <= capBytes) kept.push(e);
220
+ else break;
221
+ }
222
+ kept.reverse();
223
+ return kept;
224
+ }
225
+ /**
226
+ * Rotation decision (pure): given an inbox's events, a byte cap, and the minimum
227
+ * un-acked cursor across ALL consumers, return the events to KEEP after
228
+ * rotating. Critically, an event with `seq > protectAboveSeq` is NEVER dropped —
229
+ * that is the at-least-once guarantee: we must not evict an edge no consumer has
230
+ * acknowledged yet, even under the byte cap. Below that watermark we keep the
231
+ * newest events that fit the cap (and at least `minKeep`).
232
+ */
233
+ function rotateKeep(events, capBytes, protectAboveSeq = 0, minKeep = 100) {
234
+ if (events.length <= minKeep) return events.slice();
235
+ const kept = [];
236
+ let bytes = 0;
237
+ for (let i = events.length - 1; i >= 0; i--) {
238
+ const e = events[i];
239
+ const line = serializeEvent(e) + "\n";
240
+ bytes += line.length;
241
+ if (e.seq > protectAboveSeq || kept.length < minKeep || bytes <= capBytes) kept.push(e);
242
+ else break;
243
+ }
244
+ kept.reverse();
245
+ return kept;
246
+ }
247
+
248
+ //#endregion
249
+ //#region ts/notifyStore.ts
250
+ /**
251
+ * Filesystem side of the notification inbox — the impure half of the pure
252
+ * `notifyInbox.ts` path/serialization core. Handles the locked append (so
253
+ * concurrent daemon ticks / future writers never interleave a seq), inbox and
254
+ * cursor reads/writes, and retention GC.
255
+ *
256
+ * The lock is a mkdir-based advisory lock (same primitive `pidStore` uses), held
257
+ * only for the microseconds of a read-counter → append-line → write-counter, so
258
+ * contention is negligible even with many parents.
259
+ */
260
+ /** Stable local host id — the pid namespace is per-host. */
261
+ function hostId() {
262
+ return os.hostname() || "localhost";
263
+ }
264
+ function pidAlive(pid) {
265
+ if (!Number.isFinite(pid) || pid <= 0) return false;
266
+ try {
267
+ process.kill(pid, 0);
268
+ return true;
269
+ } catch (e) {
270
+ return e.code === "EPERM";
271
+ }
272
+ }
273
+ /**
274
+ * Pure steal decision for a per-inbox lock (extracted so it's unit-testable):
275
+ * steal the lock ONLY when its holder is provably gone (torn/missing owner on a
276
+ * lock that has EXISTED longer than the grace, or a dead pid) or its heartbeat is
277
+ * stale — NEVER merely because the contender has waited a while.
278
+ *
279
+ * Crucially, the torn-owner grace is measured from the LOCK INSTANCE's age
280
+ * (`lockAgeMs` = now − lockDir mtime), NOT from how long the contender has been
281
+ * waiting. Otherwise a contender that waited out one holder could instantly steal
282
+ * the NEXT holder's freshly-created lock during its mkdir→write-owner window
283
+ * (torn but brand-new) — letting two writers into the critical section.
284
+ */
285
+ function shouldStealLock(ownerRaw, now, opts) {
286
+ const graceMs = opts.graceMs ?? 1e3;
287
+ let ownerPid = 0;
288
+ let ownerTs = 0;
289
+ let parsed = false;
290
+ try {
291
+ const o = JSON.parse(ownerRaw);
292
+ ownerPid = o?.pid ?? 0;
293
+ ownerTs = o?.ts ?? 0;
294
+ parsed = true;
295
+ } catch {}
296
+ if (!parsed || ownerPid <= 0 || ownerTs <= 0) return opts.lockAgeMs > graceMs;
297
+ const holderDead = ownerPid !== opts.selfPid && !opts.isAlive(ownerPid);
298
+ const holderStale = now - ownerTs > opts.staleMs;
299
+ return holderDead || holderStale;
300
+ }
301
+ async function ensureDir(dir) {
302
+ await mkdir(dir, { recursive: true }).catch(() => {});
303
+ }
304
+ /**
305
+ * Atomically overwrite a single file (temp + rename) so a concurrent reader never
306
+ * observes a torn/partial write — the sweep invariant for every single-file
307
+ * mutation (cursor, watcher heartbeat, lock owner). Returns whether it landed.
308
+ */
309
+ async function atomicWrite(file, data) {
310
+ const tmp = `${file}.${randomUUID()}.tmp`;
311
+ try {
312
+ await writeFile(tmp, data);
313
+ await rename(tmp, file);
314
+ return true;
315
+ } catch {
316
+ await rm(tmp, { force: true }).catch(() => {});
317
+ return false;
318
+ }
319
+ }
320
+ /**
321
+ * Acquire a per-inbox lock; returns a release fn. Ownership is proven ONLY by a
322
+ * successful `mkdir(recursive:false)` (atomic exclusive create), and stealing is
323
+ * driven by HOLDER LIVENESS, not elapsed wait time: the holder writes an
324
+ * `owner` file ({pid, ts}) on acquire AND heartbeats its ts for the whole time
325
+ * it holds the lock, so a contender steals ONLY when that holder is dead or its
326
+ * heartbeat is stale. A live holder whose critical section legitimately runs long
327
+ * (a big GC rewrite, a slow disk) is never robbed — its heartbeat stays fresh;
328
+ * and a wedged holder stops heartbeating, so staleMs still reclaims it. There is
329
+ * deliberately NO wall-clock backstop: elapsed wait time alone never steals, so a
330
+ * live holder is inviolable.
331
+ */
332
+ function isTransientLockMkdirError(code, platform = process.platform) {
333
+ if (code === "EEXIST") return true;
334
+ return platform === "win32" && (code === "EPERM" || code === "EBUSY" || code === "ENOTEMPTY" || code === "EACCES");
335
+ }
336
+ async function acquireLock(lockDir, staleMs = 3e4) {
337
+ const ownerFile = path.join(lockDir, "owner");
338
+ const token = randomUUID();
339
+ const readOwnerToken = async () => {
340
+ try {
341
+ return JSON.parse(await readFile(ownerFile, "utf8")).token ?? null;
342
+ } catch {
343
+ return null;
344
+ }
345
+ };
346
+ const stampOwner = async () => {
347
+ const tmp = `${ownerFile}.${token}.tmp`;
348
+ try {
349
+ await writeFile(tmp, JSON.stringify({
350
+ pid: process.pid,
351
+ ts: Date.now(),
352
+ token
353
+ }));
354
+ await rename(tmp, ownerFile);
355
+ return true;
356
+ } catch {
357
+ await rm(tmp, { force: true }).catch(() => {});
358
+ return false;
359
+ }
360
+ };
361
+ const lockAgeMs = async () => {
362
+ const m = await stat(lockDir).then((s) => s.mtimeMs).catch(() => Date.now());
363
+ return Date.now() - m;
364
+ };
365
+ let transientErrors = 0;
366
+ for (;;) try {
367
+ await mkdir(lockDir, { recursive: false });
368
+ if (!await stampOwner()) {
369
+ await rm(lockDir, {
370
+ recursive: true,
371
+ force: true
372
+ }).catch(() => {});
373
+ await new Promise((r) => setTimeout(r, 15));
374
+ continue;
375
+ }
376
+ const beat = setInterval(() => {
377
+ (async () => {
378
+ if (await readOwnerToken() !== token) {
379
+ clearInterval(beat);
380
+ return;
381
+ }
382
+ await stampOwner();
383
+ })();
384
+ }, Math.max(500, Math.floor(staleMs / 3)));
385
+ if (typeof beat.unref === "function") beat.unref();
386
+ return async () => {
387
+ clearInterval(beat);
388
+ if (await readOwnerToken() === token) await rm(lockDir, {
389
+ recursive: true,
390
+ force: true
391
+ }).catch(() => {});
392
+ };
393
+ } catch (e) {
394
+ const code = e.code;
395
+ if (!isTransientLockMkdirError(code)) throw e;
396
+ if (code !== "EEXIST") {
397
+ if (++transientErrors > 200) throw e;
398
+ await new Promise((r) => setTimeout(r, 15));
399
+ continue;
400
+ }
401
+ if (shouldStealLock(await readFile(ownerFile, "utf8").catch(() => ""), Date.now(), {
402
+ staleMs,
403
+ lockAgeMs: await lockAgeMs(),
404
+ selfPid: process.pid,
405
+ isAlive: pidAlive
406
+ })) {
407
+ await rm(lockDir, {
408
+ recursive: true,
409
+ force: true
410
+ }).catch(() => {});
411
+ continue;
412
+ }
413
+ await new Promise((r) => setTimeout(r, 15));
414
+ }
415
+ }
416
+ /**
417
+ * Append one event to a parent's inbox under lock, allocating the next seq. The
418
+ * caller passes the event WITHOUT seq (we stamp it). Returns the stamped seq.
419
+ */
420
+ async function appendEvent(parentPid, ev) {
421
+ const host = ev.host;
422
+ const dir = inboxDir(host);
423
+ await ensureDir(dir);
424
+ const release = await acquireLock(path.join(dir, `${parentPid}.lock`));
425
+ try {
426
+ let last = 0;
427
+ const counterRaw = await readFile(seqPath(host, parentPid), "utf8").catch(() => "");
428
+ const parsed = parseInt(counterRaw.trim(), 10);
429
+ if (Number.isFinite(parsed) && parsed > 0) last = parsed;
430
+ else last = maxSeq(parseInboxText(await readFile(inboxPath(host, parentPid), "utf8").catch(() => "")));
431
+ const seq = nextSeq(last);
432
+ const full = {
433
+ ...ev,
434
+ seq
435
+ };
436
+ await writeFile(seqPath(host, parentPid), String(seq));
437
+ await appendFile(inboxPath(host, parentPid), serializeEvent(full) + "\n");
438
+ return seq;
439
+ } finally {
440
+ await release();
441
+ }
442
+ }
443
+ /** Read + parse a parent's inbox (empty array when none). */
444
+ async function readInbox(host, parentPid) {
445
+ return parseInboxText(await readFile(inboxPath(host, parentPid), "utf8").catch(() => ""));
446
+ }
447
+ /** All parent pids that currently have an inbox on this host. */
448
+ async function listInboxParents(host) {
449
+ const names = await readdir(inboxDir(host)).catch(() => []);
450
+ const pids = [];
451
+ for (const n of names) {
452
+ const m = /^(\d+)\.ndjson$/.exec(n);
453
+ if (m) pids.push(parseInt(m[1], 10));
454
+ }
455
+ return pids;
456
+ }
457
+ async function getCursor(host, parentPid, consumer = "parent") {
458
+ return parseCursor(await readFile(cursorPath(host, parentPid, consumer), "utf8").catch(() => null)).seq;
459
+ }
460
+ /**
461
+ * Minimum cursor across ALL consumers of a parent inbox — the watermark below
462
+ * which rotation may evict events (nothing above it has been acked by every
463
+ * reader). Returns 0 when there are no consumers — which PROTECTS EVERYTHING:
464
+ * with a 0 watermark, `rotateKeep` treats every event as unacked and evicts
465
+ * nothing (an inbox nobody reads is never trimmed).
466
+ */
467
+ async function minConsumerCursor(host, parentPid) {
468
+ const cursors = (await readdir(cursorDir(host, parentPid)).catch(() => [])).filter((n) => n.endsWith(".json")).map((n) => n.slice(0, -5));
469
+ if (cursors.length === 0) return 0;
470
+ let min = Infinity;
471
+ for (const c of cursors) min = Math.min(min, await getCursor(host, parentPid, c));
472
+ return Number.isFinite(min) ? min : 0;
473
+ }
474
+ async function setCursor(host, parentPid, seq, consumer = "parent") {
475
+ const p = cursorPath(host, parentPid, consumer);
476
+ await ensureDir(path.dirname(p));
477
+ await atomicWrite(p, serializeCursor(seq));
478
+ }
479
+ /**
480
+ * Retention: delete inboxes (+ their seq counter + cursors) for parents that are
481
+ * both dead and unreferenced by any live child. `capBytes` also rotates a live
482
+ * inbox that has grown past the cap, keeping the newest events.
483
+ */
484
+ async function gcInboxes(host, livePids, liveChildParentPids, capBytes = 10 * 1024 * 1024, hardCapBytes = capBytes * 4) {
485
+ const parents = await listInboxParents(host);
486
+ for (const p of parents) {
487
+ const release = await acquireLock(path.join(inboxDir(host), `${p}.lock`));
488
+ try {
489
+ if (!livePids.has(p) && !liveChildParentPids.has(p)) {
490
+ await rm(inboxPath(host, p), { force: true }).catch(() => {});
491
+ await rm(seqPath(host, p), { force: true }).catch(() => {});
492
+ await rm(cursorDir(host, p), {
493
+ recursive: true,
494
+ force: true
495
+ }).catch(() => {});
496
+ continue;
497
+ }
498
+ const size = await stat(inboxPath(host, p)).then((s) => s.size).catch(() => 0);
499
+ if (size <= capBytes) continue;
500
+ const events = await readInbox(host, p);
501
+ let kept = rotateKeep(events, capBytes, await minConsumerCursor(host, p));
502
+ let dropped = [];
503
+ if (eventBytes(kept) > hardCapBytes) {
504
+ const before = kept;
505
+ kept = emergencyKeep(kept, capBytes);
506
+ dropped = before.slice(0, before.length - kept.length);
507
+ }
508
+ if (kept.length < events.length) {
509
+ await writeFile(inboxPath(host, p), kept.map(serializeEvent).join("\n") + "\n");
510
+ if (dropped.length > 0) logger.error(`[notify] EMERGENCY TRIM: inbox for parent ${p} exceeded the hard cap ${hardCapBytes} bytes — dropped ${dropped.length} UNACKED events (seq ${dropped[0].seq}..${dropped[dropped.length - 1].seq}). The consumer's cursor is not advancing: run \`ay notify watch --ack\` (or \`ay notify cursor set <seq>\`) for parent ${p}.`);
511
+ } else logger.warn(`[notify] inbox for parent ${p} is ${size} bytes (> soft cap ${capBytes}) but all events are unacked — cursor not advancing?`);
512
+ } finally {
513
+ await release();
514
+ }
515
+ }
516
+ }
517
+ /** Register / refresh this parent's watch heartbeat (atomically, so a concurrent
518
+ * liveWatchers never reads a torn file and transiently drops the watcher). */
519
+ async function heartbeatWatcher(parentPid, startedAt) {
520
+ await ensureDir(watchersDir());
521
+ await atomicWrite(watcherPath(parentPid), JSON.stringify({
522
+ pid: parentPid,
523
+ started_at: startedAt,
524
+ ts: Date.now(),
525
+ watch_pid: process.pid
526
+ }));
527
+ }
528
+ /** Remove this parent's watch heartbeat (on watch exit). */
529
+ async function clearWatcher(parentPid) {
530
+ await rm(watcherPath(parentPid), { force: true }).catch(() => {});
531
+ }
532
+ /**
533
+ * Live watchers as a map of parent pid → the parent's own `started_at` (its
534
+ * authoritative self-reported start time, so the daemon never has to guess it
535
+ * from the registry and stamp a 0). A watcher counts as live ONLY if its
536
+ * heartbeat is fresh AND its process is actually alive — so a crashed `watch`
537
+ * whose heartbeat lingers for the TTL doesn't keep the daemon writing to a dead
538
+ * parent's inbox (which would violate "nothing happens unless you watch"). A
539
+ * heartbeat with a missing/non-positive `started_at` is treated as NOT live: the
540
+ * daemon's cross-session scope guard keys off a positive parent start time, so a
541
+ * 0 would defeat it — fail closed.
542
+ */
543
+ async function liveWatchers(now = Date.now()) {
544
+ const names = await readdir(watchersDir()).catch(() => []);
545
+ const live = /* @__PURE__ */ new Map();
546
+ for (const n of names) {
547
+ if (!n.endsWith(".json")) continue;
548
+ const raw = await readFile(path.join(watchersDir(), n), "utf8").catch(() => "");
549
+ try {
550
+ const o = JSON.parse(raw);
551
+ if (typeof o?.pid === "number" && typeof o?.started_at === "number" && o.started_at > 0 && typeof o?.ts === "number" && now - o.ts <= WATCHER_TTL_MS && pidAlive(o.pid) && (typeof o.watch_pid !== "number" || o.watch_pid <= 0 || pidAlive(o.watch_pid))) live.set(o.pid, o.started_at);
552
+ } catch {}
553
+ }
554
+ return live;
555
+ }
556
+
557
+ //#endregion
558
+ export { postmortemStartedAt as S, filterSinceSeq as _, getCursor as a, maxSeq as b, isTransientLockMkdirError as c, minConsumerCursor as d, readInbox as f, daemonLockOwnerPath as g, daemonLockDir as h, gcInboxes as i, listInboxParents as l, shouldStealLock as m, appendEvent as n, heartbeatWatcher as o, setCursor as p, clearWatcher as r, hostId as s, acquireLock as t, liveWatchers as u, filterSinceTs as v, notifyDir as x, filterUnread as y };
559
+ //# sourceMappingURL=notifyStore-Cbw2ZJ0X.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-Cw8hg7ok.js.map
71
+ //# sourceMappingURL=openBrowser-DhQ9pJRi.js.map
@@ -0,0 +1,5 @@
1
+ import "./logger-CDIsZ-Pp.js";
2
+ import "./globalPidIndex-Dq4KY2IV.js";
3
+ import { n as spawnerFromRecords, t as resolveSpawner } from "./parentLink-C0cIRjfL.js";
4
+
5
+ export { resolveSpawner };
@@ -0,0 +1,53 @@
1
+ import { i as readGlobalPids } from "./globalPidIndex-Dq4KY2IV.js";
2
+
3
+ //#region ts/parentLink.ts
4
+ /**
5
+ * Resolve "who spawned me" from the wrapper-pid link the registry already
6
+ * carries, into an addressable identity.
7
+ *
8
+ * The link itself is just a number: a nested `ay` inherits its parent wrapper's
9
+ * `AGENT_YES_PID` and records it as `parent_pid` (see globalPidIndex's
10
+ * GlobalPidRecord). To actually TALK to that parent — which is what
11
+ * `<ay-init-msg>` and the finished/stuck ping both need — we have to map that
12
+ * wrapper pid back to the parent's canonical record, because the reply route is
13
+ * its `agent_id` (stable across restart), not its pid.
14
+ *
15
+ * Shared by ts/initMsg.ts's caller and ts/parentPing's delivery so both address
16
+ * the SAME identity — a child that reports to a different route than the one
17
+ * printed in its own init block would be maddening to debug.
18
+ */
19
+ /**
20
+ * The parent agent for `parentPid` (a parent WRAPPER pid), or null.
21
+ *
22
+ * Null covers three different situations that all mean the same thing here —
23
+ * "nobody to report to": no parent at all (top-level, human-launched); a parent
24
+ * whose record aged out of the registry; or a parent on another host. Callers
25
+ * treat all three as "skip the wrapper / skip the ping" rather than guessing a
26
+ * route that would silently go nowhere.
27
+ */
28
+ async function resolveSpawner(parentPid) {
29
+ if (typeof parentPid !== "number" || !Number.isInteger(parentPid) || parentPid <= 0) return null;
30
+ let records;
31
+ try {
32
+ records = await readGlobalPids();
33
+ } catch {
34
+ return null;
35
+ }
36
+ return spawnerFromRecords(records, parentPid);
37
+ }
38
+ /** Pure half of resolveSpawner, so the match rule is testable without the fs. */
39
+ function spawnerFromRecords(records, parentPid) {
40
+ const matches = records.filter((r) => r.wrapper_pid === parentPid);
41
+ const parent = matches.find((r) => r.status !== "exited") ?? matches[0];
42
+ if (!parent) return null;
43
+ return {
44
+ cli: parent.cli,
45
+ pid: parent.pid,
46
+ agentId: parent.agent_id ?? null,
47
+ cwd: parent.cwd
48
+ };
49
+ }
50
+
51
+ //#endregion
52
+ export { spawnerFromRecords as n, resolveSpawner as t };
53
+ //# sourceMappingURL=parentLink-C0cIRjfL.js.map