claude-yes 1.165.0 → 1.167.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-H7IIw10E.js → SUPPORTED_CLIS-DFBATC85.js} +2 -2
- package/dist/SUPPORTED_CLIS-DLFqRE8W.js +8 -0
- package/dist/cli.js +15 -6
- package/dist/index.js +2 -2
- package/dist/{remotes-DkNuq417.js → remotes-ClT1bq16.js} +1 -1
- package/dist/{remotes-DBCvpp3B.js → remotes-T6nf0t3K.js} +2 -2
- package/dist/{schedule-BobQwIBS.js → schedule-C2RMA3qm.js} +5 -5
- package/dist/{serve-DzOWMI_8.js → serve-mLqsRwfR.js} +12 -9
- package/dist/{setup-tVfp7djj.js → setup-BtqKZx3q.js} +3 -3
- package/dist/{share-Bq_tDXQU.js → share-BjqQBWM-.js} +1 -1
- package/dist/spawnGate-B_VDMXYL.js +107 -0
- package/dist/spawnGate-UH73I2le.js +5 -0
- package/dist/{subcommands-CIFWi9vq.js → subcommands-B-WoBVhk.js} +2 -2
- package/dist/{subcommands-DtwxPMYe.js → subcommands-DWeo7ZLc.js} +29 -9
- package/dist/{tray-BMzpUSfa.js → tray-CZarCA2Q.js} +1 -1
- package/dist/{ts-jDEwTsVt.js → ts-s3wYccKf.js} +2 -2
- package/dist/{versionChecker-C0UJyFcN.js → versionChecker-BvR6tV3u.js} +2 -2
- package/dist/{webrtcRemote-Ccdzmuc-.js → webrtcRemote-GAgF5K45.js} +1 -1
- package/dist/{workspaceConfig-B0Q9-q2B.js → workspaceConfig-D3OH7and.js} +41 -2
- package/package.json +1 -1
- package/ts/cli.ts +22 -1
- package/ts/serve.ts +8 -0
- package/ts/spawnGate.spec.ts +155 -0
- package/ts/spawnGate.ts +123 -0
- package/ts/subcommands.spec.ts +127 -18
- package/ts/subcommands.ts +65 -2
- package/ts/workspaceConfig.spec.ts +85 -0
- package/ts/workspaceConfig.ts +64 -0
- package/dist/SUPPORTED_CLIS-dBgLp3mi.js +0 -8
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
|
2
|
+
import { mkdtempSync, rmSync, writeFileSync } from "fs";
|
|
3
|
+
import { tmpdir } from "os";
|
|
4
|
+
import path from "path";
|
|
5
|
+
import {
|
|
6
|
+
memAvailableMb,
|
|
7
|
+
spawnGateEnabled,
|
|
8
|
+
spawnRejectionReason,
|
|
9
|
+
waitForSpawnCapacity,
|
|
10
|
+
} from "./spawnGate.ts";
|
|
11
|
+
|
|
12
|
+
// Build a minimal live pid record. Using the test runner's own pid guarantees
|
|
13
|
+
// `isProcessAlive` (process.kill(pid, 0)) succeeds, so the record counts as live.
|
|
14
|
+
const liveRecord = (pid: number) =>
|
|
15
|
+
JSON.stringify({
|
|
16
|
+
pid,
|
|
17
|
+
cli: "claude",
|
|
18
|
+
prompt: null,
|
|
19
|
+
cwd: "/tmp",
|
|
20
|
+
log_file: null,
|
|
21
|
+
status: "active",
|
|
22
|
+
exit_code: null,
|
|
23
|
+
exit_reason: null,
|
|
24
|
+
started_at: 0,
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
describe("spawnGate", () => {
|
|
28
|
+
let original: string | undefined;
|
|
29
|
+
let tmp: string;
|
|
30
|
+
const writePids = (lines: string[]) =>
|
|
31
|
+
writeFileSync(path.join(tmp, "pids.jsonl"), lines.join("\n") + "\n");
|
|
32
|
+
|
|
33
|
+
beforeEach(() => {
|
|
34
|
+
original = process.env.AGENT_YES_HOME;
|
|
35
|
+
tmp = mkdtempSync(path.join(tmpdir(), "ay-gate-"));
|
|
36
|
+
process.env.AGENT_YES_HOME = tmp;
|
|
37
|
+
});
|
|
38
|
+
afterEach(() => {
|
|
39
|
+
if (original === undefined) delete process.env.AGENT_YES_HOME;
|
|
40
|
+
else process.env.AGENT_YES_HOME = original;
|
|
41
|
+
delete process.env.AGENT_YES_MAX_AGENTS;
|
|
42
|
+
delete process.env.AGENT_YES_MIN_FREE_MB;
|
|
43
|
+
delete process.env.AGENT_YES_SPAWN_WAIT_MS;
|
|
44
|
+
rmSync(tmp, { recursive: true, force: true });
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
describe("memAvailableMb", () => {
|
|
48
|
+
it("returns a positive number on this host", async () => {
|
|
49
|
+
const mb = await memAvailableMb();
|
|
50
|
+
expect(mb).not.toBeNull();
|
|
51
|
+
expect(mb!).toBeGreaterThan(0);
|
|
52
|
+
});
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
describe("spawnGateEnabled", () => {
|
|
56
|
+
it("is false when nothing is configured", () => {
|
|
57
|
+
expect(spawnGateEnabled()).toBe(false);
|
|
58
|
+
});
|
|
59
|
+
it("is true when a cap or floor is set", () => {
|
|
60
|
+
process.env.AGENT_YES_MAX_AGENTS = "5";
|
|
61
|
+
expect(spawnGateEnabled()).toBe(true);
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
describe("spawnRejectionReason", () => {
|
|
66
|
+
it("returns null when no limits are configured", async () => {
|
|
67
|
+
expect(await spawnRejectionReason()).toBeNull();
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("rejects when live agents reach the cap", async () => {
|
|
71
|
+
writePids([liveRecord(process.pid)]);
|
|
72
|
+
process.env.AGENT_YES_MAX_AGENTS = "1";
|
|
73
|
+
expect(await spawnRejectionReason()).toMatch(/too many agents running \(1\/1\)/);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("admits when live agents are under the cap", async () => {
|
|
77
|
+
writePids([liveRecord(process.pid)]);
|
|
78
|
+
process.env.AGENT_YES_MAX_AGENTS = "5";
|
|
79
|
+
expect(await spawnRejectionReason()).toBeNull();
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("ignores dead pids when counting live agents", async () => {
|
|
83
|
+
// pid 2^31-1 is effectively never a live process → not counted.
|
|
84
|
+
writePids([liveRecord(2147483646)]);
|
|
85
|
+
process.env.AGENT_YES_MAX_AGENTS = "1";
|
|
86
|
+
expect(await spawnRejectionReason()).toBeNull();
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it("rejects when free memory is below the floor", async () => {
|
|
90
|
+
process.env.AGENT_YES_MIN_FREE_MB = String(Number.MAX_SAFE_INTEGER);
|
|
91
|
+
expect(await spawnRejectionReason()).toMatch(/host is low on memory/);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it("admits when free memory is above the floor", async () => {
|
|
95
|
+
process.env.AGENT_YES_MIN_FREE_MB = "1";
|
|
96
|
+
expect(await spawnRejectionReason()).toBeNull();
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
describe("waitForSpawnCapacity", () => {
|
|
101
|
+
it("returns immediately (no sleep) when the gate is disabled", async () => {
|
|
102
|
+
const sleeps: number[] = [];
|
|
103
|
+
await waitForSpawnCapacity({ sleep: async (ms) => void sleeps.push(ms) });
|
|
104
|
+
expect(sleeps).toEqual([]);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it("backs off with φ growth and fails open at the deadline", async () => {
|
|
108
|
+
writePids([liveRecord(process.pid)]);
|
|
109
|
+
process.env.AGENT_YES_MAX_AGENTS = "1"; // always over → never admits
|
|
110
|
+
|
|
111
|
+
let t = 0;
|
|
112
|
+
const sleeps: number[] = [];
|
|
113
|
+
let proceededAfter: number | null = null;
|
|
114
|
+
await waitForSpawnCapacity({
|
|
115
|
+
maxWaitMs: 5000,
|
|
116
|
+
now: () => t,
|
|
117
|
+
sleep: async (ms) => {
|
|
118
|
+
sleeps.push(ms);
|
|
119
|
+
t += ms;
|
|
120
|
+
},
|
|
121
|
+
onProceedAnyway: (_r, waited) => {
|
|
122
|
+
proceededAfter = waited;
|
|
123
|
+
},
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
// 1000, then ×φ (1618), then clamped to the remaining 2382ms before deadline.
|
|
127
|
+
expect(sleeps[0]).toBe(1000);
|
|
128
|
+
expect(sleeps[1]).toBe(1618);
|
|
129
|
+
expect(sleeps.reduce((a, b) => a + b, 0)).toBe(5000);
|
|
130
|
+
expect(proceededAfter).toBe(5000);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it("admits as soon as capacity frees, without proceeding-anyway", async () => {
|
|
134
|
+
writePids([liveRecord(process.pid)]);
|
|
135
|
+
process.env.AGENT_YES_MAX_AGENTS = "1";
|
|
136
|
+
|
|
137
|
+
const sleeps: number[] = [];
|
|
138
|
+
let proceeded = false;
|
|
139
|
+
await waitForSpawnCapacity({
|
|
140
|
+
maxWaitMs: 600_000,
|
|
141
|
+
sleep: async (ms) => {
|
|
142
|
+
sleeps.push(ms);
|
|
143
|
+
// free up capacity after two backoff cycles
|
|
144
|
+
if (sleeps.length >= 2) process.env.AGENT_YES_MAX_AGENTS = "10";
|
|
145
|
+
},
|
|
146
|
+
onProceedAnyway: () => {
|
|
147
|
+
proceeded = true;
|
|
148
|
+
},
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
expect(sleeps.length).toBe(2);
|
|
152
|
+
expect(proceeded).toBe(false);
|
|
153
|
+
});
|
|
154
|
+
});
|
|
155
|
+
});
|
package/ts/spawnGate.ts
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Spawn admission control — keep an unbounded fan-out of agents from exhausting
|
|
3
|
+
* host RAM and tripping the kernel OOM-killer (which, on a box with no cgroup
|
|
4
|
+
* memory limit, hangs the whole machine). Two opt-in limits:
|
|
5
|
+
* - {@link getMaxAgents} — max concurrently-live agents
|
|
6
|
+
* - {@link getMinFreeMb} — minimum free memory before admitting a spawn
|
|
7
|
+
*
|
|
8
|
+
* Two entry points share one instantaneous check ({@link spawnRejectionReason}):
|
|
9
|
+
* - the `ay serve` daemon HARD-REJECTS (`/api/spawn` → 429) so the caller retries;
|
|
10
|
+
* - the CLI path BLOCKS-AND-WAITS ({@link waitForSpawnCapacity}) with φ-backoff,
|
|
11
|
+
* failing open after a timeout so recursive `ay <cli>` spawns get spaced out
|
|
12
|
+
* (the actual cause of the burst storms) without ever deadlocking a workflow.
|
|
13
|
+
*/
|
|
14
|
+
import { readFile } from "fs/promises";
|
|
15
|
+
import { readGlobalPids } from "./globalPidIndex.ts";
|
|
16
|
+
import { getMaxAgents, getMinFreeMb, getSpawnWaitMs } from "./workspaceConfig.ts";
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* System available memory in MB. Prefers Linux `/proc/meminfo` `MemAvailable`
|
|
20
|
+
* (the kernel's own estimate of what's reclaimable without swapping — far more
|
|
21
|
+
* accurate than free RAM), falling back to `os.freemem()` on other platforms or
|
|
22
|
+
* if the file can't be parsed. Returns null when nothing usable is available.
|
|
23
|
+
*/
|
|
24
|
+
export async function memAvailableMb(): Promise<number | null> {
|
|
25
|
+
try {
|
|
26
|
+
const txt = await readFile("/proc/meminfo", "utf-8");
|
|
27
|
+
const m = /^MemAvailable:\s+(\d+)\s*kB/m.exec(txt);
|
|
28
|
+
if (m) return Math.floor(Number(m[1]) / 1024);
|
|
29
|
+
} catch {
|
|
30
|
+
/* not Linux / unreadable — fall through */
|
|
31
|
+
}
|
|
32
|
+
try {
|
|
33
|
+
const { freemem } = await import("os");
|
|
34
|
+
return Math.floor(freemem() / (1024 * 1024));
|
|
35
|
+
} catch {
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** True when at least one spawn limit is configured (else the gate is a no-op). */
|
|
41
|
+
export function spawnGateEnabled(): boolean {
|
|
42
|
+
return getMaxAgents() !== undefined || getMinFreeMb() !== undefined;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Instantaneous capacity check. Returns a human-readable reason string when a
|
|
47
|
+
* new spawn should be held back (cap reached / memory too low), or null when
|
|
48
|
+
* there's capacity. Both limits are opt-in — unset means no check.
|
|
49
|
+
*
|
|
50
|
+
* NOTE — the `maxAgents` count is BEST-EFFORT, not a hard barrier: the check is
|
|
51
|
+
* non-atomic (check here → register in `pids.jsonl` later, in another process),
|
|
52
|
+
* so a simultaneous burst can briefly overshoot the cap before the new wrappers
|
|
53
|
+
* appear in the live count. That's acceptable because (a) the CLI path's
|
|
54
|
+
* φ-backoff desynchronizes retries, spreading a burst out, and (b) `minFreeMb`
|
|
55
|
+
* is the HARD OOM guard — it's re-evaluated against live `/proc/meminfo` on every
|
|
56
|
+
* attempt, so once RAM actually drops, further spawns are held regardless of the
|
|
57
|
+
* count. Exact admission would need a cross-process reservation+lock (a TTL'd
|
|
58
|
+
* reservation file); deliberately deferred to avoid stale reservations wedging
|
|
59
|
+
* spawns, which would be worse than a transient overshoot.
|
|
60
|
+
*/
|
|
61
|
+
export async function spawnRejectionReason(): Promise<string | null> {
|
|
62
|
+
const maxAgents = getMaxAgents();
|
|
63
|
+
if (maxAgents !== undefined) {
|
|
64
|
+
// Live agents already running (this wrapper hasn't registered itself yet),
|
|
65
|
+
// so "live >= cap" admits exactly `maxAgents` concurrent agents.
|
|
66
|
+
const live = (await readGlobalPids({ liveOnly: true })).length;
|
|
67
|
+
if (live >= maxAgents)
|
|
68
|
+
// Phrased for the end user who clicked "Spawn" (the console shows this
|
|
69
|
+
// string verbatim), with a host-admin hint appended in parentheses.
|
|
70
|
+
return `too many agents running (${live}/${maxAgents}) — this agent wasn't started. Please wait for one to finish and try again. (host: raise "maxAgents" in ~/.agent-yes/config.json or AGENT_YES_MAX_AGENTS)`;
|
|
71
|
+
}
|
|
72
|
+
const minFreeMb = getMinFreeMb();
|
|
73
|
+
if (minFreeMb !== undefined) {
|
|
74
|
+
const avail = await memAvailableMb();
|
|
75
|
+
if (avail !== null && avail < minFreeMb)
|
|
76
|
+
return `the host is low on memory (${avail}MB free, needs ${minFreeMb}MB) — this agent wasn't started, to avoid crashing the machine. Please try again in a moment. (host: adjust "minFreeMb" in ~/.agent-yes/config.json or AGENT_YES_MIN_FREE_MB)`;
|
|
77
|
+
}
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const PHI = 1.618; // golden-ratio backoff base (snomiao global pref)
|
|
82
|
+
const BACKOFF_CAP_MS = 60_000;
|
|
83
|
+
const BACKOFF_BASE_MS = 1_000;
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Block until there's spawn capacity, polling with φ-backoff (1s × φⁿ, capped at
|
|
87
|
+
* 60s — there is no OS event for "memory freed", so polling is unavoidable here).
|
|
88
|
+
* Fails open after {@link getSpawnWaitMs} ms (default 10 min): on timeout it logs
|
|
89
|
+
* and proceeds, so a set of mutually-waiting recursive spawns can never deadlock
|
|
90
|
+
* permanently. A no-op (returns immediately) when no limit is configured, so it
|
|
91
|
+
* adds ~zero overhead to the spawn hot path for users who haven't opted in.
|
|
92
|
+
*
|
|
93
|
+
* `sleep`/`now` are injectable for tests. `onWait` fires once per backoff cycle.
|
|
94
|
+
*/
|
|
95
|
+
export async function waitForSpawnCapacity(opts?: {
|
|
96
|
+
maxWaitMs?: number;
|
|
97
|
+
onWait?: (reason: string, waitedMs: number) => void;
|
|
98
|
+
onProceedAnyway?: (reason: string, waitedMs: number) => void;
|
|
99
|
+
sleep?: (ms: number) => Promise<void>;
|
|
100
|
+
now?: () => number;
|
|
101
|
+
}): Promise<void> {
|
|
102
|
+
if (!spawnGateEnabled()) return; // fast path: nothing configured
|
|
103
|
+
const maxWaitMs = opts?.maxWaitMs ?? getSpawnWaitMs();
|
|
104
|
+
// Monotonic clock for the deadline — `Date.now()` can step backward (NTP /
|
|
105
|
+
// suspend) and stretch the fail-open wait far past `maxWaitMs`.
|
|
106
|
+
const now = opts?.now ?? (() => performance.now());
|
|
107
|
+
const sleep = opts?.sleep ?? ((ms: number) => new Promise<void>((r) => setTimeout(r, ms)));
|
|
108
|
+
const start = now();
|
|
109
|
+
let delay = BACKOFF_BASE_MS;
|
|
110
|
+
for (;;) {
|
|
111
|
+
const reason = await spawnRejectionReason();
|
|
112
|
+
if (!reason) return; // capacity available — admit
|
|
113
|
+
const waited = now() - start;
|
|
114
|
+
if (waited >= maxWaitMs) {
|
|
115
|
+
opts?.onProceedAnyway?.(reason, waited);
|
|
116
|
+
return; // fail open — never deadlock a workflow
|
|
117
|
+
}
|
|
118
|
+
opts?.onWait?.(reason, waited);
|
|
119
|
+
// Don't oversleep past the deadline.
|
|
120
|
+
await sleep(Math.min(delay, BACKOFF_CAP_MS, maxWaitMs - waited));
|
|
121
|
+
delay = Math.min(delay * PHI, BACKOFF_CAP_MS);
|
|
122
|
+
}
|
|
123
|
+
}
|
package/ts/subcommands.spec.ts
CHANGED
|
@@ -113,25 +113,134 @@ describe("subcommands.isSubcommand", () => {
|
|
|
113
113
|
});
|
|
114
114
|
|
|
115
115
|
describe("subcommands.cmdHelp", () => {
|
|
116
|
-
|
|
116
|
+
const capture = async (managerCommands?: boolean) => {
|
|
117
117
|
const { cmdHelp } = await loadModule();
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
expect(capture()).toContain("ay setup"); //
|
|
133
|
-
expect(capture(
|
|
134
|
-
expect(capture(false)).toContain("ay
|
|
118
|
+
let out = "";
|
|
119
|
+
const spy = vi.spyOn(process.stdout, "write").mockImplementation((s: unknown) => {
|
|
120
|
+
out += String(s);
|
|
121
|
+
return true;
|
|
122
|
+
});
|
|
123
|
+
try {
|
|
124
|
+
await cmdHelp(managerCommands);
|
|
125
|
+
} finally {
|
|
126
|
+
spy.mockRestore();
|
|
127
|
+
}
|
|
128
|
+
return out;
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
it("hides the manager-only `setup` line for cli-bound aliases", async () => {
|
|
132
|
+
expect(await capture(true)).toContain("ay setup"); // manager
|
|
133
|
+
expect(await capture()).toContain("ay setup"); // default = manager
|
|
134
|
+
expect(await capture(false)).not.toContain("ay setup"); // cli-bound alias (cy)
|
|
135
|
+
expect(await capture(false)).toContain("ay ls"); // universal commands still shown
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it("stays plain for a human shell (no AGENT_YES_PID)", async () => {
|
|
139
|
+
const out = await capture();
|
|
140
|
+
expect(out).not.toContain("You are running inside an agent");
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it("prints self + parent identity and sub-agent guidance when nested in an agent", async () => {
|
|
144
|
+
const { appendGlobalPid } = await import("./globalPidIndex.ts");
|
|
145
|
+
const parentWrapperPid = 555001;
|
|
146
|
+
const selfWrapperPid = 555002;
|
|
147
|
+
await appendGlobalPid({
|
|
148
|
+
pid: 900001,
|
|
149
|
+
cli: "codex",
|
|
150
|
+
prompt: "orchestrate the migration",
|
|
151
|
+
cwd: "/work/parent",
|
|
152
|
+
log_file: null,
|
|
153
|
+
fifo_file: null,
|
|
154
|
+
status: "active",
|
|
155
|
+
exit_code: null,
|
|
156
|
+
exit_reason: null,
|
|
157
|
+
started_at: Date.now(),
|
|
158
|
+
wrapper_pid: parentWrapperPid,
|
|
159
|
+
});
|
|
160
|
+
await appendGlobalPid({
|
|
161
|
+
pid: process.pid,
|
|
162
|
+
cli: "claude",
|
|
163
|
+
prompt: "fix the failing test",
|
|
164
|
+
cwd: "/work/parent/child",
|
|
165
|
+
log_file: null,
|
|
166
|
+
fifo_file: null,
|
|
167
|
+
status: "active",
|
|
168
|
+
exit_code: null,
|
|
169
|
+
exit_reason: null,
|
|
170
|
+
started_at: Date.now(),
|
|
171
|
+
wrapper_pid: selfWrapperPid,
|
|
172
|
+
parent_pid: parentWrapperPid,
|
|
173
|
+
});
|
|
174
|
+
const saved = process.env.AGENT_YES_PID;
|
|
175
|
+
process.env.AGENT_YES_PID = String(selfWrapperPid);
|
|
176
|
+
try {
|
|
177
|
+
const out = await capture();
|
|
178
|
+
expect(out).toContain("You are running inside an agent");
|
|
179
|
+
expect(out).toContain(`You are agent pid ${process.pid} (claude)`);
|
|
180
|
+
expect(out).toContain(`Spawned by agent pid 900001 (codex)`);
|
|
181
|
+
expect(out).toContain("Spawn a sub-agent");
|
|
182
|
+
expect(out).toContain(`ay ls --cwd /work/parent/child`);
|
|
183
|
+
expect(out).toContain(`ay ls --watch --cwd /work/parent/child`);
|
|
184
|
+
} finally {
|
|
185
|
+
if (saved === undefined) delete process.env.AGENT_YES_PID;
|
|
186
|
+
else process.env.AGENT_YES_PID = saved;
|
|
187
|
+
}
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it("reports a nested-but-unresolved parent distinctly from top-level", async () => {
|
|
191
|
+
const { appendGlobalPid } = await import("./globalPidIndex.ts");
|
|
192
|
+
const selfWrapperPid = 555004;
|
|
193
|
+
await appendGlobalPid({
|
|
194
|
+
pid: process.pid,
|
|
195
|
+
cli: "claude",
|
|
196
|
+
prompt: null,
|
|
197
|
+
cwd: process.cwd(),
|
|
198
|
+
log_file: null,
|
|
199
|
+
fifo_file: null,
|
|
200
|
+
status: "active",
|
|
201
|
+
exit_code: null,
|
|
202
|
+
exit_reason: null,
|
|
203
|
+
started_at: Date.now(),
|
|
204
|
+
wrapper_pid: selfWrapperPid,
|
|
205
|
+
parent_pid: 999999999, // no record ever registered for this wrapper pid
|
|
206
|
+
});
|
|
207
|
+
const saved = process.env.AGENT_YES_PID;
|
|
208
|
+
process.env.AGENT_YES_PID = String(selfWrapperPid);
|
|
209
|
+
try {
|
|
210
|
+
const out = await capture();
|
|
211
|
+
expect(out).not.toContain("Top-level agent");
|
|
212
|
+
expect(out).toContain("Nested under a parent (wrapper pid 999999999)");
|
|
213
|
+
} finally {
|
|
214
|
+
if (saved === undefined) delete process.env.AGENT_YES_PID;
|
|
215
|
+
else process.env.AGENT_YES_PID = saved;
|
|
216
|
+
}
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
it("reports top-level (no parent) when parent_pid is absent", async () => {
|
|
220
|
+
const { appendGlobalPid } = await import("./globalPidIndex.ts");
|
|
221
|
+
const selfWrapperPid = 555003;
|
|
222
|
+
await appendGlobalPid({
|
|
223
|
+
pid: process.pid,
|
|
224
|
+
cli: "claude",
|
|
225
|
+
prompt: null,
|
|
226
|
+
cwd: process.cwd(),
|
|
227
|
+
log_file: null,
|
|
228
|
+
fifo_file: null,
|
|
229
|
+
status: "active",
|
|
230
|
+
exit_code: null,
|
|
231
|
+
exit_reason: null,
|
|
232
|
+
started_at: Date.now(),
|
|
233
|
+
wrapper_pid: selfWrapperPid,
|
|
234
|
+
});
|
|
235
|
+
const saved = process.env.AGENT_YES_PID;
|
|
236
|
+
process.env.AGENT_YES_PID = String(selfWrapperPid);
|
|
237
|
+
try {
|
|
238
|
+
const out = await capture();
|
|
239
|
+
expect(out).toContain("Top-level agent");
|
|
240
|
+
} finally {
|
|
241
|
+
if (saved === undefined) delete process.env.AGENT_YES_PID;
|
|
242
|
+
else process.env.AGENT_YES_PID = saved;
|
|
243
|
+
}
|
|
135
244
|
});
|
|
136
245
|
});
|
|
137
246
|
|
package/ts/subcommands.ts
CHANGED
|
@@ -387,14 +387,77 @@ export async function runSubcommand(argv: string[]): Promise<number | null> {
|
|
|
387
387
|
// ay help
|
|
388
388
|
// ---------------------------------------------------------------------------
|
|
389
389
|
|
|
390
|
-
|
|
390
|
+
/**
|
|
391
|
+
* The banner shown by `ay help` / `ay -h` when this process is itself running
|
|
392
|
+
* inside an agent (`AGENT_YES_PID` set — see resolveSender). Answers the three
|
|
393
|
+
* things a nested agent actually needs: who am I, who spawned me, and how do I
|
|
394
|
+
* drive sub-agents of my own — so it doesn't have to rediscover the fan-out
|
|
395
|
+
* primitives (spawn / ay ls forest / ay ls --watch) from scratch every session.
|
|
396
|
+
*/
|
|
397
|
+
async function buildAgentContextSection(self: GlobalPidRecord): Promise<string> {
|
|
398
|
+
const hasParentPid = typeof self.parent_pid === "number" && self.parent_pid > 0;
|
|
399
|
+
const parent = hasParentPid
|
|
400
|
+
? (
|
|
401
|
+
await listRecords(undefined, {
|
|
402
|
+
all: true,
|
|
403
|
+
active: false,
|
|
404
|
+
json: false,
|
|
405
|
+
latest: false,
|
|
406
|
+
cwdScope: null,
|
|
407
|
+
})
|
|
408
|
+
).find((r) => r.wrapper_pid === self.parent_pid)
|
|
409
|
+
: undefined;
|
|
410
|
+
|
|
411
|
+
const whoAmI = `You are agent pid ${self.pid} (${self.cli}) in ${shortenPath(self.cwd)}.`;
|
|
412
|
+
// Three distinct states: no parent at all (top-level); a parent_pid whose
|
|
413
|
+
// record we can resolve; or a parent_pid we can't resolve (its record aged
|
|
414
|
+
// out / lives on a remote) — that last case is still nested, just unknown,
|
|
415
|
+
// so it must not collapse into the "top-level" line.
|
|
416
|
+
const parentLine = !hasParentPid
|
|
417
|
+
? `Top-level agent — no parent (started from a human shell or scheduler).`
|
|
418
|
+
: parent
|
|
419
|
+
? `Spawned by agent pid ${parent.pid} (${parent.cli}) in ${shortenPath(parent.cwd)}.`
|
|
420
|
+
: `Nested under a parent (wrapper pid ${self.parent_pid}) whose record isn't in the local registry.`;
|
|
421
|
+
|
|
422
|
+
return (
|
|
423
|
+
`You are running inside an agent:\n` +
|
|
424
|
+
` ${whoAmI}\n` +
|
|
425
|
+
` ${parentLine}\n` +
|
|
426
|
+
`\n` +
|
|
427
|
+
`As an agent, you can:\n` +
|
|
428
|
+
` Spawn a sub-agent:\n` +
|
|
429
|
+
` ay <cli> -- "<prompt>" auto-links as your child\n` +
|
|
430
|
+
` ay claude --model sonnet --advisor opus -- "<prompt>" routine task\n` +
|
|
431
|
+
` ay claude --model opus --advisor fable -- "<prompt>" complex task\n` +
|
|
432
|
+
` (pick --model by task complexity so easy tasks don't cost like hard ones;\n` +
|
|
433
|
+
` --advisor is a claude-cli flag — only takes effect for claude/cy)\n` +
|
|
434
|
+
` List agents (your children nest under your own pid in the tree):\n` +
|
|
435
|
+
` ay ls --cwd ${shortenPath(self.cwd)}\n` +
|
|
436
|
+
` Watch agent state changes, scoped to your workspace:\n` +
|
|
437
|
+
` ay ls --watch --cwd ${shortenPath(self.cwd)}\n` +
|
|
438
|
+
` (NDJSON stream of state changes across every matched agent — one watcher\n` +
|
|
439
|
+
` for the whole fan-out instead of N \`ay status --watch\`es)\n` +
|
|
440
|
+
` Read one sub-agent's output:\n` +
|
|
441
|
+
` ay tail -f <pid> follow live output (no single command tails\n` +
|
|
442
|
+
` many agents' content at once yet — loop\n` +
|
|
443
|
+
` \`ay ls --json\` pids into per-pid \`ay tail\`)\n` +
|
|
444
|
+
`\n`
|
|
445
|
+
);
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
export async function cmdHelp(managerCommands = true): Promise<number> {
|
|
391
449
|
// `setup` is manager-only — hide it when invoked through a cli-bound alias
|
|
392
450
|
// (cy/claude-yes/…), where `cy setup` runs the agent instead of managing the host.
|
|
393
451
|
const setupLine = managerCommands
|
|
394
452
|
? ` ay setup guided setup: pick a workspace, share to agent-yes.com\n`
|
|
395
453
|
: ``;
|
|
454
|
+
// Only agents carry AGENT_YES_PID — a human shell never sets it — so this
|
|
455
|
+
// section is skipped entirely (no async work at all) for interactive use.
|
|
456
|
+
const self = process.env.AGENT_YES_PID ? await resolveSender() : null;
|
|
457
|
+
const agentSection = self ? await buildAgentContextSection(self) : "";
|
|
396
458
|
process.stdout.write(
|
|
397
|
-
|
|
459
|
+
agentSection +
|
|
460
|
+
`ay - agent-yes CLI\n` +
|
|
398
461
|
`\n` +
|
|
399
462
|
`Management:\n` +
|
|
400
463
|
` ay ls [keyword] list running agents\n` +
|
|
@@ -4,6 +4,9 @@ import { homedir, tmpdir } from "os";
|
|
|
4
4
|
import path from "path";
|
|
5
5
|
import {
|
|
6
6
|
expandTilde,
|
|
7
|
+
getMaxAgents,
|
|
8
|
+
getMinFreeMb,
|
|
9
|
+
getSpawnWaitMs,
|
|
7
10
|
getProvisionAllowlist,
|
|
8
11
|
getProvisionRoot,
|
|
9
12
|
getSpawnHook,
|
|
@@ -28,6 +31,9 @@ describe("workspaceConfig", () => {
|
|
|
28
31
|
delete process.env.CODEHOST_WS_ROOT;
|
|
29
32
|
delete process.env.CODEHOST_PROVISION_ALLOWLIST;
|
|
30
33
|
delete process.env.AGENT_YES_SPAWN_HOOK;
|
|
34
|
+
delete process.env.AGENT_YES_MAX_AGENTS;
|
|
35
|
+
delete process.env.AGENT_YES_MIN_FREE_MB;
|
|
36
|
+
delete process.env.AGENT_YES_SPAWN_WAIT_MS;
|
|
31
37
|
rmSync(tmp, { recursive: true, force: true });
|
|
32
38
|
});
|
|
33
39
|
|
|
@@ -146,6 +152,85 @@ describe("workspaceConfig", () => {
|
|
|
146
152
|
});
|
|
147
153
|
});
|
|
148
154
|
|
|
155
|
+
describe("getMaxAgents", () => {
|
|
156
|
+
it("is undefined (unlimited) when neither env nor config is set", () => {
|
|
157
|
+
expect(getMaxAgents()).toBeUndefined();
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it("reads a positive integer from config", () => {
|
|
161
|
+
writeConfig({ maxAgents: 8 });
|
|
162
|
+
expect(getMaxAgents()).toBe(8);
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it("env AGENT_YES_MAX_AGENTS overrides config", () => {
|
|
166
|
+
writeConfig({ maxAgents: 8 });
|
|
167
|
+
process.env.AGENT_YES_MAX_AGENTS = "3";
|
|
168
|
+
expect(getMaxAgents()).toBe(3);
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it("floors a fractional value", () => {
|
|
172
|
+
process.env.AGENT_YES_MAX_AGENTS = "4.9";
|
|
173
|
+
expect(getMaxAgents()).toBe(4);
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
it("treats 0, negative, and garbage as unlimited (undefined)", () => {
|
|
177
|
+
writeConfig({ maxAgents: 0 });
|
|
178
|
+
expect(getMaxAgents()).toBeUndefined();
|
|
179
|
+
process.env.AGENT_YES_MAX_AGENTS = "-5";
|
|
180
|
+
expect(getMaxAgents()).toBeUndefined();
|
|
181
|
+
process.env.AGENT_YES_MAX_AGENTS = "lots";
|
|
182
|
+
expect(getMaxAgents()).toBeUndefined();
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
it("treats a fractional value < 1 as unlimited, not a 0 hard-cap", () => {
|
|
186
|
+
// Regression: 0.5 must NOT floor to 0 (which would reject every spawn).
|
|
187
|
+
process.env.AGENT_YES_MAX_AGENTS = "0.5";
|
|
188
|
+
expect(getMaxAgents()).toBeUndefined();
|
|
189
|
+
});
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
describe("getMinFreeMb", () => {
|
|
193
|
+
it("is undefined (no floor) when unset", () => {
|
|
194
|
+
expect(getMinFreeMb()).toBeUndefined();
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
it("reads config and lets env override", () => {
|
|
198
|
+
writeConfig({ minFreeMb: 1024 });
|
|
199
|
+
expect(getMinFreeMb()).toBe(1024);
|
|
200
|
+
process.env.AGENT_YES_MIN_FREE_MB = "2048";
|
|
201
|
+
expect(getMinFreeMb()).toBe(2048);
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
it("treats non-positive/garbage/sub-1 as no floor", () => {
|
|
205
|
+
process.env.AGENT_YES_MIN_FREE_MB = "0";
|
|
206
|
+
expect(getMinFreeMb()).toBeUndefined();
|
|
207
|
+
process.env.AGENT_YES_MIN_FREE_MB = "nope";
|
|
208
|
+
expect(getMinFreeMb()).toBeUndefined();
|
|
209
|
+
process.env.AGENT_YES_MIN_FREE_MB = "0.5";
|
|
210
|
+
expect(getMinFreeMb()).toBeUndefined();
|
|
211
|
+
});
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
describe("getSpawnWaitMs", () => {
|
|
215
|
+
it("defaults to 10 minutes when unset", () => {
|
|
216
|
+
expect(getSpawnWaitMs()).toBe(600_000);
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
it("reads config and lets env override; allows 0 (don't wait)", () => {
|
|
220
|
+
writeConfig({ spawnWaitMs: 5000 });
|
|
221
|
+
expect(getSpawnWaitMs()).toBe(5000);
|
|
222
|
+
process.env.AGENT_YES_SPAWN_WAIT_MS = "0";
|
|
223
|
+
expect(getSpawnWaitMs()).toBe(0);
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
it("falls back to the default on negative/garbage", () => {
|
|
227
|
+
process.env.AGENT_YES_SPAWN_WAIT_MS = "-1";
|
|
228
|
+
expect(getSpawnWaitMs()).toBe(600_000);
|
|
229
|
+
process.env.AGENT_YES_SPAWN_WAIT_MS = "soon";
|
|
230
|
+
expect(getSpawnWaitMs()).toBe(600_000);
|
|
231
|
+
});
|
|
232
|
+
});
|
|
233
|
+
|
|
149
234
|
describe("getSpawnHook / hasSpawnHook", () => {
|
|
150
235
|
const isPosix = process.platform !== "win32";
|
|
151
236
|
|