codehost 0.15.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 +27 -0
- package/package.json +3 -1
- package/src/cli/commands/dev.ts +32 -1
- package/src/cli/commands/expose.ts +2 -0
- package/src/cli/commands/init.ts +27 -0
- package/src/cli/commands/serve.ts +56 -11
- 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/index.ts +2 -0
- package/src/cli/init.test.ts +48 -0
- package/src/cli/init.ts +103 -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.test.ts +76 -0
- package/src/cli/provision-server.ts +186 -0
- package/src/cli/registry.test.ts +51 -0
- package/src/cli/registry.ts +93 -0
- package/src/cli/run-server.ts +62 -2
- package/src/cli/tunnel.ts +25 -7
- 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/provision.test.ts +79 -0
- package/src/shared/provision.ts +67 -0
- package/src/shared/repo.test.ts +110 -1
- package/src/shared/repo.ts +77 -19
- package/src/shared/signaling-client.ts +10 -0
- package/src/shared/signaling.ts +55 -1
- package/src/web/discovery.tsx +226 -44
- package/src/web/history.ts +4 -1
- package/src/web/room-client.ts +114 -0
- package/worker/room.ts +9 -0
|
@@ -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";
|
|
@@ -10,6 +10,8 @@ import { connBroker } from "./conn-broker";
|
|
|
10
10
|
import {
|
|
11
11
|
DEFAULT_BRANCH,
|
|
12
12
|
type DeepLink,
|
|
13
|
+
type RepoTarget,
|
|
14
|
+
type ResolvePrefs,
|
|
13
15
|
type RoomMatch,
|
|
14
16
|
gitUrlToPath,
|
|
15
17
|
parseDeepLink,
|
|
@@ -24,7 +26,7 @@ import { deriveTags, matchQuery, shortRoomLabel, tagKey } from "../shared/tags";
|
|
|
24
26
|
|
|
25
27
|
const TOKEN_KEY = "codehost.token";
|
|
26
28
|
|
|
27
|
-
type ConnState = "idle" | "connecting" | "connected" | "failed";
|
|
29
|
+
type ConnState = "idle" | "connecting" | "provisioning" | "connected" | "failed";
|
|
28
30
|
|
|
29
31
|
/** A server discovered in a specific room (its token routes the signaling). */
|
|
30
32
|
type RoomedServer = { server: PeerInfo; room: string };
|
|
@@ -90,7 +92,7 @@ function findRoomForDeepLink(dl: DeepLink, tokens: string[], timeoutMs = 6000):
|
|
|
90
92
|
const res =
|
|
91
93
|
dl.type === "repo" ? resolveRepoTarget(servers, dl.target) : resolveDevTarget(servers, dl.target);
|
|
92
94
|
if (!res) return;
|
|
93
|
-
if (!res.folder) finish(tok); // exact match — take it now
|
|
95
|
+
if (!res.folder || res.exact) finish(tok); // exact match — take it now
|
|
94
96
|
else if (!fallbacks.some((f) => f.token === tok)) fallbacks.push({ token: tok, resolution: res });
|
|
95
97
|
},
|
|
96
98
|
});
|
|
@@ -180,6 +182,8 @@ export function Discovery() {
|
|
|
180
182
|
const [activePeerId, setActivePeerId] = useState<string | null>(null);
|
|
181
183
|
const [connState, setConnState] = useState<ConnState>("idle");
|
|
182
184
|
const [iframeSrc, setIframeSrc] = useState<string | null>(null);
|
|
185
|
+
// Streamed setup.sh output shown while connState === "provisioning".
|
|
186
|
+
const [provisionLog, setProvisionLog] = useState("");
|
|
183
187
|
|
|
184
188
|
const rtcRef = useRef<RtcClient | null>(null);
|
|
185
189
|
const activePeerRef = useRef<string | null>(null);
|
|
@@ -351,7 +355,13 @@ export function Discovery() {
|
|
|
351
355
|
sendersRef.current.delete(t);
|
|
352
356
|
}
|
|
353
357
|
|
|
354
|
-
async function connectTo(
|
|
358
|
+
async function connectTo(
|
|
359
|
+
server: PeerInfo,
|
|
360
|
+
room: string,
|
|
361
|
+
folder?: string,
|
|
362
|
+
fromHistory = false,
|
|
363
|
+
repoTarget?: RepoTarget,
|
|
364
|
+
) {
|
|
355
365
|
const send = sendersRef.current.get(room);
|
|
356
366
|
if (!send) return;
|
|
357
367
|
dialingRef.current = true; // synchronous gate against concurrent triggers
|
|
@@ -374,7 +384,7 @@ export function Discovery() {
|
|
|
374
384
|
// handshake) and push a history entry, so Back returns to the list and
|
|
375
385
|
// Forward returns here. When `fromHistory`, the browser already set the URL
|
|
376
386
|
// (back/forward/reconnect) — don't push again, but a prior entry exists.
|
|
377
|
-
|
|
387
|
+
let openFolder = folder ?? server.meta?.cwd;
|
|
378
388
|
if (fromHistory) {
|
|
379
389
|
pushedRef.current = true;
|
|
380
390
|
sharePathRef.current = window.location.pathname;
|
|
@@ -425,10 +435,23 @@ export function Discovery() {
|
|
|
425
435
|
});
|
|
426
436
|
|
|
427
437
|
await connBroker.connect(server.peerId, establish);
|
|
438
|
+
|
|
439
|
+
// For a repo deep link, ask the daemon to provision (run .codehost/setup.sh
|
|
440
|
+
// and hand back the authoritative workspace path) before opening. Streams
|
|
441
|
+
// the log under the "provisioning" state. Daemons without the route (older
|
|
442
|
+
// builds) return no path → fall back to the browser-computed folder.
|
|
443
|
+
if (repoTarget) {
|
|
444
|
+
setConnState("provisioning");
|
|
445
|
+
setProvisionLog("");
|
|
446
|
+
const ws = await runProvision(server.peerId, repoTarget);
|
|
447
|
+
if (activePeerRef.current !== server.peerId) return; // cancelled/switched mid-provision
|
|
448
|
+
if (ws) openFolder = ws;
|
|
449
|
+
}
|
|
450
|
+
|
|
428
451
|
setConnState("connected");
|
|
429
452
|
// The daemon no longer sets a default folder (current VS Code serve-web
|
|
430
453
|
// dropped that flag), so open the served workspace from here: the
|
|
431
|
-
// deep-link folder if we have one, else the server's reported cwd.
|
|
454
|
+
// provisioned/deep-link folder if we have one, else the server's reported cwd.
|
|
432
455
|
activeFolderRef.current = openFolder;
|
|
433
456
|
setIframeSrc(`/vs/${server.peerId}/${folderQuery(openFolder)}`);
|
|
434
457
|
setResolving(null);
|
|
@@ -446,6 +469,48 @@ export function Discovery() {
|
|
|
446
469
|
}
|
|
447
470
|
}
|
|
448
471
|
|
|
472
|
+
// Ask the daemon to provision a repo workspace over the tunnel: stream
|
|
473
|
+
// setup.sh's output into `provisionLog` and return the daemon-authoritative
|
|
474
|
+
// path (the `x-codehost-workspace` header). Returns null when the daemon has
|
|
475
|
+
// no provision route (older build) or the call fails — caller falls back.
|
|
476
|
+
async function runProvision(peerId: string, t: RepoTarget): Promise<string | null> {
|
|
477
|
+
const params = new URLSearchParams({
|
|
478
|
+
owner: t.owner,
|
|
479
|
+
repo: t.name,
|
|
480
|
+
branch: t.branch ?? DEFAULT_BRANCH,
|
|
481
|
+
host: t.host,
|
|
482
|
+
});
|
|
483
|
+
let res: Response;
|
|
484
|
+
try {
|
|
485
|
+
res = await connBroker.tunnelFor(peerId).fetch("GET", `/__codehost/provision?${params}`, {});
|
|
486
|
+
} catch {
|
|
487
|
+
return null;
|
|
488
|
+
}
|
|
489
|
+
const ws = res.headers.get("x-codehost-workspace");
|
|
490
|
+
if (!ws) {
|
|
491
|
+
await res.body?.cancel().catch(() => {});
|
|
492
|
+
return null;
|
|
493
|
+
}
|
|
494
|
+
let buf = `[codehost] provisioning ${t.owner}/${t.name}@${t.branch ?? DEFAULT_BRANCH}…\n`;
|
|
495
|
+
setProvisionLog(buf);
|
|
496
|
+
if (res.body) {
|
|
497
|
+
const reader = res.body.getReader();
|
|
498
|
+
const dec = new TextDecoder();
|
|
499
|
+
try {
|
|
500
|
+
for (;;) {
|
|
501
|
+
const { done, value } = await reader.read();
|
|
502
|
+
if (done) break;
|
|
503
|
+
buf += dec.decode(value, { stream: true });
|
|
504
|
+
// Hide the internal exit sentinel from the displayed log.
|
|
505
|
+
setProvisionLog(buf.replace(/\n::codehost:exit=\d+\n?/, "\n"));
|
|
506
|
+
}
|
|
507
|
+
} catch {
|
|
508
|
+
// stream interrupted (channel closed) — return the path anyway
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
return ws;
|
|
512
|
+
}
|
|
513
|
+
|
|
449
514
|
// Shareable deep-link pathname for a server+folder, with no side effects (no
|
|
450
515
|
// token — Share adds that). Keeps an existing deep-link path as-is; otherwise
|
|
451
516
|
// derives /gh|/git|/dev from the server's repo identity or opened folder.
|
|
@@ -481,6 +546,14 @@ export function Discovery() {
|
|
|
481
546
|
setTimeout(() => setCopied(false), 1500);
|
|
482
547
|
}
|
|
483
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
|
+
|
|
484
557
|
// Deep-link auto-connect: when servers arrive, pick the best match (exact repo
|
|
485
558
|
// daemon, else a root daemon's subfolder) across all rooms and open it once.
|
|
486
559
|
function tryAutoConnect() {
|
|
@@ -488,12 +561,13 @@ export function Discovery() {
|
|
|
488
561
|
const dl = deepLinkRef.current;
|
|
489
562
|
if (dl) {
|
|
490
563
|
const peers = allServers.map((x) => x.server);
|
|
491
|
-
const res =
|
|
564
|
+
const res =
|
|
565
|
+
dl.type === "repo" ? resolveRepoTarget(peers, dl.target, preferFor(dl)) : resolveDevTarget(peers, dl.target);
|
|
492
566
|
if (!res) return;
|
|
493
567
|
const match = allServers.find((x) => x.server.peerId === res.peerId);
|
|
494
568
|
if (!match) return;
|
|
495
569
|
resolvedRef.current = true;
|
|
496
|
-
void connectTo(match.server, match.room, res.folder);
|
|
570
|
+
void connectTo(match.server, match.room, res.folder, false, dl.type === "repo" ? dl.target : undefined);
|
|
497
571
|
return;
|
|
498
572
|
}
|
|
499
573
|
// No deep link, but a token arrived via the URL: open that room's server
|
|
@@ -512,7 +586,7 @@ export function Discovery() {
|
|
|
512
586
|
function recordConnect(server: PeerInfo, room: string, folder?: string) {
|
|
513
587
|
const base = {
|
|
514
588
|
token: room,
|
|
515
|
-
|
|
589
|
+
hostId: server.meta?.hostId,
|
|
516
590
|
kind: server.meta?.kind,
|
|
517
591
|
name: server.meta?.name,
|
|
518
592
|
host: server.meta?.host,
|
|
@@ -543,7 +617,8 @@ export function Discovery() {
|
|
|
543
617
|
function findServerForDeepLink(dl: DeepLink): (RoomedServer & { folder?: string }) | null {
|
|
544
618
|
if (!dl) return null;
|
|
545
619
|
const peers = allServersRef.current.map((x) => x.server);
|
|
546
|
-
const res =
|
|
620
|
+
const res =
|
|
621
|
+
dl.type === "repo" ? resolveRepoTarget(peers, dl.target, preferFor(dl)) : resolveDevTarget(peers, dl.target);
|
|
547
622
|
if (!res) return null;
|
|
548
623
|
const match = allServersRef.current.find((x) => x.server.peerId === res.peerId);
|
|
549
624
|
return match ? { ...match, folder: res.folder } : null;
|
|
@@ -563,7 +638,20 @@ export function Discovery() {
|
|
|
563
638
|
const target = findServerForDeepLink(dl);
|
|
564
639
|
if (!target) return; // its server isn't present (yet) — wait for it to appear
|
|
565
640
|
if (activePeerRef.current === target.server.peerId && connStateRef.current === "connected") return;
|
|
566
|
-
void connectTo(target.server, target.room, target.folder, true);
|
|
641
|
+
void connectTo(target.server, target.room, target.folder, true, dl.type === "repo" ? dl.target : undefined);
|
|
642
|
+
}
|
|
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();
|
|
567
655
|
}
|
|
568
656
|
|
|
569
657
|
function disconnect() {
|
|
@@ -600,6 +688,23 @@ export function Discovery() {
|
|
|
600
688
|
}));
|
|
601
689
|
const query = [...activeTags, filter].join(" ");
|
|
602
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
|
+
}
|
|
603
708
|
const toggleTag = (t: string) =>
|
|
604
709
|
setActiveTags((a) => (a.includes(t) ? a.filter((x) => x !== t) : [...a, t]));
|
|
605
710
|
const addTag = (t: string) => setActiveTags((a) => (a.includes(t) ? a : [...a, t]));
|
|
@@ -630,6 +735,27 @@ export function Discovery() {
|
|
|
630
735
|
/>
|
|
631
736
|
));
|
|
632
737
|
|
|
738
|
+
// Provisioning view: the daemon's setup.sh is running; stream its log.
|
|
739
|
+
if (connState === "provisioning") {
|
|
740
|
+
return (
|
|
741
|
+
<>
|
|
742
|
+
{roomClients}
|
|
743
|
+
<div style={styles.page}>
|
|
744
|
+
<header style={styles.header}>
|
|
745
|
+
<span style={styles.brand}>codehost</span>
|
|
746
|
+
<span style={styles.dim}>·</span>
|
|
747
|
+
<span style={styles.dim}>provisioning…</span>
|
|
748
|
+
<span style={{ flex: 1 }} />
|
|
749
|
+
<button style={styles.connectBtn} onClick={disconnect}>
|
|
750
|
+
Cancel
|
|
751
|
+
</button>
|
|
752
|
+
</header>
|
|
753
|
+
<pre style={styles.provLog}>{provisionLog || "starting…"}</pre>
|
|
754
|
+
</div>
|
|
755
|
+
</>
|
|
756
|
+
);
|
|
757
|
+
}
|
|
758
|
+
|
|
633
759
|
// Connected view: VS Code in an iframe, served over the tunnel.
|
|
634
760
|
if (iframeSrc && connState === "connected") {
|
|
635
761
|
return (
|
|
@@ -820,42 +946,85 @@ export function Discovery() {
|
|
|
820
946
|
)}
|
|
821
947
|
</>
|
|
822
948
|
)}
|
|
823
|
-
<
|
|
824
|
-
{
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
<
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
{
|
|
842
|
-
|
|
843
|
-
|
|
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
|
+
))}
|
|
844
971
|
</div>
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
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
|
+
))}
|
|
855
1024
|
{serverCount > 0 && filtered.length === 0 && (
|
|
856
1025
|
<p style={styles.dim}>No workspace matches your filter.</p>
|
|
857
1026
|
)}
|
|
858
|
-
</
|
|
1027
|
+
</div>
|
|
859
1028
|
</main>
|
|
860
1029
|
</div>
|
|
861
1030
|
</>
|
|
@@ -900,8 +1069,20 @@ const styles: Record<string, React.CSSProperties> = {
|
|
|
900
1069
|
border: "1px solid #3d3d3d", background: "transparent", color: "#9aa4af", cursor: "pointer",
|
|
901
1070
|
},
|
|
902
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
|
+
},
|
|
903
1077
|
code: { background: "#252525", padding: "2px 6px", borderRadius: 4, fontFamily: "monospace", fontSize: 12 },
|
|
904
|
-
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
|
+
},
|
|
905
1086
|
card: { display: "flex", alignItems: "center", gap: 12, background: "#252525", border: "1px solid #3d3d3d", borderRadius: 8, padding: "12px 14px" },
|
|
906
1087
|
cardMain: { flex: 1, minWidth: 0 },
|
|
907
1088
|
cardName: { fontSize: 14, fontWeight: 600, color: "#fff" },
|
|
@@ -910,4 +1091,5 @@ const styles: Record<string, React.CSSProperties> = {
|
|
|
910
1091
|
echo: { marginTop: 6, fontSize: 12, color: "#4ec9b0", fontFamily: "monospace" },
|
|
911
1092
|
connectBtn: { background: "#0e639c", border: "none", color: "#fff", padding: "6px 14px", borderRadius: 6, cursor: "pointer", fontSize: 13 },
|
|
912
1093
|
shareBtn: { background: "transparent", border: "1px solid #3d3d3d", color: "#ccc", padding: "6px 14px", borderRadius: 6, cursor: "pointer", fontSize: 13 },
|
|
1094
|
+
provLog: { flex: 1, margin: 0, padding: "14px 18px", overflow: "auto", background: "#1e1e1e", color: "#ccc", fontFamily: "monospace", fontSize: 12.5, lineHeight: 1.5, whiteSpace: "pre-wrap" },
|
|
913
1095
|
};
|
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;
|