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.
- package/default.config.yaml +51 -12
- package/dist/SUPPORTED_CLIS-Bhb4JKPv.js +9 -0
- package/dist/{SUPPORTED_CLIS-D3knhJ6_.js → SUPPORTED_CLIS-BuScRhc9.js} +2 -2
- package/dist/{agent-yes.config-ugPYTPOd.js → agent-yes.config-whqkqq9P.js} +2 -2
- package/dist/{agentShare-M6FE9AsI.js → agentShare-CD1AIPMR.js} +8 -7
- package/dist/cli.js +8 -8
- package/dist/{configShared-0MnIQ652.js → configShared-aKTg-sa5.js} +1 -1
- package/dist/{e2e-BeKjLhmO.js → e2e-jb0Hp43q.js} +1 -1
- package/dist/{expose-B3AGhVhM.js → expose-k9l6ZJDA.js} +1 -1
- package/dist/{forkNested-C1kKsXi5.js → forkNested-DOyDAHnC.js} +1 -1
- package/dist/index.js +3 -2
- package/dist/messageLog-CxrKJj77.js +94 -0
- package/dist/{nodeRuntime-DZRjxpNp.js → nodeRuntime-CwNJuwH5.js} +1 -1
- package/dist/{notifyDaemon-DgxP6GAn.js → notifyDaemon-DYoMC4tA.js} +7 -6
- package/dist/{openBrowser-DQVkxCyj.js → openBrowser-CSj21pZE.js} +1 -1
- package/dist/{reaper-CWF2_ATd.js → reaper-BUHCyxdF.js} +1 -1
- package/dist/{reaper-jfSmog15.js → reaper-CJSu8UVZ.js} +1 -1
- package/dist/{remotes-CW-yxSgW.js → remotes-CXqYR0p8.js} +3 -3
- package/dist/{remotes-oSmwSYaV.js → remotes-Cim0dBU7.js} +3 -3
- package/dist/{rustBinary-C9q17obe.js → rustBinary-DLI26u3O.js} +2 -2
- package/dist/{schedule-SisMmURQ.js → schedule-DAl2mCc9.js} +7 -6
- package/dist/{serve-BOrkFR6H.js → serve-9jP4V1l_.js} +26 -25
- package/dist/{setup-CFbckIlf.js → setup-B55UN6xV.js} +3 -3
- package/dist/{share-29YbzpbU.js → share-BfIU8t_h.js} +2 -2
- package/dist/{share-nvqy64A2.js → share-Ciw1mWVN.js} +2 -2
- package/dist/{spawnGate-CWbdnJ5K.js → spawnGate-BkjeS4tg.js} +1 -1
- package/dist/{spawnGate-DRWa_Fzj.js → spawnGate-CXjEz6Pf.js} +2 -2
- package/dist/{subcommands-CMX-dej0.js → subcommands-D7AmlO_6.js} +24 -105
- package/dist/{subcommands-DSbYPDt5.js → subcommands-lUi22sO5.js} +6 -5
- package/dist/{tray-BKTZ5gIf.js → tray-BSFi35Qg.js} +1 -1
- package/dist/{ts-Bqa5HjY3.js → ts-D9RFyHqn.js} +84 -7
- package/dist/{versionChecker-ZIgviJ62.js → versionChecker-DQQIK4Og.js} +2 -2
- package/dist/{webrtcLink-BG0Xc4-W.js → webrtcLink-B7REGtK2.js} +2 -2
- package/dist/{webrtcRemote-BmuVKcrx.js → webrtcRemote-CvIEfWX8.js} +3 -3
- package/dist/{workspaceConfig-_GtAZtsi.js → workspaceConfig-CjaRvTjf.js} +1 -1
- package/dist/ws-BpHPhcPH.js +399 -0
- package/package.json +1 -1
- package/ts/autoRetry.spec.ts +90 -1
- package/ts/autoRetry.ts +76 -0
- package/ts/index.ts +37 -4
- package/ts/messageLog.ts +3 -2
- package/ts/subcommands.ts +20 -6
- package/ts/ws.spec.ts +435 -0
- package/ts/ws.ts +522 -0
- 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
package/ts/autoRetry.spec.ts
CHANGED
|
@@ -1,5 +1,15 @@
|
|
|
1
1
|
import { describe, expect, it } from "vitest";
|
|
2
|
-
import {
|
|
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
|
-
|
|
826
|
-
|
|
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("
|
|
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 +
|
|
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`)
|
|
43
|
-
|
|
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;
|