agent-yes 1.169.0 → 1.170.1
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-D1WUoZA9.js → SUPPORTED_CLIS-ByppVvWO.js} +2 -2
- package/dist/SUPPORTED_CLIS-C_c097RZ.js +8 -0
- package/dist/{agent-yes.config-Bl08kMpl.js → agent-yes.config-ugPYTPOd.js} +2 -2
- package/dist/{agentYesHome-_eJa3DaX.js → agentYesHome-CtHb5b71.js} +1 -1
- package/dist/cli.js +10 -212
- package/dist/{configShared-CEyhl0WH.js → configShared-0MnIQ652.js} +1 -1
- package/dist/{e2e-BeKjLhmO.js → e2e-jb0Hp43q.js} +1 -1
- package/dist/{forkNested-UCKPEgSI.js → forkNested-DhJxa4q4.js} +2 -2
- package/dist/{globalPidIndex-DlmmJlO8.js → globalPidIndex-CoNr7tS8.js} +1 -1
- package/dist/index.js +4 -4
- package/dist/notifyDaemon-D5jtpCAR.js +591 -0
- package/dist/{openBrowser-CCF1iuQK.js → openBrowser-ChR4llYa.js} +1 -1
- package/dist/{pidStore-BfoBWUjc.js → pidStore-BIvsBQ8X.js} +3 -3
- package/dist/pidStore-CLxwp0qI.js +5 -0
- package/dist/{reaper-C-eWAxIj.js → reaper-CWF2_ATd.js} +27 -3
- package/dist/{reaper-BQgDb85W.js → reaper-jfSmog15.js} +1 -1
- package/dist/{remotes-cx_GDFPj.js → remotes-BQMr4_En.js} +3 -3
- package/dist/{remotes-BmAPylU_.js → remotes-CC-4GuJb.js} +3 -3
- package/dist/{runningLock-BobVW1_A.js → runningLock-CNMl13dC.js} +1 -1
- package/dist/rustBinary-DsO0IlNq.js +228 -0
- package/dist/{schedule-DTHFMzDj.js → schedule-ChTPIcXf.js} +7 -7
- package/dist/{serve-D-joIHrL.js → serve-De1gAWNI.js} +120 -26
- package/dist/{setup-K6VTmirE.js → setup-nlMqyEPB.js} +3 -3
- package/dist/{share-D_e2Fwiy.js → share-QregR8a_.js} +2 -2
- package/dist/spawnGate-BFhva-2F.js +5 -0
- package/dist/{spawnGate-DzPfa1PZ.js → spawnGate-IJDByl2U.js} +3 -3
- package/dist/{subcommands-BZPW0UVn.js → subcommands-BFlpV23b.js} +1049 -44
- package/dist/subcommands-DLDpwrfG.js +9 -0
- package/dist/{tray-CWUpaZF4.js → tray-DXr7iK3E.js} +2 -2
- package/dist/{ts-DeahYDN3.js → ts-DsxejviJ.js} +10 -10
- package/dist/{versionChecker-C7KP-aoh.js → versionChecker-Czk3aHqy.js} +2 -2
- package/dist/{webrtcLink-BG0Xc4-W.js → webrtcLink-B7REGtK2.js} +2 -2
- package/dist/{webrtcRemote-SybKurg9.js → webrtcRemote-Bx-eD_0I.js} +3 -3
- package/dist/{workspaceConfig-BC03X4Y1.js → workspaceConfig-BgqK-31W.js} +37 -6
- package/lab/ui/console-logic.js +79 -10
- package/lab/ui/index.html +174 -8
- package/lab/ui/landing.html +11 -1
- package/package.json +1 -1
- package/scripts/build-rs.ts +9 -5
- package/ts/badges.spec.ts +51 -1
- package/ts/badges.ts +35 -1
- package/ts/index.ts +13 -4
- package/ts/notifyDaemon.spec.ts +276 -0
- package/ts/notifyDaemon.ts +698 -0
- package/ts/notifyInbox.spec.ts +192 -0
- package/ts/notifyInbox.ts +294 -0
- package/ts/notifyRouter.spec.ts +355 -0
- package/ts/notifyRouter.ts +288 -0
- package/ts/notifyStore.spec.ts +308 -0
- package/ts/notifyStore.ts +439 -0
- package/ts/reaper.spec.ts +18 -1
- package/ts/reaper.ts +33 -1
- package/ts/rustBinary.ts +30 -0
- package/ts/serve.ts +180 -14
- package/ts/subcommands.spec.ts +341 -0
- package/ts/subcommands.ts +764 -29
- package/ts/workspaceConfig.spec.ts +48 -0
- package/ts/workspaceConfig.ts +46 -3
- package/dist/SUPPORTED_CLIS-BFV10E3f.js +0 -8
- package/dist/pidStore-5qyLX3Fh.js +0 -5
- package/dist/spawnGate-CQ1Il3Xk.js +0 -5
- package/dist/subcommands-DuGwtmz5.js +0 -9
package/ts/serve.ts
CHANGED
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
extractBadges,
|
|
13
13
|
extractNeedsInput,
|
|
14
14
|
extractTaskCounts,
|
|
15
|
+
isUserTyping,
|
|
15
16
|
listRecords,
|
|
16
17
|
readNotes,
|
|
17
18
|
readPtysize,
|
|
@@ -21,14 +22,18 @@ import {
|
|
|
21
22
|
writeToIpc,
|
|
22
23
|
type CommonOpts,
|
|
23
24
|
} from "./subcommands.ts";
|
|
25
|
+
import { TYPING_BADGE } from "./badges.ts";
|
|
24
26
|
import { updateGlobalPidStatus } from "./globalPidIndex.ts";
|
|
25
27
|
import { spawnRejectionReason } from "./spawnGate.ts";
|
|
28
|
+
import { findSpawnHiddenLauncher } from "./rustBinary.ts";
|
|
26
29
|
import { pgidForWrapper } from "./reaper.ts";
|
|
27
30
|
import { SUPPORTED_CLIS } from "./SUPPORTED_CLIS.ts";
|
|
28
31
|
import { getInstalledPackage } from "./versionChecker.ts";
|
|
29
32
|
import {
|
|
33
|
+
getProvisionHook,
|
|
30
34
|
getProvisionRoot,
|
|
31
35
|
getSpawnHook,
|
|
36
|
+
hasProvisionHook,
|
|
32
37
|
hasSpawnHook,
|
|
33
38
|
isProvisionAllowed,
|
|
34
39
|
resolveSpawnCwd,
|
|
@@ -234,6 +239,83 @@ function freshAgentEnv(): Record<string, string> {
|
|
|
234
239
|
return env;
|
|
235
240
|
}
|
|
236
241
|
|
|
242
|
+
// Best-effort owner/repo from a worktree's github origin remote — used to build
|
|
243
|
+
// the KOHO_* env a provision hook branches on (which account to select for this
|
|
244
|
+
// repo). Mirrors codehost's forkWorktree parse; null when there's no github
|
|
245
|
+
// origin (the hook still runs, just without KOHO_OWNER/REPO).
|
|
246
|
+
function originOwnerRepo(cwd: string): { owner: string; repo: string } | null {
|
|
247
|
+
try {
|
|
248
|
+
const r = Bun.spawnSync(["git", "-C", cwd, "remote", "get-url", "origin"]);
|
|
249
|
+
if (r.exitCode !== 0) return null;
|
|
250
|
+
const url = new TextDecoder().decode(r.stdout).trim();
|
|
251
|
+
const m = url.match(/github\.com[:/]([^/]+)\/(.+?)(?:\.git)?\/?$/i);
|
|
252
|
+
return m && m[1] && m[2] ? { owner: m[1], repo: m[2] } : null;
|
|
253
|
+
} catch {
|
|
254
|
+
return null;
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
type ProvisionHookResult =
|
|
259
|
+
| { ran: false }
|
|
260
|
+
| { ran: true; ok: boolean; code: number | null; detail: string };
|
|
261
|
+
|
|
262
|
+
// Run the "koho-style" provision hook (a host-local trusted shell hook) BEFORE a
|
|
263
|
+
// fork/from git op. Its purpose is to prepare the host for provisioning — most
|
|
264
|
+
// usefully to select the right git identity (e.g. `gh auth switch --user <who>`,
|
|
265
|
+
// keyed on the KOHO_* env we export) before the clone/worktree/setup runs. When
|
|
266
|
+
// configured it is ALSO the provisioning gate: its exit code decides admission
|
|
267
|
+
// (0 = allow, non-zero = deny), overriding the static provisionAllowlist.
|
|
268
|
+
// Returns { ran:false } when no hook is set, so the caller falls back to the
|
|
269
|
+
// allowlist. Unlike the spawn hook there is no `exec "$@"`: this hook runs to
|
|
270
|
+
// completion and hands control back — it prepares state, it is not the agent.
|
|
271
|
+
async function runProvisionHook(
|
|
272
|
+
cwd: string,
|
|
273
|
+
koho: Record<string, string>,
|
|
274
|
+
): Promise<ProvisionHookResult> {
|
|
275
|
+
const hook = getProvisionHook();
|
|
276
|
+
if (!hook) return { ran: false };
|
|
277
|
+
const shell =
|
|
278
|
+
process.env.AGENT_YES_PROVISION_SHELL?.trim() ||
|
|
279
|
+
process.env.AGENT_YES_SPAWN_SHELL?.trim() ||
|
|
280
|
+
"/bin/sh";
|
|
281
|
+
const outPath = path.join(
|
|
282
|
+
agentYesHome(),
|
|
283
|
+
`provision-hook-${process.pid}-${performance.now().toString(36).replace(".", "")}.log`,
|
|
284
|
+
);
|
|
285
|
+
const timeoutMs = Number(process.env.AGENT_YES_PROVISION_HOOK_TIMEOUT_MS) || 60_000;
|
|
286
|
+
let code: number | null = null;
|
|
287
|
+
try {
|
|
288
|
+
// Recovered login-shell env (freshAgentEnv), NOT the daemon's raw env: the
|
|
289
|
+
// oxmgr/launchd daemon runs with a minimal PATH (/usr/bin:/bin:…) that lacks
|
|
290
|
+
// Homebrew/bun/etc., so `gh`/`git` would be "command not found" and `set -e`
|
|
291
|
+
// would deny EVERY provision. freshAgentEnv re-derives the user's real PATH
|
|
292
|
+
// (as a fresh terminal would) while preserving HOME — so the hook finds `gh`
|
|
293
|
+
// and its credential store. `set -e` so any failing step denies the provision.
|
|
294
|
+
const child = Bun.spawn([shell, "-c", `set -e\n${hook}`], {
|
|
295
|
+
cwd,
|
|
296
|
+
env: { ...freshAgentEnv(), ...koho },
|
|
297
|
+
stdin: "ignore",
|
|
298
|
+
stdout: Bun.file(outPath),
|
|
299
|
+
stderr: Bun.file(outPath),
|
|
300
|
+
});
|
|
301
|
+
code = await Promise.race([
|
|
302
|
+
child.exited,
|
|
303
|
+
new Promise<number>((r) => setTimeout(() => r(124), timeoutMs)),
|
|
304
|
+
]);
|
|
305
|
+
if (code === 124) child.kill();
|
|
306
|
+
} catch (e) {
|
|
307
|
+
return { ran: true, ok: false, code: null, detail: (e as Error).message };
|
|
308
|
+
}
|
|
309
|
+
let detail = "";
|
|
310
|
+
try {
|
|
311
|
+
detail = (await Bun.file(outPath).text()).slice(0, 4096).trimEnd();
|
|
312
|
+
} catch {
|
|
313
|
+
/* no output captured */
|
|
314
|
+
}
|
|
315
|
+
await unlink(outPath).catch(() => {});
|
|
316
|
+
return { ran: true, ok: code === 0, code, detail };
|
|
317
|
+
}
|
|
318
|
+
|
|
237
319
|
// ---------------------------------------------------------------------------
|
|
238
320
|
// ay serve install / uninstall / logs (oxmgr daemon management)
|
|
239
321
|
// ---------------------------------------------------------------------------
|
|
@@ -513,6 +595,17 @@ async function cmdServeDaemon(sub: string, args: string[]): Promise<number> {
|
|
|
513
595
|
// args after `--`. Both auto-restart on crash by default (pm2) / via the
|
|
514
596
|
// explicit flag (oxmgr).
|
|
515
597
|
const serveArgv = ayServeArgv(effArgs);
|
|
598
|
+
// On Windows, interpose the window-less launcher so the manager (pm2/oxmgr)
|
|
599
|
+
// doesn't flash a console window on each (re)start. `ay-spawn-hidden` is a
|
|
600
|
+
// GUI-subsystem shim that starts the real `ay serve …` with CREATE_NO_WINDOW
|
|
601
|
+
// and mirrors its lifetime/exit-code, so the manager still tracks it exactly.
|
|
602
|
+
// Resolves to undefined off Windows or when the launcher isn't installed
|
|
603
|
+
// (older builds / a release predating it) → fall back to the raw command.
|
|
604
|
+
const spawnHidden = findSpawnHiddenLauncher();
|
|
605
|
+
// The command the manager launches: unchanged normally, or the launcher
|
|
606
|
+
// followed by the real argv when interposing. Split into program + args for
|
|
607
|
+
// pm2 (which takes `<program> … -- <args>`); oxmgr takes the joined string.
|
|
608
|
+
const managedArgv = spawnHidden ? [spawnHidden, ...serveArgv] : serveArgv;
|
|
516
609
|
// WebRTC daemons get an oxmgr health watchdog: the native WebRTC stack can
|
|
517
610
|
// freeze the JS event loop (host answers nobody, no in-process timer can
|
|
518
611
|
// recover it), so an EXTERNAL probe of the serve heartbeat is the only thing
|
|
@@ -536,7 +629,7 @@ async function cmdServeDaemon(sub: string, args: string[]): Promise<number> {
|
|
|
536
629
|
? [
|
|
537
630
|
mgr.bin,
|
|
538
631
|
"start",
|
|
539
|
-
|
|
632
|
+
managedArgv.join(" "),
|
|
540
633
|
"--name",
|
|
541
634
|
DAEMON_NAME,
|
|
542
635
|
"--restart",
|
|
@@ -552,7 +645,7 @@ async function cmdServeDaemon(sub: string, args: string[]): Promise<number> {
|
|
|
552
645
|
: [
|
|
553
646
|
mgr.bin,
|
|
554
647
|
"start",
|
|
555
|
-
|
|
648
|
+
managedArgv[0]!,
|
|
556
649
|
"--name",
|
|
557
650
|
DAEMON_NAME,
|
|
558
651
|
"--interpreter",
|
|
@@ -565,7 +658,7 @@ async function cmdServeDaemon(sub: string, args: string[]): Promise<number> {
|
|
|
565
658
|
"--exp-backoff-restart-delay",
|
|
566
659
|
"200",
|
|
567
660
|
"--",
|
|
568
|
-
...
|
|
661
|
+
...managedArgv.slice(1),
|
|
569
662
|
];
|
|
570
663
|
const proc = Bun.spawn(startArgv, { stdio: ["ignore", "inherit", "inherit"] });
|
|
571
664
|
const code = await proc.exited;
|
|
@@ -1153,8 +1246,18 @@ export async function cmdServe(rest: string[]): Promise<number> {
|
|
|
1153
1246
|
// dot and the CLI agree.
|
|
1154
1247
|
const question =
|
|
1155
1248
|
status !== "exited" && !r.unresponsive ? await logNeedsInput(r.log_file, r.cli) : null;
|
|
1249
|
+
// Last-active time: the log file's mtime, i.e. when the agent last wrote
|
|
1250
|
+
// output (stdout). The console's left panel shows the age off this instead
|
|
1251
|
+
// of started_at, so a long-lived-but-quiet agent reads as stale, not "new".
|
|
1252
|
+
// Falls back to started_at when there's no log yet (freshly spawned).
|
|
1253
|
+
const lastActiveAt = r.log_file
|
|
1254
|
+
? await stat(r.log_file)
|
|
1255
|
+
.then((s) => s.mtimeMs)
|
|
1256
|
+
.catch(() => r.started_at)
|
|
1257
|
+
: r.started_at;
|
|
1156
1258
|
return {
|
|
1157
1259
|
...r,
|
|
1260
|
+
last_active_at: lastActiveAt,
|
|
1158
1261
|
// Precedence: exited stays exited; the Rust supervisor's unresponsive flag is
|
|
1159
1262
|
// an authoritative wedge signal (`stuck`); then a blocked menu (`needs_input`);
|
|
1160
1263
|
// else the base live status — so the console's dot matches `ay ls`. (A dead
|
|
@@ -1170,8 +1273,15 @@ export async function cmdServe(rest: string[]): Promise<number> {
|
|
|
1170
1273
|
// badge). Skipped for exited agents — their screen is no longer live.
|
|
1171
1274
|
tasks: status === "exited" ? null : await logTasks(r.log_file),
|
|
1172
1275
|
// Status flags matched against the rendered screen (see badges.ts) — e.g.
|
|
1173
|
-
// an active /goal loop. [] when none matched or for exited agents.
|
|
1174
|
-
|
|
1276
|
+
// an active /goal loop. [] when none matched or for exited agents. The
|
|
1277
|
+
// time-derived "typing" chip (user typing at the terminal) is appended
|
|
1278
|
+
// from the stdin-activity marker, not the screen — same chip `ay ls` shows.
|
|
1279
|
+
badges:
|
|
1280
|
+
status === "exited"
|
|
1281
|
+
? []
|
|
1282
|
+
: await logBadges(r.log_file).then(async (b) =>
|
|
1283
|
+
(await isUserTyping(r.pid)) ? [...b, TYPING_BADGE.id] : b,
|
|
1284
|
+
),
|
|
1175
1285
|
};
|
|
1176
1286
|
};
|
|
1177
1287
|
|
|
@@ -1339,7 +1449,10 @@ export async function cmdServe(rest: string[]): Promise<number> {
|
|
|
1339
1449
|
// share token are not necessarily host admins. Setting it stays host-local
|
|
1340
1450
|
// (edit ~/.agent-yes/config.json) — there is no PUT.
|
|
1341
1451
|
if (req.method === "GET" && p === "/api/spawn-config") {
|
|
1342
|
-
return Response.json({
|
|
1452
|
+
return Response.json({
|
|
1453
|
+
hasSpawnHook: hasSpawnHook(),
|
|
1454
|
+
hasProvisionHook: hasProvisionHook(),
|
|
1455
|
+
});
|
|
1343
1456
|
}
|
|
1344
1457
|
|
|
1345
1458
|
// GET /api/notes
|
|
@@ -1620,7 +1733,18 @@ export async function cmdServe(rest: string[]): Promise<number> {
|
|
|
1620
1733
|
const record = await resolveOne(keyword, defaultOpts({ all: true }));
|
|
1621
1734
|
const args = ["restart", String(record.pid)];
|
|
1622
1735
|
if (body.fresh) args.push("--fresh");
|
|
1623
|
-
|
|
1736
|
+
// Resolve `ay` to an absolute command — same as the /api/spawn path below.
|
|
1737
|
+
// The detached daemon (oxmgr/launchd/pm2) usually has a PATH WITHOUT
|
|
1738
|
+
// ~/.bun/bin and ~/.cargo/bin, so a bare "agent-yes"/"ay" fails with
|
|
1739
|
+
// "Executable not found in $PATH" — the exact error the console surfaced on
|
|
1740
|
+
// restart. Prefer PATH; fall back to re-running THIS process's own ay entry
|
|
1741
|
+
// (process.argv[1]) — always present, since the daemon is itself an `ay serve`.
|
|
1742
|
+
const ayBin = Bun.which("ay") ?? process.argv[1];
|
|
1743
|
+
const ayCmd =
|
|
1744
|
+
process.platform === "win32" && ayBin.toLowerCase().endsWith(".exe")
|
|
1745
|
+
? [ayBin]
|
|
1746
|
+
: [process.execPath, ayBin];
|
|
1747
|
+
const child = Bun.spawn([...ayCmd, ...args], {
|
|
1624
1748
|
cwd: record.cwd,
|
|
1625
1749
|
detached: true,
|
|
1626
1750
|
stdio: ["ignore", "ignore", "ignore"],
|
|
@@ -1782,6 +1906,25 @@ export async function cmdServe(rest: string[]): Promise<number> {
|
|
|
1782
1906
|
{ status: 501 },
|
|
1783
1907
|
);
|
|
1784
1908
|
}
|
|
1909
|
+
// koho-style provision gate — runs BEFORE the worktree + setup-repo.sh so
|
|
1910
|
+
// it can select the git identity for this fork; its exit code overrides
|
|
1911
|
+
// the allowlist. When no hook is configured, falls through to the allowlist
|
|
1912
|
+
// check after the fork resolves owner/repo.
|
|
1913
|
+
const forkOrigin = originOwnerRepo(fork.fromCwd);
|
|
1914
|
+
const forkHook = await runProvisionHook(fork.fromCwd, {
|
|
1915
|
+
KOHO_ACTION: "fork",
|
|
1916
|
+
KOHO_FROM_CWD: fork.fromCwd,
|
|
1917
|
+
KOHO_BRANCH: fork.branch,
|
|
1918
|
+
KOHO_OWNER: forkOrigin?.owner ?? "",
|
|
1919
|
+
KOHO_REPO: forkOrigin?.repo ?? "",
|
|
1920
|
+
KOHO_WS_ROOT: getProvisionRoot() ?? "",
|
|
1921
|
+
});
|
|
1922
|
+
if (forkHook.ran && !forkHook.ok)
|
|
1923
|
+
return new Response(
|
|
1924
|
+
`provision hook denied this fork (exit ${forkHook.code})` +
|
|
1925
|
+
(forkHook.detail ? `:\n${forkHook.detail}` : ""),
|
|
1926
|
+
{ status: 403 },
|
|
1927
|
+
);
|
|
1785
1928
|
let result: {
|
|
1786
1929
|
ok: boolean;
|
|
1787
1930
|
folder: string;
|
|
@@ -1800,11 +1943,14 @@ export async function cmdServe(rest: string[]): Promise<number> {
|
|
|
1800
1943
|
return new Response(`fork failed: ${(e as Error).message}`, { status: 502 });
|
|
1801
1944
|
}
|
|
1802
1945
|
if (!result?.ok) return new Response(result?.error ?? "fork failed", { status: 502 });
|
|
1803
|
-
//
|
|
1804
|
-
|
|
1946
|
+
// Allowlist gate — only when a provision hook did NOT already gate this
|
|
1947
|
+
// (a configured hook overrides the allowlist). The fork runs setup-repo.sh
|
|
1948
|
+
// (code exec), the same risk surface as `from`.
|
|
1949
|
+
if (!forkHook.ran && result.spec && !isProvisionAllowed(result.spec.owner, result.spec.repo))
|
|
1805
1950
|
return new Response(
|
|
1806
1951
|
`forking '${result.spec.owner}/${result.spec.repo}' is not allowed — add the owner ` +
|
|
1807
|
-
`to provisionAllowlist in ~/.agent-yes/config.json (or "*" to allow all)
|
|
1952
|
+
`to provisionAllowlist in ~/.agent-yes/config.json (or "*" to allow all), ` +
|
|
1953
|
+
`or set a provisionHook to gate it yourself`,
|
|
1808
1954
|
{ status: 403 },
|
|
1809
1955
|
);
|
|
1810
1956
|
cwd = result.folder;
|
|
@@ -1840,14 +1986,34 @@ export async function cmdServe(rest: string[]): Promise<number> {
|
|
|
1840
1986
|
}
|
|
1841
1987
|
if (!spec) return new Response(`unrecognized spawn source: ${from}`, { status: 400 });
|
|
1842
1988
|
// Provisioning clones the repo and runs its setup script (dependency
|
|
1843
|
-
// installs + package lifecycle hooks = code execution on the host), so
|
|
1844
|
-
//
|
|
1845
|
-
|
|
1989
|
+
// installs + package lifecycle hooks = code execution on the host), so it
|
|
1990
|
+
// is gated. A koho-style provision hook, when configured, runs first (to
|
|
1991
|
+
// select the git identity for the clone) and its exit code IS the gate,
|
|
1992
|
+
// overriding the allowlist; otherwise the owner/repo allowlist gates it
|
|
1993
|
+
// (empty allowlist = deny all).
|
|
1994
|
+
const fromHook = await runProvisionHook(getProvisionRoot() ?? homedir(), {
|
|
1995
|
+
KOHO_ACTION: "from",
|
|
1996
|
+
KOHO_SOURCE: from,
|
|
1997
|
+
KOHO_OWNER: spec.owner,
|
|
1998
|
+
KOHO_REPO: spec.repo,
|
|
1999
|
+
KOHO_BRANCH: spec.branch,
|
|
2000
|
+
KOHO_WS_ROOT: getProvisionRoot() ?? "",
|
|
2001
|
+
});
|
|
2002
|
+
if (fromHook.ran) {
|
|
2003
|
+
if (!fromHook.ok)
|
|
2004
|
+
return new Response(
|
|
2005
|
+
`provision hook denied '${spec.owner}/${spec.repo}' (exit ${fromHook.code})` +
|
|
2006
|
+
(fromHook.detail ? `:\n${fromHook.detail}` : ""),
|
|
2007
|
+
{ status: 403 },
|
|
2008
|
+
);
|
|
2009
|
+
} else if (!isProvisionAllowed(spec.owner, spec.repo)) {
|
|
1846
2010
|
return new Response(
|
|
1847
2011
|
`provisioning '${spec.owner}/${spec.repo}' is not allowed — add the owner to ` +
|
|
1848
|
-
`provisionAllowlist in ~/.agent-yes/config.json (or "*" to allow all)
|
|
2012
|
+
`provisionAllowlist in ~/.agent-yes/config.json (or "*" to allow all), ` +
|
|
2013
|
+
`or set a provisionHook to gate it yourself`,
|
|
1849
2014
|
{ status: 403 },
|
|
1850
2015
|
);
|
|
2016
|
+
}
|
|
1851
2017
|
let result: { ok: boolean; folder: string; action: string; error?: string };
|
|
1852
2018
|
try {
|
|
1853
2019
|
const wsRoot = getProvisionRoot();
|
package/ts/subcommands.spec.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
2
|
import { mkdir, mkdtemp, rm, utimes, writeFile } from "fs/promises";
|
|
3
|
+
import { appendFileSync } from "fs";
|
|
3
4
|
import { tmpdir } from "os";
|
|
4
5
|
import path from "path";
|
|
5
6
|
|
|
@@ -986,6 +987,346 @@ describe("subcommands.isSlashCommand", () => {
|
|
|
986
987
|
});
|
|
987
988
|
});
|
|
988
989
|
|
|
990
|
+
describe("subcommands.waitForLogQuiet", () => {
|
|
991
|
+
it("resolves once writes to the file stop for quietMs, with the final size", async () => {
|
|
992
|
+
const dir = await mkdtemp(path.join(tmpdir(), "ay-quiet-"));
|
|
993
|
+
try {
|
|
994
|
+
const log = path.join(dir, "a.log");
|
|
995
|
+
await writeFile(log, "hello");
|
|
996
|
+
const { waitForLogQuiet } = await loadModule();
|
|
997
|
+
const size = await waitForLogQuiet(log, 50, 500);
|
|
998
|
+
expect(size).toBe(5);
|
|
999
|
+
} finally {
|
|
1000
|
+
await rm(dir, { recursive: true, force: true }).catch(() => null);
|
|
1001
|
+
}
|
|
1002
|
+
});
|
|
1003
|
+
|
|
1004
|
+
it("returns the last observed size when maxWaitMs elapses without ever going quiet", async () => {
|
|
1005
|
+
const dir = await mkdtemp(path.join(tmpdir(), "ay-quiet-"));
|
|
1006
|
+
try {
|
|
1007
|
+
const log = path.join(dir, "a.log");
|
|
1008
|
+
await writeFile(log, "x");
|
|
1009
|
+
const timer = setInterval(() => {
|
|
1010
|
+
appendFileSync(log, "x");
|
|
1011
|
+
}, 20);
|
|
1012
|
+
try {
|
|
1013
|
+
const { waitForLogQuiet } = await loadModule();
|
|
1014
|
+
const start = Date.now();
|
|
1015
|
+
const size = await waitForLogQuiet(log, 50, 200);
|
|
1016
|
+
expect(Date.now() - start).toBeGreaterThanOrEqual(190); // hit the cap, not the quiet window
|
|
1017
|
+
expect(size).toBeGreaterThan(0);
|
|
1018
|
+
} finally {
|
|
1019
|
+
clearInterval(timer);
|
|
1020
|
+
}
|
|
1021
|
+
} finally {
|
|
1022
|
+
await rm(dir, { recursive: true, force: true }).catch(() => null);
|
|
1023
|
+
}
|
|
1024
|
+
});
|
|
1025
|
+
|
|
1026
|
+
it("returns null when the file can't be stat'd", async () => {
|
|
1027
|
+
const { waitForLogQuiet } = await loadModule();
|
|
1028
|
+
expect(await waitForLogQuiet("/no/such/file/here", 50, 200)).toBeNull();
|
|
1029
|
+
});
|
|
1030
|
+
});
|
|
1031
|
+
|
|
1032
|
+
describe("subcommands.submitAndConfirm (ay send swallowed-Enter fix)", () => {
|
|
1033
|
+
const itUnix = process.platform === "linux" || process.platform === "darwin";
|
|
1034
|
+
// claude's shipped `working` busy marker — matches cliDefaults()["claude"].working.
|
|
1035
|
+
const BUSY = "⏺ Cogitating…\r\nesc to interrupt · ← for agents\r\n";
|
|
1036
|
+
const IDLE = "❯ some leftover unsent text\r\n"; // no busy marker, never changes
|
|
1037
|
+
|
|
1038
|
+
const rec = (over: any) => ({
|
|
1039
|
+
pid: process.pid,
|
|
1040
|
+
cli: "claude",
|
|
1041
|
+
prompt: null,
|
|
1042
|
+
cwd: "/tmp",
|
|
1043
|
+
log_file: null,
|
|
1044
|
+
fifo_file: null,
|
|
1045
|
+
status: "active",
|
|
1046
|
+
exit_code: null,
|
|
1047
|
+
exit_reason: null,
|
|
1048
|
+
started_at: 0,
|
|
1049
|
+
...over,
|
|
1050
|
+
});
|
|
1051
|
+
|
|
1052
|
+
async function withFifo(fn: (fifo: string) => Promise<void>) {
|
|
1053
|
+
const { spawnSync } = await import("child_process");
|
|
1054
|
+
const dir = await mkdtemp(path.join(tmpdir(), "ay-confirm-"));
|
|
1055
|
+
const fifo = path.join(dir, "test.fifo");
|
|
1056
|
+
try {
|
|
1057
|
+
const r = spawnSync("mkfifo", [fifo]);
|
|
1058
|
+
if (r.status !== 0) return; // mkfifo unavailable — skip
|
|
1059
|
+
const fs = await import("fs");
|
|
1060
|
+
const rdwrFd = fs.openSync(fifo, fs.constants.O_RDWR); // keeps writes from blocking
|
|
1061
|
+
try {
|
|
1062
|
+
await fn(fifo);
|
|
1063
|
+
} finally {
|
|
1064
|
+
fs.closeSync(rdwrFd);
|
|
1065
|
+
}
|
|
1066
|
+
} finally {
|
|
1067
|
+
await rm(dir, { recursive: true, force: true }).catch(() => null);
|
|
1068
|
+
}
|
|
1069
|
+
}
|
|
1070
|
+
|
|
1071
|
+
it.skipIf(!itUnix)(
|
|
1072
|
+
"confirms on the first attempt when the busy marker newly appears after sending",
|
|
1073
|
+
async () => {
|
|
1074
|
+
const dir = await mkdtemp(path.join(tmpdir(), "ay-confirm-log-"));
|
|
1075
|
+
try {
|
|
1076
|
+
const log = path.join(dir, "a.log");
|
|
1077
|
+
await writeFile(log, "❯ \r\n"); // idle — no busy marker yet
|
|
1078
|
+
const { submitAndConfirm } = await loadModule();
|
|
1079
|
+
await withFifo(async (fifo) => {
|
|
1080
|
+
// The Enter kicks off work: the busy marker appears shortly after,
|
|
1081
|
+
// well within the confirm window — a genuine idle→busy transition.
|
|
1082
|
+
setTimeout(() => appendFileSync(log, BUSY), 100);
|
|
1083
|
+
const { confirmed, screen } = await submitAndConfirm(rec({ log_file: log }), fifo, "\r");
|
|
1084
|
+
expect(confirmed).toBe(true);
|
|
1085
|
+
expect(screen.join("\n")).toContain("esc to interrupt");
|
|
1086
|
+
});
|
|
1087
|
+
} finally {
|
|
1088
|
+
await rm(dir, { recursive: true, force: true }).catch(() => null);
|
|
1089
|
+
}
|
|
1090
|
+
},
|
|
1091
|
+
);
|
|
1092
|
+
|
|
1093
|
+
it.skipIf(!itUnix)(
|
|
1094
|
+
"does NOT confirm from a busy marker that was already on screen before sending (false-positive guard)",
|
|
1095
|
+
async () => {
|
|
1096
|
+
// A screen already showing "esc to interrupt" (busy from whatever the agent
|
|
1097
|
+
// was already doing) proves nothing about whether THIS Enter landed — e.g.
|
|
1098
|
+
// it could be sitting queued, unsubmitted, behind an unrelated in-flight
|
|
1099
|
+
// turn. Static and unchanging (no growth either), so this must NOT confirm.
|
|
1100
|
+
const dir = await mkdtemp(path.join(tmpdir(), "ay-confirm-log-"));
|
|
1101
|
+
try {
|
|
1102
|
+
const log = path.join(dir, "a.log");
|
|
1103
|
+
await writeFile(log, BUSY);
|
|
1104
|
+
const { submitAndConfirm } = await loadModule();
|
|
1105
|
+
await withFifo(async (fifo) => {
|
|
1106
|
+
const { confirmed } = await submitAndConfirm(rec({ log_file: log }), fifo, "\r");
|
|
1107
|
+
expect(confirmed).toBe(false);
|
|
1108
|
+
});
|
|
1109
|
+
} finally {
|
|
1110
|
+
await rm(dir, { recursive: true, force: true }).catch(() => null);
|
|
1111
|
+
}
|
|
1112
|
+
},
|
|
1113
|
+
10_000,
|
|
1114
|
+
);
|
|
1115
|
+
|
|
1116
|
+
it.skipIf(!itUnix)("confirms via log growth even without a recognized busy marker", async () => {
|
|
1117
|
+
const dir = await mkdtemp(path.join(tmpdir(), "ay-confirm-log-"));
|
|
1118
|
+
try {
|
|
1119
|
+
const log = path.join(dir, "a.log");
|
|
1120
|
+
await writeFile(log, "❯ \r\n");
|
|
1121
|
+
const { submitAndConfirm } = await loadModule();
|
|
1122
|
+
await withFifo(async (fifo) => {
|
|
1123
|
+
// Simulate the CLI starting to respond shortly after Enter lands — well
|
|
1124
|
+
// within the first attempt's confirm window.
|
|
1125
|
+
setTimeout(() => appendFileSync(log, "some real response text appears here\r\n"), 100);
|
|
1126
|
+
const { confirmed } = await submitAndConfirm(rec({ log_file: log }), fifo, "\r");
|
|
1127
|
+
expect(confirmed).toBe(true);
|
|
1128
|
+
});
|
|
1129
|
+
} finally {
|
|
1130
|
+
await rm(dir, { recursive: true, force: true }).catch(() => null);
|
|
1131
|
+
}
|
|
1132
|
+
});
|
|
1133
|
+
|
|
1134
|
+
it.skipIf(!itUnix)(
|
|
1135
|
+
"gives up after exhausting retries when the Enter is swallowed (screen never changes)",
|
|
1136
|
+
async () => {
|
|
1137
|
+
const dir = await mkdtemp(path.join(tmpdir(), "ay-confirm-log-"));
|
|
1138
|
+
try {
|
|
1139
|
+
const log = path.join(dir, "a.log");
|
|
1140
|
+
await writeFile(log, IDLE); // never touched again — nothing to confirm
|
|
1141
|
+
const { submitAndConfirm } = await loadModule();
|
|
1142
|
+
await withFifo(async (fifo) => {
|
|
1143
|
+
const { confirmed, screen } = await submitAndConfirm(rec({ log_file: log }), fifo, "\r");
|
|
1144
|
+
expect(confirmed).toBe(false);
|
|
1145
|
+
expect(screen.join("\n")).toContain("leftover unsent text");
|
|
1146
|
+
});
|
|
1147
|
+
} finally {
|
|
1148
|
+
await rm(dir, { recursive: true, force: true }).catch(() => null);
|
|
1149
|
+
}
|
|
1150
|
+
},
|
|
1151
|
+
10_000,
|
|
1152
|
+
);
|
|
1153
|
+
});
|
|
1154
|
+
|
|
1155
|
+
describe("subcommands.cmdSend end-to-end submit-confirm wiring", () => {
|
|
1156
|
+
const itUnix = process.platform === "linux" || process.platform === "darwin";
|
|
1157
|
+
const BUSY = "⏺ Cogitating…\r\nesc to interrupt · ← for agents\r\n";
|
|
1158
|
+
|
|
1159
|
+
// A background drain so cmdSend's writes to the FIFO never block, mirroring
|
|
1160
|
+
// the "delivers a message to a real FIFO" setup above.
|
|
1161
|
+
async function withDrainedFifo(fn: (fifo: string) => Promise<void>) {
|
|
1162
|
+
const { spawnSync } = await import("child_process");
|
|
1163
|
+
const dir = await mkdtemp(path.join(tmpdir(), "ay-send-e2e-"));
|
|
1164
|
+
const fifo = path.join(dir, "test.fifo");
|
|
1165
|
+
try {
|
|
1166
|
+
if (spawnSync("mkfifo", [fifo]).status !== 0) return; // mkfifo unavailable — skip
|
|
1167
|
+
const fs = await import("fs");
|
|
1168
|
+
// O_NONBLOCK is REQUIRED here: without it, readSync on an empty FIFO BLOCKS
|
|
1169
|
+
// the whole event loop. An O_RDWR fd keeps a writer open, so the read waits
|
|
1170
|
+
// for bytes that only arrive once fn() calls writeToIpc — but fn() can't make
|
|
1171
|
+
// progress while the loop is blocked in readSync → deadlock. That froze the
|
|
1172
|
+
// vitest worker so hard its 30s testTimeout couldn't even fire, wedging the CI
|
|
1173
|
+
// job for the full 6h cap. The catch below expects EAGAIN, which only happens
|
|
1174
|
+
// with O_NONBLOCK. The sibling drain in "writeToIpc reliable delivery" already
|
|
1175
|
+
// opens O_NONBLOCK for exactly this reason.
|
|
1176
|
+
const rdwrFd = fs.openSync(fifo, fs.constants.O_RDWR | fs.constants.O_NONBLOCK);
|
|
1177
|
+
const drain = setInterval(() => {
|
|
1178
|
+
try {
|
|
1179
|
+
fs.readSync(rdwrFd, Buffer.alloc(4096), 0, 4096, null);
|
|
1180
|
+
} catch {
|
|
1181
|
+
/* EAGAIN or similar — nothing pending, ignore */
|
|
1182
|
+
}
|
|
1183
|
+
}, 20);
|
|
1184
|
+
// Belt-and-suspenders: never let the drain timer alone keep the worker alive.
|
|
1185
|
+
// If a future test's fn() hangs past testTimeout, the finally below won't run
|
|
1186
|
+
// (the awaited fn stays pending), but an unref'd timer can't wedge process exit.
|
|
1187
|
+
drain.unref();
|
|
1188
|
+
try {
|
|
1189
|
+
await fn(fifo);
|
|
1190
|
+
} finally {
|
|
1191
|
+
clearInterval(drain);
|
|
1192
|
+
fs.closeSync(rdwrFd);
|
|
1193
|
+
}
|
|
1194
|
+
} finally {
|
|
1195
|
+
await rm(dir, { recursive: true, force: true }).catch(() => null);
|
|
1196
|
+
}
|
|
1197
|
+
}
|
|
1198
|
+
|
|
1199
|
+
async function send(fifo: string, log: string, body: string, extraArgs: string[] = []) {
|
|
1200
|
+
const { runSubcommand } = await loadModule();
|
|
1201
|
+
const { appendGlobalPid } = await import("./globalPidIndex.ts");
|
|
1202
|
+
await appendGlobalPid({
|
|
1203
|
+
pid: process.pid,
|
|
1204
|
+
cli: "claude",
|
|
1205
|
+
prompt: null,
|
|
1206
|
+
cwd: process.cwd(),
|
|
1207
|
+
log_file: log,
|
|
1208
|
+
fifo_file: fifo,
|
|
1209
|
+
status: "active",
|
|
1210
|
+
exit_code: null,
|
|
1211
|
+
exit_reason: null,
|
|
1212
|
+
started_at: Date.now(),
|
|
1213
|
+
});
|
|
1214
|
+
const stdout: string[] = [];
|
|
1215
|
+
const orig = process.stdout.write.bind(process.stdout);
|
|
1216
|
+
(process.stdout as any).write = (s: any) => {
|
|
1217
|
+
stdout.push(String(s));
|
|
1218
|
+
return true;
|
|
1219
|
+
};
|
|
1220
|
+
const savedAyPid = process.env.AGENT_YES_PID;
|
|
1221
|
+
delete process.env.AGENT_YES_PID; // isolate from the send-safety guard, as above
|
|
1222
|
+
try {
|
|
1223
|
+
const code = await runSubcommand([
|
|
1224
|
+
"bun",
|
|
1225
|
+
"cli.js",
|
|
1226
|
+
"send",
|
|
1227
|
+
String(process.pid),
|
|
1228
|
+
body,
|
|
1229
|
+
"--force",
|
|
1230
|
+
...extraArgs,
|
|
1231
|
+
]);
|
|
1232
|
+
return { code, stdout: stdout.join("") };
|
|
1233
|
+
} finally {
|
|
1234
|
+
process.stdout.write = orig;
|
|
1235
|
+
if (savedAyPid !== undefined) process.env.AGENT_YES_PID = savedAyPid;
|
|
1236
|
+
}
|
|
1237
|
+
}
|
|
1238
|
+
|
|
1239
|
+
it.skipIf(!itUnix)(
|
|
1240
|
+
"exits 0 and reports 'sent' when a working marker appears after the Enter (genuine confirm)",
|
|
1241
|
+
async () => {
|
|
1242
|
+
const dir = await mkdtemp(path.join(tmpdir(), "ay-send-e2e-log-"));
|
|
1243
|
+
// A STATIC pre-existing busy marker cannot confirm — that's the #157
|
|
1244
|
+
// wasAlreadyWorking guard (see the "already on screen before sending" test).
|
|
1245
|
+
// Confirmation needs a real idle→working transition (or >=8 bytes of growth)
|
|
1246
|
+
// AFTER the submit. Model that: the log is idle until we send, then a steady
|
|
1247
|
+
// trickle of the working marker lands within submitAndConfirm's window, so it
|
|
1248
|
+
// confirms deterministically without fragile single-shot timing.
|
|
1249
|
+
const log = path.join(dir, "a.log");
|
|
1250
|
+
await writeFile(log, "❯ \r\n"); // idle until the submitted Enter lands
|
|
1251
|
+
let working = false;
|
|
1252
|
+
const respond = setInterval(() => {
|
|
1253
|
+
if (working) appendFileSync(log, BUSY);
|
|
1254
|
+
}, 40);
|
|
1255
|
+
respond.unref();
|
|
1256
|
+
try {
|
|
1257
|
+
await withDrainedFifo(async (fifo) => {
|
|
1258
|
+
working = true; // the submitted Enter kicks the agent into working
|
|
1259
|
+
const { code, stdout } = await send(fifo, log, "hello");
|
|
1260
|
+
expect(code).toBe(0);
|
|
1261
|
+
expect(stdout).toMatch(/^sent to pid/);
|
|
1262
|
+
expect(stdout).not.toMatch(/NOT confirmed/);
|
|
1263
|
+
});
|
|
1264
|
+
} finally {
|
|
1265
|
+
clearInterval(respond);
|
|
1266
|
+
await rm(dir, { recursive: true, force: true }).catch(() => null);
|
|
1267
|
+
}
|
|
1268
|
+
},
|
|
1269
|
+
);
|
|
1270
|
+
|
|
1271
|
+
it.skipIf(!itUnix)(
|
|
1272
|
+
"exits non-zero and reports the leftover screen when submission can't be confirmed",
|
|
1273
|
+
async () => {
|
|
1274
|
+
const dir = await mkdtemp(path.join(tmpdir(), "ay-send-e2e-log-"));
|
|
1275
|
+
try {
|
|
1276
|
+
const log = path.join(dir, "a.log");
|
|
1277
|
+
await writeFile(log, "❯ \r\n"); // stays this way — nothing ever confirms
|
|
1278
|
+
await withDrainedFifo(async (fifo) => {
|
|
1279
|
+
const { code, stdout } = await send(fifo, log, "hello");
|
|
1280
|
+
expect(code).toBe(1);
|
|
1281
|
+
expect(stdout).toMatch(/NOT confirmed/);
|
|
1282
|
+
});
|
|
1283
|
+
} finally {
|
|
1284
|
+
await rm(dir, { recursive: true, force: true }).catch(() => null);
|
|
1285
|
+
}
|
|
1286
|
+
},
|
|
1287
|
+
10_000,
|
|
1288
|
+
);
|
|
1289
|
+
|
|
1290
|
+
it.skipIf(!itUnix)(
|
|
1291
|
+
"applies submit-confirm for --code=cr too, not just the default --code=enter",
|
|
1292
|
+
async () => {
|
|
1293
|
+
// canConfirm gates on the resolved trailing byte, not the code NAME, so
|
|
1294
|
+
// every alias that resolves to Enter must go through the same confirm
|
|
1295
|
+
// path — regression test for that alias-vs-byte gap.
|
|
1296
|
+
const dir = await mkdtemp(path.join(tmpdir(), "ay-send-e2e-log-"));
|
|
1297
|
+
try {
|
|
1298
|
+
const log = path.join(dir, "a.log");
|
|
1299
|
+
await writeFile(log, "❯ \r\n"); // never confirms — nothing ever changes
|
|
1300
|
+
await withDrainedFifo(async (fifo) => {
|
|
1301
|
+
const { code, stdout } = await send(fifo, log, "hello", ["--code=cr"]);
|
|
1302
|
+
expect(code).toBe(1);
|
|
1303
|
+
expect(stdout).toMatch(/NOT confirmed/);
|
|
1304
|
+
});
|
|
1305
|
+
} finally {
|
|
1306
|
+
await rm(dir, { recursive: true, force: true }).catch(() => null);
|
|
1307
|
+
}
|
|
1308
|
+
},
|
|
1309
|
+
10_000,
|
|
1310
|
+
);
|
|
1311
|
+
|
|
1312
|
+
it.skipIf(!itUnix)("--no-wait skips confirmation entirely and always exits 0", async () => {
|
|
1313
|
+
const dir = await mkdtemp(path.join(tmpdir(), "ay-send-e2e-log-"));
|
|
1314
|
+
try {
|
|
1315
|
+
const log = path.join(dir, "a.log");
|
|
1316
|
+
await writeFile(log, "❯ \r\n"); // would fail to confirm if checked — but we opt out
|
|
1317
|
+
await withDrainedFifo(async (fifo) => {
|
|
1318
|
+
const start = Date.now();
|
|
1319
|
+
const { code, stdout } = await send(fifo, log, "hello", ["--no-wait"]);
|
|
1320
|
+
expect(code).toBe(0);
|
|
1321
|
+
expect(stdout).not.toMatch(/NOT confirmed/);
|
|
1322
|
+
expect(Date.now() - start).toBeLessThan(1000); // fast — no settle/confirm polling
|
|
1323
|
+
});
|
|
1324
|
+
} finally {
|
|
1325
|
+
await rm(dir, { recursive: true, force: true }).catch(() => null);
|
|
1326
|
+
}
|
|
1327
|
+
});
|
|
1328
|
+
});
|
|
1329
|
+
|
|
989
1330
|
describe("subcommands.writeToIpc reliable delivery", () => {
|
|
990
1331
|
const itUnix = process.platform === "linux" || process.platform === "darwin";
|
|
991
1332
|
|