agent-yes 1.196.3 → 1.198.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 (45) hide show
  1. package/default.config.yaml +51 -12
  2. package/dist/SUPPORTED_CLIS-Bhb4JKPv.js +9 -0
  3. package/dist/{SUPPORTED_CLIS-D3knhJ6_.js → SUPPORTED_CLIS-BuScRhc9.js} +2 -2
  4. package/dist/{agent-yes.config-ugPYTPOd.js → agent-yes.config-whqkqq9P.js} +2 -2
  5. package/dist/{agentShare-M6FE9AsI.js → agentShare-CD1AIPMR.js} +8 -7
  6. package/dist/cli.js +8 -8
  7. package/dist/{configShared-0MnIQ652.js → configShared-aKTg-sa5.js} +1 -1
  8. package/dist/{e2e-BeKjLhmO.js → e2e-jb0Hp43q.js} +1 -1
  9. package/dist/{expose-B3AGhVhM.js → expose-k9l6ZJDA.js} +1 -1
  10. package/dist/{forkNested-C1kKsXi5.js → forkNested-DOyDAHnC.js} +1 -1
  11. package/dist/index.js +3 -2
  12. package/dist/messageLog-CxrKJj77.js +94 -0
  13. package/dist/{nodeRuntime-DZRjxpNp.js → nodeRuntime-CwNJuwH5.js} +1 -1
  14. package/dist/{notifyDaemon-DgxP6GAn.js → notifyDaemon-DYoMC4tA.js} +7 -6
  15. package/dist/{openBrowser-DQVkxCyj.js → openBrowser-CSj21pZE.js} +1 -1
  16. package/dist/{reaper-CWF2_ATd.js → reaper-BUHCyxdF.js} +1 -1
  17. package/dist/{reaper-jfSmog15.js → reaper-CJSu8UVZ.js} +1 -1
  18. package/dist/{remotes-CW-yxSgW.js → remotes-CXqYR0p8.js} +3 -3
  19. package/dist/{remotes-oSmwSYaV.js → remotes-Cim0dBU7.js} +3 -3
  20. package/dist/{rustBinary-C9q17obe.js → rustBinary-DLI26u3O.js} +2 -2
  21. package/dist/{schedule-SisMmURQ.js → schedule-DAl2mCc9.js} +7 -6
  22. package/dist/{serve-BOrkFR6H.js → serve-9jP4V1l_.js} +26 -25
  23. package/dist/{setup-CFbckIlf.js → setup-B55UN6xV.js} +3 -3
  24. package/dist/{share-29YbzpbU.js → share-BfIU8t_h.js} +2 -2
  25. package/dist/{share-nvqy64A2.js → share-Ciw1mWVN.js} +2 -2
  26. package/dist/{spawnGate-CWbdnJ5K.js → spawnGate-BkjeS4tg.js} +1 -1
  27. package/dist/{spawnGate-DRWa_Fzj.js → spawnGate-CXjEz6Pf.js} +2 -2
  28. package/dist/{subcommands-CMX-dej0.js → subcommands-D7AmlO_6.js} +24 -105
  29. package/dist/{subcommands-DSbYPDt5.js → subcommands-lUi22sO5.js} +6 -5
  30. package/dist/{tray-BKTZ5gIf.js → tray-BSFi35Qg.js} +1 -1
  31. package/dist/{ts-Bqa5HjY3.js → ts-D9RFyHqn.js} +84 -7
  32. package/dist/{versionChecker-ZIgviJ62.js → versionChecker-DQQIK4Og.js} +2 -2
  33. package/dist/{webrtcLink-BG0Xc4-W.js → webrtcLink-B7REGtK2.js} +2 -2
  34. package/dist/{webrtcRemote-BmuVKcrx.js → webrtcRemote-CvIEfWX8.js} +3 -3
  35. package/dist/{workspaceConfig-_GtAZtsi.js → workspaceConfig-CjaRvTjf.js} +1 -1
  36. package/dist/ws-BpHPhcPH.js +399 -0
  37. package/package.json +1 -1
  38. package/ts/autoRetry.spec.ts +90 -1
  39. package/ts/autoRetry.ts +76 -0
  40. package/ts/index.ts +37 -4
  41. package/ts/messageLog.ts +3 -2
  42. package/ts/subcommands.ts +20 -6
  43. package/ts/ws.spec.ts +435 -0
  44. package/ts/ws.ts +522 -0
  45. package/dist/SUPPORTED_CLIS-L3rJRumq.js +0 -8
package/ts/subcommands.ts CHANGED
@@ -211,7 +211,7 @@ export interface MessageEdge {
211
211
  by: number;
212
212
  target: number;
213
213
  at: number;
214
- kind?: "key" | "select";
214
+ kind?: "key" | "select" | "auto-retry";
215
215
  }
216
216
 
217
217
  /**
@@ -368,7 +368,7 @@ const SUBCOMMANDS = new Set([
368
368
  // alias like `cy` (= claude-yes = "agent-yes claude") must NOT treat these as
369
369
  // subcommands — `cy setup …` should run claude with that text, not manage the
370
370
  // host. Kept separate from SUBCOMMANDS so a runner alias falls straight through.
371
- const MANAGER_SUBCOMMANDS = new Set(["setup"]);
371
+ const MANAGER_SUBCOMMANDS = new Set(["setup", "ws"]);
372
372
 
373
373
  const IDLE_THRESHOLD_MS = 60 * 1000;
374
374
 
@@ -475,6 +475,10 @@ export async function runSubcommand(argv: string[]): Promise<number | null> {
475
475
  const { cmdSetup } = await import("./setup.ts");
476
476
  return cmdSetup(rest);
477
477
  }
478
+ case "ws": {
479
+ const { cmdWs } = await import("./ws.ts");
480
+ return cmdWs(rest);
481
+ }
478
482
  case "schedule": {
479
483
  const { cmdSchedule } = await import("./schedule.ts");
480
484
  return cmdSchedule(rest);
@@ -572,6 +576,11 @@ export async function cmdHelp(managerCommands = true): Promise<number> {
572
576
  const setupLine = managerCommands
573
577
  ? ` ay setup guided setup: pick a workspace, share to agent-yes.com\n`
574
578
  : ``;
579
+ // `ws` is manager-only for the same reason as `setup`.
580
+ const wsLines = managerCommands
581
+ ? ` ay ws ls [--status] list <owner>/<repo>/tree/<branch> workspaces\n` +
582
+ ` ay ws new <owner>/<repo>[@branch] clone/refresh a workspace (ay ws help for more)\n`
583
+ : ``;
575
584
  // Only agents carry AGENT_YES_PID — a human shell never sets it — so this
576
585
  // section is skipped entirely (no async work at all) for interactive use.
577
586
  const self = process.env.AGENT_YES_PID ? await resolveSender() : null;
@@ -599,6 +608,7 @@ export async function cmdHelp(managerCommands = true): Promise<number> {
599
608
  ` ay result <keyword> [--wait] pull an agent's structured result envelope\n` +
600
609
  ` ay result set '<json>' (inside an agent) deposit your result envelope\n` +
601
610
  ` ay reap kill process groups leaked by dead agents\n` +
611
+ wsLines +
602
612
  `\n` +
603
613
  `Remote:\n` +
604
614
  setupLine +
@@ -3279,10 +3289,14 @@ async function cmdMsgs(rest: string[]): Promise<number> {
3279
3289
 
3280
3290
  for (const { dir, rec } of shown) {
3281
3291
  const when = new Date(rec.at).toLocaleTimeString();
3282
- const peer =
3283
- dir === "out"
3284
- ? `→ ${rec.to.cli} #${rec.to.pid}`
3285
- : `← ${rec.from ? `${rec.from.cli} #${rec.from.pid}` : "human"}`;
3292
+ // auto-retry nudges are written by the agent's own wrapper (from is null
3293
+ // there too) — label them as agent-yes, not a human send.
3294
+ const fromLabel = rec.from
3295
+ ? `${rec.from.cli} #${rec.from.pid}`
3296
+ : rec.kind === "auto-retry"
3297
+ ? "agent-yes"
3298
+ : "human";
3299
+ const peer = dir === "out" ? `→ ${rec.to.cli} #${rec.to.pid}` : `← ${fromLabel}`;
3286
3300
  const via = rec.remote ? ` (via ${rec.remote})` : "";
3287
3301
  const flag = rec.confirmed === false ? " (unconfirmed)" : "";
3288
3302
  const tag = rec.kind ? `[${rec.kind}] ` : "";
package/ts/ws.spec.ts ADDED
@@ -0,0 +1,435 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2
+ import { mkdtempSync, mkdirSync, realpathSync, writeFileSync, symlinkSync, rmSync } from "fs";
3
+ import os from "os";
4
+ import path from "path";
5
+ import { cmdWs, isPathInside, resolveOperand, walkWorkspaces, WS_JSON_SCHEMA } from "./ws.ts";
6
+
7
+ // Mutable fake for codehost/provision — each test overrides what it needs.
8
+ // ws.ts imports the module lazily; vitest's registry mock intercepts that too.
9
+ const prov = vi.hoisted(() => ({
10
+ wsRoot: "",
11
+ parseSource: (input: string): { owner: string; repo: string; branch: string } | null => {
12
+ const m = input.match(/^([^/]+)\/([^/@]+)(?:@(.+))?$/);
13
+ return m ? { owner: m[1]!, repo: m[2]!, branch: m[3] ?? "main" } : null;
14
+ },
15
+ readStatus: vi.fn(),
16
+ provision: vi.fn(),
17
+ createBranch: vi.fn(),
18
+ forkWorktree: vi.fn(),
19
+ }));
20
+
21
+ vi.mock("codehost/provision", () => ({
22
+ resolveWsRoot: (w?: string) => w ?? prov.wsRoot,
23
+ folderFor: (spec: { owner: string; repo: string; branch: string }, wsRoot?: string) =>
24
+ path.join(wsRoot ?? prov.wsRoot, spec.owner, spec.repo, "tree", spec.branch),
25
+ parseSource: (input: string) => prov.parseSource(input),
26
+ readStatus: (dir: string) => prov.readStatus(dir),
27
+ provision: (spec: unknown, opts?: unknown) => prov.provision(spec, opts),
28
+ createBranch: (spec: unknown, opts?: unknown) => prov.createBranch(spec, opts),
29
+ forkWorktree: (opts: unknown) => prov.forkWorktree(opts),
30
+ }));
31
+
32
+ describe("isPathInside", () => {
33
+ it("contains itself and descendants", () => {
34
+ expect(isPathInside("/a/b", "/a/b")).toBe(true);
35
+ expect(isPathInside("/a/b", "/a/b/c/d")).toBe(true);
36
+ });
37
+
38
+ it("rejects siblings sharing a name prefix (segment boundary, not startsWith)", () => {
39
+ expect(isPathInside("/a/repo", "/a/repo-two/x")).toBe(false);
40
+ });
41
+
42
+ it("rejects parents and unrelated paths", () => {
43
+ expect(isPathInside("/a/b/c", "/a/b")).toBe(false);
44
+ expect(isPathInside("/a/b", "/z")).toBe(false);
45
+ });
46
+
47
+ it("resolves relative segments before comparing", () => {
48
+ expect(isPathInside("/a/b", "/a/b/../b/c")).toBe(true);
49
+ expect(isPathInside("/a/b", "/a/b/../c")).toBe(false);
50
+ });
51
+ });
52
+
53
+ describe("walkWorkspaces", () => {
54
+ let root: string;
55
+ beforeEach(() => {
56
+ root = mkdtempSync(path.join(os.tmpdir(), "ay-ws-test-"));
57
+ });
58
+ afterEach(() => {
59
+ rmSync(root, { recursive: true, force: true });
60
+ });
61
+
62
+ const mkCheckout = (rel: string, gitAs: "dir" | "file") => {
63
+ const dir = path.join(root, rel);
64
+ mkdirSync(dir, { recursive: true });
65
+ if (gitAs === "dir") mkdirSync(path.join(dir, ".git"));
66
+ else writeFileSync(path.join(dir, ".git"), "gitdir: /elsewhere\n");
67
+ return dir;
68
+ };
69
+
70
+ it("finds clones and linked worktrees, including branches containing '/'", async () => {
71
+ const a = mkCheckout("owner/repo/tree/main", "dir");
72
+ const b = mkCheckout("owner/repo/tree/feat/deep/branch", "file");
73
+ const found = await walkWorkspaces(root);
74
+ expect(found).toEqual([
75
+ { owner: "owner", repo: "repo", branch: "feat/deep/branch", path: b },
76
+ { owner: "owner", repo: "repo", branch: "main", path: a },
77
+ ]);
78
+ });
79
+
80
+ it("does not descend into a checkout root looking for nested checkouts", async () => {
81
+ const a = mkCheckout("o/r/tree/main", "dir");
82
+ // a stray nested checkout below an existing root must not double-report
83
+ mkdirSync(path.join(a, "vendor", ".git"), { recursive: true });
84
+ const found = await walkWorkspaces(root);
85
+ expect(found.map((w) => w.branch)).toEqual(["main"]);
86
+ });
87
+
88
+ it("skips non-layout dirs, dotdirs, and symlinks", async () => {
89
+ mkCheckout("o/r/tree/main", "dir");
90
+ mkdirSync(path.join(root, "o/r/notes"), { recursive: true }); // no tree/ marker
91
+ mkdirSync(path.join(root, ".hidden/x/tree/y"), { recursive: true });
92
+ mkdirSync(path.join(root, "loop/r/tree"), { recursive: true });
93
+ // symlink cycle under tree/ must not hang or be reported
94
+ symlinkSync(path.join(root, "loop"), path.join(root, "loop/r/tree/self"));
95
+ const found = await walkWorkspaces(root);
96
+ expect(found.map((w) => `${w.owner}/${w.repo}@${w.branch}`)).toEqual(["o/r@main"]);
97
+ });
98
+
99
+ it("bounds the branch-depth walk", async () => {
100
+ const deep = "o/r/tree/" + Array.from({ length: 12 }, (_, i) => `d${i}`).join("/");
101
+ mkCheckout(deep, "dir");
102
+ const found = await walkWorkspaces(root);
103
+ expect(found).toEqual([]); // beyond MAX_BRANCH_DEPTH → ignored, not crashed
104
+ });
105
+
106
+ it("returns [] for an empty or missing root", async () => {
107
+ expect(await walkWorkspaces(root)).toEqual([]);
108
+ expect(await walkWorkspaces(path.join(root, "nope"))).toEqual([]);
109
+ });
110
+ });
111
+
112
+ describe("resolveOperand", () => {
113
+ let root: string;
114
+ beforeEach(() => {
115
+ root = mkdtempSync(path.join(os.tmpdir(), "ay-ws-op-"));
116
+ });
117
+ afterEach(() => {
118
+ rmSync(root, { recursive: true, force: true });
119
+ });
120
+
121
+ const fakeProv = (specDir: string) =>
122
+ ({
123
+ parseSource: (input: string) => {
124
+ const m = input.match(/^([^/]+)\/([^/@]+)@(.+)$/);
125
+ return m ? { owner: m[1]!, repo: m[2]!, branch: m[3]! } : null;
126
+ },
127
+ folderFor: () => specDir,
128
+ resolveWsRoot: (w?: string) => w ?? root,
129
+ }) as any;
130
+
131
+ const mkCheckout = (rel: string) => {
132
+ const dir = path.join(root, rel);
133
+ mkdirSync(path.join(dir, ".git"), { recursive: true });
134
+ return dir;
135
+ };
136
+
137
+ it("an existing local path wins over spec parsing in auto mode", async () => {
138
+ const dir = mkCheckout("o/r/tree/main");
139
+ const res = await resolveOperand(fakeProv("/unused"), dir, "auto", undefined);
140
+ expect(res).toEqual({ dir, spec: null });
141
+ });
142
+
143
+ it("falls back to spec parsing when the operand is not a path", async () => {
144
+ const dir = mkCheckout("o/r/tree/main");
145
+ const res = await resolveOperand(fakeProv(dir), "o/r@main", "auto", undefined);
146
+ expect(res.dir).toBe(dir);
147
+ expect(res.spec).toEqual({ owner: "o", repo: "r", branch: "main" });
148
+ });
149
+
150
+ it("--path mode rejects a directory that is not a checkout root", async () => {
151
+ const dir = path.join(root, "plain");
152
+ mkdirSync(dir);
153
+ await expect(resolveOperand(fakeProv("/unused"), dir, "path", undefined)).rejects.toThrow(
154
+ /not a git checkout root/,
155
+ );
156
+ });
157
+
158
+ it("--spec mode reports an unprovisioned workspace with the fix-it hint", async () => {
159
+ await expect(
160
+ resolveOperand(fakeProv(path.join(root, "missing")), "o/r@main", "spec", undefined),
161
+ ).rejects.toThrow(/not provisioned.*ay ws new/s);
162
+ });
163
+
164
+ it("reports a precise parse error for garbage", async () => {
165
+ await expect(resolveOperand(fakeProv("/unused"), "not a spec", "spec", undefined)).rejects.toThrow(
166
+ /cannot parse "not a spec"/,
167
+ );
168
+ });
169
+ });
170
+
171
+ describe("json schema tag", () => {
172
+ it("is versioned", () => {
173
+ expect(WS_JSON_SCHEMA).toBe("ay-ws/v1");
174
+ });
175
+ });
176
+
177
+ describe("cmdWs (mocked provision)", () => {
178
+ let root: string;
179
+ let out: string[];
180
+ let err: string[];
181
+ let homeBackup: string | undefined;
182
+
183
+ const CLEAN = { branch: "main", head: "abc123", ahead: 0, behind: 0, dirty: false, hasUpstream: true };
184
+
185
+ beforeEach(() => {
186
+ // realpath: os.tmpdir() is a symlink on macOS (/var → /private/var), and
187
+ // process.chdir + cwd-derived paths come back resolved.
188
+ root = realpathSync(mkdtempSync(path.join(os.tmpdir(), "ay-ws-cmd-")));
189
+ prov.wsRoot = root;
190
+ prov.readStatus.mockReset().mockResolvedValue(CLEAN);
191
+ prov.provision.mockReset();
192
+ prov.createBranch.mockReset();
193
+ prov.forkWorktree.mockReset();
194
+ out = [];
195
+ err = [];
196
+ vi.spyOn(process.stdout, "write").mockImplementation((s: any) => (out.push(String(s)), true));
197
+ vi.spyOn(process.stderr, "write").mockImplementation((s: any) => (err.push(String(s)), true));
198
+ // Isolate the agent registry so live-agent counts are deterministic (0).
199
+ homeBackup = process.env.AGENT_YES_HOME;
200
+ process.env.AGENT_YES_HOME = path.join(root, ".ay-home");
201
+ });
202
+ afterEach(() => {
203
+ vi.restoreAllMocks();
204
+ if (homeBackup === undefined) delete process.env.AGENT_YES_HOME;
205
+ else process.env.AGENT_YES_HOME = homeBackup;
206
+ rmSync(root, { recursive: true, force: true });
207
+ });
208
+
209
+ const mkCheckout = (rel: string) => {
210
+ const dir = path.join(root, rel);
211
+ mkdirSync(path.join(dir, ".git"), { recursive: true });
212
+ return dir;
213
+ };
214
+
215
+ it("no args / unknown sub → help (0) and error (1)", async () => {
216
+ expect(await cmdWs([])).toBe(0);
217
+ expect(out.join("")).toContain("ay ws ls");
218
+ expect(await cmdWs(["nope"])).toBe(1);
219
+ expect(err.join("")).toContain('unknown subcommand "nope"');
220
+ });
221
+
222
+ it("ls: empty root note, then table with agent hint column", async () => {
223
+ expect(await cmdWs(["ls"])).toBe(0);
224
+ expect(err.join("")).toContain("no workspaces under");
225
+ mkCheckout("o/r/tree/main");
226
+ expect(await cmdWs(["ls"])).toBe(0);
227
+ const table = out.join("");
228
+ expect(table).toContain("WORKSPACE");
229
+ expect(table).toContain("o/r@main");
230
+ });
231
+
232
+ it("ls --json: versioned schema envelope", async () => {
233
+ const dir = mkCheckout("o/r/tree/feat/x");
234
+ expect(await cmdWs(["ls", "--json"])).toBe(0);
235
+ const doc = JSON.parse(out.join(""));
236
+ expect(doc.schema).toBe(WS_JSON_SCHEMA);
237
+ expect(doc.wsRoot).toBe(root);
238
+ expect(doc.workspaces).toEqual([
239
+ { owner: "o", repo: "r", branch: "feat/x", path: dir, agents: { live: 0 } },
240
+ ]);
241
+ });
242
+
243
+ it("ls --status: git summaries and the per-entry error fallback", async () => {
244
+ mkCheckout("o/r/tree/clean");
245
+ mkCheckout("o/r/tree/messy");
246
+ mkCheckout("o/r/tree/broken");
247
+ prov.readStatus.mockImplementation(async (dir: string) => {
248
+ if (dir.endsWith("broken")) throw new Error("boom");
249
+ if (dir.endsWith("messy"))
250
+ return { branch: "messy", head: "h", ahead: 2, behind: 1, dirty: true, hasUpstream: false };
251
+ return CLEAN;
252
+ });
253
+ expect(await cmdWs(["ls", "--status"])).toBe(0);
254
+ const table = out.join("");
255
+ expect(table).toContain("clean");
256
+ expect(table).toContain("dirty, ahead 2, behind 1, no-upstream");
257
+ expect(table).toContain("error: boom");
258
+ });
259
+
260
+ it("ls rejects positional args and unknown flags", async () => {
261
+ await expect(cmdWs(["ls", "stray"])).rejects.toThrow(/no positional/);
262
+ await expect(cmdWs(["ls", "--nope"])).rejects.toThrow(/unknown flag --nope/);
263
+ await expect(cmdWs(["ls", "--json=1"])).rejects.toThrow(/takes no value/);
264
+ });
265
+
266
+ it("status: layout spec + state text for a path target, --json shape", async () => {
267
+ const dir = mkCheckout("o/r/tree/feat/y");
268
+ expect(await cmdWs(["status", dir])).toBe(0);
269
+ const text = out.join("");
270
+ expect(text).toContain("spec: o/r@feat/y");
271
+ expect(text).toContain("state: clean");
272
+ out.length = 0;
273
+ expect(await cmdWs(["status", dir, "--json"])).toBe(0);
274
+ const doc = JSON.parse(out.join(""));
275
+ expect(doc.schema).toBe(WS_JSON_SCHEMA);
276
+ expect(doc.workspace.branch).toBe("feat/y");
277
+ expect(doc.workspace.git).toEqual(CLEAN);
278
+ });
279
+
280
+ it("status: a checkout outside the layout omits the spec line", async () => {
281
+ const outside = mkdtempSync(path.join(os.tmpdir(), "ay-ws-out-"));
282
+ try {
283
+ mkdirSync(path.join(outside, ".git"));
284
+ expect(await cmdWs(["status", outside])).toBe(0);
285
+ expect(out.join("")).not.toContain("spec:");
286
+ } finally {
287
+ rmSync(outside, { recursive: true, force: true });
288
+ }
289
+ });
290
+
291
+ it("status: --path and --spec are mutually exclusive; one target max", async () => {
292
+ await expect(cmdWs(["status", "--path", "--spec"])).rejects.toThrow(/mutually exclusive/);
293
+ await expect(cmdWs(["status", "a", "b"])).rejects.toThrow(/at most one/);
294
+ });
295
+
296
+ it("new: provisions and prints the action + folder", async () => {
297
+ prov.provision.mockResolvedValue({ ok: true, action: "cloned", folder: "/x" });
298
+ expect(await cmdWs(["new", "o/r@dev"])).toBe(0);
299
+ expect(prov.provision).toHaveBeenCalledWith(
300
+ { owner: "o", repo: "r", branch: "dev" },
301
+ { wsRoot: undefined },
302
+ );
303
+ expect(out.join("")).toContain("cloned /x");
304
+ });
305
+
306
+ it("new: branch-not-found hints at --create, and --create falls back to createBranch", async () => {
307
+ prov.provision.mockResolvedValue({ ok: false, action: "error", reason: "branch-not-found", error: "nope" });
308
+ expect(await cmdWs(["new", "o/r@dev"])).toBe(1);
309
+ expect(err.join("")).toContain("--create");
310
+ expect(prov.createBranch).not.toHaveBeenCalled();
311
+
312
+ prov.createBranch.mockResolvedValue({ ok: true, action: "created", folder: "/y" });
313
+ expect(await cmdWs(["new", "o/r@dev", "--create"])).toBe(0);
314
+ expect(out.join("")).toContain("created /y");
315
+ });
316
+
317
+ it("new: usage and parse errors", async () => {
318
+ await expect(cmdWs(["new"])).rejects.toThrow(/usage: ay ws new/);
319
+ await expect(cmdWs(["new", "///"])).rejects.toThrow(/cannot parse/);
320
+ });
321
+
322
+ it("fork: --from is resolved and passed through with --wip", async () => {
323
+ prov.forkWorktree.mockResolvedValue({ ok: true, action: "forked", folder: "/f" });
324
+ expect(await cmdWs(["fork", "nb", "--from", root, "--wip"])).toBe(0);
325
+ expect(prov.forkWorktree).toHaveBeenCalledWith({
326
+ fromCwd: path.resolve(root),
327
+ branch: "nb",
328
+ wsRoot: undefined,
329
+ wip: true,
330
+ });
331
+ expect(out.join("")).toContain("forked /f");
332
+ });
333
+
334
+ it("help aliases and the list alias dispatch", async () => {
335
+ for (const h of ["help", "--help", "-h"]) {
336
+ out.length = 0;
337
+ expect(await cmdWs([h])).toBe(0);
338
+ expect(out.join("")).toContain("ay ws ls");
339
+ }
340
+ mkCheckout("o/r/tree/main");
341
+ out.length = 0;
342
+ expect(await cmdWs(["list"])).toBe(0);
343
+ expect(out.join("")).toContain("o/r@main");
344
+ });
345
+
346
+ it("ls/status: live agents from the registry produce counts and the ay-ls hint", async () => {
347
+ const dir = mkCheckout("o/r/tree/main");
348
+ const home = process.env.AGENT_YES_HOME!;
349
+ mkdirSync(home, { recursive: true });
350
+ writeFileSync(
351
+ path.join(home, "pids.jsonl"),
352
+ JSON.stringify({
353
+ pid: process.pid,
354
+ cli: "claude",
355
+ prompt: null,
356
+ cwd: path.join(dir, "sub"),
357
+ log_file: null,
358
+ status: "active",
359
+ exit_code: null,
360
+ exit_reason: null,
361
+ started_at: 1,
362
+ }) + "\n",
363
+ );
364
+ expect(await cmdWs(["ls"])).toBe(0);
365
+ expect(err.join("")).toContain("ay ls --cwd");
366
+ out.length = 0;
367
+ err.length = 0;
368
+ expect(await cmdWs(["status", dir])).toBe(0);
369
+ expect(out.join("")).toContain("agents: 1 live");
370
+ expect(err.join("")).toContain(`ay ls --cwd ${dir}`);
371
+ });
372
+
373
+ it("status: defaults to cwd, and honors --path / --spec modes", async () => {
374
+ const dir = mkCheckout("o/r/tree/main");
375
+ const prevCwd = process.cwd();
376
+ process.chdir(dir);
377
+ try {
378
+ expect(await cmdWs(["status"])).toBe(0);
379
+ expect(out.join("")).toContain("spec: o/r@main");
380
+ } finally {
381
+ process.chdir(prevCwd);
382
+ }
383
+ out.length = 0;
384
+ expect(await cmdWs(["status", dir, "--path"])).toBe(0);
385
+ expect(out.join("")).toContain("branch: main");
386
+ out.length = 0;
387
+ expect(await cmdWs(["status", "o/r@main", "--spec", "--json"])).toBe(0);
388
+ expect(JSON.parse(out.join("")).workspace.path).toBe(dir);
389
+ });
390
+
391
+ it("new: a non-branch failure reports without the --create hint", async () => {
392
+ prov.provision.mockResolvedValue({ ok: false, action: "error", reason: "repo-not-found", error: "gone" });
393
+ expect(await cmdWs(["new", "o/r"])).toBe(1);
394
+ const msg = err.join("");
395
+ expect(msg).toContain("repo-not-found");
396
+ expect(msg).not.toContain("--create");
397
+ });
398
+
399
+ it("fork: --from=<eq-form> and a stale AGENT_YES_PID both resolve", async () => {
400
+ prov.forkWorktree.mockResolvedValue({ ok: true, action: "forked", folder: "/f" });
401
+ expect(await cmdWs(["fork", "nb", `--from=${root}`])).toBe(0);
402
+ expect(prov.forkWorktree).toHaveBeenCalledWith(
403
+ expect.objectContaining({ fromCwd: path.resolve(root) }),
404
+ );
405
+ // An AGENT_YES_PID with no matching registry record falls back to cwd.
406
+ const envBackup = process.env.AGENT_YES_PID;
407
+ process.env.AGENT_YES_PID = "999999999";
408
+ try {
409
+ expect(await cmdWs(["fork", "nb2"])).toBe(0);
410
+ expect(prov.forkWorktree).toHaveBeenLastCalledWith(
411
+ expect.objectContaining({ fromCwd: path.resolve(process.cwd()) }),
412
+ );
413
+ } finally {
414
+ if (envBackup === undefined) delete process.env.AGENT_YES_PID;
415
+ else process.env.AGENT_YES_PID = envBackup;
416
+ }
417
+ });
418
+
419
+ it("fork: defaults --from to cwd (no agent env), surfaces failure", async () => {
420
+ const envBackup = process.env.AGENT_YES_PID;
421
+ delete process.env.AGENT_YES_PID;
422
+ try {
423
+ prov.forkWorktree.mockResolvedValue({ ok: false, error: "no origin" });
424
+ expect(await cmdWs(["fork", "nb"])).toBe(1);
425
+ expect(prov.forkWorktree).toHaveBeenCalledWith(
426
+ expect.objectContaining({ fromCwd: path.resolve(process.cwd()), wip: false }),
427
+ );
428
+ expect(err.join("")).toContain("fork failed: no origin");
429
+ await expect(cmdWs(["fork"])).rejects.toThrow(/usage: ay ws fork/);
430
+ await expect(cmdWs(["fork", "nb", "--from"])).rejects.toThrow(/requires a value/);
431
+ } finally {
432
+ if (envBackup !== undefined) process.env.AGENT_YES_PID = envBackup;
433
+ }
434
+ });
435
+ });