agent-yes 1.169.0 → 1.170.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. package/dist/{SUPPORTED_CLIS-D1WUoZA9.js → SUPPORTED_CLIS-ByppVvWO.js} +2 -2
  2. package/dist/SUPPORTED_CLIS-C_c097RZ.js +8 -0
  3. package/dist/{agent-yes.config-Bl08kMpl.js → agent-yes.config-ugPYTPOd.js} +2 -2
  4. package/dist/{agentYesHome-_eJa3DaX.js → agentYesHome-CtHb5b71.js} +1 -1
  5. package/dist/cli.js +10 -212
  6. package/dist/{configShared-CEyhl0WH.js → configShared-0MnIQ652.js} +1 -1
  7. package/dist/{e2e-BeKjLhmO.js → e2e-jb0Hp43q.js} +1 -1
  8. package/dist/{forkNested-UCKPEgSI.js → forkNested-DhJxa4q4.js} +2 -2
  9. package/dist/{globalPidIndex-DlmmJlO8.js → globalPidIndex-CoNr7tS8.js} +1 -1
  10. package/dist/index.js +4 -4
  11. package/dist/notifyDaemon-D5jtpCAR.js +591 -0
  12. package/dist/{openBrowser-CCF1iuQK.js → openBrowser-ChR4llYa.js} +1 -1
  13. package/dist/{pidStore-BfoBWUjc.js → pidStore-BIvsBQ8X.js} +3 -3
  14. package/dist/pidStore-CLxwp0qI.js +5 -0
  15. package/dist/{reaper-C-eWAxIj.js → reaper-CWF2_ATd.js} +27 -3
  16. package/dist/{reaper-BQgDb85W.js → reaper-jfSmog15.js} +1 -1
  17. package/dist/{remotes-cx_GDFPj.js → remotes-BQMr4_En.js} +3 -3
  18. package/dist/{remotes-BmAPylU_.js → remotes-CC-4GuJb.js} +3 -3
  19. package/dist/{runningLock-BobVW1_A.js → runningLock-CNMl13dC.js} +1 -1
  20. package/dist/rustBinary-DsO0IlNq.js +228 -0
  21. package/dist/{schedule-DTHFMzDj.js → schedule-ChTPIcXf.js} +7 -7
  22. package/dist/{serve-D-joIHrL.js → serve-De1gAWNI.js} +120 -26
  23. package/dist/{setup-K6VTmirE.js → setup-nlMqyEPB.js} +3 -3
  24. package/dist/{share-D_e2Fwiy.js → share-QregR8a_.js} +2 -2
  25. package/dist/spawnGate-BFhva-2F.js +5 -0
  26. package/dist/{spawnGate-DzPfa1PZ.js → spawnGate-IJDByl2U.js} +3 -3
  27. package/dist/{subcommands-BZPW0UVn.js → subcommands-BFlpV23b.js} +1049 -44
  28. package/dist/subcommands-DLDpwrfG.js +9 -0
  29. package/dist/{tray-CWUpaZF4.js → tray-DXr7iK3E.js} +2 -2
  30. package/dist/{ts-DeahYDN3.js → ts-DsxejviJ.js} +10 -10
  31. package/dist/{versionChecker-C7KP-aoh.js → versionChecker-Czk3aHqy.js} +2 -2
  32. package/dist/{webrtcLink-BG0Xc4-W.js → webrtcLink-B7REGtK2.js} +2 -2
  33. package/dist/{webrtcRemote-SybKurg9.js → webrtcRemote-Bx-eD_0I.js} +3 -3
  34. package/dist/{workspaceConfig-BC03X4Y1.js → workspaceConfig-BgqK-31W.js} +37 -6
  35. package/lab/ui/console-logic.js +79 -10
  36. package/lab/ui/index.html +174 -8
  37. package/lab/ui/landing.html +11 -1
  38. package/package.json +1 -1
  39. package/scripts/build-rs.ts +9 -5
  40. package/ts/badges.spec.ts +51 -1
  41. package/ts/badges.ts +35 -1
  42. package/ts/index.ts +13 -4
  43. package/ts/notifyDaemon.spec.ts +276 -0
  44. package/ts/notifyDaemon.ts +698 -0
  45. package/ts/notifyInbox.spec.ts +192 -0
  46. package/ts/notifyInbox.ts +294 -0
  47. package/ts/notifyRouter.spec.ts +355 -0
  48. package/ts/notifyRouter.ts +288 -0
  49. package/ts/notifyStore.spec.ts +308 -0
  50. package/ts/notifyStore.ts +439 -0
  51. package/ts/reaper.spec.ts +18 -1
  52. package/ts/reaper.ts +33 -1
  53. package/ts/rustBinary.ts +30 -0
  54. package/ts/serve.ts +180 -14
  55. package/ts/subcommands.spec.ts +341 -0
  56. package/ts/subcommands.ts +764 -29
  57. package/ts/workspaceConfig.spec.ts +48 -0
  58. package/ts/workspaceConfig.ts +46 -3
  59. package/dist/SUPPORTED_CLIS-BFV10E3f.js +0 -8
  60. package/dist/pidStore-5qyLX3Fh.js +0 -5
  61. package/dist/spawnGate-CQ1Il3Xk.js +0 -5
  62. package/dist/subcommands-DuGwtmz5.js +0 -9
@@ -0,0 +1,355 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { classifyNeedsInput } from "./needsInput.ts";
3
+ import {
4
+ type ChildObservation,
5
+ type RouterState,
6
+ stepRouter,
7
+ } from "./notifyRouter.ts";
8
+
9
+ // Mirror the claude `needsInput`/`working` config (rs/default.config.yaml): the
10
+ // menu cursor sits on a NUMBERED option, and a spinner marker means "working".
11
+ const CFG = {
12
+ needsInput: [/❯ ?\d+\./m],
13
+ working: [/esc to interrupt/, /to run in background/],
14
+ };
15
+
16
+ const child = (over: Partial<ChildObservation> = {}): ChildObservation => ({
17
+ pid: 100,
18
+ wrapper_pid: 100,
19
+ started_at: 7000,
20
+ parent_pid: 1,
21
+ parent_started_at: 3000,
22
+ cli: "claude",
23
+ cwd: "/repo",
24
+ state: "active",
25
+ question: null,
26
+ ...over,
27
+ });
28
+
29
+ const step = (prev: RouterState, obs: ChildObservation[], now: number, idleConfirmMs = 30_000) =>
30
+ stepRouter(prev, obs, now, { idleConfirmMs });
31
+
32
+ describe("notifyRouter — idle hysteresis (P1)", () => {
33
+ it("does NOT emit idle until the child has been idle for idleConfirmMs", () => {
34
+ let s: RouterState = new Map();
35
+ let r = step(s, [child({ state: "idle" })], 0);
36
+ expect(r.events).toEqual([]); // timer just started
37
+ s = r.next;
38
+
39
+ r = step(s, [child({ state: "idle" })], 10_000);
40
+ expect(r.events).toEqual([]); // still within the window
41
+ s = r.next;
42
+
43
+ r = step(s, [child({ state: "idle" })], 30_000);
44
+ expect(r.events.map((e) => e.edge)).toEqual(["idle"]); // confirmed
45
+ s = r.next;
46
+
47
+ r = step(s, [child({ state: "idle" })], 40_000);
48
+ expect(r.events).toEqual([]); // edge, not level — one per episode
49
+ });
50
+
51
+ it("treats active→idle→active→idle as two separate idle episodes", () => {
52
+ let s: RouterState = new Map();
53
+ s = step(s, [child({ state: "idle" })], 0).next;
54
+ s = step(s, [child({ state: "idle" })], 30_000).next; // episode 1 emits
55
+ // work resumes — resets the episode
56
+ s = step(s, [child({ state: "active" })], 35_000).next;
57
+ let r = step(s, [child({ state: "idle" })], 40_000);
58
+ expect(r.events).toEqual([]); // new episode, timer restarts
59
+ s = r.next;
60
+ r = step(s, [child({ state: "idle" })], 70_000);
61
+ expect(r.events.map((e) => e.edge)).toEqual(["idle"]); // episode 2 emits
62
+ });
63
+
64
+ it("a long silent working spell (state stays active) never emits idle", () => {
65
+ // The load-bearing guard: a 2-minute test run is `active`, not `idle`, so no
66
+ // amount of elapsed wall-clock produces a false idle edge.
67
+ let s: RouterState = new Map();
68
+ for (const t of [0, 30_000, 60_000, 120_000]) {
69
+ const r = step(s, [child({ state: "active" })], t);
70
+ expect(r.events).toEqual([]);
71
+ s = r.next;
72
+ }
73
+ });
74
+ });
75
+
76
+ describe("notifyRouter — needs_input", () => {
77
+ it("emits immediately on entering needs_input, with the question", () => {
78
+ const r = step(new Map(), [child({ state: "needs_input", question: "Approve? 1.Yes 2.No" })], 0);
79
+ expect(r.events).toHaveLength(1);
80
+ expect(r.events[0]!.edge).toBe("needs_input");
81
+ expect(r.events[0]!.question).toBe("Approve? 1.Yes 2.No");
82
+ });
83
+
84
+ it("does not re-fire while the same question stays on screen", () => {
85
+ let s: RouterState = new Map();
86
+ s = step(s, [child({ state: "needs_input", question: "Q1" })], 0).next;
87
+ const r = step(s, [child({ state: "needs_input", question: "Q1" })], 1_000);
88
+ expect(r.events).toEqual([]);
89
+ });
90
+
91
+ it("re-fires when the compact question changes (a genuinely new question)", () => {
92
+ let s: RouterState = new Map();
93
+ s = step(s, [child({ state: "needs_input", question: "Q1" })], 0).next;
94
+ const r = step(s, [child({ state: "needs_input", question: "Q2" })], 1_000);
95
+ expect(r.events.map((e) => e.edge)).toEqual(["needs_input"]);
96
+ expect(r.events[0]!.question).toBe("Q2");
97
+ });
98
+ });
99
+
100
+ describe("notifyRouter — exited", () => {
101
+ it("emits exactly once on stop", () => {
102
+ let s: RouterState = new Map();
103
+ let r = step(s, [child({ state: "stopped" })], 0);
104
+ expect(r.events.map((e) => e.edge)).toEqual(["exited"]);
105
+ s = r.next;
106
+ r = step(s, [child({ state: "stopped" })], 1_000);
107
+ expect(r.events).toEqual([]);
108
+ });
109
+
110
+ it("synthesizes an exited edge when a tracked child vanishes (reaped)", () => {
111
+ let s: RouterState = new Map();
112
+ s = step(s, [child({ state: "active" })], 0).next; // now tracked
113
+ const r = step(s, [], 1_000); // child gone from the live set
114
+ expect(r.events.map((e) => e.edge)).toEqual(["exited"]);
115
+ expect(r.events[0]!.child_pid).toBe(100);
116
+ // C2/I3: the synthetic exited must carry BOTH start times even though the
117
+ // child is gone from the current observation — else the pid-reuse guards
118
+ // (child on reconcile, parent on the read side) can't verify identity and
119
+ // a recycled pid's edge could be suppressed or mis-delivered.
120
+ expect(r.events[0]!.child_started_at).toBe(7000);
121
+ expect(r.events[0]!.parent_started_at).toBe(3000);
122
+ expect(r.next.has(100)).toBe(false); // forgotten
123
+ });
124
+
125
+ it("does not synthesize a second exited if we already emitted stopped", () => {
126
+ let s: RouterState = new Map();
127
+ s = step(s, [child({ state: "stopped" })], 0).next; // emitted exited
128
+ const r = step(s, [], 1_000); // then reaped
129
+ expect(r.events).toEqual([]);
130
+ });
131
+ });
132
+
133
+ describe("notifyRouter — routing scope", () => {
134
+ it("ignores children with no parent_pid (top-level agents)", () => {
135
+ const r = step(new Map(), [child({ state: "needs_input", parent_pid: 0, question: "Q" })], 0);
136
+ expect(r.events).toEqual([]);
137
+ });
138
+
139
+ it("addresses each edge to the child's parent_pid", () => {
140
+ const r = step(new Map(), [child({ state: "stopped", parent_pid: 42 })], 0);
141
+ expect(r.events[0]!.parent_pid).toBe(42);
142
+ });
143
+ });
144
+
145
+ // P1 regression: the real render the parent hit — a child parked at an idle
146
+ // prompt with a RESOLVED prior menu lingering in scrollback must NOT be
147
+ // misclassified as needs_input (which would mask the idle edge).
148
+ describe("notifyRouter — idle-prompt fixture (P1 regression)", () => {
149
+ const idlePromptWithResolvedMenu = [
150
+ " Do you want to proceed?",
151
+ " 1. Yes",
152
+ " 2. No, keep planning",
153
+ " ⎿ Selected 1. Yes",
154
+ "",
155
+ "● Committed as abc1234 and pushed to origin/feat-x.",
156
+ "",
157
+ "╭──────────────────────────────────────────╮",
158
+ "│ > push it │",
159
+ "╰──────────────────────────────────────────╯",
160
+ " ? for shortcuts",
161
+ ];
162
+
163
+ it("a resolved menu in scrollback is NOT needs_input (cursor not on a number)", () => {
164
+ // The numbered options linger but no `❯ N.` cursor remains — so the screen
165
+ // is quiet, classified idle, not needs_input.
166
+ expect(classifyNeedsInput(idlePromptWithResolvedMenu, CFG)).toBeNull();
167
+ });
168
+
169
+ it("an ACTIVE menu (cursor on a number) still classifies as needs_input", () => {
170
+ const activeMenu = [
171
+ " Do you want to proceed?",
172
+ "❯ 1. Yes",
173
+ " 2. No, keep planning",
174
+ ];
175
+ expect(classifyNeedsInput(activeMenu, CFG)).not.toBeNull();
176
+ });
177
+
178
+ it("a working spinner wins over a lingering menu (no false needs_input)", () => {
179
+ const workingWithOldMenu = ["❯ 2. No, keep planning", "● Running tests… (esc to interrupt)"];
180
+ expect(classifyNeedsInput(workingWithOldMenu, CFG)).toBeNull();
181
+ });
182
+
183
+ it("end-to-end: such an idle child emits ONE idle edge after the confirm window", () => {
184
+ // Feeding the router the `idle` state (what deriveLiveState yields for the
185
+ // fixture above) produces exactly one idle edge per episode.
186
+ let s: RouterState = new Map();
187
+ s = step(s, [child({ state: "idle" })], 0).next;
188
+ const r = step(s, [child({ state: "idle" })], 30_000);
189
+ expect(r.events.map((e) => e.edge)).toEqual(["idle"]);
190
+ });
191
+ });
192
+
193
+ describe("notifyRouter — carry-forward vs exited (C2: watcher-lapse ≠ child-death)", () => {
194
+ it("carries forward an unobserved SAME child (alive, matching start time)", () => {
195
+ // Track a child, then a tick with NO observation of it but the SAME child
196
+ // still alive (matching started_at → parent's watcher merely lapsed).
197
+ let s: RouterState = new Map();
198
+ s = stepRouter(s, [child({ pid: 100, started_at: 7000, state: "active" })], 0).next;
199
+ const r = stepRouter(s, [], 1_000, { aliveChildStartedAt: new Map([[100, 7000]]) });
200
+ expect(r.events).toEqual([]); // no false exited
201
+ expect(r.next.has(100)).toBe(true); // carried forward
202
+ expect(r.next.get(100)!.started_at).toBe(7000); // identity preserved
203
+ });
204
+
205
+ it("synthesizes exited when the child's pid is not alive at all", () => {
206
+ let s: RouterState = new Map();
207
+ s = stepRouter(s, [child({ pid: 100, state: "active" })], 0).next;
208
+ const r = stepRouter(s, [], 1_000, { aliveChildStartedAt: new Map() }); // pid dead
209
+ expect(r.events.map((e) => e.edge)).toEqual(["exited"]);
210
+ expect(r.events[0]!.child_started_at).toBe(7000);
211
+ });
212
+
213
+ it("synthesizes exited IMMEDIATELY when the pid is reused by a different child during a lapse", () => {
214
+ // The identity-aware fix: pid 100 is alive but its start time now differs
215
+ // (7000 → 9000) — the old child died and its pid was reused. We must NOT
216
+ // carry it forward as if still running; emit the old child's exited now, no
217
+ // waiting for the watcher to return.
218
+ let s: RouterState = new Map();
219
+ s = stepRouter(s, [child({ pid: 100, started_at: 7000, state: "active" })], 0).next;
220
+ const r = stepRouter(s, [], 1_000, { aliveChildStartedAt: new Map([[100, 9000]]) });
221
+ expect(r.events.map((e) => e.edge)).toEqual(["exited"]);
222
+ expect(r.events[0]!.child_started_at).toBe(7000); // the OLD child's identity
223
+ expect(r.next.has(100)).toBe(false); // dropped
224
+ });
225
+
226
+ it("recovers the old child's exited on watcher RETURN (same-child lapse then reuse)", () => {
227
+ // Child tracked; watcher lapses with the SAME child still alive (carried);
228
+ // when the watcher returns, the pid is observed with a NEW start time → the
229
+ // old child's exited is recovered by the hot-path guard, new child fresh.
230
+ let s: RouterState = new Map();
231
+ s = stepRouter(s, [child({ pid: 100, started_at: 1000, state: "idle" })], 0).next;
232
+ s = stepRouter(s, [], 1_000, { aliveChildStartedAt: new Map([[100, 1000]]) }).next; // carried
233
+ const r = stepRouter(
234
+ s,
235
+ [child({ pid: 100, started_at: 2000, state: "needs_input", question: "New?" })],
236
+ 2_000,
237
+ );
238
+ const edges = r.events.map((e) => e.edge);
239
+ expect(edges).toContain("exited"); // old child (1000) recovered
240
+ expect(r.events.find((e) => e.edge === "exited")!.child_started_at).toBe(1000);
241
+ expect(edges).toContain("needs_input"); // new child (2000) fresh
242
+ expect(r.next.get(100)!.started_at).toBe(2000);
243
+ });
244
+ });
245
+
246
+ describe("notifyRouter — hot-path pid reuse (Important)", () => {
247
+ it("treats a same-pid child with a NEW start time as a fresh child", () => {
248
+ // Old child exits at pid 100; then pid 100 is recycled by a NEW child that
249
+ // goes needs_input. The new child's needs_input must fire (not be suppressed
250
+ // by the old child's state), and the old child gets a synthetic exited.
251
+ let s: RouterState = new Map();
252
+ s = step(s, [child({ pid: 100, started_at: 1000, state: "idle" })], 0).next;
253
+ s = step(s, [child({ pid: 100, started_at: 1000, state: "idle" })], 30_000).next; // old idle-emitted
254
+ const r = step(
255
+ s,
256
+ [child({ pid: 100, started_at: 2000, state: "needs_input", question: "New?" })],
257
+ 31_000,
258
+ );
259
+ const edges = r.events.map((e) => e.edge);
260
+ expect(edges).toContain("exited"); // old child (started_at 1000) closed out
261
+ expect(edges).toContain("needs_input"); // new child (started_at 2000) fires
262
+ expect(r.events.find((e) => e.edge === "exited")!.child_started_at).toBe(1000);
263
+ expect(r.events.find((e) => e.edge === "needs_input")!.child_started_at).toBe(2000);
264
+ // The next state reflects the NEW child only.
265
+ expect(r.next.get(100)!.started_at).toBe(2000);
266
+ expect(r.next.get(100)!.exitedEmitted).toBe(false);
267
+ });
268
+
269
+ it("does NOT inherit emitted-memory from a tracked state with no start time (fail-safe)", () => {
270
+ // Old state seeded WITHOUT a start time (unverifiable). A same-pid child now
271
+ // observed with a start time must NOT inherit idleEmitted/exitedEmitted — else
272
+ // its first edge is suppressed. It rebuilds fresh (a duplicate is acceptable),
273
+ // and NO synthetic exited is emitted (it may be the same child).
274
+ const seeded: RouterState = new Map([
275
+ [
276
+ 100,
277
+ {
278
+ parent_pid: 1,
279
+ started_at: undefined, // unverifiable
280
+ cli: "claude",
281
+ cwd: "/repo",
282
+ state: "idle",
283
+ idleSince: 0,
284
+ idleEmitted: true, // already emitted for the OLD episode
285
+ inNeedsInput: false,
286
+ needsInputQuestion: null,
287
+ exitedEmitted: false,
288
+ },
289
+ ],
290
+ ]);
291
+ const r = stepRouter(seeded, [child({ pid: 100, started_at: 5000, state: "idle" })], 0, {
292
+ idleConfirmMs: 30_000,
293
+ });
294
+ expect(r.events.some((e) => e.edge === "exited")).toBe(false); // no false exited
295
+ expect(r.next.get(100)!.idleEmitted).toBe(false); // fresh — not inherited
296
+ expect(r.next.get(100)!.started_at).toBe(5000);
297
+ });
298
+
299
+ it("does not double-close an old child that had already exited", () => {
300
+ let s: RouterState = new Map();
301
+ s = step(s, [child({ pid: 100, started_at: 1000, state: "stopped" })], 0).next; // exited emitted
302
+ const r = step(s, [child({ pid: 100, started_at: 2000, state: "idle" })], 1_000);
303
+ // Old child already exited → no second exited; new child just starts its idle timer.
304
+ expect(r.events).toEqual([]);
305
+ expect(r.next.get(100)!.started_at).toBe(2000);
306
+ });
307
+ });
308
+
309
+ describe("notifyRouter — startup reconcile (baseline)", () => {
310
+ it("emits needs_input immediately for a child already blocked at daemon start", () => {
311
+ const r = step(new Map(), [child({ state: "needs_input", question: "Q" })], 5_000);
312
+ expect(r.events.map((e) => e.edge)).toEqual(["needs_input"]);
313
+ });
314
+
315
+ it("emits exited immediately for a child already stopped at daemon start", () => {
316
+ const r = step(new Map(), [child({ state: "stopped" })], 5_000);
317
+ expect(r.events.map((e) => e.edge)).toEqual(["exited"]);
318
+ });
319
+
320
+ it("a child already idle at start emits only after the confirm window (from now)", () => {
321
+ let s: RouterState = new Map();
322
+ let r = step(s, [child({ state: "idle" })], 5_000);
323
+ expect(r.events).toEqual([]); // timer anchored at first observation
324
+ s = r.next;
325
+ r = step(s, [child({ state: "idle" })], 35_000);
326
+ expect(r.events.map((e) => e.edge)).toEqual(["idle"]);
327
+ });
328
+
329
+ it("respects a SEEDED prior state (no duplicate baseline across daemon restart)", () => {
330
+ // The daemon seeds prior emitted state from the inbox so a restart doesn't
331
+ // re-emit. A seeded exitedEmitted child that is still `stopped` stays quiet —
332
+ // the seed carries the SAME start time as the observation, so the identity is
333
+ // verifiable and the emitted-memory is (correctly) inherited.
334
+ const seeded: RouterState = new Map([
335
+ [
336
+ 100,
337
+ {
338
+ parent_pid: 1,
339
+ wrapper_pid: 100,
340
+ started_at: 7000, // matches the child() factory → verifiable identity
341
+ cli: "claude",
342
+ cwd: "/repo",
343
+ state: "stopped",
344
+ idleSince: null,
345
+ idleEmitted: false,
346
+ inNeedsInput: false,
347
+ needsInputQuestion: null,
348
+ exitedEmitted: true,
349
+ },
350
+ ],
351
+ ]);
352
+ const r = step(seeded, [child({ state: "stopped" })], 0);
353
+ expect(r.events).toEqual([]);
354
+ });
355
+ });
@@ -0,0 +1,288 @@
1
+ /**
2
+ * Pure edge-detection + debounce state machine for subagent→parent
3
+ * notifications. The heart of `ay notifyd`: given each tick's observed child
4
+ * states, decide which EDGES to push to which parent, exactly once per episode,
5
+ * without spamming on transient flicker.
6
+ *
7
+ * Design (frozen with codex + the two agents who hit the pain):
8
+ * - Signal is the query-layer `deriveLiveState` state — NOT a bare no-output
9
+ * timer. `idle` already means "idle prompt visible AND no working spinner",
10
+ * so a long, silent tool call (a 2-minute test run) is classified `active`
11
+ * and never produces a false idle edge. This is the load-bearing P1 guard.
12
+ * - Three edges, child→parent only (never cascade a parent's own state):
13
+ * needs_input : immediate; re-fire only when the COMPACT question changes.
14
+ * exited : immediate, once.
15
+ * idle : only after the child has been continuously `idle` for
16
+ * `idleConfirmMs` (hysteresis), once per idle episode.
17
+ * - Edge, not level: a state that stays idle emits one edge, not one per tick.
18
+ * - Reaped-child safety: a child that vanishes from the live set without our
19
+ * seeing `stopped` gets a synthetic `exited` (mirrors `diffLsStates`).
20
+ *
21
+ * Pure + synchronous (no clock, no fs — the caller passes `now`) so it is
22
+ * trivially unit-testable, like `lsWatch.ts` / `needsInput.ts`. The poll loop,
23
+ * inbox writes, payload enrichment, and startup reconcile live in
24
+ * `subcommands.ts` (`cmdNotifyd`); persistence lives in `notifyInbox.ts`.
25
+ */
26
+
27
+ import type { NotifyEdge } from "./notifyInbox.ts";
28
+
29
+ /** One child's observed state this tick (from `deriveLiveState` + the registry). */
30
+ export interface ChildObservation {
31
+ pid: number;
32
+ wrapper_pid?: number;
33
+ /** The child's own start time — carried into events for the pid-reuse guard. */
34
+ started_at?: number;
35
+ /** The parent this child links to (parent wrapper pid). Required to route. */
36
+ parent_pid: number;
37
+ /** The parent's start time — carried so a synthetic exited keeps the parent guard. */
38
+ parent_started_at?: number;
39
+ cli: string;
40
+ cwd: string;
41
+ /** `deriveLiveState` state: active | idle | stopped | needs_input | stuck. */
42
+ state: string;
43
+ /** Compact question when state === "needs_input", else null. */
44
+ question: string | null;
45
+ }
46
+
47
+ /** A decided notification, before the daemon enriches it (seq/ts/tail/git). */
48
+ export interface PendingNotification {
49
+ parent_pid: number;
50
+ child_pid: number;
51
+ child_wrapper_pid?: number;
52
+ /** The child's start time (from the observation / carried state). */
53
+ child_started_at?: number;
54
+ /** The parent's start time (from the observation / carried state). */
55
+ parent_started_at?: number;
56
+ cli: string;
57
+ cwd: string;
58
+ edge: NotifyEdge;
59
+ prev_state: string | null;
60
+ state: string;
61
+ question: string | null;
62
+ }
63
+
64
+ /** Per-child debounce memory, carried across ticks. */
65
+ export interface ChildRouterState {
66
+ parent_pid: number;
67
+ wrapper_pid?: number;
68
+ /** The child's start time — so a synthetic exited (child already gone) keeps it. */
69
+ started_at?: number;
70
+ /** The parent's start time — carried so a synthetic exited keeps the parent guard. */
71
+ parent_started_at?: number;
72
+ cli: string;
73
+ cwd: string;
74
+ /** Last observed state. */
75
+ state: string;
76
+ /** When the current idle episode began (ms), or null if not idle. */
77
+ idleSince: number | null;
78
+ /** Whether we already emitted the idle edge for the current episode. */
79
+ idleEmitted: boolean;
80
+ /** Whether we are inside a needs_input episode we've emitted at least once. */
81
+ inNeedsInput: boolean;
82
+ /** The compact question of the last emitted needs_input (for change re-fire). */
83
+ needsInputQuestion: string | null;
84
+ /** Whether we already emitted the exited edge for this child. */
85
+ exitedEmitted: boolean;
86
+ }
87
+
88
+ export type RouterState = Map<number, ChildRouterState>;
89
+
90
+ export interface RouterConfig {
91
+ /** How long a child must stay continuously idle before we emit an idle edge. */
92
+ idleConfirmMs: number;
93
+ /**
94
+ * pid → started_at for every child ACTUALLY ALIVE right now (from the registry).
95
+ * A tracked child that drops out of this tick's observations is carried forward
96
+ * (not false-exited) ONLY when the live pid's start time still MATCHES the
97
+ * tracked one — i.e. it is the SAME child, merely unobserved (its parent's
98
+ * watcher lapsed). If the pid is alive but with a DIFFERENT start time, the old
99
+ * child died and its pid was reused during the lapse → synthesize its exited
100
+ * immediately (identity-aware, no waiting for the watcher to return). If the
101
+ * tracked start time is unknown (rare), fall back to liveness-only carry-forward
102
+ * to avoid a false exited. When omitted entirely, any unobserved tracked child
103
+ * is treated as exited (the caller has no liveness info).
104
+ */
105
+ aliveChildStartedAt?: Map<number, number>;
106
+ }
107
+
108
+ const DEFAULT_IDLE_CONFIRM_MS = 30_000;
109
+
110
+ /**
111
+ * Advance the router by one tick. Returns the notifications to push plus the
112
+ * next RouterState. Only children with a numeric `parent_pid` are considered —
113
+ * the caller is responsible for including only opted-in children.
114
+ *
115
+ * First-observation (baseline / startup-reconcile) semantics: a child we've
116
+ * never seen that is ALREADY terminal emits immediately for `needs_input` /
117
+ * `stopped`, and starts its idle timer at `now` for `idle` (so a child parked
118
+ * idle when the daemon starts still notifies after the confirm window). To avoid
119
+ * re-emitting the same baseline on every daemon restart, the caller SEEDS the
120
+ * prior state from each inbox's already-written edges (see cmdNotifyd).
121
+ */
122
+ export function stepRouter(
123
+ prev: RouterState,
124
+ observations: ChildObservation[],
125
+ now: number,
126
+ config: Partial<RouterConfig> = {},
127
+ ): { events: PendingNotification[]; next: RouterState } {
128
+ const idleConfirmMs = config.idleConfirmMs ?? DEFAULT_IDLE_CONFIRM_MS;
129
+ const events: PendingNotification[] = [];
130
+ const next: RouterState = new Map();
131
+ const seen = new Set<number>();
132
+
133
+ for (const obs of observations) {
134
+ if (typeof obs.parent_pid !== "number" || obs.parent_pid <= 0) continue;
135
+ seen.add(obs.pid);
136
+ let p = prev.get(obs.pid);
137
+ // Hot-path pid-reuse guard (mirrors the startup reconcile guard). Two cases,
138
+ // both fail-safe — an unverifiable identity is NEVER inherited:
139
+ // 1. CONFIRMED reuse: both start times known and different → the old child
140
+ // is gone. `seen` marks the pid so the vanished-loop won't fire; emit its
141
+ // synthetic exited here, then drop `p` so the new child rebuilds fresh.
142
+ // 2. UNVERIFIABLE: the observation has a start time but the tracked state has
143
+ // none (e.g. seeded without one). We can't confirm it's the same child,
144
+ // so we DON'T inherit its emitted-edge memory (a re-emit / duplicate is
145
+ // safe; a suppressed first edge is not) — but we also DON'T synthesize an
146
+ // exited, since it may well be the same child (avoid a false exited).
147
+ if (p && obs.started_at != null && p.started_at != null && p.started_at !== obs.started_at) {
148
+ if (!p.exitedEmitted) events.push(syntheticExited(p, obs.pid));
149
+ p = undefined;
150
+ } else if (p && obs.started_at != null && p.started_at == null) {
151
+ p = undefined; // unverifiable — rebuild fresh, no inherit, no synthetic exited
152
+ }
153
+ const cs: ChildRouterState = {
154
+ parent_pid: obs.parent_pid,
155
+ wrapper_pid: obs.wrapper_pid,
156
+ started_at: obs.started_at,
157
+ parent_started_at: obs.parent_started_at,
158
+ cli: obs.cli,
159
+ cwd: obs.cwd,
160
+ state: obs.state,
161
+ idleSince: p?.idleSince ?? null,
162
+ idleEmitted: p?.idleEmitted ?? false,
163
+ inNeedsInput: p?.inNeedsInput ?? false,
164
+ needsInputQuestion: p?.needsInputQuestion ?? null,
165
+ exitedEmitted: p?.exitedEmitted ?? false,
166
+ };
167
+ const prevState = p?.state ?? null;
168
+ const emit = (edge: NotifyEdge) =>
169
+ events.push({
170
+ parent_pid: obs.parent_pid,
171
+ child_pid: obs.pid,
172
+ child_wrapper_pid: obs.wrapper_pid,
173
+ child_started_at: obs.started_at,
174
+ parent_started_at: obs.parent_started_at,
175
+ cli: obs.cli,
176
+ cwd: obs.cwd,
177
+ edge,
178
+ prev_state: prevState,
179
+ state: obs.state,
180
+ question: edge === "needs_input" ? obs.question : null,
181
+ });
182
+
183
+ switch (obs.state) {
184
+ case "needs_input": {
185
+ // Leaving idle/other → reset those episodes.
186
+ cs.idleSince = null;
187
+ cs.idleEmitted = false;
188
+ // Fire on entry, or when the compact question changed (a NEW question).
189
+ // Compare the compact (chrome-stripped) question so a spinner/elapsed-
190
+ // seconds cosmetic redraw doesn't double-fire.
191
+ if (!cs.inNeedsInput || cs.needsInputQuestion !== obs.question) {
192
+ emit("needs_input");
193
+ cs.inNeedsInput = true;
194
+ cs.needsInputQuestion = obs.question;
195
+ }
196
+ break;
197
+ }
198
+ case "idle": {
199
+ cs.inNeedsInput = false;
200
+ cs.needsInputQuestion = null;
201
+ // Continue the episode if we were already idle; else start it now.
202
+ if (p?.state === "idle" && p.idleSince != null) {
203
+ cs.idleSince = p.idleSince;
204
+ cs.idleEmitted = p.idleEmitted;
205
+ } else {
206
+ cs.idleSince = now;
207
+ cs.idleEmitted = false;
208
+ }
209
+ if (!cs.idleEmitted && now - (cs.idleSince ?? now) >= idleConfirmMs) {
210
+ emit("idle");
211
+ cs.idleEmitted = true;
212
+ }
213
+ break;
214
+ }
215
+ case "stopped": {
216
+ cs.idleSince = null;
217
+ cs.idleEmitted = false;
218
+ cs.inNeedsInput = false;
219
+ cs.needsInputQuestion = null;
220
+ if (!cs.exitedEmitted) {
221
+ emit("exited");
222
+ cs.exitedEmitted = true;
223
+ }
224
+ break;
225
+ }
226
+ // "active", "stuck", or any unknown busy state: real work (or a wedge) is
227
+ // happening — reset the idle/needs_input episodes so a later idle counts
228
+ // as a fresh episode, and emit nothing.
229
+ default: {
230
+ cs.idleSince = null;
231
+ cs.idleEmitted = false;
232
+ cs.inNeedsInput = false;
233
+ cs.needsInputQuestion = null;
234
+ break;
235
+ }
236
+ }
237
+
238
+ next.set(obs.pid, cs);
239
+ }
240
+
241
+ // Reaped-child safety: a child we were tracking dropped out of the live set
242
+ // without our observing `stopped`. Synthesize one exited edge (once) so a
243
+ // "done" transition is never dropped, then forget it.
244
+ for (const [pid, cs] of prev) {
245
+ if (seen.has(pid)) continue;
246
+ // Distinguish "child died" from "child merely not observed this tick" (its
247
+ // parent's watcher lapsed, so it fell out of the observed scope) — and do it
248
+ // IDENTITY-AWARE, so a pid reused during the lapse doesn't masquerade as the
249
+ // same still-running child. Carry forward only when the SAME child is still
250
+ // alive (live start time matches, or the tracked start time is unknown —
251
+ // fail-safe against a false exited). Otherwise the child is gone (dead, or its
252
+ // pid now belongs to a different child) → synthesize its exited.
253
+ if (config.aliveChildStartedAt) {
254
+ const liveStart = config.aliveChildStartedAt.get(pid);
255
+ const sameChildAlive =
256
+ liveStart !== undefined && (cs.started_at == null || liveStart === cs.started_at);
257
+ if (sameChildAlive) {
258
+ next.set(pid, cs);
259
+ continue;
260
+ }
261
+ }
262
+ if (!cs.exitedEmitted) events.push(syntheticExited(cs, pid));
263
+ // Dead & gone (or pid reused) — drop from the tracked set.
264
+ }
265
+
266
+ return { events, next };
267
+ }
268
+
269
+ /**
270
+ * A synthetic `exited` for a child that left the live set without our observing
271
+ * its stop — either reaped between ticks, or displaced by a pid-reuse. Built from
272
+ * the last-known router state so it still carries the child's identity.
273
+ */
274
+ function syntheticExited(cs: ChildRouterState, pid: number): PendingNotification {
275
+ return {
276
+ parent_pid: cs.parent_pid,
277
+ child_pid: pid,
278
+ child_wrapper_pid: cs.wrapper_pid,
279
+ child_started_at: cs.started_at,
280
+ parent_started_at: cs.parent_started_at,
281
+ cli: cs.cli,
282
+ cwd: cs.cwd,
283
+ edge: "exited",
284
+ prev_state: cs.state,
285
+ state: "stopped",
286
+ question: null,
287
+ };
288
+ }