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
@@ -0,0 +1,399 @@
1
+ import "./logger-CDIsZ-Pp.js";
2
+ import "./globalPidIndex-CoNr7tS8.js";
3
+ import "./messageLog-CxrKJj77.js";
4
+ import "./configShared-aKTg-sa5.js";
5
+ import { S as listRecords } from "./subcommands-D7AmlO_6.js";
6
+ import "./e2e-jb0Hp43q.js";
7
+ import "./webrtcLink-B7REGtK2.js";
8
+ import "./remotes-Cim0dBU7.js";
9
+ import { i as getProvisionRoot } from "./workspaceConfig-CjaRvTjf.js";
10
+ import { existsSync } from "fs";
11
+ import { lstat, opendir } from "fs/promises";
12
+ import path from "path";
13
+
14
+ //#region ts/ws.ts
15
+ /**
16
+ * `ay ws` — workspace management over the codehost/provision standard layout
17
+ * (`<wsRoot>/<owner>/<repo>/tree/<branch>`, independent clones except forked
18
+ * linked worktrees).
19
+ *
20
+ * v1 surface (read/list/new/fork only — deletion/gc/doctor are deliberately
21
+ * deferred until workspace lifecycle data exists to design them around):
22
+ *
23
+ * ay ws ls [--status] [--json]
24
+ * ay ws status [<spec-or-path>] [--path|--spec] [--json]
25
+ * ay ws new <source> [--create]
26
+ * ay ws fork <branch> [--from <path>] [--wip]
27
+ *
28
+ * `--json` schema (stable, versioned via `schema`):
29
+ * ls: { schema:"ay-ws/v1", wsRoot, workspaces: WsEntry[] }
30
+ * status: { schema:"ay-ws/v1", workspace: WsEntry }
31
+ * WsEntry = { owner, repo, branch, path, agents: { live: number },
32
+ * git?: GitStatus | null, gitError?: string }
33
+ *
34
+ * codehost/provision is a peer package (bun-linked in dev). Like serve.ts's
35
+ * fork path, it is imported lazily so `ay ls` etc. never pay for it and a
36
+ * missing install produces a clear actionable error instead of a module crash.
37
+ */
38
+ async function loadProvision() {
39
+ try {
40
+ return await import("codehost/provision");
41
+ } catch (e) {
42
+ throw new Error(`'ay ws' needs the 'codehost' package (codehost/provision) — install it (npm i -g codehost) or 'bun link' it for local dev: ${e.message}`);
43
+ }
44
+ }
45
+ const WS_JSON_SCHEMA = "ay-ws/v1";
46
+ const STATUS_CONCURRENCY = 8;
47
+ const MAX_BRANCH_DEPTH = 8;
48
+ /**
49
+ * Path containment that matches how the OS actually compares paths: segment
50
+ * boundaries via path.relative (never a raw startsWith), case-insensitive on
51
+ * Windows (and macOS's default case-insensitive FS is fine with this too —
52
+ * false positives there require two dirs differing only by case).
53
+ */
54
+ function isPathInside(parent, child) {
55
+ let p = path.resolve(parent);
56
+ let c = path.resolve(child);
57
+ if (process.platform === "win32") {
58
+ p = p.toLowerCase();
59
+ c = c.toLowerCase();
60
+ }
61
+ const rel = path.relative(p, c);
62
+ return rel === "" || !rel.startsWith("..") && !path.isAbsolute(rel);
63
+ }
64
+ /** True when `dir` is a git checkout root (`.git` file = linked worktree, dir = clone). */
65
+ function isCheckoutRoot(dir) {
66
+ return existsSync(path.join(dir, ".git"));
67
+ }
68
+ async function subdirs(dir) {
69
+ const out = [];
70
+ let d;
71
+ try {
72
+ d = await opendir(dir);
73
+ } catch {
74
+ return out;
75
+ }
76
+ for await (const ent of d) {
77
+ if (ent.name.startsWith(".")) continue;
78
+ if (ent.isDirectory()) out.push(ent.name);
79
+ else if (ent.isSymbolicLink()) continue;
80
+ }
81
+ return out.sort();
82
+ }
83
+ /**
84
+ * Walk `<wsRoot>/<owner>/<repo>/tree/**` collecting checkout roots. The branch
85
+ * may contain `/`, so below `tree/` we descend until a `.git` marker is found
86
+ * (a checkout root is never nested inside another checkout in this layout),
87
+ * bounded by MAX_BRANCH_DEPTH.
88
+ */
89
+ async function walkWorkspaces(wsRoot) {
90
+ const found = [];
91
+ async function walkBranches(dir, owner, repo, segs) {
92
+ if (segs.length > MAX_BRANCH_DEPTH) return;
93
+ for (const name of await subdirs(dir)) {
94
+ const p = path.join(dir, name);
95
+ const branchSegs = [...segs, name];
96
+ if (isCheckoutRoot(p)) found.push({
97
+ owner,
98
+ repo,
99
+ branch: branchSegs.join("/"),
100
+ path: p
101
+ });
102
+ else await walkBranches(p, owner, repo, branchSegs);
103
+ }
104
+ }
105
+ for (const owner of await subdirs(wsRoot)) for (const repo of await subdirs(path.join(wsRoot, owner))) {
106
+ const tree = path.join(wsRoot, owner, repo, "tree");
107
+ try {
108
+ if (!(await lstat(tree)).isDirectory()) continue;
109
+ } catch {
110
+ continue;
111
+ }
112
+ await walkBranches(tree, owner, repo, []);
113
+ }
114
+ return found;
115
+ }
116
+ /** Live (non-exited) agents whose cwd sits inside `wsPath`. */
117
+ function liveAgentsIn(records, wsPath) {
118
+ return records.filter((r) => r.cwd && isPathInside(wsPath, r.cwd));
119
+ }
120
+ async function mapBounded(items, limit, fn) {
121
+ const out = new Array(items.length);
122
+ let next = 0;
123
+ const workers = Array.from({ length: Math.min(limit, items.length) }, async () => {
124
+ while (next < items.length) {
125
+ const i = next++;
126
+ out[i] = await fn(items[i]);
127
+ }
128
+ });
129
+ await Promise.all(workers);
130
+ return out;
131
+ }
132
+ /**
133
+ * Resolve a status/inspect operand deterministically:
134
+ * 1. `--path` forces path, `--spec` forces source-spec parsing
135
+ * 2. an operand that exists on disk is a path
136
+ * 3. otherwise it must parse as a source (owner/repo[@branch|/tree/branch] or URL)
137
+ * Prints the normalized resolution so the user always sees what was addressed.
138
+ */
139
+ async function resolveOperand(prov, operand, mode, wsRoot) {
140
+ const asPath = () => {
141
+ const dir = path.resolve(operand);
142
+ if (!existsSync(dir)) throw new Error(`path does not exist: ${dir}`);
143
+ if (!isCheckoutRoot(dir)) throw new Error(`not a git checkout root (no .git): ${dir}`);
144
+ return {
145
+ dir,
146
+ spec: null
147
+ };
148
+ };
149
+ const asSpec = () => {
150
+ const spec = prov.parseSource(operand);
151
+ if (!spec) throw new Error(`cannot parse "${operand}" as a source — expected <owner>/<repo>, <owner>/<repo>@<branch>, <owner>/<repo>/tree/<branch>, or a github URL` + (existsSync(operand) ? "" : " (and it is not an existing path)"));
152
+ const dir = prov.folderFor(spec, wsRoot);
153
+ if (!existsSync(dir)) throw new Error(`workspace not provisioned: ${dir} (ay ws new ${operand})`);
154
+ return {
155
+ dir,
156
+ spec
157
+ };
158
+ };
159
+ if (mode === "path") return asPath();
160
+ if (mode === "spec") return asSpec();
161
+ if (existsSync(path.resolve(operand))) return asPath();
162
+ return asSpec();
163
+ }
164
+ /**
165
+ * Default source for `ws fork`: the calling agent's registered cwd when run
166
+ * inside an agent (AGENT_YES_PID is the wrapper pid of the enclosing agent),
167
+ * else the current directory.
168
+ */
169
+ async function defaultForkFrom() {
170
+ const envPid = Number(process.env.AGENT_YES_PID);
171
+ if (envPid > 0) {
172
+ const recs = await listRecords(void 0, {
173
+ all: true,
174
+ active: false,
175
+ json: false,
176
+ latest: false,
177
+ cwdScope: null
178
+ });
179
+ const self = recs.find((r) => r.wrapper_pid === envPid) ?? recs.find((r) => r.pid === envPid);
180
+ if (self?.cwd) return self.cwd;
181
+ }
182
+ return process.cwd();
183
+ }
184
+ function parseFlags(args, known) {
185
+ const flags = {};
186
+ const positional = [];
187
+ for (let i = 0; i < args.length; i++) {
188
+ const a = args[i];
189
+ if (!a.startsWith("--")) {
190
+ positional.push(a);
191
+ continue;
192
+ }
193
+ const eq = a.indexOf("=");
194
+ const name = eq === -1 ? a.slice(2) : a.slice(2, eq);
195
+ const kind = known[name];
196
+ if (!kind) throw new Error(`unknown flag --${name}`);
197
+ if (kind === "bool") {
198
+ if (eq !== -1) throw new Error(`--${name} takes no value`);
199
+ flags[name] = true;
200
+ } else {
201
+ const v = eq !== -1 ? a.slice(eq + 1) : args[++i];
202
+ if (v === void 0) throw new Error(`--${name} requires a value`);
203
+ flags[name] = v;
204
+ }
205
+ }
206
+ return {
207
+ flags,
208
+ positional
209
+ };
210
+ }
211
+ async function cmdWsLs(args) {
212
+ const { flags, positional } = parseFlags(args, {
213
+ status: "bool",
214
+ json: "bool"
215
+ });
216
+ if (positional.length > 0) throw new Error(`ws ls takes no positional args`);
217
+ const prov = await loadProvision();
218
+ const wsRoot = prov.resolveWsRoot(getProvisionRoot());
219
+ const [bare, records] = await Promise.all([walkWorkspaces(wsRoot), listRecords(void 0, {
220
+ all: false,
221
+ active: false,
222
+ json: false,
223
+ latest: false,
224
+ cwdScope: null
225
+ })]);
226
+ let entries = bare.map((w) => ({
227
+ ...w,
228
+ agents: { live: liveAgentsIn(records, w.path).length }
229
+ }));
230
+ if (flags.status) entries = await mapBounded(entries, STATUS_CONCURRENCY, async (e) => {
231
+ try {
232
+ return {
233
+ ...e,
234
+ git: await prov.readStatus(e.path)
235
+ };
236
+ } catch (err) {
237
+ return {
238
+ ...e,
239
+ git: null,
240
+ gitError: err.message.slice(0, 200)
241
+ };
242
+ }
243
+ });
244
+ if (flags.json) {
245
+ process.stdout.write(JSON.stringify({
246
+ schema: WS_JSON_SCHEMA,
247
+ wsRoot,
248
+ workspaces: entries
249
+ }, null, 2) + "\n");
250
+ return 0;
251
+ }
252
+ if (entries.length === 0) {
253
+ process.stderr.write(`no workspaces under ${wsRoot} (layout <owner>/<repo>/tree/<branch>)\n`);
254
+ return 0;
255
+ }
256
+ const specOf = (e) => `${e.owner}/${e.repo}@${e.branch}`;
257
+ const specW = Math.max(9, ...entries.map((e) => specOf(e).length));
258
+ const agentsW = 6;
259
+ const header = flags.status ? `${"WORKSPACE".padEnd(specW)} ${"AGENTS".padEnd(agentsW)} GIT\n` : `${"WORKSPACE".padEnd(specW)} ${"AGENTS".padEnd(agentsW)} PATH\n`;
260
+ process.stdout.write(header);
261
+ for (const e of entries) {
262
+ const agents = e.agents.live > 0 ? String(e.agents.live) : "-";
263
+ const tail = flags.status ? gitSummary(e) : e.path;
264
+ process.stdout.write(`${specOf(e).padEnd(specW)} ${agents.padEnd(agentsW)} ${tail}\n`);
265
+ }
266
+ if (entries.some((e) => e.agents.live > 0)) process.stderr.write(`\n ay ls --cwd <path> # the agents inside a workspace\n`);
267
+ return 0;
268
+ }
269
+ function gitSummary(e) {
270
+ if (e.gitError) return `error: ${e.gitError}`;
271
+ const g = e.git;
272
+ if (!g) return "?";
273
+ const parts = [];
274
+ if (g.dirty) parts.push("dirty");
275
+ if (g.ahead > 0) parts.push(`ahead ${g.ahead}`);
276
+ if (g.behind > 0) parts.push(`behind ${g.behind}`);
277
+ if (!g.hasUpstream) parts.push("no-upstream");
278
+ return parts.length ? parts.join(", ") : "clean";
279
+ }
280
+ async function cmdWsStatus(args) {
281
+ const { flags, positional } = parseFlags(args, {
282
+ json: "bool",
283
+ path: "bool",
284
+ spec: "bool"
285
+ });
286
+ if (flags.path && flags.spec) throw new Error("--path and --spec are mutually exclusive");
287
+ if (positional.length > 1) throw new Error("ws status takes at most one target");
288
+ const prov = await loadProvision();
289
+ const wsRoot = getProvisionRoot();
290
+ const { dir, spec } = await resolveOperand(prov, positional[0] ?? ".", flags.path ? "path" : flags.spec ? "spec" : "auto", wsRoot);
291
+ const git = await prov.readStatus(dir);
292
+ const layoutSpec = spec ?? (() => {
293
+ const root = prov.resolveWsRoot(wsRoot);
294
+ if (!isPathInside(root, dir)) return null;
295
+ const segs = path.relative(root, dir).split(path.sep);
296
+ return segs.length >= 4 && segs[2] === "tree" ? {
297
+ owner: segs[0],
298
+ repo: segs[1],
299
+ branch: segs.slice(3).join("/")
300
+ } : null;
301
+ })();
302
+ const entry = {
303
+ owner: layoutSpec?.owner ?? "",
304
+ repo: layoutSpec?.repo ?? "",
305
+ branch: layoutSpec?.branch ?? git.branch,
306
+ path: dir,
307
+ agents: { live: liveAgentsIn(await listRecords(void 0, {
308
+ all: false,
309
+ active: false,
310
+ json: false,
311
+ latest: false,
312
+ cwdScope: null
313
+ }), dir).length },
314
+ git
315
+ };
316
+ if (flags.json) {
317
+ process.stdout.write(JSON.stringify({
318
+ schema: WS_JSON_SCHEMA,
319
+ workspace: entry
320
+ }, null, 2) + "\n");
321
+ return 0;
322
+ }
323
+ process.stdout.write(`${dir}\n` + (layoutSpec ? ` spec: ${layoutSpec.owner}/${layoutSpec.repo}@${layoutSpec.branch}\n` : "") + ` branch: ${git.branch} @ ${git.head}\n state: ${gitSummary(entry)}\n agents: ${entry.agents.live} live\n`);
324
+ if (entry.agents.live > 0) process.stderr.write(`\n ay ls --cwd ${dir}\n`);
325
+ return 0;
326
+ }
327
+ async function cmdWsNew(args) {
328
+ const { flags, positional } = parseFlags(args, { create: "bool" });
329
+ const source = positional[0];
330
+ if (!source || positional.length > 1) throw new Error("usage: ay ws new <owner>/<repo>[@branch] [--create]");
331
+ const prov = await loadProvision();
332
+ const wsRoot = getProvisionRoot();
333
+ const spec = prov.parseSource(source);
334
+ if (!spec) throw new Error(`cannot parse "${source}" as a source (owner/repo[@branch] or URL)`);
335
+ process.stderr.write(`provisioning ${spec.owner}/${spec.repo}@${spec.branch} …\n`);
336
+ let res = await prov.provision(spec, { wsRoot });
337
+ if (!res.ok && res.reason === "branch-not-found" && flags.create) {
338
+ process.stderr.write(`branch not found on remote — creating it locally (--create)\n`);
339
+ res = await prov.createBranch(spec, { wsRoot });
340
+ }
341
+ if (!res.ok) {
342
+ process.stderr.write(`provision failed (${res.reason ?? "error"}): ${res.error ?? "unknown"}\n` + (res.reason === "branch-not-found" && !flags.create ? ` (branch missing on the remote — re-run with --create to branch off the default)\n` : ""));
343
+ return 1;
344
+ }
345
+ process.stdout.write(`${res.action} ${res.folder}\n`);
346
+ return 0;
347
+ }
348
+ async function cmdWsFork(args) {
349
+ const { flags, positional } = parseFlags(args, {
350
+ from: "value",
351
+ wip: "bool"
352
+ });
353
+ const branch = positional[0];
354
+ if (!branch || positional.length > 1) throw new Error("usage: ay ws fork <new-branch> [--from <path>] [--wip]");
355
+ const prov = await loadProvision();
356
+ const fromCwd = path.resolve(typeof flags.from === "string" ? flags.from : await defaultForkFrom());
357
+ process.stderr.write(`forking ${fromCwd} → branch ${branch}${flags.wip ? " (with WIP)" : ""} …\n`);
358
+ const res = await prov.forkWorktree({
359
+ fromCwd,
360
+ branch,
361
+ wsRoot: getProvisionRoot(),
362
+ wip: !!flags.wip
363
+ });
364
+ if (!res.ok) {
365
+ process.stderr.write(`fork failed: ${res.error ?? "unknown"}\n`);
366
+ return 1;
367
+ }
368
+ process.stdout.write(`${res.action} ${res.folder}\n`);
369
+ process.stderr.write(`\n ay claude --cwd ${res.folder} -- "<prompt>" # work there\n`);
370
+ return 0;
371
+ }
372
+ function wsHelp() {
373
+ process.stdout.write("ay ws - workspaces under <wsRoot>/<owner>/<repo>/tree/<branch>\n\n ay ws ls [--status] [--json] list workspaces (+git state, +live agent count)\n ay ws status [<spec|path>] [--json] one workspace's git state (default: cwd)\n ay ws new <owner>/<repo>[@branch] clone/refresh a workspace (--create: new branch)\n ay ws fork <branch> [--from <path>] sibling worktree off HEAD (--wip: carry changes)\n\n targets: owner/repo, owner/repo@branch, owner/repo/tree/branch, github URL, or a path\n wsRoot: CODEHOST_WS_ROOT > config provisionRoot > ~/ws\n");
374
+ return 0;
375
+ }
376
+ /** `ay ws <sub> …` dispatcher (called from runSubcommand). */
377
+ async function cmdWs(args) {
378
+ const sub = args[0];
379
+ const rest = args.slice(1);
380
+ switch (sub) {
381
+ case "ls":
382
+ case "list": return cmdWsLs(rest);
383
+ case "status": return cmdWsStatus(rest);
384
+ case "new": return cmdWsNew(rest);
385
+ case "fork": return cmdWsFork(rest);
386
+ case void 0:
387
+ case "help":
388
+ case "--help":
389
+ case "-h": return wsHelp();
390
+ default:
391
+ process.stderr.write(`ay ws: unknown subcommand "${sub}"\n\n`);
392
+ wsHelp();
393
+ return 1;
394
+ }
395
+ }
396
+
397
+ //#endregion
398
+ export { cmdWs };
399
+ //# sourceMappingURL=ws-BpHPhcPH.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-yes",
3
- "version": "1.196.3",
3
+ "version": "1.198.0",
4
4
  "description": "A wrapper tool that automates interactions with various AI CLI tools by automatically handling common prompts and responses.",
5
5
  "keywords": [
6
6
  "ai",
@@ -1,5 +1,15 @@
1
1
  import { describe, expect, it } from "vitest";
2
- import { AUTO_RETRY_MAX_DELAY_SECS, autoRetryBackoffMs, shouldFireRetry } from "./autoRetry.ts";
2
+ import {
3
+ AUTO_RETRY_MAX_DELAY_SECS,
4
+ AUTO_RETRY_REASON_FALLBACK,
5
+ AUTO_RETRY_REASONS,
6
+ autoRetryBackoffMs,
7
+ buildAutoRetryMessage,
8
+ classifyAutoRetryReason,
9
+ formatDurationSecs,
10
+ shouldFireRetry,
11
+ } from "./autoRetry.ts";
12
+ import { loadSharedCliDefaults } from "./configShared.ts";
3
13
 
4
14
  describe("autoRetryBackoffMs", () => {
5
15
  it("doubles 8,16,32,…,256 then caps", () => {
@@ -32,3 +42,82 @@ describe("shouldFireRetry", () => {
32
42
  expect(shouldFireRetry(false, true, 10_000, 5_000)).toBe(true);
33
43
  });
34
44
  });
45
+
46
+ describe("classifyAutoRetryReason", () => {
47
+ it("maps real banners to inert paraphrases", () => {
48
+ expect(classifyAutoRetryReason("● API Error: 529 Overloaded")).toBe(AUTO_RETRY_REASONS[0]);
49
+ expect(classifyAutoRetryReason("API Error: Response stalled mid-stream.")).toBe(
50
+ AUTO_RETRY_REASONS[1],
51
+ );
52
+ expect(classifyAutoRetryReason("API Error: Connection closed mid-response.")).toBe(
53
+ AUTO_RETRY_REASONS[2],
54
+ );
55
+ expect(classifyAutoRetryReason("Claude usage limit reached")).toBe(AUTO_RETRY_REASONS[3]);
56
+ expect(classifyAutoRetryReason("You are being rate-limited")).toBe(AUTO_RETRY_REASONS[4]);
57
+ expect(classifyAutoRetryReason("API Error: 503 Service Unavailable")).toBe(
58
+ AUTO_RETRY_REASON_FALLBACK,
59
+ );
60
+ });
61
+ });
62
+
63
+ describe("formatDurationSecs", () => {
64
+ it("renders s / m / h forms", () => {
65
+ expect(formatDurationSecs(0)).toBe("0s");
66
+ expect(formatDurationSecs(45)).toBe("45s");
67
+ expect(formatDurationSecs(60)).toBe("1m");
68
+ expect(formatDurationSecs(200)).toBe("3m20s");
69
+ expect(formatDurationSecs(3600)).toBe("1h");
70
+ expect(formatDurationSecs(3900)).toBe("1h05m");
71
+ expect(formatDurationSecs(8 * 3600)).toBe("8h");
72
+ });
73
+ });
74
+
75
+ describe("buildAutoRetryMessage", () => {
76
+ it("is one line and carries attempt/reason/backoff context + ignore clause", () => {
77
+ const msg = buildAutoRetryMessage(3, AUTO_RETRY_REASONS[0], 45, 64);
78
+ expect(msg).not.toContain("\n");
79
+ expect(msg.startsWith("retry [")).toBe(true);
80
+ expect(msg).toContain("#3");
81
+ expect(msg).toContain("45s ago");
82
+ expect(msg).toContain("in 1m04s");
83
+ expect(msg).toContain("giving up after 8h");
84
+ expect(msg).toContain("ignore it");
85
+ });
86
+
87
+ // Self-trigger guard: the message is typed into the PTY and stays visible in
88
+ // the transcript, so if it ever matched a CLI's own screen-scrape patterns
89
+ // it would re-arm the retry loop (autoRetry), block the streak reset, kill
90
+ // the session (fatal), or fake a state (working/enter/needsInput). Mirrors
91
+ // rs/src/config.rs test_auto_retry_message_is_inert_against_all_cli_patterns.
92
+ it("never matches any CLI's screen-scrape patterns", async () => {
93
+ const clis = await loadSharedCliDefaults();
94
+ expect(Object.keys(clis).length).toBeGreaterThan(0);
95
+ for (const [cliName, conf] of Object.entries(clis)) {
96
+ for (const reason of AUTO_RETRY_REASONS) {
97
+ const cases: [number, number, number][] = [
98
+ [1, 0, 8],
99
+ [5, 500, 128],
100
+ [12, 30_000, 256],
101
+ ];
102
+ for (const [attempt, since, next] of cases) {
103
+ const msg = buildAutoRetryMessage(attempt, reason, since, next);
104
+ const groups: [string, RegExp[] | undefined][] = [
105
+ ["autoRetry", conf.autoRetry],
106
+ ["fatal", conf.fatal],
107
+ ["enter", conf.enter],
108
+ ["working", conf.working],
109
+ ["needsInput", conf.needsInput],
110
+ ];
111
+ for (const [kind, patterns] of groups) {
112
+ for (const rx of patterns ?? []) {
113
+ expect(
114
+ rx.test(msg),
115
+ `retry message must not match ${cliName}.${kind} /${rx}/: ${msg}`,
116
+ ).toBe(false);
117
+ }
118
+ }
119
+ }
120
+ }
121
+ }
122
+ });
123
+ });
package/ts/autoRetry.ts CHANGED
@@ -37,3 +37,79 @@ export function shouldFireRetry(
37
37
  ): boolean {
38
38
  return !working && ready && idleMs >= minIdleMs;
39
39
  }
40
+
41
+ /** Fallback reason when no autoRetry match was captured before firing. */
42
+ export const AUTO_RETRY_REASON_FALLBACK = "a transient server-side error";
43
+
44
+ /**
45
+ * All reason strings `classifyAutoRetryReason` can produce — used by the
46
+ * self-trigger guard spec to prove every possible typed message is inert
47
+ * against every CLI's screen-scrape patterns. Mirrors Rust's RETRY_REASONS.
48
+ */
49
+ export const AUTO_RETRY_REASONS = [
50
+ "the model backend reported it is temporarily busy",
51
+ "the response stream stalled mid-way",
52
+ "the connection dropped mid-response",
53
+ "a usage cap was reached (it may need time to reset)",
54
+ "requests are being throttled by the server",
55
+ AUTO_RETRY_REASON_FALLBACK,
56
+ ] as const;
57
+
58
+ /**
59
+ * Paraphrase the matched recoverable-error banner into a short reason.
60
+ *
61
+ * Deliberately NEVER echoes the raw banner wording ("API Error", "Overloaded",
62
+ * "rate limit", …): the built message is typed into the PTY and stays visible
63
+ * in the transcript, where raw wording would re-match the autoRetry patterns —
64
+ * keeping the error state true forever, which blocks the streak reset on
65
+ * recovery. Mirrors Rust's classify_retry_reason.
66
+ */
67
+ export function classifyAutoRetryReason(screen: string): string {
68
+ const s = screen.toLowerCase();
69
+ if (s.includes("overload")) return AUTO_RETRY_REASONS[0];
70
+ if (s.includes("stalled")) return AUTO_RETRY_REASONS[1];
71
+ if (s.includes("connection closed")) return AUTO_RETRY_REASONS[2];
72
+ if (s.includes("usage limit") || s.includes("session limit")) return AUTO_RETRY_REASONS[3];
73
+ if (s.includes("rate") && s.includes("limit")) return AUTO_RETRY_REASONS[4];
74
+ return AUTO_RETRY_REASON_FALLBACK;
75
+ }
76
+
77
+ /** Human-readable duration: "45s", "3m20s", "1h04m", "8h". Mirrors Rust's fmt_dur_secs. */
78
+ export function formatDurationSecs(total: number): string {
79
+ const t = Math.max(0, Math.floor(total));
80
+ if (t < 60) return `${t}s`;
81
+ if (t < 3600) {
82
+ const m = Math.floor(t / 60);
83
+ const s = t % 60;
84
+ return s === 0 ? `${m}m` : `${m}m${String(s).padStart(2, "0")}s`;
85
+ }
86
+ const h = Math.floor(t / 3600);
87
+ const m = Math.floor((t % 3600) / 60);
88
+ return m === 0 ? `${h}h` : `${h}h${String(m).padStart(2, "0")}m`;
89
+ }
90
+
91
+ /**
92
+ * The single line typed into the agent's prompt instead of a bare "retry":
93
+ * says WHO is nudging (agent-yes, automated), WHY (paraphrased reason), and
94
+ * the backoff state (attempt #, elapsed, next delay, give-up horizon), plus an
95
+ * explicit "ignore if nothing failed" clause so an agent that already
96
+ * recovered — or is mid-question — doesn't burn a turn asking what "retry"
97
+ * refers to. Must stay one line (it is submitted with a single "\r").
98
+ * Mirrors Rust's build_retry_message in rs/src/context.rs.
99
+ */
100
+ export function buildAutoRetryMessage(
101
+ attempt: number,
102
+ reason: string,
103
+ sinceFirstSecs: number,
104
+ nextBackoffSecs: number,
105
+ ): string {
106
+ const since = formatDurationSecs(sinceFirstSecs);
107
+ const next = formatDurationSecs(nextBackoffSecs);
108
+ const giveUp = formatDurationSecs(AUTO_RETRY_GIVE_UP_MS / 1000);
109
+ return (
110
+ `retry [auto-retry #${attempt} by agent-yes: ${reason}; first seen ${since} ago; ` +
111
+ `if this attempt fails too, the next nudge comes in ${next} (giving up after ${giveUp}). ` +
112
+ `This is an automated recovery nudge - if no request actually failed, ignore it and ` +
113
+ `simply continue your previous task.]`
114
+ );
115
+ }
package/ts/index.ts CHANGED
@@ -21,9 +21,14 @@ import { sendEnter, sendMessage } from "./core/messaging.ts";
21
21
  import {
22
22
  AUTO_RETRY_GIVE_UP_MS,
23
23
  AUTO_RETRY_MIN_IDLE_MS,
24
+ AUTO_RETRY_REASON_FALLBACK,
24
25
  autoRetryBackoffMs,
26
+ buildAutoRetryMessage,
27
+ classifyAutoRetryReason,
28
+ formatDurationSecs,
25
29
  shouldFireRetry,
26
30
  } from "./autoRetry.ts";
31
+ import { recordInbox } from "./messageLog.ts";
27
32
  import {
28
33
  initializeLogPaths,
29
34
  setupDebugLogging,
@@ -794,6 +799,9 @@ export default async function agentYes({
794
799
  let retryStartedAt: number | null = null;
795
800
  let retryNextAt: number | null = null;
796
801
  let autoRetryScreen = "";
802
+ // Paraphrased reason captured when the error banner was matched — folded
803
+ // into the typed retry message so the agent knows why it is being nudged.
804
+ let retryReason: string | null = null;
797
805
  const heartbeatInterval = setInterval(async () => {
798
806
  try {
799
807
  const rendered = removeControlCharacters(xtermProxy.tail(12));
@@ -822,15 +830,36 @@ export default async function agentYes({
822
830
  retryNextAt = now + 500; // busy / not at prompt / still active — re-check shortly
823
831
  } else {
824
832
  retryStreak += 1;
825
- logger.warn(`[${cli}-yes] auto-retry: typing 'retry' (attempt ${retryStreak})`);
826
- // Write "retry" + Enter atomically (mirrors rs do_send_retry); using
833
+ const nextBackoffMs = autoRetryBackoffMs(retryStreak);
834
+ const reason = retryReason ?? AUTO_RETRY_REASON_FALLBACK;
835
+ const sinceFirstSecs = retryStartedAt === null ? 0 : (now - retryStartedAt) / 1000;
836
+ const line = buildAutoRetryMessage(
837
+ retryStreak,
838
+ reason,
839
+ sinceFirstSecs,
840
+ nextBackoffMs / 1000,
841
+ );
842
+ logger.warn(
843
+ `[${cli}-yes] auto-retry: typing retry nudge (attempt ${retryStreak}, reason: ${reason})`,
844
+ );
845
+ // Write the nudge + Enter atomically (mirrors rs do_send_retry); using
827
846
  // sendMessage would split text/Enter across the fast heartbeat ticks.
828
- ctx.messageContext.shell.write("retry\r");
847
+ ctx.messageContext.shell.write(line + "\r");
829
848
  ctx.idleWaiter.ping();
849
+ // Structured trace for `ay msgs` / the console (mirrors the Rust
850
+ // runtime's record_auto_retry_inbox) — best-effort, never blocks.
851
+ void recordInbox({
852
+ at: now,
853
+ from: null,
854
+ to: { pid: process.pid, cli, cwd: workingDir },
855
+ kind: "auto-retry",
856
+ body: `${reason} (attempt ${retryStreak}, next backoff ${formatDurationSecs(nextBackoffMs / 1000)})`,
857
+ wrapped: false,
858
+ });
830
859
  // Self-schedule the next retry with escalated backoff. (Leaving nextAt
831
860
  // null and re-arming from the stdout pipeline would tight-loop while the
832
861
  // error banner stays on screen.) Reset on recovery cancels this.
833
- retryNextAt = now + autoRetryBackoffMs(retryStreak);
862
+ retryNextAt = now + nextBackoffMs;
834
863
  }
835
864
  }
836
865
  }
@@ -1185,6 +1214,10 @@ export default async function agentYes({
1185
1214
  const errVisible = conf.autoRetry.some((rx: RegExp) => rx.test(rendered));
1186
1215
  const readyVisible = conf.ready?.some((rx: RegExp) => rx.test(rendered)) ?? false;
1187
1216
  if (errVisible && readyVisible) {
1217
+ // Remember WHY (paraphrased — see classifyAutoRetryReason) so
1218
+ // the typed message can explain itself. Refresh on every match:
1219
+ // the banner may change across attempts (e.g. overload → 5xx).
1220
+ retryReason = classifyAutoRetryReason(rendered);
1188
1221
  if (retryNextAt === null) {
1189
1222
  if (retryStartedAt === null) retryStartedAt = Date.now();
1190
1223
  const delayMs = autoRetryBackoffMs(retryStreak);
package/ts/messageLog.ts CHANGED
@@ -39,8 +39,9 @@ export interface MessageRecord {
39
39
  to: MailParty;
40
40
  /** What kind of stdin write this was. Omitted for a normal `ay send` text
41
41
  * message; "key" for raw keystrokes (`ay key`), "select" for a menu pick
42
- * (`ay select`). For key/select the `body` holds the key names / choice. */
43
- kind?: "key" | "select";
42
+ * (`ay select`), "auto-retry" for the wrapper's own recoverable-error nudge
43
+ * (from is null; `body` holds the paraphrased reason + backoff state). */
44
+ kind?: "key" | "select" | "auto-retry";
44
45
  /** The message body (without the `[ay-msg …]` wrapper), or — for a key/select
45
46
  * record — the keystroke names / chosen option. */
46
47
  body: string;