agent-yes 1.175.1 → 1.176.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 (47) hide show
  1. package/agent-yes.config.schema.json +2 -2
  2. package/default.config.yaml +45 -0
  3. package/dist/SUPPORTED_CLIS-BC4fOfBC.js +8 -0
  4. package/dist/{SUPPORTED_CLIS-DH7pORiR.js → SUPPORTED_CLIS-TMmgX_p6.js} +2 -2
  5. package/dist/agentShare-DRfghQz8.js +231 -0
  6. package/dist/bash-yes.js +10 -0
  7. package/dist/cli.js +8 -8
  8. package/dist/cmd-yes.js +10 -0
  9. package/dist/{e2e-jb0Hp43q.js → e2e-BeKjLhmO.js} +1 -1
  10. package/dist/{forkNested-DhJxa4q4.js → forkNested-C8q7E6mp.js} +1 -1
  11. package/dist/index.js +2 -2
  12. package/dist/{notifyDaemon-CLRNmdHy.js → notifyDaemon-C08fJ2Ai.js} +5 -5
  13. package/dist/{openBrowser-ChR4llYa.js → openBrowser-BO5RVYwL.js} +1 -1
  14. package/dist/powershell-yes.js +10 -0
  15. package/dist/{remotes-BQMr4_En.js → remotes-BIHNxn1a.js} +3 -3
  16. package/dist/{remotes-CC-4GuJb.js → remotes-DsTnQhyx.js} +3 -3
  17. package/dist/{rustBinary-DSN5ekb5.js → rustBinary-DZTU07SA.js} +2 -2
  18. package/dist/{schedule-DXGDI0Uo.js → schedule-BQtFOk0z.js} +5 -5
  19. package/dist/{serve-DgQjFYQC.js → serve-B5PQdiOb.js} +143 -76
  20. package/dist/{setup-DHVydU-j.js → setup-BCqFNJoB.js} +3 -3
  21. package/dist/{share-QregR8a_.js → share-29YbzpbU.js} +6 -6
  22. package/dist/share-nvqy64A2.js +4 -0
  23. package/dist/{spawnGate-BFhva-2F.js → spawnGate-C5nBiUZQ.js} +1 -1
  24. package/dist/{spawnGate-IJDByl2U.js → spawnGate-Clp9xBPX.js} +2 -2
  25. package/dist/{subcommands-DjA3FgJk.js → subcommands-BbSJ_nTU.js} +736 -714
  26. package/dist/subcommands-BokdtqqC.js +9 -0
  27. package/dist/{tray-DXr7iK3E.js → tray-DKwVRBwr.js} +1 -1
  28. package/dist/{ts-PerHq0yJ.js → ts-Ce7a44Rj.js} +3 -3
  29. package/dist/{versionChecker-CfZx87hW.js → versionChecker-C7C7QAPu.js} +2 -2
  30. package/dist/{webrtcLink-B7REGtK2.js → webrtcLink-BG0Xc4-W.js} +2 -2
  31. package/dist/{webrtcRemote-Bx-eD_0I.js → webrtcRemote-BLEbZnbY.js} +3 -3
  32. package/dist/{workspaceConfig-BgqK-31W.js → workspaceConfig-DKd6UvVm.js} +1 -1
  33. package/lab/ui/index.html +495 -291
  34. package/lab/ui/qrcode.js +2297 -0
  35. package/lab/ui/sw.js +2 -1
  36. package/package.json +8 -2
  37. package/scripts/build-rgui.ts +78 -0
  38. package/ts/agentShare.lifecycle.spec.ts +139 -0
  39. package/ts/agentShare.spec.ts +238 -0
  40. package/ts/agentShare.ts +330 -0
  41. package/ts/forkNested.spec.ts +39 -2
  42. package/ts/index.ts +5 -1
  43. package/ts/serve.ts +119 -5
  44. package/ts/share.ts +10 -3
  45. package/ts/subcommands.ts +29 -0
  46. package/dist/SUPPORTED_CLIS-COSdc1BZ.js +0 -8
  47. package/dist/subcommands-C9u1em-X.js +0 -9
@@ -3,8 +3,8 @@ import { n as logger } from "./logger-CDIsZ-Pp.js";
3
3
  import { t as agentYesHome } from "./agentYesHome-CtHb5b71.js";
4
4
  import { a as updateGlobalPidStatus, i as readGlobalPids } from "./globalPidIndex-CoNr7tS8.js";
5
5
  import { t as loadSharedCliDefaults } from "./configShared-0MnIQ652.js";
6
- import { n as isWebrtcSpec } from "./webrtcLink-B7REGtK2.js";
7
- import { a as resolveRemoteSpec, i as readRemotes } from "./remotes-CC-4GuJb.js";
6
+ import { n as isWebrtcSpec } from "./webrtcLink-BG0Xc4-W.js";
7
+ import { a as resolveRemoteSpec, i as readRemotes } from "./remotes-DsTnQhyx.js";
8
8
  import ms from "ms";
9
9
  import yargs from "yargs";
10
10
  import { appendFile, mkdir, open, readFile, readdir, rename, rm, stat, writeFile } from "fs/promises";
@@ -13,798 +13,798 @@ import path from "path";
13
13
  import { randomUUID } from "node:crypto";
14
14
  import os from "node:os";
15
15
 
16
- //#region ts/notifyInbox.ts
17
- /**
18
- * Pure, fs-free core for the subagent→parent notification inbox — the storage
19
- * and watermark layer under `ay notify` / `ay notifyd`.
20
- *
21
- * Motivation (real incident): a parent agent fanned out sub-agents that finished
22
- * their work but sat at an idle `❯` prompt WITHOUT exiting (claude-yes does not
23
- * exit on idle). Claude Code's background-task notification only fires on process
24
- * EXIT, so the parent never learned the children went idle and left them parked
25
- * 16 minutes. `ay ls --watch` already streams transitions, but it is PULL: the
26
- * parent must run the watch loop. The whole point of this pain is the parent is
27
- * NOT watching. So we accumulate qualifying edges into a per-parent append-only
28
- * inbox that the parent drains on its own schedule (its Monitor loop), with a
29
- * persisted cursor so a parent that restarts reads only the unread edges.
30
- *
31
- * This module is the pure part (path math, NDJSON (de)serialization, seq/cursor
32
- * filtering, retention decisions) so it is trivially unit-testable, mirroring
33
- * `lsWatch.ts` / `needsInput.ts` / `resultEnvelope.ts`. The fs read/write + lock
34
- * + CLI live in `subcommands.ts`; the detection/debounce lives in
35
- * `notifyRouter.ts`.
36
- */
37
- /** Root dir for all notification state. */
38
- function notifyDir() {
39
- return path.join(agentYesHome(), "notify");
40
- }
41
- /** Per-host inbox directory (pids are only unique within a host). */
42
- function inboxDir(host) {
43
- return path.join(notifyDir(), "inbox", sanitizeHost(host));
44
- }
45
- /** Absolute path of one parent's append-only NDJSON inbox. */
46
- function inboxPath(host, parentPid) {
47
- return path.join(inboxDir(host), `${parentPid}.ndjson`);
48
- }
49
- /** Sidecar seq counter for a parent inbox (last allocated seq). */
50
- function seqPath(host, parentPid) {
51
- return path.join(inboxDir(host), `${parentPid}.seq`);
52
- }
53
- /** Per-consumer cursor directory for a parent inbox. */
54
- function cursorDir(host, parentPid) {
55
- return path.join(notifyDir(), "cursors", sanitizeHost(host), String(parentPid));
56
- }
57
- /** Absolute path of one consumer's cursor file for a parent inbox. */
58
- function cursorPath(host, parentPid, consumer = "parent") {
59
- return path.join(cursorDir(host, parentPid), `${sanitizeConsumer(consumer)}.json`);
60
- }
61
- /** The daemon singleton lock dir (mkdir-based, like pidStore). */
62
- function daemonLockDir() {
63
- return path.join(notifyDir(), "notifyd.lock");
64
- }
65
- /**
66
- * Owner metadata inside the lock dir ({pid, started_at, ts}) — the SINGLE source
67
- * of truth for the running daemon's identity, used for stale-lock steal and for
68
- * `notifyd status`/`stop` (a running daemon refreshes `ts` each tick).
69
- */
70
- function daemonLockOwnerPath() {
71
- return path.join(daemonLockDir(), "owner.json");
72
- }
73
- /** Registry dir of parents currently running `ay notify watch` (heartbeats). */
74
- function watchersDir() {
75
- return path.join(notifyDir(), "watchers");
76
- }
77
- /** Heartbeat file for one watching parent. */
78
- function watcherPath(parentPid) {
79
- return path.join(watchersDir(), `${parentPid}.json`);
80
- }
81
- /** A watcher heartbeat is "live" if refreshed within this window. */
82
- const WATCHER_TTL_MS = 15e3;
83
- function sanitizeSegment(s, fallback) {
84
- const cleaned = s.replace(/[^A-Za-z0-9._-]/g, "_");
85
- return cleaned === "" || cleaned === "." || cleaned === ".." ? fallback : cleaned;
86
- }
87
- function sanitizeHost(host) {
88
- return sanitizeSegment(host, "localhost");
89
- }
90
- function sanitizeConsumer(consumer) {
91
- return sanitizeSegment(consumer, "parent");
92
- }
93
- /** Serialize one event to its NDJSON line (no trailing newline). */
94
- function serializeEvent(ev) {
95
- return JSON.stringify(ev);
96
- }
16
+ //#region ts/agentTree.ts
97
17
  /**
98
- * Parse an inbox's raw text into events. Tolerant by design: a torn final line
99
- * (a writer mid-append) or any unparseable line is skipped, never throws a
100
- * reader must survive a concurrent writer. Order is file order (= seq order,
101
- * since a single locked writer appends in increasing seq).
18
+ * Link records into a forest via parent_pid === wrapper_pid. Records whose
19
+ * parent isn't present in the set (top-level agents, or links into agents
20
+ * filtered out by a keyword/scope) become roots. Root and sibling order follows
21
+ * the input order, so a caller that pre-sorts (e.g. newest-first) is preserved.
102
22
  */
103
- function parseInboxText(text) {
104
- const out = [];
105
- for (const line of text.split("\n")) {
106
- const t = line.trim();
107
- if (!t) continue;
108
- try {
109
- const ev = JSON.parse(t);
110
- if (isValidEvent(ev)) out.push(ev);
111
- } catch {}
23
+ function buildAgentForest(records) {
24
+ const nodes = records.map((record) => ({
25
+ record,
26
+ children: []
27
+ }));
28
+ const byWrapper = /* @__PURE__ */ new Map();
29
+ for (const n of nodes) {
30
+ const w = n.record.wrapper_pid;
31
+ if (typeof w === "number" && w > 0) byWrapper.set(w, n);
112
32
  }
113
- return out;
33
+ const roots = [];
34
+ for (const n of nodes) {
35
+ const p = n.record.parent_pid;
36
+ const parent = typeof p === "number" && p > 0 ? byWrapper.get(p) : void 0;
37
+ if (parent && parent !== n) parent.children.push(n);
38
+ else roots.push(n);
39
+ }
40
+ const seen = /* @__PURE__ */ new Set();
41
+ const mark = (n) => {
42
+ if (seen.has(n)) return;
43
+ seen.add(n);
44
+ n.children.forEach(mark);
45
+ };
46
+ roots.forEach(mark);
47
+ for (const n of nodes) if (!seen.has(n)) roots.push(n);
48
+ return roots;
114
49
  }
115
- const EDGES = new Set([
116
- "needs_input",
117
- "idle",
118
- "exited"
119
- ]);
120
50
  /**
121
- * Full shape validation at the storage boundary a torn/partial line missing
122
- * the correlation fields must never flow through to a consumer's output. Every
123
- * event we surface has the fields a reader relies on.
51
+ * Depth-first flatten a forest into rows carrying a box-drawing branch prefix.
52
+ * A `visited` guard makes a pathological parent_pid cycle terminate instead of
53
+ * recursing forever.
124
54
  */
125
- function isValidEvent(ev) {
126
- if (!ev || typeof ev !== "object") return false;
127
- const e = ev;
128
- 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");
129
- }
130
- /** Next seq to allocate given the last stored seq counter (or the inbox max). */
131
- function nextSeq(lastSeq) {
132
- return (Number.isFinite(lastSeq) && lastSeq > 0 ? lastSeq : 0) + 1;
133
- }
134
- /** Highest seq present in a parsed inbox (0 when empty). */
135
- function maxSeq(events) {
136
- let m = 0;
137
- for (const e of events) if (e.seq > m) m = e.seq;
138
- return m;
55
+ function flattenForest(roots) {
56
+ const rows = [];
57
+ const visited = /* @__PURE__ */ new Set();
58
+ const walk = (node, ancestorsLast) => {
59
+ if (visited.has(node)) return;
60
+ visited.add(node);
61
+ const depth = ancestorsLast.length;
62
+ let prefix = "";
63
+ for (let i = 0; i < depth - 1; i++) prefix += ancestorsLast[i] ? " " : "│ ";
64
+ if (depth > 0) prefix += ancestorsLast[depth - 1] ? "└─ " : "├─ ";
65
+ rows.push({
66
+ record: node.record,
67
+ prefix,
68
+ depth
69
+ });
70
+ node.children.forEach((c, i) => walk(c, [...ancestorsLast, i === node.children.length - 1]));
71
+ };
72
+ for (const r of roots) walk(r, []);
73
+ return rows;
139
74
  }
75
+
76
+ //#endregion
77
+ //#region ts/todoParse.ts
140
78
  /**
141
- * Filter events for `--since <seq>`: strictly-greater than the given seq. A
142
- * `since` of 0/undefined returns everything.
79
+ * Parse an agent's todo/task list out of its RENDERED TUI screen.
80
+ *
81
+ * Source of truth for EVERY CLI (claude, codex, gemini, …) is the screen the
82
+ * agent draws — never a CLI-specific session file. The durable copy is the
83
+ * per-pid raw log (`<cwd>/.agent-yes/<pid>.raw.log`); rendering it through a
84
+ * headless xterm (see renderRawLog) collapses the reflow/redraw frames into the
85
+ * final coherent text, which is what we scan here.
86
+ *
87
+ * The todo list in these TUIs renders as a tree block anchored by the `⎿`
88
+ * branch glyph, one marker per line:
89
+ *
90
+ * ⎿ ☒ Wire up the parser
91
+ * ☒ Add the badge
92
+ * ◼ Compute in /api/ls ← in progress
93
+ * ◻ Render in the console ← pending
94
+ * ◻ Tests
95
+ *
96
+ * Badge = `${done}/${total}` (done is the numerator → "2/5").
97
+ *
98
+ * This parse is deliberately conservative: we only report a count when a block
99
+ * is confidently detected (the `⎿` anchor + ≥2 consecutive marker lines), so an
100
+ * agent that merely prints a check glyph in prose never produces a phantom badge.
143
101
  */
144
- function filterSinceSeq(events, sinceSeq) {
145
- if (!sinceSeq || sinceSeq <= 0) return events.slice();
146
- return events.filter((e) => e.seq > sinceSeq);
147
- }
148
- /** Filter events after a wall-clock `--since <ts>` (inclusive lower bound). */
149
- function filterSinceTs(events, sinceTs) {
150
- if (!sinceTs || sinceTs <= 0) return events.slice();
151
- return events.filter((e) => e.ts >= sinceTs);
152
- }
153
- /** Unread = seq strictly greater than the consumer's cursor. */
154
- function filterUnread(events, cursorSeq) {
155
- return events.filter((e) => e.seq > (cursorSeq > 0 ? cursorSeq : 0));
156
- }
157
- /** Parse a cursor file's text; a missing/garbage cursor reads as seq 0. */
158
- function parseCursor(text) {
159
- if (!text) return { seq: 0 };
160
- try {
161
- const c = JSON.parse(text);
162
- return { seq: Number.isFinite(c?.seq) && c.seq > 0 ? c.seq : 0 };
163
- } catch {
164
- return { seq: 0 };
165
- }
166
- }
167
- function serializeCursor(seq) {
168
- return JSON.stringify({ seq: Math.max(0, Math.floor(seq)) });
102
+ const DONE = new Set([
103
+ "✔",
104
+ "☑",
105
+ "✓",
106
+ "☒"
107
+ ]);
108
+ const IN_PROGRESS = new Set(["◼"]);
109
+ const PENDING = new Set(["◻", "☐"]);
110
+ const ANCHOR = "⎿";
111
+ function markerOf(line) {
112
+ let s = line.replace(/^\s+/, "");
113
+ if (s.startsWith(ANCHOR)) s = s.slice(1).replace(/^\s+/, "");
114
+ const ch = [...s][0];
115
+ if (ch === void 0) return null;
116
+ if (DONE.has(ch)) return "done";
117
+ if (IN_PROGRESS.has(ch)) return "inprogress";
118
+ if (PENDING.has(ch)) return "pending";
119
+ return null;
169
120
  }
170
121
  /**
171
- * Rotation decision (pure): given an inbox's events, a byte cap, and the minimum
172
- * un-acked cursor across ALL consumers, return the events to KEEP after
173
- * rotating. Critically, an event with `seq > protectAboveSeq` is NEVER dropped —
174
- * that is the at-least-once guarantee: we must not evict an edge no consumer has
175
- * acknowledged yet, even under the byte cap. Below that watermark we keep the
176
- * newest events that fit the cap (and at least `minKeep`).
122
+ * Find the MOST RECENT confidently-detected todo block in the rendered lines and
123
+ * return its {done, total}. Returns null when none qualifies (caller omits the
124
+ * badge entirely never shows "0/0").
125
+ *
126
+ * A block is a maximal run of consecutive marker lines. It only counts when it
127
+ * is anchored the `⎿` glyph appears on the run's first line or the line
128
+ * directly above it — and has ≥2 marker lines. The last qualifying block wins,
129
+ * since the agent's current todo state is the one drawn most recently.
177
130
  */
178
- function rotateKeep(events, capBytes, protectAboveSeq = 0, minKeep = 100) {
179
- if (events.length <= minKeep) return events.slice();
180
- const kept = [];
181
- let bytes = 0;
182
- for (let i = events.length - 1; i >= 0; i--) {
183
- const e = events[i];
184
- const line = serializeEvent(e) + "\n";
185
- bytes += line.length;
186
- if (e.seq > protectAboveSeq || kept.length < minKeep || bytes <= capBytes) kept.push(e);
187
- else break;
131
+ function parseTaskCounts(lines) {
132
+ let best = null;
133
+ const n = lines.length;
134
+ let i = 0;
135
+ while (i < n) {
136
+ if (markerOf(lines[i]) === null) {
137
+ i++;
138
+ continue;
139
+ }
140
+ let hasAnchor = i > 0 && lines[i - 1].includes(ANCHOR);
141
+ const counts = {
142
+ done: 0,
143
+ inprogress: 0,
144
+ pending: 0
145
+ };
146
+ let j = i;
147
+ for (; j < n; j++) {
148
+ const mk = markerOf(lines[j]);
149
+ if (mk === null) break;
150
+ if (lines[j].includes(ANCHOR)) hasAnchor = true;
151
+ counts[mk]++;
152
+ }
153
+ const total = counts.done + counts.inprogress + counts.pending;
154
+ if (hasAnchor && total >= 2) best = {
155
+ done: counts.done,
156
+ total
157
+ };
158
+ i = j === i ? i + 1 : j;
188
159
  }
189
- kept.reverse();
190
- return kept;
160
+ return best;
191
161
  }
192
162
 
193
163
  //#endregion
194
- //#region ts/notifyStore.ts
164
+ //#region ts/badges.ts
165
+ const BADGE_DEFS = [
166
+ {
167
+ id: "goal-active",
168
+ label: "goal",
169
+ title: "A /goal Stop-hook loop is active on this agent",
170
+ pattern: /\/goal active/i
171
+ },
172
+ {
173
+ id: "session-limit",
174
+ label: "limit",
175
+ title: "Usage session limit hit — waiting for the reset time shown on screen",
176
+ pattern: /you['’]?ve hit your session limit/i
177
+ },
178
+ {
179
+ id: "retrying",
180
+ label: "retry",
181
+ title: "Waiting for the API — the CLI is auto-retrying on its own backoff (no action needed)",
182
+ pattern: /Waiting for API response[\s\S]{0,40}will retry in \d/i
183
+ }
184
+ ];
195
185
  /**
196
- * Filesystem side of the notification inbox the impure half of the pure
197
- * `notifyInbox.ts` path/serialization core. Handles the locked append (so
198
- * concurrent daemon ticks / future writers never interleave a seq), inbox and
199
- * cursor reads/writes, and retention GC.
200
- *
201
- * The lock is a mkdir-based advisory lock (same primitive `pidStore` uses), held
202
- * only for the microseconds of a read-counter → append-line → write-counter, so
203
- * contention is negligible even with many parents.
186
+ * Which badge ids match the given rendered screen lines. Pure so it's
187
+ * unit-tested without a live PTY/log file.
204
188
  */
205
- /** Stable local host id — the pid namespace is per-host. */
206
- function hostId() {
207
- return os.hostname() || "localhost";
208
- }
209
- function pidAlive(pid) {
210
- if (!Number.isFinite(pid) || pid <= 0) return false;
211
- try {
212
- process.kill(pid, 0);
213
- return true;
214
- } catch (e) {
215
- return e.code === "EPERM";
216
- }
189
+ function matchBadges(lines, defs = BADGE_DEFS) {
190
+ const text = lines.join("\n");
191
+ return defs.filter((d) => d.pattern.test(text)).map((d) => d.id);
217
192
  }
218
193
  /**
219
- * Pure steal decision for a per-inbox lock (extracted so it's unit-testable):
220
- * steal the lock ONLY when its holder is provably gone (torn/missing owner on a
221
- * lock that has EXISTED longer than the grace, or a dead pid) or its heartbeat is
222
- * staleNEVER merely because the contender has waited a while.
223
- *
224
- * Crucially, the torn-owner grace is measured from the LOCK INSTANCE's age
225
- * (`lockAgeMs` = now − lockDir mtime), NOT from how long the contender has been
226
- * waiting. Otherwise a contender that waited out one holder could instantly steal
227
- * the NEXT holder's freshly-created lock during its mkdir→write-owner window
228
- * (torn but brand-new) — letting two writers into the critical section.
194
+ * A time-derived flag (NOT screen-matched): lit when the user typed at this
195
+ * agent's terminal within the last few seconds. The ls code appends its id from
196
+ * the Rust runner's stdin-activity marker, so it resolves through `badgeDef`
197
+ * like any other chip but `matchBadges` never produces it (it isn't in
198
+ * BADGE_DEFS, and its pattern can't match), keeping screen matching pure.
229
199
  */
230
- function shouldStealLock(ownerRaw, now, opts) {
231
- const graceMs = opts.graceMs ?? 1e3;
232
- let ownerPid = 0;
233
- let ownerTs = 0;
234
- let parsed = false;
235
- try {
236
- const o = JSON.parse(ownerRaw);
237
- ownerPid = o?.pid ?? 0;
238
- ownerTs = o?.ts ?? 0;
239
- parsed = true;
240
- } catch {}
241
- if (!parsed || ownerPid <= 0 || ownerTs <= 0) return opts.lockAgeMs > graceMs;
242
- const holderDead = ownerPid !== opts.selfPid && !opts.isAlive(ownerPid);
243
- const holderStale = now - ownerTs > opts.staleMs;
244
- return holderDead || holderStale;
200
+ const TYPING_BADGE = {
201
+ id: "typing",
202
+ label: "typing",
203
+ title: "The user is typing at this agent's terminal — ay send backs off until they pause",
204
+ pattern: /(?!)/
205
+ };
206
+ function badgeDef(id, defs = BADGE_DEFS) {
207
+ return defs.find((d) => d.id === id) ?? (id === TYPING_BADGE.id ? TYPING_BADGE : void 0);
245
208
  }
246
- async function ensureDir(dir) {
247
- await mkdir(dir, { recursive: true }).catch(() => {});
209
+
210
+ //#endregion
211
+ //#region ts/needsInput.ts
212
+ function reTest(re, s) {
213
+ return (re.global || re.sticky ? new RegExp(re.source, re.flags.replace(/[gy]/g, "")) : re).test(s);
214
+ }
215
+ function isChromeLine(s) {
216
+ const t = s.trim();
217
+ return !t || /^─+$/.test(t) || /^esc to (interrupt|cancel)/i.test(t) || /\? for shortcuts/.test(t) || /\d+%\s*until auto-compact/i.test(t);
248
218
  }
249
219
  /**
250
- * Atomically overwrite a single file (temp + rename) so a concurrent reader never
251
- * observes a torn/partial write the sweep invariant for every single-file
252
- * mutation (cursor, watcher heartbeat, lock owner). Returns whether it landed.
220
+ * Returns a NeedsInput when the screen shows an unresolved selection menu, else
221
+ * null. `cfg.working` short-circuits to null (an actively-working agent isn't
222
+ * blocked). Pure + synchronous so it's trivially unit-testable.
253
223
  */
254
- async function atomicWrite(file, data) {
255
- const tmp = `${file}.${randomUUID()}.tmp`;
256
- try {
257
- await writeFile(tmp, data);
258
- await rename(tmp, file);
259
- return true;
260
- } catch {
261
- await rm(tmp, { force: true }).catch(() => {});
262
- return false;
263
- }
224
+ function classifyNeedsInput(lines, cfg) {
225
+ const patterns = cfg.needsInput ?? [];
226
+ if (patterns.length === 0) return null;
227
+ const text = lines.join("\n");
228
+ if ((cfg.working ?? []).some((re) => reTest(re, text))) return null;
229
+ if (!patterns.some((re) => reTest(re, text))) return null;
230
+ let last = -1;
231
+ for (let i = 0; i < lines.length; i++) if (patterns.some((re) => reTest(re, lines[i]))) last = i;
232
+ const start = Math.max(0, last - 6);
233
+ const end = Math.min(lines.length, last + 6);
234
+ return { question: lines.slice(start, end).map((l) => l.trim()).filter((l) => l && !isChromeLine(l)).join(" • ").slice(0, 400) };
264
235
  }
236
+ const OPTION_LINE = /^[\s❯›>▶◉○●·*-]*?(\d+)\.\s/;
265
237
  /**
266
- * Acquire a per-inbox lock; returns a release fn. Ownership is proven ONLY by a
267
- * successful `mkdir(recursive:false)` (atomic exclusive create), and stealing is
268
- * driven by HOLDER LIVENESS, not elapsed wait time: the holder writes an
269
- * `owner` file ({pid, ts}) on acquire AND heartbeats its ts for the whole time
270
- * it holds the lock, so a contender steals ONLY when that holder is dead or its
271
- * heartbeat is stale. A live holder whose critical section legitimately runs long
272
- * (a big GC rewrite, a slow disk) is never robbed — its heartbeat stays fresh;
273
- * and a wedged holder stops heartbeating, so staleMs still reclaims it. There is
274
- * deliberately NO wall-clock backstop: elapsed wait time alone never steals, so a
275
- * live holder is inviolable.
238
+ * Parse the selection menu a `needs_input` agent is parked on into a cursor
239
+ * position + the available option numbers, so a caller can compute how far the
240
+ * cursor must move (Down/Up) to reach a target option. Returns null when the
241
+ * screen isn't a menu (delegates that judgement to {@link classifyNeedsInput},
242
+ * so `working` still wins) or no numbered cursor line is found. Pure the
243
+ * `ay select` action reuses the exact detection `ay ls` renders with.
276
244
  */
277
- async function acquireLock(lockDir, staleMs = 3e4) {
278
- const ownerFile = path.join(lockDir, "owner");
279
- const token = randomUUID();
280
- const readOwnerToken = async () => {
281
- try {
282
- return JSON.parse(await readFile(ownerFile, "utf8")).token ?? null;
283
- } catch {
284
- return null;
245
+ function parseMenu(lines, cfg) {
246
+ const ni = classifyNeedsInput(lines, cfg);
247
+ if (!ni) return null;
248
+ const patterns = cfg.needsInput ?? [];
249
+ let cursorLine = -1;
250
+ for (let i = 0; i < lines.length; i++) if (patterns.some((re) => reTest(re, lines[i]))) cursorLine = i;
251
+ if (cursorLine < 0) return null;
252
+ const cm = /(\d+)\./.exec(lines[cursorLine]);
253
+ if (!cm) return null;
254
+ const cursor = parseInt(cm[1], 10);
255
+ const start = Math.max(0, cursorLine - 12);
256
+ const end = Math.min(lines.length, cursorLine + 12);
257
+ const options = [];
258
+ for (let i = start; i < end; i++) {
259
+ const m = OPTION_LINE.exec(lines[i]);
260
+ if (m) {
261
+ const v = parseInt(m[1], 10);
262
+ if (!options.includes(v)) options.push(v);
285
263
  }
264
+ }
265
+ if (!options.includes(cursor)) options.push(cursor);
266
+ options.sort((a, b) => a - b);
267
+ return {
268
+ cursor,
269
+ options,
270
+ question: ni.question
286
271
  };
287
- const stampOwner = async () => {
288
- const tmp = `${ownerFile}.${token}.tmp`;
289
- try {
290
- await writeFile(tmp, JSON.stringify({
291
- pid: process.pid,
292
- ts: Date.now(),
293
- token
294
- }));
295
- await rename(tmp, ownerFile);
296
- return true;
297
- } catch {
298
- await rm(tmp, { force: true }).catch(() => {});
299
- return false;
300
- }
272
+ }
273
+ /**
274
+ * True when the rendered screen still shows a "busy" marker (config `working`,
275
+ * e.g. claude's `esc to interrupt`). Paired with a long-quiet log this is the
276
+ * `stuck` signal: a live spinner writes to the log every frame, so a busy marker
277
+ * on screen WITHOUT recent output means the agent wedged mid-stream (a silent
278
+ * API stream stall) rather than finishing. Pure + synchronous like the rest of
279
+ * this module so it's trivially unit-testable.
280
+ */
281
+ function isWorkingScreen(lines, working) {
282
+ if (!working?.length) return false;
283
+ const text = lines.join("\n");
284
+ return working.some((re) => reTest(re, text));
285
+ }
286
+
287
+ //#endregion
288
+ //#region ts/lsWatch.ts
289
+ /**
290
+ * Diff the previous per-pid states against the current snapshot and return the
291
+ * transition events to emit plus the next prev-map. Pure: no I/O, no clock —
292
+ * the caller passes `ts`.
293
+ *
294
+ * Emits an event when:
295
+ * - an agent is seen for the first time (baseline, `prev_state: null`)
296
+ * - its `state` or `question` changed since last tick
297
+ * - it vanished from the live set without us ever seeing it `stopped` (reaped
298
+ * between ticks) — a synthetic `stopped` event so a "done" transition is
299
+ * never silently dropped.
300
+ */
301
+ function diffLsStates(prev, cur, ts) {
302
+ const events = [];
303
+ const next = /* @__PURE__ */ new Map();
304
+ const curPids = /* @__PURE__ */ new Set();
305
+ for (const a of cur) {
306
+ curPids.add(a.pid);
307
+ next.set(a.pid, a);
308
+ const p = prev.get(a.pid);
309
+ if (!p) events.push({
310
+ ...toEvent(a, ts),
311
+ prev_state: null
312
+ });
313
+ else if (p.state !== a.state || p.question !== a.question) events.push({
314
+ ...toEvent(a, ts),
315
+ prev_state: p.state
316
+ });
317
+ }
318
+ for (const [pid, p] of prev) if (!curPids.has(pid) && p.state !== "stopped") events.push({
319
+ ts,
320
+ pid,
321
+ cli: p.cli,
322
+ cwd: p.cwd,
323
+ state: "stopped",
324
+ question: null,
325
+ prev_state: p.state
326
+ });
327
+ return {
328
+ events,
329
+ next
301
330
  };
302
- const lockAgeMs = async () => {
303
- const m = await stat(lockDir).then((s) => s.mtimeMs).catch(() => Date.now());
304
- return Date.now() - m;
331
+ }
332
+ function toEvent(a, ts) {
333
+ return {
334
+ ts,
335
+ pid: a.pid,
336
+ cli: a.cli,
337
+ cwd: a.cwd,
338
+ state: a.state,
339
+ question: a.question
305
340
  };
306
- for (;;) try {
307
- await mkdir(lockDir, { recursive: false });
308
- if (!await stampOwner()) {
309
- await rm(lockDir, {
310
- recursive: true,
311
- force: true
312
- }).catch(() => {});
313
- await new Promise((r) => setTimeout(r, 15));
314
- continue;
315
- }
316
- const beat = setInterval(() => {
317
- (async () => {
318
- if (await readOwnerToken() !== token) {
319
- clearInterval(beat);
320
- return;
321
- }
322
- await stampOwner();
323
- })();
324
- }, Math.max(500, Math.floor(staleMs / 3)));
325
- if (typeof beat.unref === "function") beat.unref();
326
- return async () => {
327
- clearInterval(beat);
328
- if (await readOwnerToken() === token) await rm(lockDir, {
329
- recursive: true,
330
- force: true
331
- }).catch(() => {});
332
- };
333
- } catch (e) {
334
- if (e.code !== "EEXIST") throw e;
335
- if (shouldStealLock(await readFile(ownerFile, "utf8").catch(() => ""), Date.now(), {
336
- staleMs,
337
- lockAgeMs: await lockAgeMs(),
338
- selfPid: process.pid,
339
- isAlive: pidAlive
340
- })) {
341
- await rm(lockDir, {
342
- recursive: true,
343
- force: true
344
- }).catch(() => {});
345
- continue;
346
- }
347
- await new Promise((r) => setTimeout(r, 15));
348
- }
349
341
  }
342
+
343
+ //#endregion
344
+ //#region ts/notifyInbox.ts
350
345
  /**
351
- * Append one event to a parent's inbox under lock, allocating the next seq. The
352
- * caller passes the event WITHOUT seq (we stamp it). Returns the stamped seq.
346
+ * Pure, fs-free core for the subagent→parent notification inbox the storage
347
+ * and watermark layer under `ay notify` / `ay notifyd`.
348
+ *
349
+ * Motivation (real incident): a parent agent fanned out sub-agents that finished
350
+ * their work but sat at an idle `❯` prompt WITHOUT exiting (claude-yes does not
351
+ * exit on idle). Claude Code's background-task notification only fires on process
352
+ * EXIT, so the parent never learned the children went idle and left them parked
353
+ * 16 minutes. `ay ls --watch` already streams transitions, but it is PULL: the
354
+ * parent must run the watch loop. The whole point of this pain is the parent is
355
+ * NOT watching. So we accumulate qualifying edges into a per-parent append-only
356
+ * inbox that the parent drains on its own schedule (its Monitor loop), with a
357
+ * persisted cursor so a parent that restarts reads only the unread edges.
358
+ *
359
+ * This module is the pure part (path math, NDJSON (de)serialization, seq/cursor
360
+ * filtering, retention decisions) so it is trivially unit-testable, mirroring
361
+ * `lsWatch.ts` / `needsInput.ts` / `resultEnvelope.ts`. The fs read/write + lock
362
+ * + CLI live in `subcommands.ts`; the detection/debounce lives in
363
+ * `notifyRouter.ts`.
353
364
  */
354
- async function appendEvent(parentPid, ev) {
355
- const host = ev.host;
356
- const dir = inboxDir(host);
357
- await ensureDir(dir);
358
- const release = await acquireLock(path.join(dir, `${parentPid}.lock`));
359
- try {
360
- let last = 0;
361
- const counterRaw = await readFile(seqPath(host, parentPid), "utf8").catch(() => "");
362
- const parsed = parseInt(counterRaw.trim(), 10);
363
- if (Number.isFinite(parsed) && parsed > 0) last = parsed;
364
- else last = maxSeq(parseInboxText(await readFile(inboxPath(host, parentPid), "utf8").catch(() => "")));
365
- const seq = nextSeq(last);
366
- const full = {
367
- ...ev,
368
- seq
369
- };
370
- await writeFile(seqPath(host, parentPid), String(seq));
371
- await appendFile(inboxPath(host, parentPid), serializeEvent(full) + "\n");
372
- return seq;
373
- } finally {
374
- await release();
375
- }
365
+ /** Root dir for all notification state. */
366
+ function notifyDir() {
367
+ return path.join(agentYesHome(), "notify");
368
+ }
369
+ /** Per-host inbox directory (pids are only unique within a host). */
370
+ function inboxDir(host) {
371
+ return path.join(notifyDir(), "inbox", sanitizeHost(host));
376
372
  }
377
- /** Read + parse a parent's inbox (empty array when none). */
378
- async function readInbox(host, parentPid) {
379
- return parseInboxText(await readFile(inboxPath(host, parentPid), "utf8").catch(() => ""));
373
+ /** Absolute path of one parent's append-only NDJSON inbox. */
374
+ function inboxPath(host, parentPid) {
375
+ return path.join(inboxDir(host), `${parentPid}.ndjson`);
380
376
  }
381
- /** All parent pids that currently have an inbox on this host. */
382
- async function listInboxParents(host) {
383
- const names = await readdir(inboxDir(host)).catch(() => []);
384
- const pids = [];
385
- for (const n of names) {
386
- const m = /^(\d+)\.ndjson$/.exec(n);
387
- if (m) pids.push(parseInt(m[1], 10));
388
- }
389
- return pids;
377
+ /** Sidecar seq counter for a parent inbox (last allocated seq). */
378
+ function seqPath(host, parentPid) {
379
+ return path.join(inboxDir(host), `${parentPid}.seq`);
390
380
  }
391
- async function getCursor(host, parentPid, consumer = "parent") {
392
- return parseCursor(await readFile(cursorPath(host, parentPid, consumer), "utf8").catch(() => null)).seq;
381
+ /** Per-consumer cursor directory for a parent inbox. */
382
+ function cursorDir(host, parentPid) {
383
+ return path.join(notifyDir(), "cursors", sanitizeHost(host), String(parentPid));
393
384
  }
394
- /**
395
- * Minimum cursor across ALL consumers of a parent inbox — the watermark below
396
- * which rotation may evict events (nothing above it has been acked by every
397
- * reader). Returns 0 when there are no consumers — which PROTECTS EVERYTHING:
398
- * with a 0 watermark, `rotateKeep` treats every event as unacked and evicts
399
- * nothing (an inbox nobody reads is never trimmed).
400
- */
401
- async function minConsumerCursor(host, parentPid) {
402
- const cursors = (await readdir(cursorDir(host, parentPid)).catch(() => [])).filter((n) => n.endsWith(".json")).map((n) => n.slice(0, -5));
403
- if (cursors.length === 0) return 0;
404
- let min = Infinity;
405
- for (const c of cursors) min = Math.min(min, await getCursor(host, parentPid, c));
406
- return Number.isFinite(min) ? min : 0;
385
+ /** Absolute path of one consumer's cursor file for a parent inbox. */
386
+ function cursorPath(host, parentPid, consumer = "parent") {
387
+ return path.join(cursorDir(host, parentPid), `${sanitizeConsumer(consumer)}.json`);
407
388
  }
408
- async function setCursor(host, parentPid, seq, consumer = "parent") {
409
- const p = cursorPath(host, parentPid, consumer);
410
- await ensureDir(path.dirname(p));
411
- await atomicWrite(p, serializeCursor(seq));
389
+ /** The daemon singleton lock dir (mkdir-based, like pidStore). */
390
+ function daemonLockDir() {
391
+ return path.join(notifyDir(), "notifyd.lock");
412
392
  }
413
393
  /**
414
- * Retention: delete inboxes (+ their seq counter + cursors) for parents that are
415
- * both dead and unreferenced by any live child. `capBytes` also rotates a live
416
- * inbox that has grown past the cap, keeping the newest events.
394
+ * Owner metadata inside the lock dir ({pid, started_at, ts}) the SINGLE source
395
+ * of truth for the running daemon's identity, used for stale-lock steal and for
396
+ * `notifyd status`/`stop` (a running daemon refreshes `ts` each tick).
417
397
  */
418
- async function gcInboxes(host, livePids, liveChildParentPids, capBytes = 10 * 1024 * 1024) {
419
- const parents = await listInboxParents(host);
420
- for (const p of parents) {
421
- const release = await acquireLock(path.join(inboxDir(host), `${p}.lock`));
422
- try {
423
- if (!livePids.has(p) && !liveChildParentPids.has(p)) {
424
- await rm(inboxPath(host, p), { force: true }).catch(() => {});
425
- await rm(seqPath(host, p), { force: true }).catch(() => {});
426
- await rm(cursorDir(host, p), {
427
- recursive: true,
428
- force: true
429
- }).catch(() => {});
430
- continue;
431
- }
432
- const size = await stat(inboxPath(host, p)).then((s) => s.size).catch(() => 0);
433
- if (size <= capBytes) continue;
434
- const events = await readInbox(host, p);
435
- const kept = rotateKeep(events, capBytes, await minConsumerCursor(host, p));
436
- if (kept.length < events.length) await writeFile(inboxPath(host, p), kept.map(serializeEvent).join("\n") + "\n");
437
- else logger.warn(`[notify] inbox for parent ${p} is ${size} bytes (> soft cap ${capBytes}) but all events are unacked — cursor not advancing?`);
438
- } finally {
439
- await release();
440
- }
441
- }
398
+ function daemonLockOwnerPath() {
399
+ return path.join(daemonLockDir(), "owner.json");
442
400
  }
443
- /** Register / refresh this parent's watch heartbeat (atomically, so a concurrent
444
- * liveWatchers never reads a torn file and transiently drops the watcher). */
445
- async function heartbeatWatcher(parentPid, startedAt) {
446
- await ensureDir(watchersDir());
447
- await atomicWrite(watcherPath(parentPid), JSON.stringify({
448
- pid: parentPid,
449
- started_at: startedAt,
450
- ts: Date.now()
451
- }));
401
+ /** Registry dir of parents currently running `ay notify watch` (heartbeats). */
402
+ function watchersDir() {
403
+ return path.join(notifyDir(), "watchers");
452
404
  }
453
- /** Remove this parent's watch heartbeat (on watch exit). */
454
- async function clearWatcher(parentPid) {
455
- await rm(watcherPath(parentPid), { force: true }).catch(() => {});
405
+ /** Heartbeat file for one watching parent. */
406
+ function watcherPath(parentPid) {
407
+ return path.join(watchersDir(), `${parentPid}.json`);
408
+ }
409
+ /** A watcher heartbeat is "live" if refreshed within this window. */
410
+ const WATCHER_TTL_MS = 15e3;
411
+ function sanitizeSegment(s, fallback) {
412
+ const cleaned = s.replace(/[^A-Za-z0-9._-]/g, "_");
413
+ return cleaned === "" || cleaned === "." || cleaned === ".." ? fallback : cleaned;
414
+ }
415
+ function sanitizeHost(host) {
416
+ return sanitizeSegment(host, "localhost");
417
+ }
418
+ function sanitizeConsumer(consumer) {
419
+ return sanitizeSegment(consumer, "parent");
420
+ }
421
+ /** Serialize one event to its NDJSON line (no trailing newline). */
422
+ function serializeEvent(ev) {
423
+ return JSON.stringify(ev);
456
424
  }
457
425
  /**
458
- * Live watchers as a map of parent pid the parent's own `started_at` (its
459
- * authoritative self-reported start time, so the daemon never has to guess it
460
- * from the registry and stamp a 0). A watcher counts as live ONLY if its
461
- * heartbeat is fresh AND its process is actually alive — so a crashed `watch`
462
- * whose heartbeat lingers for the TTL doesn't keep the daemon writing to a dead
463
- * parent's inbox (which would violate "nothing happens unless you watch"). A
464
- * heartbeat with a missing/non-positive `started_at` is treated as NOT live: the
465
- * daemon's cross-session scope guard keys off a positive parent start time, so a
466
- * 0 would defeat it — fail closed.
426
+ * Parse an inbox's raw text into events. Tolerant by design: a torn final line
427
+ * (a writer mid-append) or any unparseable line is skipped, never throws a
428
+ * reader must survive a concurrent writer. Order is file order (= seq order,
429
+ * since a single locked writer appends in increasing seq).
467
430
  */
468
- async function liveWatchers(now = Date.now()) {
469
- const names = await readdir(watchersDir()).catch(() => []);
470
- const live = /* @__PURE__ */ new Map();
471
- for (const n of names) {
472
- if (!n.endsWith(".json")) continue;
473
- const raw = await readFile(path.join(watchersDir(), n), "utf8").catch(() => "");
431
+ function parseInboxText(text) {
432
+ const out = [];
433
+ for (const line of text.split("\n")) {
434
+ const t = line.trim();
435
+ if (!t) continue;
474
436
  try {
475
- const o = JSON.parse(raw);
476
- 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)) live.set(o.pid, o.started_at);
437
+ const ev = JSON.parse(t);
438
+ if (isValidEvent(ev)) out.push(ev);
477
439
  } catch {}
478
440
  }
479
- return live;
441
+ return out;
480
442
  }
481
-
482
- //#endregion
483
- //#region ts/agentTree.ts
443
+ const EDGES = new Set([
444
+ "needs_input",
445
+ "idle",
446
+ "exited"
447
+ ]);
484
448
  /**
485
- * Link records into a forest via parent_pid === wrapper_pid. Records whose
486
- * parent isn't present in the set (top-level agents, or links into agents
487
- * filtered out by a keyword/scope) become roots. Root and sibling order follows
488
- * the input order, so a caller that pre-sorts (e.g. newest-first) is preserved.
449
+ * Full shape validation at the storage boundary a torn/partial line missing
450
+ * the correlation fields must never flow through to a consumer's output. Every
451
+ * event we surface has the fields a reader relies on.
489
452
  */
490
- function buildAgentForest(records) {
491
- const nodes = records.map((record) => ({
492
- record,
493
- children: []
494
- }));
495
- const byWrapper = /* @__PURE__ */ new Map();
496
- for (const n of nodes) {
497
- const w = n.record.wrapper_pid;
498
- if (typeof w === "number" && w > 0) byWrapper.set(w, n);
499
- }
500
- const roots = [];
501
- for (const n of nodes) {
502
- const p = n.record.parent_pid;
503
- const parent = typeof p === "number" && p > 0 ? byWrapper.get(p) : void 0;
504
- if (parent && parent !== n) parent.children.push(n);
505
- else roots.push(n);
453
+ function isValidEvent(ev) {
454
+ if (!ev || typeof ev !== "object") return false;
455
+ const e = ev;
456
+ 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");
457
+ }
458
+ /** Next seq to allocate given the last stored seq counter (or the inbox max). */
459
+ function nextSeq(lastSeq) {
460
+ return (Number.isFinite(lastSeq) && lastSeq > 0 ? lastSeq : 0) + 1;
461
+ }
462
+ /** Highest seq present in a parsed inbox (0 when empty). */
463
+ function maxSeq(events) {
464
+ let m = 0;
465
+ for (const e of events) if (e.seq > m) m = e.seq;
466
+ return m;
467
+ }
468
+ /**
469
+ * Filter events for `--since <seq>`: strictly-greater than the given seq. A
470
+ * `since` of 0/undefined returns everything.
471
+ */
472
+ function filterSinceSeq(events, sinceSeq) {
473
+ if (!sinceSeq || sinceSeq <= 0) return events.slice();
474
+ return events.filter((e) => e.seq > sinceSeq);
475
+ }
476
+ /** Filter events after a wall-clock `--since <ts>` (inclusive lower bound). */
477
+ function filterSinceTs(events, sinceTs) {
478
+ if (!sinceTs || sinceTs <= 0) return events.slice();
479
+ return events.filter((e) => e.ts >= sinceTs);
480
+ }
481
+ /** Unread = seq strictly greater than the consumer's cursor. */
482
+ function filterUnread(events, cursorSeq) {
483
+ return events.filter((e) => e.seq > (cursorSeq > 0 ? cursorSeq : 0));
484
+ }
485
+ /** Parse a cursor file's text; a missing/garbage cursor reads as seq 0. */
486
+ function parseCursor(text) {
487
+ if (!text) return { seq: 0 };
488
+ try {
489
+ const c = JSON.parse(text);
490
+ return { seq: Number.isFinite(c?.seq) && c.seq > 0 ? c.seq : 0 };
491
+ } catch {
492
+ return { seq: 0 };
506
493
  }
507
- const seen = /* @__PURE__ */ new Set();
508
- const mark = (n) => {
509
- if (seen.has(n)) return;
510
- seen.add(n);
511
- n.children.forEach(mark);
512
- };
513
- roots.forEach(mark);
514
- for (const n of nodes) if (!seen.has(n)) roots.push(n);
515
- return roots;
494
+ }
495
+ function serializeCursor(seq) {
496
+ return JSON.stringify({ seq: Math.max(0, Math.floor(seq)) });
516
497
  }
517
498
  /**
518
- * Depth-first flatten a forest into rows carrying a box-drawing branch prefix.
519
- * A `visited` guard makes a pathological parent_pid cycle terminate instead of
520
- * recursing forever.
499
+ * Rotation decision (pure): given an inbox's events, a byte cap, and the minimum
500
+ * un-acked cursor across ALL consumers, return the events to KEEP after
501
+ * rotating. Critically, an event with `seq > protectAboveSeq` is NEVER dropped —
502
+ * that is the at-least-once guarantee: we must not evict an edge no consumer has
503
+ * acknowledged yet, even under the byte cap. Below that watermark we keep the
504
+ * newest events that fit the cap (and at least `minKeep`).
521
505
  */
522
- function flattenForest(roots) {
523
- const rows = [];
524
- const visited = /* @__PURE__ */ new Set();
525
- const walk = (node, ancestorsLast) => {
526
- if (visited.has(node)) return;
527
- visited.add(node);
528
- const depth = ancestorsLast.length;
529
- let prefix = "";
530
- for (let i = 0; i < depth - 1; i++) prefix += ancestorsLast[i] ? " " : "│ ";
531
- if (depth > 0) prefix += ancestorsLast[depth - 1] ? "└─ " : "├─ ";
532
- rows.push({
533
- record: node.record,
534
- prefix,
535
- depth
536
- });
537
- node.children.forEach((c, i) => walk(c, [...ancestorsLast, i === node.children.length - 1]));
538
- };
539
- for (const r of roots) walk(r, []);
540
- return rows;
506
+ function rotateKeep(events, capBytes, protectAboveSeq = 0, minKeep = 100) {
507
+ if (events.length <= minKeep) return events.slice();
508
+ const kept = [];
509
+ let bytes = 0;
510
+ for (let i = events.length - 1; i >= 0; i--) {
511
+ const e = events[i];
512
+ const line = serializeEvent(e) + "\n";
513
+ bytes += line.length;
514
+ if (e.seq > protectAboveSeq || kept.length < minKeep || bytes <= capBytes) kept.push(e);
515
+ else break;
516
+ }
517
+ kept.reverse();
518
+ return kept;
541
519
  }
542
520
 
543
521
  //#endregion
544
- //#region ts/todoParse.ts
522
+ //#region ts/notifyStore.ts
545
523
  /**
546
- * Parse an agent's todo/task list out of its RENDERED TUI screen.
547
- *
548
- * Source of truth for EVERY CLI (claude, codex, gemini, ) is the screen the
549
- * agent draws never a CLI-specific session file. The durable copy is the
550
- * per-pid raw log (`<cwd>/.agent-yes/<pid>.raw.log`); rendering it through a
551
- * headless xterm (see renderRawLog) collapses the reflow/redraw frames into the
552
- * final coherent text, which is what we scan here.
553
- *
554
- * The todo list in these TUIs renders as a tree block anchored by the `⎿`
555
- * branch glyph, one marker per line:
556
- *
557
- * ⎿ ☒ Wire up the parser
558
- * ☒ Add the badge
559
- * ◼ Compute in /api/ls ← in progress
560
- * ◻ Render in the console ← pending
561
- * ◻ Tests
562
- *
563
- * Badge = `${done}/${total}` (done is the numerator → "2/5").
524
+ * Filesystem side of the notification inbox the impure half of the pure
525
+ * `notifyInbox.ts` path/serialization core. Handles the locked append (so
526
+ * concurrent daemon ticks / future writers never interleave a seq), inbox and
527
+ * cursor reads/writes, and retention GC.
564
528
  *
565
- * This parse is deliberately conservative: we only report a count when a block
566
- * is confidently detected (the `⎿` anchor + ≥2 consecutive marker lines), so an
567
- * agent that merely prints a check glyph in prose never produces a phantom badge.
529
+ * The lock is a mkdir-based advisory lock (same primitive `pidStore` uses), held
530
+ * only for the microseconds of a read-counter append-line → write-counter, so
531
+ * contention is negligible even with many parents.
568
532
  */
569
- const DONE = new Set([
570
- "✔",
571
- "",
572
- "✓",
573
- "☒"
574
- ]);
575
- const IN_PROGRESS = new Set(["◼"]);
576
- const PENDING = new Set(["◻", "☐"]);
577
- const ANCHOR = "⎿";
578
- function markerOf(line) {
579
- let s = line.replace(/^\s+/, "");
580
- if (s.startsWith(ANCHOR)) s = s.slice(1).replace(/^\s+/, "");
581
- const ch = [...s][0];
582
- if (ch === void 0) return null;
583
- if (DONE.has(ch)) return "done";
584
- if (IN_PROGRESS.has(ch)) return "inprogress";
585
- if (PENDING.has(ch)) return "pending";
586
- return null;
533
+ /** Stable local host id — the pid namespace is per-host. */
534
+ function hostId() {
535
+ return os.hostname() || "localhost";
536
+ }
537
+ function pidAlive(pid) {
538
+ if (!Number.isFinite(pid) || pid <= 0) return false;
539
+ try {
540
+ process.kill(pid, 0);
541
+ return true;
542
+ } catch (e) {
543
+ return e.code === "EPERM";
544
+ }
587
545
  }
588
546
  /**
589
- * Find the MOST RECENT confidently-detected todo block in the rendered lines and
590
- * return its {done, total}. Returns null when none qualifies (caller omits the
591
- * badge entirely never shows "0/0").
547
+ * Pure steal decision for a per-inbox lock (extracted so it's unit-testable):
548
+ * steal the lock ONLY when its holder is provably gone (torn/missing owner on a
549
+ * lock that has EXISTED longer than the grace, or a dead pid) or its heartbeat is
550
+ * stale — NEVER merely because the contender has waited a while.
592
551
  *
593
- * A block is a maximal run of consecutive marker lines. It only counts when it
594
- * is anchored the `⎿` glyph appears on the run's first line or the line
595
- * directly above it and has ≥2 marker lines. The last qualifying block wins,
596
- * since the agent's current todo state is the one drawn most recently.
552
+ * Crucially, the torn-owner grace is measured from the LOCK INSTANCE's age
553
+ * (`lockAgeMs` = now lockDir mtime), NOT from how long the contender has been
554
+ * waiting. Otherwise a contender that waited out one holder could instantly steal
555
+ * the NEXT holder's freshly-created lock during its mkdir→write-owner window
556
+ * (torn but brand-new) — letting two writers into the critical section.
597
557
  */
598
- function parseTaskCounts(lines) {
599
- let best = null;
600
- const n = lines.length;
601
- let i = 0;
602
- while (i < n) {
603
- if (markerOf(lines[i]) === null) {
604
- i++;
605
- continue;
606
- }
607
- let hasAnchor = i > 0 && lines[i - 1].includes(ANCHOR);
608
- const counts = {
609
- done: 0,
610
- inprogress: 0,
611
- pending: 0
612
- };
613
- let j = i;
614
- for (; j < n; j++) {
615
- const mk = markerOf(lines[j]);
616
- if (mk === null) break;
617
- if (lines[j].includes(ANCHOR)) hasAnchor = true;
618
- counts[mk]++;
619
- }
620
- const total = counts.done + counts.inprogress + counts.pending;
621
- if (hasAnchor && total >= 2) best = {
622
- done: counts.done,
623
- total
624
- };
625
- i = j === i ? i + 1 : j;
626
- }
627
- return best;
558
+ function shouldStealLock(ownerRaw, now, opts) {
559
+ const graceMs = opts.graceMs ?? 1e3;
560
+ let ownerPid = 0;
561
+ let ownerTs = 0;
562
+ let parsed = false;
563
+ try {
564
+ const o = JSON.parse(ownerRaw);
565
+ ownerPid = o?.pid ?? 0;
566
+ ownerTs = o?.ts ?? 0;
567
+ parsed = true;
568
+ } catch {}
569
+ if (!parsed || ownerPid <= 0 || ownerTs <= 0) return opts.lockAgeMs > graceMs;
570
+ const holderDead = ownerPid !== opts.selfPid && !opts.isAlive(ownerPid);
571
+ const holderStale = now - ownerTs > opts.staleMs;
572
+ return holderDead || holderStale;
628
573
  }
629
-
630
- //#endregion
631
- //#region ts/badges.ts
632
- const BADGE_DEFS = [
633
- {
634
- id: "goal-active",
635
- label: "goal",
636
- title: "A /goal Stop-hook loop is active on this agent",
637
- pattern: /\/goal active/i
638
- },
639
- {
640
- id: "session-limit",
641
- label: "limit",
642
- title: "Usage session limit hit — waiting for the reset time shown on screen",
643
- pattern: /you['’]?ve hit your session limit/i
644
- },
645
- {
646
- id: "retrying",
647
- label: "retry",
648
- title: "Waiting for the API — the CLI is auto-retrying on its own backoff (no action needed)",
649
- pattern: /Waiting for API response[\s\S]{0,40}will retry in \d/i
574
+ async function ensureDir(dir) {
575
+ await mkdir(dir, { recursive: true }).catch(() => {});
576
+ }
577
+ /**
578
+ * Atomically overwrite a single file (temp + rename) so a concurrent reader never
579
+ * observes a torn/partial write — the sweep invariant for every single-file
580
+ * mutation (cursor, watcher heartbeat, lock owner). Returns whether it landed.
581
+ */
582
+ async function atomicWrite(file, data) {
583
+ const tmp = `${file}.${randomUUID()}.tmp`;
584
+ try {
585
+ await writeFile(tmp, data);
586
+ await rename(tmp, file);
587
+ return true;
588
+ } catch {
589
+ await rm(tmp, { force: true }).catch(() => {});
590
+ return false;
650
591
  }
651
- ];
592
+ }
652
593
  /**
653
- * Which badge ids match the given rendered screen lines. Pure so it's
654
- * unit-tested without a live PTY/log file.
594
+ * Acquire a per-inbox lock; returns a release fn. Ownership is proven ONLY by a
595
+ * successful `mkdir(recursive:false)` (atomic exclusive create), and stealing is
596
+ * driven by HOLDER LIVENESS, not elapsed wait time: the holder writes an
597
+ * `owner` file ({pid, ts}) on acquire AND heartbeats its ts for the whole time
598
+ * it holds the lock, so a contender steals ONLY when that holder is dead or its
599
+ * heartbeat is stale. A live holder whose critical section legitimately runs long
600
+ * (a big GC rewrite, a slow disk) is never robbed — its heartbeat stays fresh;
601
+ * and a wedged holder stops heartbeating, so staleMs still reclaims it. There is
602
+ * deliberately NO wall-clock backstop: elapsed wait time alone never steals, so a
603
+ * live holder is inviolable.
655
604
  */
656
- function matchBadges(lines, defs = BADGE_DEFS) {
657
- const text = lines.join("\n");
658
- return defs.filter((d) => d.pattern.test(text)).map((d) => d.id);
605
+ async function acquireLock(lockDir, staleMs = 3e4) {
606
+ const ownerFile = path.join(lockDir, "owner");
607
+ const token = randomUUID();
608
+ const readOwnerToken = async () => {
609
+ try {
610
+ return JSON.parse(await readFile(ownerFile, "utf8")).token ?? null;
611
+ } catch {
612
+ return null;
613
+ }
614
+ };
615
+ const stampOwner = async () => {
616
+ const tmp = `${ownerFile}.${token}.tmp`;
617
+ try {
618
+ await writeFile(tmp, JSON.stringify({
619
+ pid: process.pid,
620
+ ts: Date.now(),
621
+ token
622
+ }));
623
+ await rename(tmp, ownerFile);
624
+ return true;
625
+ } catch {
626
+ await rm(tmp, { force: true }).catch(() => {});
627
+ return false;
628
+ }
629
+ };
630
+ const lockAgeMs = async () => {
631
+ const m = await stat(lockDir).then((s) => s.mtimeMs).catch(() => Date.now());
632
+ return Date.now() - m;
633
+ };
634
+ for (;;) try {
635
+ await mkdir(lockDir, { recursive: false });
636
+ if (!await stampOwner()) {
637
+ await rm(lockDir, {
638
+ recursive: true,
639
+ force: true
640
+ }).catch(() => {});
641
+ await new Promise((r) => setTimeout(r, 15));
642
+ continue;
643
+ }
644
+ const beat = setInterval(() => {
645
+ (async () => {
646
+ if (await readOwnerToken() !== token) {
647
+ clearInterval(beat);
648
+ return;
649
+ }
650
+ await stampOwner();
651
+ })();
652
+ }, Math.max(500, Math.floor(staleMs / 3)));
653
+ if (typeof beat.unref === "function") beat.unref();
654
+ return async () => {
655
+ clearInterval(beat);
656
+ if (await readOwnerToken() === token) await rm(lockDir, {
657
+ recursive: true,
658
+ force: true
659
+ }).catch(() => {});
660
+ };
661
+ } catch (e) {
662
+ if (e.code !== "EEXIST") throw e;
663
+ if (shouldStealLock(await readFile(ownerFile, "utf8").catch(() => ""), Date.now(), {
664
+ staleMs,
665
+ lockAgeMs: await lockAgeMs(),
666
+ selfPid: process.pid,
667
+ isAlive: pidAlive
668
+ })) {
669
+ await rm(lockDir, {
670
+ recursive: true,
671
+ force: true
672
+ }).catch(() => {});
673
+ continue;
674
+ }
675
+ await new Promise((r) => setTimeout(r, 15));
676
+ }
659
677
  }
660
678
  /**
661
- * A time-derived flag (NOT screen-matched): lit when the user typed at this
662
- * agent's terminal within the last few seconds. The ls code appends its id from
663
- * the Rust runner's stdin-activity marker, so it resolves through `badgeDef`
664
- * like any other chip — but `matchBadges` never produces it (it isn't in
665
- * BADGE_DEFS, and its pattern can't match), keeping screen matching pure.
679
+ * Append one event to a parent's inbox under lock, allocating the next seq. The
680
+ * caller passes the event WITHOUT seq (we stamp it). Returns the stamped seq.
666
681
  */
667
- const TYPING_BADGE = {
668
- id: "typing",
669
- label: "typing",
670
- title: "The user is typing at this agent's terminal — ay send backs off until they pause",
671
- pattern: /(?!)/
672
- };
673
- function badgeDef(id, defs = BADGE_DEFS) {
674
- return defs.find((d) => d.id === id) ?? (id === TYPING_BADGE.id ? TYPING_BADGE : void 0);
682
+ async function appendEvent(parentPid, ev) {
683
+ const host = ev.host;
684
+ const dir = inboxDir(host);
685
+ await ensureDir(dir);
686
+ const release = await acquireLock(path.join(dir, `${parentPid}.lock`));
687
+ try {
688
+ let last = 0;
689
+ const counterRaw = await readFile(seqPath(host, parentPid), "utf8").catch(() => "");
690
+ const parsed = parseInt(counterRaw.trim(), 10);
691
+ if (Number.isFinite(parsed) && parsed > 0) last = parsed;
692
+ else last = maxSeq(parseInboxText(await readFile(inboxPath(host, parentPid), "utf8").catch(() => "")));
693
+ const seq = nextSeq(last);
694
+ const full = {
695
+ ...ev,
696
+ seq
697
+ };
698
+ await writeFile(seqPath(host, parentPid), String(seq));
699
+ await appendFile(inboxPath(host, parentPid), serializeEvent(full) + "\n");
700
+ return seq;
701
+ } finally {
702
+ await release();
703
+ }
675
704
  }
676
-
677
- //#endregion
678
- //#region ts/needsInput.ts
679
- function reTest(re, s) {
680
- return (re.global || re.sticky ? new RegExp(re.source, re.flags.replace(/[gy]/g, "")) : re).test(s);
705
+ /** Read + parse a parent's inbox (empty array when none). */
706
+ async function readInbox(host, parentPid) {
707
+ return parseInboxText(await readFile(inboxPath(host, parentPid), "utf8").catch(() => ""));
681
708
  }
682
- function isChromeLine(s) {
683
- const t = s.trim();
684
- return !t || /^─+$/.test(t) || /^esc to (interrupt|cancel)/i.test(t) || /\? for shortcuts/.test(t) || /\d+%\s*until auto-compact/i.test(t);
709
+ /** All parent pids that currently have an inbox on this host. */
710
+ async function listInboxParents(host) {
711
+ const names = await readdir(inboxDir(host)).catch(() => []);
712
+ const pids = [];
713
+ for (const n of names) {
714
+ const m = /^(\d+)\.ndjson$/.exec(n);
715
+ if (m) pids.push(parseInt(m[1], 10));
716
+ }
717
+ return pids;
718
+ }
719
+ async function getCursor(host, parentPid, consumer = "parent") {
720
+ return parseCursor(await readFile(cursorPath(host, parentPid, consumer), "utf8").catch(() => null)).seq;
685
721
  }
686
722
  /**
687
- * Returns a NeedsInput when the screen shows an unresolved selection menu, else
688
- * null. `cfg.working` short-circuits to null (an actively-working agent isn't
689
- * blocked). Pure + synchronous so it's trivially unit-testable.
723
+ * Minimum cursor across ALL consumers of a parent inbox the watermark below
724
+ * which rotation may evict events (nothing above it has been acked by every
725
+ * reader). Returns 0 when there are no consumers — which PROTECTS EVERYTHING:
726
+ * with a 0 watermark, `rotateKeep` treats every event as unacked and evicts
727
+ * nothing (an inbox nobody reads is never trimmed).
690
728
  */
691
- function classifyNeedsInput(lines, cfg) {
692
- const patterns = cfg.needsInput ?? [];
693
- if (patterns.length === 0) return null;
694
- const text = lines.join("\n");
695
- if ((cfg.working ?? []).some((re) => reTest(re, text))) return null;
696
- if (!patterns.some((re) => reTest(re, text))) return null;
697
- let last = -1;
698
- for (let i = 0; i < lines.length; i++) if (patterns.some((re) => reTest(re, lines[i]))) last = i;
699
- const start = Math.max(0, last - 6);
700
- const end = Math.min(lines.length, last + 6);
701
- return { question: lines.slice(start, end).map((l) => l.trim()).filter((l) => l && !isChromeLine(l)).join(" • ").slice(0, 400) };
729
+ async function minConsumerCursor(host, parentPid) {
730
+ const cursors = (await readdir(cursorDir(host, parentPid)).catch(() => [])).filter((n) => n.endsWith(".json")).map((n) => n.slice(0, -5));
731
+ if (cursors.length === 0) return 0;
732
+ let min = Infinity;
733
+ for (const c of cursors) min = Math.min(min, await getCursor(host, parentPid, c));
734
+ return Number.isFinite(min) ? min : 0;
735
+ }
736
+ async function setCursor(host, parentPid, seq, consumer = "parent") {
737
+ const p = cursorPath(host, parentPid, consumer);
738
+ await ensureDir(path.dirname(p));
739
+ await atomicWrite(p, serializeCursor(seq));
702
740
  }
703
- const OPTION_LINE = /^[\s❯›>▶◉○●·*-]*?(\d+)\.\s/;
704
741
  /**
705
- * Parse the selection menu a `needs_input` agent is parked on into a cursor
706
- * position + the available option numbers, so a caller can compute how far the
707
- * cursor must move (Down/Up) to reach a target option. Returns null when the
708
- * screen isn't a menu (delegates that judgement to {@link classifyNeedsInput},
709
- * so `working` still wins) or no numbered cursor line is found. Pure — the
710
- * `ay select` action reuses the exact detection `ay ls` renders with.
742
+ * Retention: delete inboxes (+ their seq counter + cursors) for parents that are
743
+ * both dead and unreferenced by any live child. `capBytes` also rotates a live
744
+ * inbox that has grown past the cap, keeping the newest events.
711
745
  */
712
- function parseMenu(lines, cfg) {
713
- const ni = classifyNeedsInput(lines, cfg);
714
- if (!ni) return null;
715
- const patterns = cfg.needsInput ?? [];
716
- let cursorLine = -1;
717
- for (let i = 0; i < lines.length; i++) if (patterns.some((re) => reTest(re, lines[i]))) cursorLine = i;
718
- if (cursorLine < 0) return null;
719
- const cm = /(\d+)\./.exec(lines[cursorLine]);
720
- if (!cm) return null;
721
- const cursor = parseInt(cm[1], 10);
722
- const start = Math.max(0, cursorLine - 12);
723
- const end = Math.min(lines.length, cursorLine + 12);
724
- const options = [];
725
- for (let i = start; i < end; i++) {
726
- const m = OPTION_LINE.exec(lines[i]);
727
- if (m) {
728
- const v = parseInt(m[1], 10);
729
- if (!options.includes(v)) options.push(v);
746
+ async function gcInboxes(host, livePids, liveChildParentPids, capBytes = 10 * 1024 * 1024) {
747
+ const parents = await listInboxParents(host);
748
+ for (const p of parents) {
749
+ const release = await acquireLock(path.join(inboxDir(host), `${p}.lock`));
750
+ try {
751
+ if (!livePids.has(p) && !liveChildParentPids.has(p)) {
752
+ await rm(inboxPath(host, p), { force: true }).catch(() => {});
753
+ await rm(seqPath(host, p), { force: true }).catch(() => {});
754
+ await rm(cursorDir(host, p), {
755
+ recursive: true,
756
+ force: true
757
+ }).catch(() => {});
758
+ continue;
759
+ }
760
+ const size = await stat(inboxPath(host, p)).then((s) => s.size).catch(() => 0);
761
+ if (size <= capBytes) continue;
762
+ const events = await readInbox(host, p);
763
+ const kept = rotateKeep(events, capBytes, await minConsumerCursor(host, p));
764
+ if (kept.length < events.length) await writeFile(inboxPath(host, p), kept.map(serializeEvent).join("\n") + "\n");
765
+ else logger.warn(`[notify] inbox for parent ${p} is ${size} bytes (> soft cap ${capBytes}) but all events are unacked — cursor not advancing?`);
766
+ } finally {
767
+ await release();
730
768
  }
731
769
  }
732
- if (!options.includes(cursor)) options.push(cursor);
733
- options.sort((a, b) => a - b);
734
- return {
735
- cursor,
736
- options,
737
- question: ni.question
738
- };
739
770
  }
740
- /**
741
- * True when the rendered screen still shows a "busy" marker (config `working`,
742
- * e.g. claude's `esc to interrupt`). Paired with a long-quiet log this is the
743
- * `stuck` signal: a live spinner writes to the log every frame, so a busy marker
744
- * on screen WITHOUT recent output means the agent wedged mid-stream (a silent
745
- * API stream stall) rather than finishing. Pure + synchronous like the rest of
746
- * this module so it's trivially unit-testable.
747
- */
748
- function isWorkingScreen(lines, working) {
749
- if (!working?.length) return false;
750
- const text = lines.join("\n");
751
- return working.some((re) => reTest(re, text));
771
+ /** Register / refresh this parent's watch heartbeat (atomically, so a concurrent
772
+ * liveWatchers never reads a torn file and transiently drops the watcher). */
773
+ async function heartbeatWatcher(parentPid, startedAt) {
774
+ await ensureDir(watchersDir());
775
+ await atomicWrite(watcherPath(parentPid), JSON.stringify({
776
+ pid: parentPid,
777
+ started_at: startedAt,
778
+ ts: Date.now()
779
+ }));
780
+ }
781
+ /** Remove this parent's watch heartbeat (on watch exit). */
782
+ async function clearWatcher(parentPid) {
783
+ await rm(watcherPath(parentPid), { force: true }).catch(() => {});
752
784
  }
753
-
754
- //#endregion
755
- //#region ts/lsWatch.ts
756
785
  /**
757
- * Diff the previous per-pid states against the current snapshot and return the
758
- * transition events to emit plus the next prev-map. Pure: no I/O, no clock —
759
- * the caller passes `ts`.
760
- *
761
- * Emits an event when:
762
- * - an agent is seen for the first time (baseline, `prev_state: null`)
763
- * - its `state` or `question` changed since last tick
764
- * - it vanished from the live set without us ever seeing it `stopped` (reaped
765
- * between ticks) a synthetic `stopped` event so a "done" transition is
766
- * never silently dropped.
786
+ * Live watchers as a map of parent pid the parent's own `started_at` (its
787
+ * authoritative self-reported start time, so the daemon never has to guess it
788
+ * from the registry and stamp a 0). A watcher counts as live ONLY if its
789
+ * heartbeat is fresh AND its process is actually alive — so a crashed `watch`
790
+ * whose heartbeat lingers for the TTL doesn't keep the daemon writing to a dead
791
+ * parent's inbox (which would violate "nothing happens unless you watch"). A
792
+ * heartbeat with a missing/non-positive `started_at` is treated as NOT live: the
793
+ * daemon's cross-session scope guard keys off a positive parent start time, so a
794
+ * 0 would defeat it fail closed.
767
795
  */
768
- function diffLsStates(prev, cur, ts) {
769
- const events = [];
770
- const next = /* @__PURE__ */ new Map();
771
- const curPids = /* @__PURE__ */ new Set();
772
- for (const a of cur) {
773
- curPids.add(a.pid);
774
- next.set(a.pid, a);
775
- const p = prev.get(a.pid);
776
- if (!p) events.push({
777
- ...toEvent(a, ts),
778
- prev_state: null
779
- });
780
- else if (p.state !== a.state || p.question !== a.question) events.push({
781
- ...toEvent(a, ts),
782
- prev_state: p.state
783
- });
796
+ async function liveWatchers(now = Date.now()) {
797
+ const names = await readdir(watchersDir()).catch(() => []);
798
+ const live = /* @__PURE__ */ new Map();
799
+ for (const n of names) {
800
+ if (!n.endsWith(".json")) continue;
801
+ const raw = await readFile(path.join(watchersDir(), n), "utf8").catch(() => "");
802
+ try {
803
+ const o = JSON.parse(raw);
804
+ 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)) live.set(o.pid, o.started_at);
805
+ } catch {}
784
806
  }
785
- for (const [pid, p] of prev) if (!curPids.has(pid) && p.state !== "stopped") events.push({
786
- ts,
787
- pid,
788
- cli: p.cli,
789
- cwd: p.cwd,
790
- state: "stopped",
791
- question: null,
792
- prev_state: p.state
793
- });
794
- return {
795
- events,
796
- next
797
- };
798
- }
799
- function toEvent(a, ts) {
800
- return {
801
- ts,
802
- pid: a.pid,
803
- cli: a.cli,
804
- cwd: a.cwd,
805
- state: a.state,
806
- question: a.question
807
- };
807
+ return live;
808
808
  }
809
809
 
810
810
  //#endregion
@@ -968,6 +968,25 @@ async function recordRead(by, target) {
968
968
  async function lastReadAt(by, target) {
969
969
  return (await readReads()).get(`${by}${READS_KEY_SEP}${target}`) ?? null;
970
970
  }
971
+ async function recentReadEdges(windowMs = READ_WINDOW_MS) {
972
+ const now = Date.now();
973
+ const map = await readReads();
974
+ const out = [];
975
+ for (const [key, at] of map) {
976
+ if (now - at > windowMs) continue;
977
+ const i = key.indexOf(READS_KEY_SEP);
978
+ const by = key.slice(0, i);
979
+ if (!by.startsWith("agent:")) continue;
980
+ const byPid = Number(by.slice(6));
981
+ const target = Number(key.slice(i + 1));
982
+ if (byPid && target && byPid !== target) out.push({
983
+ by: byPid,
984
+ target,
985
+ at
986
+ });
987
+ }
988
+ return out;
989
+ }
971
990
  async function resolveSender() {
972
991
  const envPid = process.env.AGENT_YES_PID ? Number(process.env.AGENT_YES_PID) : null;
973
992
  if (!envPid || Number.isNaN(envPid)) return null;
@@ -1126,19 +1145,19 @@ async function runSubcommand(argv) {
1126
1145
  case "restart": return await cmdRestart(rest);
1127
1146
  case "note": return await cmdNote(rest);
1128
1147
  case "serve": {
1129
- const { cmdServe } = await import("./serve-DgQjFYQC.js");
1148
+ const { cmdServe } = await import("./serve-B5PQdiOb.js");
1130
1149
  return cmdServe(rest);
1131
1150
  }
1132
1151
  case "setup": {
1133
- const { cmdSetup } = await import("./setup-DHVydU-j.js");
1152
+ const { cmdSetup } = await import("./setup-BCqFNJoB.js");
1134
1153
  return cmdSetup(rest);
1135
1154
  }
1136
1155
  case "schedule": {
1137
- const { cmdSchedule } = await import("./schedule-DXGDI0Uo.js");
1156
+ const { cmdSchedule } = await import("./schedule-BQtFOk0z.js");
1138
1157
  return cmdSchedule(rest);
1139
1158
  }
1140
1159
  case "remote": {
1141
- const { cmdRemote } = await import("./remotes-BQMr4_En.js");
1160
+ const { cmdRemote } = await import("./remotes-BIHNxn1a.js");
1142
1161
  return cmdRemote(rest);
1143
1162
  }
1144
1163
  case "reap":
@@ -1531,7 +1550,7 @@ async function fetchRemoteRecordsRaw(url, token, opts) {
1531
1550
  let base = url;
1532
1551
  let bearer = token;
1533
1552
  if (isWebrtcSpec(url)) {
1534
- const { startWebrtcBridge } = await import("./webrtcRemote-Bx-eD_0I.js");
1553
+ const { startWebrtcBridge } = await import("./webrtcRemote-BLEbZnbY.js");
1535
1554
  bridge = await startWebrtcBridge(url);
1536
1555
  base = bridge.baseUrl;
1537
1556
  bearer = bridge.token;
@@ -3040,7 +3059,10 @@ function stopTipForCli(cli, pid) {
3040
3059
  const GRACEFUL_EXIT_COMMANDS = {
3041
3060
  claude: "/exit",
3042
3061
  codex: "/exit",
3043
- gemini: "/quit"
3062
+ gemini: "/quit",
3063
+ bash: "exit",
3064
+ cmd: "exit",
3065
+ powershell: "exit"
3044
3066
  };
3045
3067
  function controlCodeFromName(name) {
3046
3068
  switch (name) {
@@ -3969,7 +3991,7 @@ async function cmdNotify(rest) {
3969
3991
  }
3970
3992
  const ensure = async () => {
3971
3993
  if (!argv["ensure-daemon"]) return;
3972
- const { ensureDaemon } = await import("./notifyDaemon-CLRNmdHy.js");
3994
+ const { ensureDaemon } = await import("./notifyDaemon-C08fJ2Ai.js");
3973
3995
  await ensureDaemon().catch(() => null);
3974
3996
  };
3975
3997
  await heartbeatWatcher(parent, selfStartedAt);
@@ -4043,7 +4065,7 @@ async function cmdNotifyCursor(args) {
4043
4065
  }
4044
4066
  async function cmdNotifyd(rest) {
4045
4067
  const sub = rest[0] ?? "status";
4046
- const daemon = await import("./notifyDaemon-CLRNmdHy.js");
4068
+ const daemon = await import("./notifyDaemon-C08fJ2Ai.js");
4047
4069
  switch (sub) {
4048
4070
  case "run": return daemon.runDaemon();
4049
4071
  case "once": return daemon.runDaemon({ once: true });
@@ -4069,5 +4091,5 @@ async function cmdNotifyd(rest) {
4069
4091
  }
4070
4092
 
4071
4093
  //#endregion
4072
- export { resolveOne as A, writeKeysPaced as B, matchKeyword as C, renderLogTailLines as D, readPtysize as E, snapshotStatus as F, hostId as G, TYPING_BADGE as H, stdinActivityPath as I, readInbox as J, listInboxParents as K, stopTipForCli as L, resolveResumeArgs as M, restartHintLines as N, renderRawLog as O, runSubcommand as P, notifyDir as Q, submitAndConfirm as R, listRecords as S, readNotes as T, appendEvent as U, writeToIpc as V, gcInboxes as W, daemonLockDir as X, shouldStealLock as Y, daemonLockOwnerPath as Z, isPidAlive as _, cmdHelp as a, isUserTyping as b, deriveLiveState as c, extractMenu as d, extractNeedsInput as f, isExitRequest as g, isAgentStuck as h, backoffWhileTyping as i, resolveReadWindow as j, renderRawLogLines as k, deriveLiveStatus as l, finalizedLines as m, READ_PAGE_DEFAULT as n, controlCodeFromName as o, extractTaskCounts as p, liveWatchers as q, TYPING_WINDOW_MS as r, cursorAbs as s, GRACEFUL_EXIT_COMMANDS as t, extractBadges as u, isSlashCommand as v, menuSelectKeys as w, lastStdinAt as x, isSubcommand as y, waitForLogQuiet as z };
4073
- //# sourceMappingURL=subcommands-DjA3FgJk.js.map
4094
+ export { TYPING_BADGE as $, renderRawLogLines as A, waitForLogQuiet as B, matchKeyword as C, recentReadEdges as D, readPtysize as E, runSubcommand as F, hostId as G, writeToIpc as H, snapshotStatus as I, readInbox as J, listInboxParents as K, stdinActivityPath as L, resolveReadWindow as M, resolveResumeArgs as N, renderLogTailLines as O, restartHintLines as P, notifyDir as Q, stopTipForCli as R, listRecords as S, readNotes as T, appendEvent as U, writeKeysPaced as V, gcInboxes as W, daemonLockDir as X, shouldStealLock as Y, daemonLockOwnerPath as Z, isPidAlive as _, cmdHelp as a, isUserTyping as b, deriveLiveState as c, extractMenu as d, extractNeedsInput as f, isExitRequest as g, isAgentStuck as h, backoffWhileTyping as i, resolveOne as j, renderRawLog as k, deriveLiveStatus as l, finalizedLines as m, READ_PAGE_DEFAULT as n, controlCodeFromName as o, extractTaskCounts as p, liveWatchers as q, TYPING_WINDOW_MS as r, cursorAbs as s, GRACEFUL_EXIT_COMMANDS as t, extractBadges as u, isSlashCommand as v, menuSelectKeys as w, lastStdinAt as x, isSubcommand as y, submitAndConfirm as z };
4095
+ //# sourceMappingURL=subcommands-BbSJ_nTU.js.map