agent-yes 1.176.0 → 1.177.1

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/lab/ui/index.html CHANGED
@@ -961,6 +961,7 @@
961
961
  margin-bottom: 4px;
962
962
  }
963
963
  .lcard .nfield input,
964
+ .lcard .nfield select,
964
965
  .lcard .nfield textarea {
965
966
  width: 100%;
966
967
  background: var(--bg);
@@ -973,6 +974,7 @@
973
974
  resize: vertical;
974
975
  }
975
976
  .lcard .nfield input:focus,
977
+ .lcard .nfield select:focus,
976
978
  .lcard .nfield textarea:focus {
977
979
  border-color: var(--accent);
978
980
  }
@@ -1018,7 +1020,9 @@
1018
1020
  }
1019
1021
  .row.sel.peerfocus {
1020
1022
  background: var(--panel);
1021
- box-shadow: inset 3px 0 0 var(--accent), inset -3px 0 0 var(--peer);
1023
+ box-shadow:
1024
+ inset 3px 0 0 var(--accent),
1025
+ inset -3px 0 0 var(--peer);
1022
1026
  }
1023
1027
  /* stdin just landed here (someone typed, or `ay send` pushed a command) —
1024
1028
  pulse the row regardless of who (if anyone) is focused on it. Runs while
@@ -1703,7 +1707,9 @@
1703
1707
  <span class="robadge" title="view-only share — you can watch but not steer this agent"
1704
1708
  >👁 read-only</span
1705
1709
  >
1706
- <span class="rwbadge" title="read-write share — you can type but not stop/restart this agent"
1710
+ <span
1711
+ class="rwbadge"
1712
+ title="read-write share — you can type but not stop/restart this agent"
1707
1713
  >✏️ read-write</span
1708
1714
  >
1709
1715
  <span class="live"
@@ -1848,9 +1854,9 @@
1848
1854
  <div class="sharewarn" id="shareWarn" hidden>
1849
1855
  <div class="warnhead">⚠️ This link lets anyone control this agent</div>
1850
1856
  <p>
1851
- Whoever opens this link can <strong>type into this agent</strong> — and because an
1852
- agent can run commands, that can mean <strong>control of your whole system</strong>.
1853
- The link isn't a password prompt: no further confirmation is asked.
1857
+ Whoever opens this link can <strong>type into this agent</strong> — and because an agent
1858
+ can run commands, that can mean <strong>control of your whole system</strong>. The link
1859
+ isn't a password prompt: no further confirmation is asked.
1854
1860
  </p>
1855
1861
  <p><strong>Only share it with someone you completely trust.</strong></p>
1856
1862
  <button class="danger" id="shareReveal" type="button">
@@ -2058,14 +2064,121 @@
2058
2064
  // A codehost room can hold several machines: every `codehost serve`
2059
2065
  // daemon advertises its live agents (PeerMeta.agents) and proxies the
2060
2066
  // local ay-serve API over its WebRTC tunnel at /__codehost/agent-yes/*.
2061
- // This wire aggregates all hosts' agents into one list, then routes each
2062
- // per-agent call to the host that owns the pid. Known v1 limitation:
2063
- // pids may collide across hosts (last listed wins); the host: tag keeps
2064
- // them tellable-apart visually.
2067
+ //
2068
+ // Every machine in the room is its own fleet source, id "<room>/<peerId>",
2069
+ // sharing the room's one signaling/WebRTC connection. A source is therefore
2070
+ // always exactly one machine — the invariant the rest of the console already
2071
+ // assumes (`local` and each ay-share room are one machine too). v1 instead
2072
+ // hid N machines behind one transport and picked a peer per request, which
2073
+ // silently pinned `/api/spawn` and `/api/ls/subscribe` to whichever host was
2074
+ // listed first, and let equal pids on two machines overwrite each other.
2065
2075
  const CH_API = "/__codehost/agent-yes";
2066
- class CodehostClient {
2067
- constructor(token) {
2068
- Object.assign(this, { token, room: null, byPid: new Map(), onstate: () => {} });
2076
+
2077
+ // Machines that can answer for agents: anything advertising agents, plus
2078
+ // root daemons (their ay serve may know agents the broadcast hasn't picked
2079
+ // up yet, and an idle root is still a valid spawn target).
2080
+ const isHostPeer = (p) =>
2081
+ !!p?.meta && ((p.meta.agents || []).length > 0 || p.meta.kind === "root");
2082
+
2083
+ // One codehost machine behind the standard transport shape. Every call is
2084
+ // pinned to `peerId`, so a source built on this tx can never leak a request
2085
+ // to a sibling machine in the same room. Both the room handle and the peer's
2086
+ // meta are read at call time, never captured: the handle isn't assigned yet
2087
+ // if the first peer broadcast lands during joinRoom, and the meta goes stale
2088
+ // on every rebroadcast.
2089
+ function chPeerTx(chRoom, peerId, getMeta) {
2090
+ const req = (method, path, init) => chRoom.room.fetch(peerId, method, CH_API + path, init);
2091
+ const ok = (res) => res.status >= 200 && res.status < 300;
2092
+ // The daemon is in the room but its `ay serve` may be down. It still
2093
+ // advertises the agents it knows, so degrade to that list rather than
2094
+ // dropping the machine out of the fleet entirely.
2095
+ const advertised = () =>
2096
+ (getMeta()?.agents || []).map((a) => ({
2097
+ pid: a.pid,
2098
+ cli: a.tool,
2099
+ title: a.title || null,
2100
+ prompt: a.title || null,
2101
+ cwd: a.cwd,
2102
+ status: a.state,
2103
+ started_at: a.startedAt,
2104
+ }));
2105
+ return {
2106
+ async fetchJSON(path) {
2107
+ let res;
2108
+ try {
2109
+ res = await req("GET", path);
2110
+ if (!ok(res)) throw new Error("HTTP " + res.status);
2111
+ } catch (err) {
2112
+ if (path.startsWith("/api/ls")) return advertised();
2113
+ throw err;
2114
+ }
2115
+ return res.json();
2116
+ },
2117
+ async fetchText(path) {
2118
+ const res = await req("GET", path);
2119
+ if (!ok(res)) throw new Error("HTTP " + res.status);
2120
+ return res.text();
2121
+ },
2122
+ async post(path, bodyObj) {
2123
+ const res = await req("POST", path, {
2124
+ headers: { "content-type": "application/json" },
2125
+ body: JSON.stringify(bodyObj),
2126
+ });
2127
+ return { ok: ok(res), text: await res.text() };
2128
+ },
2129
+ async del(path) {
2130
+ const res = await req("DELETE", path);
2131
+ return { ok: ok(res), text: await res.text() };
2132
+ },
2133
+ subscribe(path, onText, onOpen, onError) {
2134
+ let cancelled = false;
2135
+ let reader = null;
2136
+ (async () => {
2137
+ try {
2138
+ const res = await req("GET", path, { headers: { accept: "text/event-stream" } });
2139
+ if (!ok(res) || !res.body) throw new Error("HTTP " + res.status);
2140
+ onOpen && onOpen();
2141
+ reader = res.body.getReader();
2142
+ const dec = new TextDecoder();
2143
+ let buf = "";
2144
+ for (;;) {
2145
+ const { done, value } = await reader.read();
2146
+ if (done || cancelled) break;
2147
+ buf += dec.decode(value, { stream: true });
2148
+ let i;
2149
+ while ((i = buf.indexOf("\n\n")) >= 0) {
2150
+ const evt = buf.slice(0, i);
2151
+ buf = buf.slice(i + 2);
2152
+ for (const line of evt.split("\n"))
2153
+ if (line.startsWith("data:")) {
2154
+ try {
2155
+ onText(JSON.parse(line.slice(5).trim()));
2156
+ } catch {}
2157
+ }
2158
+ }
2159
+ }
2160
+ if (!cancelled) onError && onError();
2161
+ } catch {
2162
+ if (!cancelled) onError && onError();
2163
+ }
2164
+ })();
2165
+ return () => {
2166
+ cancelled = true;
2167
+ try {
2168
+ reader?.cancel();
2169
+ } catch {}
2170
+ };
2171
+ },
2172
+ };
2173
+ }
2174
+
2175
+ // A joined codehost room. It owns the connection and keeps the fleet in sync
2176
+ // with the room's server peers: a source appears when a machine joins and is
2177
+ // dropped when it leaves. The room itself is never a source — only machines are.
2178
+ class CodehostRoom {
2179
+ constructor(name, token) {
2180
+ Object.assign(this, { name, token, room: null, live: false, tried: false });
2181
+ this.peers = new Map(); // peerId -> latest peer broadcast (meta included)
2069
2182
  }
2070
2183
  async connect() {
2071
2184
  if (!window.__codehost)
@@ -2075,10 +2188,24 @@
2075
2188
  this.room = window.__codehost.joinRoom({
2076
2189
  token: this.token,
2077
2190
  onStatus: (open) => {
2078
- if (!open) this.onstate("closed");
2191
+ if (this.orphaned()) return;
2192
+ this.live = open;
2193
+ // Keep the machines listed while the socket retries — room-client
2194
+ // self-heals, and dropping the rows would flicker the whole tree.
2195
+ // Drop their streams though: a stream that never errors would leave
2196
+ // `streaming` set, the reconcile poll would skip the machine, and it
2197
+ // would sit offline forever. syncPeers re-arms them on reconnect.
2198
+ if (!open)
2199
+ for (const s of roomSources(this.name)) {
2200
+ s.live = false;
2201
+ unsubscribeSource(s);
2202
+ }
2203
+ renderRoomsIfOpen();
2079
2204
  },
2080
- onPeers: () => {
2081
- this.onstate("open");
2205
+ onPeers: (peers) => {
2206
+ if (this.orphaned()) return;
2207
+ this.live = true;
2208
+ this.syncPeers(peers);
2082
2209
  firstPeers();
2083
2210
  },
2084
2211
  });
@@ -2089,122 +2216,60 @@
2089
2216
  close() {
2090
2217
  this.room?.close();
2091
2218
  }
2092
- hosts() {
2093
- // Machines that can answer for agents: anything advertising agents,
2094
- // plus root daemons (their ay serve may know agents the broadcast
2095
- // hasn't picked up yet).
2096
- return (this.room?.peers || []).filter(
2097
- (p) => p.meta && ((p.meta.agents || []).length || p.meta.kind === "root"),
2098
- );
2099
- }
2100
- anyHost() {
2101
- const h = this.hosts();
2102
- return h.length ? h[0].peerId : null;
2103
- }
2104
- peerFor(path) {
2105
- const m = /^\/api\/(?:tail|size|resize|read|status)\/([^/?]+)/.exec(path);
2106
- const kw = m ? decodeURIComponent(m[1]) : null;
2107
- return (kw && this.byPid.get(kw)) || null;
2108
- }
2109
- async ls(path) {
2110
- const hosts = this.hosts();
2111
- const lists = await Promise.all(
2112
- hosts.map(async (p) => {
2113
- try {
2114
- const res = await this.room.fetch(p.peerId, "GET", CH_API + path);
2115
- if (!res.ok) throw new Error("HTTP " + res.status);
2116
- return (await res.json()).map((e) => ({ ...e, _host: p.meta?.host }));
2117
- } catch {
2118
- // ay serve down on that host — fall back to the daemon-advertised list.
2119
- return (p.meta?.agents || []).map((a) => ({
2120
- pid: a.pid,
2121
- cli: a.tool,
2122
- title: a.title || null,
2123
- prompt: a.title || null,
2124
- cwd: a.cwd,
2125
- status: a.state,
2126
- started_at: a.startedAt,
2127
- _host: p.meta?.host,
2128
- }));
2129
- }
2130
- }),
2131
- );
2132
- const byPid = new Map();
2133
- const merged = [];
2134
- lists.forEach((arr, i) => {
2135
- for (const e of arr) {
2136
- byPid.set(String(e.pid), hosts[i].peerId);
2137
- merged.push(e);
2138
- }
2139
- });
2140
- this.byPid = byPid;
2141
- return merged;
2219
+ // True once the room has been forgotten (removeRoom drops it from chRooms)
2220
+ // or superseded by a re-add under the same name. joinRoom's callbacks can
2221
+ // fire a queued peer broadcast AFTER that, and without this guard syncPeers
2222
+ // would `sources.set` the machines back for a room the user just forgot.
2223
+ orphaned() {
2224
+ return chRooms.get(this.name) !== this;
2142
2225
  }
2143
- async fetchJSON(path) {
2144
- if (path.startsWith("/api/ls")) return this.ls(path);
2145
- const peer = this.peerFor(path) || this.anyHost();
2146
- if (!peer) throw new Error("no codehost peer");
2147
- const res = await this.room.fetch(peer, "GET", CH_API + path);
2148
- if (!res.ok) throw new Error("HTTP " + res.status);
2149
- return res.json();
2150
- }
2151
- async post(path, bodyObj) {
2152
- const kw = bodyObj && bodyObj.keyword != null ? String(bodyObj.keyword) : null;
2153
- const peer = (kw && this.byPid.get(kw)) || this.peerFor(path) || this.anyHost();
2154
- if (!peer) return { ok: false, text: "no codehost peer in the room" };
2155
- const res = await this.room.fetch(peer, "POST", CH_API + path, {
2156
- headers: { "content-type": "application/json" },
2157
- body: JSON.stringify(bodyObj),
2158
- });
2159
- return { ok: res.status >= 200 && res.status < 300, text: await res.text() };
2160
- }
2161
- subscribe(path, onText, onOpen, onError) {
2162
- let cancelled = false;
2163
- let reader = null;
2164
- (async () => {
2165
- const peer = this.peerFor(path) || this.anyHost();
2166
- if (!peer) {
2167
- onError && onError();
2168
- return;
2169
- }
2170
- try {
2171
- const res = await this.room.fetch(peer, "GET", CH_API + path, {
2172
- headers: { accept: "text/event-stream" },
2173
- });
2174
- if (!res.ok || !res.body) throw new Error("HTTP " + res.status);
2175
- onOpen && onOpen();
2176
- reader = res.body.getReader();
2177
- const dec = new TextDecoder();
2178
- let buf = "";
2179
- for (;;) {
2180
- const { done, value } = await reader.read();
2181
- if (done || cancelled) break;
2182
- buf += dec.decode(value, { stream: true });
2183
- let i;
2184
- while ((i = buf.indexOf("\n\n")) >= 0) {
2185
- const evt = buf.slice(0, i);
2186
- buf = buf.slice(i + 2);
2187
- for (const line of evt.split("\n"))
2188
- if (line.startsWith("data:")) {
2189
- try {
2190
- onText(JSON.parse(line.slice(5).trim()));
2191
- } catch {}
2192
- }
2193
- }
2194
- }
2195
- if (!cancelled) onError && onError();
2196
- } catch {
2197
- if (!cancelled) onError && onError();
2226
+ // Reconcile the fleet against the room's current server peers. Called on
2227
+ // every peer broadcast — including the frequent ones that only carry new
2228
+ // agent meta — so it must stay cheap when the peer SET hasn't changed.
2229
+ syncPeers(peers) {
2230
+ if (this.orphaned()) return;
2231
+ const hosts = (peers || []).filter(isHostPeer);
2232
+ const seen = new Set();
2233
+ let changed = false;
2234
+ for (const p of hosts) {
2235
+ seen.add(p.peerId);
2236
+ this.peers.set(p.peerId, p);
2237
+ const id = this.name + "/" + p.peerId;
2238
+ let s = sources.get(id);
2239
+ if (!s) {
2240
+ s = {
2241
+ id,
2242
+ room: this.name,
2243
+ peerId: p.peerId,
2244
+ host: CH_HOST,
2245
+ kind: "ch",
2246
+ chRoom: this,
2247
+ tx: chPeerTx(this, p.peerId, () => this.peers.get(p.peerId)?.meta),
2248
+ client: null,
2249
+ live: false,
2250
+ tried: false,
2251
+ devices: new Set(),
2252
+ serverCount: 0,
2253
+ };
2254
+ sources.set(id, s);
2255
+ changed = true;
2198
2256
  }
2199
- })();
2200
- return () => {
2201
- cancelled = true;
2202
- try {
2203
- reader?.cancel();
2204
- } catch {}
2205
- };
2257
+ s.deviceLabel = p.meta?.host || "";
2258
+ subscribeSource(s); // no-op while already streaming; re-arms after a drop
2259
+ }
2260
+ for (const peerId of [...this.peers.keys()]) {
2261
+ if (seen.has(peerId)) continue;
2262
+ this.peers.delete(peerId);
2263
+ removeSource(this.name + "/" + peerId);
2264
+ changed = true;
2265
+ }
2266
+ renderRoomsIfOpen();
2267
+ if (changed)
2268
+ loadList(); // poll the newcomers until their stream opens
2269
+ else mergeRender();
2206
2270
  }
2207
2271
  }
2272
+ const chRooms = new Map(); // room name -> CodehostRoom
2208
2273
 
2209
2274
  // `ay serve --http` serves this page itself and prints a #k=<token> link;
2210
2275
  // boot() stores the token and local /api calls carry it as ?token= (query,
@@ -2219,7 +2284,7 @@
2219
2284
 
2220
2285
  // ---- transports: a uniform { fetchJSON, post, subscribe } over each wire ----
2221
2286
  // local (same-origin ay serve), an ay-share RTCClient, or a codehost room.
2222
- // CodehostClient already exposes this shape, so it's used as a tx directly.
2287
+ // A codehost room builds one of these per machine in it (see chPeerTx).
2223
2288
  const localTx = {
2224
2289
  async fetchJSON(path) {
2225
2290
  return (await fetch(withTok(path))).json();
@@ -2285,13 +2350,27 @@
2285
2350
  }
2286
2351
 
2287
2352
  // ---- fleet: local + every saved room, all connected at once ------------
2288
- // Each source contributes its agents to one merged list (tagged with the
2289
- // owning source on `_room`/`_key`); per-agent ops route back via srcFor/txFor.
2353
+ // A source is ONE machine. Each contributes its agents to one merged list,
2354
+ // tagged with the owning source on `_src` (routing: srcFor/txFor) and with
2355
+ // the room it belongs to on `_room` (display: the tree's room grouping).
2356
+ // Those differ only for codehost rooms, where one room holds many machines.
2290
2357
  // Live counts in the rooms panel come from each source's serverCount/devices.
2291
2358
  const LOCAL = "local";
2292
- const sources = new Map(); // id -> { id, host, kind, tx, client, live, devices, serverCount }
2293
- const srcFor = (e) => (e && sources.get(e._room)) || sources.get(LOCAL) || null;
2359
+ const sources = new Map(); // id -> { id, room, host, kind, tx, client, live, devices, serverCount }
2360
+ const srcFor = (e) => (e && sources.get(e._src)) || sources.get(LOCAL) || null;
2294
2361
  const txFor = (e) => srcFor(e)?.tx || localTx;
2362
+ // Every machine belonging to a room (for an ay-share room, the room itself).
2363
+ const roomSources = (name) => [...sources.values()].filter((s) => (s.room || s.id) === name);
2364
+ const roomLive = (name) =>
2365
+ chRooms.has(name) ? chRooms.get(name).live : !!sources.get(name)?.live;
2366
+ // How a machine names itself in a picker: "room · user@host", or just the
2367
+ // room when its device label hasn't arrived yet.
2368
+ const sourceLabel = (s) => {
2369
+ if (!s) return "";
2370
+ if (s.id === LOCAL) return "local";
2371
+ const room = s.room || s.id;
2372
+ return s.deviceLabel ? room + " · " + s.deviceLabel : room;
2373
+ };
2295
2374
  // True when the agent lives in a view-only share room (the host advertises
2296
2375
  // this via /api/whoami → share.readonly and 403s every write regardless).
2297
2376
  const isReadonlyEnt = (e) => !!srcFor(e)?.readonly;
@@ -2397,33 +2476,23 @@
2397
2476
  return null;
2398
2477
  }
2399
2478
  }
2400
- // Resolve the live RTCPeerConnection that carries a SPECIFIC agent. A codehost
2401
- // room holds one peer per host (room.rtcs keyed by peerId); byPid maps the
2402
- // agent's pid to its peerId, so each agent resolves to ITS host's connection —
2479
+ // Resolve the live RTCPeerConnection that carries a source. A codehost room
2480
+ // holds one peer per machine (room.rtcs keyed by peerId) and a source pins
2481
+ // exactly one of them, so each agent resolves to ITS machine's connection —
2403
2482
  // a VPS agent reads wan while a tak.local agent in the same room reads lan.
2404
- function pcForAgent(s, e) {
2483
+ function pcForAgent(s) {
2405
2484
  if (!s) return null;
2406
2485
  if (s.kind === "rtc") return s.client?.pc || null; // share room: one host
2407
- if (s.kind === "ch") {
2408
- const peerId = s.client?.byPid?.get(String(e?.pid));
2409
- return (peerId && s.client?.room?.rtcs?.get(peerId)?.pc) || null;
2410
- }
2486
+ if (s.kind === "ch") return s.chRoom?.room?.rtcs?.get(s.peerId)?.pc || null;
2411
2487
  return null; // local ay-serve has no peer connection
2412
2488
  }
2413
2489
  // Two cache views, both filled from one classification pass:
2414
- // - connKinds keyed by the true PEER (room + peerId) drives the header badge,
2415
- // so two machines that happen to share a hostname never collide.
2490
+ // - connKinds keyed by the source, i.e. the true PEER, drives the header
2491
+ // badge, so two machines that share a hostname never collide.
2416
2492
  // - hostKinds keyed by (room, host) drives the peer-group header tags, which
2417
2493
  // only ever render when hosts are already distinct.
2418
2494
  const hostKeyOf = (e) => (e?._room || "") + " " + (e?._host || "");
2419
- function peerKeyOf(s, e) {
2420
- if (!s || s.id === LOCAL || s.kind == null) return LOCAL;
2421
- if (s.kind === "ch") {
2422
- const peerId = s.client?.byPid?.get(String(e?.pid));
2423
- return s.id + " " + (peerId || e?._host || "");
2424
- }
2425
- return s.id; // rtc share: one peer per room
2426
- }
2495
+ const peerKeyOf = (s) => (!s || s.id === LOCAL || s.kind == null ? LOCAL : s.id);
2427
2496
  const connKinds = new Map(); // peerKey -> "local" | "lan" | "wan" | "relay"
2428
2497
  const hostKinds = new Map(); // hostKey -> same (peer-header tags)
2429
2498
  const connInfo = new Map(); // peerKey -> full detail for the hover tooltip
@@ -2461,7 +2530,7 @@
2461
2530
  // Probe each distinct peer once (not once per agent), keyed by peerKey.
2462
2531
  const reps = new Map(); // peerKey -> a representative entry for that peer
2463
2532
  for (const e of entries) {
2464
- const k = peerKeyOf(srcFor(e), e);
2533
+ const k = peerKeyOf(srcFor(e));
2465
2534
  if (!reps.has(k)) reps.set(k, e);
2466
2535
  }
2467
2536
  connKinds.clear();
@@ -2480,7 +2549,7 @@
2480
2549
  }
2481
2550
  return;
2482
2551
  }
2483
- const info = await inspectConn(pcForAgent(s, e));
2552
+ const info = await inspectConn(pcForAgent(s));
2484
2553
  if (info) {
2485
2554
  connKinds.set(key, info.kind);
2486
2555
  connInfo.set(key, { ...info, signaling: signalingOf(s) });
@@ -2490,7 +2559,7 @@
2490
2559
  // Project peer kinds onto (room,host) keys for the tree's peer headers.
2491
2560
  hostKinds.clear();
2492
2561
  for (const e of entries) {
2493
- const kind = connKinds.get(peerKeyOf(srcFor(e), e));
2562
+ const kind = connKinds.get(peerKeyOf(srcFor(e)));
2494
2563
  if (kind) hostKinds.set(hostKeyOf(e), kind);
2495
2564
  }
2496
2565
  paintHeaderBadge();
@@ -2513,7 +2582,7 @@
2513
2582
  const badge = $("ctype");
2514
2583
  if (!badge) return;
2515
2584
  const e = sel ? entries.find((x) => x._key === sel) : null;
2516
- const key = e ? peerKeyOf(srcFor(e), e) : null;
2585
+ const key = e ? peerKeyOf(srcFor(e)) : null;
2517
2586
  const kind = key ? connKinds.get(key) : null;
2518
2587
  if (!kind) {
2519
2588
  badge.hidden = true;
@@ -3120,7 +3189,7 @@
3120
3189
  alert("Share created but the response was unreadable.");
3121
3190
  return null;
3122
3191
  }
3123
- currentShare = { shareId: info.shareId, link: info.link, srcRoom: e._room, perm };
3192
+ currentShare = { shareId: info.shareId, link: info.link, srcId: e._src, perm };
3124
3193
  return info;
3125
3194
  }
3126
3195
  // "I understand — reveal": now that the operator has read the warning, mint
@@ -3144,7 +3213,8 @@
3144
3213
  const who = e.title || cliLabel(e) || ident(e) || `pid ${e.pid}`;
3145
3214
  const rw = perm === "rw";
3146
3215
  $("shareTitle").textContent = rw ? "Share control of this agent" : "Share this agent";
3147
- $("shareSub").textContent = `${rw ? "read-write · can type" : "view-only · can watch"} · ${who}`;
3216
+ $("shareSub").textContent =
3217
+ `${rw ? "read-write · can type" : "view-only · can watch"} · ${who}`;
3148
3218
  $("shareNote").textContent = rw
3149
3219
  ? "Read-write: viewers can type into this agent (interrupt, run commands). They cannot stop or restart it. The link expires and stops working when revoked."
3150
3220
  : "View-only: viewers see this agent's terminal (which may include secrets) but cannot type, stop, or restart it. The link expires and stops working when revoked.";
@@ -3198,10 +3268,20 @@
3198
3268
  }
3199
3269
  async function revokeCurrentShare() {
3200
3270
  if (!currentShare) return closeShareModal();
3201
- // DELETE over whichever transport owns the agent's host (local or the
3202
- // room the agent lives in) the same host that minted the share.
3203
- const e = entries.find((x) => x._room === currentShare.srcRoom) || null;
3204
- const tx = (e ? srcFor(e)?.tx : null) || localTx;
3271
+ // DELETE over the transport of the exact MACHINE that minted the share.
3272
+ // Keying by source id, not room, matters for a codehost room with several
3273
+ // machines routing by room would revoke against the wrong host.
3274
+ const src = sources.get(currentShare.srcId);
3275
+ // Only fall back to localTx for a LOCAL share. If a remote machine's source
3276
+ // has dropped (room/peer gone while the modal sat open), sending the DELETE
3277
+ // to localhost would 404 yet still close the modal — the operator would
3278
+ // think the share was killed while it stays live on the (now unreachable)
3279
+ // host. Say so and keep the modal open instead.
3280
+ const tx = src?.tx || (currentShare.srcId === LOCAL ? localTx : null);
3281
+ if (!tx) {
3282
+ alert("Can't revoke: the machine that owns this share is no longer connected.");
3283
+ return;
3284
+ }
3205
3285
  try {
3206
3286
  await tx.del("/api/share/" + encodeURIComponent(currentShare.shareId));
3207
3287
  } catch {}
@@ -3673,25 +3753,25 @@
3673
3753
  }
3674
3754
  }
3675
3755
 
3676
- // Pull one source's agent list, tagging each row with its origin + a
3677
- // composite key (pids can collide across rooms). Updates the source's live
3678
- // flag, device set, and server count for the rooms panel.
3679
- // Stamp a source's raw /api/ls records with their origin + composite key
3680
- // (pids collide across rooms) and recompute the source's device set + server
3681
- // count for the rooms panel. Pure transform of the source's current records.
3756
+ // Stamp a source's raw /api/ls records with their machine (`_src`, how every
3757
+ // per-agent op routes back), their room (`_room`, how the tree groups), and a
3758
+ // composite key. `_key` is per-MACHINE, not per-room: two machines in one
3759
+ // codehost room can each run pid 1234, and a room-scoped key would collide.
3760
+ // Recomputes the source's device set + server count for the rooms panel.
3761
+ // Pure transform of the source's current records.
3682
3762
  function stampAgents(s) {
3683
3763
  s.devices = new Set();
3764
+ const room = s.room || s.id;
3684
3765
  const out = [...(s.byPid?.values() || [])].map((e) => {
3685
- // codehost stamps _host per agent; agent-yes share rooms fall back to
3686
- // the room's device label (from /api/whoami). Local stays unlabelled
3687
- // (no deviceLabel) so a single-machine view keeps its clean path-only
3688
- // identity instead of a blank "@:" prefix.
3689
- const host = e._host || s.deviceLabel || "";
3766
+ // A source is one machine, so its device label (codehost peer meta, or
3767
+ // /api/whoami on an ay-share room) labels every agent on it. Local stays
3768
+ // unlabelled (no deviceLabel) so a single-machine view keeps its clean
3769
+ // path-only identity instead of a blank "@:" prefix.
3770
+ const host = s.deviceLabel || "";
3690
3771
  if (host) s.devices.add(host);
3691
- return { ...e, _room: s.id, _key: s.id + "#" + e.pid, _host: host };
3772
+ return { ...e, _src: s.id, _room: room, _key: s.id + "#" + e.pid, _host: host };
3692
3773
  });
3693
- s.serverCount =
3694
- s.kind === "ch" ? s.client?.hosts().length || 0 : s.devices.size || (s.live ? 1 : 0);
3774
+ s.serverCount = s.kind === "ch" ? (s.live ? 1 : 0) : s.devices.size || (s.live ? 1 : 0);
3695
3775
  s.agents = out;
3696
3776
  }
3697
3777
 
@@ -3731,6 +3811,10 @@
3731
3811
  // backoff reconnect (no-op if one's already queued or the room's gone).
3732
3812
  if (s.kind === "rtc") scheduleRtcReconnect(s);
3733
3813
  return [];
3814
+ } finally {
3815
+ // A codehost machine has no connect phase of its own — it becomes "tried"
3816
+ // the first time we poll it, so the badge doesn't sit on "connecting…".
3817
+ s.tried = true;
3734
3818
  }
3735
3819
  }
3736
3820
 
@@ -3993,9 +4077,12 @@
3993
4077
  } catch {}
3994
4078
  }
3995
4079
 
3996
- // Match a stored/linked selection token against an entry: either the full
3997
- // composite key (room#pid) or a bare pid (?pid= deep links, legacy ay.sel).
3998
- const matchSel = (e, token) => e._key === token || String(e.pid) === String(token);
4080
+ // Match a stored/linked selection token against an entry: the full composite
4081
+ // key (`ay.sel` stores it verbatim), the `<room>#<pid>` deep-link form (the
4082
+ // hash never names a machine see the deep-link writer so it resolves to
4083
+ // whichever machine in the room owns that pid), or a bare pid (?pid= links).
4084
+ const matchSel = (e, token) =>
4085
+ e._key === token || e._room + "#" + e.pid === token || String(e.pid) === String(token);
3999
4086
 
4000
4087
  // Reconcile poll: refresh only the sources that aren't streaming (older
4001
4088
  // hosts, or ones whose stream hasn't delivered its first snapshot yet) and
@@ -4023,16 +4110,20 @@
4023
4110
  for (const k of advanceStdinFlashes(entries, stdinSeen)) flashUntil.set(k, until);
4024
4111
  // Badge: total agents + how many rooms are live. Red only when nothing
4025
4112
  // at all is reachable (no source answered).
4113
+ // Count ROOMS, not machines — a codehost room with three machines is still
4114
+ // one room, and it can be connecting before any machine has appeared.
4026
4115
  const roomSrcs = srcs.filter((s) => s.id !== LOCAL);
4027
- const liveRooms = roomSrcs.filter((s) => s.live).length;
4116
+ const liveRooms = new Set(roomSrcs.filter((s) => s.live).map((s) => s.room || s.id)).size;
4028
4117
  const anyLive = srcs.some((s) => s.live);
4029
- const connecting = roomSrcs.some((s) => !s.tried);
4118
+ const connecting =
4119
+ roomSrcs.some((s) => !s.tried) || [...chRooms.values()].some((r) => !r.tried);
4120
+ const roomCount = roomSrcs.length || chRooms.size;
4030
4121
  const n = entries.length;
4031
- if (!srcs.length) {
4122
+ if (!srcs.length && !chRooms.size) {
4032
4123
  setConn("● no fleet", "var(--muted)");
4033
4124
  } else if (!anyLive) {
4034
4125
  if (connecting) setConn("● connecting…", "var(--amber)");
4035
- else setConn(roomSrcs.length ? "● rooms offline" : "● ay serve down", "var(--red)");
4126
+ else setConn(roomCount ? "● rooms offline" : "● ay serve down", "var(--red)");
4036
4127
  } else {
4037
4128
  const roomBit = liveRooms ? ` · ${liveRooms} room${liveRooms === 1 ? "" : "s"}` : "";
4038
4129
  setConn(`● ${n} agent${n === 1 ? "" : "s"}${roomBit}`, "var(--green)");
@@ -4620,7 +4711,7 @@
4620
4711
  srcBranch: src.git.branch,
4621
4712
  fromCwd: src.cwd,
4622
4713
  cli: src.cli,
4623
- room: src._room,
4714
+ src: src._src,
4624
4715
  });
4625
4716
  }
4626
4717
  return rows;
@@ -4775,7 +4866,7 @@
4775
4866
  srcBranch: forkSrc.git.branch,
4776
4867
  fromCwd: forkSrc.cwd,
4777
4868
  cli: forkSrc.cli,
4778
- room: forkSrc._room,
4869
+ src: forkSrc._src,
4779
4870
  });
4780
4871
  omniRows.push({ kind: "spawncwd" });
4781
4872
  }
@@ -4856,7 +4947,7 @@
4856
4947
  closeOmni(false); // committing — keep the bg as-is, spawnAndSelect takes over
4857
4948
  await spawnAndSelect(
4858
4949
  { cli: anchor?.cli || "claude", cwd: anchor?.cwd || undefined, prompt: q || undefined },
4859
- anchor?._room,
4950
+ anchor?._src,
4860
4951
  );
4861
4952
  return;
4862
4953
  }
@@ -4875,7 +4966,7 @@
4875
4966
  prompt: q || undefined,
4876
4967
  fork: { fromCwd: row.fromCwd, branch: branch.trim() },
4877
4968
  },
4878
- row.room,
4969
+ row.src,
4879
4970
  );
4880
4971
  return;
4881
4972
  }
@@ -4885,7 +4976,7 @@
4885
4976
  closeOmni(false);
4886
4977
  await spawnAndSelect(
4887
4978
  { cli: anchor?.cli || "claude", cwd: cwd.trim() || undefined, prompt: q || undefined },
4888
- anchor?._room,
4979
+ anchor?._src,
4889
4980
  );
4890
4981
  return;
4891
4982
  }
@@ -4987,8 +5078,10 @@
4987
5078
  return "ch-" + (h >>> 0).toString(36).slice(0, 4).padStart(4, "0");
4988
5079
  }
4989
5080
 
4990
- function removeSource(room) {
4991
- const s = sources.get(room);
5081
+ // Drop ONE machine from the fleet. A codehost machine shares its room's
5082
+ // connection, so only the room may close it (see removeRoom).
5083
+ function removeSource(id) {
5084
+ const s = sources.get(id);
4992
5085
  if (!s) return;
4993
5086
  s.removed = true; // stop any pending exp-backoff reconnect (see below)
4994
5087
  clearTimeout(s.reconnectTimer);
@@ -4997,7 +5090,21 @@
4997
5090
  s.client?.close?.();
4998
5091
  s.client?.pc?.close?.();
4999
5092
  } catch {}
5000
- sources.delete(room);
5093
+ sources.delete(id);
5094
+ }
5095
+
5096
+ // Forget a whole room: every machine in it, plus the room's own connection.
5097
+ function removeRoom(name) {
5098
+ const ch = chRooms.get(name);
5099
+ if (ch) {
5100
+ chRooms.delete(name);
5101
+ for (const peerId of ch.peers.keys()) removeSource(name + "/" + peerId);
5102
+ try {
5103
+ ch.close();
5104
+ } catch {}
5105
+ return;
5106
+ }
5107
+ removeSource(name); // an ay-share room IS its single machine
5001
5108
  }
5002
5109
 
5003
5110
  // Exponential-backoff reconnect for an agent-yes share room (the RTCClient
@@ -5119,11 +5226,29 @@
5119
5226
  async function addRoomSource(room, token, host) {
5120
5227
  host = host || SIG_DEFAULT;
5121
5228
  saveRoom(room, token, host); // cache so the badge can list & reconnect later
5229
+ // A codehost room is a connection, not a machine: it contributes one source
5230
+ // per machine as peers announce themselves (CodehostRoom.syncPeers).
5231
+ if (host === CH_HOST) {
5232
+ if (chRooms.has(room)) return;
5233
+ const ch = new CodehostRoom(room, token);
5234
+ chRooms.set(room, ch);
5235
+ renderRoomsIfOpen();
5236
+ try {
5237
+ await ch.connect();
5238
+ } catch {
5239
+ ch.live = false;
5240
+ }
5241
+ ch.tried = true;
5242
+ renderRoomsIfOpen();
5243
+ loadList();
5244
+ return;
5245
+ }
5122
5246
  if (sources.has(room)) return;
5123
5247
  const s = {
5124
5248
  id: room,
5249
+ room,
5125
5250
  host,
5126
- kind: host === CH_HOST ? "ch" : "rtc",
5251
+ kind: "rtc",
5127
5252
  tx: null,
5128
5253
  client: null,
5129
5254
  live: false,
@@ -5134,25 +5259,12 @@
5134
5259
  sources.set(room, s);
5135
5260
  renderRoomsIfOpen();
5136
5261
  try {
5137
- if (host === CH_HOST) {
5138
- const c = new CodehostClient(token);
5139
- c.onstate = (st) => {
5140
- if (st === "closed") {
5141
- s.live = false;
5142
- renderRoomsIfOpen();
5143
- }
5144
- };
5145
- await c.connect();
5146
- s.client = c;
5147
- s.tx = c;
5148
- } else {
5149
- s.token = token;
5150
- await connectRtcSource(s);
5151
- }
5262
+ s.token = token;
5263
+ await connectRtcSource(s);
5152
5264
  s.live = true;
5153
5265
  } catch (e) {
5154
5266
  s.live = false;
5155
- if (s.kind === "rtc") scheduleRtcReconnect(s);
5267
+ scheduleRtcReconnect(s);
5156
5268
  }
5157
5269
  s.tried = true;
5158
5270
  renderRoomsIfOpen();
@@ -5194,10 +5306,9 @@
5194
5306
  // in it right now". The signaling layer can't tell us the count until we've
5195
5307
  // connected, so we show the source's last-known serverCount.
5196
5308
  function roomStatus(n) {
5197
- const s = sources.get(n);
5198
- if (!s) return `<span class="rstat off">○</span>`;
5199
- if (!s.live) return `<span class="rstat off" title="offline">○</span>`;
5200
- const c = s.serverCount;
5309
+ if (!sources.has(n) && !chRooms.has(n)) return `<span class="rstat off">○</span>`;
5310
+ if (!roomLive(n)) return `<span class="rstat off" title="offline">○</span>`;
5311
+ const c = roomSources(n).filter((s) => s.live).length;
5201
5312
  return `<span class="rstat on" title="${c} live ${c === 1 ? "server" : "servers"}">● ${c}</span>`;
5202
5313
  }
5203
5314
 
@@ -5207,7 +5318,7 @@
5207
5318
  const items = names.length
5208
5319
  ? names
5209
5320
  .map(
5210
- (n) => `<div class="ritem ${sources.get(n)?.live ? "cur" : ""}">
5321
+ (n) => `<div class="ritem ${roomLive(n) ? "cur" : ""}">
5211
5322
  ${roomStatus(n)}
5212
5323
  <span class="rname" data-room="${esc(n)}">${esc(n)}</span>
5213
5324
  <span class="rhost">${esc(r[n].host)}</span>
@@ -5254,7 +5365,7 @@
5254
5365
  }
5255
5366
  const del = ev.target.closest(".rx");
5256
5367
  if (del) {
5257
- removeSource(del.dataset.del);
5368
+ removeRoom(del.dataset.del);
5258
5369
  dropRoom(del.dataset.del);
5259
5370
  renderRooms();
5260
5371
  loadList();
@@ -5287,13 +5398,28 @@
5287
5398
  // The link carries only WHAT to run; the capability to run it is the viewer's
5288
5399
  // own connected fleet (cached room) + a y/N confirm on that host. So a public
5289
5400
  // launch link can never spawn on a machine the clicker doesn't already control.
5290
- function showLaunch(spec) {
5401
+ // One button per MACHINE we can actually reach, so a codehost room with three
5402
+ // machines offers three targets. A saved room with no live machine yet (boot
5403
+ // race, or the room is down) still gets one button under its own name —
5404
+ // launchOn connects it, and spawnTarget then resolves a machine inside it.
5405
+ function fleetTargets() {
5291
5406
  const rooms = loadRooms();
5292
- const names = Object.keys(rooms).sort((a, b) => rooms[b].ts - rooms[a].ts);
5407
+ return Object.keys(rooms)
5408
+ .sort((a, b) => rooms[b].ts - rooms[a].ts)
5409
+ .flatMap((n) => {
5410
+ const live = roomSources(n).filter((s) => s.live);
5411
+ return live.length
5412
+ ? live.map((s) => ({ id: s.id, label: sourceLabel(s) }))
5413
+ : [{ id: n, label: n }];
5414
+ });
5415
+ }
5416
+
5417
+ function showLaunch(spec) {
5418
+ const names = fleetTargets();
5293
5419
  const cmd = "ay " + (spec.cli || "claude") + (spec.prompt ? ` -- "${spec.prompt}"` : "");
5294
5420
  const fleets = names.length
5295
- ? `<div class="lfleets">${names.map((n) => `<button class="lfleet" data-room="${esc(n)}">▷ ${esc(n)}</button>`).join("")}</div>
5296
- <div class="lhint">pick a fleet to run on</div>`
5421
+ ? `<div class="lfleets">${names.map((t) => `<button class="lfleet" data-room="${esc(t.id)}">▷ ${esc(t.label)}</button>`).join("")}</div>
5422
+ <div class="lhint">pick a machine to run on</div>`
5297
5423
  : `<div class="lwarn">No connected fleet on this device. Run <code>bunx agent-yes serve --share</code>, open its link once, then reopen this launch link.</div>`;
5298
5424
  $("launch").innerHTML = `<div class="lcard">
5299
5425
  <div class="ltitle">Launch agent</div>
@@ -5305,11 +5431,16 @@
5305
5431
  $("launch").style.display = "flex";
5306
5432
  }
5307
5433
 
5308
- // Pick the fleet to spawn on: an explicit roomId, else the selected agent's
5309
- // source, else local, else any live source.
5310
- function spawnTarget(roomId) {
5434
+ // Pick the MACHINE to spawn on: an explicit source id, else any live machine
5435
+ // in an explicitly named room (a launch link names a room, not a machine, and
5436
+ // a machine that has since left the room degrades to its room), else the
5437
+ // selected agent's machine, else local, else any live machine.
5438
+ function spawnTarget(id) {
5439
+ const room = id && id.includes("/") ? id.slice(0, id.indexOf("/")) : null;
5311
5440
  return (
5312
- sources.get(roomId) ||
5441
+ sources.get(id) ||
5442
+ (id && roomSources(id).find((s) => s.live)) ||
5443
+ (room && roomSources(room).find((s) => s.live)) ||
5313
5444
  srcFor(entries.find((e) => e._key === sel)) ||
5314
5445
  sources.get(LOCAL) ||
5315
5446
  [...sources.values()].find((s) => s.live) ||
@@ -5330,7 +5461,7 @@
5330
5461
  }
5331
5462
  // Match by "newest agent that wasn't here before" ON THIS fleet — the
5332
5463
  // spawn returns the wrapper pid, but the agent registers under its own.
5333
- const before = new Set(entries.filter((e) => e._room === target.id).map((e) => e.pid));
5464
+ const before = new Set(entries.filter((e) => e._src === target.id).map((e) => e.pid));
5334
5465
  const res = await target.tx.post("/api/spawn", {
5335
5466
  cli: spec.cli || "claude",
5336
5467
  from: spec.from || undefined,
@@ -5345,7 +5476,7 @@
5345
5476
  for (let i = 0; i < 14; i++) {
5346
5477
  await loadList();
5347
5478
  const fresh = entries
5348
- .filter((e) => e._room === target.id && !before.has(e.pid))
5479
+ .filter((e) => e._src === target.id && !before.has(e.pid))
5349
5480
  .sort((a, b) => (b.started_at || 0) - (a.started_at || 0));
5350
5481
  if (fresh.length) {
5351
5482
  select(fresh[0]._key);
@@ -5356,12 +5487,17 @@
5356
5487
  return true;
5357
5488
  }
5358
5489
 
5359
- async function launchOn(room, spec) {
5360
- const r = loadRooms()[room];
5361
- if (!r) return;
5362
- $("launch").style.display = "none";
5363
- await connectRoom(room, r.token, r.host);
5364
- await spawnAndSelect(spec, room);
5490
+ // `id` is a machine (source id) once its room is connected, or a bare room
5491
+ // name when the launch link was opened before the room came up.
5492
+ async function launchOn(id, spec) {
5493
+ if (!sources.has(id)) {
5494
+ const name = id.split("/")[0];
5495
+ const r = loadRooms()[name];
5496
+ if (!r) return;
5497
+ $("launch").style.display = "none";
5498
+ await connectRoom(name, r.token, r.host);
5499
+ } else $("launch").style.display = "none";
5500
+ await spawnAndSelect(spec, id);
5365
5501
  }
5366
5502
 
5367
5503
  $("launch").addEventListener("click", (ev) => {
@@ -5373,18 +5509,32 @@
5373
5509
  if (ev.target.closest(".lcancel")) $("launch").style.display = "none";
5374
5510
  });
5375
5511
 
5376
- // ---- "+ New agent": a spawn form for the CURRENTLY connected fleet ----
5512
+ // ---- "+ New agent": a spawn form for one machine in the fleet ----------
5377
5513
  // Click → fill (cli defaults to claude, cwd prefilled from the selected agent
5378
- // when there is one, prompt optional) → POST /api/spawn on this connection.
5514
+ // when there is one, prompt optional) → POST /api/spawn on that machine.
5379
5515
  // Always allowed: the console already controls every running agent's stdin.
5516
+ //
5517
+ // The Host row appears only when the fleet holds more than one machine. It
5518
+ // defaults to spawnTarget()'s pick — the selected agent's machine — so the
5519
+ // common "spawn next to what I'm looking at" flow needs no interaction.
5380
5520
  function showNew() {
5381
5521
  const here = entries.find((x) => x._key === sel);
5382
5522
  const cwd = here?.cwd || "";
5383
5523
  const target = spawnTarget();
5384
- const where = target ? (target.id === LOCAL ? "local" : target.id) : "local";
5524
+ const hosts = [...sources.values()].filter((s) => s.live || s.id === target?.id);
5385
5525
  $("newform").dataset.room = target ? target.id : "";
5526
+ const hostRow =
5527
+ hosts.length > 1
5528
+ ? `<div class="nfield"><label>Host</label><select id="nf-host">${hosts
5529
+ .map(
5530
+ (s) =>
5531
+ `<option value="${esc(s.id)}"${s.id === target?.id ? " selected" : ""}>${esc(sourceLabel(s))}</option>`,
5532
+ )
5533
+ .join("")}</select></div>`
5534
+ : "";
5386
5535
  $("newform").innerHTML = `<div class="lcard">
5387
- <div class="ltitle">New agent · ${esc(where)}</div>
5536
+ <div class="ltitle">New agent · ${esc(sourceLabel(target) || "local")}</div>
5537
+ ${hostRow}
5388
5538
  <div class="nfield"><label>CLI</label><input id="nf-cli" value="claude" spellcheck="false" autocapitalize="off" /></div>
5389
5539
  <div class="nfield"><label>Spawn from — optional</label><input id="nf-from" placeholder="github URL / owner/repo@branch — provisions a worktree" spellcheck="false" autocapitalize="off" /></div>
5390
5540
  <div class="nfield"><label>Working dir</label><input id="nf-cwd" value="${esc(cwd)}" placeholder="(host default — ignored when Spawn from is set)" spellcheck="false" autocapitalize="off" /></div>
@@ -5394,24 +5544,38 @@
5394
5544
  <button class="lcancel" id="nf-cancel">cancel</button>
5395
5545
  </div>
5396
5546
  <div id="nf-hook" class="lhint" style="display:none"></div>
5397
- <div class="lhint">POST /api/spawn on this fleet. ${IS_MAC ? "⌘" : "Ctrl"}+Enter to launch.</div></div>`;
5547
+ <div class="lhint">POST /api/spawn on this machine. ${IS_MAC ? "⌘" : "Ctrl"}+Enter to launch.</div></div>`;
5398
5548
  $("newform").style.display = "flex";
5399
5549
  setTimeout(() => $("nf-prompt")?.focus(), 0);
5400
- // Surface whether this host runs a spawn hook (a trusted local command run
5401
- // before every launch provisions env/cwd; see ~/.agent-yes/config.json).
5402
- // Read-only: /api/spawn-config discloses only the boolean, never the body.
5403
- if (target?.tx?.fetchJSON) {
5404
- target.tx
5405
- .fetchJSON("/api/spawn-config")
5406
- .then((c) => {
5407
- const el = $("nf-hook");
5408
- if (el && c && c.hasSpawnHook) {
5409
- el.textContent = "⚙ this host runs a spawn hook before launch";
5410
- el.style.display = "";
5411
- }
5412
- })
5413
- .catch(() => {});
5414
- }
5550
+ // Retarget the form when the user picks another machine the spawn-hook
5551
+ // hint below is per-machine, so it has to be re-read too.
5552
+ $("nf-host")?.addEventListener("change", (ev) => {
5553
+ $("newform").dataset.room = ev.target.value;
5554
+ showHook(sources.get(ev.target.value));
5555
+ });
5556
+ showHook(target);
5557
+ }
5558
+
5559
+ // Surface whether the target host runs a spawn hook (a trusted local command
5560
+ // run before every launch — provisions env/cwd; see ~/.agent-yes/config.json).
5561
+ // Read-only: /api/spawn-config discloses only the boolean, never the body.
5562
+ function showHook(target) {
5563
+ const el = $("nf-hook");
5564
+ if (el) el.style.display = "none";
5565
+ if (!target?.tx?.fetchJSON) return;
5566
+ const forId = target.id;
5567
+ target.tx
5568
+ .fetchJSON("/api/spawn-config")
5569
+ .then((c) => {
5570
+ // A slow answer for a host the user has since switched away from must
5571
+ // not paint the new host's hint.
5572
+ if (!el || $("newform").dataset.room !== forId) return;
5573
+ if (c && c.hasSpawnHook) {
5574
+ el.textContent = "⚙ this host runs a spawn hook before launch";
5575
+ el.style.display = "";
5576
+ }
5577
+ })
5578
+ .catch(() => {});
5415
5579
  }
5416
5580
 
5417
5581
  async function submitNew() {
@@ -5433,7 +5597,7 @@
5433
5597
  const norm = (p) => (p || "").replace(/\/+$/, "");
5434
5598
  const busy = entries.some(
5435
5599
  (e) =>
5436
- (room ? e._room === room : true) &&
5600
+ (room ? e._src === room : true) &&
5437
5601
  e.exit_code == null &&
5438
5602
  norm(e.cwd) === norm(spec.cwd),
5439
5603
  );
@@ -5590,7 +5754,7 @@
5590
5754
  if (!m) return;
5591
5755
  const [, room, pid] = m;
5592
5756
  const key = room + "#" + pid;
5593
- const e = entries.find((x) => x._key === key);
5757
+ const e = entries.find((x) => matchSel(x, key));
5594
5758
  if (e) {
5595
5759
  if (sel !== e._key) select(e._key);
5596
5760
  return;
@@ -5600,7 +5764,7 @@
5600
5764
  autoPid = key;
5601
5765
  autoPidExplicit = true;
5602
5766
  const r = loadRooms()[room];
5603
- if (r && !sources.get(room)) connectRoom(room, r.token, r.host);
5767
+ if (r && !sources.has(room) && !chRooms.has(room)) connectRoom(room, r.token, r.host);
5604
5768
  });
5605
5769
 
5606
5770
  // ---- activity-gated polling + auto-reload on new deploy ----------------