agent-yes 1.113.0 → 1.115.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/README.md CHANGED
@@ -24,7 +24,7 @@ Or with a package manager you already have:
24
24
  bun add -g agent-yes # or: npm install -g agent-yes
25
25
  ```
26
26
 
27
- Then: `ay claude` (run an agent with auto-yes) · `ay serve share` (web console + shareable link) · live console at https://agent-yes.com
27
+ Then: `ay claude` (run an agent with auto-yes) · `ay serve --share` (web console + shareable link) · live console at https://agent-yes.com
28
28
 
29
29
  ## Features
30
30
 
@@ -0,0 +1,8 @@
1
+ import "./ts-B62nAAfY.js";
2
+ import "./logger-B9h0djqx.js";
3
+ import "./versionChecker-BdkE7S2A.js";
4
+ import "./pidStore-DBjlqzo8.js";
5
+ import "./globalPidIndex-yVd3mbsV.js";
6
+ import { t as SUPPORTED_CLIS } from "./SUPPORTED_CLIS-VayLM5qX.js";
7
+
8
+ export { SUPPORTED_CLIS };
@@ -1,8 +1,8 @@
1
- import { t as CLIS_CONFIG } from "./ts-ChVJOKNr.js";
1
+ import { t as CLIS_CONFIG } from "./ts-B62nAAfY.js";
2
2
 
3
3
  //#region ts/SUPPORTED_CLIS.ts
4
4
  const SUPPORTED_CLIS = Object.keys(CLIS_CONFIG);
5
5
 
6
6
  //#endregion
7
7
  export { SUPPORTED_CLIS as t };
8
- //# sourceMappingURL=SUPPORTED_CLIS-BpO7ZYx_.js.map
8
+ //# sourceMappingURL=SUPPORTED_CLIS-VayLM5qX.js.map
package/dist/cli.js CHANGED
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env bun
2
2
  import { n as logger } from "./logger-B9h0djqx.js";
3
- import { i as versionString, n as displayVersion, r as getInstalledPackage, t as checkAndAutoUpdate } from "./versionChecker-wxlWV_VT.js";
3
+ import { i as versionString, n as displayVersion, r as getInstalledPackage, t as checkAndAutoUpdate } from "./versionChecker-BdkE7S2A.js";
4
4
  import { argv } from "process";
5
5
  import { execFileSync, spawn } from "child_process";
6
6
  import ms from "ms";
@@ -482,7 +482,7 @@ function buildRustArgs(argv, cliFromScript, supportedClis) {
482
482
  {
483
483
  const rawArg = process.argv[2];
484
484
  const isHelpFlag = rawArg === "-h" || rawArg === "--help";
485
- const { isSubcommand, runSubcommand, cmdHelp } = await import("./subcommands-DU5RXU0g.js");
485
+ const { isSubcommand, runSubcommand, cmdHelp } = await import("./subcommands-SOHKtDbk.js");
486
486
  if (isHelpFlag && process.argv.length === 3) {
487
487
  cmdHelp();
488
488
  process.exit(0);
@@ -515,7 +515,7 @@ if (config.useRust) {
515
515
  }
516
516
  }
517
517
  if (rustBinary) {
518
- const { SUPPORTED_CLIS } = await import("./SUPPORTED_CLIS-DtJakX4G.js");
518
+ const { SUPPORTED_CLIS } = await import("./SUPPORTED_CLIS-DUVB1HyL.js");
519
519
  const rustArgs = buildRustArgs(process.argv, config.cli, SUPPORTED_CLIS);
520
520
  if (config.verbose) {
521
521
  console.log(`[rust] Using binary: ${rustBinary}`);
package/dist/index.js CHANGED
@@ -1,6 +1,6 @@
1
- import { a as removeControlCharacters, i as AgentContext, n as agentYes, r as config, t as CLIS_CONFIG } from "./ts-ChVJOKNr.js";
1
+ import { a as removeControlCharacters, i as AgentContext, n as agentYes, r as config, t as CLIS_CONFIG } from "./ts-B62nAAfY.js";
2
2
  import "./logger-B9h0djqx.js";
3
- import "./versionChecker-wxlWV_VT.js";
3
+ import "./versionChecker-BdkE7S2A.js";
4
4
  import "./pidStore-DBjlqzo8.js";
5
5
  import "./globalPidIndex-yVd3mbsV.js";
6
6
 
@@ -1,11 +1,11 @@
1
- import "./ts-ChVJOKNr.js";
1
+ import "./ts-B62nAAfY.js";
2
2
  import "./logger-B9h0djqx.js";
3
- import "./versionChecker-wxlWV_VT.js";
3
+ import "./versionChecker-BdkE7S2A.js";
4
4
  import "./pidStore-DBjlqzo8.js";
5
5
  import "./globalPidIndex-yVd3mbsV.js";
6
- import { t as SUPPORTED_CLIS } from "./SUPPORTED_CLIS-BpO7ZYx_.js";
6
+ import { t as SUPPORTED_CLIS } from "./SUPPORTED_CLIS-VayLM5qX.js";
7
7
  import "./remotes-C3xPRtfg.js";
8
- import { c as listRecords, d as renderRawLog, f as resolveOne, g as writeToIpc, m as snapshotStatus, r as controlCodeFromName, u as readNotes } from "./subcommands-Za5uwCq6.js";
8
+ import { c as listRecords, d as renderRawLog, f as resolveOne, g as writeToIpc, m as snapshotStatus, r as controlCodeFromName, u as readNotes } from "./subcommands-t1uOb17r.js";
9
9
  import yargs from "yargs";
10
10
  import { mkdir, open, readFile, writeFile } from "fs/promises";
11
11
  import { homedir, hostname, userInfo } from "os";
@@ -176,6 +176,11 @@ Options:
176
176
  description: "Deprecated no-op — the console can always spawn agents"
177
177
  }).help(false).version(false).exitProcess(false).parseAsync();
178
178
  if (argv.daemon) return cmdServeDaemon("install", rest.filter((a) => a !== "--daemon" && a !== "-d"));
179
+ const stray = argv._.map(String);
180
+ if (stray.length) {
181
+ const hint = stray.includes("share") ? " (did you mean --share?)" : "";
182
+ process.stderr.write(`ay serve: ignoring unknown argument${stray.length > 1 ? "s" : ""}: ${stray.join(" ")}${hint}\n`);
183
+ }
179
184
  const port = argv.port ?? DEFAULT_PORT;
180
185
  const host = argv.host ?? "127.0.0.1";
181
186
  const tokenFlag = typeof argv.token === "string" ? argv.token : void 0;
@@ -220,6 +225,58 @@ Options:
220
225
  return null;
221
226
  }
222
227
  };
228
+ const GIT_TTL_MS = 5e3;
229
+ const gitCache = /* @__PURE__ */ new Map();
230
+ const gitStatus = async (cwd) => {
231
+ if (!cwd) return null;
232
+ const now = Date.now();
233
+ const hit = gitCache.get(cwd);
234
+ if (hit && now - hit.at < GIT_TTL_MS) return hit.val;
235
+ let val = null;
236
+ try {
237
+ const proc = Bun.spawn([
238
+ "git",
239
+ "status",
240
+ "--porcelain",
241
+ "--branch"
242
+ ], {
243
+ cwd,
244
+ stdout: "pipe",
245
+ stderr: "ignore",
246
+ signal: AbortSignal.timeout(2e3)
247
+ });
248
+ const out = await new Response(proc.stdout).text();
249
+ await proc.exited;
250
+ if (proc.exitCode === 0) {
251
+ const lines = out.split("\n");
252
+ const h = /^## (.+)$/.exec(lines[0] ?? "")?.[1] ?? "";
253
+ const unborn = /^No commits yet on (.+)$/.exec(h);
254
+ const branch = unborn ? unborn[1] : /^(.+?)(?:\.\.\.|\s|$)/.exec(h)?.[1] || null;
255
+ const ahead = Number(/\bahead (\d+)/.exec(h)?.[1] ?? 0);
256
+ const behind = Number(/\bbehind (\d+)/.exec(h)?.[1] ?? 0);
257
+ const changed = lines.slice(1).filter((l) => l.trim().length > 0).length;
258
+ val = {
259
+ branch,
260
+ dirty: changed > 0,
261
+ changed,
262
+ ahead,
263
+ behind
264
+ };
265
+ }
266
+ } catch {
267
+ val = null;
268
+ }
269
+ gitCache.set(cwd, {
270
+ at: now,
271
+ val
272
+ });
273
+ return val;
274
+ };
275
+ const withMeta = async (r) => ({
276
+ ...r,
277
+ title: await logTitle(r.log_file),
278
+ git: r.status === "exited" ? null : await gitStatus(r.cwd)
279
+ });
223
280
  const apiFetch = async (req) => {
224
281
  if (!checkAuth(req, token)) return new Response("Unauthorized", { status: 401 });
225
282
  const url = new URL(req.url);
@@ -232,11 +289,7 @@ Options:
232
289
  });
233
290
  try {
234
291
  const records = await listRecords(keyword, opts);
235
- const withTitles = await Promise.all(records.map(async (r) => ({
236
- ...r,
237
- title: await logTitle(r.log_file)
238
- })));
239
- return Response.json(withTitles);
292
+ return Response.json(await Promise.all(records.map(withMeta)));
240
293
  } catch (e) {
241
294
  return new Response(e.message, { status: 500 });
242
295
  }
@@ -258,10 +311,7 @@ Options:
258
311
  const sent = /* @__PURE__ */ new Map();
259
312
  const compute = async () => {
260
313
  const records = await listRecords(keyword, opts);
261
- return Promise.all(records.map(async (r) => ({
262
- ...r,
263
- title: await logTitle(r.log_file)
264
- })));
314
+ return Promise.all(records.map(withMeta));
265
315
  };
266
316
  const tick = async (first) => {
267
317
  if (closed) return;
@@ -601,16 +651,18 @@ Options:
601
651
  process.stdout.write(` ay remote add <alias> ${scheme}://${token}@<host>:${port}\n\n`);
602
652
  if (!useHttps) process.stdout.write("for HTTPS: ay serve --tls-cert cert.pem --tls-key key.pem\n openssl req -x509 -newkey rsa:2048 -keyout key.pem -out cert.pem -days 365 -nodes -subj '/CN=localhost'\n\n");
603
653
  }
654
+ let closeShare;
604
655
  if (wantWebrtc) {
605
656
  const webrtcVal = argv.webrtc ?? argv.share;
606
657
  const explicitUrl = typeof webrtcVal === "string" && webrtcVal.startsWith("webrtc://") ? webrtcVal : void 0;
607
658
  try {
608
- const { startShare, loadOrCreateShareRoom } = await import("./share-BsCeIfQM.js");
609
- const { link } = await startShare({
659
+ const { startShare, loadOrCreateShareRoom } = await import("./share-DwzKXEsJ.js");
660
+ const { link, close } = await startShare({
610
661
  url: explicitUrl ?? await loadOrCreateShareRoom(),
611
662
  localFetch: apiFetch,
612
663
  apiToken: token
613
664
  });
665
+ closeShare = close;
614
666
  process.stdout.write(`${wantHttp ? "\n" : ""}shared over WebRTC — open this link (the token is eaten from the URL on open):\n ${link}\n` + (explicitUrl ? "\n" : ` (persistent room — same link across restarts; delete ~/.agent-yes/.share-room to rotate)\n\n`));
615
667
  } catch (e) {
616
668
  process.stderr.write(`ay serve --webrtc failed: ${e.message}\n`);
@@ -620,10 +672,12 @@ Options:
620
672
  process.stdout.write(`(Ctrl-C to stop)\n`);
621
673
  await new Promise((resolve) => {
622
674
  process.on("SIGINT", () => {
675
+ closeShare?.();
623
676
  server?.stop();
624
677
  resolve();
625
678
  });
626
679
  process.on("SIGTERM", () => {
680
+ closeShare?.();
627
681
  server?.stop();
628
682
  resolve();
629
683
  });
@@ -633,4 +687,4 @@ Options:
633
687
 
634
688
  //#endregion
635
689
  export { cmdServe };
636
- //# sourceMappingURL=serve-YPd9gtRO.js.map
690
+ //# sourceMappingURL=serve-CP61tKuJ.js.map
@@ -68,8 +68,12 @@ async function startShare(opts) {
68
68
  const wsScheme = host.startsWith("localhost") || host.startsWith("127.") ? "ws" : "wss";
69
69
  const link = `${host === "s.agent-yes.com" ? "https://agent-yes.com" : "http://localhost:7778"}/#${room}:${token}${host === "s.agent-yes.com" ? "" : "@" + host}`;
70
70
  const peers = /* @__PURE__ */ new Map();
71
+ let closed = false;
72
+ let currentWs;
71
73
  const connectSignaling = (onReady) => {
74
+ if (closed) return;
72
75
  const ws = new WebSocket(`${wsScheme}://${host}/${room}`, [SUB]);
76
+ currentWs = ws;
73
77
  let ready = false;
74
78
  ws.onopen = () => {
75
79
  ws.send(JSON.stringify({
@@ -81,6 +85,7 @@ async function startShare(opts) {
81
85
  onReady();
82
86
  };
83
87
  ws.onmessage = async (ev) => {
88
+ if (closed) return;
84
89
  const m = JSON.parse(ev.data);
85
90
  if (m.type === "peer-join") startPeer(ws, m.peer);
86
91
  else if (m.type === "answer") await peers.get(m.from)?.pc.setRemoteDescription({
@@ -91,6 +96,7 @@ async function startShare(opts) {
91
96
  else if (m.type === "peer-leave") closePeer(m.peer);
92
97
  };
93
98
  ws.onclose = () => {
99
+ if (closed) return;
94
100
  setTimeout(() => connectSignaling(() => {}), ready ? 1500 : 4e3);
95
101
  };
96
102
  ws.onerror = () => {};
@@ -135,7 +141,10 @@ async function startShare(opts) {
135
141
  peers.delete(peerId);
136
142
  }
137
143
  function send(dc, obj) {
138
- if (dc.readyState === "open") dc.send(JSON.stringify(obj));
144
+ if (dc.readyState !== "open") return;
145
+ try {
146
+ dc.send(JSON.stringify(obj));
147
+ } catch {}
139
148
  }
140
149
  async function onReq(dc, aborts, req) {
141
150
  if (req.t === "abort") {
@@ -190,12 +199,20 @@ async function startShare(opts) {
190
199
  }
191
200
  }
192
201
  await new Promise((resolve) => connectSignaling(resolve));
202
+ const close = () => {
203
+ closed = true;
204
+ try {
205
+ currentWs?.close();
206
+ } catch {}
207
+ for (const peerId of [...peers.keys()]) closePeer(peerId);
208
+ };
193
209
  return {
194
210
  room,
195
- link
211
+ link,
212
+ close
196
213
  };
197
214
  }
198
215
 
199
216
  //#endregion
200
217
  export { loadOrCreateShareRoom, startShare };
201
- //# sourceMappingURL=share-BsCeIfQM.js.map
218
+ //# sourceMappingURL=share-DwzKXEsJ.js.map
@@ -1,6 +1,6 @@
1
1
  import "./logger-B9h0djqx.js";
2
2
  import "./globalPidIndex-yVd3mbsV.js";
3
3
  import "./remotes-C3xPRtfg.js";
4
- import { a as finalizedLines, c as listRecords, d as renderRawLog, f as resolveOne, g as writeToIpc, h as stopTipForCli, i as cursorAbs, l as matchKeyword, m as snapshotStatus, n as cmdHelp, o as isPidAlive, p as runSubcommand, r as controlCodeFromName, s as isSubcommand, t as GRACEFUL_EXIT_COMMANDS, u as readNotes } from "./subcommands-Za5uwCq6.js";
4
+ import { a as finalizedLines, c as listRecords, d as renderRawLog, f as resolveOne, g as writeToIpc, h as stopTipForCli, i as cursorAbs, l as matchKeyword, m as snapshotStatus, n as cmdHelp, o as isPidAlive, p as runSubcommand, r as controlCodeFromName, s as isSubcommand, t as GRACEFUL_EXIT_COMMANDS, u as readNotes } from "./subcommands-t1uOb17r.js";
5
5
 
6
6
  export { cmdHelp, isSubcommand, runSubcommand };
@@ -163,7 +163,7 @@ async function runSubcommand(argv) {
163
163
  case "restart": return await cmdRestart(rest);
164
164
  case "note": return await cmdNote(rest);
165
165
  case "serve": {
166
- const { cmdServe } = await import("./serve-YPd9gtRO.js");
166
+ const { cmdServe } = await import("./serve-CP61tKuJ.js");
167
167
  return cmdServe(rest);
168
168
  }
169
169
  case "setup": {
@@ -1595,4 +1595,4 @@ async function cmdStatus(rest) {
1595
1595
 
1596
1596
  //#endregion
1597
1597
  export { finalizedLines as a, listRecords as c, renderRawLog as d, resolveOne as f, writeToIpc as g, stopTipForCli as h, cursorAbs as i, matchKeyword as l, snapshotStatus as m, cmdHelp as n, isPidAlive as o, runSubcommand as p, controlCodeFromName as r, isSubcommand as s, GRACEFUL_EXIT_COMMANDS as t, readNotes as u };
1598
- //# sourceMappingURL=subcommands-Za5uwCq6.js.map
1598
+ //# sourceMappingURL=subcommands-t1uOb17r.js.map
@@ -1,5 +1,5 @@
1
1
  import { n as logger, t as addTransport } from "./logger-B9h0djqx.js";
2
- import { r as getInstalledPackage } from "./versionChecker-wxlWV_VT.js";
2
+ import { r as getInstalledPackage } from "./versionChecker-BdkE7S2A.js";
3
3
  import { n as agentYesHome, t as PidStore } from "./pidStore-DBjlqzo8.js";
4
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";
@@ -1714,4 +1714,4 @@ function sleep(ms) {
1714
1714
 
1715
1715
  //#endregion
1716
1716
  export { removeControlCharacters as a, AgentContext as i, agentYes as n, config as r, CLIS_CONFIG as t };
1717
- //# sourceMappingURL=ts-ChVJOKNr.js.map
1717
+ //# sourceMappingURL=ts-B62nAAfY.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.113.0";
10
+ var version = "1.115.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-wxlWV_VT.js.map
224
+ //# sourceMappingURL=versionChecker-BdkE7S2A.js.map
@@ -115,6 +115,20 @@ export function tagsFor(e) {
115
115
  return t;
116
116
  }
117
117
 
118
+ // Compact git indicator from the record's `git` snapshot (server-side
119
+ // `git status --porcelain --branch`): "±3" changed files, "↑1" ahead, "↓2"
120
+ // behind. Returns "" when there's no git info or the tree is clean and in sync,
121
+ // so a tidy repo adds no noise. Branch itself is shown via the path identity.
122
+ export function gitLabel(e) {
123
+ const g = e.git;
124
+ if (!g) return "";
125
+ const parts = [];
126
+ if (g.changed > 0) parts.push("±" + g.changed);
127
+ if (g.ahead > 0) parts.push("↑" + g.ahead);
128
+ if (g.behind > 0) parts.push("↓" + g.behind);
129
+ return parts.join(" ");
130
+ }
131
+
118
132
  // Human age of an agent ("12s" / "5m" / "3h"). `now` is injectable so tests
119
133
  // don't depend on the wall clock; the browser calls age(e) and gets Date.now().
120
134
  export function age(e, now = Date.now()) {
@@ -130,7 +144,16 @@ export function age(e, now = Date.now()) {
130
144
  // case-insensitive substring search over title/prompt/cli/cwd/status.
131
145
  export function matches(e, toks) {
132
146
  const hay =
133
- (e.title || "") + " " + (e.prompt || "") + " " + e.cli + " " + (e.cwd || "") + " " + e.status;
147
+ (e.title || "") +
148
+ " " +
149
+ (e.prompt || "") +
150
+ " " +
151
+ e.cli +
152
+ " " +
153
+ (e.cwd || "") +
154
+ " " +
155
+ e.status +
156
+ (e.git?.dirty ? " dirty" : "");
134
157
  return toks.every((tok) => {
135
158
  tok = tok.toLowerCase();
136
159
  const ci = tok.indexOf(":");
package/lab/ui/index.html CHANGED
@@ -505,6 +505,17 @@
505
505
  color: var(--muted);
506
506
  font-size: 11.5px;
507
507
  }
508
+ /* git chip: ±changed ↑ahead ↓behind, amber when the worktree is dirty */
509
+ .git {
510
+ font-family: var(--mono);
511
+ font-size: 10.5px;
512
+ color: var(--muted);
513
+ white-space: nowrap;
514
+ flex: none;
515
+ }
516
+ .git.dirty {
517
+ color: var(--amber);
518
+ }
508
519
  .detail {
509
520
  color: var(--muted);
510
521
  font-size: 12.5px;
@@ -737,6 +748,7 @@
737
748
  />
738
749
  <script src="https://cdn.jsdelivr.net/npm/@xterm/xterm@5.5.0/lib/xterm.min.js"></script>
739
750
  <script src="https://cdn.jsdelivr.net/npm/@xterm/addon-fit@0.10.0/lib/addon-fit.min.js"></script>
751
+ <script src="https://cdn.jsdelivr.net/npm/@xterm/addon-web-links@0.11.0/lib/addon-web-links.min.js"></script>
740
752
  <script type="module">
741
753
  // codehost room transport — vendored from codehost's `bun run build:lib`.
742
754
  // Loaded as a module (deferred), so the classic script below awaits the
@@ -821,6 +833,7 @@
821
833
  repoBranch,
822
834
  ident,
823
835
  tagsFor,
836
+ gitLabel,
824
837
  age,
825
838
  matches,
826
839
  nextIndex,
@@ -909,6 +922,7 @@
909
922
  connect() {
910
923
  return new Promise((resolve, reject) => {
911
924
  const ws = new WebSocket(`wss://${this.host}/${this.room}`, [SUB]);
925
+ this.ws = ws; // kept so close() can drop the signaling registration too
912
926
  let pc,
913
927
  settled = false;
914
928
  const fail = (e) => {
@@ -966,6 +980,7 @@
966
980
  if (stream) stream(r.chunk);
967
981
  } else if (r.t === "end") {
968
982
  if (call) {
983
+ clearTimeout(call.timer);
969
984
  this.calls.delete(r.id);
970
985
  r.error
971
986
  ? call.reject(new Error(r.error))
@@ -976,8 +991,21 @@
976
991
  req(method, path, body) {
977
992
  const id = this.nextId++;
978
993
  return new Promise((resolve, reject) => {
979
- this.calls.set(id, { status: 0, body: "", resolve, reject });
980
- this.dc.send(JSON.stringify({ t: "req", id, method, path, body }));
994
+ // Without a deadline a request over a silently-dead DataChannel (host
995
+ // gone, ICE not yet timed out) never settles, so the caller — and the
996
+ // poll loop — hangs forever and the room never reconnects. Reject on a
997
+ // timeout so listSource sees the failure and triggers backoff.
998
+ const timer = setTimeout(() => {
999
+ if (this.calls.delete(id)) reject(new Error("request timed out"));
1000
+ }, 12000);
1001
+ this.calls.set(id, { status: 0, body: "", resolve, reject, timer });
1002
+ try {
1003
+ this.dc.send(JSON.stringify({ t: "req", id, method, path, body }));
1004
+ } catch (e) {
1005
+ clearTimeout(timer);
1006
+ this.calls.delete(id);
1007
+ reject(e); // channel already torn down
1008
+ }
981
1009
  });
982
1010
  }
983
1011
  subscribe(path, onRaw) {
@@ -991,6 +1019,26 @@
991
1019
  } catch {}
992
1020
  };
993
1021
  }
1022
+ // Tear down BOTH wires. Closing only the pc leaves the signaling socket
1023
+ // open and this client registered in the room, so each reconnect would
1024
+ // leak another peer on the host. onstate is detached by the caller first.
1025
+ close() {
1026
+ try {
1027
+ this.ws?.close();
1028
+ } catch {}
1029
+ try {
1030
+ this.pc?.close();
1031
+ } catch {}
1032
+ this.dc = null;
1033
+ // Settle anything still in flight now, rather than letting each req's
1034
+ // 12s timeout fire long after the client is gone.
1035
+ for (const c of this.calls.values()) {
1036
+ clearTimeout(c.timer);
1037
+ c.reject(new Error("connection closed"));
1038
+ }
1039
+ this.calls.clear();
1040
+ this.streams.clear();
1041
+ }
994
1042
  }
995
1043
 
996
1044
  // ---- codehost rooms: a THIRD wire -----------------------------------
@@ -1298,6 +1346,11 @@
1298
1346
  s.serverCount = 0;
1299
1347
  s.byPid = new Map();
1300
1348
  s.agents = [];
1349
+ // A failed poll on an agent-yes room means the tunnel is unresponsive
1350
+ // (host gone/restarted) — the fastest, most reliable drop signal we get,
1351
+ // since WebRTC's own failed/closed state can lag 15-30s. Kick off the
1352
+ // backoff reconnect (no-op if one's already queued or the room's gone).
1353
+ if (s.kind === "rtc") scheduleRtcReconnect(s);
1301
1354
  return [];
1302
1355
  }
1303
1356
  }
@@ -1333,6 +1386,16 @@
1333
1386
  // Compact list: one line per agent (dot + cli + title), persisted per device.
1334
1387
  let compactList = localStorage.getItem("ay.compactList") === "1";
1335
1388
 
1389
+ // A small git chip (±changed ↑ahead ↓behind) for a row, amber when the tree
1390
+ // is dirty. Empty for clean/in-sync repos and non-git cwds — no noise.
1391
+ function gitChipHtml(e) {
1392
+ const label = gitLabel(e);
1393
+ if (!label) return "";
1394
+ const g = e.git || {};
1395
+ const tip = `git: ${g.changed || 0} changed · ${g.ahead || 0} ahead · ${g.behind || 0} behind`;
1396
+ return `<span class="git${g.dirty ? " dirty" : ""}" title="${esc(tip)}">${esc(label)}</span>`;
1397
+ }
1398
+
1336
1399
  function renderList() {
1337
1400
  const toks = $("q").value.trim().split(/\s+/).filter(Boolean);
1338
1401
  const shown = entries.filter((e) => matches(e, toks));
@@ -1355,6 +1418,7 @@
1355
1418
  ${hasIdent(id) ? `<span class="cident" title="${esc(fullIdent(e))}">${esc(id)}</span>` : ""}
1356
1419
  ${cli ? `<span class="cname">${esc(cli)}</span>` : ""}
1357
1420
  <span class="ctitle ${e.title ? "" : "dim"}" title="${esc(t)}">${esc(t)}</span>
1421
+ ${gitChipHtml(e)}
1358
1422
  <span class="age">${age(e)}</span></div>`;
1359
1423
  })
1360
1424
  .join("") || `<div class="empty">no match</div>`;
@@ -1376,6 +1440,7 @@
1376
1440
  <div class="r1"><span class="dot ${esc(e.status)}"></span>
1377
1441
  <span class="name">${esc(cliLabel(e) || ident(e) || "agent")}</span>
1378
1442
  <span class="badge">pid ${e.pid}</span>
1443
+ ${gitChipHtml(e)}
1379
1444
  <span class="age">${age(e)}</span></div>
1380
1445
  ${e.title ? `<div class="rowtitle" title="${esc(e.title)}">${esc(e.title)}</div>` : ""}
1381
1446
  ${e.prompt ? `<div class="detail" title="${esc(e.prompt)}">${esc(e.prompt)}</div>` : ""}
@@ -1513,6 +1578,19 @@
1513
1578
  });
1514
1579
  fit = new FitAddon.FitAddon();
1515
1580
  term.loadAddon(fit);
1581
+ // Make plain URLs in the output clickable. Agents that emit OSC 8
1582
+ // hyperlinks already get clickable text, but most output is bare URLs —
1583
+ // this addon scans the rendered buffer and links them too. Click opens a
1584
+ // new tab (noopener so the page can't be tampered with via window.opener).
1585
+ try {
1586
+ term.loadAddon(
1587
+ new WebLinksAddon.WebLinksAddon((e, uri) =>
1588
+ window.open(uri, "_blank", "noopener,noreferrer"),
1589
+ ),
1590
+ );
1591
+ } catch {
1592
+ /* addon CDN blocked — terminal still works, just without auto-links */
1593
+ }
1516
1594
  term.open(logEl);
1517
1595
  // On a phone, auto-focusing yanks up the soft keyboard the moment you open
1518
1596
  // an agent — even when you only meant to read the tail. Skip it there; a tap
@@ -1709,6 +1787,8 @@
1709
1787
  function removeSource(room) {
1710
1788
  const s = sources.get(room);
1711
1789
  if (!s) return;
1790
+ s.removed = true; // stop any pending exp-backoff reconnect (see below)
1791
+ clearTimeout(s.reconnectTimer);
1712
1792
  unsubscribeSource(s);
1713
1793
  try {
1714
1794
  s.client?.close?.();
@@ -1717,6 +1797,97 @@
1717
1797
  sources.delete(room);
1718
1798
  }
1719
1799
 
1800
+ // Exponential-backoff reconnect for an agent-yes share room (the RTCClient
1801
+ // wire). codehost rooms already self-heal via room-client.js; the agent-yes
1802
+ // viewer was one-shot, so a dropped DataChannel (e.g. the host restarting)
1803
+ // left the room dead until a manual reload. Retry with jittered backoff
1804
+ // until the room is forgotten.
1805
+ const RTC_BACKOFF_MIN = 1000,
1806
+ RTC_BACKOFF_MAX = 30000;
1807
+ async function connectRtcSource(s) {
1808
+ s.reconnecting = true; // block overlapping attempts while this one is in flight
1809
+ try {
1810
+ // Detach the stale peer's onstate BEFORE closing it — otherwise its own
1811
+ // "closed" event schedules another reconnect mid-replace, and that timer
1812
+ // fires after we've already recovered, churning a healthy channel forever.
1813
+ if (s.client) s.client.onstate = () => {};
1814
+ // The delta stream died silently with the old client (RTCClient never
1815
+ // fires onError), leaving s.streaming=true and s.unsub set — which would
1816
+ // make the reconcile poll skip this source AND block re-subscription.
1817
+ // Clear it so the fresh transport below re-establishes the stream.
1818
+ unsubscribeSource(s);
1819
+ try {
1820
+ s.client?.close?.(); // drop the stale peer AND its signaling socket
1821
+ } catch {}
1822
+ const c = new RTCClient(s.host, s.id, s.token);
1823
+ c.onstate = (st) => {
1824
+ if (st === "failed" || st === "closed") {
1825
+ s.live = false;
1826
+ renderRoomsIfOpen();
1827
+ scheduleRtcReconnect(s); // "disconnected" is transient; ignore it
1828
+ }
1829
+ };
1830
+ try {
1831
+ await c.connect();
1832
+ } catch (e) {
1833
+ c.close(); // connect() already opened the signaling socket — don't
1834
+ throw e; // leak it on every failed retry of an offline room
1835
+ }
1836
+ if (s.removed) {
1837
+ c.close(); // room was forgotten mid-connect — don't install/leak it
1838
+ return;
1839
+ }
1840
+ s.client = c;
1841
+ s.tx = rtcTx(c);
1842
+ s.reconnectDelay = RTC_BACKOFF_MIN; // a healthy connect resets the backoff
1843
+ clearTimeout(s.reconnectTimer); // cancel any reconnect queued during replace
1844
+ s.reconnectTimer = null;
1845
+ subscribeSource(s); // re-establish the live delta stream over the new wire
1846
+ // agent-yes share (unlike codehost) sends no per-agent device id, so
1847
+ // every machine's rows would be unlabelled. Ask the host who it is once
1848
+ // and tag this room's agents with it in listSource. Best-effort: an older
1849
+ // host with no /api/whoami just leaves rows device-less.
1850
+ try {
1851
+ s.deviceLabel = (await s.tx.fetchJSON("/api/whoami"))?.host || "";
1852
+ // whoami resolves after the first listSource may have already rendered
1853
+ // this room's rows device-less (and activity-gated polling might not
1854
+ // re-fire soon on a backgrounded tab). Re-render now so the host label
1855
+ // lands immediately instead of lagging a poll.
1856
+ if (s.deviceLabel) loadList();
1857
+ } catch {
1858
+ s.deviceLabel = "";
1859
+ }
1860
+ } finally {
1861
+ s.reconnecting = false;
1862
+ }
1863
+ }
1864
+ function scheduleRtcReconnect(s) {
1865
+ // forgotten, already queued, or a connect is already in flight
1866
+ if (s.removed || s.reconnectTimer || s.reconnecting) return;
1867
+ const base = s.reconnectDelay || RTC_BACKOFF_MIN;
1868
+ const delay = Math.round(base * (0.75 + Math.random() * 0.5)); // jitter
1869
+ s.reconnectDelay = Math.min(base * 2, RTC_BACKOFF_MAX);
1870
+ s.reconnectTimer = setTimeout(async () => {
1871
+ s.reconnectTimer = null;
1872
+ if (s.removed || s.reconnecting) return;
1873
+ try {
1874
+ await connectRtcSource(s);
1875
+ s.live = true;
1876
+ renderRoomsIfOpen();
1877
+ // Await the refresh: a prior failed poll cleared `entries`, so select()
1878
+ // below would find nothing and skip the rebind until the list is back.
1879
+ await loadList();
1880
+ // A terminal opened before the drop still holds the dead tx (its tail
1881
+ // subscription + send closures). Re-select to rebind it to the new
1882
+ // transport, else the room looks live but the terminal stays frozen.
1883
+ if (sel && sel.startsWith(s.id + "#")) select(sel);
1884
+ } catch {
1885
+ s.live = false;
1886
+ scheduleRtcReconnect(s); // keep trying
1887
+ }
1888
+ }, delay);
1889
+ }
1890
+
1720
1891
  // Add a room to the fleet and connect it — WITHOUT dropping the others, so
1721
1892
  // every saved room streams its agents at once. Idempotent: a room already
1722
1893
  // in the fleet just refreshes. The connection runs in the background; the
@@ -1751,29 +1922,13 @@
1751
1922
  s.client = c;
1752
1923
  s.tx = c;
1753
1924
  } else {
1754
- const c = new RTCClient(host, room, token);
1755
- c.onstate = (st) => {
1756
- if (st === "failed" || st === "closed") {
1757
- s.live = false;
1758
- renderRoomsIfOpen();
1759
- }
1760
- };
1761
- await c.connect();
1762
- s.client = c;
1763
- s.tx = rtcTx(c);
1764
- // agent-yes share (unlike codehost) sends no per-agent device id, so
1765
- // every machine's rows would be unlabelled. Ask the host who it is
1766
- // once and tag this room's agents with it in listSource. Best-effort:
1767
- // an older host with no /api/whoami just leaves rows device-less.
1768
- try {
1769
- s.deviceLabel = (await s.tx.fetchJSON("/api/whoami"))?.host || "";
1770
- } catch {
1771
- s.deviceLabel = "";
1772
- }
1925
+ s.token = token;
1926
+ await connectRtcSource(s);
1773
1927
  }
1774
1928
  s.live = true;
1775
1929
  } catch (e) {
1776
1930
  s.live = false;
1931
+ if (s.kind === "rtc") scheduleRtcReconnect(s);
1777
1932
  }
1778
1933
  s.tried = true;
1779
1934
  renderRoomsIfOpen();
@@ -1843,7 +1998,7 @@
1843
1998
  <span style="opacity:.6">Windows:</span>
1844
1999
  <code id="cmdinstallwin" class="copy" title="click to copy">powershell -c "irm https://agent-yes.com/setup.ps1 | iex"</code></div>
1845
2000
  <div class="rconnect">share your own fleet — run this, then open the printed link:
1846
- <code id="cmd" class="copy" title="click to copy">ay serve share</code></div>
2001
+ <code id="cmd" class="copy" title="click to copy">ay serve --share</code></div>
1847
2002
  <div class="rconnect">or view a <b>codehost</b> room — paste <code>ch:&lt;room-token&gt;</code> (or a
1848
2003
  codehost.dev share link) above; every machine in the room shows its agents here.</div>`;
1849
2004
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-yes",
3
- "version": "1.113.0",
3
+ "version": "1.115.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",
package/ts/serve.ts CHANGED
@@ -228,6 +228,18 @@ export async function cmdServe(rest: string[]): Promise<number> {
228
228
  return cmdServeDaemon("install", fwd);
229
229
  }
230
230
 
231
+ // `ay serve` takes only flags (plus the install/uninstall/logs subcommands
232
+ // handled above). A bare word like `ay serve share` is silently dropped into
233
+ // argv._ by yargs and would otherwise start in the wrong mode — most often
234
+ // it's a typo for the `--share` flag — so warn instead of quietly ignoring it.
235
+ const stray = (argv._ as Array<string | number>).map(String);
236
+ if (stray.length) {
237
+ const hint = stray.includes("share") ? " (did you mean --share?)" : "";
238
+ process.stderr.write(
239
+ `ay serve: ignoring unknown argument${stray.length > 1 ? "s" : ""}: ${stray.join(" ")}${hint}\n`,
240
+ );
241
+ }
242
+
231
243
  const port = (argv.port as number) ?? DEFAULT_PORT;
232
244
  const host = (argv.host as string) ?? "127.0.0.1";
233
245
  const tokenFlag = typeof argv.token === "string" ? argv.token : undefined;
@@ -291,6 +303,62 @@ export async function cmdServe(rest: string[]): Promise<number> {
291
303
  }
292
304
  };
293
305
 
306
+ // Per-cwd git snapshot for the list: branch + dirty/changed count + ahead/behind
307
+ // vs upstream, all from a single `git status --porcelain --branch`. Cached per
308
+ // cwd with a short TTL so the 1s subscribe tick (and /api/ls polls) spawn at most
309
+ // one git per repo every few seconds — agents sharing a cwd share the result.
310
+ // Non-git dirs, errors, and timeouts cache as null.
311
+ interface GitInfo {
312
+ branch: string | null;
313
+ dirty: boolean;
314
+ changed: number;
315
+ ahead: number;
316
+ behind: number;
317
+ }
318
+ const GIT_TTL_MS = 5000;
319
+ const gitCache = new Map<string, { at: number; val: GitInfo | null }>();
320
+ const gitStatus = async (cwd: string | null | undefined): Promise<GitInfo | null> => {
321
+ if (!cwd) return null;
322
+ const now = Date.now();
323
+ const hit = gitCache.get(cwd);
324
+ if (hit && now - hit.at < GIT_TTL_MS) return hit.val;
325
+ let val: GitInfo | null = null;
326
+ try {
327
+ const proc = Bun.spawn(["git", "status", "--porcelain", "--branch"], {
328
+ cwd,
329
+ stdout: "pipe",
330
+ stderr: "ignore",
331
+ signal: AbortSignal.timeout(2000),
332
+ });
333
+ const out = await new Response(proc.stdout).text();
334
+ await proc.exited;
335
+ if (proc.exitCode === 0) {
336
+ const lines = out.split("\n");
337
+ // Branch header, e.g. "## main...origin/main [ahead 1, behind 2]",
338
+ // "## main" (no upstream), "## HEAD (no branch)", or "## No commits yet on x".
339
+ const h = /^## (.+)$/.exec(lines[0] ?? "")?.[1] ?? "";
340
+ const unborn = /^No commits yet on (.+)$/.exec(h);
341
+ const branch = unborn ? unborn[1]! : /^(.+?)(?:\.\.\.|\s|$)/.exec(h)?.[1] || null;
342
+ const ahead = Number(/\bahead (\d+)/.exec(h)?.[1] ?? 0);
343
+ const behind = Number(/\bbehind (\d+)/.exec(h)?.[1] ?? 0);
344
+ const changed = lines.slice(1).filter((l) => l.trim().length > 0).length;
345
+ val = { branch, dirty: changed > 0, changed, ahead, behind };
346
+ }
347
+ } catch {
348
+ val = null; // git missing, not a repo, or timed out
349
+ }
350
+ gitCache.set(cwd, { at: now, val });
351
+ return val;
352
+ };
353
+
354
+ // One agent record decorated for the console: the latest OSC title + a git
355
+ // snapshot (skipped for exited agents — their repo state is no longer live).
356
+ const withMeta = async (r: Awaited<ReturnType<typeof listRecords>>[number]) => ({
357
+ ...r,
358
+ title: await logTitle(r.log_file),
359
+ git: r.status === "exited" ? null : await gitStatus(r.cwd),
360
+ });
361
+
294
362
  // The whole API as a plain handler: served over HTTP by Bun.serve (--http)
295
363
  // and called in-process by the WebRTC bridge (--webrtc) — the latter needs
296
364
  // no TCP port at all.
@@ -311,10 +379,7 @@ export async function cmdServe(rest: string[]): Promise<number> {
311
379
  });
312
380
  try {
313
381
  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);
382
+ return Response.json(await Promise.all(records.map(withMeta)));
318
383
  } catch (e) {
319
384
  return new Response((e as Error).message, { status: 500 });
320
385
  }
@@ -348,9 +413,7 @@ export async function cmdServe(rest: string[]): Promise<number> {
348
413
  const sent = new Map<number, string>();
349
414
  const compute = async () => {
350
415
  const records = await listRecords(keyword, opts);
351
- return Promise.all(
352
- records.map(async (r) => ({ ...r, title: await logTitle(r.log_file) })),
353
- );
416
+ return Promise.all(records.map(withMeta));
354
417
  };
355
418
  const tick = async (first: boolean) => {
356
419
  if (closed) return;
@@ -777,6 +840,7 @@ export async function cmdServe(rest: string[]): Promise<number> {
777
840
  // can reach this machine peer-to-peer. The bridge calls apiFetch in-process,
778
841
  // so without --http no port is opened at all. Bare flag mints a room; a
779
842
  // webrtc:// value joins an explicit one.
843
+ let closeShare: (() => void) | undefined; // closes WebRTC peers on shutdown
780
844
  if (wantWebrtc) {
781
845
  const webrtcVal = (argv.webrtc ?? argv.share) as string | undefined;
782
846
  const explicitUrl =
@@ -785,11 +849,12 @@ export async function cmdServe(rest: string[]): Promise<number> {
785
849
  const { startShare, loadOrCreateShareRoom } = await import("./share.ts");
786
850
  // No explicit webrtc:// URL → reuse the persisted room (minted once and
787
851
  // saved like the serve token), so the link is stable across restarts.
788
- const { link } = await startShare({
852
+ const { link, close } = await startShare({
789
853
  url: explicitUrl ?? (await loadOrCreateShareRoom()),
790
854
  localFetch: apiFetch,
791
855
  apiToken: token,
792
856
  });
857
+ closeShare = close;
793
858
  process.stdout.write(
794
859
  `${wantHttp ? "\n" : ""}shared over WebRTC — open this link (the token is eaten from the URL on open):\n ${link}\n` +
795
860
  (explicitUrl
@@ -806,10 +871,12 @@ export async function cmdServe(rest: string[]): Promise<number> {
806
871
 
807
872
  await new Promise<void>((resolve) => {
808
873
  process.on("SIGINT", () => {
874
+ closeShare?.();
809
875
  server?.stop();
810
876
  resolve();
811
877
  });
812
878
  process.on("SIGTERM", () => {
879
+ closeShare?.();
813
880
  server?.stop();
814
881
  resolve();
815
882
  });
package/ts/share.ts CHANGED
@@ -90,7 +90,9 @@ async function importRTC(): Promise<any> {
90
90
 
91
91
  /** Start the share bridge. Resolves once signaling is connected; runs until the
92
92
  * process exits, reconnecting signaling on drop. Returns the shareable link. */
93
- export async function startShare(opts: ShareOpts): Promise<{ room: string; link: string }> {
93
+ export async function startShare(
94
+ opts: ShareOpts,
95
+ ): Promise<{ room: string; link: string; close: () => void }> {
94
96
  const minted = !opts.url;
95
97
  const sighost = opts.sighost ?? DEFAULT_SIGHOST;
96
98
  const { room, token, host } = opts.url
@@ -109,9 +111,13 @@ export async function startShare(opts: ShareOpts): Promise<{ room: string; link:
109
111
 
110
112
  type Peer = { pc: any; aborts: Map<number, AbortController> };
111
113
  const peers = new Map<string, Peer>();
114
+ let closed = false; // set by close(); stops signaling reconnect + new peers
115
+ let currentWs: WebSocket | undefined; // the live rendezvous socket, for close()
112
116
 
113
117
  const connectSignaling = (onReady: () => void) => {
118
+ if (closed) return; // a reconnect timer queued before close() must not revive it
114
119
  const ws = new WebSocket(`${wsScheme}://${host}/${room}`, [SUB]);
120
+ currentWs = ws;
115
121
  let ready = false;
116
122
  ws.onopen = () => {
117
123
  ws.send(JSON.stringify({ type: "hello", role: "host", token }));
@@ -119,6 +125,7 @@ export async function startShare(opts: ShareOpts): Promise<{ room: string; link:
119
125
  onReady();
120
126
  };
121
127
  ws.onmessage = async (ev) => {
128
+ if (closed) return;
122
129
  const m = JSON.parse(ev.data as string);
123
130
  if (m.type === "peer-join") startPeer(ws, m.peer);
124
131
  else if (m.type === "answer")
@@ -131,6 +138,7 @@ export async function startShare(opts: ShareOpts): Promise<{ room: string; link:
131
138
  else if (m.type === "peer-leave") closePeer(m.peer);
132
139
  };
133
140
  ws.onclose = () => {
141
+ if (closed) return; // shutting down — don't resurrect the rendezvous
134
142
  // Keep established WebRTC peers; just re-establish the rendezvous so new
135
143
  // browsers can still join. Backoff a little to avoid hot-looping.
136
144
  setTimeout(() => connectSignaling(() => {}), ready ? 1500 : 4000);
@@ -172,7 +180,16 @@ export async function startShare(opts: ShareOpts): Promise<{ room: string; link:
172
180
  }
173
181
 
174
182
  function send(dc: any, obj: object) {
175
- if (dc.readyState === "open") dc.send(JSON.stringify(obj));
183
+ // readyState alone is racy: node-datachannel can still report "open" for a
184
+ // tick after a dropped peer's channel is torn down underneath, so dc.send()
185
+ // throws "DataChannel is closed". Swallow it — the frame is for a peer that's
186
+ // already gone (closePeer aborts its in-flight requests right behind this).
187
+ if (dc.readyState !== "open") return;
188
+ try {
189
+ dc.send(JSON.stringify(obj));
190
+ } catch {
191
+ /* peer vanished mid-send; dropping the frame is correct */
192
+ }
176
193
  }
177
194
 
178
195
  async function onReq(dc: any, aborts: Map<number, AbortController>, req: any) {
@@ -218,5 +235,19 @@ export async function startShare(opts: ShareOpts): Promise<{ room: string; link:
218
235
 
219
236
  await new Promise<void>((resolve) => connectSignaling(resolve));
220
237
  void minted; // (informational) caller decides how to surface the link
221
- return { room, link };
238
+
239
+ // Clean shutdown: stop the rendezvous (so it can't reconnect or accept new
240
+ // peers) and close every peer connection so browsers get an immediate
241
+ // DataChannel close and reconnect right away, instead of waiting out the
242
+ // ~15-30s ICE timeout that an abrupt process exit would otherwise force.
243
+ const close = () => {
244
+ closed = true;
245
+ try {
246
+ currentWs?.close();
247
+ } catch {
248
+ /* already closing */
249
+ }
250
+ for (const peerId of [...peers.keys()]) closePeer(peerId);
251
+ };
252
+ return { room, link, close };
222
253
  }
@@ -1,8 +0,0 @@
1
- import "./ts-ChVJOKNr.js";
2
- import "./logger-B9h0djqx.js";
3
- import "./versionChecker-wxlWV_VT.js";
4
- import "./pidStore-DBjlqzo8.js";
5
- import "./globalPidIndex-yVd3mbsV.js";
6
- import { t as SUPPORTED_CLIS } from "./SUPPORTED_CLIS-BpO7ZYx_.js";
7
-
8
- export { SUPPORTED_CLIS };