agent-yes 1.95.0 → 1.97.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.
@@ -1,7 +1,7 @@
1
1
  import { n as logger, t as addTransport } from "./logger-B9h0djqx.js";
2
- import { r as getInstalledPackage } from "./versionChecker-DfIPG9ui.js";
3
- import { i as shouldUseLock, r as releaseLock, t as acquireLock } from "./runningLock-C22d9SRJ.js";
4
- import { t as PidStore } from "./pidStore-DTzl6zeh.js";
2
+ import { r as getInstalledPackage } from "./versionChecker-MNvA73o9.js";
3
+ import { n as agentYesHome, t as PidStore } from "./pidStore-DBjlqzo8.js";
4
+ import { i as shouldUseLock, r as releaseLock, t as acquireLock } from "./runningLock-CJxsoGdb.js";
5
5
  import { i as readGlobalPids } from "./globalPidIndex-yVd3mbsV.js";
6
6
  import { arch, platform } from "process";
7
7
  import { execSync } from "child_process";
@@ -1423,10 +1423,19 @@ async function agentYes({ cli, cliArgs = [], prompt, robust = true, cwd, env, ex
1423
1423
  notifyWebhook("EXIT", `${exitReason} exitCode=${exitCode ?? "?"}`, workingDir).catch(() => null);
1424
1424
  return pendingExitCode.resolve(exitCode);
1425
1425
  });
1426
+ const writeCurrentPtysize = (cols, rows) => {
1427
+ const dir = path.join(agentYesHome(), "ptysize");
1428
+ mkdir(dir, { recursive: true }).then(() => writeFile(path.join(dir, String(process.pid)), `${cols} ${rows}\n`)).catch(() => null);
1429
+ };
1430
+ {
1431
+ const { cols, rows } = getTerminalDimensions();
1432
+ writeCurrentPtysize(cols, rows);
1433
+ }
1426
1434
  process.stdout.on("resize", () => {
1427
1435
  const { cols, rows } = getTerminalDimensions();
1428
1436
  shell.resize(cols, rows);
1429
1437
  xtermProxy.resize(cols, rows);
1438
+ writeCurrentPtysize(cols, rows);
1430
1439
  });
1431
1440
  const isStillWorkingQ = () => {
1432
1441
  const rendered = xtermProxy.tail(24).replace(/\s+/g, " ");
@@ -1705,4 +1714,4 @@ function sleep(ms) {
1705
1714
 
1706
1715
  //#endregion
1707
1716
  export { removeControlCharacters as a, AgentContext as i, agentYes as n, config as r, CLIS_CONFIG as t };
1708
- //# sourceMappingURL=ts-BvWaEGsr.js.map
1717
+ //# sourceMappingURL=ts-BAc4Jcrw.js.map
@@ -7,7 +7,7 @@ import { fileURLToPath } from "url";
7
7
 
8
8
  //#region package.json
9
9
  var name = "agent-yes";
10
- var version = "1.95.0";
10
+ var version = "1.97.0";
11
11
 
12
12
  //#endregion
13
13
  //#region ts/versionChecker.ts
@@ -221,4 +221,4 @@ async function displayVersion() {
221
221
 
222
222
  //#endregion
223
223
  export { versionString as i, displayVersion as n, getInstalledPackage as r, checkAndAutoUpdate as t };
224
- //# sourceMappingURL=versionChecker-DfIPG9ui.js.map
224
+ //# sourceMappingURL=versionChecker-MNvA73o9.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-yes",
3
- "version": "1.95.0",
3
+ "version": "1.97.0",
4
4
  "description": "A wrapper tool that automates interactions with various AI CLI tools by automatically handling common prompts and responses.",
5
5
  "keywords": [
6
6
  "ai",
@@ -69,6 +69,7 @@
69
69
  },
70
70
  "scripts": {
71
71
  "build": "tsdown",
72
+ "cf": "bun scripts/cf.ts",
72
73
  "build:rs": "cargo install --path rs --features swarm",
73
74
  "postbuild": "bun ./ts/postbuild.ts",
74
75
  "demo": "bun run build && bun link && claude-yes -- demo",
@@ -92,6 +93,7 @@
92
93
  "execa": "^9.6.1",
93
94
  "from-node-stream": "^0.2.0",
94
95
  "ms": "^2.1.3",
96
+ "node-datachannel": "^0.32.3",
95
97
  "phpdie": "^1.7.0",
96
98
  "proper-lockfile": "^4.1.2",
97
99
  "sflow": "^1.27.0",
@@ -142,5 +144,7 @@
142
144
  "engines": {
143
145
  "node": ">=22.0.0"
144
146
  },
145
- "trustedDependencies": []
147
+ "trustedDependencies": [
148
+ "node-datachannel"
149
+ ]
146
150
  }
package/scripts/cf.ts ADDED
@@ -0,0 +1,51 @@
1
+ #!/usr/bin/env bun
2
+ // Thin wrapper that runs `wrangler` against the SNOLAB Cloudflare account using
3
+ // the API token saved in .env.local — so we never depend on `wrangler login`
4
+ // state (which points at a different account) and never pass the token on the
5
+ // CLI. Usage: bun scripts/cf.ts <wrangler args...>
6
+ // e.g. bun scripts/cf.ts whoami
7
+ // bun scripts/cf.ts pages deploy ./dist --project-name agent-yes
8
+ import { spawnSync } from "node:child_process";
9
+ import { existsSync, readFileSync, renameSync, rmSync } from "node:fs";
10
+ import { homedir } from "node:os";
11
+ import path from "node:path";
12
+
13
+ // SNOLAB account — agent-yes.com lives here. Account id is not a secret.
14
+ const SNOLAB_ACCOUNT_ID = "0beef4cd2d2da6befa47d8d149d6e157";
15
+
16
+ const root = path.join(import.meta.dir, "..");
17
+ const env: Record<string, string | undefined> = { ...process.env };
18
+
19
+ // Load .env.local (bun also auto-loads it, but be explicit so this works from
20
+ // any cwd and is obvious to a reader).
21
+ try {
22
+ for (const line of readFileSync(path.join(root, ".env.local"), "utf8").split("\n")) {
23
+ const m = line.match(/^\s*([A-Z0-9_]+)\s*=\s*(.*?)\s*$/);
24
+ if (m && !(m[1] in env)) env[m[1]] = m[2];
25
+ }
26
+ } catch {
27
+ /* no .env.local — fall through to the check below */
28
+ }
29
+
30
+ if (!env.CLOUDFLARE_API_TOKEN) {
31
+ console.error("CLOUDFLARE_API_TOKEN is missing — add it to .env.local (see scripts/cf.ts).");
32
+ process.exit(1);
33
+ }
34
+ env.CLOUDFLARE_ACCOUNT_ID = SNOLAB_ACCOUNT_ID;
35
+
36
+ // wrangler otherwise prefers a stored OAuth login over CLOUDFLARE_API_TOKEN and
37
+ // pins the OAuth account (Axon), ignoring CLOUDFLARE_ACCOUNT_ID. Two sources to
38
+ // neutralise: the global OAuth config (~/.wrangler) and a project-level account
39
+ // cache (.wrangler/wrangler-account.json) that pins whatever account first
40
+ // deployed. Drop the cache, move the OAuth config aside for the run, restore it.
41
+ rmSync(path.join(root, ".wrangler/wrangler-account.json"), { force: true });
42
+ const oauthCfg = path.join(homedir(), ".wrangler/config/default.toml");
43
+ const oauthBak = oauthCfg + ".cf-bak";
44
+ const hadOauth = existsSync(oauthCfg);
45
+ if (hadOauth) renameSync(oauthCfg, oauthBak);
46
+ try {
47
+ const r = spawnSync("bunx", ["wrangler", ...process.argv.slice(2)], { stdio: "inherit", env });
48
+ process.exitCode = r.status ?? 1;
49
+ } finally {
50
+ if (hadOauth && existsSync(oauthBak)) renameSync(oauthBak, oauthCfg);
51
+ }
package/ts/index.ts CHANGED
@@ -5,6 +5,7 @@ import path from "path";
5
5
  import DIE from "phpdie";
6
6
  import sflow from "sflow";
7
7
  import { XtermProxy } from "./xterm-proxy.ts";
8
+ import { agentYesHome } from "./agentYesHome.ts";
8
9
  import {
9
10
  extractSessionId,
10
11
  getSessionForCwd,
@@ -643,11 +644,26 @@ export default async function agentYes({
643
644
  return pendingExitCode.resolve(exitCode);
644
645
  });
645
646
 
647
+ // Record the agent's current PTY size to ~/.agent-yes/ptysize/<pid> so `ay serve`
648
+ // / the web console can render the existing buffer at the agent's real width
649
+ // before adapting. Mirrors the Rust runtime (rs/src/pty_spawner.rs).
650
+ const writeCurrentPtysize = (cols: number, rows: number) => {
651
+ const dir = path.join(agentYesHome(), "ptysize");
652
+ void mkdir(dir, { recursive: true })
653
+ .then(() => writeFile(path.join(dir, String(process.pid)), `${cols} ${rows}\n`))
654
+ .catch(() => null);
655
+ };
656
+ {
657
+ const { cols, rows } = getTerminalDimensions();
658
+ writeCurrentPtysize(cols, rows);
659
+ }
660
+
646
661
  // when current tty resized, resize both pty and xterm proxy
647
662
  process.stdout.on("resize", () => {
648
663
  const { cols, rows } = getTerminalDimensions();
649
664
  shell.resize(cols, rows);
650
665
  xtermProxy.resize(cols, rows);
666
+ writeCurrentPtysize(cols, rows);
651
667
  });
652
668
 
653
669
  const isStillWorkingQ = () => {
package/ts/rustBinary.ts CHANGED
@@ -41,10 +41,7 @@ export function getBinaryName(): string {
41
41
  */
42
42
  export function getBinDir(): string {
43
43
  // First check for binaries in the npm package
44
- const packageBinDir = path.resolve(
45
- import.meta.dirname ?? import.meta.dir,
46
- "../bin",
47
- );
44
+ const packageBinDir = path.resolve(import.meta.dirname ?? import.meta.dir, "../bin");
48
45
  if (existsSync(packageBinDir)) {
49
46
  return packageBinDir;
50
47
  }
@@ -61,8 +58,7 @@ export function getBinDir(): string {
61
58
  const cacheDir =
62
59
  process.env.AGENT_YES_CACHE_DIR ||
63
60
  path.join(
64
- process.env.XDG_CACHE_HOME ||
65
- path.join(process.env.HOME || "/tmp", ".cache"),
61
+ process.env.XDG_CACHE_HOME || path.join(process.env.HOME || "/tmp", ".cache"),
66
62
  "agent-yes",
67
63
  );
68
64
 
@@ -78,14 +74,8 @@ export function findRustBinary(verbose = false): string | undefined {
78
74
  const ext = process.platform === "win32" ? ".exe" : "";
79
75
  const searchPaths = [
80
76
  // 1. Check relative to this script (in the repo during development)
81
- path.resolve(
82
- import.meta.dirname ?? import.meta.dir,
83
- `../rs/target/release/agent-yes${ext}`,
84
- ),
85
- path.resolve(
86
- import.meta.dirname ?? import.meta.dir,
87
- `../rs/target/debug/agent-yes${ext}`,
88
- ),
77
+ path.resolve(import.meta.dirname ?? import.meta.dir, `../rs/target/release/agent-yes${ext}`),
78
+ path.resolve(import.meta.dirname ?? import.meta.dir, `../rs/target/debug/agent-yes${ext}`),
89
79
 
90
80
  // 2. Check in npm package bin directory
91
81
  path.join(getBinDir(), binaryName),
@@ -149,9 +139,7 @@ export async function downloadBinary(verbose = false): Promise<string> {
149
139
 
150
140
  const response = await fetch(url);
151
141
  if (!response.ok) {
152
- throw new Error(
153
- `Failed to download binary: ${response.status} ${response.statusText}`,
154
- );
142
+ throw new Error(`Failed to download binary: ${response.status} ${response.statusText}`);
155
143
  }
156
144
 
157
145
  const isWindows = process.platform === "win32";
@@ -243,19 +231,14 @@ function getRustBinaryVersion(binaryPath: string): string | null {
243
231
  */
244
232
  function autoRebuildIfOutdated(binaryPath: string, verbose: boolean): boolean {
245
233
  // Only auto-rebuild for local dev builds (target/release or target/debug)
246
- if (
247
- !binaryPath.includes("/target/release") &&
248
- !binaryPath.includes("/target/debug")
249
- ) {
234
+ if (!binaryPath.includes("/target/release") && !binaryPath.includes("/target/debug")) {
250
235
  return true; // not a dev build, skip
251
236
  }
252
237
 
253
238
  const binaryVersion = getRustBinaryVersion(binaryPath);
254
239
  const pkgVersion = getInstalledPackage().version;
255
240
  if (verbose) {
256
- console.log(
257
- `[rust] Binary version: ${binaryVersion}, package version: ${pkgVersion}`,
258
- );
241
+ console.log(`[rust] Binary version: ${binaryVersion}, package version: ${pkgVersion}`);
259
242
  }
260
243
 
261
244
  if (binaryVersion === pkgVersion) {
@@ -263,15 +246,9 @@ function autoRebuildIfOutdated(binaryPath: string, verbose: boolean): boolean {
263
246
  }
264
247
 
265
248
  // Find the rs/ directory relative to the binary (binary is at rs/target/release/agent-yes)
266
- const rsDir = binaryPath.replace(
267
- /\/target\/(release|debug)\/agent-yes.*$/,
268
- "",
269
- );
249
+ const rsDir = binaryPath.replace(/\/target\/(release|debug)\/agent-yes.*$/, "");
270
250
  if (!existsSync(path.join(rsDir, "Cargo.toml"))) {
271
- if (verbose)
272
- console.log(
273
- `[rust] Cannot find Cargo.toml at ${rsDir}, skipping rebuild`,
274
- );
251
+ if (verbose) console.log(`[rust] Cannot find Cargo.toml at ${rsDir}, skipping rebuild`);
275
252
  return true; // can't rebuild, use as-is
276
253
  }
277
254
 
@@ -299,9 +276,7 @@ function autoRebuildIfOutdated(binaryPath: string, verbose: boolean): boolean {
299
276
  process.stderr.write(`\x1b[32m[rust] Rebuild complete\x1b[0m\n`);
300
277
  return true;
301
278
  } catch {
302
- process.stderr.write(
303
- `\x1b[31m[rust] Auto-rebuild failed, using outdated binary\x1b[0m\n`,
304
- );
279
+ process.stderr.write(`\x1b[31m[rust] Auto-rebuild failed, using outdated binary\x1b[0m\n`);
305
280
  return true; // still usable, just old
306
281
  }
307
282
  }
package/ts/serve.ts CHANGED
@@ -1,4 +1,5 @@
1
- import { mkdir, readFile, writeFile } from "fs/promises";
1
+ import { mkdir, open, readFile, writeFile } from "fs/promises";
2
+ import { watch } from "node:fs";
2
3
  import { createHash, randomBytes, timingSafeEqual } from "crypto";
3
4
  import { homedir } from "os";
4
5
  import path from "path";
@@ -13,6 +14,7 @@ import {
13
14
  writeToIpc,
14
15
  type CommonOpts,
15
16
  } from "./subcommands.ts";
17
+ import { SUPPORTED_CLIS } from "./SUPPORTED_CLIS.ts";
16
18
 
17
19
  const DEFAULT_PORT = 7432;
18
20
 
@@ -123,6 +125,8 @@ export async function cmdServe(rest: string[]): Promise<number> {
123
125
  ` --port N Port to listen on (default: ${DEFAULT_PORT})\n` +
124
126
  ` --host HOST Interface to bind (default: 127.0.0.1; use 0.0.0.0 to expose)\n` +
125
127
  ` --token TOKEN Auth token (auto-generated and saved if omitted)\n` +
128
+ ` --share [URL] Share over WebRTC to agent-yes.com (bare flag mints a room+link)\n` +
129
+ ` --allow-spawn Deprecated no-op — the console can always spawn agents\n` +
126
130
  ` --tls-cert FILE TLS certificate PEM\n` +
127
131
  ` --tls-key FILE TLS private key PEM\n\n` +
128
132
  `Subcommands:\n` +
@@ -153,6 +157,16 @@ export async function cmdServe(rest: string[]): Promise<number> {
153
157
  .option("token", { type: "string", description: "Auth token (auto-generated if omitted)" })
154
158
  .option("tls-cert", { type: "string", description: "TLS certificate file (PEM)" })
155
159
  .option("tls-key", { type: "string", description: "TLS private key file (PEM)" })
160
+ .option("share", {
161
+ type: "string",
162
+ description:
163
+ "Share over WebRTC: bare flag mints a room+link, or pass webrtc://room:token@host",
164
+ })
165
+ .option("allow-spawn", {
166
+ type: "boolean",
167
+ default: false,
168
+ description: "Deprecated no-op — the console can always spawn agents",
169
+ })
156
170
  .help(false)
157
171
  .version(false)
158
172
  .exitProcess(false);
@@ -178,6 +192,11 @@ export async function cmdServe(rest: string[]): Promise<number> {
178
192
  }
179
193
 
180
194
  const token = await loadOrCreateToken(tokenFlag);
195
+ // Spawning is always allowed: a connected console already has full read-write
196
+ // control over every running agent (it writes straight to their stdin), so it
197
+ // can already make an agent do anything — gating /api/spawn behind a flag or a
198
+ // y/N prompt bought no real safety. We just log each spawn so the host sees it.
199
+ // (--allow-spawn is still accepted as a no-op for older invocations.)
181
200
 
182
201
  const serverOpts: any = {
183
202
  hostname: host,
@@ -242,10 +261,39 @@ export async function cmdServe(rest: string[]): Promise<number> {
242
261
  }
243
262
  }
244
263
 
264
+ // GET /api/size/:keyword — the agent's current PTY size, so the console can
265
+ // render the existing buffer at the agent's real width before adapting.
266
+ const sizeM = /^\/api\/size\/(.+)$/.exec(p);
267
+ if (req.method === "GET" && sizeM) {
268
+ const keyword = decodeURIComponent(sizeM[1]!);
269
+ try {
270
+ const record = await resolveOne(keyword, defaultOpts());
271
+ const ayHome = process.env.AGENT_YES_HOME ?? path.join(homedir(), ".agent-yes");
272
+ let cols: number | null = null;
273
+ let rows: number | null = null;
274
+ try {
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) */
283
+ }
284
+ return Response.json({ pid: record.pid, cols, rows });
285
+ } catch (e) {
286
+ return new Response((e as Error).message, { status: 404 });
287
+ }
288
+ }
289
+
245
290
  // GET /api/tail/:keyword — SSE streaming
246
291
  const tailM = /^\/api\/tail\/(.+)$/.exec(p);
247
292
  if (req.method === "GET" && tailM) {
248
293
  const keyword = decodeURIComponent(tailM[1]!);
294
+ // raw=1 streams the unmodified PTY bytes (ANSI/cursor control intact) so a
295
+ // browser xterm.js can render the real terminal; default stays ANSI-stripped.
296
+ const raw = url.searchParams.get("raw") === "1";
249
297
  try {
250
298
  const record = await resolveOne(keyword, defaultOpts());
251
299
  if (!record.log_file)
@@ -259,10 +307,12 @@ export async function cmdServe(rest: string[]): Promise<number> {
259
307
  ctrl.enqueue(enc.encode(`data: ${JSON.stringify(text)}\n\n`));
260
308
  const ping = () => ctrl.enqueue(enc.encode(": ping\n\n"));
261
309
 
262
- // Initial tail
310
+ // Initial tail. Raw: replay the last ~64 KB of PTY bytes (enough to
311
+ // contain a recent full-screen redraw so xterm converges fast).
263
312
  const initBuf = await readFile(logPath).catch(() => Buffer.alloc(0));
264
- const initText = await renderRawLog(initBuf, { mode: "tail", n: 96 });
265
- send(initText);
313
+ if (raw)
314
+ send(new TextDecoder().decode(initBuf.slice(Math.max(0, initBuf.length - 65536))));
315
+ else send(await renderRawLog(initBuf, { mode: "tail", n: 96 }));
266
316
 
267
317
  let offset = initBuf.length;
268
318
  let closed = false;
@@ -281,30 +331,59 @@ export async function cmdServe(rest: string[]): Promise<number> {
281
331
  // eslint-disable-next-line no-control-regex
282
332
  const ctrlRe = /[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/g;
283
333
 
284
- const poller = setInterval(async () => {
285
- if (closed) {
286
- clearInterval(poller);
287
- return;
288
- }
334
+ // Stream only the bytes appended since `offset` (incremental read,
335
+ // not a full re-read), driven by fs.watch for near-instant echo with
336
+ // a short fallback poll in case the watcher misses an event. The old
337
+ // 300 ms full-file poll was the dominant typing-echo latency.
338
+ const fh = await open(logPath, "r").catch(() => null);
339
+ let reading = false;
340
+ const flush = async () => {
341
+ if (closed || reading || !fh) return;
342
+ reading = true;
289
343
  try {
290
- const full = await readFile(logPath);
291
- if (full.length <= offset) return;
292
- const chunk = full.slice(offset);
293
- offset = full.length;
294
- const text = new TextDecoder()
295
- .decode(chunk)
296
- .replace(ansiRe, "")
297
- .replace(ctrlRe, "");
298
- if (text.trim()) send(text.trimStart());
344
+ const { size } = await fh.stat();
345
+ if (size < offset) offset = size; // truncated/rotated
346
+ if (size > offset) {
347
+ const len = size - offset;
348
+ const buf = Buffer.allocUnsafe(len);
349
+ const { bytesRead } = await fh.read(buf, 0, len, offset);
350
+ offset += bytesRead;
351
+ const chunk = buf.subarray(0, bytesRead);
352
+ if (raw) {
353
+ send(new TextDecoder().decode(chunk));
354
+ } else {
355
+ const text = new TextDecoder()
356
+ .decode(chunk)
357
+ .replace(ansiRe, "")
358
+ .replace(ctrlRe, "");
359
+ if (text.trim()) send(text.trimStart());
360
+ }
361
+ }
299
362
  } catch {
300
363
  /* log gone */
364
+ } finally {
365
+ reading = false;
301
366
  }
302
- }, 300);
367
+ };
368
+
369
+ let watcher: ReturnType<typeof watch> | null = null;
370
+ try {
371
+ watcher = watch(logPath, () => void flush());
372
+ } catch {
373
+ /* fs.watch unsupported — the fallback poll below still works */
374
+ }
375
+ const poller = setInterval(() => void flush(), 60);
303
376
 
304
377
  req.signal.addEventListener("abort", () => {
305
378
  closed = true;
306
379
  clearInterval(heartbeat);
307
380
  clearInterval(poller);
381
+ try {
382
+ watcher?.close();
383
+ } catch {
384
+ /* already closed */
385
+ }
386
+ void fh?.close().catch(() => {});
308
387
  try {
309
388
  ctrl.close();
310
389
  } catch {
@@ -356,6 +435,71 @@ export async function cmdServe(rest: string[]): Promise<number> {
356
435
  }
357
436
  }
358
437
 
438
+ // POST /api/resize/:keyword body {cols, rows} — drive the agent's PTY size.
439
+ // Mirrors `ay attach`: write ~/.agent-yes/winsize/<pid> then SIGWINCH; the
440
+ // agent's resize listener picks it up and reflows its TUI to that width.
441
+ const resizeM = /^\/api\/resize\/(.+)$/.exec(p);
442
+ if (req.method === "POST" && resizeM) {
443
+ const keyword = decodeURIComponent(resizeM[1]!);
444
+ let body: { cols?: number; rows?: number };
445
+ try {
446
+ body = await req.json();
447
+ } catch {
448
+ return new Response("invalid JSON body", { status: 400 });
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 });
470
+ }
471
+ }
472
+
473
+ // POST /api/spawn body {cli, cwd, prompt} — launch a new agent
474
+ if (req.method === "POST" && p === "/api/spawn") {
475
+ let body: { cli?: string; cwd?: string; prompt?: string };
476
+ try {
477
+ body = await req.json();
478
+ } catch {
479
+ return new Response("invalid JSON body", { status: 400 });
480
+ }
481
+ const cli = String(body.cli ?? "claude");
482
+ if (!SUPPORTED_CLIS.includes(cli as never))
483
+ return new Response(`unsupported cli: ${cli}`, { status: 400 });
484
+ const cwd = typeof body.cwd === "string" && body.cwd ? body.cwd : process.cwd();
485
+ const prompt = String(body.prompt ?? "");
486
+ process.stderr.write(
487
+ `→ console spawned: ay ${cli}${prompt ? ` -- "${prompt.slice(0, 60)}"` : ""} (cwd: ${cwd})\n`,
488
+ );
489
+ try {
490
+ const child = Bun.spawn(["ay", cli, ...(prompt ? ["--", prompt] : [])], {
491
+ cwd,
492
+ stdin: "ignore",
493
+ stdout: "ignore",
494
+ stderr: "ignore",
495
+ });
496
+ child.unref();
497
+ return Response.json({ ok: true, pid: child.pid, cli, cwd });
498
+ } catch (e) {
499
+ return new Response((e as Error).message, { status: 500 });
500
+ }
501
+ }
502
+
359
503
  return new Response("Not Found", { status: 404 });
360
504
  },
361
505
  };
@@ -380,6 +524,27 @@ export async function cmdServe(rest: string[]): Promise<number> {
380
524
  ` openssl req -x509 -newkey rsa:2048 -keyout key.pem -out cert.pem -days 365 -nodes -subj '/CN=localhost'\n\n`,
381
525
  );
382
526
  }
527
+ // --share: bridge this local server to a WebRTC room so the agent-yes.com
528
+ // console can reach it peer-to-peer. Bare flag mints a room; a webrtc:// value
529
+ // joins an explicit one.
530
+ if (argv.share !== undefined) {
531
+ const shareUrl =
532
+ typeof argv.share === "string" && argv.share.startsWith("webrtc://") ? argv.share : undefined;
533
+ try {
534
+ const { startShare } = await import("./share.ts");
535
+ const { link } = await startShare({
536
+ url: shareUrl,
537
+ apiUrl: `http://127.0.0.1:${port}`,
538
+ apiToken: token,
539
+ });
540
+ process.stdout.write(
541
+ `\nshared over WebRTC — open this link (the token is eaten from the URL on open):\n ${link}\n\n`,
542
+ );
543
+ } catch (e) {
544
+ process.stderr.write(`ay serve --share failed: ${(e as Error).message}\n`);
545
+ }
546
+ }
547
+
383
548
  process.stdout.write(`(Ctrl-C to stop)\n`);
384
549
 
385
550
  await new Promise<void>((resolve) => {