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