agent-yes 1.85.0 → 1.87.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/ts/serve.ts ADDED
@@ -0,0 +1,373 @@
1
+ import { mkdir, readFile, writeFile } from "fs/promises";
2
+ import { createHash, randomBytes, timingSafeEqual } from "crypto";
3
+ import { homedir } from "os";
4
+ import path from "path";
5
+ import yargs from "yargs";
6
+ import {
7
+ controlCodeFromName,
8
+ listRecords,
9
+ readNotes,
10
+ renderRawLog,
11
+ resolveOne,
12
+ snapshotStatus,
13
+ writeToIpc,
14
+ type CommonOpts,
15
+ } from "./subcommands.ts";
16
+
17
+ const DEFAULT_PORT = 7432;
18
+
19
+ function agentYesHome(): string {
20
+ return process.env.AGENT_YES_HOME ?? path.join(homedir(), ".agent-yes");
21
+ }
22
+
23
+ function tokenPath(): string {
24
+ return path.join(agentYesHome(), ".serve-token");
25
+ }
26
+
27
+ async function loadOrCreateToken(tokenFlag?: string): Promise<string> {
28
+ if (tokenFlag) return tokenFlag;
29
+ try {
30
+ return (await readFile(tokenPath(), "utf-8")).trim();
31
+ } catch {
32
+ const token = randomBytes(20).toString("hex");
33
+ await mkdir(agentYesHome(), { recursive: true });
34
+ await writeFile(tokenPath(), token, { mode: 0o600 });
35
+ return token;
36
+ }
37
+ }
38
+
39
+ function checkAuth(req: Request, expectedToken: string): boolean {
40
+ const auth = req.headers.get("authorization") ?? "";
41
+ if (!auth.startsWith("Bearer ")) return false;
42
+ const provided = auth.slice(7);
43
+ // Constant-time compare; pad both to the same length first
44
+ const maxLen = Math.max(provided.length, expectedToken.length);
45
+ const a = Buffer.from(provided.padEnd(maxLen, "\0"));
46
+ const b = Buffer.from(expectedToken.padEnd(maxLen, "\0"));
47
+ return timingSafeEqual(a, b) && provided.length === expectedToken.length;
48
+ }
49
+
50
+ const defaultOpts = (overrides: Partial<CommonOpts> = {}): CommonOpts => ({
51
+ all: false,
52
+ active: false,
53
+ json: true,
54
+ latest: true,
55
+ cwdScope: null,
56
+ ...overrides,
57
+ });
58
+
59
+ // ---------------------------------------------------------------------------
60
+ // ay serve install / uninstall / logs (oxmgr daemon management)
61
+ // ---------------------------------------------------------------------------
62
+
63
+ const DAEMON_NAME = "agent-yes";
64
+
65
+ async function cmdServeDaemon(sub: string, args: string[]): Promise<number> {
66
+ const oxmgrBin = Bun.which("oxmgr");
67
+ if (!oxmgrBin) {
68
+ process.stderr.write(
69
+ "ay serve install: oxmgr not found\n" +
70
+ " install with: cargo install oxmgr\n" +
71
+ " or: bun add -g oxmgr\n",
72
+ );
73
+ return 1;
74
+ }
75
+
76
+ if (sub === "install") {
77
+ const token = await loadOrCreateToken(undefined);
78
+ // Build the ay serve command with forwarded args (port, host, etc.)
79
+ const serveCmd = ["ay", "serve", ...args].join(" ");
80
+ const proc = Bun.spawn(
81
+ [oxmgrBin, "start", serveCmd, "--name", DAEMON_NAME, "--restart", "always"],
82
+ { stdio: ["ignore", "inherit", "inherit"] },
83
+ );
84
+ const code = await proc.exited;
85
+ if (code === 0) {
86
+ process.stdout.write(`\ninstalled '${DAEMON_NAME}' as a daemon via oxmgr\n`);
87
+ process.stdout.write(`token: ${token}\n\n`);
88
+ process.stdout.write(` ay ls ${token}@<host>:${DEFAULT_PORT}\n`);
89
+ process.stdout.write(` ay serve logs # view server logs\n`);
90
+ process.stdout.write(` ay serve uninstall # remove daemon\n`);
91
+ }
92
+ return code ?? 1;
93
+ }
94
+
95
+ if (sub === "uninstall") {
96
+ const proc = Bun.spawn([oxmgrBin, "delete", DAEMON_NAME], {
97
+ stdio: ["ignore", "inherit", "inherit"],
98
+ });
99
+ return (await proc.exited) ?? 1;
100
+ }
101
+
102
+ if (sub === "logs") {
103
+ const proc = Bun.spawn([oxmgrBin, "logs", DAEMON_NAME, ...args], {
104
+ stdio: ["ignore", "inherit", "inherit"],
105
+ });
106
+ return (await proc.exited) ?? 1;
107
+ }
108
+
109
+ return 1;
110
+ }
111
+
112
+ // ---------------------------------------------------------------------------
113
+ // ay serve
114
+ // ---------------------------------------------------------------------------
115
+
116
+ export async function cmdServe(rest: string[]): Promise<number> {
117
+ // Daemon subcommands
118
+ const sub = rest[0];
119
+ if (sub === "install" || sub === "uninstall" || sub === "logs") {
120
+ return cmdServeDaemon(sub, rest.slice(1));
121
+ }
122
+
123
+ const y = yargs(rest)
124
+ .usage("Usage: ay serve [options]")
125
+ .option("port", { type: "number", default: DEFAULT_PORT, description: "Port to listen on" })
126
+ .option("host", {
127
+ type: "string",
128
+ default: "127.0.0.1",
129
+ description: "Interface to bind (use 0.0.0.0 to expose)",
130
+ })
131
+ .option("token", { type: "string", description: "Auth token (auto-generated if omitted)" })
132
+ .option("tls-cert", { type: "string", description: "TLS certificate file (PEM)" })
133
+ .option("tls-key", { type: "string", description: "TLS private key file (PEM)" })
134
+ .help(false)
135
+ .version(false)
136
+ .exitProcess(false);
137
+
138
+ const argv = await y.parseAsync();
139
+ const port = (argv.port as number) ?? DEFAULT_PORT;
140
+ const host = (argv.host as string) ?? "127.0.0.1";
141
+ const tokenFlag = typeof argv.token === "string" ? argv.token : undefined;
142
+ const certPath = typeof argv["tls-cert"] === "string" ? argv["tls-cert"] : undefined;
143
+ const keyPath = typeof argv["tls-key"] === "string" ? argv["tls-key"] : undefined;
144
+
145
+ if ((certPath && !keyPath) || (!certPath && keyPath)) {
146
+ process.stderr.write("ay serve: --tls-cert and --tls-key must both be provided\n");
147
+ return 1;
148
+ }
149
+ const useHttps = !!(certPath && keyPath);
150
+ const scheme = useHttps ? "https" : "http";
151
+
152
+ if (host !== "127.0.0.1" && host !== "localhost") {
153
+ process.stderr.write(
154
+ "ay serve: warning: binding to non-loopback — ensure your network is trusted or use Tailscale/VPN\n",
155
+ );
156
+ }
157
+
158
+ const token = await loadOrCreateToken(tokenFlag);
159
+
160
+ const serverOpts: any = {
161
+ hostname: host,
162
+ port,
163
+ async fetch(req: Request): Promise<Response> {
164
+ if (!checkAuth(req, token)) {
165
+ return new Response("Unauthorized", { status: 401 });
166
+ }
167
+
168
+ const url = new URL(req.url);
169
+ const p = url.pathname;
170
+
171
+ // GET /api/ls
172
+ if (req.method === "GET" && p === "/api/ls") {
173
+ const keyword = url.searchParams.get("keyword") ?? undefined;
174
+ const opts = defaultOpts({
175
+ all: url.searchParams.get("all") === "1",
176
+ active: url.searchParams.get("active") === "1",
177
+ });
178
+ try {
179
+ const records = await listRecords(keyword, opts);
180
+ return Response.json(records);
181
+ } catch (e) {
182
+ return new Response((e as Error).message, { status: 500 });
183
+ }
184
+ }
185
+
186
+ // GET /api/notes
187
+ if (req.method === "GET" && p === "/api/notes") {
188
+ const notes = await readNotes();
189
+ return Response.json(Object.fromEntries(notes));
190
+ }
191
+
192
+ // GET /api/status/:keyword
193
+ const statusM = /^\/api\/status\/(.+)$/.exec(p);
194
+ if (req.method === "GET" && statusM) {
195
+ const keyword = decodeURIComponent(statusM[1]!);
196
+ try {
197
+ const record = await resolveOne(keyword, defaultOpts({ all: true }));
198
+ const snap = await snapshotStatus(record);
199
+ return Response.json(snap);
200
+ } catch (e) {
201
+ return new Response((e as Error).message, { status: 404 });
202
+ }
203
+ }
204
+
205
+ // GET /api/read/:keyword?mode=cat|tail|head&n=N — static log read
206
+ const readM = /^\/api\/read\/(.+)$/.exec(p);
207
+ if (req.method === "GET" && readM) {
208
+ const keyword = decodeURIComponent(readM[1]!);
209
+ const mode = (url.searchParams.get("mode") ?? "tail") as "cat" | "tail" | "head";
210
+ const n = parseInt(url.searchParams.get("n") ?? "96", 10) || 96;
211
+ try {
212
+ const record = await resolveOne(keyword, defaultOpts());
213
+ if (!record.log_file)
214
+ return new Response(`pid ${record.pid}: no log_file`, { status: 404 });
215
+ const buf = await readFile(record.log_file);
216
+ const text = await renderRawLog(buf, { mode, n });
217
+ return new Response(text, { headers: { "Content-Type": "text/plain; charset=utf-8" } });
218
+ } catch (e) {
219
+ return new Response((e as Error).message, { status: 404 });
220
+ }
221
+ }
222
+
223
+ // GET /api/tail/:keyword — SSE streaming
224
+ const tailM = /^\/api\/tail\/(.+)$/.exec(p);
225
+ if (req.method === "GET" && tailM) {
226
+ const keyword = decodeURIComponent(tailM[1]!);
227
+ try {
228
+ const record = await resolveOne(keyword, defaultOpts());
229
+ if (!record.log_file)
230
+ return new Response(`pid ${record.pid}: no log_file`, { status: 404 });
231
+ const logPath = record.log_file;
232
+
233
+ const stream = new ReadableStream({
234
+ async start(ctrl) {
235
+ const enc = new TextEncoder();
236
+ const send = (text: string) =>
237
+ ctrl.enqueue(enc.encode(`data: ${JSON.stringify(text)}\n\n`));
238
+ const ping = () => ctrl.enqueue(enc.encode(": ping\n\n"));
239
+
240
+ // Initial tail
241
+ const initBuf = await readFile(logPath).catch(() => Buffer.alloc(0));
242
+ const initText = await renderRawLog(initBuf, { mode: "tail", n: 96 });
243
+ send(initText);
244
+
245
+ let offset = initBuf.length;
246
+ let closed = false;
247
+
248
+ const heartbeat = setInterval(() => {
249
+ if (closed) {
250
+ clearInterval(heartbeat);
251
+ return;
252
+ }
253
+ ping();
254
+ }, 15_000);
255
+
256
+ // eslint-disable-next-line no-control-regex
257
+ const ansiRe =
258
+ /\x1b\[[0-?]*[ -/]*[@-~]|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)|\x1b[@-Z\\-_]/g;
259
+ // eslint-disable-next-line no-control-regex
260
+ const ctrlRe = /[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/g;
261
+
262
+ const poller = setInterval(async () => {
263
+ if (closed) {
264
+ clearInterval(poller);
265
+ return;
266
+ }
267
+ try {
268
+ const full = await readFile(logPath);
269
+ if (full.length <= offset) return;
270
+ const chunk = full.slice(offset);
271
+ offset = full.length;
272
+ const text = new TextDecoder()
273
+ .decode(chunk)
274
+ .replace(ansiRe, "")
275
+ .replace(ctrlRe, "");
276
+ if (text.trim()) send(text.trimStart());
277
+ } catch {
278
+ /* log gone */
279
+ }
280
+ }, 300);
281
+
282
+ req.signal.addEventListener("abort", () => {
283
+ closed = true;
284
+ clearInterval(heartbeat);
285
+ clearInterval(poller);
286
+ try {
287
+ ctrl.close();
288
+ } catch {
289
+ /* already closed */
290
+ }
291
+ });
292
+ },
293
+ });
294
+
295
+ return new Response(stream, {
296
+ headers: {
297
+ "Content-Type": "text/event-stream",
298
+ "Cache-Control": "no-cache",
299
+ Connection: "keep-alive",
300
+ },
301
+ });
302
+ } catch (e) {
303
+ return new Response((e as Error).message, { status: 404 });
304
+ }
305
+ }
306
+
307
+ // POST /api/send body: {keyword, msg, code?}
308
+ if (req.method === "POST" && p === "/api/send") {
309
+ let body: { keyword: string; msg: string; code?: string };
310
+ try {
311
+ body = await req.json();
312
+ } catch {
313
+ return new Response("invalid JSON body", { status: 400 });
314
+ }
315
+ const { keyword, msg = "", code = "enter" } = body;
316
+ if (!keyword || typeof keyword !== "string") {
317
+ return new Response("missing keyword", { status: 400 });
318
+ }
319
+ try {
320
+ const record = await resolveOne(keyword, defaultOpts());
321
+ if (!record.fifo_file)
322
+ return new Response(`pid ${record.pid}: no fifo_file`, { status: 409 });
323
+ const trailing = controlCodeFromName(code.toLowerCase());
324
+ if (msg && trailing) {
325
+ await writeToIpc(record.fifo_file, msg);
326
+ await new Promise((r) => setTimeout(r, 200));
327
+ await writeToIpc(record.fifo_file, trailing);
328
+ } else {
329
+ await writeToIpc(record.fifo_file, msg + trailing);
330
+ }
331
+ return Response.json({ ok: true, pid: record.pid });
332
+ } catch (e) {
333
+ return new Response((e as Error).message, { status: 404 });
334
+ }
335
+ }
336
+
337
+ return new Response("Not Found", { status: 404 });
338
+ },
339
+ };
340
+
341
+ if (useHttps) {
342
+ serverOpts.tls = { cert: Bun.file(certPath!), key: Bun.file(keyPath!) };
343
+ }
344
+
345
+ const server = Bun.serve(serverOpts);
346
+
347
+ process.stdout.write(`ay serve ${scheme}://${host}:${port}\n`);
348
+ process.stdout.write(`token: ${token}\n\n`);
349
+ process.stdout.write(`connect from another machine:\n`);
350
+ process.stdout.write(` ay ls ${token}@<host>:${port}\n`);
351
+ process.stdout.write(` ay tail ${token}@<host>:${port}:<keyword>\n`);
352
+ process.stdout.write(` ay send ${token}@<host>:${port}:<keyword> "message"\n\n`);
353
+ if (!useHttps) {
354
+ process.stdout.write(
355
+ `for HTTPS: ay serve --tls-cert cert.pem --tls-key key.pem\n` +
356
+ ` openssl req -x509 -newkey rsa:2048 -keyout key.pem -out cert.pem -days 365 -nodes -subj '/CN=localhost'\n\n`,
357
+ );
358
+ }
359
+ process.stdout.write(`(Ctrl-C to stop)\n`);
360
+
361
+ await new Promise<void>((resolve) => {
362
+ process.on("SIGINT", () => {
363
+ server.stop();
364
+ resolve();
365
+ });
366
+ process.on("SIGTERM", () => {
367
+ server.stop();
368
+ resolve();
369
+ });
370
+ });
371
+
372
+ return 0;
373
+ }
@@ -926,6 +926,109 @@ describe("subcommands.cmdStatus", () => {
926
926
  expect(snap).toMatchObject({ pid: process.pid, cli: "claude" });
927
927
  expect(typeof snap.age_ms).toBe("number");
928
928
  });
929
+
930
+ it("--wait-idle returns 0 immediately for an idle agent", async () => {
931
+ const mod = await loadModule();
932
+ const { appendGlobalPid } = await import("./globalPidIndex.ts");
933
+ const logFile = path.join(testHome, "idle.raw.log");
934
+ await writeFile(logFile, "old\n");
935
+ // Stale mtime: > IDLE_THRESHOLD_MS (60s) in the past
936
+ const stale = (Date.now() - 5 * 60 * 1000) / 1000;
937
+ const { utimes } = await import("fs/promises");
938
+ await utimes(logFile, stale, stale);
939
+ await appendGlobalPid({
940
+ pid: process.pid,
941
+ cli: "claude",
942
+ prompt: "wait-idle-test",
943
+ cwd: process.cwd(),
944
+ log_file: logFile,
945
+ status: "active",
946
+ exit_code: null,
947
+ exit_reason: null,
948
+ started_at: Date.now() - 10_000,
949
+ });
950
+
951
+ const stdout: string[] = [];
952
+ (process.stdout as any).write = (s: any) => {
953
+ stdout.push(String(s));
954
+ return true;
955
+ };
956
+ (process.stderr as any).write = () => true;
957
+ const code = await mod.runSubcommand([
958
+ "bun",
959
+ "cli.js",
960
+ "status",
961
+ String(process.pid),
962
+ "--wait-idle",
963
+ "--timeout=2s",
964
+ "--interval=0.5",
965
+ ]);
966
+ expect(code).toBe(0);
967
+ const snap = JSON.parse(stdout.join("").trim().split("\n").pop()!);
968
+ expect(snap.state).toBe("idle");
969
+ });
970
+
971
+ it("--wait-idle returns 1 when the agent is stopped", async () => {
972
+ const mod = await loadModule();
973
+ const { appendGlobalPid } = await import("./globalPidIndex.ts");
974
+ // Pick a pid that is almost certainly not alive.
975
+ const deadPid = 999_999;
976
+ await appendGlobalPid({
977
+ pid: deadPid,
978
+ cli: "claude",
979
+ prompt: "wait-idle-stopped",
980
+ cwd: process.cwd(),
981
+ log_file: null,
982
+ status: "active",
983
+ exit_code: null,
984
+ exit_reason: null,
985
+ started_at: Date.now() - 10_000,
986
+ });
987
+
988
+ (process.stdout as any).write = () => true;
989
+ (process.stderr as any).write = () => true;
990
+ const code = await mod.runSubcommand([
991
+ "bun",
992
+ "cli.js",
993
+ "status",
994
+ String(deadPid),
995
+ "--wait-idle",
996
+ "--interval=0.5",
997
+ ]);
998
+ expect(code).toBe(1);
999
+ });
1000
+
1001
+ it("--wait-idle returns 2 on timeout while still active", async () => {
1002
+ const mod = await loadModule();
1003
+ const { appendGlobalPid } = await import("./globalPidIndex.ts");
1004
+ const logFile = path.join(testHome, "active.raw.log");
1005
+ await writeFile(logFile, "fresh\n");
1006
+ // Fresh mtime keeps state = active
1007
+ await appendGlobalPid({
1008
+ pid: process.pid,
1009
+ cli: "claude",
1010
+ prompt: "wait-idle-timeout",
1011
+ cwd: process.cwd(),
1012
+ log_file: logFile,
1013
+ status: "active",
1014
+ exit_code: null,
1015
+ exit_reason: null,
1016
+ started_at: Date.now() - 10_000,
1017
+ });
1018
+
1019
+ (process.stdout as any).write = () => true;
1020
+ (process.stderr as any).write = () => true;
1021
+ const code = await mod.runSubcommand([
1022
+ "bun",
1023
+ "cli.js",
1024
+ "status",
1025
+ String(process.pid),
1026
+ "--wait-idle",
1027
+ "--timeout=600ms",
1028
+ "--interval=0.5",
1029
+ ]);
1030
+ expect(code).toBe(2);
1031
+ });
929
1032
  });
930
1033
 
931
1034
  // ---------------------------------------------------------------------------