agent-yes 1.176.0 → 1.177.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/SUPPORTED_CLIS-CqxJj_q5.js +8 -0
- package/dist/{SUPPORTED_CLIS-TMmgX_p6.js → SUPPORTED_CLIS-D7WMaGvg.js} +2 -2
- package/dist/{agentShare-DRfghQz8.js → agentShare-BDRVkUS5.js} +2 -2
- package/dist/cli.js +4 -4
- package/dist/index.js +2 -2
- package/dist/{notifyDaemon-C08fJ2Ai.js → notifyDaemon-CibpOxkN.js} +2 -2
- package/dist/{rustBinary-DZTU07SA.js → rustBinary-BPF24JLj.js} +2 -2
- package/dist/{schedule-BQtFOk0z.js → schedule-mhFoLlO_.js} +4 -4
- package/dist/{serve-B5PQdiOb.js → serve-Jq_kF9Ik.js} +10 -10
- package/dist/{setup-BCqFNJoB.js → setup-dmAF9LVD.js} +2 -2
- package/dist/{subcommands-BbSJ_nTU.js → subcommands-BrV8B9hG.js} +6 -6
- package/dist/{subcommands-BokdtqqC.js → subcommands-HIlFVQrW.js} +1 -1
- package/dist/{ts-Ce7a44Rj.js → ts-CObUr3JC.js} +2 -2
- package/dist/{versionChecker-C7C7QAPu.js → versionChecker-DlNr6KFg.js} +2 -2
- package/lab/ui/index.html +415 -251
- package/package.json +1 -1
- package/dist/SUPPORTED_CLIS-BC4fOfBC.js +0 -8
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:
|
|
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
|
|
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
|
-
|
|
1853
|
-
|
|
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
|
-
//
|
|
2062
|
-
//
|
|
2063
|
-
//
|
|
2064
|
-
//
|
|
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
|
-
|
|
2067
|
-
|
|
2068
|
-
|
|
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 (
|
|
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.
|
|
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
|
-
|
|
2093
|
-
|
|
2094
|
-
|
|
2095
|
-
|
|
2096
|
-
|
|
2097
|
-
|
|
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
|
-
|
|
2144
|
-
|
|
2145
|
-
|
|
2146
|
-
|
|
2147
|
-
|
|
2148
|
-
|
|
2149
|
-
|
|
2150
|
-
|
|
2151
|
-
|
|
2152
|
-
|
|
2153
|
-
|
|
2154
|
-
|
|
2155
|
-
|
|
2156
|
-
|
|
2157
|
-
|
|
2158
|
-
|
|
2159
|
-
|
|
2160
|
-
|
|
2161
|
-
|
|
2162
|
-
|
|
2163
|
-
|
|
2164
|
-
|
|
2165
|
-
|
|
2166
|
-
|
|
2167
|
-
|
|
2168
|
-
|
|
2169
|
-
|
|
2170
|
-
|
|
2171
|
-
|
|
2172
|
-
|
|
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
|
-
|
|
2201
|
-
|
|
2202
|
-
|
|
2203
|
-
|
|
2204
|
-
|
|
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
|
-
//
|
|
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
|
-
//
|
|
2289
|
-
// owning source on `
|
|
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.
|
|
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
|
|
2401
|
-
//
|
|
2402
|
-
//
|
|
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
|
|
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
|
|
2415
|
-
// so two machines that
|
|
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
|
-
|
|
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)
|
|
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
|
|
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)
|
|
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)
|
|
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,
|
|
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 =
|
|
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
|
|
3202
|
-
//
|
|
3203
|
-
|
|
3204
|
-
const
|
|
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
|
-
//
|
|
3677
|
-
//
|
|
3678
|
-
//
|
|
3679
|
-
//
|
|
3680
|
-
//
|
|
3681
|
-
//
|
|
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
|
-
//
|
|
3686
|
-
//
|
|
3687
|
-
// (no deviceLabel) so a single-machine view keeps its clean
|
|
3688
|
-
// identity instead of a blank "@:" prefix.
|
|
3689
|
-
const host =
|
|
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,
|
|
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:
|
|
3997
|
-
//
|
|
3998
|
-
|
|
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).
|
|
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 =
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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?.
|
|
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.
|
|
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?.
|
|
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
|
-
|
|
4991
|
-
|
|
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(
|
|
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:
|
|
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
|
-
|
|
5138
|
-
|
|
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
|
-
|
|
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
|
-
|
|
5198
|
-
if (!
|
|
5199
|
-
|
|
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 ${
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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((
|
|
5296
|
-
<div class="lhint">pick a
|
|
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
|
|
5309
|
-
//
|
|
5310
|
-
|
|
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(
|
|
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.
|
|
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.
|
|
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
|
-
|
|
5360
|
-
|
|
5361
|
-
|
|
5362
|
-
|
|
5363
|
-
|
|
5364
|
-
|
|
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
|
|
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
|
|
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
|
|
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(
|
|
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
|
|
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
|
-
//
|
|
5401
|
-
//
|
|
5402
|
-
|
|
5403
|
-
|
|
5404
|
-
target.
|
|
5405
|
-
|
|
5406
|
-
|
|
5407
|
-
|
|
5408
|
-
|
|
5409
|
-
|
|
5410
|
-
|
|
5411
|
-
|
|
5412
|
-
|
|
5413
|
-
|
|
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.
|
|
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
|
|
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.
|
|
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 ----------------
|