codehost 0.16.0 → 0.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,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 = dl.type === "repo" ? resolveRepoTarget(peers, dl.target) : resolveDevTarget(peers, dl.target);
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
- peerId: server.peerId,
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 = dl.type === "repo" ? resolveRepoTarget(peers, dl.target) : resolveDevTarget(peers, dl.target);
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
- <ul style={styles.list}>
909
- {filtered.map(({ server: s, room, name, tags }) => {
910
- const isActive = s.peerId === activePeerId;
911
- return (
912
- <li key={s.peerId} style={styles.card}>
913
- <div style={styles.cardMain}>
914
- <div style={styles.cardName}>{name}</div>
915
- <div style={styles.tagRow}>
916
- {tags.map((tag) => (
917
- <button key={tag} style={styles.tag} onClick={() => addTag(tag)} title={`filter by ${tag}`}>
918
- {tag}
919
- </button>
920
- ))}
921
- </div>
922
- <div style={styles.idLine}>peer {s.peerId.slice(0, 8)}</div>
923
- {isActive && (
924
- <div style={styles.echo}>
925
- {connState === "connecting" && "negotiating WebRTC…"}
926
- {connState === "failed" && "connection failed"}
927
- </div>
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
- <button
931
- style={styles.connectBtn}
932
- onClick={() => connectTo(s, room)}
933
- disabled={isActive && connState === "connecting"}
934
- >
935
- {isActive && connState === "connecting" ? "…" : "Connect"}
936
- </button>
937
- </li>
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
- </ul>
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" },
@@ -7,11 +7,14 @@ const HISTORY_KEY = "codehost.history";
7
7
 
8
8
  export interface HistoryEntry {
9
9
  token: string;
10
- peerId?: string;
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;