agent-yes 1.121.0 → 1.122.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 +27 -4
- package/dist/SUPPORTED_CLIS-BVqe3k7F.js +8 -0
- package/dist/{SUPPORTED_CLIS-O57LGUEG.js → SUPPORTED_CLIS-DFYbm2uk.js} +2 -2
- package/dist/{agent-yes.config-kmtJKJHk.js → agent-yes.config-z-IPzH5U.js} +3 -2
- package/dist/cli.js +5 -5
- package/dist/index.js +2 -2
- package/dist/reaper-Dj8R7ltI.js +64 -0
- package/dist/reaper-HqcUms2d.js +3 -0
- package/dist/{remotes-DavR4Hca.js → remotes-CpGcTr7A.js} +1 -1
- package/dist/{remotes-BufkGk0e.js → remotes-D2fqaRU8.js} +1 -1
- package/dist/schedule-CJaNc82S.js +144 -0
- package/dist/{serve-D2czcYNC.js → serve-VczpuYDk.js} +121 -26
- package/dist/{setup-f1FIFcZm.js → setup-DveWqmSR.js} +5 -42
- package/dist/{share-B6QVr5D1.js → share-ClsUSd_0.js} +69 -9
- package/dist/{subcommands-CzpZQHO6.js → subcommands-CmNGbbIA.js} +15 -6
- package/dist/{subcommands-DobVXouH.js → subcommands-CzZ4uBIa.js} +2 -2
- package/dist/{tray-B8_rx1iu.js → tray-DjCIyakK.js} +22 -10
- package/dist/{ts-D91dm1E0.js → ts-7kSDmCpQ.js} +76 -7
- package/dist/{versionChecker-CAtpgnoQ.js → versionChecker-B5vxV_hH.js} +13 -19
- package/dist/workspaceConfig-XP2NEWmV.js +56 -0
- package/lab/ui/index.html +63 -32
- package/package.json +1 -1
- package/ts/autoRetry.spec.ts +19 -0
- package/ts/autoRetry.ts +16 -0
- package/ts/configShared.ts +4 -0
- package/ts/index.ts +102 -0
- package/ts/oxmgrService.ts +36 -0
- package/ts/pty.ts +19 -1
- package/ts/reaper.spec.ts +45 -0
- package/ts/reaper.ts +77 -0
- package/ts/schedule.spec.ts +30 -0
- package/ts/schedule.ts +161 -0
- package/ts/serve.ts +174 -19
- package/ts/share.ts +81 -10
- package/ts/subcommands.ts +0 -0
- package/ts/tray.spec.ts +9 -1
- package/ts/tray.ts +30 -14
- package/ts/versionChecker.ts +24 -27
- package/dist/SUPPORTED_CLIS-CegJgoEf.js +0 -8
package/ts/schedule.ts
ADDED
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { SUPPORTED_CLIS } from "./SUPPORTED_CLIS.ts";
|
|
3
|
+
import { resolveSpawnCwd } from "./workspaceConfig.ts";
|
|
4
|
+
import { ensureBootAutostart } from "./oxmgrService.ts";
|
|
5
|
+
|
|
6
|
+
// `ay schedule` — run an agent on a recurring schedule via oxmgr's cron support.
|
|
7
|
+
// A scheduled job runs once immediately AND on every cron tick (with
|
|
8
|
+
// --restart never so it isn't relaunched merely on exit), and — like the share
|
|
9
|
+
// daemon — survives reboot once oxmgr's system service is installed.
|
|
10
|
+
|
|
11
|
+
const SCHED_PREFIX = "agent-yes-cron-";
|
|
12
|
+
|
|
13
|
+
/** `HH:MM` → a daily cron; otherwise a raw 5-field cron passes through. null if neither. */
|
|
14
|
+
export function toCron(spec: string): string | null {
|
|
15
|
+
const s = spec.trim();
|
|
16
|
+
const hm = /^(\d{1,2}):(\d{2})$/.exec(s);
|
|
17
|
+
if (hm) {
|
|
18
|
+
const h = Number(hm[1]),
|
|
19
|
+
m = Number(hm[2]);
|
|
20
|
+
return h < 24 && m < 60 ? `${m} ${h} * * *` : null;
|
|
21
|
+
}
|
|
22
|
+
return /^\S+(\s+\S+){4}$/.test(s) ? s : null; // exactly 5 whitespace-separated fields
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Single-quote for oxmgr's shell-style command parsing (verified: it respects quotes). */
|
|
26
|
+
export function shellQuote(s: string): string {
|
|
27
|
+
return `'${s.replace(/'/g, `'\\''`)}'`;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function schedName(explicit: string | undefined, cli: string, key: string): string {
|
|
31
|
+
if (explicit) return explicit.startsWith(SCHED_PREFIX) ? explicit : SCHED_PREFIX + explicit;
|
|
32
|
+
return SCHED_PREFIX + cli + "-" + createHash("sha1").update(key).digest("hex").slice(0, 6);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async function run(cmd: string[], capture = false): Promise<{ code: number; out: string }> {
|
|
36
|
+
const p = Bun.spawn(cmd, {
|
|
37
|
+
stdin: "ignore",
|
|
38
|
+
stdout: capture ? "pipe" : "inherit",
|
|
39
|
+
stderr: capture ? "pipe" : "inherit",
|
|
40
|
+
});
|
|
41
|
+
const out = capture ? await new Response(p.stdout).text() : "";
|
|
42
|
+
return { code: (await p.exited) ?? 1, out };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export async function cmdSchedule(rest: string[]): Promise<number> {
|
|
46
|
+
const oxmgrBin = Bun.which("oxmgr");
|
|
47
|
+
if (!oxmgrBin) {
|
|
48
|
+
process.stderr.write(
|
|
49
|
+
"ay schedule: oxmgr not found\n" +
|
|
50
|
+
" install with: cargo install oxmgr\n" +
|
|
51
|
+
" or: bun add -g oxmgr\n",
|
|
52
|
+
);
|
|
53
|
+
return 1;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const sub = rest[0];
|
|
57
|
+
|
|
58
|
+
if (!sub || sub === "-h" || sub === "--help") {
|
|
59
|
+
process.stdout.write(
|
|
60
|
+
`Usage:\n` +
|
|
61
|
+
` ay schedule <when> <cli> [--cwd DIR] [--name N] [-- <prompt>]\n` +
|
|
62
|
+
` schedule a recurring agent\n` +
|
|
63
|
+
` ay schedule list list scheduled agents\n` +
|
|
64
|
+
` ay schedule remove <name> remove one\n\n` +
|
|
65
|
+
`<when> is a daily HH:MM (e.g. 10:00) or a 5-field cron ("0 10 * * *").\n` +
|
|
66
|
+
`The agent runs once now and then on every tick, and survives reboot.\n\n` +
|
|
67
|
+
`Example — a 10am daily QA pass:\n` +
|
|
68
|
+
` ay schedule 10:00 claude --cwd ~/ws/product -- "run a full QA pass and write a report"\n`,
|
|
69
|
+
);
|
|
70
|
+
return 0;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if (sub === "list" || sub === "ls") {
|
|
74
|
+
// oxmgr lists everything; scheduled agents are the agent-yes-cron-* rows.
|
|
75
|
+
process.stdout.write(`scheduled agents are named '${SCHED_PREFIX}*':\n`);
|
|
76
|
+
return (await run([oxmgrBin, "list"])).code;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
if (sub === "remove" || sub === "rm" || sub === "delete") {
|
|
80
|
+
const name = rest[1];
|
|
81
|
+
if (!name) {
|
|
82
|
+
process.stderr.write("usage: ay schedule remove <name>\n");
|
|
83
|
+
return 1;
|
|
84
|
+
}
|
|
85
|
+
const full = name.startsWith(SCHED_PREFIX) ? name : SCHED_PREFIX + name;
|
|
86
|
+
return (await run([oxmgrBin, "delete", full])).code;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// ay schedule <when> <cli> [--cwd DIR] [--name N] [-- <prompt>]
|
|
90
|
+
const dashIdx = rest.indexOf("--");
|
|
91
|
+
const head = dashIdx >= 0 ? rest.slice(0, dashIdx) : rest;
|
|
92
|
+
const prompt = dashIdx >= 0 ? rest.slice(dashIdx + 1).join(" ") : "";
|
|
93
|
+
|
|
94
|
+
let nameFlag: string | undefined;
|
|
95
|
+
let cwdFlag: string | undefined;
|
|
96
|
+
const pos: string[] = [];
|
|
97
|
+
for (let i = 0; i < head.length; i++) {
|
|
98
|
+
if (head[i] === "--name") nameFlag = head[++i];
|
|
99
|
+
else if (head[i] === "--cwd") cwdFlag = head[++i];
|
|
100
|
+
else pos.push(head[i]!);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const [when, cli] = pos;
|
|
104
|
+
if (!when || !cli) {
|
|
105
|
+
process.stderr.write("usage: ay schedule <when> <cli> [-- <prompt>]\n");
|
|
106
|
+
return 1;
|
|
107
|
+
}
|
|
108
|
+
const cron = toCron(when);
|
|
109
|
+
if (!cron) {
|
|
110
|
+
process.stderr.write(`ay schedule: bad <when> "${when}" — use HH:MM or a 5-field cron\n`);
|
|
111
|
+
return 1;
|
|
112
|
+
}
|
|
113
|
+
if (!SUPPORTED_CLIS.includes(cli as never)) {
|
|
114
|
+
process.stderr.write(`ay schedule: unsupported cli "${cli}"\n`);
|
|
115
|
+
return 1;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const cwd = resolveSpawnCwd(cwdFlag);
|
|
119
|
+
// Absolute interpreter + bin: oxmgr's daemon PATH may lack ~/.bun/bin. Quote
|
|
120
|
+
// every piece — oxmgr shell-parses the command, so a space in the interpreter
|
|
121
|
+
// path, the ay path, or the prompt would otherwise split into bogus args.
|
|
122
|
+
const ayBin = Bun.which("ay");
|
|
123
|
+
const ayInvoke = ayBin ? `${shellQuote(process.execPath)} ${shellQuote(ayBin)}` : "ay";
|
|
124
|
+
const agentCmd = `${ayInvoke} ${cli}${prompt ? ` -- ${shellQuote(prompt)}` : ""}`;
|
|
125
|
+
const name = schedName(nameFlag, cli, cron + "\0" + prompt + "\0" + cwd);
|
|
126
|
+
|
|
127
|
+
const { code } = await run([
|
|
128
|
+
oxmgrBin,
|
|
129
|
+
"start",
|
|
130
|
+
agentCmd,
|
|
131
|
+
"--name",
|
|
132
|
+
name,
|
|
133
|
+
"--cwd",
|
|
134
|
+
cwd,
|
|
135
|
+
"--restart",
|
|
136
|
+
"never", // only (re)launched by the cron tick, not on plain exit
|
|
137
|
+
"--cron-restart",
|
|
138
|
+
cron,
|
|
139
|
+
]);
|
|
140
|
+
if (code !== 0) return code;
|
|
141
|
+
|
|
142
|
+
// Persist across reboots (idempotent; same wrapper the daemon install uses).
|
|
143
|
+
// Best-effort: the schedule is already registered, so don't fail the command if
|
|
144
|
+
// boot registration can't be done here — just report it honestly. This SKIPS
|
|
145
|
+
// the install when the service is already registered, so it won't bounce the
|
|
146
|
+
// oxmgr daemon (and every process it manages) on a routine `ay schedule`.
|
|
147
|
+
const onBoot = await ensureBootAutostart(oxmgrBin);
|
|
148
|
+
|
|
149
|
+
process.stdout.write(
|
|
150
|
+
`\nscheduled '${name}'\n` +
|
|
151
|
+
` ${cli}${prompt ? ` -- "${prompt.slice(0, 60)}${prompt.length > 60 ? "…" : ""}"` : ""}\n` +
|
|
152
|
+
` when: ${cron} cwd: ${cwd}\n` +
|
|
153
|
+
(onBoot
|
|
154
|
+
? ` runs now and on every tick; survives reboot.\n`
|
|
155
|
+
: ` runs now and on every tick.\n` +
|
|
156
|
+
` start-on-boot: not registered — run \`oxmgr service install\` to enable\n`) +
|
|
157
|
+
`\n ay schedule list # all scheduled agents\n` +
|
|
158
|
+
` ay schedule remove ${name.slice(SCHED_PREFIX.length)}\n`,
|
|
159
|
+
);
|
|
160
|
+
return 0;
|
|
161
|
+
}
|
package/ts/serve.ts
CHANGED
|
@@ -40,6 +40,26 @@ async function loadOrCreateToken(tokenFlag?: string): Promise<string> {
|
|
|
40
40
|
}
|
|
41
41
|
}
|
|
42
42
|
|
|
43
|
+
// Read the serve token WITHOUT creating one — `ay serve status` must be a pure
|
|
44
|
+
// read (creating a token as a side effect of asking "is it running?" is wrong).
|
|
45
|
+
async function loadTokenReadOnly(): Promise<string | null> {
|
|
46
|
+
try {
|
|
47
|
+
return (await readFile(tokenPath(), "utf-8")).trim();
|
|
48
|
+
} catch {
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// The persisted WebRTC share link (mode 0600), written by a --share/--webrtc
|
|
54
|
+
// daemon so the secret-bearing link survives restarts. null if not sharing.
|
|
55
|
+
async function readShareLink(): Promise<string | null> {
|
|
56
|
+
try {
|
|
57
|
+
return (await readFile(path.join(agentYesHome(), ".share-link"), "utf-8")).trim();
|
|
58
|
+
} catch {
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
43
63
|
function tokenEqual(provided: string, expectedToken: string): boolean {
|
|
44
64
|
// Constant-time compare; pad both to the same length first
|
|
45
65
|
const maxLen = Math.max(provided.length, expectedToken.length);
|
|
@@ -133,32 +153,67 @@ function ayServeArgv(args: string[]): string[] {
|
|
|
133
153
|
return [...launcher, "serve", ...args];
|
|
134
154
|
}
|
|
135
155
|
|
|
156
|
+
// Per-user login auto-start entry on Windows. pm2 core has no Windows startup
|
|
157
|
+
// integration (`pm2 startup` errors "Init system not found"), so we register a
|
|
158
|
+
// HKCU Run value that runs `pm2 resurrect` at login — no admin, removed on
|
|
159
|
+
// uninstall. HKCU (not HKLM) keeps it user-scoped and admin-free.
|
|
160
|
+
const WIN_RUN_KEY = "HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Run";
|
|
161
|
+
const WIN_RUN_VALUE = DAEMON_NAME;
|
|
162
|
+
|
|
136
163
|
// Register the daemon to come up automatically. The *scope* is per-platform by
|
|
137
|
-
// design: Linux → at system boot (before login); Windows → at user login.
|
|
164
|
+
// design: Linux → at system boot (before login); macOS/Windows → at user login.
|
|
138
165
|
// - Linux (oxmgr): `oxmgr service install` wires a systemd **--user** unit,
|
|
139
166
|
// which on its own only starts after the user logs in. To make it start at
|
|
140
167
|
// boot without requiring root (no system-scope unit, no sudo), we also
|
|
141
168
|
// `loginctl enable-linger`, which keeps the user's systemd instance — and
|
|
142
169
|
// thus our service — running from boot. Best-effort; linger failing just
|
|
143
170
|
// downgrades us to login-scope.
|
|
144
|
-
// - Windows (pm2):
|
|
145
|
-
//
|
|
171
|
+
// - Windows (pm2): pm2 core can't install a startup hook here, so we save the
|
|
172
|
+
// process list and add a HKCU Run entry that runs `pm2 resurrect` at login.
|
|
173
|
+
// - macOS (pm2): `pm2 startup` wires a launchd agent (best-effort).
|
|
146
174
|
// Idempotent and best-effort: returns false on failure without aborting the
|
|
147
175
|
// install — the process is still crash-managed, just not boot/login-persistent.
|
|
148
176
|
async function ensureBootAutostart(mgr: DaemonManager): Promise<boolean> {
|
|
149
177
|
try {
|
|
150
|
-
if (mgr.id
|
|
151
|
-
//
|
|
152
|
-
|
|
178
|
+
if (mgr.id === "oxmgr") {
|
|
179
|
+
// Skip `service install` when the service is ALREADY registered: re-running
|
|
180
|
+
// it re-bootstraps the oxmgr daemon, which restarts every managed process —
|
|
181
|
+
// it once took down a VS Code serve-web session on each `ay serve install`.
|
|
182
|
+
// `oxmgr service status` exits 0 only when already installed.
|
|
183
|
+
// oxmgr's --system defaults to "auto" (launchd/systemd/Task Scheduler); it's
|
|
184
|
+
// a `service`-level flag, so it goes before the subcommand, not after.
|
|
185
|
+
const installed =
|
|
186
|
+
(await spawnExit([mgr.bin, "service", "status"])) === 0 ||
|
|
187
|
+
(await spawnExit([mgr.bin, "service", "install"])) === 0;
|
|
188
|
+
if (installed && process.platform === "linux") {
|
|
189
|
+
// Upgrade login-scope → boot-scope: linger starts the user manager at boot.
|
|
190
|
+
await spawnExit(["loginctl", "enable-linger", userInfo().username]);
|
|
191
|
+
}
|
|
192
|
+
return installed;
|
|
153
193
|
}
|
|
154
|
-
//
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
194
|
+
// pm2: persist the current process list first — boot/login resurrect reads it.
|
|
195
|
+
if ((await spawnExit([mgr.bin, "save"])) !== 0) return false;
|
|
196
|
+
if (process.platform === "win32") {
|
|
197
|
+
// pm2 has no Windows startup integration — add a HKCU Run entry ourselves.
|
|
198
|
+
const data = mgr.bin.includes(" ") ? `"${mgr.bin}" resurrect` : `${mgr.bin} resurrect`;
|
|
199
|
+
return (
|
|
200
|
+
(await spawnExit([
|
|
201
|
+
"reg",
|
|
202
|
+
"add",
|
|
203
|
+
WIN_RUN_KEY,
|
|
204
|
+
"/v",
|
|
205
|
+
WIN_RUN_VALUE,
|
|
206
|
+
"/t",
|
|
207
|
+
"REG_SZ",
|
|
208
|
+
"/d",
|
|
209
|
+
data,
|
|
210
|
+
"/f",
|
|
211
|
+
])) === 0
|
|
212
|
+
);
|
|
160
213
|
}
|
|
161
|
-
|
|
214
|
+
// macOS (and any non-Windows pm2 install): pm2 startup wires the init script
|
|
215
|
+
// (may need sudo; best-effort).
|
|
216
|
+
return (await spawnExit([mgr.bin, "startup"])) === 0;
|
|
162
217
|
} catch {
|
|
163
218
|
return false;
|
|
164
219
|
}
|
|
@@ -298,11 +353,17 @@ async function cmdServeDaemon(sub: string, args: string[]): Promise<number> {
|
|
|
298
353
|
? `start-on-boot: enabled (systemd --user + linger, starts at boot)\n`
|
|
299
354
|
: `start-on-boot: not registered — needs a user systemd session; run \`oxmgr service install\` to enable\n`,
|
|
300
355
|
);
|
|
356
|
+
else if (process.platform === "win32")
|
|
357
|
+
process.stdout.write(
|
|
358
|
+
onBoot
|
|
359
|
+
? `start-on-login: enabled (a HKCU Run entry runs \`pm2 resurrect\`)\n`
|
|
360
|
+
: `start-on-login: not registered — \`pm2 save\` or the registry write failed\n`,
|
|
361
|
+
);
|
|
301
362
|
else
|
|
302
363
|
process.stdout.write(
|
|
303
364
|
onBoot
|
|
304
|
-
? `start-on-
|
|
305
|
-
: `start-on-
|
|
365
|
+
? `start-on-boot: enabled (pm2 startup registered with the system init)\n`
|
|
366
|
+
: `start-on-boot: not registered — run \`pm2 startup\` (may need sudo) to enable\n`,
|
|
306
367
|
);
|
|
307
368
|
process.stdout.write(`token: ${token}\n\n`);
|
|
308
369
|
if (httpish) {
|
|
@@ -327,9 +388,13 @@ async function cmdServeDaemon(sub: string, args: string[]): Promise<number> {
|
|
|
327
388
|
stdio: ["ignore", "inherit", "inherit"],
|
|
328
389
|
});
|
|
329
390
|
const code = (await proc.exited) ?? 1;
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
await
|
|
391
|
+
if (mgr.id === "pm2" && code === 0) {
|
|
392
|
+
// Drop it from the persisted pm2 list too, so `pm2 resurrect` won't revive it.
|
|
393
|
+
await spawnExit([mgr.bin, "save"]);
|
|
394
|
+
// Remove the Windows login auto-start entry we added at install time.
|
|
395
|
+
if (process.platform === "win32")
|
|
396
|
+
await spawnExit(["reg", "delete", WIN_RUN_KEY, "/v", WIN_RUN_VALUE, "/f"]);
|
|
397
|
+
}
|
|
333
398
|
return code;
|
|
334
399
|
}
|
|
335
400
|
|
|
@@ -343,6 +408,84 @@ async function cmdServeDaemon(sub: string, args: string[]): Promise<number> {
|
|
|
343
408
|
return 1;
|
|
344
409
|
}
|
|
345
410
|
|
|
411
|
+
// ay serve status — report whether the server is installed as a daemon and/or
|
|
412
|
+
// currently reachable, plus its mode, port, version, token, and share link.
|
|
413
|
+
// Read-only: never mints a token or disturbs the daemon. `--json` for scripts.
|
|
414
|
+
async function cmdServeStatus(args: string[]): Promise<number> {
|
|
415
|
+
const json = args.includes("--json");
|
|
416
|
+
const mgr = resolveDaemonManager();
|
|
417
|
+
const token = await loadTokenReadOnly();
|
|
418
|
+
const shareLink = await readShareLink();
|
|
419
|
+
|
|
420
|
+
// A non-null arg list means the daemon is registered with the manager.
|
|
421
|
+
const daemonArgs = mgr ? await readDaemonServeArgs(mgr) : null;
|
|
422
|
+
const installed = daemonArgs !== null;
|
|
423
|
+
const a = daemonArgs ?? [];
|
|
424
|
+
const port = portFromArgs(a);
|
|
425
|
+
// Mirror cmdServe/install mode resolution: webrtc when --webrtc/--share; http
|
|
426
|
+
// when --http/--share OR no --webrtc at all (http is the implicit default).
|
|
427
|
+
const webrtcish = a.some((x) => x.startsWith("--webrtc") || x.startsWith("--share"));
|
|
428
|
+
const httpish =
|
|
429
|
+
a.some((x) => x.startsWith("--http") || x.startsWith("--share")) ||
|
|
430
|
+
!a.some((x) => x.startsWith("--webrtc"));
|
|
431
|
+
const mode = httpish && webrtcish ? "http+webrtc" : webrtcish ? "webrtc" : "http";
|
|
432
|
+
|
|
433
|
+
// Probe the local HTTP API — catches both a daemon and a foreground `ay serve`.
|
|
434
|
+
// Webrtc-only servers open no port, so a null probe there is expected, not down.
|
|
435
|
+
const runningVersion = httpish && token ? await fetchDaemonVersion(port, token) : null;
|
|
436
|
+
const current = getInstalledPackage().version;
|
|
437
|
+
|
|
438
|
+
if (json) {
|
|
439
|
+
process.stdout.write(
|
|
440
|
+
JSON.stringify(
|
|
441
|
+
{
|
|
442
|
+
manager: mgr?.id ?? null,
|
|
443
|
+
installed,
|
|
444
|
+
mode,
|
|
445
|
+
port: httpish ? port : null,
|
|
446
|
+
reachable: runningVersion !== null,
|
|
447
|
+
runningVersion,
|
|
448
|
+
currentVersion: current,
|
|
449
|
+
upToDate: runningVersion !== null && runningVersion === current,
|
|
450
|
+
args: a,
|
|
451
|
+
hasToken: !!token,
|
|
452
|
+
shareLink,
|
|
453
|
+
},
|
|
454
|
+
null,
|
|
455
|
+
2,
|
|
456
|
+
) + "\n",
|
|
457
|
+
);
|
|
458
|
+
return 0;
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
const w = (s = "") => process.stdout.write(s + "\n");
|
|
462
|
+
w(`daemon name: ${DAEMON_NAME}`);
|
|
463
|
+
w(`manager: ${mgr ? mgr.id : "none — install pm2 or oxmgr to daemonize"}`);
|
|
464
|
+
if (installed) {
|
|
465
|
+
w(`installed: yes (via ${mgr!.id})`);
|
|
466
|
+
w(`mode: ${mode}${httpish ? ` (port ${port})` : ""}`);
|
|
467
|
+
if (a.length) w(`args: ${a.join(" ")}`);
|
|
468
|
+
} else {
|
|
469
|
+
w(`installed: no — start a daemon with: ay serve install [--share]`);
|
|
470
|
+
}
|
|
471
|
+
if (runningVersion !== null) {
|
|
472
|
+
const tag = runningVersion === current ? "up to date" : `outdated (current v${current})`;
|
|
473
|
+
w(`http api: reachable on 127.0.0.1:${port} — v${runningVersion} (${tag})`);
|
|
474
|
+
} else if (mode === "webrtc") {
|
|
475
|
+
w(`http api: none (webrtc-only)`);
|
|
476
|
+
} else {
|
|
477
|
+
w(`http api: not reachable on 127.0.0.1:${port} (not running)`);
|
|
478
|
+
}
|
|
479
|
+
w(`token: ${token ?? "(none yet — created on first serve)"}`);
|
|
480
|
+
if (shareLink) w(`share link: ${shareLink}`);
|
|
481
|
+
if (token && httpish) {
|
|
482
|
+
w();
|
|
483
|
+
w(`connect: ay ls ${token}@<host>:${port}`);
|
|
484
|
+
w(` ay remote add <alias> http://${token}@<host>:${port}`);
|
|
485
|
+
}
|
|
486
|
+
return 0;
|
|
487
|
+
}
|
|
488
|
+
|
|
346
489
|
// ---------------------------------------------------------------------------
|
|
347
490
|
// ay serve
|
|
348
491
|
// ---------------------------------------------------------------------------
|
|
@@ -372,6 +515,7 @@ export async function cmdServe(rest: string[]): Promise<number> {
|
|
|
372
515
|
` --tls-key FILE TLS private key PEM\n\n` +
|
|
373
516
|
`Subcommands:\n` +
|
|
374
517
|
` ay serve install install as background daemon (pm2 on Windows, else oxmgr)\n` +
|
|
518
|
+
` ay serve status show daemon/server status (add --json for scripts)\n` +
|
|
375
519
|
` ay serve uninstall remove daemon\n` +
|
|
376
520
|
` ay serve logs view daemon logs\n\n` +
|
|
377
521
|
`Once running, connect from another machine:\n` +
|
|
@@ -383,6 +527,7 @@ export async function cmdServe(rest: string[]): Promise<number> {
|
|
|
383
527
|
|
|
384
528
|
// Daemon subcommands
|
|
385
529
|
const sub = rest[0];
|
|
530
|
+
if (sub === "status") return cmdServeStatus(rest.slice(1));
|
|
386
531
|
if (sub === "install" || sub === "uninstall" || sub === "logs") {
|
|
387
532
|
return cmdServeDaemon(sub, rest.slice(1));
|
|
388
533
|
}
|
|
@@ -962,8 +1107,18 @@ export async function cmdServe(rest: string[]): Promise<number> {
|
|
|
962
1107
|
process.stderr.write(
|
|
963
1108
|
`→ console spawned: ay ${cli}${prompt ? ` -- "${prompt.slice(0, 60)}"` : ""} (cwd: ${cwd})\n`,
|
|
964
1109
|
);
|
|
1110
|
+
// Resolve `ay` to an absolute command. The detached daemon (oxmgr/launchd/
|
|
1111
|
+
// pm2) usually has a PATH WITHOUT ~/.bun/bin, so a bare "ay" fails with
|
|
1112
|
+
// "Executable not found in $PATH: ay". Prefer PATH; fall back to re-running
|
|
1113
|
+
// THIS process's own ay entry (process.argv[1]) — always present, since the
|
|
1114
|
+
// daemon is itself an `ay serve`.
|
|
1115
|
+
const ayBin = Bun.which("ay") ?? process.argv[1];
|
|
1116
|
+
const ayCmd =
|
|
1117
|
+
process.platform === "win32" && ayBin.toLowerCase().endsWith(".exe")
|
|
1118
|
+
? [ayBin]
|
|
1119
|
+
: [process.execPath, ayBin];
|
|
965
1120
|
try {
|
|
966
|
-
const child = Bun.spawn([
|
|
1121
|
+
const child = Bun.spawn([...ayCmd, cli, ...(prompt ? ["--", prompt] : [])], {
|
|
967
1122
|
cwd,
|
|
968
1123
|
env: freshAgentEnv(), // don't leak our Claude Code session into the agent
|
|
969
1124
|
stdin: "ignore",
|
package/ts/share.ts
CHANGED
|
@@ -25,8 +25,47 @@ import {
|
|
|
25
25
|
} from "../lab/ui/e2e.js";
|
|
26
26
|
|
|
27
27
|
const SUB = "ay-signal-1";
|
|
28
|
-
|
|
28
|
+
// MAX_CHUNK is imported from e2e.js; ICE is replaced by STUN + getIceServers (TURN) below.
|
|
29
29
|
const DEFAULT_SIGHOST = "s.agent-yes.com";
|
|
30
|
+
const HOST_HEARTBEAT_MS = 20000; // keepalive ping to the rendezvous + silent-drop detection
|
|
31
|
+
|
|
32
|
+
type IceServer = { urls: string | string[]; username?: string; credential?: string };
|
|
33
|
+
const STUN: IceServer[] = [{ urls: "stun:stun.l.google.com:19302" }];
|
|
34
|
+
|
|
35
|
+
// Short-lived Cloudflare TURN credentials, minted from a long-term TURN key, so
|
|
36
|
+
// browsers can RELAY when a direct P2P path is impossible (symmetric NAT /
|
|
37
|
+
// CGNAT — the main cause of "rooms offline"). Set CF_TURN_KEY_ID +
|
|
38
|
+
// CF_TURN_API_TOKEN (create a TURN key in the Cloudflare dashboard: Realtime →
|
|
39
|
+
// TURN) to enable; without them we use STUN only, exactly as before. Cached
|
|
40
|
+
// until just before expiry; STUN-only fallback on any error so sharing never
|
|
41
|
+
// breaks because TURN is misconfigured or unreachable.
|
|
42
|
+
let iceCache: { servers: IceServer[]; exp: number } | null = null;
|
|
43
|
+
async function getIceServers(): Promise<IceServer[]> {
|
|
44
|
+
const keyId = process.env.CF_TURN_KEY_ID;
|
|
45
|
+
const apiToken = process.env.CF_TURN_API_TOKEN;
|
|
46
|
+
if (!keyId || !apiToken) return STUN;
|
|
47
|
+
if (iceCache && iceCache.exp > Date.now()) return iceCache.servers;
|
|
48
|
+
const ttl = 3600; // credential lifetime, seconds
|
|
49
|
+
try {
|
|
50
|
+
const r = await fetch(
|
|
51
|
+
`https://rtc.live.cloudflare.com/v1/turn/keys/${keyId}/credentials/generate-ice-servers`,
|
|
52
|
+
{
|
|
53
|
+
method: "POST",
|
|
54
|
+
headers: { Authorization: `Bearer ${apiToken}`, "Content-Type": "application/json" },
|
|
55
|
+
body: JSON.stringify({ ttl }),
|
|
56
|
+
signal: AbortSignal.timeout(5000),
|
|
57
|
+
},
|
|
58
|
+
);
|
|
59
|
+
if (!r.ok) throw new Error(`Cloudflare TURN ${r.status}`);
|
|
60
|
+
const j = (await r.json()) as { iceServers?: IceServer[] };
|
|
61
|
+
const servers = j.iceServers?.length ? j.iceServers : STUN;
|
|
62
|
+
iceCache = { servers, exp: Date.now() + (ttl - 300) * 1000 }; // refresh ~5min early
|
|
63
|
+
return servers;
|
|
64
|
+
} catch (e) {
|
|
65
|
+
console.error(`[share] Cloudflare TURN credential fetch failed; using STUN only: ${e}`);
|
|
66
|
+
return STUN;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
30
69
|
|
|
31
70
|
export interface ShareOpts {
|
|
32
71
|
/** webrtc://room:token@host, or undefined to mint a fresh (unpersisted)
|
|
@@ -216,15 +255,43 @@ export async function startShare(
|
|
|
216
255
|
const ws = new WebSocket(`${wsScheme}://${host}/${room}`, [SUB]);
|
|
217
256
|
currentWs = ws;
|
|
218
257
|
let ready = false;
|
|
258
|
+
let lastRecv = Date.now();
|
|
259
|
+
let hb: ReturnType<typeof setInterval> | undefined;
|
|
260
|
+
const stopHb = () => {
|
|
261
|
+
if (hb) {
|
|
262
|
+
clearInterval(hb);
|
|
263
|
+
hb = undefined;
|
|
264
|
+
}
|
|
265
|
+
};
|
|
219
266
|
ws.onopen = () => {
|
|
220
267
|
ws.send(JSON.stringify({ type: "hello", role: "host", v: 2, token: authToken }));
|
|
221
268
|
ready = true;
|
|
269
|
+
lastRecv = Date.now();
|
|
270
|
+
// Keepalive + dead-link detection: ping the rendezvous and expect a pong.
|
|
271
|
+
// A silently dropped ws (idle DO timeout, network flap) never fires
|
|
272
|
+
// onclose, so if the server goes quiet for ~2 intervals, close+reconnect
|
|
273
|
+
// ourselves — otherwise new browsers can't join until the process restarts.
|
|
274
|
+
stopHb();
|
|
275
|
+
hb = setInterval(() => {
|
|
276
|
+
if (Date.now() - lastRecv > HOST_HEARTBEAT_MS * 2 + 5000) {
|
|
277
|
+
stopHb();
|
|
278
|
+
try {
|
|
279
|
+
ws.close();
|
|
280
|
+
} catch {}
|
|
281
|
+
return;
|
|
282
|
+
}
|
|
283
|
+
try {
|
|
284
|
+
ws.send(JSON.stringify({ type: "ping" }));
|
|
285
|
+
} catch {}
|
|
286
|
+
}, HOST_HEARTBEAT_MS);
|
|
222
287
|
onReady();
|
|
223
288
|
};
|
|
224
289
|
ws.onmessage = async (ev) => {
|
|
225
290
|
if (closed) return;
|
|
291
|
+
lastRecv = Date.now();
|
|
226
292
|
const m = JSON.parse(ev.data as string);
|
|
227
|
-
if (m.type === "
|
|
293
|
+
if (m.type === "pong") return; // heartbeat ack — liveness already recorded
|
|
294
|
+
if (m.type === "peer-join") startPeer(ws, m.peer).catch(() => {});
|
|
228
295
|
else if (m.type === "answer") {
|
|
229
296
|
const peer = peers.get(m.from);
|
|
230
297
|
if (!peer) return;
|
|
@@ -252,6 +319,7 @@ export async function startShare(
|
|
|
252
319
|
else if (m.type === "peer-leave") closePeer(m.peer);
|
|
253
320
|
};
|
|
254
321
|
ws.onclose = (ev: any) => {
|
|
322
|
+
stopHb();
|
|
255
323
|
if (closed) return; // shutting down — don't resurrect the rendezvous
|
|
256
324
|
// The signaling server pins a room to its first host's authToken. A 1008
|
|
257
325
|
// means a different generation already owns this room — don't hot-loop;
|
|
@@ -265,14 +333,15 @@ export async function startShare(
|
|
|
265
333
|
}
|
|
266
334
|
// Keep established WebRTC peers; just re-establish the rendezvous so new
|
|
267
335
|
// browsers can still join. Backoff a little to avoid hot-looping.
|
|
268
|
-
setTimeout(() => connectSignaling(() => {}), ready ?
|
|
336
|
+
setTimeout(() => connectSignaling(() => {}), ready ? 1000 : 2000);
|
|
269
337
|
};
|
|
270
338
|
ws.onerror = () => {};
|
|
271
339
|
return ws;
|
|
272
340
|
};
|
|
273
341
|
|
|
274
|
-
function startPeer(ws: WebSocket, peerId: string) {
|
|
275
|
-
const
|
|
342
|
+
async function startPeer(ws: WebSocket, peerId: string) {
|
|
343
|
+
const iceServers = await getIceServers();
|
|
344
|
+
const pc = new RTCPeerConnection({ iceServers });
|
|
276
345
|
let resolveKeys!: () => void;
|
|
277
346
|
const keysReady = new Promise<void>((r) => (resolveKeys = r));
|
|
278
347
|
const peer: Peer = {
|
|
@@ -318,11 +387,13 @@ export async function startShare(
|
|
|
318
387
|
dc.onmessage = (e: any) => {
|
|
319
388
|
peer.recvChain = peer.recvChain.then(() => onFrame(peerId, dc, peer, e.data)).catch(() => {});
|
|
320
389
|
};
|
|
321
|
-
pc.createOffer()
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
390
|
+
const offer = await pc.createOffer();
|
|
391
|
+
await pc.setLocalDescription(offer);
|
|
392
|
+
// Hand the browser the same ICE servers (incl. the short-lived TURN creds)
|
|
393
|
+
// so it can relay too when there's no direct path.
|
|
394
|
+
ws.send(
|
|
395
|
+
JSON.stringify({ type: "offer", to: peerId, sdp: pc.localDescription.sdp, iceServers }),
|
|
396
|
+
);
|
|
326
397
|
}
|
|
327
398
|
|
|
328
399
|
function closePeer(peerId: string) {
|
package/ts/subcommands.ts
CHANGED
|
Binary file
|
package/ts/tray.spec.ts
CHANGED
|
@@ -243,9 +243,12 @@ describe("tray", () => {
|
|
|
243
243
|
const originalPlatform = process.platform;
|
|
244
244
|
Object.defineProperty(process, "platform", { value: "darwin" });
|
|
245
245
|
|
|
246
|
-
// Simulate existing tray PID file with a live process (our own PID)
|
|
246
|
+
// Simulate existing tray PID file with a live process (our own PID):
|
|
247
|
+
// the exclusive-create claim (writeFile flag "wx") fails, and the live
|
|
248
|
+
// owner check confirms a tray is already running, so we bail.
|
|
247
249
|
mockFs.existsSync.mockReturnValue(true);
|
|
248
250
|
mockFsPromises.readFile.mockResolvedValue(String(process.pid));
|
|
251
|
+
mockFsPromises.writeFile.mockRejectedValueOnce(new Error("EEXIST"));
|
|
249
252
|
|
|
250
253
|
const { startTray } = await import("./tray.ts");
|
|
251
254
|
await startTray();
|
|
@@ -295,6 +298,9 @@ describe("tray", () => {
|
|
|
295
298
|
const originalPlatform = process.platform;
|
|
296
299
|
Object.defineProperty(process, "platform", { value: "darwin" });
|
|
297
300
|
mockFs.existsSync.mockReturnValue(false);
|
|
301
|
+
// Auto-spawn is opt-in (the systray2 helper busy-loops under Bun).
|
|
302
|
+
const prevTrayEnv = process.env.AGENT_YES_TRAY;
|
|
303
|
+
process.env.AGENT_YES_TRAY = "1";
|
|
298
304
|
|
|
299
305
|
const { ensureTray } = await import("./tray.ts");
|
|
300
306
|
await ensureTray();
|
|
@@ -306,6 +312,8 @@ describe("tray", () => {
|
|
|
306
312
|
);
|
|
307
313
|
expect(mockSpawn.child.unref).toHaveBeenCalled();
|
|
308
314
|
|
|
315
|
+
if (prevTrayEnv === undefined) delete process.env.AGENT_YES_TRAY;
|
|
316
|
+
else process.env.AGENT_YES_TRAY = prevTrayEnv;
|
|
309
317
|
Object.defineProperty(process, "platform", { value: originalPlatform });
|
|
310
318
|
});
|
|
311
319
|
|