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.
- package/CHANGELOG.md +25 -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 +104 -15
- package/src/cli/commands/setup.ts +43 -7
- package/src/cli/config.test.ts +31 -0
- package/src/cli/config.ts +29 -7
- package/src/cli/daemonize.ts +4 -0
- package/src/cli/init.test.ts +4 -2
- package/src/cli/init.ts +9 -3
- 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
|
@@ -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
|
+
}
|
package/src/shared/repo.test.ts
CHANGED
|
@@ -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");
|
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
|
|