codehost 0.16.0 → 0.17.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/CHANGELOG.md +17 -0
- package/package.json +2 -1
- package/src/cli/commands/dev.ts +32 -1
- package/src/cli/commands/expose.ts +2 -0
- package/src/cli/commands/serve.ts +54 -10
- package/src/cli/config.test.ts +31 -0
- package/src/cli/config.ts +20 -7
- package/src/cli/daemonize.ts +4 -0
- package/src/cli/plugins/agent-yes.test.ts +71 -0
- package/src/cli/plugins/agent-yes.ts +116 -0
- package/src/cli/plugins/types.ts +39 -0
- package/src/cli/provision-server.ts +11 -3
- package/src/cli/registry.test.ts +51 -0
- package/src/cli/registry.ts +93 -0
- package/src/cli/run-server.ts +59 -8
- package/src/cli/tunnel.ts +25 -14
- package/src/cli/vscode-install.ts +20 -3
- package/src/cli/workspaces.test.ts +65 -0
- package/src/cli/workspaces.ts +80 -0
- package/src/shared/repo.test.ts +110 -1
- package/src/shared/repo.ts +67 -19
- package/src/shared/signaling-client.ts +10 -0
- package/src/shared/signaling.ts +55 -1
- package/src/web/discovery.tsx +134 -38
- package/src/web/history.ts +4 -1
- package/src/web/room-client.ts +114 -0
- package/worker/room.ts +9 -0
package/src/shared/repo.ts
CHANGED
|
@@ -173,26 +173,66 @@ export interface Resolution {
|
|
|
173
173
|
peerId: string;
|
|
174
174
|
/** Folder to open via ?folder= (root kind); undefined opens the repo as-is. */
|
|
175
175
|
folder?: string;
|
|
176
|
+
/** The folder is a checkout the daemon *enumerated* (it exists on disk), not
|
|
177
|
+
* an optimistic layout-synthesized path — rank it like an exact match. */
|
|
178
|
+
exact?: boolean;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** Machine preference (from history) used to break ties between matches. */
|
|
182
|
+
export interface ResolvePrefs {
|
|
183
|
+
/** Stable machine id — prefer servers advertising it. */
|
|
184
|
+
hostId?: string;
|
|
185
|
+
/** Hostname fallback for pre-hostId daemons/entries. */
|
|
186
|
+
host?: string;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function prefers(meta: PeerMeta | null | undefined, prefer?: ResolvePrefs): boolean {
|
|
190
|
+
if (!prefer || !meta) return false;
|
|
191
|
+
if (prefer.hostId && meta.hostId) return meta.hostId === prefer.hostId;
|
|
192
|
+
return !!prefer.host && meta.host === prefer.host;
|
|
176
193
|
}
|
|
177
194
|
|
|
178
195
|
/**
|
|
179
196
|
* Pick the best live server for a repo deep link. Prefers an exact `repo`
|
|
180
197
|
* daemon; otherwise falls back to a `root` daemon that can open the subfolder.
|
|
181
|
-
*
|
|
182
|
-
*
|
|
183
|
-
*
|
|
184
|
-
* /Users/sno
|
|
198
|
+
* Ties (several repo daemons, or several roots) break toward the machine in
|
|
199
|
+
* `prefer` — the one history says served this repo last. Among several roots,
|
|
200
|
+
* then prefers the **deepest** (longest cwd) — with a nested setup like
|
|
201
|
+
* /Users/sno and /Users/sno/ws both serving, the layout subfolder exists under
|
|
202
|
+
* the deeper one (observed: /gh/snomiao/codehost belongs to /Users/sno/ws/...,
|
|
203
|
+
* not /Users/sno/...). Returns null if nothing matches.
|
|
185
204
|
*/
|
|
186
|
-
export function resolveRepoTarget(
|
|
205
|
+
export function resolveRepoTarget(
|
|
206
|
+
servers: PeerInfo[],
|
|
207
|
+
target: RepoTarget,
|
|
208
|
+
prefer?: ResolvePrefs,
|
|
209
|
+
): Resolution | null {
|
|
187
210
|
const key = repoKey(target);
|
|
188
|
-
const
|
|
211
|
+
const repoMatches = servers.filter(
|
|
189
212
|
(s) => s.meta?.kind !== "root" && s.meta?.repo === key && branchOk(s.meta, target),
|
|
190
213
|
);
|
|
214
|
+
const repoMatch = repoMatches.find((s) => prefers(s.meta, prefer)) ?? repoMatches[0];
|
|
191
215
|
if (repoMatch) return { peerId: repoMatch.peerId };
|
|
192
216
|
|
|
193
|
-
const
|
|
217
|
+
const roots = servers
|
|
194
218
|
.filter((s) => s.meta?.kind === "root")
|
|
195
|
-
.sort(
|
|
219
|
+
.sort(
|
|
220
|
+
(a, b) =>
|
|
221
|
+
Number(prefers(b.meta, prefer)) - Number(prefers(a.meta, prefer)) ||
|
|
222
|
+
(b.meta?.cwd.length ?? 0) - (a.meta?.cwd.length ?? 0),
|
|
223
|
+
);
|
|
224
|
+
|
|
225
|
+
// A root that *enumerated* a matching checkout knows it exists on disk —
|
|
226
|
+
// rank it exact, ahead of any synthesized fallback.
|
|
227
|
+
const wantBranch = target.branch || DEFAULT_BRANCH;
|
|
228
|
+
for (const s of roots) {
|
|
229
|
+
const ws = s.meta?.workspaces?.find(
|
|
230
|
+
(w) => w.repo === key && (!w.branch || w.branch === wantBranch),
|
|
231
|
+
);
|
|
232
|
+
if (ws) return { peerId: s.peerId, folder: ws.path, exact: true };
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
const root = roots[0];
|
|
196
236
|
if (root && root.meta) {
|
|
197
237
|
const folder = `${trimSlash(root.meta.cwd)}/${fillLayout(root.meta.layout || DEFAULT_LAYOUT, target)}`;
|
|
198
238
|
return { peerId: root.peerId, folder };
|
|
@@ -204,13 +244,20 @@ export function resolveRepoTarget(servers: PeerInfo[], target: RepoTarget): Reso
|
|
|
204
244
|
* to `target.host` when the link carries one (a bare path is ambiguous across
|
|
205
245
|
* machines). Compares with leading + trailing slashes stripped: `parseDeepLink`
|
|
206
246
|
* forces a leading "/" on the path, but a served cwd may lack one (e.g. an
|
|
207
|
-
* `expose` server's `localhost:<port>`), so a trailing-only trim never matches.
|
|
247
|
+
* `expose` server's `localhost:<port>`), so a trailing-only trim never matches.
|
|
248
|
+
* A root daemon whose *advertised workspaces* include the path matches too —
|
|
249
|
+
* that's how directories registered with the host daemon resolve. */
|
|
208
250
|
export function resolveDevTarget(servers: PeerInfo[], target: DevTarget): Resolution | null {
|
|
209
251
|
const want = stripEnds(target.path);
|
|
210
|
-
const
|
|
211
|
-
|
|
212
|
-
);
|
|
213
|
-
|
|
252
|
+
const hostOk = (meta: PeerMeta) => !target.host || meta.host === target.host;
|
|
253
|
+
const hit = servers.find((s) => s.meta && stripEnds(s.meta.cwd) === want && hostOk(s.meta));
|
|
254
|
+
if (hit) return { peerId: hit.peerId };
|
|
255
|
+
for (const s of servers) {
|
|
256
|
+
if (!s.meta || !hostOk(s.meta)) continue;
|
|
257
|
+
const ws = s.meta.workspaces?.find((w) => stripEnds(w.path) === want);
|
|
258
|
+
if (ws) return { peerId: s.peerId, folder: ws.path, exact: true };
|
|
259
|
+
}
|
|
260
|
+
return null;
|
|
214
261
|
}
|
|
215
262
|
|
|
216
263
|
/** A candidate room (its token) plus how the deep link resolved within it. */
|
|
@@ -221,14 +268,15 @@ export interface RoomMatch {
|
|
|
221
268
|
|
|
222
269
|
/**
|
|
223
270
|
* Rank matches found while searching multiple rooms for a token-less deep link.
|
|
224
|
-
* An *exact* match (a server that genuinely serves this repo/folder —
|
|
225
|
-
*
|
|
226
|
-
* the repo as a subfolder, which
|
|
227
|
-
*
|
|
228
|
-
*
|
|
271
|
+
* An *exact* match (a server that genuinely serves this repo/folder — a repo
|
|
272
|
+
* daemon, or a root whose enumerated checkout matched) beats a *root fallback*
|
|
273
|
+
* (a root daemon that would open the repo as a synthesized subfolder, which
|
|
274
|
+
* `resolveRepoTarget` returns for ANY repo link). Without this preference,
|
|
275
|
+
* first-responder-wins could pick an unrelated room that merely has a root
|
|
276
|
+
* server. Returns null when there are no matches.
|
|
229
277
|
*/
|
|
230
278
|
export function pickRoomMatch(matches: RoomMatch[]): RoomMatch | null {
|
|
231
|
-
return matches.find((m) => !m.resolution.folder) ?? matches[0] ?? null;
|
|
279
|
+
return matches.find((m) => !m.resolution.folder || m.resolution.exact) ?? matches[0] ?? null;
|
|
232
280
|
}
|
|
233
281
|
|
|
234
282
|
function branchOk(meta: PeerMeta, target: RepoTarget): boolean {
|
|
@@ -161,6 +161,16 @@ export class SignalingClient {
|
|
|
161
161
|
this.ws?.send(JSON.stringify(msg));
|
|
162
162
|
}
|
|
163
163
|
|
|
164
|
+
/** Replace the advertised metadata: a live socket pushes it to the room now,
|
|
165
|
+
* and every future (re)connect's `hello` carries it. */
|
|
166
|
+
updateMeta(meta: PeerMeta): void {
|
|
167
|
+
this.opts.meta = meta;
|
|
168
|
+
if (this.ws?.readyState === 1 /* OPEN */) {
|
|
169
|
+
const msg: ClientMessage = { type: "meta", meta };
|
|
170
|
+
this.ws.send(JSON.stringify(msg));
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
164
174
|
close(): void {
|
|
165
175
|
this.closed = true;
|
|
166
176
|
this.stopHeartbeat();
|
package/src/shared/signaling.ts
CHANGED
|
@@ -4,6 +4,34 @@
|
|
|
4
4
|
|
|
5
5
|
export type Role = "server" | "viewer";
|
|
6
6
|
|
|
7
|
+
/** One live agent CLI session on the daemon's machine (sourced from agent-yes's
|
|
8
|
+
* registry) — advertised so clients can see which agents run where. Interact
|
|
9
|
+
* with it over the tunnel's `/__codehost/agent-yes/*` proxy. */
|
|
10
|
+
export interface AgentInfo {
|
|
11
|
+
pid: number;
|
|
12
|
+
/** CLI the agent runs, e.g. "claude", "codex". */
|
|
13
|
+
tool: string;
|
|
14
|
+
/** Prompt/note snippet for display. */
|
|
15
|
+
title?: string;
|
|
16
|
+
/** Working directory (?folder= form) — ties an agent to a workspace. */
|
|
17
|
+
cwd: string;
|
|
18
|
+
state: "active" | "idle";
|
|
19
|
+
/** Unix ms when the agent started. */
|
|
20
|
+
startedAt?: number;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** One checkout a root daemon found on disk under its layout — advertised so
|
|
24
|
+
* clients can list and exactly match real workspaces instead of synthesizing
|
|
25
|
+
* optimistic paths. */
|
|
26
|
+
export interface WorkspaceInfo {
|
|
27
|
+
/** ?folder= form path of the checkout (see toPosixPath). */
|
|
28
|
+
path: string;
|
|
29
|
+
/** Host-agnostic repo identity, e.g. "github.com/owner/repo". */
|
|
30
|
+
repo?: string;
|
|
31
|
+
/** Branch from the layout path, e.g. "main". */
|
|
32
|
+
branch?: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
7
35
|
/** Metadata a `codehost serve`/`dev` daemon advertises about itself. */
|
|
8
36
|
export interface PeerMeta {
|
|
9
37
|
/** Human label, defaults to hostname. */
|
|
@@ -12,6 +40,13 @@ export interface PeerMeta {
|
|
|
12
40
|
cwd: string;
|
|
13
41
|
/** Hostname of the machine running the daemon. */
|
|
14
42
|
host: string;
|
|
43
|
+
/**
|
|
44
|
+
* Stable machine identity (UUID persisted in ~/.codehost/config.json). All
|
|
45
|
+
* daemons on one machine share it, unlike the per-process peerId, so clients
|
|
46
|
+
* can group peers by host and keep history across daemon restarts. Absent on
|
|
47
|
+
* older daemons — fall back to `host`.
|
|
48
|
+
*/
|
|
49
|
+
hostId?: string;
|
|
15
50
|
/**
|
|
16
51
|
* "repo": serves a single folder (`codehost dev`), git-identified when possible.
|
|
17
52
|
* "root": serves a workspace root (`codehost serve`) whose repos live under it.
|
|
@@ -28,6 +63,17 @@ export interface PeerMeta {
|
|
|
28
63
|
* `cwd + "/" + fill(layout, target)`.
|
|
29
64
|
*/
|
|
30
65
|
layout?: string;
|
|
66
|
+
/**
|
|
67
|
+
* root kind: the checkouts that actually exist under `cwd` (enumerated on
|
|
68
|
+
* start, after each provision, and on a slow rescan). Capped server-side;
|
|
69
|
+
* absent on older daemons and on repo/expose kinds.
|
|
70
|
+
*/
|
|
71
|
+
workspaces?: WorkspaceInfo[];
|
|
72
|
+
/**
|
|
73
|
+
* Live agent CLI sessions on this machine (from the agent-yes plugin).
|
|
74
|
+
* Advertised by root daemons; capped server-side.
|
|
75
|
+
*/
|
|
76
|
+
agents?: AgentInfo[];
|
|
31
77
|
}
|
|
32
78
|
|
|
33
79
|
export interface PeerInfo {
|
|
@@ -61,7 +107,15 @@ export interface PingMessage {
|
|
|
61
107
|
type: "ping";
|
|
62
108
|
}
|
|
63
109
|
|
|
64
|
-
|
|
110
|
+
/** Replace this peer's advertised metadata mid-session (e.g. a provision
|
|
111
|
+
* created a new workspace) — the room re-broadcasts the peer list. Older
|
|
112
|
+
* workers ignore it; the meta still lands via `hello` on the next reconnect. */
|
|
113
|
+
export interface MetaMessage {
|
|
114
|
+
type: "meta";
|
|
115
|
+
meta: PeerMeta;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export type ClientMessage = HelloMessage | SignalMessage | PingMessage | MetaMessage;
|
|
65
119
|
|
|
66
120
|
// ---- Server -> Client ----
|
|
67
121
|
|
package/src/web/discovery.tsx
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { useEffect, useRef, useState } from "react";
|
|
2
|
-
import type { PeerInfo } from "../shared/signaling";
|
|
2
|
+
import type { AgentInfo, PeerInfo, WorkspaceInfo } from "../shared/signaling";
|
|
3
3
|
import { TOKEN_REQUIREMENTS, validateToken } from "../shared/token";
|
|
4
4
|
import { SignalingClient } from "../shared/signaling-client";
|
|
5
5
|
import type { RtcSignal } from "../shared/rtc";
|
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
DEFAULT_BRANCH,
|
|
12
12
|
type DeepLink,
|
|
13
13
|
type RepoTarget,
|
|
14
|
+
type ResolvePrefs,
|
|
14
15
|
type RoomMatch,
|
|
15
16
|
gitUrlToPath,
|
|
16
17
|
parseDeepLink,
|
|
@@ -91,7 +92,7 @@ function findRoomForDeepLink(dl: DeepLink, tokens: string[], timeoutMs = 6000):
|
|
|
91
92
|
const res =
|
|
92
93
|
dl.type === "repo" ? resolveRepoTarget(servers, dl.target) : resolveDevTarget(servers, dl.target);
|
|
93
94
|
if (!res) return;
|
|
94
|
-
if (!res.folder) finish(tok); // exact match — take it now
|
|
95
|
+
if (!res.folder || res.exact) finish(tok); // exact match — take it now
|
|
95
96
|
else if (!fallbacks.some((f) => f.token === tok)) fallbacks.push({ token: tok, resolution: res });
|
|
96
97
|
},
|
|
97
98
|
});
|
|
@@ -545,6 +546,14 @@ export function Discovery() {
|
|
|
545
546
|
setTimeout(() => setCopied(false), 1500);
|
|
546
547
|
}
|
|
547
548
|
|
|
549
|
+
// The machine history says served this repo last — break resolution ties
|
|
550
|
+
// toward it (stable hostId when recorded, hostname for older entries).
|
|
551
|
+
function preferFor(dl: DeepLink): ResolvePrefs | undefined {
|
|
552
|
+
if (dl?.type !== "repo") return undefined;
|
|
553
|
+
const h = historyFor(repoKey(dl.target));
|
|
554
|
+
return h ? { hostId: h.hostId, host: h.host } : undefined;
|
|
555
|
+
}
|
|
556
|
+
|
|
548
557
|
// Deep-link auto-connect: when servers arrive, pick the best match (exact repo
|
|
549
558
|
// daemon, else a root daemon's subfolder) across all rooms and open it once.
|
|
550
559
|
function tryAutoConnect() {
|
|
@@ -552,7 +561,8 @@ export function Discovery() {
|
|
|
552
561
|
const dl = deepLinkRef.current;
|
|
553
562
|
if (dl) {
|
|
554
563
|
const peers = allServers.map((x) => x.server);
|
|
555
|
-
const res =
|
|
564
|
+
const res =
|
|
565
|
+
dl.type === "repo" ? resolveRepoTarget(peers, dl.target, preferFor(dl)) : resolveDevTarget(peers, dl.target);
|
|
556
566
|
if (!res) return;
|
|
557
567
|
const match = allServers.find((x) => x.server.peerId === res.peerId);
|
|
558
568
|
if (!match) return;
|
|
@@ -576,7 +586,7 @@ export function Discovery() {
|
|
|
576
586
|
function recordConnect(server: PeerInfo, room: string, folder?: string) {
|
|
577
587
|
const base = {
|
|
578
588
|
token: room,
|
|
579
|
-
|
|
589
|
+
hostId: server.meta?.hostId,
|
|
580
590
|
kind: server.meta?.kind,
|
|
581
591
|
name: server.meta?.name,
|
|
582
592
|
host: server.meta?.host,
|
|
@@ -607,7 +617,8 @@ export function Discovery() {
|
|
|
607
617
|
function findServerForDeepLink(dl: DeepLink): (RoomedServer & { folder?: string }) | null {
|
|
608
618
|
if (!dl) return null;
|
|
609
619
|
const peers = allServersRef.current.map((x) => x.server);
|
|
610
|
-
const res =
|
|
620
|
+
const res =
|
|
621
|
+
dl.type === "repo" ? resolveRepoTarget(peers, dl.target, preferFor(dl)) : resolveDevTarget(peers, dl.target);
|
|
611
622
|
if (!res) return null;
|
|
612
623
|
const match = allServersRef.current.find((x) => x.server.peerId === res.peerId);
|
|
613
624
|
return match ? { ...match, folder: res.folder } : null;
|
|
@@ -630,6 +641,19 @@ export function Discovery() {
|
|
|
630
641
|
void connectTo(target.server, target.room, target.folder, true, dl.type === "repo" ? dl.target : undefined);
|
|
631
642
|
}
|
|
632
643
|
|
|
644
|
+
// Open an enumerated checkout via its deep link, reusing the URL-driven
|
|
645
|
+
// resolution (provisioning, history, machine preference) instead of dialing
|
|
646
|
+
// the card's peer directly.
|
|
647
|
+
function openWorkspace(server: PeerInfo, w: WorkspaceInfo) {
|
|
648
|
+
const path = w.repo
|
|
649
|
+
? shareableDeepLink({ repo: w.repo, branch: w.branch })
|
|
650
|
+
: shareableDeepLink({ folder: w.path, host: server.meta?.host });
|
|
651
|
+
if (!path) return;
|
|
652
|
+
history.pushState(null, "", path);
|
|
653
|
+
setResolving(deepLinkLabel(parseDeepLink(path)));
|
|
654
|
+
syncToUrl();
|
|
655
|
+
}
|
|
656
|
+
|
|
633
657
|
function disconnect() {
|
|
634
658
|
// Mirror Cmd+Left: if connecting pushed a history entry, pop it — the
|
|
635
659
|
// browser restores the previous URL and our popstate handler tears down.
|
|
@@ -664,6 +688,23 @@ export function Discovery() {
|
|
|
664
688
|
}));
|
|
665
689
|
const query = [...activeTags, filter].join(" ");
|
|
666
690
|
const filtered = tagged.filter((t) => matchQuery({ name: t.name, tags: t.tags }, query));
|
|
691
|
+
// Group workspaces by machine: the stable hostId when the daemon advertises
|
|
692
|
+
// one, else the hostname string (older daemons), else the peer stands alone.
|
|
693
|
+
// Agents are machine-level (advertised by the host's root daemon) — collect
|
|
694
|
+
// them per group, deduped by pid across peers.
|
|
695
|
+
const hostGroups: { key: string; label: string; items: typeof filtered; agents: AgentInfo[] }[] = [];
|
|
696
|
+
for (const t of filtered) {
|
|
697
|
+
const key = t.server.meta?.hostId ?? t.server.meta?.host ?? t.server.peerId;
|
|
698
|
+
let group = hostGroups.find((g) => g.key === key);
|
|
699
|
+
if (!group) {
|
|
700
|
+
group = { key, label: t.server.meta?.host ?? t.name, items: [], agents: [] };
|
|
701
|
+
hostGroups.push(group);
|
|
702
|
+
}
|
|
703
|
+
group.items.push(t);
|
|
704
|
+
for (const a of t.server.meta?.agents ?? []) {
|
|
705
|
+
if (!group.agents.some((x) => x.pid === a.pid)) group.agents.push(a);
|
|
706
|
+
}
|
|
707
|
+
}
|
|
667
708
|
const toggleTag = (t: string) =>
|
|
668
709
|
setActiveTags((a) => (a.includes(t) ? a.filter((x) => x !== t) : [...a, t]));
|
|
669
710
|
const addTag = (t: string) => setActiveTags((a) => (a.includes(t) ? a : [...a, t]));
|
|
@@ -905,42 +946,85 @@ export function Discovery() {
|
|
|
905
946
|
)}
|
|
906
947
|
</>
|
|
907
948
|
)}
|
|
908
|
-
<
|
|
909
|
-
{
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
<
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
{
|
|
927
|
-
|
|
928
|
-
|
|
949
|
+
<div>
|
|
950
|
+
{hostGroups.map((g) => (
|
|
951
|
+
<section key={g.key}>
|
|
952
|
+
<div style={styles.hostHead}>
|
|
953
|
+
<span style={styles.hostName}>{g.label}</span>
|
|
954
|
+
<span style={styles.count}>
|
|
955
|
+
{g.items.length} workspace{g.items.length === 1 ? "" : "s"}
|
|
956
|
+
{g.agents.length > 0 && ` · ${g.agents.length} agent${g.agents.length === 1 ? "" : "s"}`}
|
|
957
|
+
</span>
|
|
958
|
+
</div>
|
|
959
|
+
{g.agents.length > 0 && (
|
|
960
|
+
<div style={styles.agentRow}>
|
|
961
|
+
{g.agents.map((a) => (
|
|
962
|
+
<span
|
|
963
|
+
key={a.pid}
|
|
964
|
+
style={styles.agentChip}
|
|
965
|
+
title={`${a.cwd}${a.title ? `\n${a.title}` : ""}`}
|
|
966
|
+
>
|
|
967
|
+
<span style={{ color: a.state === "active" ? "#4ec9b0" : "#777" }}>●</span> {a.tool}{" "}
|
|
968
|
+
{a.pid}
|
|
969
|
+
</span>
|
|
970
|
+
))}
|
|
929
971
|
</div>
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
972
|
+
)}
|
|
973
|
+
<ul style={styles.list}>
|
|
974
|
+
{g.items.map(({ server: s, room, name, tags }) => {
|
|
975
|
+
const isActive = s.peerId === activePeerId;
|
|
976
|
+
return (
|
|
977
|
+
<li key={s.peerId} style={styles.card}>
|
|
978
|
+
<div style={styles.cardMain}>
|
|
979
|
+
<div style={styles.cardName}>{name}</div>
|
|
980
|
+
<div style={styles.tagRow}>
|
|
981
|
+
{tags.map((tag) => (
|
|
982
|
+
<button key={tag} style={styles.tag} onClick={() => addTag(tag)} title={`filter by ${tag}`}>
|
|
983
|
+
{tag}
|
|
984
|
+
</button>
|
|
985
|
+
))}
|
|
986
|
+
</div>
|
|
987
|
+
{(s.meta?.workspaces?.length ?? 0) > 0 && (
|
|
988
|
+
<div style={styles.wsRow}>
|
|
989
|
+
{s.meta!.workspaces!.map((w) => (
|
|
990
|
+
<button
|
|
991
|
+
key={w.path}
|
|
992
|
+
style={styles.wsLink}
|
|
993
|
+
onClick={() => openWorkspace(s, w)}
|
|
994
|
+
title={w.path}
|
|
995
|
+
>
|
|
996
|
+
{w.repo
|
|
997
|
+
? `${w.repo.split("/").slice(1).join("/")}${w.branch ? ` @${w.branch}` : ""}`
|
|
998
|
+
: w.path}
|
|
999
|
+
</button>
|
|
1000
|
+
))}
|
|
1001
|
+
</div>
|
|
1002
|
+
)}
|
|
1003
|
+
<div style={styles.idLine}>peer {s.peerId.slice(0, 8)}</div>
|
|
1004
|
+
{isActive && (
|
|
1005
|
+
<div style={styles.echo}>
|
|
1006
|
+
{connState === "connecting" && "negotiating WebRTC…"}
|
|
1007
|
+
{connState === "failed" && "connection failed"}
|
|
1008
|
+
</div>
|
|
1009
|
+
)}
|
|
1010
|
+
</div>
|
|
1011
|
+
<button
|
|
1012
|
+
style={styles.connectBtn}
|
|
1013
|
+
onClick={() => connectTo(s, room)}
|
|
1014
|
+
disabled={isActive && connState === "connecting"}
|
|
1015
|
+
>
|
|
1016
|
+
{isActive && connState === "connecting" ? "…" : "Connect"}
|
|
1017
|
+
</button>
|
|
1018
|
+
</li>
|
|
1019
|
+
);
|
|
1020
|
+
})}
|
|
1021
|
+
</ul>
|
|
1022
|
+
</section>
|
|
1023
|
+
))}
|
|
940
1024
|
{serverCount > 0 && filtered.length === 0 && (
|
|
941
1025
|
<p style={styles.dim}>No workspace matches your filter.</p>
|
|
942
1026
|
)}
|
|
943
|
-
</
|
|
1027
|
+
</div>
|
|
944
1028
|
</main>
|
|
945
1029
|
</div>
|
|
946
1030
|
</>
|
|
@@ -985,8 +1069,20 @@ const styles: Record<string, React.CSSProperties> = {
|
|
|
985
1069
|
border: "1px solid #3d3d3d", background: "transparent", color: "#9aa4af", cursor: "pointer",
|
|
986
1070
|
},
|
|
987
1071
|
idLine: { fontFamily: "monospace", fontSize: 11, color: "#666", marginTop: 6 },
|
|
1072
|
+
wsRow: { display: "flex", flexDirection: "column", alignItems: "flex-start", gap: 3, marginTop: 8 },
|
|
1073
|
+
wsLink: {
|
|
1074
|
+
fontFamily: "monospace", fontSize: 12, padding: "2px 0", border: "none", background: "transparent",
|
|
1075
|
+
color: "#75beff", cursor: "pointer", textAlign: "left",
|
|
1076
|
+
},
|
|
988
1077
|
code: { background: "#252525", padding: "2px 6px", borderRadius: 4, fontFamily: "monospace", fontSize: 12 },
|
|
989
|
-
list: { listStyle: "none", margin: 0, padding: 0, display: "flex", flexDirection: "column", gap: 8 },
|
|
1078
|
+
list: { listStyle: "none", margin: "0 0 14px", padding: 0, display: "flex", flexDirection: "column", gap: 8 },
|
|
1079
|
+
hostHead: { display: "flex", alignItems: "baseline", gap: 10, margin: "0 0 8px" },
|
|
1080
|
+
hostName: { fontSize: 13, fontWeight: 600, color: "#dcdcaa", fontFamily: "monospace" },
|
|
1081
|
+
agentRow: { display: "flex", flexWrap: "wrap", gap: 6, margin: "0 0 8px" },
|
|
1082
|
+
agentChip: {
|
|
1083
|
+
fontFamily: "monospace", fontSize: 11.5, padding: "2px 8px", borderRadius: 999,
|
|
1084
|
+
border: "1px solid #3d3d3d", color: "#9aa4af",
|
|
1085
|
+
},
|
|
990
1086
|
card: { display: "flex", alignItems: "center", gap: 12, background: "#252525", border: "1px solid #3d3d3d", borderRadius: 8, padding: "12px 14px" },
|
|
991
1087
|
cardMain: { flex: 1, minWidth: 0 },
|
|
992
1088
|
cardName: { fontSize: 14, fontWeight: 600, color: "#fff" },
|
package/src/web/history.ts
CHANGED
|
@@ -7,11 +7,14 @@ const HISTORY_KEY = "codehost.history";
|
|
|
7
7
|
|
|
8
8
|
export interface HistoryEntry {
|
|
9
9
|
token: string;
|
|
10
|
-
peerId
|
|
10
|
+
/** Stable machine id of the server that opened it — unlike a peerId it
|
|
11
|
+
* survives daemon restarts, so reconnect prefers the same machine. */
|
|
12
|
+
hostId?: string;
|
|
11
13
|
kind?: "repo" | "root";
|
|
12
14
|
/** For root-kind opens, the ?folder= path used. */
|
|
13
15
|
folder?: string;
|
|
14
16
|
name?: string;
|
|
17
|
+
/** Hostname — display + match fallback for pre-hostId daemons/entries. */
|
|
15
18
|
host?: string;
|
|
16
19
|
lastConnected: number;
|
|
17
20
|
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { SignalingClient } from "../shared/signaling-client";
|
|
2
|
+
import type { PeerInfo } from "../shared/signaling";
|
|
3
|
+
import type { RtcSignal } from "../shared/rtc";
|
|
4
|
+
import { RtcClient } from "./rtc-client";
|
|
5
|
+
import { TunnelClient } from "./tunnel-client";
|
|
6
|
+
|
|
7
|
+
// Embeddable codehost room client for OTHER sites (agent-yes.com first): join a
|
|
8
|
+
// room as a viewer, watch the peer list (hosts advertise workspaces + agents in
|
|
9
|
+
// PeerMeta), and speak HTTP to any server peer over a lazily-dialed WebRTC
|
|
10
|
+
// tunnel. No Service Worker, no React, no cross-tab broker — one module that
|
|
11
|
+
// `bun build --target browser` bundles standalone (see scripts.build:lib).
|
|
12
|
+
|
|
13
|
+
export type { AgentInfo, PeerInfo, PeerMeta, WorkspaceInfo } from "../shared/signaling";
|
|
14
|
+
|
|
15
|
+
export const DEFAULT_SIGNAL_URL = "wss://signal.codehost.dev";
|
|
16
|
+
|
|
17
|
+
export interface RoomOptions {
|
|
18
|
+
/** Room token (bearer secret — same one `codehost serve -t` uses). */
|
|
19
|
+
token: string;
|
|
20
|
+
/** Signaling server (default wss://signal.codehost.dev). */
|
|
21
|
+
signalUrl?: string;
|
|
22
|
+
/** Live server-peer list, fired on every room membership/meta change. */
|
|
23
|
+
onPeers?: (peers: PeerInfo[]) => void;
|
|
24
|
+
/** Signaling socket state. */
|
|
25
|
+
onStatus?: (open: boolean) => void;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export class CodehostRoom {
|
|
29
|
+
/** Server peers currently in the room (viewers filtered out). */
|
|
30
|
+
peers: PeerInfo[] = [];
|
|
31
|
+
private signaling: SignalingClient;
|
|
32
|
+
private rtcs = new Map<string, RtcClient>();
|
|
33
|
+
private tunnels = new Map<string, Promise<TunnelClient>>();
|
|
34
|
+
private closed = false;
|
|
35
|
+
|
|
36
|
+
constructor(opts: RoomOptions) {
|
|
37
|
+
this.signaling = new SignalingClient({
|
|
38
|
+
url: opts.signalUrl ?? DEFAULT_SIGNAL_URL,
|
|
39
|
+
token: opts.token,
|
|
40
|
+
role: "viewer",
|
|
41
|
+
onOpen: () => opts.onStatus?.(true),
|
|
42
|
+
onClose: () => opts.onStatus?.(false),
|
|
43
|
+
onPeers: (peers) => {
|
|
44
|
+
this.peers = peers.filter((p) => p.role === "server");
|
|
45
|
+
opts.onPeers?.(this.peers);
|
|
46
|
+
},
|
|
47
|
+
onSignal: (from, data) => void this.rtcs.get(from)?.handleSignal(data),
|
|
48
|
+
});
|
|
49
|
+
this.signaling.connect();
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** HTTP over the peer's tunnel (dialed on first use, reused after). The
|
|
53
|
+
* response streams — long-lived SSE bodies work. */
|
|
54
|
+
async fetch(
|
|
55
|
+
peerId: string,
|
|
56
|
+
method: string,
|
|
57
|
+
path: string,
|
|
58
|
+
init: { headers?: Record<string, string>; body?: Uint8Array | string } = {},
|
|
59
|
+
): Promise<Response> {
|
|
60
|
+
const tunnel = await this.dial(peerId);
|
|
61
|
+
const body = typeof init.body === "string" ? new TextEncoder().encode(init.body) : init.body;
|
|
62
|
+
return tunnel.fetch(method, path, init.headers ?? {}, body);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
private dial(peerId: string): Promise<TunnelClient> {
|
|
66
|
+
const existing = this.tunnels.get(peerId);
|
|
67
|
+
if (existing) return existing;
|
|
68
|
+
const drop = () => {
|
|
69
|
+
this.tunnels.delete(peerId);
|
|
70
|
+
this.rtcs.get(peerId)?.close();
|
|
71
|
+
this.rtcs.delete(peerId);
|
|
72
|
+
};
|
|
73
|
+
const dialing = new Promise<TunnelClient>((resolve, reject) => {
|
|
74
|
+
const timer = setTimeout(() => {
|
|
75
|
+
drop();
|
|
76
|
+
reject(new Error("dial timed out"));
|
|
77
|
+
}, 15000);
|
|
78
|
+
const rtc = new RtcClient({
|
|
79
|
+
sendSignal: (data: RtcSignal) => this.signaling.sendSignal(peerId, data),
|
|
80
|
+
onOpen: (channel) => {
|
|
81
|
+
clearTimeout(timer);
|
|
82
|
+
resolve(new TunnelClient(channel));
|
|
83
|
+
},
|
|
84
|
+
onClose: drop,
|
|
85
|
+
onState: (state) => {
|
|
86
|
+
if (state === "failed" || state === "disconnected") drop();
|
|
87
|
+
},
|
|
88
|
+
});
|
|
89
|
+
this.rtcs.set(peerId, rtc);
|
|
90
|
+
rtc.start().catch((err) => {
|
|
91
|
+
clearTimeout(timer);
|
|
92
|
+
drop();
|
|
93
|
+
reject(err);
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
this.tunnels.set(peerId, dialing);
|
|
97
|
+
dialing.catch(() => this.tunnels.delete(peerId));
|
|
98
|
+
return dialing;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
close(): void {
|
|
102
|
+
if (this.closed) return;
|
|
103
|
+
this.closed = true;
|
|
104
|
+
for (const rtc of this.rtcs.values()) rtc.close();
|
|
105
|
+
this.rtcs.clear();
|
|
106
|
+
this.tunnels.clear();
|
|
107
|
+
this.signaling.close();
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Join a codehost room as a viewer. */
|
|
112
|
+
export function joinRoom(opts: RoomOptions): CodehostRoom {
|
|
113
|
+
return new CodehostRoom(opts);
|
|
114
|
+
}
|
package/worker/room.ts
CHANGED
|
@@ -71,6 +71,15 @@ export class Room implements DurableObject {
|
|
|
71
71
|
return;
|
|
72
72
|
}
|
|
73
73
|
|
|
74
|
+
if (msg.type === "meta") {
|
|
75
|
+
const att = this.touch(ws);
|
|
76
|
+
if (!att) return; // never said hello
|
|
77
|
+
att.meta = msg.meta ?? null;
|
|
78
|
+
ws.serializeAttachment(att);
|
|
79
|
+
this.broadcastPeers();
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
|
|
74
83
|
if (msg.type === "signal") {
|
|
75
84
|
const att = this.touch(ws);
|
|
76
85
|
if (!att) return;
|