claude-yes 1.167.0 → 1.169.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-DFBATC85.js → SUPPORTED_CLIS-BhtgiqTC.js} +2 -2
- package/dist/SUPPORTED_CLIS-D-ea9sTR.js +8 -0
- package/dist/cli.js +47 -6
- package/dist/{e2e-Bfw7qL9O.js → e2e-BeKjLhmO.js} +1 -1
- package/dist/forkNested-UCKPEgSI.js +59 -0
- package/dist/index.js +2 -2
- package/dist/{openBrowser-CuOlFtow.js → openBrowser-CCF1iuQK.js} +1 -1
- package/dist/{remotes-T6nf0t3K.js → remotes-BmAPylU_.js} +3 -3
- package/dist/{remotes-ClT1bq16.js → remotes-cx_GDFPj.js} +3 -3
- package/dist/{runningLock-V4qvXgAw.js → runningLock-BobVW1_A.js} +2 -1
- package/dist/{schedule-C2RMA3qm.js → schedule-CQge8oP_.js} +5 -5
- package/dist/{serve-mLqsRwfR.js → serve-tpnNSWeN.js} +34 -15
- package/dist/{setup-BtqKZx3q.js → setup-BSAdV0mz.js} +3 -3
- package/dist/{share-BjqQBWM-.js → share-D_e2Fwiy.js} +2 -2
- package/dist/{spawnGate-UH73I2le.js → spawnGate-CQ1Il3Xk.js} +1 -1
- package/dist/{spawnGate-B_VDMXYL.js → spawnGate-DzPfa1PZ.js} +2 -2
- package/dist/subcommands-B4Pwp2Z_.js +9 -0
- package/dist/{subcommands-DWeo7ZLc.js → subcommands-BV32LcSR.js} +45 -11
- package/dist/{tray-CZarCA2Q.js → tray-CWUpaZF4.js} +2 -2
- package/dist/{ts-s3wYccKf.js → ts-DBYdQgbR.js} +39 -18
- package/dist/{versionChecker-BvR6tV3u.js → versionChecker-CL0RTOKo.js} +2 -2
- package/dist/{webrtcLink-CBZkZ-LT.js → webrtcLink-BG0Xc4-W.js} +2 -2
- package/dist/{webrtcRemote-GAgF5K45.js → webrtcRemote-SybKurg9.js} +3 -3
- package/dist/{workspaceConfig-D3OH7and.js → workspaceConfig-BC03X4Y1.js} +1 -1
- package/lab/ui/console-logic.js +16 -0
- package/lab/ui/index.html +28 -0
- package/package.json +1 -1
- package/ts/autoRetry.spec.ts +16 -1
- package/ts/autoRetry.ts +23 -0
- package/ts/badges.spec.ts +54 -0
- package/ts/badges.ts +42 -0
- package/ts/cli.ts +46 -0
- package/ts/forkNested.spec.ts +34 -0
- package/ts/forkNested.ts +64 -0
- package/ts/idleWaiter.spec.ts +12 -0
- package/ts/idleWaiter.ts +5 -0
- package/ts/index.ts +23 -5
- package/ts/needsInput.ts +1 -1
- package/ts/parseCliArgs.spec.ts +18 -0
- package/ts/parseCliArgs.ts +8 -0
- package/ts/removeControlCharacters.spec.ts +21 -0
- package/ts/removeControlCharacters.ts +32 -6
- package/ts/runningLock.ts +1 -0
- package/ts/serve.ts +24 -0
- package/ts/subcommands.spec.ts +22 -0
- package/ts/subcommands.ts +40 -13
- package/dist/SUPPORTED_CLIS-DLFqRE8W.js +0 -8
- package/dist/subcommands-B-WoBVhk.js +0 -9
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { n as logger, t as addTransport } from "./logger-CDIsZ-Pp.js";
|
|
2
|
-
import { r as getInstalledPackage } from "./versionChecker-
|
|
2
|
+
import { r as getInstalledPackage } from "./versionChecker-CL0RTOKo.js";
|
|
3
3
|
import { t as agentYesHome } from "./agentYesHome-_eJa3DaX.js";
|
|
4
|
-
import { i as shouldUseLock, r as releaseLock, t as acquireLock } from "./runningLock-
|
|
4
|
+
import { i as shouldUseLock, r as releaseLock, t as acquireLock } from "./runningLock-BobVW1_A.js";
|
|
5
5
|
import { t as PidStore } from "./pidStore-BfoBWUjc.js";
|
|
6
6
|
import { i as readGlobalPids } from "./globalPidIndex-DlmmJlO8.js";
|
|
7
7
|
import { n as register, r as sweep } from "./reaper-C-eWAxIj.js";
|
|
@@ -289,8 +289,14 @@ var pty_default = pty;
|
|
|
289
289
|
|
|
290
290
|
//#endregion
|
|
291
291
|
//#region ts/removeControlCharacters.ts
|
|
292
|
+
const ESC = String.fromCharCode(27);
|
|
293
|
+
const C1 = String.fromCharCode(155);
|
|
294
|
+
const BACKSLASH = String.fromCharCode(92);
|
|
295
|
+
const ST_PATTERN_SOURCE = ESC + BACKSLASH + BACKSLASH;
|
|
296
|
+
const OSC_PATTERN = new RegExp(ESC + "][^\x07\x1B]*(?:\x07|" + ST_PATTERN_SOURCE + ")", "g");
|
|
297
|
+
const CSI_PATTERN = new RegExp("[" + ESC + C1 + "][[()#;?]*(?:[0-9]{1,4}(?:;[0-9]{0,4})*)?[0-9A-ORZcf-nqry=><]", "g");
|
|
292
298
|
function removeControlCharacters(str) {
|
|
293
|
-
return str.replace(
|
|
299
|
+
return str.replace(OSC_PATTERN, "").replace(CSI_PATTERN, "");
|
|
294
300
|
}
|
|
295
301
|
|
|
296
302
|
//#endregion
|
|
@@ -474,6 +480,10 @@ var IdleWaiter = class {
|
|
|
474
480
|
this.lastActivityTime = Date.now();
|
|
475
481
|
return this;
|
|
476
482
|
}
|
|
483
|
+
/** Milliseconds since the last ping. */
|
|
484
|
+
idleTimeMs() {
|
|
485
|
+
return Date.now() - this.lastActivityTime;
|
|
486
|
+
}
|
|
477
487
|
async wait(ms) {
|
|
478
488
|
while (this.lastActivityTime >= Date.now() - ms) await new Promise((resolve) => setTimeout(resolve, this.checkInterval));
|
|
479
489
|
}
|
|
@@ -511,8 +521,12 @@ async function sendEnter(context, waitms = 1e3) {
|
|
|
511
521
|
logger.debug(`sendEnter| idleWait took ${String(Date.now() - st)}ms`);
|
|
512
522
|
context.nextStdout.unready();
|
|
513
523
|
context.shell.write("\r");
|
|
524
|
+
context.idleWaiter.ping();
|
|
514
525
|
for (const ms of [1e3, 3e3]) await Promise.race([context.nextStdout.wait(), new Promise((resolve) => setTimeout(() => {
|
|
515
|
-
if (!context.nextStdout.isReady)
|
|
526
|
+
if (!context.nextStdout.isReady) {
|
|
527
|
+
context.shell.write("\r");
|
|
528
|
+
context.idleWaiter.ping();
|
|
529
|
+
}
|
|
516
530
|
resolve();
|
|
517
531
|
}, ms))]);
|
|
518
532
|
}
|
|
@@ -543,11 +557,21 @@ async function sendMessage(context, message, { waitForReady = true } = {}) {
|
|
|
543
557
|
const AUTO_RETRY_BASE_SECS = 8;
|
|
544
558
|
const AUTO_RETRY_MAX_DELAY_SECS = 256;
|
|
545
559
|
const AUTO_RETRY_GIVE_UP_MS = 8 * 3600 * 1e3;
|
|
560
|
+
const AUTO_RETRY_MIN_IDLE_MS = 5e3;
|
|
546
561
|
/** Backoff (ms) before the Nth consecutive auto-retry — doubles, then caps. */
|
|
547
562
|
function autoRetryBackoffMs(streak) {
|
|
548
563
|
const shift = Math.min(streak, 20);
|
|
549
564
|
return Math.min(AUTO_RETRY_BASE_SECS * 2 ** shift, AUTO_RETRY_MAX_DELAY_SECS) * 1e3;
|
|
550
565
|
}
|
|
566
|
+
/**
|
|
567
|
+
* Whether a scheduled auto-retry may actually fire: the agent must be sitting
|
|
568
|
+
* idle at a ready prompt (not mid-work) AND the terminal must have been quiet
|
|
569
|
+
* for at least `minIdleMs` — see AUTO_RETRY_MIN_IDLE_MS. Mirrors Rust's
|
|
570
|
+
* should_fire_retry in rs/src/context.rs.
|
|
571
|
+
*/
|
|
572
|
+
function shouldFireRetry(working, ready, idleMs, minIdleMs) {
|
|
573
|
+
return !working && ready && idleMs >= minIdleMs;
|
|
574
|
+
}
|
|
551
575
|
|
|
552
576
|
//#endregion
|
|
553
577
|
//#region ts/core/logging.ts
|
|
@@ -1503,17 +1527,13 @@ async function agentYes({ cli, cliArgs = [], skipPermissions = false, prompt, ro
|
|
|
1503
1527
|
retryNextAt = null;
|
|
1504
1528
|
retryStartedAt = null;
|
|
1505
1529
|
retryStreak = 0;
|
|
1506
|
-
} else if (now >= retryNextAt)
|
|
1507
|
-
|
|
1508
|
-
|
|
1509
|
-
|
|
1510
|
-
|
|
1511
|
-
|
|
1512
|
-
|
|
1513
|
-
ctx.messageContext.shell.write("retry\r");
|
|
1514
|
-
ctx.idleWaiter.ping();
|
|
1515
|
-
retryNextAt = now + autoRetryBackoffMs(retryStreak);
|
|
1516
|
-
}
|
|
1530
|
+
} else if (now >= retryNextAt) if (!shouldFireRetry(conf.working?.some((rx) => rx.test(autoRetryScreen)) ?? false, conf.ready?.some((rx) => rx.test(autoRetryScreen)) ?? false, ctx.idleWaiter.idleTimeMs(), AUTO_RETRY_MIN_IDLE_MS)) retryNextAt = now + 500;
|
|
1531
|
+
else {
|
|
1532
|
+
retryStreak += 1;
|
|
1533
|
+
logger.warn(`[${cli}-yes] auto-retry: typing 'retry' (attempt ${retryStreak})`);
|
|
1534
|
+
ctx.messageContext.shell.write("retry\r");
|
|
1535
|
+
ctx.idleWaiter.ping();
|
|
1536
|
+
retryNextAt = now + autoRetryBackoffMs(retryStreak);
|
|
1517
1537
|
}
|
|
1518
1538
|
}
|
|
1519
1539
|
if (rendered === lastHeartbeatRendered) return;
|
|
@@ -1689,10 +1709,11 @@ async function agentYes({ cli, cliArgs = [], skipPermissions = false, prompt, ro
|
|
|
1689
1709
|
writable: new WritableStream({ write: async (data) => {
|
|
1690
1710
|
await ctx.stdinReady.wait();
|
|
1691
1711
|
shell.write(data);
|
|
1712
|
+
ctx.idleWaiter.ping();
|
|
1692
1713
|
} }),
|
|
1693
1714
|
readable: xtermProxy.readable
|
|
1694
|
-
}).forEach(() => {
|
|
1695
|
-
ctx.idleWaiter.ping();
|
|
1715
|
+
}).forEach((chunk) => {
|
|
1716
|
+
if (removeControlCharacters(chunk).trim()) ctx.idleWaiter.ping();
|
|
1696
1717
|
pidStore.updateStatus(shell.pid, "active").catch(() => null);
|
|
1697
1718
|
ctx.nextStdout.ready();
|
|
1698
1719
|
}).forkTo(async function rawLogger(f) {
|
|
@@ -1803,4 +1824,4 @@ function sleep(ms) {
|
|
|
1803
1824
|
|
|
1804
1825
|
//#endregion
|
|
1805
1826
|
export { removeControlCharacters as a, AgentContext as i, agentYes as n, config as r, CLIS_CONFIG as t };
|
|
1806
|
-
//# sourceMappingURL=ts-
|
|
1827
|
+
//# sourceMappingURL=ts-DBYdQgbR.js.map
|
|
@@ -7,7 +7,7 @@ import { fileURLToPath } from "url";
|
|
|
7
7
|
|
|
8
8
|
//#region package.json
|
|
9
9
|
var name = "claude-yes";
|
|
10
|
-
var version = "1.
|
|
10
|
+
var version = "1.169.0";
|
|
11
11
|
|
|
12
12
|
//#endregion
|
|
13
13
|
//#region ts/versionChecker.ts
|
|
@@ -215,4 +215,4 @@ async function displayVersion() {
|
|
|
215
215
|
|
|
216
216
|
//#endregion
|
|
217
217
|
export { versionString as i, displayVersion as n, getInstalledPackage as r, checkAndAutoUpdate as t };
|
|
218
|
-
//# sourceMappingURL=versionChecker-
|
|
218
|
+
//# sourceMappingURL=versionChecker-CL0RTOKo.js.map
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { u as parseSecret } from "./e2e-
|
|
1
|
+
import { u as parseSecret } from "./e2e-BeKjLhmO.js";
|
|
2
2
|
|
|
3
3
|
//#region ts/webrtcLink.ts
|
|
4
4
|
const SIGNAL_SUBPROTOCOL = "ay-signal-1";
|
|
@@ -42,4 +42,4 @@ function isWebrtcSpec(spec) {
|
|
|
42
42
|
|
|
43
43
|
//#endregion
|
|
44
44
|
export { isWebrtcSpec as n, parseWebrtcLink as r, SIGNAL_SUBPROTOCOL as t };
|
|
45
|
-
//# sourceMappingURL=webrtcLink-
|
|
45
|
+
//# sourceMappingURL=webrtcLink-BG0Xc4-W.js.map
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { a as computeTranscriptHash, c as open, d as randomHex, f as seal, l as packEnvelope, n as FLAG_CONFIRM, o as deriveAuthToken, p as unpackEnvelope, s as deriveDirKeys } from "./e2e-
|
|
2
|
-
import { n as isWebrtcSpec, r as parseWebrtcLink, t as SIGNAL_SUBPROTOCOL } from "./webrtcLink-
|
|
1
|
+
import { a as computeTranscriptHash, c as open, d as randomHex, f as seal, l as packEnvelope, n as FLAG_CONFIRM, o as deriveAuthToken, p as unpackEnvelope, s as deriveDirKeys } from "./e2e-BeKjLhmO.js";
|
|
2
|
+
import { n as isWebrtcSpec, r as parseWebrtcLink, t as SIGNAL_SUBPROTOCOL } from "./webrtcLink-BG0Xc4-W.js";
|
|
3
3
|
import { RTCPeerConnection } from "node-datachannel/polyfill";
|
|
4
4
|
|
|
5
5
|
//#region ts/webrtcRemote.ts
|
|
@@ -256,4 +256,4 @@ async function startWebrtcBridge(link) {
|
|
|
256
256
|
|
|
257
257
|
//#endregion
|
|
258
258
|
export { startWebrtcBridge };
|
|
259
|
-
//# sourceMappingURL=webrtcRemote-
|
|
259
|
+
//# sourceMappingURL=webrtcRemote-SybKurg9.js.map
|
|
@@ -162,4 +162,4 @@ function resolveSpawnCwd(input) {
|
|
|
162
162
|
|
|
163
163
|
//#endregion
|
|
164
164
|
export { getSpawnWaitMs as a, isProvisionAllowed as c, getSpawnHook as i, resolveSpawnCwd as l, getMinFreeMb as n, getWorkspaceRoot as o, getProvisionRoot as r, hasSpawnHook as s, getMaxAgents as t, setWorkspaceRoot as u };
|
|
165
|
-
//# sourceMappingURL=workspaceConfig-
|
|
165
|
+
//# sourceMappingURL=workspaceConfig-BC03X4Y1.js.map
|
package/lab/ui/console-logic.js
CHANGED
|
@@ -180,6 +180,22 @@ export function taskLabel(e) {
|
|
|
180
180
|
return `${t.done}/${t.total}`;
|
|
181
181
|
}
|
|
182
182
|
|
|
183
|
+
// Display metadata for status flags matched server-side against an agent's
|
|
184
|
+
// screen (ts/badges.ts BADGE_DEFS — matching runs server-side; the API sends
|
|
185
|
+
// just the matched ids in e.badges). Keep the label/title text here in sync
|
|
186
|
+
// with ts/badges.ts. An id with no entry still renders (falls back to the raw
|
|
187
|
+
// id), so a newly-added server-side badge is never silently dropped.
|
|
188
|
+
export const BADGE_META = {
|
|
189
|
+
"goal-active": { label: "goal", title: "A /goal Stop-hook loop is active on this agent" },
|
|
190
|
+
};
|
|
191
|
+
|
|
192
|
+
// Status-flag chips ("badges") matched against the agent's screen — e.g. an
|
|
193
|
+
// active /goal loop. [] when e.badges is missing/empty. Returns
|
|
194
|
+
// { id, label, title } objects ready for the caller to render as chips.
|
|
195
|
+
export function badgesFor(e) {
|
|
196
|
+
return (e.badges || []).map((id) => ({ id, ...(BADGE_META[id] || { label: id, title: id }) }));
|
|
197
|
+
}
|
|
198
|
+
|
|
183
199
|
// Human age of an agent ("12s" / "5m" / "3h"). `now` is injectable so tests
|
|
184
200
|
// don't depend on the wall clock; the browser calls age(e) and gets Date.now().
|
|
185
201
|
export function age(e, now = Date.now()) {
|
package/lab/ui/index.html
CHANGED
|
@@ -891,6 +891,20 @@
|
|
|
891
891
|
color: var(--green);
|
|
892
892
|
border-color: #2a3a2a;
|
|
893
893
|
}
|
|
894
|
+
/* Status-flag chip ("badge") matched server-side against the agent's
|
|
895
|
+
screen — e.g. an active /goal loop (ts/badges.ts). Same pill shape as
|
|
896
|
+
.tasks but accent-colored so a matched flag stands out as a distinct
|
|
897
|
+
signal, not just a counter. */
|
|
898
|
+
.flag {
|
|
899
|
+
font-family: var(--mono);
|
|
900
|
+
font-size: 10.5px;
|
|
901
|
+
color: var(--accent);
|
|
902
|
+
white-space: nowrap;
|
|
903
|
+
flex: none;
|
|
904
|
+
padding: 0 5px;
|
|
905
|
+
border: 1px solid var(--accent);
|
|
906
|
+
border-radius: 8px;
|
|
907
|
+
}
|
|
894
908
|
/* The pending question when an agent needs_input — blue accent to match the
|
|
895
909
|
needs_input dot, reading as "your turn / here's what it's asking". */
|
|
896
910
|
.detail.ask {
|
|
@@ -1543,6 +1557,7 @@
|
|
|
1543
1557
|
sortEntries,
|
|
1544
1558
|
SORT_MODES,
|
|
1545
1559
|
taskLabel,
|
|
1560
|
+
badgesFor,
|
|
1546
1561
|
hashHue,
|
|
1547
1562
|
selFromBottom,
|
|
1548
1563
|
parseSel,
|
|
@@ -3219,6 +3234,17 @@
|
|
|
3219
3234
|
return `<span class="tasks${allDone ? " done" : ""}" title="${esc(tip)}">${esc(label)}</span>`;
|
|
3220
3235
|
}
|
|
3221
3236
|
|
|
3237
|
+
// Status-flag chips ("badges") matched server-side against the agent's
|
|
3238
|
+
// screen (ts/badges.ts) — e.g. an active /goal loop. One small pill per
|
|
3239
|
+
// matched flag; "" when none matched. CSS class is "flag" (not "badge")
|
|
3240
|
+
// to avoid colliding with the existing pid pill's .badge class. See
|
|
3241
|
+
// BADGE_META in console-logic.js for the id → label/title mapping.
|
|
3242
|
+
function badgeChipsHtml(e) {
|
|
3243
|
+
return badgesFor(e)
|
|
3244
|
+
.map((b) => `<span class="flag" title="${esc(b.title)}">${esc(b.label)}</span>`)
|
|
3245
|
+
.join("");
|
|
3246
|
+
}
|
|
3247
|
+
|
|
3222
3248
|
// A room/peer group header row. Non-selectable; carries the same tree rails
|
|
3223
3249
|
// as agent rows so the hierarchy reads as one tree. The room/peer layers
|
|
3224
3250
|
// only appear here when there are ≥2 of them (single ones fold away in
|
|
@@ -3268,6 +3294,7 @@
|
|
|
3268
3294
|
${cli ? `<span class="cname">${esc(cli)}</span>` : ""}
|
|
3269
3295
|
<span class="ctitle ${e.title ? "" : "dim"}" title="${esc(t)}">${esc(t)}</span>
|
|
3270
3296
|
${taskChipHtml(e)}
|
|
3297
|
+
${badgeChipsHtml(e)}
|
|
3271
3298
|
${gitChipHtml(e)}
|
|
3272
3299
|
<span class="age">${age(e)}</span></div>`;
|
|
3273
3300
|
})
|
|
@@ -3293,6 +3320,7 @@
|
|
|
3293
3320
|
<span class="name">${esc(cliLabel(e) || ident(e) || "agent")}</span>
|
|
3294
3321
|
<span class="badge">pid ${e.pid}</span>
|
|
3295
3322
|
${taskChipHtml(e)}
|
|
3323
|
+
${badgeChipsHtml(e)}
|
|
3296
3324
|
${gitChipHtml(e)}
|
|
3297
3325
|
<span class="age">${age(e)}</span></div>
|
|
3298
3326
|
${e.title ? `<div class="rowtitle" title="${esc(e.title)}">${esc(e.title)}</div>` : ""}
|
package/package.json
CHANGED
package/ts/autoRetry.spec.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, expect, it } from "vitest";
|
|
2
|
-
import { AUTO_RETRY_MAX_DELAY_SECS, autoRetryBackoffMs } from "./autoRetry.ts";
|
|
2
|
+
import { AUTO_RETRY_MAX_DELAY_SECS, autoRetryBackoffMs, shouldFireRetry } from "./autoRetry.ts";
|
|
3
3
|
|
|
4
4
|
describe("autoRetryBackoffMs", () => {
|
|
5
5
|
it("doubles 8,16,32,…,256 then caps", () => {
|
|
@@ -17,3 +17,18 @@ describe("autoRetryBackoffMs", () => {
|
|
|
17
17
|
expect(Number.isFinite(autoRetryBackoffMs(1000))).toBe(true);
|
|
18
18
|
});
|
|
19
19
|
});
|
|
20
|
+
|
|
21
|
+
describe("shouldFireRetry", () => {
|
|
22
|
+
it("requires ready + not working + past the idle window", () => {
|
|
23
|
+
// Busy — never fire even if otherwise ready and quiet.
|
|
24
|
+
expect(shouldFireRetry(true, true, 10_000, 5_000)).toBe(false);
|
|
25
|
+
// Not at a recognized ready prompt — don't fire.
|
|
26
|
+
expect(shouldFireRetry(false, false, 10_000, 5_000)).toBe(false);
|
|
27
|
+
// Ready and idle, but the quiet window hasn't elapsed yet (user may still
|
|
28
|
+
// be mid-typing) — defer.
|
|
29
|
+
expect(shouldFireRetry(false, true, 4_999, 5_000)).toBe(false);
|
|
30
|
+
// Ready, idle, and past the quiet window — fire.
|
|
31
|
+
expect(shouldFireRetry(false, true, 5_000, 5_000)).toBe(true);
|
|
32
|
+
expect(shouldFireRetry(false, true, 10_000, 5_000)).toBe(true);
|
|
33
|
+
});
|
|
34
|
+
});
|
package/ts/autoRetry.ts
CHANGED
|
@@ -8,9 +8,32 @@ export const AUTO_RETRY_BASE_SECS = 8; // first backoff; doubles each consecutiv
|
|
|
8
8
|
export const AUTO_RETRY_MAX_DELAY_SECS = 256; // cap: 8,16,32,…,256 then hold
|
|
9
9
|
export const AUTO_RETRY_GIVE_UP_MS = 8 * 3600 * 1000; // stop after 8h (claude's usage window is ~5h)
|
|
10
10
|
|
|
11
|
+
// Minimum quiet time (no CLI output, no forwarded stdin — see idleWaiter.ping()
|
|
12
|
+
// call sites) required before a scheduled auto-retry may actually fire, on top
|
|
13
|
+
// of the backoff delay above. The backoff schedule alone can elapse while the
|
|
14
|
+
// user is mid-typing into the prompt; typing "retry" + Enter over that would
|
|
15
|
+
// submit a mangled line. Deliberately short — this only debounces against
|
|
16
|
+
// active typing, not a real excuse to delay recovery.
|
|
17
|
+
export const AUTO_RETRY_MIN_IDLE_MS = 5_000;
|
|
18
|
+
|
|
11
19
|
/** Backoff (ms) before the Nth consecutive auto-retry — doubles, then caps. */
|
|
12
20
|
export function autoRetryBackoffMs(streak: number): number {
|
|
13
21
|
const shift = Math.min(streak, 20); // guard against absurd streaks blowing up 2 ** n
|
|
14
22
|
const secs = Math.min(AUTO_RETRY_BASE_SECS * 2 ** shift, AUTO_RETRY_MAX_DELAY_SECS);
|
|
15
23
|
return secs * 1000;
|
|
16
24
|
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Whether a scheduled auto-retry may actually fire: the agent must be sitting
|
|
28
|
+
* idle at a ready prompt (not mid-work) AND the terminal must have been quiet
|
|
29
|
+
* for at least `minIdleMs` — see AUTO_RETRY_MIN_IDLE_MS. Mirrors Rust's
|
|
30
|
+
* should_fire_retry in rs/src/context.rs.
|
|
31
|
+
*/
|
|
32
|
+
export function shouldFireRetry(
|
|
33
|
+
working: boolean,
|
|
34
|
+
ready: boolean,
|
|
35
|
+
idleMs: number,
|
|
36
|
+
minIdleMs: number,
|
|
37
|
+
): boolean {
|
|
38
|
+
return !working && ready && idleMs >= minIdleMs;
|
|
39
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { badgeDef, BADGE_DEFS, matchBadges, type BadgeDef } from "./badges.ts";
|
|
3
|
+
|
|
4
|
+
describe("matchBadges", () => {
|
|
5
|
+
it("matches goal-active when the /goal status line is on screen", () => {
|
|
6
|
+
const lines = ["some output", "/goal active (42s)", "more output"];
|
|
7
|
+
expect(matchBadges(lines)).toEqual(["goal-active"]);
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
it("is case-insensitive", () => {
|
|
11
|
+
expect(matchBadges(["/GOAL ACTIVE (1s)"])).toEqual(["goal-active"]);
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it("returns empty when nothing matches", () => {
|
|
15
|
+
expect(matchBadges(["just some regular CLI output", "nothing special here"])).toEqual([]);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it("returns empty for an empty screen", () => {
|
|
19
|
+
expect(matchBadges([])).toEqual([]);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it("matches across a join boundary between lines (pattern spans the joined text)", () => {
|
|
23
|
+
// Sanity: matchBadges joins lines with \n before testing, so a pattern that
|
|
24
|
+
// doesn't care about line boundaries still finds a hit split across two lines.
|
|
25
|
+
const defs: BadgeDef[] = [{ id: "spans", label: "x", title: "t", pattern: /foo\nbar/ }];
|
|
26
|
+
expect(matchBadges(["foo", "bar"], defs)).toEqual(["spans"]);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it("supports custom def sets independent of the built-in BADGE_DEFS", () => {
|
|
30
|
+
const defs: BadgeDef[] = [
|
|
31
|
+
{ id: "custom-error", label: "err", title: "custom error banner", pattern: /FATAL: boom/ },
|
|
32
|
+
];
|
|
33
|
+
expect(matchBadges(["FATAL: boom"], defs)).toEqual(["custom-error"]);
|
|
34
|
+
expect(matchBadges(["FATAL: boom"])).toEqual([]); // not in the default set
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("can match multiple badges at once, in def order", () => {
|
|
38
|
+
const defs: BadgeDef[] = [
|
|
39
|
+
{ id: "a", label: "a", title: "a", pattern: /alpha/ },
|
|
40
|
+
{ id: "b", label: "b", title: "b", pattern: /beta/ },
|
|
41
|
+
];
|
|
42
|
+
expect(matchBadges(["alpha and beta both here"], defs)).toEqual(["a", "b"]);
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
describe("badgeDef", () => {
|
|
47
|
+
it("looks up a built-in definition by id", () => {
|
|
48
|
+
expect(badgeDef("goal-active")).toBe(BADGE_DEFS[0]);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("returns undefined for an unknown id", () => {
|
|
52
|
+
expect(badgeDef("does-not-exist")).toBeUndefined();
|
|
53
|
+
});
|
|
54
|
+
});
|
package/ts/badges.ts
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Badge/flag matchers: small regex patterns run against an agent's last
|
|
3
|
+
* rendered screen (the same tail window `ay tail` shows — see
|
|
4
|
+
* extractBadges in subcommands.ts) to surface a short status chip in the
|
|
5
|
+
* web console's agent list. Extensible: add more entries to BADGE_DEFS as
|
|
6
|
+
* useful patterns turn up (a known error banner, a loop-state flag, etc.).
|
|
7
|
+
* Pure and CLI-agnostic — a definition just won't match on CLIs that never
|
|
8
|
+
* print its pattern.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
export interface BadgeDef {
|
|
12
|
+
/** Stable id, used as the wire value and as the key when re-deriving the label. */
|
|
13
|
+
id: string;
|
|
14
|
+
/** Short chip text, e.g. "goal". Keep it a couple of characters — the chip is tiny. */
|
|
15
|
+
label: string;
|
|
16
|
+
/** Tooltip shown on hover, explaining what the badge means. */
|
|
17
|
+
title: string;
|
|
18
|
+
/** Matched against the tail-rendered screen text (lines joined with \n). */
|
|
19
|
+
pattern: RegExp;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export const BADGE_DEFS: BadgeDef[] = [
|
|
23
|
+
{
|
|
24
|
+
id: "goal-active",
|
|
25
|
+
label: "goal",
|
|
26
|
+
title: "A /goal Stop-hook loop is active on this agent",
|
|
27
|
+
pattern: /\/goal active/i,
|
|
28
|
+
},
|
|
29
|
+
];
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Which badge ids match the given rendered screen lines. Pure so it's
|
|
33
|
+
* unit-tested without a live PTY/log file.
|
|
34
|
+
*/
|
|
35
|
+
export function matchBadges(lines: string[], defs: BadgeDef[] = BADGE_DEFS): string[] {
|
|
36
|
+
const text = lines.join("\n");
|
|
37
|
+
return defs.filter((d) => d.pattern.test(text)).map((d) => d.id);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function badgeDef(id: string, defs: BadgeDef[] = BADGE_DEFS): BadgeDef | undefined {
|
|
41
|
+
return defs.find((d) => d.id === id);
|
|
42
|
+
}
|
package/ts/cli.ts
CHANGED
|
@@ -106,6 +106,52 @@ if (config.useRust) {
|
|
|
106
106
|
console.log(`[rust] Args: ${rustArgs.join(" ")}`);
|
|
107
107
|
}
|
|
108
108
|
|
|
109
|
+
// Nested + non-tty (e.g. an agent ran this via its Bash tool): detach the
|
|
110
|
+
// agent so we don't block the parent's tool call for the whole session, then
|
|
111
|
+
// print how to drive it and exit. `--attach`/AGENT_YES_ATTACH=1 opts out.
|
|
112
|
+
const attach = config.attach || process.env.AGENT_YES_ATTACH === "1";
|
|
113
|
+
const { shouldForkNested, buildSpawnTutorial, waitForFifo } = await import("./forkNested.ts");
|
|
114
|
+
if (
|
|
115
|
+
shouldForkNested({
|
|
116
|
+
isTTY: Boolean(process.stdout.isTTY),
|
|
117
|
+
ayPid: process.env.AGENT_YES_PID,
|
|
118
|
+
attach,
|
|
119
|
+
})
|
|
120
|
+
) {
|
|
121
|
+
const forked = spawn(rustBinary, rustArgs, {
|
|
122
|
+
detached: true,
|
|
123
|
+
stdio: "ignore",
|
|
124
|
+
env: process.env,
|
|
125
|
+
cwd: process.cwd(),
|
|
126
|
+
});
|
|
127
|
+
const forkedPid = forked.pid;
|
|
128
|
+
if (!forkedPid) {
|
|
129
|
+
console.error("Failed to spawn agent: no pid.");
|
|
130
|
+
process.exit(1);
|
|
131
|
+
}
|
|
132
|
+
// Race a fast startup failure against FIFO registration so we never print a
|
|
133
|
+
// success tutorial for a dead pid. The wrapper keeps its own per-pid log, so
|
|
134
|
+
// real output stays reachable via `ay tail` even with stdio ignored. Store a
|
|
135
|
+
// pre-formatted message (not a union) — the callbacks run async, so control-flow
|
|
136
|
+
// analysis can't narrow a union here anyway.
|
|
137
|
+
let deathMsg: string | null = null;
|
|
138
|
+
forked.on("error", (err) => {
|
|
139
|
+
deathMsg ??= `Failed to spawn agent: ${err.message}`;
|
|
140
|
+
});
|
|
141
|
+
forked.on("exit", (code, signal) => {
|
|
142
|
+
deathMsg ??= `Agent exited immediately (${signal ?? `code ${code}`}). See: ay tail ${forkedPid}`;
|
|
143
|
+
});
|
|
144
|
+
const registered = await waitForFifo(forkedPid, 2000, () => deathMsg !== null);
|
|
145
|
+
if (deathMsg) {
|
|
146
|
+
console.error(deathMsg);
|
|
147
|
+
process.exit(1);
|
|
148
|
+
}
|
|
149
|
+
forked.unref();
|
|
150
|
+
console.log(buildSpawnTutorial(config.cli || "agent", forkedPid));
|
|
151
|
+
if (!registered) console.log(`(note: still registering — give ay send/tail a moment)`);
|
|
152
|
+
process.exit(0);
|
|
153
|
+
}
|
|
154
|
+
|
|
109
155
|
const child = spawn(rustBinary, rustArgs, {
|
|
110
156
|
stdio: "inherit",
|
|
111
157
|
env: process.env,
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { buildSpawnTutorial, shouldForkNested } from "./forkNested";
|
|
3
|
+
|
|
4
|
+
describe("shouldForkNested", () => {
|
|
5
|
+
it("forks when nested (AGENT_YES_PID set) and stdout is not a TTY", () => {
|
|
6
|
+
expect(shouldForkNested({ isTTY: false, ayPid: "1234", attach: false })).toBe(true);
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
it("does NOT fork on an interactive TTY (a human running it directly)", () => {
|
|
10
|
+
expect(shouldForkNested({ isTTY: true, ayPid: "1234", attach: false })).toBe(false);
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it("does NOT fork when not nested — a human piping output has no AGENT_YES_PID", () => {
|
|
14
|
+
expect(shouldForkNested({ isTTY: false, ayPid: undefined, attach: false })).toBe(false);
|
|
15
|
+
expect(shouldForkNested({ isTTY: false, ayPid: "", attach: false })).toBe(false);
|
|
16
|
+
expect(shouldForkNested({ isTTY: false, ayPid: " ", attach: false })).toBe(false);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it("does NOT fork when attach opts out, regardless of context", () => {
|
|
20
|
+
expect(shouldForkNested({ isTTY: false, ayPid: "1234", attach: true })).toBe(false);
|
|
21
|
+
});
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
describe("buildSpawnTutorial", () => {
|
|
25
|
+
it("names the cli + pid and lists the drive commands with that pid", () => {
|
|
26
|
+
const out = buildSpawnTutorial("claude", 4242);
|
|
27
|
+
expect(out).toContain("Spawned claude agent as pid 4242");
|
|
28
|
+
expect(out).toContain("ay tail 4242");
|
|
29
|
+
expect(out).toContain("ay send 4242");
|
|
30
|
+
expect(out).toContain("ay ls");
|
|
31
|
+
expect(out).toContain("ay result get 4242");
|
|
32
|
+
expect(out).toContain("ay exit 4242");
|
|
33
|
+
});
|
|
34
|
+
});
|
package/ts/forkNested.ts
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { existsSync } from "fs";
|
|
2
|
+
import path from "path";
|
|
3
|
+
import { agentYesHome } from "./agentYesHome.ts";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Should a nested agent-run detach (fork) instead of blocking the caller?
|
|
7
|
+
*
|
|
8
|
+
* A claude/codex agent that runs `ay <cli> -- <task>` inside its Bash tool would
|
|
9
|
+
* otherwise block that Bash call for the whole (possibly very long) session and
|
|
10
|
+
* time out. When we detect that context we spawn the agent detached, print a
|
|
11
|
+
* tutorial, and return immediately so the parent stays responsive.
|
|
12
|
+
*
|
|
13
|
+
* The context is: we are NESTED inside another agent (`AGENT_YES_PID` is injected
|
|
14
|
+
* into an agent's environment by its wrapper — a human shell never has it) AND
|
|
15
|
+
* stdout is not a TTY (captured/piped, e.g. a tool's Bash). A human piping output
|
|
16
|
+
* (`ay claude | cat`) has no `AGENT_YES_PID`, so they still block as before.
|
|
17
|
+
* `attach` (the `--attach` flag or `AGENT_YES_ATTACH=1`) forces foreground.
|
|
18
|
+
*/
|
|
19
|
+
export function shouldForkNested(opts: {
|
|
20
|
+
isTTY: boolean;
|
|
21
|
+
ayPid: string | undefined;
|
|
22
|
+
attach: boolean;
|
|
23
|
+
}): boolean {
|
|
24
|
+
if (opts.attach) return false;
|
|
25
|
+
if (opts.isTTY) return false;
|
|
26
|
+
return Boolean(opts.ayPid && opts.ayPid.trim());
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** The tutorial printed to the parent agent after a detached spawn, telling it
|
|
30
|
+
* exactly how to drive the agent it just started. */
|
|
31
|
+
export function buildSpawnTutorial(cli: string, pid: number): string {
|
|
32
|
+
return [
|
|
33
|
+
`Spawned ${cli} agent as pid ${pid} (detached — this shell returned immediately).`,
|
|
34
|
+
`It runs in the background; drive it with:`,
|
|
35
|
+
` ay tail ${pid} # watch its output (live)`,
|
|
36
|
+
` ay send ${pid} "..." # send it a message / instruction`,
|
|
37
|
+
` ay send ${pid} /compact # send a slash command`,
|
|
38
|
+
` ay ls # list running agents`,
|
|
39
|
+
` ay result get ${pid} # read its final result when done`,
|
|
40
|
+
` ay exit ${pid} # stop it`,
|
|
41
|
+
].join("\n");
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Poll for the spawned wrapper's stdin FIFO to appear, so the tutorial's
|
|
46
|
+
* `ay send`/`ay tail` work the instant we print them (the wrapper registers its
|
|
47
|
+
* FIFO a moment after spawn). Resolves true once registered, false on timeout or
|
|
48
|
+
* if `aborted()` reports the child already died (so a startup failure fails fast
|
|
49
|
+
* instead of waiting out the whole window).
|
|
50
|
+
*/
|
|
51
|
+
export async function waitForFifo(
|
|
52
|
+
pid: number,
|
|
53
|
+
timeoutMs = 2000,
|
|
54
|
+
aborted?: () => boolean,
|
|
55
|
+
): Promise<boolean> {
|
|
56
|
+
const fifo = path.join(agentYesHome(), "fifo", `${pid}.stdin`);
|
|
57
|
+
const deadline = Date.now() + timeoutMs;
|
|
58
|
+
while (Date.now() < deadline) {
|
|
59
|
+
if (aborted?.()) return false;
|
|
60
|
+
if (existsSync(fifo)) return true;
|
|
61
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
62
|
+
}
|
|
63
|
+
return existsSync(fifo);
|
|
64
|
+
}
|
package/ts/idleWaiter.spec.ts
CHANGED
|
@@ -59,6 +59,18 @@ describe("IdleWaiter", () => {
|
|
|
59
59
|
expect(result).toBe(waiter);
|
|
60
60
|
});
|
|
61
61
|
|
|
62
|
+
it("idleTimeMs reflects elapsed time since the last ping", async () => {
|
|
63
|
+
const waiter = new IdleWaiter();
|
|
64
|
+
waiter.ping();
|
|
65
|
+
expect(waiter.idleTimeMs()).toBeLessThan(20);
|
|
66
|
+
|
|
67
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
68
|
+
expect(waiter.idleTimeMs()).toBeGreaterThanOrEqual(50);
|
|
69
|
+
|
|
70
|
+
waiter.ping();
|
|
71
|
+
expect(waiter.idleTimeMs()).toBeLessThan(20);
|
|
72
|
+
});
|
|
73
|
+
|
|
62
74
|
it("should wait until idle period has passed", async () => {
|
|
63
75
|
const waiter = new IdleWaiter();
|
|
64
76
|
waiter.checkInterval = 10;
|
package/ts/idleWaiter.ts
CHANGED
|
@@ -24,6 +24,11 @@ export class IdleWaiter {
|
|
|
24
24
|
return this;
|
|
25
25
|
}
|
|
26
26
|
|
|
27
|
+
/** Milliseconds since the last ping. */
|
|
28
|
+
idleTimeMs() {
|
|
29
|
+
return Date.now() - this.lastActivityTime;
|
|
30
|
+
}
|
|
31
|
+
|
|
27
32
|
async wait(ms: number) {
|
|
28
33
|
while (this.lastActivityTime >= Date.now() - ms)
|
|
29
34
|
await new Promise((resolve) => setTimeout(resolve, this.checkInterval));
|
package/ts/index.ts
CHANGED
|
@@ -18,7 +18,12 @@ import { logger } from "./logger.ts";
|
|
|
18
18
|
import { createFifoStream } from "./beta/fifo.ts";
|
|
19
19
|
import { PidStore } from "./pidStore.ts";
|
|
20
20
|
import { sendEnter, sendMessage } from "./core/messaging.ts";
|
|
21
|
-
import {
|
|
21
|
+
import {
|
|
22
|
+
AUTO_RETRY_GIVE_UP_MS,
|
|
23
|
+
AUTO_RETRY_MIN_IDLE_MS,
|
|
24
|
+
autoRetryBackoffMs,
|
|
25
|
+
shouldFireRetry,
|
|
26
|
+
} from "./autoRetry.ts";
|
|
22
27
|
import {
|
|
23
28
|
initializeLogPaths,
|
|
24
29
|
setupDebugLogging,
|
|
@@ -805,8 +810,12 @@ export default async function agentYes({
|
|
|
805
810
|
} else if (now >= retryNextAt) {
|
|
806
811
|
const working = conf.working?.some((rx: RegExp) => rx.test(autoRetryScreen)) ?? false;
|
|
807
812
|
const readyNow = conf.ready?.some((rx: RegExp) => rx.test(autoRetryScreen)) ?? false;
|
|
808
|
-
|
|
809
|
-
|
|
813
|
+
// Also require a few quiet seconds on top of the backoff delay, so a
|
|
814
|
+
// scheduled retry doesn't collide with a line the user is actively
|
|
815
|
+
// typing into the prompt (see AUTO_RETRY_MIN_IDLE_MS).
|
|
816
|
+
const idleMs = ctx.idleWaiter.idleTimeMs();
|
|
817
|
+
if (!shouldFireRetry(working, readyNow, idleMs, AUTO_RETRY_MIN_IDLE_MS)) {
|
|
818
|
+
retryNextAt = now + 500; // busy / not at prompt / still active — re-check shortly
|
|
810
819
|
} else {
|
|
811
820
|
retryStreak += 1;
|
|
812
821
|
logger.warn(`[${cli}-yes] auto-retry: typing 'retry' (attempt ${retryStreak})`);
|
|
@@ -1096,13 +1105,22 @@ export default async function agentYes({
|
|
|
1096
1105
|
write: async (data) => {
|
|
1097
1106
|
await ctx.stdinReady.wait();
|
|
1098
1107
|
shell.write(data);
|
|
1108
|
+
// Forwarded user input counts as activity too (mirrors the Rust
|
|
1109
|
+
// runtime's ping on stdin forward) — the auto-retry idle gate below
|
|
1110
|
+
// must not fire while the user is actively typing.
|
|
1111
|
+
ctx.idleWaiter.ping();
|
|
1099
1112
|
},
|
|
1100
1113
|
}),
|
|
1101
1114
|
readable: xtermProxy.readable,
|
|
1102
1115
|
})
|
|
1103
1116
|
|
|
1104
|
-
.forEach(() => {
|
|
1105
|
-
|
|
1117
|
+
.forEach((chunk) => {
|
|
1118
|
+
// Only ping activity if there's visible content (not just ANSI/cursor
|
|
1119
|
+
// control sequences) — mirrors the Rust runtime's handle_output gate.
|
|
1120
|
+
// Without this, periodic control-only chatter (e.g. cursor position
|
|
1121
|
+
// queries) would keep resetting the auto-retry idle clock and the
|
|
1122
|
+
// scheduled retry could defer indefinitely without ever firing.
|
|
1123
|
+
if (removeControlCharacters(chunk).trim()) ctx.idleWaiter.ping();
|
|
1106
1124
|
pidStore.updateStatus(shell.pid, "active").catch(() => null);
|
|
1107
1125
|
ctx.nextStdout.ready();
|
|
1108
1126
|
})
|