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.
@@ -0,0 +1,80 @@
1
+ import { existsSync, readdirSync, statSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { GITHUB_HOST, toPosixPath } from "../shared/repo";
4
+ import type { WorkspaceInfo } from "../shared/signaling";
5
+
6
+ // Enumerate the checkouts that exist under a root daemon's home by walking the
7
+ // workspace layout template (e.g. "{owner}/{repo}/tree/{branch}") one segment
8
+ // at a time: a literal segment must exist, a placeholder segment matches one
9
+ // directory level. A leaf only counts as a workspace when it holds a `.git`
10
+ // (directory, or file for a worktree). Feeds PeerMeta.workspaces.
11
+
12
+ /** Cap the advertised list (meta rides the signaling room broadcast). */
13
+ const MAX_WORKSPACES = 200;
14
+ /** Cap the intermediate walk so a huge home dir can't blow up enumeration. */
15
+ const MAX_FRONTIER = 1000;
16
+
17
+ export function enumerateWorkspaces(rootDir: string, layout: string, host = GITHUB_HOST): WorkspaceInfo[] {
18
+ let frontier = [{ dir: rootDir, vars: {} as Record<string, string> }];
19
+ for (const seg of layout.split("/").filter(Boolean)) {
20
+ const names = [...seg.matchAll(/\{(\w+)\}/g)].map((m) => m[1]);
21
+ const next: typeof frontier = [];
22
+ for (const f of frontier) {
23
+ if (next.length >= MAX_FRONTIER) break;
24
+ if (names.length === 0) {
25
+ const dir = join(f.dir, seg);
26
+ if (isDir(dir)) next.push({ dir, vars: f.vars });
27
+ continue;
28
+ }
29
+ const re = segmentPattern(seg);
30
+ for (const name of listDirs(f.dir)) {
31
+ if (next.length >= MAX_FRONTIER) break;
32
+ if (name.startsWith(".")) continue;
33
+ const m = re.exec(name);
34
+ if (!m) continue;
35
+ const vars = { ...f.vars };
36
+ names.forEach((n, i) => (vars[n] = m[i + 1]));
37
+ next.push({ dir: join(f.dir, name), vars });
38
+ }
39
+ }
40
+ frontier = next;
41
+ if (frontier.length === 0) return [];
42
+ }
43
+
44
+ const out: WorkspaceInfo[] = [];
45
+ for (const f of frontier) {
46
+ if (out.length >= MAX_WORKSPACES) break;
47
+ if (!existsSync(join(f.dir, ".git"))) continue;
48
+ const { owner, repo, branch } = f.vars;
49
+ out.push({
50
+ path: toPosixPath(f.dir),
51
+ ...(owner && repo ? { repo: `${host}/${owner}/${repo}` } : {}),
52
+ ...(branch ? { branch } : {}),
53
+ });
54
+ }
55
+ return out;
56
+ }
57
+
58
+ /** A layout segment as a matcher: literals exact, `{name}`s capture. */
59
+ function segmentPattern(seg: string): RegExp {
60
+ const escaped = seg.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
61
+ return new RegExp(`^${escaped.replace(/\\\{\w+\\\}/g, "([^/]+)")}$`);
62
+ }
63
+
64
+ function isDir(p: string): boolean {
65
+ try {
66
+ return statSync(p).isDirectory();
67
+ } catch {
68
+ return false;
69
+ }
70
+ }
71
+
72
+ function listDirs(p: string): string[] {
73
+ try {
74
+ return readdirSync(p, { withFileTypes: true })
75
+ .filter((e) => e.isDirectory() || e.isSymbolicLink())
76
+ .map((e) => e.name);
77
+ } catch {
78
+ return [];
79
+ }
80
+ }
@@ -1,6 +1,6 @@
1
1
  import { describe, expect, test } from "bun:test";
2
2
  import { gitUrlToPath, parseDeepLink, pickRoomMatch, repoKey, resolveDevTarget, resolveRepoTarget, shareableDeepLink, toPosixPath } from "./repo";
3
- import type { PeerInfo } from "./signaling";
3
+ import type { PeerInfo, WorkspaceInfo } from "./signaling";
4
4
  import { parseGitRemote } from "../cli/git";
5
5
 
6
6
  describe("toPosixPath", () => {
@@ -217,6 +217,115 @@ describe("resolveRepoTarget root selection", () => {
217
217
  });
218
218
  });
219
219
 
220
+ describe("resolveDevTarget advertised workspaces", () => {
221
+ const root: PeerInfo = {
222
+ peerId: "root1",
223
+ role: "server",
224
+ meta: {
225
+ name: "mac",
226
+ host: "mbp",
227
+ cwd: "/Users/sno",
228
+ kind: "root",
229
+ workspaces: [{ path: "/Users/sno/scratch/tool" }],
230
+ },
231
+ };
232
+
233
+ test("a /host/<host>/<path> link matches a registered workspace on a root daemon", () => {
234
+ const res = resolveDevTarget([root], { host: "mbp", path: "/Users/sno/scratch/tool" });
235
+ expect(res?.peerId).toBe("root1");
236
+ expect(res?.folder).toBe("/Users/sno/scratch/tool");
237
+ expect(res?.exact).toBe(true);
238
+ });
239
+
240
+ test("host scoping still applies to workspace matches", () => {
241
+ expect(resolveDevTarget([root], { host: "other", path: "/Users/sno/scratch/tool" })).toBeNull();
242
+ });
243
+
244
+ test("an exact cwd mount still wins (no folder synthesized)", () => {
245
+ const res = resolveDevTarget([root], { host: "mbp", path: "/Users/sno" });
246
+ expect(res).toEqual({ peerId: "root1" });
247
+ });
248
+ });
249
+
250
+ describe("resolveRepoTarget enumerated workspaces (exact)", () => {
251
+ const target = { host: "github.com", owner: "snomiao", name: "codehost" };
252
+ const root = (peerId: string, cwd: string, workspaces?: WorkspaceInfo[]): PeerInfo => ({
253
+ peerId,
254
+ role: "server",
255
+ meta: { name: peerId, host: "Mac", cwd, kind: "root", workspaces },
256
+ });
257
+
258
+ test("an enumerated checkout beats a deeper root's synthesized fallback", () => {
259
+ const servers = [
260
+ root("deep", "/Users/sno/ws"),
261
+ root("listed", "/Users/sno", [
262
+ { path: "/Users/sno/snomiao/codehost/tree/main", repo: "github.com/snomiao/codehost", branch: "main" },
263
+ ]),
264
+ ];
265
+ const res = resolveRepoTarget(servers, target);
266
+ expect(res?.peerId).toBe("listed");
267
+ expect(res?.folder).toBe("/Users/sno/snomiao/codehost/tree/main");
268
+ expect(res?.exact).toBe(true);
269
+ });
270
+
271
+ test("branch mismatch falls back to the synthesized path", () => {
272
+ const servers = [
273
+ root("listed", "/Users/sno/ws", [
274
+ { path: "/Users/sno/ws/snomiao/codehost/tree/main", repo: "github.com/snomiao/codehost", branch: "main" },
275
+ ]),
276
+ ];
277
+ const res = resolveRepoTarget(servers, { ...target, branch: "dev" });
278
+ expect(res?.exact).toBeUndefined();
279
+ expect(res?.folder).toBe("/Users/sno/ws/snomiao/codehost/tree/dev");
280
+ });
281
+
282
+ test("pickRoomMatch ranks an exact enumerated match like a repo daemon", () => {
283
+ const fallback = { token: "room-a", resolution: { peerId: "x", folder: "/a/b" } };
284
+ const exact = { token: "room-b", resolution: { peerId: "y", folder: "/c/d", exact: true } };
285
+ expect(pickRoomMatch([fallback, exact])?.token).toBe("room-b");
286
+ });
287
+ });
288
+
289
+ describe("resolveRepoTarget machine preference (history)", () => {
290
+ const target = { host: "github.com", owner: "snomiao", name: "codehost" };
291
+ const repoDaemon = (peerId: string, hostId?: string, host = "Mac"): PeerInfo => ({
292
+ peerId,
293
+ role: "server",
294
+ meta: { name: peerId, host, hostId, cwd: "/x", repo: "github.com/snomiao/codehost" },
295
+ });
296
+ const root = (peerId: string, cwd: string, hostId?: string, host = "Mac"): PeerInfo => ({
297
+ peerId,
298
+ role: "server",
299
+ meta: { name: peerId, host, hostId, cwd, kind: "root" },
300
+ });
301
+
302
+ test("among equal repo daemons, prefers the history hostId", () => {
303
+ const servers = [repoDaemon("a", "id-a"), repoDaemon("b", "id-b")];
304
+ expect(resolveRepoTarget(servers, target, { hostId: "id-b" })?.peerId).toBe("b");
305
+ expect(resolveRepoTarget(servers, target, { hostId: "id-a" })?.peerId).toBe("a");
306
+ });
307
+
308
+ test("hostname fallback matches entries/daemons without a hostId", () => {
309
+ const servers = [repoDaemon("a", undefined, "mbp"), repoDaemon("b", undefined, "win")];
310
+ expect(resolveRepoTarget(servers, target, { host: "win" })?.peerId).toBe("b");
311
+ });
312
+
313
+ test("preferred root beats a deeper root on another machine", () => {
314
+ const servers = [root("deep", "/Users/sno/ws/x", "id-other"), root("mine", "/Users/sno", "id-mine")];
315
+ expect(resolveRepoTarget(servers, target, { hostId: "id-mine" })?.peerId).toBe("mine");
316
+ });
317
+
318
+ test("no preference keeps the existing order (deepest root, first repo daemon)", () => {
319
+ const servers = [root("shallow", "/Users/sno", "id-1"), root("deep", "/Users/sno/ws", "id-2")];
320
+ expect(resolveRepoTarget(servers, target)?.peerId).toBe("deep");
321
+ });
322
+
323
+ test("a stale preference (machine offline) falls back gracefully", () => {
324
+ const servers = [repoDaemon("a", "id-a")];
325
+ expect(resolveRepoTarget(servers, target, { hostId: "gone" })?.peerId).toBe("a");
326
+ });
327
+ });
328
+
220
329
  describe("gitUrlToPath", () => {
221
330
  test("github URLs -> /gh, preserving branch (incl. slashes)", () => {
222
331
  expect(gitUrlToPath("https://github.com/snomiao/codehost")).toBe("/gh/snomiao/codehost");
@@ -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
- * Among several roots, prefers the **deepest** (longest cwd) with a nested
182
- * setup like /Users/sno and /Users/sno/ws both serving, the layout subfolder
183
- * exists under the deeper one (observed: /gh/snomiao/codehost belongs to
184
- * /Users/sno/ws/..., not /Users/sno/...). Returns null if nothing matches.
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(servers: PeerInfo[], target: RepoTarget): Resolution | null {
205
+ export function resolveRepoTarget(
206
+ servers: PeerInfo[],
207
+ target: RepoTarget,
208
+ prefer?: ResolvePrefs,
209
+ ): Resolution | null {
187
210
  const key = repoKey(target);
188
- const repoMatch = servers.find(
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 root = servers
217
+ const roots = servers
194
218
  .filter((s) => s.meta?.kind === "root")
195
- .sort((a, b) => (b.meta?.cwd.length ?? 0) - (a.meta?.cwd.length ?? 0))[0];
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 hit = servers.find(
211
- (s) => s.meta && stripEnds(s.meta.cwd) === want && (!target.host || s.meta.host === target.host),
212
- );
213
- return hit ? { peerId: hit.peerId } : null;
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 — no
225
- * synthesized `folder`) beats a *root fallback* (a root daemon that would open
226
- * the repo as a subfolder, which `resolveRepoTarget` returns for ANY repo link).
227
- * Without this preference, first-responder-wins could pick an unrelated room
228
- * that merely has a root server. Returns null when there are no matches.
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();
@@ -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
- export type ClientMessage = HelloMessage | SignalMessage | PingMessage;
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