agent-yes 1.169.1 → 1.171.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. package/dist/SUPPORTED_CLIS-BMExykB1.js +8 -0
  2. package/dist/{SUPPORTED_CLIS-Bwg9VqLI.js → SUPPORTED_CLIS-D6MArV7A.js} +2 -2
  3. package/dist/{agent-yes.config-Bl08kMpl.js → agent-yes.config-ugPYTPOd.js} +2 -2
  4. package/dist/{agentYesHome-_eJa3DaX.js → agentYesHome-CtHb5b71.js} +1 -1
  5. package/dist/cli.js +10 -212
  6. package/dist/{configShared-CEyhl0WH.js → configShared-0MnIQ652.js} +1 -1
  7. package/dist/{e2e-BeKjLhmO.js → e2e-jb0Hp43q.js} +1 -1
  8. package/dist/{forkNested-UCKPEgSI.js → forkNested-DhJxa4q4.js} +2 -2
  9. package/dist/{globalPidIndex-DlmmJlO8.js → globalPidIndex-CoNr7tS8.js} +1 -1
  10. package/dist/index.js +4 -4
  11. package/dist/notifyDaemon-BlGV0y4j.js +591 -0
  12. package/dist/{openBrowser-CCF1iuQK.js → openBrowser-ChR4llYa.js} +1 -1
  13. package/dist/{pidStore-BfoBWUjc.js → pidStore-BIvsBQ8X.js} +3 -3
  14. package/dist/pidStore-CLxwp0qI.js +5 -0
  15. package/dist/{reaper-C-eWAxIj.js → reaper-CWF2_ATd.js} +27 -3
  16. package/dist/{reaper-BQgDb85W.js → reaper-jfSmog15.js} +1 -1
  17. package/dist/{remotes-cx_GDFPj.js → remotes-BQMr4_En.js} +3 -3
  18. package/dist/{remotes-BmAPylU_.js → remotes-CC-4GuJb.js} +3 -3
  19. package/dist/{runningLock-BobVW1_A.js → runningLock-CNMl13dC.js} +1 -1
  20. package/dist/rustBinary-mYbyV0SD.js +228 -0
  21. package/dist/{schedule-CtVvSyaN.js → schedule-C5nUV0GO.js} +7 -7
  22. package/dist/{serve-CLmk5ujF.js → serve-ChcayCC3.js} +122 -26
  23. package/dist/{setup-CYbXOcpH.js → setup-CCQZ9tPB.js} +3 -3
  24. package/dist/{share-D_e2Fwiy.js → share-QregR8a_.js} +2 -2
  25. package/dist/spawnGate-BFhva-2F.js +5 -0
  26. package/dist/{spawnGate-DzPfa1PZ.js → spawnGate-IJDByl2U.js} +3 -3
  27. package/dist/subcommands-CIk1SSdW.js +9 -0
  28. package/dist/{subcommands--EUush_X.js → subcommands-CtWxwydR.js} +1049 -44
  29. package/dist/{tray-CWUpaZF4.js → tray-DXr7iK3E.js} +2 -2
  30. package/dist/{ts-Bbhf5JbN.js → ts-WHYKp13w.js} +8 -8
  31. package/dist/{versionChecker-BmWkWhcJ.js → versionChecker-BoPKPL9B.js} +2 -2
  32. package/dist/{webrtcLink-BG0Xc4-W.js → webrtcLink-B7REGtK2.js} +2 -2
  33. package/dist/{webrtcRemote-SybKurg9.js → webrtcRemote-Bx-eD_0I.js} +3 -3
  34. package/dist/{workspaceConfig-BC03X4Y1.js → workspaceConfig-BgqK-31W.js} +37 -6
  35. package/lab/ui/console-logic.js +125 -10
  36. package/lab/ui/index.html +340 -11
  37. package/lab/ui/landing.html +11 -1
  38. package/package.json +1 -1
  39. package/scripts/build-rs.ts +9 -5
  40. package/ts/badges.spec.ts +51 -1
  41. package/ts/badges.ts +35 -1
  42. package/ts/notifyDaemon.spec.ts +276 -0
  43. package/ts/notifyDaemon.ts +698 -0
  44. package/ts/notifyInbox.spec.ts +192 -0
  45. package/ts/notifyInbox.ts +294 -0
  46. package/ts/notifyRouter.spec.ts +355 -0
  47. package/ts/notifyRouter.ts +288 -0
  48. package/ts/notifyStore.spec.ts +308 -0
  49. package/ts/notifyStore.ts +439 -0
  50. package/ts/reaper.spec.ts +18 -1
  51. package/ts/reaper.ts +33 -1
  52. package/ts/rustBinary.ts +30 -0
  53. package/ts/serve.ts +194 -14
  54. package/ts/subcommands.spec.ts +341 -0
  55. package/ts/subcommands.ts +764 -29
  56. package/ts/workspaceConfig.spec.ts +48 -0
  57. package/ts/workspaceConfig.ts +46 -3
  58. package/dist/SUPPORTED_CLIS-DiL99G3j.js +0 -8
  59. package/dist/pidStore-5qyLX3Fh.js +0 -5
  60. package/dist/spawnGate-CQ1Il3Xk.js +0 -5
  61. package/dist/subcommands-1ZcaHYkb.js +0 -9
package/ts/badges.spec.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { describe, expect, it } from "vitest";
2
- import { badgeDef, BADGE_DEFS, matchBadges, type BadgeDef } from "./badges.ts";
2
+ import { badgeDef, BADGE_DEFS, matchBadges, TYPING_BADGE, type BadgeDef } from "./badges.ts";
3
3
 
4
4
  describe("matchBadges", () => {
5
5
  it("matches goal-active when the /goal status line is on screen", () => {
@@ -41,6 +41,41 @@ describe("matchBadges", () => {
41
41
  ];
42
42
  expect(matchBadges(["alpha and beta both here"], defs)).toEqual(["a", "b"]);
43
43
  });
44
+
45
+ it("matches session-limit when claude's usage-limit banner is on screen", () => {
46
+ expect(matchBadges(["You've hit your session limit · resets 9:50pm (Asia/Tokyo)"])).toEqual([
47
+ "session-limit",
48
+ ]);
49
+ });
50
+
51
+ it("matches with or without the apostrophe (straight ', curly ’, or none)", () => {
52
+ expect(matchBadges(["You've hit your session limit"])).toEqual(["session-limit"]);
53
+ expect(matchBadges(["Youve hit your session limit"])).toEqual(["session-limit"]);
54
+ });
55
+
56
+ it("does not match an unrelated 'limit' mention", () => {
57
+ expect(matchBadges(["rate limit exceeded, please slow down"])).toEqual([]);
58
+ });
59
+
60
+ it("matches retrying on claude's self-retry backoff banner", () => {
61
+ expect(
62
+ matchBadges(["✻ Waiting for API response · will retry in 2m 17s · check your network"]),
63
+ ).toEqual(["retrying"]);
64
+ });
65
+
66
+ it("matches retrying across a line-wrapped banner (joined text)", () => {
67
+ expect(
68
+ matchBadges(["✻ Waiting for API response ·", "will retry in 45s · check your network"]),
69
+ ).toEqual(["retrying"]);
70
+ });
71
+
72
+ it("does not light retrying for a normal in-flight wait (no 'will retry')", () => {
73
+ expect(matchBadges(["✻ Waiting for API response… (esc to interrupt)"])).toEqual([]);
74
+ });
75
+
76
+ it("does not light retrying for an agent merely discussing retries (anchored to the banner)", () => {
77
+ expect(matchBadges(["the client will retry in 5 seconds with backoff"])).toEqual([]);
78
+ });
44
79
  });
45
80
 
46
81
  describe("badgeDef", () => {
@@ -51,4 +86,19 @@ describe("badgeDef", () => {
51
86
  it("returns undefined for an unknown id", () => {
52
87
  expect(badgeDef("does-not-exist")).toBeUndefined();
53
88
  });
89
+
90
+ it("resolves the time-derived typing badge even though it isn't in BADGE_DEFS", () => {
91
+ expect(badgeDef("typing")).toBe(TYPING_BADGE);
92
+ expect(BADGE_DEFS).not.toContain(TYPING_BADGE);
93
+ });
94
+ });
95
+
96
+ describe("TYPING_BADGE", () => {
97
+ it("is never produced by screen matching (its pattern can't match)", () => {
98
+ // Presence is derived from the stdin-activity marker, not the rendered
99
+ // screen — matchBadges must never surface it, even against text mentioning
100
+ // typing. A screen can't cause a false 'user is typing' chip.
101
+ expect(matchBadges(["the user is typing a lot right now", "typing typing typing"])).toEqual([]);
102
+ expect(TYPING_BADGE.pattern.test("typing")).toBe(false);
103
+ });
54
104
  });
package/ts/badges.ts CHANGED
@@ -26,6 +26,26 @@ export const BADGE_DEFS: BadgeDef[] = [
26
26
  title: "A /goal Stop-hook loop is active on this agent",
27
27
  pattern: /\/goal active/i,
28
28
  },
29
+ {
30
+ id: "session-limit",
31
+ label: "limit",
32
+ title: "Usage session limit hit — waiting for the reset time shown on screen",
33
+ pattern: /you['’]?ve hit your session limit/i,
34
+ },
35
+ {
36
+ // The CLI is auto-retrying an API call on its OWN backoff — claude prints
37
+ // "✻ Waiting for API response · will retry in 2m 17s · check your network"
38
+ // and recovers by itself. agent-yes injects NOTHING here (unlike the runtime's
39
+ // autoRetry, which types "retry"); this badge just annotates "waiting on the
40
+ // API, no action needed" so the agent doesn't read as plainly busy. Anchored
41
+ // on the FULL banner (not a bare "will retry in") so an agent merely
42
+ // *discussing* retries can't light it up; `[\s\S]{0,40}` spans the "· "
43
+ // separator and any line-wrap between the two phrases.
44
+ id: "retrying",
45
+ label: "retry",
46
+ title: "Waiting for the API — the CLI is auto-retrying on its own backoff (no action needed)",
47
+ pattern: /Waiting for API response[\s\S]{0,40}will retry in \d/i,
48
+ },
29
49
  ];
30
50
 
31
51
  /**
@@ -37,6 +57,20 @@ export function matchBadges(lines: string[], defs: BadgeDef[] = BADGE_DEFS): str
37
57
  return defs.filter((d) => d.pattern.test(text)).map((d) => d.id);
38
58
  }
39
59
 
60
+ /**
61
+ * A time-derived flag (NOT screen-matched): lit when the user typed at this
62
+ * agent's terminal within the last few seconds. The ls code appends its id from
63
+ * the Rust runner's stdin-activity marker, so it resolves through `badgeDef`
64
+ * like any other chip — but `matchBadges` never produces it (it isn't in
65
+ * BADGE_DEFS, and its pattern can't match), keeping screen matching pure.
66
+ */
67
+ export const TYPING_BADGE: BadgeDef = {
68
+ id: "typing",
69
+ label: "typing",
70
+ title: "The user is typing at this agent's terminal — ay send backs off until they pause",
71
+ pattern: /(?!)/, // never matches; presence comes from stdin activity, not the screen
72
+ };
73
+
40
74
  export function badgeDef(id: string, defs: BadgeDef[] = BADGE_DEFS): BadgeDef | undefined {
41
- return defs.find((d) => d.id === id);
75
+ return defs.find((d) => d.id === id) ?? (id === TYPING_BADGE.id ? TYPING_BADGE : undefined);
42
76
  }
@@ -0,0 +1,276 @@
1
+ import { afterEach, beforeEach, describe, expect, it } from "vitest";
2
+ import { mkdtemp, mkdir, readFile, rm, writeFile } from "fs/promises";
3
+ import { tmpdir } from "os";
4
+ import path from "path";
5
+ import {
6
+ acquireDaemonLock,
7
+ daemonStatus,
8
+ reconcileFromInboxes,
9
+ requestDaemonStop,
10
+ } from "./notifyDaemon.ts";
11
+ import { stat } from "fs/promises";
12
+ import { daemonLockDir, daemonLockOwnerPath } from "./notifyInbox.ts";
13
+ import { appendEvent, hostId } from "./notifyStore.ts";
14
+ import type { NotifyEvent } from "./notifyInbox.ts";
15
+
16
+ // The lock steal/keep race codex flagged: a crashed daemon's stale lock must be
17
+ // stealable (else runDaemon deadlocks forever), while a LIVE owner's lock must
18
+ // be respected (else two daemons run). Liveness is injected so both branches are
19
+ // deterministic.
20
+ describe("notifyd singleton lock", () => {
21
+ let home: string;
22
+ const prev = process.env.AGENT_YES_HOME;
23
+
24
+ beforeEach(async () => {
25
+ home = await mkdtemp(path.join(tmpdir(), "ay-notify-lock-"));
26
+ process.env.AGENT_YES_HOME = home;
27
+ });
28
+ afterEach(async () => {
29
+ if (prev === undefined) delete process.env.AGENT_YES_HOME;
30
+ else process.env.AGENT_YES_HOME = prev;
31
+ await rm(home, { recursive: true, force: true }).catch(() => {});
32
+ });
33
+
34
+ it("acquires the lock on a clean install (parent dir absent, no ENOENT trap)", async () => {
35
+ // notify/ does not exist yet — the classic clean-install bug was mkdir(lock)
36
+ // throwing ENOENT and being misread as "held". Must acquire cleanly.
37
+ expect(await acquireDaemonLock(() => false)).toBe(true);
38
+ const owner = JSON.parse(await readFile(daemonLockOwnerPath(), "utf8"));
39
+ expect(owner.pid).toBe(process.pid);
40
+ });
41
+
42
+ it("steals a stale lock whose owner is dead (no permanent deadlock)", async () => {
43
+ await mkdir(daemonLockDir(), { recursive: true });
44
+ await writeFile(
45
+ daemonLockOwnerPath(),
46
+ JSON.stringify({ pid: 424242, started_at: 1, ts: Date.now() }),
47
+ );
48
+ // 424242 reported dead → steal (dead pid, no wait needed).
49
+ expect(await acquireDaemonLock(() => false)).toBe(true);
50
+ const owner = JSON.parse(await readFile(daemonLockOwnerPath(), "utf8"));
51
+ expect(owner.pid).toBe(process.pid);
52
+ });
53
+
54
+ it("refuses the lock when a LIVE owner holds it (no double daemon)", async () => {
55
+ await mkdir(daemonLockDir(), { recursive: true });
56
+ // A complete, live, fresh owner (a real daemon always heartbeats its ts).
57
+ await writeFile(
58
+ daemonLockOwnerPath(),
59
+ JSON.stringify({ pid: 424242, started_at: 1, ts: Date.now() }),
60
+ );
61
+ expect(await acquireDaemonLock((pid) => pid === 424242)).toBe(false);
62
+ const owner = JSON.parse(await readFile(daemonLockOwnerPath(), "utf8"));
63
+ expect(owner.pid).toBe(424242); // untouched
64
+ });
65
+
66
+ it("steals a live-but-STALE owner (heartbeat not refreshed → wedged/gone)", async () => {
67
+ await mkdir(daemonLockDir(), { recursive: true });
68
+ await writeFile(
69
+ daemonLockOwnerPath(),
70
+ JSON.stringify({ pid: 424242, started_at: 1, ts: 1 }), // ancient ts
71
+ );
72
+ expect(await acquireDaemonLock((pid) => pid === 424242)).toBe(true);
73
+ });
74
+
75
+ it("eventually steals a torn owner, but only AFTER the grace (no double daemon)", async () => {
76
+ // A torn owner is also the mkdir→writeOwner window of a concurrent daemon
77
+ // start, so it is respected within the grace (~1s) and stolen only past it —
78
+ // the same invariant as the per-inbox lock (the steal decision itself is
79
+ // unit-tested via shouldStealLock in notifyStore.spec).
80
+ await mkdir(daemonLockDir(), { recursive: true });
81
+ await writeFile(daemonLockOwnerPath(), "{ not json");
82
+ const t0 = Date.now();
83
+ expect(await acquireDaemonLock(() => true)).toBe(true);
84
+ expect(Date.now() - t0).toBeGreaterThanOrEqual(900); // waited out the grace
85
+ });
86
+
87
+ it("a second live-owner acquire from a DIFFERENT pid is refused", async () => {
88
+ // First acquire writes owner = our pid. A second caller that sees a different
89
+ // live owner must back off (mkdir(recursive:false) is the exclusive proof;
90
+ // the owner is alive so it isn't stolen).
91
+ expect(await acquireDaemonLock(() => true)).toBe(true);
92
+ const owner = JSON.parse(await readFile(daemonLockOwnerPath(), "utf8"));
93
+ // Rewrite the owner to a DIFFERENT, "alive" pid to simulate another daemon.
94
+ await writeFile(
95
+ daemonLockOwnerPath(),
96
+ JSON.stringify({ ...owner, pid: owner.pid + 1 }),
97
+ );
98
+ expect(await acquireDaemonLock((pid) => pid === owner.pid + 1)).toBe(false);
99
+ });
100
+ });
101
+
102
+ describe("notifyd identity (status/stop safety)", () => {
103
+ let home: string;
104
+ const prev = process.env.AGENT_YES_HOME;
105
+ beforeEach(async () => {
106
+ home = await mkdtemp(path.join(tmpdir(), "ay-notify-id-"));
107
+ process.env.AGENT_YES_HOME = home;
108
+ });
109
+ afterEach(async () => {
110
+ if (prev === undefined) delete process.env.AGENT_YES_HOME;
111
+ else process.env.AGENT_YES_HOME = prev;
112
+ await rm(home, { recursive: true, force: true }).catch(() => {});
113
+ });
114
+
115
+ it("trusts a live owner with a fresh heartbeat", async () => {
116
+ await mkdir(daemonLockDir(), { recursive: true });
117
+ await writeFile(
118
+ daemonLockOwnerPath(),
119
+ JSON.stringify({ pid: process.pid, started_at: 1, ts: Date.now() }),
120
+ );
121
+ expect(await daemonStatus()).toBe(process.pid);
122
+ });
123
+
124
+ it("returns null for a STALE heartbeat even if the pid is alive (pid-reuse guard)", async () => {
125
+ // process.pid is alive, but its owner ts is ancient — a recycled pid that
126
+ // isn't refreshing THIS file must not be trusted or killed.
127
+ await mkdir(daemonLockDir(), { recursive: true });
128
+ await writeFile(
129
+ daemonLockOwnerPath(),
130
+ JSON.stringify({ pid: process.pid, started_at: 1, ts: 1 }),
131
+ );
132
+ expect(await daemonStatus()).toBeNull();
133
+ });
134
+
135
+ it("returns null when no owner file exists", async () => {
136
+ expect(await daemonStatus()).toBeNull();
137
+ });
138
+
139
+ it("returns null for an INCOMPLETE owner missing started_at (I1)", async () => {
140
+ await mkdir(daemonLockDir(), { recursive: true });
141
+ // pid alive + fresh ts, but no started_at → identity incomplete, not trusted.
142
+ await writeFile(
143
+ daemonLockOwnerPath(),
144
+ JSON.stringify({ pid: process.pid, ts: Date.now() }),
145
+ );
146
+ expect(await daemonStatus()).toBeNull();
147
+ });
148
+
149
+ it("requestDaemonStop removes the lock (cooperative) for a valid owner", async () => {
150
+ await mkdir(daemonLockDir(), { recursive: true });
151
+ await writeFile(
152
+ daemonLockOwnerPath(),
153
+ JSON.stringify({ pid: process.pid, started_at: 1, ts: Date.now(), token: "T" }),
154
+ );
155
+ expect(await requestDaemonStop()).toBe(process.pid);
156
+ await expect(stat(daemonLockDir())).rejects.toThrow(); // lock removed
157
+ });
158
+
159
+ it("requestDaemonStop refuses an owner with no fencing token (can't confirm identity)", async () => {
160
+ await mkdir(daemonLockDir(), { recursive: true });
161
+ await writeFile(
162
+ daemonLockOwnerPath(),
163
+ JSON.stringify({ pid: process.pid, started_at: 1, ts: Date.now() }), // no token
164
+ );
165
+ expect(await requestDaemonStop()).toBeNull();
166
+ await expect(stat(daemonLockDir())).resolves.toBeTruthy(); // lock untouched
167
+ });
168
+ });
169
+
170
+ describe("startup reconcile pid-reuse guard", () => {
171
+ let home: string;
172
+ const prev = process.env.AGENT_YES_HOME;
173
+ const host = hostId();
174
+ beforeEach(async () => {
175
+ home = await mkdtemp(path.join(tmpdir(), "ay-notify-rec-"));
176
+ process.env.AGENT_YES_HOME = home;
177
+ });
178
+ afterEach(async () => {
179
+ if (prev === undefined) delete process.env.AGENT_YES_HOME;
180
+ else process.env.AGENT_YES_HOME = prev;
181
+ await rm(home, { recursive: true, force: true }).catch(() => {});
182
+ });
183
+
184
+ const exitedEv = (childPid: number, childStartedAt: number): Omit<NotifyEvent, "seq"> => ({
185
+ ts: 1,
186
+ host,
187
+ parent_pid: 1,
188
+ child_pid: childPid,
189
+ child_started_at: childStartedAt,
190
+ cli: "claude",
191
+ cwd: "/repo",
192
+ edge: "exited",
193
+ prev_state: "active",
194
+ state: "stopped",
195
+ question: null,
196
+ });
197
+
198
+ it("seeds a child still live with the SAME start time (no re-emit) + copies identity", async () => {
199
+ await appendEvent(1, { ...exitedEv(555, 1000), parent_started_at: 42 });
200
+ const seeded = await reconcileFromInboxes(host, new Map([[555, 1000]]), new Set([1]));
201
+ expect(seeded.get(555)?.exitedEmitted).toBe(true);
202
+ // I2: identity copied into the seeded state so the hot-path guard can fire.
203
+ expect(seeded.get(555)?.started_at).toBe(1000);
204
+ expect(seeded.get(555)?.parent_started_at).toBe(42);
205
+ });
206
+
207
+ it("does NOT inherit exitedEmitted when the pid was reused after an exit (identity-aware)", async () => {
208
+ // Old child (pid 555, started 1000) exited; then pid 555 is reused by a NEW
209
+ // child (started 2000) whose latest edge is idle. On reconcile the new child
210
+ // matches liveStarted=2000, and its exitedEmitted must be FALSE (its own exit
211
+ // still fires later), not inherited from the old incarnation's exit.
212
+ await appendEvent(1, exitedEv(555, 1000)); // old child exited
213
+ await appendEvent(1, {
214
+ ...exitedEv(555, 2000),
215
+ edge: "idle",
216
+ state: "idle",
217
+ }); // new child's later idle
218
+ const cs = (await reconcileFromInboxes(host, new Map([[555, 2000]]), new Set([1]))).get(555)!;
219
+ expect(cs.started_at).toBe(2000);
220
+ expect(cs.exitedEmitted).toBe(false);
221
+ });
222
+
223
+ it("does NOT seed the idle emitted flag — a restart RE-CONFIRMS idle (never suppress)", async () => {
224
+ const idleEv: Omit<NotifyEvent, "seq"> = {
225
+ ts: 1,
226
+ host,
227
+ parent_pid: 1,
228
+ child_pid: 555,
229
+ child_started_at: 1000,
230
+ cli: "claude",
231
+ cwd: "/repo",
232
+ edge: "idle",
233
+ prev_state: "active",
234
+ state: "idle",
235
+ question: null,
236
+ };
237
+ await appendEvent(1, idleEv);
238
+ const cs = (await reconcileFromInboxes(host, new Map([[555, 1000]]), new Set([1]))).get(555)!;
239
+ // Identity is seeded (hot-path guard), but the idle episode is NOT marked
240
+ // emitted → the post-restart idle observation re-confirms and re-emits.
241
+ expect(cs.started_at).toBe(1000);
242
+ expect(cs.idleEmitted).toBe(false);
243
+ expect(cs.idleSince).toBeNull();
244
+ });
245
+
246
+ it("does NOT seed a pid whose live start time differs (reused pid emits fresh)", async () => {
247
+ await appendEvent(1, exitedEv(555, 1000));
248
+ // pid 555 now belongs to a NEW child (started_at 2000) → must not inherit
249
+ // the old child's exitedEmitted, else its own exit would be suppressed.
250
+ const seeded = await reconcileFromInboxes(host, new Map([[555, 2000]]), new Set([1]));
251
+ expect(seeded.has(555)).toBe(false);
252
+ });
253
+
254
+ it("does NOT seed a reaped child absent from the registry", async () => {
255
+ await appendEvent(1, exitedEv(555, 1000));
256
+ const seeded = await reconcileFromInboxes(host, new Map(), new Set([1]));
257
+ expect(seeded.has(555)).toBe(false);
258
+ });
259
+
260
+ it("does NOT seed an event with no child_started_at (C2: can't verify → re-emit)", async () => {
261
+ // A pre-C2 / synthetic event without a start time can't be identity-checked,
262
+ // so it must not seed (better a duplicate edge than a suppressed one).
263
+ await appendEvent(1, { ...exitedEv(555, 1000), child_started_at: 0 });
264
+ const seeded = await reconcileFromInboxes(host, new Map([[555, 1000]]), new Set([1]));
265
+ expect(seeded.has(555)).toBe(false);
266
+ });
267
+
268
+ it("does NOT seed an inbox whose parent is NOT currently watched (I1)", async () => {
269
+ // Parent 1's inbox exists, but no one is watching parent 1 now → the daemon
270
+ // must not hold its state (else it could later write a synthetic exited into
271
+ // an unwatched inbox, violating "nothing happens unless you watch").
272
+ await appendEvent(1, exitedEv(555, 1000));
273
+ const seeded = await reconcileFromInboxes(host, new Map([[555, 1000]]), new Set()); // none watched
274
+ expect(seeded.size).toBe(0);
275
+ });
276
+ });