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.
- package/dist/{SUPPORTED_CLIS-C3tcRetU.js → SUPPORTED_CLIS-AFV5pjSx.js} +2 -2
- package/dist/{SUPPORTED_CLIS-CY3SaV0D.js → SUPPORTED_CLIS-DRVuSo48.js} +3 -3
- package/dist/{agentShare-g6N1A6A_.js → agentShare-qdFSp9Uq.js} +6 -5
- package/dist/{callback-L4k-v2GG.js → callback-Da7MRLh6.js} +6 -5
- package/dist/{callback-z-w0ND5z.js → callback-DfNFdZin.js} +5 -5
- package/dist/{callbackCore-Cv4EOGjE.js → callbackCore-DcptaZwH.js} +1 -1
- package/dist/{callbackCore-Cg3Ye0ZS.js → callbackCore-K99x3C75.js} +1 -1
- package/dist/{channels-BwpN_8mu.js → channels-DQaZbare.js} +2 -2
- package/dist/cli.js +11 -11
- package/dist/{cwdConflictWarn-DZTxTGXq.js → cwdConflictWarn-CtQhl-vF.js} +1 -1
- package/dist/{cwdPassthroughHint-DDVkhBiD.js → cwdPassthroughHint-apaX_O7p.js} +1 -1
- package/dist/{expose-BG17GV7D.js → expose-BXn6My6A.js} +1 -1
- package/dist/{forkNested-VroZS-nk.js → forkNested-CDfWPiHw.js} +12 -2
- package/dist/{hist-CoXBBjJt.js → hist-BpocAe1N.js} +1 -1
- package/dist/index.js +2 -2
- package/dist/initMsg-BjRseolL.js +3 -0
- package/dist/initMsg-C-w4A_eF.js +85 -0
- package/dist/needsInput-103zhZ90.js +79 -0
- package/dist/{nodeRuntime-CT0w6zsB.js → nodeRuntime-CRoEMWkp.js} +1 -1
- package/dist/{notifyDaemon-BL1tmxUK.js → notifyDaemon-BFzqcdN1.js} +32 -5
- package/dist/notifyStore-CEDiVyis.js +4 -0
- package/dist/notifyStore-Cbw2ZJ0X.js +559 -0
- package/dist/{openBrowser-Cw8hg7ok.js → openBrowser-DhQ9pJRi.js} +1 -1
- package/dist/parentLink-B798ASCW.js +5 -0
- package/dist/parentLink-C0cIRjfL.js +53 -0
- package/dist/parentPingLoop-BIKxzbPH.js +469 -0
- package/dist/{remotes-DWVw-Q7V.js → remotes-Dg_JscKk.js} +2 -2
- package/dist/{remotes-iIvqiFWK.js → remotes-iMhdZytp.js} +3 -3
- package/dist/{rustBinary-DSVRhDzO.js → rustBinary-JGNROWfY.js} +2 -2
- package/dist/{schedule-i4o5SJ_h.js → schedule-DbLpjgl5.js} +6 -6
- package/dist/{serve-CpGJD2Kt.js → serve-ucVXOci1.js} +31 -30
- package/dist/{setup-B-iQKsQW.js → setup-De7uYRZb.js} +4 -4
- package/dist/{share-DOnynkf4.js → share-Db_zb6gH.js} +1 -1
- package/dist/{share-B-e_9WBx.js → share-NUhi-Wdd.js} +1 -1
- package/dist/{spawnGate-S6EoFdzX.js → spawnGate-BvaZeHE7.js} +1 -1
- package/dist/{spawnGate-BmsTv340.js → spawnGate-CEXVmTtv.js} +2 -2
- package/dist/subcommands-C04exkIM.js +11 -0
- package/dist/{subcommands-CQPnT5qu.js → subcommands-DtF31RoW.js} +41 -656
- package/dist/{systemPathLink-CjLmiPbB.js → systemPathLink-7TFPwJna.js} +1 -1
- package/dist/{terminal-CybRgMZu.js → terminal-Dm32SXU-.js} +2 -2
- package/dist/{todoCli-DZmOmcvd.js → todoCli-c_R7D7Y-.js} +1 -1
- package/dist/{tray-Cjm3a8JH.js → tray-DZnGjJBK.js} +1 -1
- package/dist/trayApp-CZbwLK9t.js +5 -0
- package/dist/{trayApp-UglHbk_m.js → trayApp-CfkPG0wy.js} +2 -2
- package/dist/{ts-nuUnrVcf.js → ts-CGtoPER5.js} +43 -10
- package/dist/{versionChecker-CMOLbEEi.js → versionChecker-CJgW9DHY.js} +2 -2
- package/dist/{webrtcLink-CS4D-3G1.js → webrtcLink-DyCj380i.js} +1 -1
- package/dist/{webrtcRemote-zQybN9IR.js → webrtcRemote-BDg0jjTs.js} +2 -2
- package/dist/{widget-y5Bpvwsa.js → widget-DXtaX3QR.js} +3 -3
- package/dist/{workspaceConfig-BDqp7fYl.js → workspaceConfig-HiWQzq6A.js} +1 -1
- package/dist/{ws-CH8GzcQn.js → ws-CqlS5JhN.js} +5 -4
- package/dist/{ws-BrLV-AlH.js → ws-aKRZpI9b.js} +9 -11
- package/package.json +1 -1
- package/ts/forkNested.spec.ts +22 -0
- package/ts/forkNested.ts +19 -0
- package/ts/index.ts +77 -12
- package/ts/initMsg.spec.ts +114 -0
- package/ts/initMsg.ts +107 -0
- package/ts/ownerHeartbeat.ts +40 -1
- package/ts/parentLink.spec.ts +64 -0
- package/ts/parentLink.ts +58 -0
- package/ts/parentPing.spec.ts +176 -0
- package/ts/parentPing.ts +195 -0
- package/ts/parentPingLoop.delivery.spec.ts +236 -0
- package/ts/parentPingLoop.spec.ts +79 -0
- package/ts/parentPingLoop.ts +196 -0
- package/ts/parentPingSend.spec.ts +195 -0
- package/ts/parentPingSend.ts +148 -0
- package/ts/parentWatching.spec.ts +61 -0
- package/ts/parentWatching.ts +89 -0
- package/ts/subcommands.ts +11 -2
- package/ts/trayApp.spec.ts +31 -4
- package/ts/ws.ts +17 -12
- package/dist/subcommands-Cgt2-kjg.js +0 -10
- 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` +
|
package/ts/trayApp.spec.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
53
|
-
(globalThis as { Bun?:
|
|
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
|
-
(
|
|
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
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
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 };
|
package/dist/trayApp-CmwwBg8v.js
DELETED