agent-yes 1.97.0 → 1.99.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-BGUPuqya.js +8 -0
- package/dist/{SUPPORTED_CLIS-eD-UlqO_.js → SUPPORTED_CLIS-eIjVu8HF.js} +2 -2
- package/dist/cli.js +3 -3
- package/dist/index.js +2 -2
- package/dist/serve-SQYFRbm3.js +554 -0
- package/dist/{share-DUhUA1Pi.js → share-BsCeIfQM.js} +21 -4
- package/dist/{subcommands-B4gXEu5I.js → subcommands-D3Z9cD9u.js} +1 -1
- package/dist/{subcommands-K242usI5.js → subcommands-z8Y8gcD_.js} +8 -3
- package/dist/{ts-BAc4Jcrw.js → ts-BECoCPV1.js} +2 -2
- package/dist/{versionChecker-MNvA73o9.js → versionChecker-pct2j3wR.js} +2 -2
- package/lab/ui/index.html +1645 -0
- package/lab/ui/room-client.js +520 -0
- package/package.json +4 -2
- package/ts/serve.ts +463 -311
- package/ts/share.ts +49 -17
- package/ts/subcommands.ts +6 -0
- package/dist/SUPPORTED_CLIS-CNO_pj9f.js +0 -8
- package/dist/serve-CKcbVPy6.js +0 -451
package/ts/serve.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { mkdir, open, readFile, writeFile } from "fs/promises";
|
|
2
2
|
import { watch } from "node:fs";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
3
4
|
import { createHash, randomBytes, timingSafeEqual } from "crypto";
|
|
4
5
|
import { homedir } from "os";
|
|
5
6
|
import path from "path";
|
|
@@ -38,10 +39,7 @@ async function loadOrCreateToken(tokenFlag?: string): Promise<string> {
|
|
|
38
39
|
}
|
|
39
40
|
}
|
|
40
41
|
|
|
41
|
-
function
|
|
42
|
-
const auth = req.headers.get("authorization") ?? "";
|
|
43
|
-
if (!auth.startsWith("Bearer ")) return false;
|
|
44
|
-
const provided = auth.slice(7);
|
|
42
|
+
function tokenEqual(provided: string, expectedToken: string): boolean {
|
|
45
43
|
// Constant-time compare; pad both to the same length first
|
|
46
44
|
const maxLen = Math.max(provided.length, expectedToken.length);
|
|
47
45
|
const a = Buffer.from(provided.padEnd(maxLen, "\0"));
|
|
@@ -49,6 +47,14 @@ function checkAuth(req: Request, expectedToken: string): boolean {
|
|
|
49
47
|
return timingSafeEqual(a, b) && provided.length === expectedToken.length;
|
|
50
48
|
}
|
|
51
49
|
|
|
50
|
+
function checkAuth(req: Request, expectedToken: string): boolean {
|
|
51
|
+
const auth = req.headers.get("authorization") ?? "";
|
|
52
|
+
if (auth.startsWith("Bearer ")) return tokenEqual(auth.slice(7), expectedToken);
|
|
53
|
+
// Fallback: ?token= query param — the web UI's EventSource cannot set headers.
|
|
54
|
+
const q = new URL(req.url).searchParams.get("token");
|
|
55
|
+
return q ? tokenEqual(q, expectedToken) : false;
|
|
56
|
+
}
|
|
57
|
+
|
|
52
58
|
const defaultOpts = (overrides: Partial<CommonOpts> = {}): CommonOpts => ({
|
|
53
59
|
all: false,
|
|
54
60
|
active: false,
|
|
@@ -77,20 +83,38 @@ async function cmdServeDaemon(sub: string, args: string[]): Promise<number> {
|
|
|
77
83
|
|
|
78
84
|
if (sub === "install") {
|
|
79
85
|
const token = await loadOrCreateToken(undefined);
|
|
80
|
-
// Build the ay serve command with forwarded args (port, host, etc.)
|
|
81
|
-
|
|
86
|
+
// Build the ay serve command with forwarded args (port, host, --webrtc, etc.).
|
|
87
|
+
// Absolute paths: oxmgr's daemon environment may not have ~/.bun/bin in
|
|
88
|
+
// PATH, so a bare `ay` (or its `#!/usr/bin/env bun` shebang) fails to spawn.
|
|
89
|
+
const ayBin = Bun.which("ay");
|
|
90
|
+
const serveCmd = [...(ayBin ? [process.execPath, ayBin] : ["ay"]), "serve", ...args].join(" ");
|
|
82
91
|
const proc = Bun.spawn(
|
|
83
92
|
[oxmgrBin, "start", serveCmd, "--name", DAEMON_NAME, "--restart", "always"],
|
|
84
93
|
{ stdio: ["ignore", "inherit", "inherit"] },
|
|
85
94
|
);
|
|
86
95
|
const code = await proc.exited;
|
|
87
96
|
if (code === 0) {
|
|
97
|
+
const portM = /--port[=\s](\d+)/.exec(args.join(" "));
|
|
98
|
+
const port = portM ? Number(portM[1]) : DEFAULT_PORT;
|
|
99
|
+
// Mirror cmdServe's mode resolution: webrtc-only daemons open no HTTP port.
|
|
100
|
+
const webrtcish = args.some((a) => a.startsWith("--webrtc") || a.startsWith("--share"));
|
|
101
|
+
const httpish =
|
|
102
|
+
args.some((a) => a.startsWith("--http") || a.startsWith("--share")) ||
|
|
103
|
+
!args.some((a) => a.startsWith("--webrtc"));
|
|
88
104
|
process.stdout.write(`\ninstalled '${DAEMON_NAME}' as a daemon via oxmgr\n`);
|
|
89
105
|
process.stdout.write(`token: ${token}\n\n`);
|
|
90
|
-
|
|
91
|
-
|
|
106
|
+
if (httpish) {
|
|
107
|
+
process.stdout.write(` ay ls ${token}@<host>:${port}\n`);
|
|
108
|
+
process.stdout.write(` ay remote add <alias> http://${token}@<host>:${port}\n`);
|
|
109
|
+
}
|
|
92
110
|
process.stdout.write(` ay serve logs # view server logs\n`);
|
|
93
111
|
process.stdout.write(` ay serve uninstall # remove daemon\n`);
|
|
112
|
+
if (webrtcish) {
|
|
113
|
+
process.stdout.write(
|
|
114
|
+
`\nthe WebRTC share link is printed by the daemon — see: ay serve logs\n` +
|
|
115
|
+
`(the room persists in ~/.agent-yes/.share-room, so the link survives restarts)\n`,
|
|
116
|
+
);
|
|
117
|
+
}
|
|
94
118
|
}
|
|
95
119
|
return code ?? 1;
|
|
96
120
|
}
|
|
@@ -120,12 +144,22 @@ export async function cmdServe(rest: string[]): Promise<number> {
|
|
|
120
144
|
if (rest.includes("-h") || rest.includes("--help")) {
|
|
121
145
|
process.stdout.write(
|
|
122
146
|
`Usage: ay serve [options]\n\n` +
|
|
123
|
-
`Start an
|
|
147
|
+
`Start an API server (HTTP and/or WebRTC) so browsers and remote machines\n` +
|
|
148
|
+
`can list/tail/send agents.\n\n` +
|
|
149
|
+
`Modes (default: --http):\n` +
|
|
150
|
+
` --http HTTP API + web console on --port; no WebRTC\n` +
|
|
151
|
+
` --webrtc [URL] Share over WebRTC (bare flag mints a room+link on\n` +
|
|
152
|
+
` agent-yes.com, or pass webrtc://room:token@host).\n` +
|
|
153
|
+
` Alone it needs NO port — combine with --http for both.\n` +
|
|
154
|
+
` The minted room persists in ~/.agent-yes/.share-room\n` +
|
|
155
|
+
` (stable link across restarts; delete the file to rotate).\n` +
|
|
156
|
+
` --share [URL] Legacy alias for --http --webrtc\n\n` +
|
|
124
157
|
`Options:\n` +
|
|
125
158
|
` --port N Port to listen on (default: ${DEFAULT_PORT})\n` +
|
|
126
159
|
` --host HOST Interface to bind (default: 127.0.0.1; use 0.0.0.0 to expose)\n` +
|
|
127
160
|
` --token TOKEN Auth token (auto-generated and saved if omitted)\n` +
|
|
128
|
-
` --
|
|
161
|
+
` -d, --daemon Install these flags as a background daemon via oxmgr\n` +
|
|
162
|
+
` (same as: ay serve install <flags>)\n` +
|
|
129
163
|
` --allow-spawn Deprecated no-op — the console can always spawn agents\n` +
|
|
130
164
|
` --tls-cert FILE TLS certificate PEM\n` +
|
|
131
165
|
` --tls-key FILE TLS private key PEM\n\n` +
|
|
@@ -157,10 +191,24 @@ export async function cmdServe(rest: string[]): Promise<number> {
|
|
|
157
191
|
.option("token", { type: "string", description: "Auth token (auto-generated if omitted)" })
|
|
158
192
|
.option("tls-cert", { type: "string", description: "TLS certificate file (PEM)" })
|
|
159
193
|
.option("tls-key", { type: "string", description: "TLS private key file (PEM)" })
|
|
160
|
-
.option("
|
|
194
|
+
.option("http", {
|
|
195
|
+
type: "boolean",
|
|
196
|
+
description: "Serve the HTTP API + web console on --port (default mode)",
|
|
197
|
+
})
|
|
198
|
+
.option("webrtc", {
|
|
161
199
|
type: "string",
|
|
162
200
|
description:
|
|
163
|
-
"Share over WebRTC: bare flag mints a room+link, or pass webrtc://room:token@host",
|
|
201
|
+
"Share over WebRTC: bare flag mints a room+link, or pass webrtc://room:token@host. Needs no port unless combined with --http",
|
|
202
|
+
})
|
|
203
|
+
.option("share", {
|
|
204
|
+
type: "string",
|
|
205
|
+
description: "Legacy alias for --http --webrtc",
|
|
206
|
+
})
|
|
207
|
+
.option("daemon", {
|
|
208
|
+
alias: "d",
|
|
209
|
+
type: "boolean",
|
|
210
|
+
default: false,
|
|
211
|
+
description: "Install as a background daemon via oxmgr (same as: ay serve install <flags>)",
|
|
164
212
|
})
|
|
165
213
|
.option("allow-spawn", {
|
|
166
214
|
type: "boolean",
|
|
@@ -172,6 +220,14 @@ export async function cmdServe(rest: string[]): Promise<number> {
|
|
|
172
220
|
.exitProcess(false);
|
|
173
221
|
|
|
174
222
|
const argv = await y.parseAsync();
|
|
223
|
+
|
|
224
|
+
// --daemon/-d: install these exact flags as the oxmgr daemon instead of
|
|
225
|
+
// serving in the foreground (sugar for `ay serve install <flags>`).
|
|
226
|
+
if (argv.daemon) {
|
|
227
|
+
const fwd = rest.filter((a) => a !== "--daemon" && a !== "-d");
|
|
228
|
+
return cmdServeDaemon("install", fwd);
|
|
229
|
+
}
|
|
230
|
+
|
|
175
231
|
const port = (argv.port as number) ?? DEFAULT_PORT;
|
|
176
232
|
const host = (argv.host as string) ?? "127.0.0.1";
|
|
177
233
|
const tokenFlag = typeof argv.token === "string" ? argv.token : undefined;
|
|
@@ -185,7 +241,13 @@ export async function cmdServe(rest: string[]): Promise<number> {
|
|
|
185
241
|
const useHttps = !!(certPath && keyPath);
|
|
186
242
|
const scheme = useHttps ? "https" : "http";
|
|
187
243
|
|
|
188
|
-
|
|
244
|
+
// Modes: --http (HTTP listener + web console), --webrtc (port-free WebRTC
|
|
245
|
+
// share), or both. Bare `ay serve` stays HTTP-only; --share keeps its old
|
|
246
|
+
// meaning (HTTP + WebRTC) for existing invocations.
|
|
247
|
+
const wantWebrtc = argv.webrtc !== undefined || argv.share !== undefined;
|
|
248
|
+
const wantHttp = argv.http === true || argv.share !== undefined || argv.webrtc === undefined;
|
|
249
|
+
|
|
250
|
+
if (wantHttp && host !== "127.0.0.1" && host !== "localhost") {
|
|
189
251
|
process.stderr.write(
|
|
190
252
|
"ay serve: warning: binding to non-loopback — ensure your network is trusted or use Tailscale/VPN\n",
|
|
191
253
|
);
|
|
@@ -198,350 +260,440 @@ export async function cmdServe(rest: string[]): Promise<number> {
|
|
|
198
260
|
// y/N prompt bought no real safety. We just log each spawn so the host sees it.
|
|
199
261
|
// (--allow-spawn is still accepted as a no-op for older invocations.)
|
|
200
262
|
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
263
|
+
// Agents retitle their terminal by writing OSC 0/2 (\x1b]2;name\x07) into the
|
|
264
|
+
// PTY stream we log; surfacing the most recent one lets the console label list
|
|
265
|
+
// rows without streaming every log. Cached per (size, mtime) — the UI polls
|
|
266
|
+
// /api/ls every few seconds and exited agents' logs never change again.
|
|
267
|
+
const titleCache = new Map<string, { size: number; mtimeMs: number; title: string | null }>();
|
|
268
|
+
const logTitle = async (logFile: string | null | undefined): Promise<string | null> => {
|
|
269
|
+
if (!logFile) return null;
|
|
270
|
+
try {
|
|
271
|
+
const fh = await open(logFile, "r");
|
|
272
|
+
try {
|
|
273
|
+
const { size, mtimeMs } = await fh.stat();
|
|
274
|
+
const hit = titleCache.get(logFile);
|
|
275
|
+
if (hit && hit.size === size && hit.mtimeMs === mtimeMs) return hit.title;
|
|
276
|
+
const len = Math.min(size, 65536);
|
|
277
|
+
const buf = Buffer.allocUnsafe(len);
|
|
278
|
+
const { bytesRead } = await fh.read(buf, 0, len, size - len);
|
|
279
|
+
const text = buf.toString("utf-8", 0, bytesRead);
|
|
280
|
+
// eslint-disable-next-line no-control-regex
|
|
281
|
+
const oscTitleRe = /\x1b\][02];([^\x07\x1b]*)(?:\x07|\x1b\\)/g;
|
|
282
|
+
let title: string | null = null;
|
|
283
|
+
for (let m; (m = oscTitleRe.exec(text)); ) if (m[1]!.trim()) title = m[1]!.trim();
|
|
284
|
+
titleCache.set(logFile, { size, mtimeMs, title });
|
|
285
|
+
return title;
|
|
286
|
+
} finally {
|
|
287
|
+
await fh.close();
|
|
207
288
|
}
|
|
289
|
+
} catch {
|
|
290
|
+
return null;
|
|
291
|
+
}
|
|
292
|
+
};
|
|
293
|
+
|
|
294
|
+
// The whole API as a plain handler: served over HTTP by Bun.serve (--http)
|
|
295
|
+
// and called in-process by the WebRTC bridge (--webrtc) — the latter needs
|
|
296
|
+
// no TCP port at all.
|
|
297
|
+
const apiFetch = async (req: Request): Promise<Response> => {
|
|
298
|
+
if (!checkAuth(req, token)) {
|
|
299
|
+
return new Response("Unauthorized", { status: 401 });
|
|
300
|
+
}
|
|
208
301
|
|
|
209
|
-
|
|
210
|
-
|
|
302
|
+
const url = new URL(req.url);
|
|
303
|
+
const p = url.pathname;
|
|
211
304
|
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
305
|
+
// GET /api/ls
|
|
306
|
+
if (req.method === "GET" && p === "/api/ls") {
|
|
307
|
+
const keyword = url.searchParams.get("keyword") ?? undefined;
|
|
308
|
+
const opts = defaultOpts({
|
|
309
|
+
all: url.searchParams.get("all") === "1",
|
|
310
|
+
active: url.searchParams.get("active") === "1",
|
|
311
|
+
});
|
|
312
|
+
try {
|
|
313
|
+
const records = await listRecords(keyword, opts);
|
|
314
|
+
const withTitles = await Promise.all(
|
|
315
|
+
records.map(async (r) => ({ ...r, title: await logTitle(r.log_file) })),
|
|
316
|
+
);
|
|
317
|
+
return Response.json(withTitles);
|
|
318
|
+
} catch (e) {
|
|
319
|
+
return new Response((e as Error).message, { status: 500 });
|
|
225
320
|
}
|
|
321
|
+
}
|
|
226
322
|
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
323
|
+
// GET /api/notes
|
|
324
|
+
if (req.method === "GET" && p === "/api/notes") {
|
|
325
|
+
const notes = await readNotes();
|
|
326
|
+
return Response.json(Object.fromEntries(notes));
|
|
327
|
+
}
|
|
232
328
|
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
}
|
|
329
|
+
// GET /api/status/:keyword
|
|
330
|
+
const statusM = /^\/api\/status\/(.+)$/.exec(p);
|
|
331
|
+
if (req.method === "GET" && statusM) {
|
|
332
|
+
const keyword = decodeURIComponent(statusM[1]!);
|
|
333
|
+
try {
|
|
334
|
+
const record = await resolveOne(keyword, defaultOpts({ all: true }));
|
|
335
|
+
const snap = await snapshotStatus(record);
|
|
336
|
+
return Response.json(snap);
|
|
337
|
+
} catch (e) {
|
|
338
|
+
return new Response((e as Error).message, { status: 404 });
|
|
244
339
|
}
|
|
340
|
+
}
|
|
245
341
|
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
}
|
|
342
|
+
// GET /api/read/:keyword?mode=cat|tail|head&n=N — static log read
|
|
343
|
+
const readM = /^\/api\/read\/(.+)$/.exec(p);
|
|
344
|
+
if (req.method === "GET" && readM) {
|
|
345
|
+
const keyword = decodeURIComponent(readM[1]!);
|
|
346
|
+
const mode = (url.searchParams.get("mode") ?? "tail") as "cat" | "tail" | "head";
|
|
347
|
+
const n = parseInt(url.searchParams.get("n") ?? "96", 10) || 96;
|
|
348
|
+
try {
|
|
349
|
+
const record = await resolveOne(keyword, defaultOpts());
|
|
350
|
+
if (!record.log_file)
|
|
351
|
+
return new Response(`pid ${record.pid}: no log_file`, { status: 404 });
|
|
352
|
+
const buf = await readFile(record.log_file);
|
|
353
|
+
const text = await renderRawLog(buf, { mode, n });
|
|
354
|
+
return new Response(text, { headers: { "Content-Type": "text/plain; charset=utf-8" } });
|
|
355
|
+
} catch (e) {
|
|
356
|
+
return new Response((e as Error).message, { status: 404 });
|
|
262
357
|
}
|
|
358
|
+
}
|
|
263
359
|
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
360
|
+
// GET /api/size/:keyword — the agent's current PTY size, so the console can
|
|
361
|
+
// render the existing buffer at the agent's real width before adapting.
|
|
362
|
+
const sizeM = /^\/api\/size\/(.+)$/.exec(p);
|
|
363
|
+
if (req.method === "GET" && sizeM) {
|
|
364
|
+
const keyword = decodeURIComponent(sizeM[1]!);
|
|
365
|
+
try {
|
|
366
|
+
const record = await resolveOne(keyword, defaultOpts());
|
|
367
|
+
const ayHome = process.env.AGENT_YES_HOME ?? path.join(homedir(), ".agent-yes");
|
|
368
|
+
let cols: number | null = null;
|
|
369
|
+
let rows: number | null = null;
|
|
269
370
|
try {
|
|
270
|
-
const
|
|
271
|
-
const
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
const txt = await readFile(path.join(ayHome, "ptysize", String(record.pid)), "utf-8");
|
|
276
|
-
const [c, r] = txt.trim().split(/\s+/).map(Number);
|
|
277
|
-
if (c > 0 && r > 0) {
|
|
278
|
-
cols = c;
|
|
279
|
-
rows = r;
|
|
280
|
-
}
|
|
281
|
-
} catch {
|
|
282
|
-
/* no ptysize sidecar (older agent or not yet written) */
|
|
371
|
+
const txt = await readFile(path.join(ayHome, "ptysize", String(record.pid)), "utf-8");
|
|
372
|
+
const [c = 0, r = 0] = txt.trim().split(/\s+/).map(Number);
|
|
373
|
+
if (c > 0 && r > 0) {
|
|
374
|
+
cols = c;
|
|
375
|
+
rows = r;
|
|
283
376
|
}
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
return new Response((e as Error).message, { status: 404 });
|
|
377
|
+
} catch {
|
|
378
|
+
/* no ptysize sidecar (older agent or not yet written) */
|
|
287
379
|
}
|
|
380
|
+
return Response.json({ pid: record.pid, cols, rows });
|
|
381
|
+
} catch (e) {
|
|
382
|
+
return new Response((e as Error).message, { status: 404 });
|
|
288
383
|
}
|
|
384
|
+
}
|
|
289
385
|
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
}
|
|
386
|
+
// GET /api/tail/:keyword — SSE streaming
|
|
387
|
+
const tailM = /^\/api\/tail\/(.+)$/.exec(p);
|
|
388
|
+
if (req.method === "GET" && tailM) {
|
|
389
|
+
const keyword = decodeURIComponent(tailM[1]!);
|
|
390
|
+
// raw=1 streams the unmodified PTY bytes (ANSI/cursor control intact) so a
|
|
391
|
+
// browser xterm.js can render the real terminal; default stays ANSI-stripped.
|
|
392
|
+
const raw = url.searchParams.get("raw") === "1";
|
|
393
|
+
try {
|
|
394
|
+
const record = await resolveOne(keyword, defaultOpts());
|
|
395
|
+
if (!record.log_file)
|
|
396
|
+
return new Response(`pid ${record.pid}: no log_file`, { status: 404 });
|
|
397
|
+
const logPath = record.log_file;
|
|
398
|
+
|
|
399
|
+
const stream = new ReadableStream({
|
|
400
|
+
async start(ctrl) {
|
|
401
|
+
const enc = new TextEncoder();
|
|
402
|
+
const send = (text: string) =>
|
|
403
|
+
ctrl.enqueue(enc.encode(`data: ${JSON.stringify(text)}\n\n`));
|
|
404
|
+
const ping = () => ctrl.enqueue(enc.encode(": ping\n\n"));
|
|
405
|
+
|
|
406
|
+
// Initial tail. Raw: replay the last ~64 KB of PTY bytes (enough to
|
|
407
|
+
// contain a recent full-screen redraw so xterm converges fast).
|
|
408
|
+
const initBuf = await readFile(logPath).catch(() => Buffer.alloc(0));
|
|
409
|
+
if (raw)
|
|
410
|
+
send(new TextDecoder().decode(initBuf.slice(Math.max(0, initBuf.length - 65536))));
|
|
411
|
+
else send(await renderRawLog(initBuf, { mode: "tail", n: 96 }));
|
|
412
|
+
|
|
413
|
+
let offset = initBuf.length;
|
|
414
|
+
let closed = false;
|
|
415
|
+
|
|
416
|
+
const heartbeat = setInterval(() => {
|
|
417
|
+
if (closed) {
|
|
418
|
+
clearInterval(heartbeat);
|
|
419
|
+
return;
|
|
420
|
+
}
|
|
421
|
+
ping();
|
|
422
|
+
}, 15_000);
|
|
423
|
+
|
|
424
|
+
// eslint-disable-next-line no-control-regex
|
|
425
|
+
const ansiRe =
|
|
426
|
+
/\x1b\[[0-?]*[ -/]*[@-~]|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)|\x1b[@-Z\\-_]/g;
|
|
427
|
+
// eslint-disable-next-line no-control-regex
|
|
428
|
+
const ctrlRe = /[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/g;
|
|
429
|
+
|
|
430
|
+
// Stream only the bytes appended since `offset` (incremental read,
|
|
431
|
+
// not a full re-read), driven by fs.watch for near-instant echo with
|
|
432
|
+
// a short fallback poll in case the watcher misses an event. The old
|
|
433
|
+
// 300 ms full-file poll was the dominant typing-echo latency.
|
|
434
|
+
const fh = await open(logPath, "r").catch(() => null);
|
|
435
|
+
let reading = false;
|
|
436
|
+
const flush = async () => {
|
|
437
|
+
if (closed || reading || !fh) return;
|
|
438
|
+
reading = true;
|
|
439
|
+
try {
|
|
440
|
+
const { size } = await fh.stat();
|
|
441
|
+
if (size < offset) offset = size; // truncated/rotated
|
|
442
|
+
if (size > offset) {
|
|
443
|
+
const len = size - offset;
|
|
444
|
+
const buf = Buffer.allocUnsafe(len);
|
|
445
|
+
const { bytesRead } = await fh.read(buf, 0, len, offset);
|
|
446
|
+
offset += bytesRead;
|
|
447
|
+
const chunk = buf.subarray(0, bytesRead);
|
|
448
|
+
if (raw) {
|
|
449
|
+
send(new TextDecoder().decode(chunk));
|
|
450
|
+
} else {
|
|
451
|
+
const text = new TextDecoder()
|
|
452
|
+
.decode(chunk)
|
|
453
|
+
.replace(ansiRe, "")
|
|
454
|
+
.replace(ctrlRe, "");
|
|
455
|
+
if (text.trim()) send(text.trimStart());
|
|
361
456
|
}
|
|
362
|
-
} catch {
|
|
363
|
-
/* log gone */
|
|
364
|
-
} finally {
|
|
365
|
-
reading = false;
|
|
366
457
|
}
|
|
367
|
-
}
|
|
458
|
+
} catch {
|
|
459
|
+
/* log gone */
|
|
460
|
+
} finally {
|
|
461
|
+
reading = false;
|
|
462
|
+
}
|
|
463
|
+
};
|
|
368
464
|
|
|
369
|
-
|
|
465
|
+
let watcher: ReturnType<typeof watch> | null = null;
|
|
466
|
+
try {
|
|
467
|
+
watcher = watch(logPath, () => void flush());
|
|
468
|
+
} catch {
|
|
469
|
+
/* fs.watch unsupported — the fallback poll below still works */
|
|
470
|
+
}
|
|
471
|
+
const poller = setInterval(() => void flush(), 60);
|
|
472
|
+
|
|
473
|
+
req.signal.addEventListener("abort", () => {
|
|
474
|
+
closed = true;
|
|
475
|
+
clearInterval(heartbeat);
|
|
476
|
+
clearInterval(poller);
|
|
370
477
|
try {
|
|
371
|
-
watcher
|
|
478
|
+
watcher?.close();
|
|
372
479
|
} catch {
|
|
373
|
-
/*
|
|
480
|
+
/* already closed */
|
|
374
481
|
}
|
|
375
|
-
|
|
482
|
+
void fh?.close().catch(() => {});
|
|
483
|
+
try {
|
|
484
|
+
ctrl.close();
|
|
485
|
+
} catch {
|
|
486
|
+
/* already closed */
|
|
487
|
+
}
|
|
488
|
+
});
|
|
489
|
+
},
|
|
490
|
+
});
|
|
376
491
|
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
void fh?.close().catch(() => {});
|
|
387
|
-
try {
|
|
388
|
-
ctrl.close();
|
|
389
|
-
} catch {
|
|
390
|
-
/* already closed */
|
|
391
|
-
}
|
|
392
|
-
});
|
|
393
|
-
},
|
|
394
|
-
});
|
|
395
|
-
|
|
396
|
-
return new Response(stream, {
|
|
397
|
-
headers: {
|
|
398
|
-
"Content-Type": "text/event-stream",
|
|
399
|
-
"Cache-Control": "no-cache",
|
|
400
|
-
Connection: "keep-alive",
|
|
401
|
-
},
|
|
402
|
-
});
|
|
403
|
-
} catch (e) {
|
|
404
|
-
return new Response((e as Error).message, { status: 404 });
|
|
405
|
-
}
|
|
492
|
+
return new Response(stream, {
|
|
493
|
+
headers: {
|
|
494
|
+
"Content-Type": "text/event-stream",
|
|
495
|
+
"Cache-Control": "no-cache",
|
|
496
|
+
Connection: "keep-alive",
|
|
497
|
+
},
|
|
498
|
+
});
|
|
499
|
+
} catch (e) {
|
|
500
|
+
return new Response((e as Error).message, { status: 404 });
|
|
406
501
|
}
|
|
502
|
+
}
|
|
407
503
|
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
}
|
|
432
|
-
return Response.json({ ok: true, pid: record.pid });
|
|
433
|
-
} catch (e) {
|
|
434
|
-
return new Response((e as Error).message, { status: 404 });
|
|
504
|
+
// POST /api/send body: {keyword, msg, code?}
|
|
505
|
+
if (req.method === "POST" && p === "/api/send") {
|
|
506
|
+
let body: { keyword: string; msg: string; code?: string };
|
|
507
|
+
try {
|
|
508
|
+
body = (await req.json()) as typeof body;
|
|
509
|
+
} catch {
|
|
510
|
+
return new Response("invalid JSON body", { status: 400 });
|
|
511
|
+
}
|
|
512
|
+
const { keyword, msg = "", code = "enter" } = body;
|
|
513
|
+
if (!keyword || typeof keyword !== "string") {
|
|
514
|
+
return new Response("missing keyword", { status: 400 });
|
|
515
|
+
}
|
|
516
|
+
try {
|
|
517
|
+
const record = await resolveOne(keyword, defaultOpts());
|
|
518
|
+
if (!record.fifo_file)
|
|
519
|
+
return new Response(`pid ${record.pid}: no fifo_file`, { status: 409 });
|
|
520
|
+
const trailing = controlCodeFromName(code.toLowerCase());
|
|
521
|
+
if (msg && trailing) {
|
|
522
|
+
await writeToIpc(record.fifo_file, msg);
|
|
523
|
+
await new Promise((r) => setTimeout(r, 200));
|
|
524
|
+
await writeToIpc(record.fifo_file, trailing);
|
|
525
|
+
} else {
|
|
526
|
+
await writeToIpc(record.fifo_file, msg + trailing);
|
|
435
527
|
}
|
|
528
|
+
return Response.json({ ok: true, pid: record.pid });
|
|
529
|
+
} catch (e) {
|
|
530
|
+
return new Response((e as Error).message, { status: 404 });
|
|
436
531
|
}
|
|
532
|
+
}
|
|
437
533
|
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
534
|
+
// POST /api/resize/:keyword body {cols, rows} — drive the agent's PTY size.
|
|
535
|
+
// Mirrors `ay attach`: write ~/.agent-yes/winsize/<pid> then SIGWINCH; the
|
|
536
|
+
// agent's resize listener picks it up and reflows its TUI to that width.
|
|
537
|
+
const resizeM = /^\/api\/resize\/(.+)$/.exec(p);
|
|
538
|
+
if (req.method === "POST" && resizeM) {
|
|
539
|
+
const keyword = decodeURIComponent(resizeM[1]!);
|
|
540
|
+
let body: { cols?: number; rows?: number };
|
|
541
|
+
try {
|
|
542
|
+
body = (await req.json()) as typeof body;
|
|
543
|
+
} catch {
|
|
544
|
+
return new Response("invalid JSON body", { status: 400 });
|
|
545
|
+
}
|
|
546
|
+
const cols = Math.max(1, Math.floor(Number(body.cols) || 0));
|
|
547
|
+
const rows = Math.max(1, Math.floor(Number(body.rows) || 0));
|
|
548
|
+
if (!cols || !rows) return new Response("missing cols/rows", { status: 400 });
|
|
549
|
+
try {
|
|
550
|
+
const record = await resolveOne(keyword, defaultOpts());
|
|
551
|
+
const ayHome = process.env.AGENT_YES_HOME ?? path.join(homedir(), ".agent-yes");
|
|
552
|
+
const winsizeDir = path.join(ayHome, "winsize");
|
|
553
|
+
await mkdir(winsizeDir, { recursive: true });
|
|
554
|
+
await writeFile(
|
|
555
|
+
path.join(winsizeDir, String(record.pid)),
|
|
556
|
+
`${cols} ${rows} ${Date.now()}\n`,
|
|
557
|
+
);
|
|
445
558
|
try {
|
|
446
|
-
|
|
559
|
+
process.kill(record.pid, "SIGWINCH");
|
|
447
560
|
} catch {
|
|
448
|
-
|
|
449
|
-
}
|
|
450
|
-
const cols = Math.max(1, Math.floor(Number(body.cols) || 0));
|
|
451
|
-
const rows = Math.max(1, Math.floor(Number(body.rows) || 0));
|
|
452
|
-
if (!cols || !rows) return new Response("missing cols/rows", { status: 400 });
|
|
453
|
-
try {
|
|
454
|
-
const record = await resolveOne(keyword, defaultOpts());
|
|
455
|
-
const ayHome = process.env.AGENT_YES_HOME ?? path.join(homedir(), ".agent-yes");
|
|
456
|
-
const winsizeDir = path.join(ayHome, "winsize");
|
|
457
|
-
await mkdir(winsizeDir, { recursive: true });
|
|
458
|
-
await writeFile(
|
|
459
|
-
path.join(winsizeDir, String(record.pid)),
|
|
460
|
-
`${cols} ${rows} ${Date.now()}\n`,
|
|
461
|
-
);
|
|
462
|
-
try {
|
|
463
|
-
process.kill(record.pid, "SIGWINCH");
|
|
464
|
-
} catch {
|
|
465
|
-
/* agent gone */
|
|
466
|
-
}
|
|
467
|
-
return Response.json({ ok: true, pid: record.pid, cols, rows });
|
|
468
|
-
} catch (e) {
|
|
469
|
-
return new Response((e as Error).message, { status: 404 });
|
|
561
|
+
/* agent gone */
|
|
470
562
|
}
|
|
563
|
+
return Response.json({ ok: true, pid: record.pid, cols, rows });
|
|
564
|
+
} catch (e) {
|
|
565
|
+
return new Response((e as Error).message, { status: 404 });
|
|
471
566
|
}
|
|
567
|
+
}
|
|
472
568
|
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
}
|
|
569
|
+
// POST /api/spawn body {cli, cwd, prompt} — launch a new agent
|
|
570
|
+
if (req.method === "POST" && p === "/api/spawn") {
|
|
571
|
+
let body: { cli?: string; cwd?: string; prompt?: string };
|
|
572
|
+
try {
|
|
573
|
+
body = (await req.json()) as typeof body;
|
|
574
|
+
} catch {
|
|
575
|
+
return new Response("invalid JSON body", { status: 400 });
|
|
576
|
+
}
|
|
577
|
+
const cli = String(body.cli ?? "claude");
|
|
578
|
+
if (!SUPPORTED_CLIS.includes(cli as never))
|
|
579
|
+
return new Response(`unsupported cli: ${cli}`, { status: 400 });
|
|
580
|
+
const cwd = typeof body.cwd === "string" && body.cwd ? body.cwd : process.cwd();
|
|
581
|
+
const prompt = String(body.prompt ?? "");
|
|
582
|
+
process.stderr.write(
|
|
583
|
+
`→ console spawned: ay ${cli}${prompt ? ` -- "${prompt.slice(0, 60)}"` : ""} (cwd: ${cwd})\n`,
|
|
584
|
+
);
|
|
585
|
+
try {
|
|
586
|
+
const child = Bun.spawn(["ay", cli, ...(prompt ? ["--", prompt] : [])], {
|
|
587
|
+
cwd,
|
|
588
|
+
stdin: "ignore",
|
|
589
|
+
stdout: "ignore",
|
|
590
|
+
stderr: "ignore",
|
|
591
|
+
});
|
|
592
|
+
child.unref();
|
|
593
|
+
return Response.json({ ok: true, pid: child.pid, cli, cwd });
|
|
594
|
+
} catch (e) {
|
|
595
|
+
return new Response((e as Error).message, { status: 500 });
|
|
501
596
|
}
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
return new Response("Not Found", { status: 404 });
|
|
600
|
+
};
|
|
502
601
|
|
|
503
|
-
|
|
504
|
-
|
|
602
|
+
// Web console: the lab UI served straight from the package, so --http needs
|
|
603
|
+
// no separate proxy and no agent-yes.com. Static routes are unauthenticated
|
|
604
|
+
// (the page holds no secrets); the page carries the token via the #k= link
|
|
605
|
+
// and sends it on every /api call.
|
|
606
|
+
const uiDir = path.join(path.dirname(fileURLToPath(import.meta.url)), "..", "lab", "ui");
|
|
607
|
+
const serveUiFile = async (name: string, type: string): Promise<Response> => {
|
|
608
|
+
try {
|
|
609
|
+
const buf = await readFile(path.join(uiDir, name));
|
|
610
|
+
return new Response(buf, { headers: { "Content-Type": type } });
|
|
611
|
+
} catch {
|
|
612
|
+
return new Response("UI assets not found in this install — use the /api endpoints", {
|
|
613
|
+
status: 404,
|
|
614
|
+
});
|
|
615
|
+
}
|
|
616
|
+
};
|
|
617
|
+
const httpFetch = async (req: Request): Promise<Response> => {
|
|
618
|
+
const p = new URL(req.url).pathname;
|
|
619
|
+
if (req.method === "GET" && (p === "/" || p === "/index.html"))
|
|
620
|
+
return serveUiFile("index.html", "text/html; charset=utf-8");
|
|
621
|
+
if (req.method === "GET" && p === "/room-client.js")
|
|
622
|
+
return serveUiFile("room-client.js", "text/javascript; charset=utf-8");
|
|
623
|
+
if (req.method === "GET" && p === "/favicon.ico") return new Response(null, { status: 204 });
|
|
624
|
+
return apiFetch(req);
|
|
505
625
|
};
|
|
506
626
|
|
|
627
|
+
const serverOpts: any = {
|
|
628
|
+
hostname: host,
|
|
629
|
+
port,
|
|
630
|
+
idleTimeout: 0, // never time out SSE/tail streams
|
|
631
|
+
fetch: httpFetch,
|
|
632
|
+
};
|
|
507
633
|
if (useHttps) {
|
|
508
634
|
serverOpts.tls = { cert: Bun.file(certPath!), key: Bun.file(keyPath!) };
|
|
509
635
|
}
|
|
510
636
|
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
637
|
+
let server: ReturnType<typeof Bun.serve> | null = null;
|
|
638
|
+
if (wantHttp) {
|
|
639
|
+
try {
|
|
640
|
+
server = Bun.serve(serverOpts);
|
|
641
|
+
} catch (e) {
|
|
642
|
+
if ((e as { code?: string }).code === "EADDRINUSE") {
|
|
643
|
+
process.stderr.write(
|
|
644
|
+
`ay serve: port ${port} is already in use — pick another with --port N,\n` +
|
|
645
|
+
`or run a port-free WebRTC-only share with: ay serve --webrtc\n`,
|
|
646
|
+
);
|
|
647
|
+
return 1;
|
|
648
|
+
}
|
|
649
|
+
throw e;
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
const uiHost = host === "0.0.0.0" || host === "::" ? "127.0.0.1" : host;
|
|
653
|
+
process.stdout.write(`ay serve ${scheme}://${host}:${port}\n`);
|
|
654
|
+
process.stdout.write(`token: ${token}\n\n`);
|
|
655
|
+
process.stdout.write(`web console (token in the # is eaten on open):\n`);
|
|
656
|
+
process.stdout.write(` ${scheme}://${uiHost}:${port}/#k=${token}\n\n`);
|
|
657
|
+
process.stdout.write(`connect from another machine:\n`);
|
|
658
|
+
process.stdout.write(` ay ls ${token}@<host>:${port}\n`);
|
|
659
|
+
process.stdout.write(` ay tail ${token}@<host>:${port}:<keyword>\n`);
|
|
660
|
+
process.stdout.write(` ay send ${token}@<host>:${port}:<keyword> "message"\n\n`);
|
|
661
|
+
process.stdout.write(`save as alias:\n`);
|
|
662
|
+
process.stdout.write(` ay remote add <alias> ${scheme}://${token}@<host>:${port}\n\n`);
|
|
663
|
+
if (!useHttps) {
|
|
664
|
+
process.stdout.write(
|
|
665
|
+
`for HTTPS: ay serve --tls-cert cert.pem --tls-key key.pem\n` +
|
|
666
|
+
` openssl req -x509 -newkey rsa:2048 -keyout key.pem -out cert.pem -days 365 -nodes -subj '/CN=localhost'\n\n`,
|
|
667
|
+
);
|
|
668
|
+
}
|
|
526
669
|
}
|
|
527
|
-
|
|
528
|
-
//
|
|
529
|
-
//
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
670
|
+
|
|
671
|
+
// --webrtc / --share: bridge to a WebRTC room so the agent-yes.com console
|
|
672
|
+
// can reach this machine peer-to-peer. The bridge calls apiFetch in-process,
|
|
673
|
+
// so without --http no port is opened at all. Bare flag mints a room; a
|
|
674
|
+
// webrtc:// value joins an explicit one.
|
|
675
|
+
if (wantWebrtc) {
|
|
676
|
+
const webrtcVal = (argv.webrtc ?? argv.share) as string | undefined;
|
|
677
|
+
const explicitUrl =
|
|
678
|
+
typeof webrtcVal === "string" && webrtcVal.startsWith("webrtc://") ? webrtcVal : undefined;
|
|
533
679
|
try {
|
|
534
|
-
const { startShare } = await import("./share.ts");
|
|
680
|
+
const { startShare, loadOrCreateShareRoom } = await import("./share.ts");
|
|
681
|
+
// No explicit webrtc:// URL → reuse the persisted room (minted once and
|
|
682
|
+
// saved like the serve token), so the link is stable across restarts.
|
|
535
683
|
const { link } = await startShare({
|
|
536
|
-
url:
|
|
537
|
-
|
|
684
|
+
url: explicitUrl ?? (await loadOrCreateShareRoom()),
|
|
685
|
+
localFetch: apiFetch,
|
|
538
686
|
apiToken: token,
|
|
539
687
|
});
|
|
540
688
|
process.stdout.write(
|
|
541
|
-
|
|
689
|
+
`${wantHttp ? "\n" : ""}shared over WebRTC — open this link (the token is eaten from the URL on open):\n ${link}\n` +
|
|
690
|
+
(explicitUrl
|
|
691
|
+
? "\n"
|
|
692
|
+
: ` (persistent room — same link across restarts; delete ~/.agent-yes/.share-room to rotate)\n\n`),
|
|
542
693
|
);
|
|
543
694
|
} catch (e) {
|
|
544
|
-
process.stderr.write(`ay serve --
|
|
695
|
+
process.stderr.write(`ay serve --webrtc failed: ${(e as Error).message}\n`);
|
|
696
|
+
if (!wantHttp) return 1; // nothing else is running
|
|
545
697
|
}
|
|
546
698
|
}
|
|
547
699
|
|
|
@@ -549,11 +701,11 @@ export async function cmdServe(rest: string[]): Promise<number> {
|
|
|
549
701
|
|
|
550
702
|
await new Promise<void>((resolve) => {
|
|
551
703
|
process.on("SIGINT", () => {
|
|
552
|
-
server
|
|
704
|
+
server?.stop();
|
|
553
705
|
resolve();
|
|
554
706
|
});
|
|
555
707
|
process.on("SIGTERM", () => {
|
|
556
|
-
server
|
|
708
|
+
server?.stop();
|
|
557
709
|
resolve();
|
|
558
710
|
});
|
|
559
711
|
});
|