agent-yes 1.265.4 → 1.266.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (75) hide show
  1. package/dist/{SUPPORTED_CLIS-C3tcRetU.js → SUPPORTED_CLIS-AFV5pjSx.js} +2 -2
  2. package/dist/{SUPPORTED_CLIS-CY3SaV0D.js → SUPPORTED_CLIS-DRVuSo48.js} +3 -3
  3. package/dist/{agentShare-g6N1A6A_.js → agentShare-qdFSp9Uq.js} +6 -5
  4. package/dist/{callback-L4k-v2GG.js → callback-Da7MRLh6.js} +6 -5
  5. package/dist/{callback-z-w0ND5z.js → callback-DfNFdZin.js} +5 -5
  6. package/dist/{callbackCore-Cv4EOGjE.js → callbackCore-DcptaZwH.js} +1 -1
  7. package/dist/{callbackCore-Cg3Ye0ZS.js → callbackCore-K99x3C75.js} +1 -1
  8. package/dist/{channels-BwpN_8mu.js → channels-DQaZbare.js} +2 -2
  9. package/dist/cli.js +11 -11
  10. package/dist/{cwdConflictWarn-DZTxTGXq.js → cwdConflictWarn-CtQhl-vF.js} +1 -1
  11. package/dist/{cwdPassthroughHint-DDVkhBiD.js → cwdPassthroughHint-apaX_O7p.js} +1 -1
  12. package/dist/{expose-BG17GV7D.js → expose-BXn6My6A.js} +1 -1
  13. package/dist/{forkNested-VroZS-nk.js → forkNested-CDfWPiHw.js} +12 -2
  14. package/dist/{hist-CoXBBjJt.js → hist-BpocAe1N.js} +1 -1
  15. package/dist/index.js +2 -2
  16. package/dist/initMsg-BjRseolL.js +3 -0
  17. package/dist/initMsg-C-w4A_eF.js +85 -0
  18. package/dist/needsInput-103zhZ90.js +79 -0
  19. package/dist/{nodeRuntime-CT0w6zsB.js → nodeRuntime-CRoEMWkp.js} +1 -1
  20. package/dist/{notifyDaemon-BL1tmxUK.js → notifyDaemon-BFzqcdN1.js} +32 -5
  21. package/dist/notifyStore-CEDiVyis.js +4 -0
  22. package/dist/notifyStore-Cbw2ZJ0X.js +559 -0
  23. package/dist/{openBrowser-Cw8hg7ok.js → openBrowser-DhQ9pJRi.js} +1 -1
  24. package/dist/parentLink-B798ASCW.js +5 -0
  25. package/dist/parentLink-C0cIRjfL.js +53 -0
  26. package/dist/parentPingLoop-BIKxzbPH.js +469 -0
  27. package/dist/{remotes-DWVw-Q7V.js → remotes-Dg_JscKk.js} +2 -2
  28. package/dist/{remotes-iIvqiFWK.js → remotes-iMhdZytp.js} +3 -3
  29. package/dist/{rustBinary-DSVRhDzO.js → rustBinary-JGNROWfY.js} +2 -2
  30. package/dist/{schedule-i4o5SJ_h.js → schedule-DbLpjgl5.js} +6 -6
  31. package/dist/{serve-CpGJD2Kt.js → serve-ucVXOci1.js} +31 -30
  32. package/dist/{setup-B-iQKsQW.js → setup-De7uYRZb.js} +4 -4
  33. package/dist/{share-DOnynkf4.js → share-Db_zb6gH.js} +1 -1
  34. package/dist/{share-B-e_9WBx.js → share-NUhi-Wdd.js} +1 -1
  35. package/dist/{spawnGate-S6EoFdzX.js → spawnGate-BvaZeHE7.js} +1 -1
  36. package/dist/{spawnGate-BmsTv340.js → spawnGate-CEXVmTtv.js} +2 -2
  37. package/dist/subcommands-C04exkIM.js +11 -0
  38. package/dist/{subcommands-CQPnT5qu.js → subcommands-DtF31RoW.js} +41 -656
  39. package/dist/{systemPathLink-CjLmiPbB.js → systemPathLink-7TFPwJna.js} +1 -1
  40. package/dist/{terminal-CybRgMZu.js → terminal-Dm32SXU-.js} +2 -2
  41. package/dist/{todoCli-DZmOmcvd.js → todoCli-c_R7D7Y-.js} +1 -1
  42. package/dist/{tray-Cjm3a8JH.js → tray-DZnGjJBK.js} +1 -1
  43. package/dist/trayApp-CZbwLK9t.js +5 -0
  44. package/dist/{trayApp-UglHbk_m.js → trayApp-CfkPG0wy.js} +2 -2
  45. package/dist/{ts-nuUnrVcf.js → ts-CGtoPER5.js} +43 -10
  46. package/dist/{versionChecker-CMOLbEEi.js → versionChecker-CJgW9DHY.js} +2 -2
  47. package/dist/{webrtcLink-CS4D-3G1.js → webrtcLink-DyCj380i.js} +1 -1
  48. package/dist/{webrtcRemote-zQybN9IR.js → webrtcRemote-BDg0jjTs.js} +2 -2
  49. package/dist/{widget-y5Bpvwsa.js → widget-DXtaX3QR.js} +3 -3
  50. package/dist/{workspaceConfig-BDqp7fYl.js → workspaceConfig-HiWQzq6A.js} +1 -1
  51. package/dist/{ws-CH8GzcQn.js → ws-CqlS5JhN.js} +5 -4
  52. package/dist/{ws-BrLV-AlH.js → ws-aKRZpI9b.js} +9 -11
  53. package/package.json +1 -1
  54. package/ts/forkNested.spec.ts +22 -0
  55. package/ts/forkNested.ts +19 -0
  56. package/ts/index.ts +77 -12
  57. package/ts/initMsg.spec.ts +114 -0
  58. package/ts/initMsg.ts +107 -0
  59. package/ts/ownerHeartbeat.ts +40 -1
  60. package/ts/parentLink.spec.ts +64 -0
  61. package/ts/parentLink.ts +58 -0
  62. package/ts/parentPing.spec.ts +176 -0
  63. package/ts/parentPing.ts +195 -0
  64. package/ts/parentPingLoop.delivery.spec.ts +236 -0
  65. package/ts/parentPingLoop.spec.ts +79 -0
  66. package/ts/parentPingLoop.ts +196 -0
  67. package/ts/parentPingSend.spec.ts +195 -0
  68. package/ts/parentPingSend.ts +148 -0
  69. package/ts/parentWatching.spec.ts +61 -0
  70. package/ts/parentWatching.ts +89 -0
  71. package/ts/subcommands.ts +11 -2
  72. package/ts/trayApp.spec.ts +31 -4
  73. package/ts/ws.ts +17 -12
  74. package/dist/subcommands-Cgt2-kjg.js +0 -10
  75. package/dist/trayApp-CmwwBg8v.js +0 -5
@@ -0,0 +1,236 @@
1
+ /**
2
+ * The half of startParentPingLoop that only runs once a spawner actually
3
+ * resolves: the poll timer firing a ping, the suppression check, and pingExit.
4
+ *
5
+ * Split out from parentPingLoop.spec.ts rather than merged into it because
6
+ * covering this half means mocking `resolveSpawner`, and those tests are
7
+ * specifically about what the REAL resolver does with an absent/dead parent —
8
+ * mocking it there would leave that rule untested.
9
+ */
10
+
11
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
12
+
13
+ // Resolved per-test: the loop only arms its timer when this yields a spawner.
14
+ let spawner: { cli: string; pid: number; agentId: string | null; cwd: string } | null = null;
15
+ let resolveErr: Error | null = null;
16
+
17
+ vi.mock("./parentLink.ts", () => ({
18
+ resolveSpawner: async () => {
19
+ if (resolveErr) throw resolveErr;
20
+ return spawner;
21
+ },
22
+ }));
23
+
24
+ // deliverPing spawns a real `ay send` child process; stub the delivery but keep
25
+ // buildPingBody real so the body the parent would receive is still exercised.
26
+ const delivered: { target: string; body: string; selfWrapperPid: number }[] = [];
27
+ let deliverOk = true;
28
+
29
+ vi.mock("./parentPingSend.ts", async () => {
30
+ const actual = await vi.importActual<typeof import("./parentPingSend.ts")>("./parentPingSend.ts");
31
+ return {
32
+ ...actual,
33
+ deliverPing: async (opts: { target: string; body: string; selfWrapperPid: number }) => {
34
+ delivered.push(opts);
35
+ return deliverOk;
36
+ },
37
+ };
38
+ });
39
+
40
+ const CONF = {
41
+ needsInput: [/❯ ?\d+\./m],
42
+ working: [/esc to interrupt/],
43
+ ready: [/^\s*❯\s*$/m],
44
+ };
45
+
46
+ const self = { cli: "claude", pid: 1, cwd: "/repo" };
47
+
48
+ async function loadLoop() {
49
+ return (await import("./parentPingLoop.ts")).startParentPingLoop;
50
+ }
51
+
52
+ /** Let the `inFlight` promise chain inside send() drain. */
53
+ async function drain() {
54
+ for (let i = 0; i < 10; i++) await Promise.resolve();
55
+ }
56
+
57
+ beforeEach(() => {
58
+ delivered.length = 0;
59
+ deliverOk = true;
60
+ resolveErr = null;
61
+ spawner = { cli: "claude", pid: 42, agentId: "agt_parent", cwd: "/parent" };
62
+ vi.resetModules();
63
+ });
64
+
65
+ afterEach(() => {
66
+ vi.useRealTimers();
67
+ });
68
+
69
+ describe("startParentPingLoop delivery", () => {
70
+ const base = {
71
+ parentPid: 7,
72
+ selfWrapperPid: 2,
73
+ self: { ...self },
74
+ patterns: CONF,
75
+ };
76
+
77
+ it("a pending menu on the first poll reports `stuck` to the parent's agent_id", async () => {
78
+ vi.useFakeTimers();
79
+ const startParentPingLoop = await loadLoop();
80
+ const loop = startParentPingLoop({
81
+ ...base,
82
+ screen: () => ["Do you want to proceed?", "❯ 1. Yes", " 2. No"],
83
+ pollMs: 1_000,
84
+ mode: "always",
85
+ now: () => 1_000,
86
+ });
87
+
88
+ await expect(loop.ready).resolves.toBe(true);
89
+ await vi.advanceTimersByTimeAsync(1_000);
90
+ await drain();
91
+
92
+ expect(delivered).toHaveLength(1);
93
+ // Routed to the stable agent_id, never the parent's pid.
94
+ expect(delivered[0].target).toBe("agt_parent");
95
+ expect(delivered[0].selfWrapperPid).toBe(2);
96
+ expect(delivered[0].body).toContain("is STUCK");
97
+ // The tail the parent gets is the screen that justified the report.
98
+ expect(delivered[0].body).toContain("Do you want to proceed?");
99
+ loop.stop();
100
+ });
101
+
102
+ it("`never` suppresses the report but still consumes the episode", async () => {
103
+ vi.useFakeTimers();
104
+ const startParentPingLoop = await loadLoop();
105
+ const loop = startParentPingLoop({
106
+ ...base,
107
+ screen: () => ["Do you want to proceed?", "❯ 1. Yes"],
108
+ pollMs: 1_000,
109
+ mode: "never",
110
+ now: () => 1_000,
111
+ });
112
+
113
+ await expect(loop.ready).resolves.toBe(true);
114
+ await vi.advanceTimersByTimeAsync(1_000);
115
+ await drain();
116
+
117
+ expect(delivered).toHaveLength(0);
118
+ loop.stop();
119
+ });
120
+
121
+ it("pingExit reports the exit code and stops the timer", async () => {
122
+ const startParentPingLoop = await loadLoop();
123
+ const loop = startParentPingLoop({
124
+ ...base,
125
+ screen: () => ["❯"],
126
+ pollMs: 60_000,
127
+ mode: "always",
128
+ now: () => 1_000,
129
+ });
130
+
131
+ await expect(loop.ready).resolves.toBe(true);
132
+ await loop.pingExit(3);
133
+
134
+ expect(delivered).toHaveLength(1);
135
+ expect(delivered[0].target).toBe("agt_parent");
136
+ expect(delivered[0].body).toContain("has EXITED");
137
+ expect(delivered[0].body).toContain("Exit code: 3");
138
+ });
139
+
140
+ it("pingExit still reports when the screen render throws", async () => {
141
+ const startParentPingLoop = await loadLoop();
142
+ const loop = startParentPingLoop({
143
+ ...base,
144
+ screen: () => {
145
+ throw new Error("render race");
146
+ },
147
+ pollMs: 60_000,
148
+ mode: "always",
149
+ now: () => 1_000,
150
+ });
151
+
152
+ await expect(loop.ready).resolves.toBe(true);
153
+ await loop.pingExit(null);
154
+
155
+ expect(delivered).toHaveLength(1);
156
+ expect(delivered[0].body).toContain("has EXITED");
157
+ // No tail to attach, and a null exit code stays off the report entirely.
158
+ expect(delivered[0].body).not.toContain("Last output:");
159
+ expect(delivered[0].body).not.toContain("Exit code:");
160
+ });
161
+
162
+ it("an exit that fails to deliver is not thrown at the wrapper's exit path", async () => {
163
+ deliverOk = false;
164
+ const startParentPingLoop = await loadLoop();
165
+ const loop = startParentPingLoop({
166
+ ...base,
167
+ screen: () => ["❯"],
168
+ pollMs: 60_000,
169
+ mode: "always",
170
+ now: () => 1_000,
171
+ });
172
+
173
+ await expect(loop.ready).resolves.toBe(true);
174
+ await expect(loop.pingExit(1)).resolves.toBeUndefined();
175
+ expect(delivered).toHaveLength(1);
176
+ });
177
+
178
+ it("reports exactly once — the exit episode is terminal", async () => {
179
+ const startParentPingLoop = await loadLoop();
180
+ const loop = startParentPingLoop({
181
+ ...base,
182
+ screen: () => ["❯"],
183
+ pollMs: 60_000,
184
+ mode: "always",
185
+ now: () => 1_000,
186
+ });
187
+
188
+ await loop.ready;
189
+ await loop.pingExit(0);
190
+ await loop.pingExit(0);
191
+
192
+ expect(delivered).toHaveLength(1);
193
+ });
194
+
195
+ it("a spawner lookup that throws is treated as no parent, not a crash", async () => {
196
+ resolveErr = new Error("registry exploded");
197
+ const startParentPingLoop = await loadLoop();
198
+ const loop = startParentPingLoop({
199
+ ...base,
200
+ screen: () => ["❯"],
201
+ mode: "always",
202
+ });
203
+
204
+ await expect(loop.ready).resolves.toBe(false);
205
+ await expect(loop.pingExit(0)).resolves.toBeUndefined();
206
+ expect(delivered).toHaveLength(0);
207
+ });
208
+
209
+ it("a screen() hiccup mid-poll skips the tick instead of reporting a false state", async () => {
210
+ vi.useFakeTimers();
211
+ let throwNow = true;
212
+ const startParentPingLoop = await loadLoop();
213
+ const loop = startParentPingLoop({
214
+ ...base,
215
+ screen: () => {
216
+ if (throwNow) throw new Error("render race");
217
+ return ["Do you want to proceed?", "❯ 1. Yes"];
218
+ },
219
+ pollMs: 1_000,
220
+ mode: "always",
221
+ now: () => 1_000,
222
+ });
223
+
224
+ await expect(loop.ready).resolves.toBe(true);
225
+ await vi.advanceTimersByTimeAsync(1_000);
226
+ await drain();
227
+ expect(delivered).toHaveLength(0); // the throwing tick reported nothing
228
+
229
+ throwNow = false;
230
+ await vi.advanceTimersByTimeAsync(1_000);
231
+ await drain();
232
+ expect(delivered).toHaveLength(1); // the next good tick still does
233
+
234
+ loop.stop();
235
+ });
236
+ });
@@ -0,0 +1,79 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { classifySelfState, startParentPingLoop } from "./parentPingLoop.ts";
3
+
4
+ // Mirrors the claude config (rs/default.config.yaml): a numbered menu cursor is
5
+ // a pending question, a spinner marker means work is happening, `❯ ` alone is
6
+ // the idle prompt.
7
+ const CONF = {
8
+ needsInput: [/❯ ?\d+\./m],
9
+ working: [/esc to interrupt/],
10
+ ready: [/^\s*❯\s*$/m],
11
+ };
12
+
13
+ describe("classifySelfState", () => {
14
+ it("a working spinner beats everything — a long silent tool call is not done", () => {
15
+ expect(classifySelfState(["❯ 1. yes", "esc to interrupt"], CONF)).toBe("active");
16
+ expect(classifySelfState(["❯", "esc to interrupt"], CONF)).toBe("active");
17
+ });
18
+
19
+ it("a pending menu is needs_input", () => {
20
+ expect(classifySelfState(["Do you want to proceed?", "❯ 1. Yes", " 2. No"], CONF)).toBe(
21
+ "needs_input",
22
+ );
23
+ });
24
+
25
+ it("a bare prompt is idle", () => {
26
+ expect(classifySelfState(["all done", "❯"], CONF)).toBe("idle");
27
+ });
28
+
29
+ it("falls back to active on an unrecognised screen — a false 'idle' is the costly error", () => {
30
+ expect(classifySelfState(["...something..."], CONF)).toBe("active");
31
+ expect(classifySelfState([], CONF)).toBe("active");
32
+ expect(classifySelfState(["❯"], {})).toBe("active");
33
+ });
34
+ });
35
+
36
+ describe("startParentPingLoop", () => {
37
+ const self = { cli: "claude", pid: 1, cwd: "/repo" };
38
+ const base = {
39
+ selfWrapperPid: 2,
40
+ self,
41
+ patterns: CONF,
42
+ screen: () => ["❯"],
43
+ };
44
+
45
+ it("never starts a timer for a top-level agent (no parent)", async () => {
46
+ const loop = startParentPingLoop({ ...base, parentPid: undefined });
47
+ await expect(loop.ready).resolves.toBe(false);
48
+ loop.stop();
49
+ });
50
+
51
+ it("treats an unresolvable parent as no parent — a route that goes nowhere is worse than none", async () => {
52
+ const loop = startParentPingLoop({ ...base, parentPid: 2 ** 30 });
53
+ await expect(loop.ready).resolves.toBe(false);
54
+ loop.stop();
55
+ });
56
+
57
+ it("pingExit is a safe no-op when there is nobody to report to", async () => {
58
+ const loop = startParentPingLoop({ ...base, parentPid: undefined });
59
+ await expect(loop.pingExit(0)).resolves.toBeUndefined();
60
+ });
61
+
62
+ it("survives a screen() that throws (a render hiccup is not a signal)", async () => {
63
+ const loop = startParentPingLoop({
64
+ ...base,
65
+ parentPid: undefined,
66
+ screen: () => {
67
+ throw new Error("render race");
68
+ },
69
+ });
70
+ await expect(loop.ready).resolves.toBe(false);
71
+ await expect(loop.pingExit(1)).resolves.toBeUndefined();
72
+ });
73
+
74
+ it("stop() before the spawner lookup settles never arms a timer", async () => {
75
+ const loop = startParentPingLoop({ ...base, parentPid: process.pid });
76
+ loop.stop();
77
+ await expect(loop.ready).resolves.toBe(false);
78
+ });
79
+ });
@@ -0,0 +1,196 @@
1
+ /**
2
+ * Wiring for the sub-agent→parent ping: a small poller the wrapper owns for its
3
+ * OWN agent, feeding `stepParentPing` and delivering whatever it decides.
4
+ *
5
+ * Kept out of ts/index.ts (already ~1400 lines) and out of the auto-response
6
+ * heartbeat: this must not share a tick budget with the thing that answers
7
+ * permission prompts, and its classification is a different question ("is my
8
+ * agent done/stuck?") from the heartbeat's ("does this line need a keystroke?").
9
+ *
10
+ * No-parent is the common case (a human-launched top-level agent), and it costs
11
+ * nothing: we resolve the spawner once and simply never start a timer.
12
+ */
13
+
14
+ import { classifyNeedsInput } from "./needsInput.ts";
15
+ import { logger } from "./logger.ts";
16
+ import { resolveSpawner } from "./parentLink.ts";
17
+ import { replyTargetOf } from "./initMsg.ts";
18
+ import {
19
+ DEFAULT_PING_CONFIG,
20
+ initialPingState,
21
+ stepParentPing,
22
+ type PingConfig,
23
+ type PingState,
24
+ type SelfState,
25
+ } from "./parentPing.ts";
26
+ import { buildPingBody, deliverPing, type PingSelf } from "./parentPingSend.ts";
27
+ import {
28
+ isParentWatching,
29
+ parseReportMode,
30
+ shouldDeliverReport,
31
+ type ReportMode,
32
+ } from "./parentWatching.ts";
33
+
34
+ const POLL_MS = 5_000;
35
+
36
+ export interface ScreenPatterns {
37
+ ready?: RegExp[];
38
+ working?: RegExp[];
39
+ needsInput?: RegExp[];
40
+ }
41
+
42
+ /**
43
+ * Classify the wrapper's own agent from its rendered screen.
44
+ *
45
+ * Order matters and is the same precedence `deriveLiveState` uses: a `working`
46
+ * spinner beats everything (a long silent tool call is NOT done), then a pending
47
+ * menu, then an idle prompt. Anything unrecognised falls back to `active` —
48
+ * always the safe default, because a false `active` costs a delayed report while
49
+ * a false `idle` tells the parent "finished" about an agent mid-task.
50
+ */
51
+ export function classifySelfState(lines: string[], conf: ScreenPatterns): SelfState {
52
+ const text = lines.join("\n");
53
+ if (conf.working?.some((re) => re.test(text))) return "active";
54
+ if (classifyNeedsInput(lines, conf)) return "needs_input";
55
+ if (conf.ready?.some((re) => re.test(text))) return "idle";
56
+ return "active";
57
+ }
58
+
59
+ export interface ParentPingLoop {
60
+ /** Stop polling (the exit ping stays available). */
61
+ stop(): void;
62
+ /**
63
+ * Report the agent's termination. Awaited on the wrapper's exit path so the
64
+ * message is actually handed off before the process goes away.
65
+ */
66
+ pingExit(exitCode: number | null): Promise<void>;
67
+ /** Resolves once the spawner lookup settled — for tests and for `ay` to know
68
+ * whether a parent link exists at all. */
69
+ ready: Promise<boolean>;
70
+ }
71
+
72
+ /**
73
+ * Start watching our own agent and reporting to whoever spawned it.
74
+ *
75
+ * `screen()` returns the currently-rendered tail lines (the wrapper's xterm
76
+ * proxy); it is called on every poll, so it must be cheap and must not throw.
77
+ */
78
+ export function startParentPingLoop(opts: {
79
+ /** Parent WRAPPER pid inherited via AGENT_YES_PID, or undefined for a root agent. */
80
+ parentPid: number | undefined;
81
+ /** This wrapper's own pid — what `ay send` must attribute the report to. */
82
+ selfWrapperPid: number;
83
+ self: PingSelf;
84
+ patterns: ScreenPatterns;
85
+ screen: () => string[];
86
+ pollMs?: number;
87
+ config?: PingConfig;
88
+ /**
89
+ * Override `AGENT_YES_REPORT_PARENT`. `auto` (default) stays quiet while the
90
+ * parent is demonstrably monitoring this child — a parent harness with its own
91
+ * monitor loop already sees idle/needs_input/working, and an injected report on
92
+ * top of that interrupts it for nothing. See ts/parentWatching.ts.
93
+ */
94
+ mode?: ReportMode;
95
+ /** Test seam. */
96
+ now?: () => number;
97
+ }): ParentPingLoop {
98
+ const cfg = opts.config ?? DEFAULT_PING_CONFIG;
99
+ const now = opts.now ?? (() => Date.now());
100
+ const mode: ReportMode = opts.mode ?? parseReportMode(process.env.AGENT_YES_REPORT_PARENT);
101
+ let state: PingState = initialPingState();
102
+ let target: string | null = null;
103
+ let parentAgentPid: number | null = null;
104
+ let timer: ReturnType<typeof setInterval> | null = null;
105
+ let stopped = false;
106
+ // One ping in flight at a time: `ay send` can take seconds (paste settle on the
107
+ // parent's side), and overlapping sends would interleave two reports into one
108
+ // composer line.
109
+ let inFlight: Promise<unknown> = Promise.resolve();
110
+
111
+ const send = (
112
+ decision: ReturnType<typeof stepParentPing>["ping"],
113
+ tail: string | null,
114
+ ): Promise<void> => {
115
+ if (!decision || !target) return Promise.resolve();
116
+ const t = target;
117
+ inFlight = inFlight
118
+ .then(async () => {
119
+ // Checked at SEND time, not at poll time: whether the parent is watching
120
+ // can change between the edge firing and the report going out, and this
121
+ // is the moment the answer actually matters. Only reached a handful of
122
+ // times per agent lifetime, so the lookup's cost is irrelevant.
123
+ const watching =
124
+ mode === "auto" && opts.parentPid !== undefined && parentAgentPid !== null
125
+ ? await isParentWatching(opts.parentPid, parentAgentPid, opts.self.pid, now())
126
+ : false;
127
+ if (!shouldDeliverReport({ mode, parentIsWatching: watching, reason: decision.reason })) {
128
+ logger.debug(
129
+ `[parent-ping] ${decision.reason} suppressed (mode=${mode}, parent watching=${watching})`,
130
+ );
131
+ return;
132
+ }
133
+ const body = buildPingBody(decision, opts.self, tail);
134
+ const ok = await deliverPing({
135
+ target: t,
136
+ body,
137
+ selfWrapperPid: opts.selfWrapperPid,
138
+ });
139
+ logger.debug(
140
+ `[parent-ping] ${decision.reason} attempt ${decision.attempt} → ${t}: ${ok ? "sent" : "FAILED"}`,
141
+ );
142
+ })
143
+ .catch(() => {});
144
+ return inFlight as Promise<void>;
145
+ };
146
+
147
+ const ready = resolveSpawner(opts.parentPid)
148
+ .then((spawner) => {
149
+ if (!spawner || stopped) return false;
150
+ target = replyTargetOf(spawner);
151
+ parentAgentPid = spawner.pid;
152
+ timer = setInterval(() => {
153
+ let lines: string[] = [];
154
+ try {
155
+ lines = opts.screen();
156
+ } catch {
157
+ return; // a render hiccup is not a signal
158
+ }
159
+ const stepped = stepParentPing(
160
+ state,
161
+ { now: now(), state: classifySelfState(lines, opts.patterns) },
162
+ cfg,
163
+ );
164
+ state = stepped.state;
165
+ if (stepped.ping) void send(stepped.ping, lines.join("\n"));
166
+ }, opts.pollMs ?? POLL_MS);
167
+ timer.unref?.();
168
+ return true;
169
+ })
170
+ .catch(() => false);
171
+
172
+ return {
173
+ stop() {
174
+ stopped = true;
175
+ if (timer) clearInterval(timer);
176
+ timer = null;
177
+ },
178
+ ready,
179
+ async pingExit(exitCode) {
180
+ await ready;
181
+ this.stop();
182
+ if (!target) return;
183
+ let tail: string | null = null;
184
+ try {
185
+ tail = opts.screen().join("\n");
186
+ } catch {
187
+ /* ignore */
188
+ }
189
+ opts.self.exitCode = exitCode;
190
+ const stepped = stepParentPing(state, { now: now(), state: "exited" }, cfg);
191
+ state = stepped.state;
192
+ if (!stepped.ping) return;
193
+ await send(stepped.ping, tail);
194
+ },
195
+ };
196
+ }