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,195 @@
1
+ import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
2
+ import { tmpdir } from "node:os";
3
+ import path from "node:path";
4
+ import { afterEach, beforeEach, describe, expect, it } from "vitest";
5
+ import { buildPingBody, buildSendArgv, deliverPing, type PingSelf } from "./parentPingSend.ts";
6
+ import type { PingDecision } from "./parentPing.ts";
7
+
8
+ const self: PingSelf = {
9
+ cli: "claude",
10
+ pid: 777,
11
+ cwd: "/repo",
12
+ prompt: "fix the flaky test",
13
+ };
14
+ const d = (over: Partial<PingDecision> = {}): PingDecision => ({
15
+ reason: "finished",
16
+ attempt: 1,
17
+ question: null,
18
+ ...over,
19
+ });
20
+
21
+ describe("buildPingBody", () => {
22
+ it("wraps in <ay-report …> with the child's identity", () => {
23
+ const out = buildPingBody(d(), self);
24
+ expect(out).toMatch(/^<ay-report from claude #777 @ \/repo>/);
25
+ expect(out.endsWith("</ay-report>")).toBe(true);
26
+ });
27
+
28
+ it("says which edge fired", () => {
29
+ expect(buildPingBody(d(), self)).toContain("looks FINISHED");
30
+ expect(buildPingBody(d({ reason: "stuck" }), self)).toContain("is STUCK");
31
+ expect(buildPingBody(d({ reason: "exited" }), self)).toContain("has EXITED");
32
+ });
33
+
34
+ it("is explicit that this is the WRAPPER talking, not the agent", () => {
35
+ const out = buildPingBody(d(), self);
36
+ expect(out).toContain("automatic report from its agent-yes wrapper");
37
+ expect(out).toContain("not as its answer");
38
+ });
39
+
40
+ it("marks a repeat as a reminder so the parent knows it already ignored one", () => {
41
+ expect(buildPingBody(d({ attempt: 1 }), self)).not.toContain("reminder");
42
+ expect(buildPingBody(d({ attempt: 3 }), self)).toContain("reminder #2 — no reply yet");
43
+ });
44
+
45
+ it("reminds the parent what it asked for", () => {
46
+ expect(buildPingBody(d(), self)).toContain("Task it was spawned with: fix the flaky test");
47
+ expect(buildPingBody(d(), { ...self, prompt: null })).not.toContain("Task it was spawned with");
48
+ });
49
+
50
+ it("carries the question for a stuck report", () => {
51
+ const out = buildPingBody(d({ reason: "stuck", question: "Approve edit?" }), self);
52
+ expect(out).toContain("It is asking: Approve edit?");
53
+ });
54
+
55
+ it("includes the exit code only for an exit report", () => {
56
+ expect(buildPingBody(d({ reason: "exited" }), { ...self, exitCode: 2 })).toContain(
57
+ "Exit code: 2",
58
+ );
59
+ expect(buildPingBody(d({ reason: "exited" }), { ...self, exitCode: 0 })).toContain(
60
+ "Exit code: 0",
61
+ );
62
+ expect(buildPingBody(d({ reason: "exited" }), { ...self, exitCode: null })).not.toContain(
63
+ "Exit code:",
64
+ );
65
+ });
66
+
67
+ it("attaches the tail as evidence, because the parent is not tailing", () => {
68
+ const out = buildPingBody(d(), self, " \nall tests passed\n");
69
+ expect(out).toContain("Last output:");
70
+ expect(out).toContain("all tests passed");
71
+ });
72
+
73
+ it("omits an empty tail section entirely", () => {
74
+ expect(buildPingBody(d(), self, " \n ")).not.toContain("Last output:");
75
+ expect(buildPingBody(d(), self, null)).not.toContain("Last output:");
76
+ });
77
+
78
+ it("clips a runaway tail on a word boundary", () => {
79
+ const out = buildPingBody(d(), self, "word ".repeat(1000));
80
+ expect(out).toContain("…");
81
+ expect(out.length).toBeLessThan(2000);
82
+ });
83
+
84
+ it("offers result/cat for an exited child and tail/send for a live one", () => {
85
+ expect(buildPingBody(d({ reason: "exited" }), self)).toContain("ay result get 777");
86
+ expect(buildPingBody(d({ reason: "stuck" }), self)).toContain(`ay send 777 "..."`);
87
+ });
88
+ });
89
+
90
+ describe("buildSendArgv", () => {
91
+ it("re-invokes THIS entrypoint so the ping uses the same build", () => {
92
+ const { cmd, args } = buildSendArgv("agt_parent", "/opt/agent-yes/dist/agent-yes.js");
93
+ expect(cmd).toBe(process.execPath);
94
+ expect(args).toEqual([
95
+ "/opt/agent-yes/dist/agent-yes.js",
96
+ "send",
97
+ "agt_parent",
98
+ "-",
99
+ "--force",
100
+ "--no-wait",
101
+ ]);
102
+ });
103
+
104
+ it("falls back to `ay` on PATH when there is no argv[1]", () => {
105
+ const { cmd, args } = buildSendArgv("agt_parent");
106
+ expect(cmd).toBe("ay");
107
+ expect(args[0]).toBe("send");
108
+ });
109
+
110
+ it("reads the body from stdin (`-`) so a multi-line report needs no escaping", () => {
111
+ expect(buildSendArgv("x", "e.js").args).toContain("-");
112
+ });
113
+
114
+ it("forces past the recency guard and never blocks on the parent's screen", () => {
115
+ const { args } = buildSendArgv("x", "e.js");
116
+ expect(args).toContain("--force");
117
+ expect(args).toContain("--no-wait");
118
+ });
119
+ });
120
+
121
+ describe("deliverPing", () => {
122
+ it("resolves false instead of throwing when the command cannot be spawned", async () => {
123
+ const argv1 = process.argv[1];
124
+ process.argv[1] = "/nonexistent/definitely-not-here.js";
125
+ try {
126
+ await expect(
127
+ deliverPing({
128
+ target: "x",
129
+ body: "b",
130
+ selfWrapperPid: 1,
131
+ timeoutMs: 5_000,
132
+ }),
133
+ ).resolves.toBe(false);
134
+ } finally {
135
+ process.argv[1] = argv1!;
136
+ }
137
+ });
138
+ });
139
+
140
+ describe("deliverPing — the wire contract, against a stub `ay send`", () => {
141
+ // Exercises the real subprocess path: what argv the stub is invoked with, what
142
+ // arrives on its stdin, and what AGENT_YES_PID it inherits. That last one is
143
+ // the subtle bug this guards: the wrapper's own AGENT_YES_PID is the PARENT's
144
+ // wrapper pid, so without an override the parent would receive a report
145
+ // apparently sent by itself.
146
+ let dir: string;
147
+ let out: string;
148
+ let argv1: string | undefined;
149
+
150
+ beforeEach(() => {
151
+ dir = mkdtempSync(path.join(tmpdir(), "ay-ping-"));
152
+ out = path.join(dir, "captured.json");
153
+ const stub = path.join(dir, "stub.mjs");
154
+ writeFileSync(
155
+ stub,
156
+ `import { writeFileSync } from "node:fs";
157
+ let body = "";
158
+ process.stdin.setEncoding("utf8");
159
+ process.stdin.on("data", (c) => (body += c));
160
+ process.stdin.on("end", () => {
161
+ writeFileSync(${JSON.stringify(out)}, JSON.stringify({
162
+ args: process.argv.slice(2),
163
+ body,
164
+ ayPid: process.env.AGENT_YES_PID,
165
+ }));
166
+ process.exit(0);
167
+ });`,
168
+ );
169
+ argv1 = process.argv[1];
170
+ process.argv[1] = stub;
171
+ });
172
+
173
+ afterEach(() => {
174
+ if (argv1 !== undefined) process.argv[1] = argv1;
175
+ rmSync(dir, { recursive: true, force: true });
176
+ });
177
+
178
+ it("hands the report to `ay send <target> - --force --no-wait` on stdin", async () => {
179
+ const body = buildPingBody(d({ reason: "stuck", question: "Approve?" }), self, "tail line");
180
+ await expect(
181
+ deliverPing({
182
+ target: "agt_parent",
183
+ body,
184
+ selfWrapperPid: 4242,
185
+ timeoutMs: 20_000,
186
+ }),
187
+ ).resolves.toBe(true);
188
+
189
+ const captured = JSON.parse(readFileSync(out, "utf8"));
190
+ expect(captured.args).toEqual(["send", "agt_parent", "-", "--force", "--no-wait"]);
191
+ expect(captured.body).toBe(body);
192
+ // Attributed to the CHILD's wrapper, not the inherited parent's.
193
+ expect(captured.ayPid).toBe("4242");
194
+ });
195
+ });
@@ -0,0 +1,148 @@
1
+ /**
2
+ * Delivery side of the sub-agent→parent ping: turn a `PingDecision` into a
3
+ * message and inject it into the parent's stdin.
4
+ *
5
+ * We deliberately deliver through the real `ay send` subprocess rather than
6
+ * writing the parent's FIFO directly. `ay send` is where the `<ay-msg nonce
7
+ * from …>` attribution wrapper, the paste/submit handling and the durable
8
+ * message log all live; re-implementing any of that here would drift. The cost
9
+ * is one short-lived process per ping, which happens at most a handful of times
10
+ * per agent lifetime.
11
+ *
12
+ * Two flags are load-bearing:
13
+ * --force the recency guard ("you must have tailed this target lately")
14
+ * exists to stop an agent from firing at a target it never looked
15
+ * at. A child reporting to its own parent is the one relationship
16
+ * where that guard is pure false-positive.
17
+ * --no-wait the wrapper must never block its own PTY loop waiting for the
18
+ * parent's screen to settle.
19
+ */
20
+
21
+ import { spawn } from "node:child_process";
22
+ import type { PingDecision } from "./parentPing.ts";
23
+ import { shortenHome } from "./initMsg.ts";
24
+ import { logger } from "./logger.ts";
25
+
26
+ export interface PingSelf {
27
+ cli: string;
28
+ /** The child AGENT's pid — what the parent will use in `ay tail <pid>`. */
29
+ pid: number;
30
+ cwd: string;
31
+ /** The task this child was spawned with, for a one-line reminder. */
32
+ prompt?: string | null;
33
+ /** Exit code, when the ping reason is "exited". */
34
+ exitCode?: number | null;
35
+ }
36
+
37
+ const HEADLINE: Record<PingDecision["reason"], string> = {
38
+ finished: "looks FINISHED (idle at its prompt, no longer working)",
39
+ stuck: "is STUCK (parked waiting for input / not making progress)",
40
+ exited: "has EXITED",
41
+ };
42
+
43
+ /** Trim to `max` chars on a whitespace boundary where possible. */
44
+ function clip(s: string, max: number): string {
45
+ const t = s.trim();
46
+ if (t.length <= max) return t;
47
+ return t.slice(0, max).replace(/\s+\S*$/, "") + "…";
48
+ }
49
+
50
+ /**
51
+ * The report body. Pure so the wording is testable and stable.
52
+ *
53
+ * It is explicitly framed as coming from the WRAPPER, not the agent: the agent
54
+ * itself may have said nothing at all (that is usually why this fires), and a
55
+ * parent that mistakes an automatic ping for the child's considered answer would
56
+ * act on a summary the child never wrote. The tail is included because the whole
57
+ * point is that the parent is not tailing.
58
+ */
59
+ export function buildPingBody(
60
+ decision: PingDecision,
61
+ self: PingSelf,
62
+ tail?: string | null,
63
+ ): string {
64
+ const attempt = decision.attempt > 1 ? ` (reminder #${decision.attempt - 1} — no reply yet)` : "";
65
+ const lines = [
66
+ `<ay-report from ${self.cli} #${self.pid} @ ${shortenHome(self.cwd)}>`,
67
+ `Your sub-agent ${HEADLINE[decision.reason]}${attempt}.`,
68
+ `This is an automatic report from its agent-yes wrapper, not from the agent itself —`,
69
+ `treat the tail below as evidence, not as its answer.`,
70
+ ];
71
+ if (self.prompt) lines.push(`Task it was spawned with: ${clip(self.prompt, 200)}`);
72
+ if (decision.reason === "exited" && self.exitCode != null)
73
+ lines.push(`Exit code: ${self.exitCode}`);
74
+ if (decision.question) lines.push(`It is asking: ${clip(decision.question, 300)}`);
75
+ if (tail?.trim()) {
76
+ lines.push(`Last output:`, clip(tail, 1200));
77
+ }
78
+ lines.push(
79
+ decision.reason === "exited"
80
+ ? `Next: ay result get ${self.pid} | ay cat ${self.pid}`
81
+ : `Next: ay tail ${self.pid} | ay send ${self.pid} "..." | ay exit ${self.pid}`,
82
+ `</ay-report>`,
83
+ );
84
+ return lines.join("\n");
85
+ }
86
+
87
+ /**
88
+ * Argv for the `ay send` that carries `body` to `target`. Re-invokes THIS
89
+ * entrypoint (argv[0] + argv[1]) rather than a bare `ay` on PATH, so a wrapper
90
+ * running from a checkout, a global link, or `npx` all ping through the exact
91
+ * same build they are part of.
92
+ */
93
+ export function buildSendArgv(target: string, argv1?: string): { cmd: string; args: string[] } {
94
+ const send = ["send", target, "-", "--force", "--no-wait"];
95
+ if (argv1) return { cmd: process.execPath, args: [argv1, ...send] };
96
+ return { cmd: "ay", args: send };
97
+ }
98
+
99
+ /**
100
+ * Fire one ping. Resolves when the send subprocess exits; never throws — a
101
+ * failed report must not take down the agent it is reporting about.
102
+ *
103
+ * `selfWrapperPid` is stamped as AGENT_YES_PID for the subprocess so `ay send`
104
+ * attributes the message to THIS child. Without it the subprocess would inherit
105
+ * the wrapper's own AGENT_YES_PID — which is the PARENT's wrapper pid — and the
106
+ * parent would receive a message apparently sent by itself.
107
+ */
108
+ export async function deliverPing(opts: {
109
+ target: string;
110
+ body: string;
111
+ selfWrapperPid: number;
112
+ timeoutMs?: number;
113
+ }): Promise<boolean> {
114
+ const { cmd, args } = buildSendArgv(opts.target, process.argv[1]);
115
+ return await new Promise<boolean>((resolve) => {
116
+ let done = false;
117
+ const finish = (ok: boolean) => {
118
+ if (done) return;
119
+ done = true;
120
+ resolve(ok);
121
+ };
122
+ try {
123
+ const child = spawn(cmd, args, {
124
+ stdio: ["pipe", "ignore", "ignore"],
125
+ env: { ...process.env, AGENT_YES_PID: String(opts.selfWrapperPid) },
126
+ });
127
+ const timer = setTimeout(() => {
128
+ child.kill("SIGKILL");
129
+ finish(false);
130
+ }, opts.timeoutMs ?? 30_000);
131
+ timer.unref?.();
132
+ child.on("error", (err) => {
133
+ clearTimeout(timer);
134
+ logger.debug(`[parent-ping] send failed: ${err.message}`);
135
+ finish(false);
136
+ });
137
+ child.on("exit", (code) => {
138
+ clearTimeout(timer);
139
+ finish(code === 0);
140
+ });
141
+ child.stdin?.on("error", () => {});
142
+ child.stdin?.end(opts.body);
143
+ } catch (err) {
144
+ logger.debug(`[parent-ping] spawn failed: ${(err as Error).message}`);
145
+ finish(false);
146
+ }
147
+ });
148
+ }
@@ -0,0 +1,61 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { isParentWatching, parseReportMode, shouldDeliverReport } from "./parentWatching.ts";
3
+
4
+ describe("parseReportMode", () => {
5
+ it("defaults to auto for unset or unrecognised values", () => {
6
+ expect(parseReportMode(undefined)).toBe("auto");
7
+ expect(parseReportMode("")).toBe("auto");
8
+ expect(parseReportMode("yes")).toBe("auto");
9
+ expect(parseReportMode("auto")).toBe("auto");
10
+ });
11
+
12
+ it("accepts always/never, case- and space-insensitively", () => {
13
+ expect(parseReportMode("always")).toBe("always");
14
+ expect(parseReportMode(" ALWAYS ")).toBe("always");
15
+ expect(parseReportMode("Never")).toBe("never");
16
+ });
17
+ });
18
+
19
+ describe("shouldDeliverReport", () => {
20
+ const at = (over: Partial<Parameters<typeof shouldDeliverReport>[0]> = {}) =>
21
+ shouldDeliverReport({
22
+ mode: "auto",
23
+ parentIsWatching: false,
24
+ reason: "finished",
25
+ ...over,
26
+ });
27
+
28
+ it("auto sends when nobody is watching — the case this feature exists for", () => {
29
+ expect(at({ reason: "finished" })).toBe(true);
30
+ expect(at({ reason: "stuck" })).toBe(true);
31
+ });
32
+
33
+ it("auto stays quiet when the parent is already monitoring", () => {
34
+ expect(at({ parentIsWatching: true, reason: "finished" })).toBe(false);
35
+ expect(at({ parentIsWatching: true, reason: "stuck" })).toBe(false);
36
+ });
37
+
38
+ it("auto still sends `exited` to a watching parent — a poll can miss it entirely", () => {
39
+ expect(at({ parentIsWatching: true, reason: "exited" })).toBe(true);
40
+ });
41
+
42
+ it("never suppresses everything, including exited", () => {
43
+ for (const reason of ["finished", "stuck", "exited"] as const) {
44
+ expect(at({ mode: "never", reason })).toBe(false);
45
+ expect(at({ mode: "never", reason, parentIsWatching: true })).toBe(false);
46
+ }
47
+ });
48
+
49
+ it("always sends even to a watching parent", () => {
50
+ for (const reason of ["finished", "stuck", "exited"] as const) {
51
+ expect(at({ mode: "always", reason, parentIsWatching: true })).toBe(true);
52
+ }
53
+ });
54
+ });
55
+
56
+ describe("isParentWatching", () => {
57
+ it("fails open (false) when there is no watcher and no recent read", async () => {
58
+ // Nothing in this test process has watched or tailed these synthetic pids.
59
+ await expect(isParentWatching(2 ** 30, 2 ** 30 + 1, 2 ** 30 + 2)).resolves.toBe(false);
60
+ });
61
+ });
@@ -0,0 +1,89 @@
1
+ /**
2
+ * "Is my parent already monitoring me?" — the suppression signal for the
3
+ * automatic finished/stuck/exited push.
4
+ *
5
+ * A parent harness that HAS a monitor loop (Claude Code's Monitor tool, an
6
+ * `ay ls --watch` NDJSON stream, `ay notify watch`, or just a polling `ay tail`)
7
+ * already sees the child go idle / needs_input / stopped, with better fidelity
8
+ * and on its own schedule. Injecting an `<ay-report …>` into that parent's stdin
9
+ * on top of it is pure noise — worse than noise, since a message lands in the
10
+ * parent's composer and interrupts whatever it was doing.
11
+ *
12
+ * So the push is a FALLBACK, not a duplicate channel: it fires only when nobody
13
+ * is looking. Two independent signals count as "looking", and either is enough:
14
+ *
15
+ * 1. A live `ay notify watch` heartbeat for this parent (the explicit,
16
+ * purpose-built watcher — see notifyStore's watcher registry).
17
+ * 2. This parent tailed/read THIS child within the read window — the same
18
+ * recency ledger `ay send`'s misdelivery guard uses. This is what catches
19
+ * an ad-hoc monitor loop that just polls `ay tail`/`ay ls`, which is what a
20
+ * harness-driven parent actually does.
21
+ *
22
+ * Failing OPEN (assume not watching, so we do send) is deliberate: a duplicate
23
+ * report is a minor annoyance, a silently stranded parent is the bug this whole
24
+ * feature exists to fix.
25
+ */
26
+
27
+ import { logger } from "./logger.ts";
28
+
29
+ /** How the wrapper's automatic report behaves. `AGENT_YES_REPORT_PARENT`. */
30
+ export type ReportMode = "auto" | "always" | "never";
31
+
32
+ /**
33
+ * Parse the mode from the env value. Anything unrecognised (including unset)
34
+ * is `auto` — the setting is an escape hatch, not something to fail a run over.
35
+ */
36
+ export function parseReportMode(raw: string | undefined): ReportMode {
37
+ const v = raw?.trim().toLowerCase();
38
+ return v === "always" || v === "never" ? v : "auto";
39
+ }
40
+
41
+ /**
42
+ * Decide whether to actually deliver a report. Pure, so the policy is testable
43
+ * without touching the fs.
44
+ *
45
+ * `always` and `never` are absolute. `auto` sends only when the parent shows no
46
+ * sign of monitoring — EXCEPT that `exited` always goes out: it is terminal, it
47
+ * is the one edge a poll can miss entirely (the child's record can be reaped
48
+ * between two polls), and it can never repeat, so it cannot become spam.
49
+ */
50
+ export function shouldDeliverReport(opts: {
51
+ mode: ReportMode;
52
+ parentIsWatching: boolean;
53
+ reason: "finished" | "stuck" | "exited";
54
+ }): boolean {
55
+ if (opts.mode === "never") return false;
56
+ if (opts.mode === "always") return true;
57
+ if (opts.reason === "exited") return true;
58
+ return !opts.parentIsWatching;
59
+ }
60
+
61
+ /**
62
+ * Whether `parentWrapperPid` currently appears to be monitoring the agent
63
+ * `selfAgentPid`. Best-effort and fail-open (returns false on any error).
64
+ */
65
+ export async function isParentWatching(
66
+ parentWrapperPid: number,
67
+ parentAgentPid: number,
68
+ selfAgentPid: number,
69
+ now = Date.now(),
70
+ ): Promise<boolean> {
71
+ try {
72
+ const { liveWatchers } = await import("./notifyStore.ts");
73
+ const watchers = await liveWatchers(now);
74
+ if (watchers.has(parentWrapperPid)) return true;
75
+ } catch (error) {
76
+ logger.debug("[parent-ping] watcher lookup failed:", error);
77
+ }
78
+ try {
79
+ // Imported lazily and only when a report is about to fire (a handful of
80
+ // times per agent lifetime), so subcommands.ts never lands on the wrapper's
81
+ // startup path.
82
+ const { lastReadAt, READ_WINDOW_MS } = await import("./subcommands.ts");
83
+ const last = await lastReadAt(`agent:${parentAgentPid}`, selfAgentPid);
84
+ if (last !== null && now - last <= READ_WINDOW_MS) return true;
85
+ } catch (error) {
86
+ logger.debug("[parent-ping] read-recency lookup failed:", error);
87
+ }
88
+ return false;
89
+ }
package/ts/subcommands.ts CHANGED
@@ -126,7 +126,7 @@ async function compactNotes(): Promise<void> {
126
126
  // wrong agent (e.g. you tail "babaiban" but a send resolves to "qq-cli").
127
127
  // ---------------------------------------------------------------------------
128
128
 
129
- const READ_WINDOW_MS = 60_000; // "read recently" = within the last minute
129
+ export const READ_WINDOW_MS = 60_000; // "read recently" = within the last minute
130
130
 
131
131
  // Max time writeToIpc will keep retrying a backed-up FIFO before erroring. A live
132
132
  // agent drains its stdin in milliseconds; only a wedged reader hits this.
@@ -185,7 +185,7 @@ async function recordRead(by: string, target: number): Promise<void> {
185
185
  }
186
186
  }
187
187
 
188
- async function lastReadAt(by: string, target: number): Promise<number | null> {
188
+ export async function lastReadAt(by: string, target: number): Promise<number | null> {
189
189
  const map = await readReads();
190
190
  return map.get(`${by}${READS_KEY_SEP}${target}`) ?? null;
191
191
  }
@@ -775,11 +775,20 @@ async function buildAgentContextSection(self: GlobalPidRecord): Promise<string>
775
775
  : parent
776
776
  ? `Spawned by agent pid ${parent.pid} (${parent.cli}) in ${shortenPath(parent.cwd)}.`
777
777
  : `Nested under a parent (wrapper pid ${self.parent_pid}) whose record isn't in the local registry.`;
778
+ // The reporting duty is stated in the `<ay-init-msg>` block wrapping this
779
+ // agent's initial prompt, but that block scrolls out of a long session (or is
780
+ // compacted away) long before the agent finishes. `ay help` is where an agent
781
+ // goes when it has lost the thread, so restate the obligation here.
782
+ const dutyLine = parent
783
+ ? ` You owe it a report: \`ay send ${parent.agent_id || parent.pid} "..."\` when you finish, and\n` +
784
+ ` when you are blocked. It is not watching your terminal.\n`
785
+ : ``;
778
786
 
779
787
  return (
780
788
  `You are running inside an agent:\n` +
781
789
  ` ${whoAmI}\n` +
782
790
  ` ${parentLine}\n` +
791
+ dutyLine +
783
792
  `\n` +
784
793
  `As an agent, you can:\n` +
785
794
  ` Spawn a sub-agent:\n` +
@@ -28,7 +28,33 @@ function setPlatform(value: string) {
28
28
 
29
29
  // Stub the Bun global's spawn so launchTray/stopTray never spawn real processes.
30
30
  const spawnMock = vi.fn();
31
- let originalBun: unknown;
31
+ let originalSpawn: unknown;
32
+ let hadBun: boolean;
33
+
34
+ type BunLike = { spawn?: unknown };
35
+
36
+ // Under the BUN runtime `globalThis.Bun` is both non-writable AND
37
+ // NON-CONFIGURABLE, so it can be neither assigned (`globalThis.Bun = …` throws
38
+ // "Attempted to assign to readonly property" in an ESM module, which is always
39
+ // strict) nor redefined via Object.defineProperty. Its `spawn` property IS
40
+ // writable, though — so we stub the method in place instead of swapping the
41
+ // whole global. Under node there is no Bun global at all, so we define a minimal
42
+ // stand-in and remove it again on restore.
43
+ function installBunSpawn(fn: unknown): void {
44
+ const g = globalThis as { Bun?: BunLike };
45
+ if (!g.Bun) {
46
+ Object.defineProperty(globalThis, "Bun", { value: {}, configurable: true, writable: true });
47
+ }
48
+ (globalThis as { Bun: BunLike }).Bun.spawn = fn;
49
+ }
50
+
51
+ function restoreBunSpawn(spawn: unknown, present: boolean): void {
52
+ if (!present) {
53
+ delete (globalThis as { Bun?: BunLike }).Bun;
54
+ return;
55
+ }
56
+ (globalThis as { Bun: BunLike }).Bun.spawn = spawn;
57
+ }
32
58
  function goodChild() {
33
59
  return { unref: vi.fn(), exited: Promise.resolve(0) };
34
60
  }
@@ -49,14 +75,15 @@ describe("trayApp", () => {
49
75
  mockRustBinary.findTrayLauncher.mockReturnValue(undefined);
50
76
  mockHome.agentYesHome.mockReturnValue("/tmp/ay-home");
51
77
  spawnMock.mockReturnValue(goodChild());
52
- originalBun = (globalThis as { Bun?: unknown }).Bun;
53
- (globalThis as { Bun?: unknown }).Bun = { spawn: spawnMock };
78
+ hadBun = "Bun" in globalThis;
79
+ originalSpawn = (globalThis as { Bun?: BunLike }).Bun?.spawn;
80
+ installBunSpawn(spawnMock);
54
81
  stdout = vi.spyOn(process.stdout, "write").mockReturnValue(true);
55
82
  stderr = vi.spyOn(process.stderr, "write").mockReturnValue(true);
56
83
  });
57
84
 
58
85
  afterEach(() => {
59
- (globalThis as { Bun?: unknown }).Bun = originalBun;
86
+ restoreBunSpawn(originalSpawn, hadBun);
60
87
  setPlatform(originalPlatform);
61
88
  stdout.mockRestore();
62
89
  stderr.mockRestore();
package/ts/ws.ts CHANGED
@@ -139,22 +139,27 @@ function isCheckoutRoot(dir: string): boolean {
139
139
  }
140
140
 
141
141
  // List real subdirectories (no symlinks, no dotdirs), tolerating vanished dirs.
142
+ //
143
+ // The ITERATION is inside the try, not just the open. `opendir` is lazy on some
144
+ // runtimes (notably Bun): the ENOENT for a missing directory surfaces from the
145
+ // first `for await` step, not from the `await opendir(...)`, so a try that wraps
146
+ // only the open lets it escape — walkWorkspaces would throw on a missing root
147
+ // instead of returning [].
142
148
  async function subdirs(dir: string): Promise<string[]> {
143
149
  const out: string[] = [];
144
- let d: Awaited<ReturnType<typeof opendir>>;
145
150
  try {
146
- d = await opendir(dir);
147
- } catch {
148
- return out;
149
- }
150
- for await (const ent of d) {
151
- if (ent.name.startsWith(".")) continue;
152
- if (ent.isDirectory()) {
153
- out.push(ent.name);
154
- } else if (ent.isSymbolicLink()) {
155
- // never follow symlinks — a link cycle under tree/ must not loop the walk
156
- continue;
151
+ for await (const ent of await opendir(dir)) {
152
+ if (ent.name.startsWith(".")) continue;
153
+ if (ent.isDirectory()) {
154
+ out.push(ent.name);
155
+ } else if (ent.isSymbolicLink()) {
156
+ // never follow symlinks — a link cycle under tree/ must not loop the walk
157
+ continue;
158
+ }
157
159
  }
160
+ } catch {
161
+ // Vanished/unreadable dir — a partial listing is still the right answer.
162
+ return out.sort();
158
163
  }
159
164
  return out.sort();
160
165
  }
@@ -1,10 +0,0 @@
1
- import "./logger-CDIsZ-Pp.js";
2
- import "./globalPidIndex-Dq4KY2IV.js";
3
- import "./messageLog-BYFwioFY.js";
4
- import "./e2e-CUMZC53I.js";
5
- import "./configShared-DtHMBCl7.js";
6
- import { A as readLogForRender, B as resolveResumeArgs, C as isUnknownManagerToken, D as matchKeyword, E as listRecords, F as renderLogTailLines, G as stopTipForCli, H as runSubcommand, I as renderRawLog, J as writeKeysPaced, K as submitAndConfirm, L as renderRawLogLines, M as readPtysize, N as recentMessageEdges, O as menuSelectKeys, P as recentReadEdges, R as resolveOne, S as isSubcommand, T as lastStdinAt, U as snapshotStatus, V as restartHintLines, W as stdinActivityPath, Y as writeToIpc, _ as isAgentStuck, a as backoffWhileTyping, b as isPidAlive, c as controlCodeFromName, d as deriveLiveStatus, f as extractBadges, g as finalizedLines, h as extractTaskCounts, i as TYPING_WINDOW_MS, j as readNotes, k as readAgentPtysize, l as cursorAbs, m as extractNeedsInput, n as MAX_RENDER_BYTES, o as cmdDeepseek, p as extractMenu, q as waitForLogQuiet, r as READ_PAGE_DEFAULT, s as cmdHelp, t as GRACEFUL_EXIT_COMMANDS, u as deriveLiveState, v as isBareManagerInvocation, w as isUserTyping, x as isSlashCommand, y as isExitRequest, z as resolveReadWindow } from "./subcommands-CQPnT5qu.js";
7
- import "./webrtcLink-CS4D-3G1.js";
8
- import "./remotes-iIvqiFWK.js";
9
-
10
- export { cmdHelp, isBareManagerInvocation, isSubcommand, isUnknownManagerToken, runSubcommand };
@@ -1,5 +0,0 @@
1
- import "./versionChecker-CMOLbEEi.js";
2
- import "./rustBinary-DSVRhDzO.js";
3
- import { a as trayHiddenMarker, i as launchTray, n as hasDesktop, r as isTrayHidden, t as cmdTray } from "./trayApp-UglHbk_m.js";
4
-
5
- export { cmdTray };