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.
@@ -0,0 +1,116 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { join } from "node:path";
4
+ import { toPosixPath } from "../../shared/repo";
5
+ import type { AgentInfo } from "../../shared/signaling";
6
+ import type { DaemonPlugin } from "./types";
7
+
8
+ // agent-yes plugin: advertises the machine's live agent CLI sessions (read
9
+ // straight from agent-yes's registry, so it works even when `ay serve` is
10
+ // down) and proxies /__codehost/agent-yes/* to the local `ay serve` API with
11
+ // its bearer token injected. Granting room members agent control is not new
12
+ // trust: the room token already grants code execution (the editor is a
13
+ // terminal) — same boundary as provisioning (see shared/provision.ts).
14
+
15
+ const AY_DIR = join(homedir(), ".agent-yes");
16
+ const AY_PORT = Number(process.env.CODEHOST_AY_PORT) || 7432;
17
+ /** Cap the advertised list (meta rides the signaling room broadcast). */
18
+ const MAX_AGENTS = 50;
19
+
20
+ interface AyRecord {
21
+ pid: number;
22
+ cli?: string;
23
+ prompt?: string | null;
24
+ cwd?: string;
25
+ status?: "active" | "idle" | "exited";
26
+ started_at?: number;
27
+ }
28
+
29
+ /** agent-yes's global registry: JSONL, last line per pid wins. */
30
+ export function readAgents(dir: string = AY_DIR): AgentInfo[] {
31
+ let raw: string;
32
+ try {
33
+ raw = readFileSync(join(dir, "pids.jsonl"), "utf8");
34
+ } catch {
35
+ return [];
36
+ }
37
+ const byPid = new Map<number, AyRecord>();
38
+ for (const line of raw.split("\n")) {
39
+ if (!line.trim()) continue;
40
+ try {
41
+ const rec = JSON.parse(line) as AyRecord;
42
+ if (typeof rec.pid === "number") byPid.set(rec.pid, { ...byPid.get(rec.pid), ...rec });
43
+ } catch {
44
+ // skip malformed lines
45
+ }
46
+ }
47
+ const out: AgentInfo[] = [];
48
+ for (const rec of [...byPid.values()].sort((a, b) => (b.started_at ?? 0) - (a.started_at ?? 0))) {
49
+ if (out.length >= MAX_AGENTS) break;
50
+ if (rec.status === "exited" || !alive(rec.pid)) continue;
51
+ out.push({
52
+ pid: rec.pid,
53
+ tool: rec.cli || "agent",
54
+ ...(rec.prompt ? { title: rec.prompt.slice(0, 120) } : {}),
55
+ cwd: toPosixPath(rec.cwd ?? ""),
56
+ state: rec.status === "active" ? "active" : "idle",
57
+ ...(rec.started_at ? { startedAt: rec.started_at } : {}),
58
+ });
59
+ }
60
+ return out;
61
+ }
62
+
63
+ function alive(pid: number): boolean {
64
+ try {
65
+ process.kill(pid, 0);
66
+ return true;
67
+ } catch {
68
+ return false;
69
+ }
70
+ }
71
+
72
+ function serveToken(dir: string): string | null {
73
+ try {
74
+ const t = readFileSync(join(dir, ".serve-token"), "utf8").trim();
75
+ return t || null;
76
+ } catch {
77
+ return null;
78
+ }
79
+ }
80
+
81
+ /** The plugin, or null when this machine has never run agent-yes. */
82
+ export function agentYesPlugin(dir: string = AY_DIR): DaemonPlugin | null {
83
+ if (!existsSync(dir)) return null;
84
+ return {
85
+ name: "agent-yes",
86
+ meta: () => {
87
+ const agents = readAgents(dir);
88
+ return agents.length > 0 ? { agents } : {};
89
+ },
90
+ route: async (path, req) => {
91
+ const token = serveToken(dir);
92
+ if (!token) {
93
+ return new Response(JSON.stringify({ error: "ay serve has no token (is agent-yes installed?)" }), {
94
+ status: 503,
95
+ headers: { "content-type": "application/json" },
96
+ });
97
+ }
98
+ const headers = new Headers(req.headers);
99
+ headers.set("authorization", `Bearer ${token}`);
100
+ headers.set("host", `127.0.0.1:${AY_PORT}`);
101
+ try {
102
+ return await fetch(`http://127.0.0.1:${AY_PORT}${path}`, {
103
+ method: req.method,
104
+ headers,
105
+ body: req.body as BodyInit | undefined,
106
+ redirect: "manual",
107
+ });
108
+ } catch {
109
+ return new Response(JSON.stringify({ error: "ay serve is not running (start it with `ay serve install`)" }), {
110
+ status: 502,
111
+ headers: { "content-type": "application/json" },
112
+ });
113
+ }
114
+ },
115
+ };
116
+ }
@@ -0,0 +1,39 @@
1
+ import type { PeerMeta } from "../../shared/signaling";
2
+
3
+ // Daemon plugin surface: a plugin contributes (a) fields merged into the
4
+ // advertised PeerMeta on every refresh, and (b) an HTTP route mounted under the
5
+ // tunnel at /__codehost/<name>/*. codehost stays the transport + registry;
6
+ // plugins (agent-yes first) own their domain.
7
+
8
+ export interface DaemonPlugin {
9
+ /** Identifier; also the tunnel mount: requests to /__codehost/<name>/* land
10
+ * in `route`. */
11
+ name: string;
12
+ /** Resource contribution, merged into PeerMeta by the daemon's meta builder
13
+ * (startup + every refresh). Keep it small — it rides room broadcasts. */
14
+ meta?: () => Partial<PeerMeta>;
15
+ /** Serve a tunneled request. `path` is the remainder after the mount, always
16
+ * starting with "/" and keeping the query string. */
17
+ route?: (path: string, req: { method: string; headers: Headers; body?: Uint8Array }) => Promise<Response>;
18
+ }
19
+
20
+ /** Route a local tunnel request to the owning plugin, or null to fall through. */
21
+ export function routePlugins(
22
+ plugins: DaemonPlugin[],
23
+ req: { method: string; path: string; headers: Headers; body?: Uint8Array },
24
+ ): Promise<Response> | null {
25
+ for (const p of plugins) {
26
+ if (!p.route) continue;
27
+ const mount = `/__codehost/${p.name}`;
28
+ if (req.path === mount || req.path.startsWith(`${mount}/`) || req.path.startsWith(`${mount}?`)) {
29
+ const rest = req.path.slice(mount.length) || "/";
30
+ return p.route(rest.startsWith("/") ? rest : `/${rest}`, req);
31
+ }
32
+ }
33
+ return null;
34
+ }
35
+
36
+ /** Merge every plugin's meta contribution into a base meta. */
37
+ export function withPluginMeta(base: PeerMeta, plugins: DaemonPlugin[]): PeerMeta {
38
+ return Object.assign({}, base, ...plugins.map((p) => p.meta?.() ?? {}));
39
+ }
@@ -0,0 +1,76 @@
1
+ import { afterAll, describe, expect, test } from "bun:test";
2
+ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { handleProvision, isProvisionPath } from "./provision-server";
6
+
7
+ // A throwaway home with an optional .codehost/setup.sh.
8
+ function makeHome(setup?: string, configYaml?: string): string {
9
+ const home = mkdtempSync(join(tmpdir(), "codehost-prov-"));
10
+ if (setup || configYaml) mkdirSync(join(home, ".codehost"), { recursive: true });
11
+ if (setup) writeFileSync(join(home, ".codehost", "setup.sh"), setup);
12
+ if (configYaml) writeFileSync(join(home, ".codehost", "config.yaml"), configYaml);
13
+ homes.push(home);
14
+ return home;
15
+ }
16
+ const homes: string[] = [];
17
+ afterAll(() => homes.forEach((h) => rmSync(h, { recursive: true, force: true })));
18
+
19
+ const q = (owner: string, repo: string, branch = "main") =>
20
+ `/__codehost/provision?owner=${owner}&repo=${repo}&branch=${branch}`;
21
+
22
+ describe("isProvisionPath", () => {
23
+ test("matches the route, ignores query + other paths", () => {
24
+ expect(isProvisionPath("/__codehost/provision?owner=a")).toBe(true);
25
+ expect(isProvisionPath("/vs/abc/?folder=x")).toBe(false);
26
+ });
27
+ });
28
+
29
+ describe("handleProvision", () => {
30
+ test("no setup script → 200 + workspace path in header/body (today's behavior)", async () => {
31
+ const home = makeHome();
32
+ const res = await handleProvision(q("snomiao", "codehost"), { homeDir: home, host: "github.com" });
33
+ expect(res.status).toBe(200);
34
+ expect(res.headers.get("x-codehost-workspace")).toBe(`${home}/snomiao/codehost/tree/main`);
35
+ expect(await res.json()).toEqual({ workspace: `${home}/snomiao/codehost/tree/main` });
36
+ });
37
+
38
+ test("runs setup.sh, streams output + exit sentinel, env is passed", async () => {
39
+ const home = makeHome('echo "owner=$CODEHOST_OWNER branch=$CODEHOST_BRANCH ws=$CODEHOST_WS"\nexit 0\n');
40
+ const res = await handleProvision(q("snomiao", "codehost", "feat/x"), { homeDir: home, host: "github.com" });
41
+ expect(res.status).toBe(200);
42
+ expect(res.headers.get("x-codehost-workspace")).toBe(`${home}/snomiao/codehost/tree/feat/x`);
43
+ const body = await res.text();
44
+ expect(body).toContain(`owner=snomiao branch=feat/x ws=${home}/snomiao/codehost/tree/feat/x`);
45
+ expect(body).toContain("::codehost:exit=0");
46
+ });
47
+
48
+ test("propagates a non-zero exit code in the sentinel", async () => {
49
+ const home = makeHome('echo "boom" >&2\nexit 7\n');
50
+ const res = await handleProvision(q("snomiao", "codehost"), { homeDir: home, host: "github.com" });
51
+ const body = await res.text();
52
+ expect(body).toContain("boom");
53
+ expect(body).toContain("::codehost:exit=7");
54
+ });
55
+
56
+ test("rejects a traversal identity with 400 (no spawn)", async () => {
57
+ const home = makeHome("echo SHOULD_NOT_RUN\nexit 0\n");
58
+ const res = await handleProvision(q("..", "codehost"), { homeDir: home, host: "github.com" });
59
+ expect(res.status).toBe(400);
60
+ expect(await res.text()).not.toContain("SHOULD_NOT_RUN");
61
+ });
62
+
63
+ test("enforces the config allowlist with 403", async () => {
64
+ const home = makeHome("echo hi\nexit 0\n", "allowlist:\n - github.com/snomiao/*\n");
65
+ const ok = await handleProvision(q("snomiao", "codehost"), { homeDir: home, host: "github.com" });
66
+ expect(ok.status).toBe(200);
67
+ const denied = await handleProvision(q("evil", "repo"), { homeDir: home, host: "github.com" });
68
+ expect(denied.status).toBe(403);
69
+ });
70
+
71
+ test("config.yaml workspace template overrides the layout", async () => {
72
+ const home = makeHome(undefined, 'workspace: "ws/{owner}/{repo}/tree/{branch}"\n');
73
+ const res = await handleProvision(q("snomiao", "codehost"), { homeDir: home, host: "github.com" });
74
+ expect(res.headers.get("x-codehost-workspace")).toBe(`${home}/ws/snomiao/codehost/tree/main`);
75
+ });
76
+ });
@@ -0,0 +1,186 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { parse as parseYaml } from "yaml";
4
+ import { repoAllowed, resolveWorkspacePath, validateProvisionTarget, type ProvisionTarget } from "../shared/provision";
5
+ import { fromPosixPath, repoKey, toPosixPath } from "../shared/repo";
6
+
7
+ // Daemon side of provisioning. A repo open hits `GET /__codehost/provision?...`
8
+ // over the tunnel; this validates the identity, computes the daemon-authoritative
9
+ // workspace path, and (if `.codehost/setup.sh` exists) runs it, streaming its
10
+ // output back as the response body. The resolved path rides in the
11
+ // `x-codehost-workspace` header so it never depends on parsing script output.
12
+
13
+ export const PROVISION_PATH = "/__codehost/provision";
14
+ const TIMEOUT_MS = Number(process.env.CODEHOST_PROVISION_TIMEOUT_MS) || 15 * 60_000;
15
+
16
+ export interface ProvisionDeps {
17
+ /** Real OS path of the served home root. */
18
+ homeDir: string;
19
+ /** Git host advertised by this daemon (default github.com). */
20
+ host: string;
21
+ /** Called after a setup script finishes (any exit code) — lets the daemon
22
+ * re-enumerate + re-advertise its workspaces. */
23
+ onProvisioned?: () => void;
24
+ }
25
+
26
+ export interface CodehostConfig {
27
+ workspace?: string; // layout template, e.g. "ws/{owner}/{repo}/tree/{branch}"
28
+ allowlist?: string[];
29
+ }
30
+
31
+ /** True for the provision route (ignoring the query string). */
32
+ export function isProvisionPath(path: string): boolean {
33
+ return path.split("?")[0] === PROVISION_PATH;
34
+ }
35
+
36
+ export function readCodehostConfig(homeDir: string): CodehostConfig {
37
+ try {
38
+ const raw = readFileSync(join(homeDir, ".codehost", "config.yaml"), "utf8");
39
+ const c = (parseYaml(raw) ?? {}) as Record<string, unknown>;
40
+ return {
41
+ workspace: typeof c.workspace === "string" ? c.workspace : undefined,
42
+ allowlist: Array.isArray(c.allowlist)
43
+ ? c.allowlist.filter((x): x is string => typeof x === "string")
44
+ : undefined,
45
+ };
46
+ } catch {
47
+ return {};
48
+ }
49
+ }
50
+
51
+ /** Locate the host's setup script (platform-appropriate), or null if none — in
52
+ * which case provisioning is a no-op and we just return the path. */
53
+ function findSetupScript(homeDir: string): string[] | null {
54
+ const dir = join(homeDir, ".codehost");
55
+ if (process.platform === "win32") {
56
+ const bat = join(dir, "setup.bat");
57
+ if (existsSync(bat)) return ["cmd", "/c", bat];
58
+ const ps1 = join(dir, "setup.ps1");
59
+ if (existsSync(ps1)) return ["powershell", "-NoProfile", "-ExecutionPolicy", "Bypass", "-File", ps1];
60
+ }
61
+ const sh = join(dir, "setup.sh");
62
+ if (existsSync(sh)) return ["bash", sh];
63
+ return null;
64
+ }
65
+
66
+ // Per-workspace coalescing: a concurrent open of the same target waits for the
67
+ // running provision instead of spawning a second one.
68
+ const inFlight = new Map<string, Promise<number>>();
69
+
70
+ export async function handleProvision(rawPath: string, deps: ProvisionDeps): Promise<Response> {
71
+ const url = new URL(`http://x${rawPath}`);
72
+ const v = validateProvisionTarget(
73
+ url.searchParams.get("owner") ?? "",
74
+ url.searchParams.get("repo") ?? "",
75
+ url.searchParams.get("branch") ?? "",
76
+ );
77
+ if (!v.ok) return json(400, { error: v.reason });
78
+
79
+ const host = (url.searchParams.get("host") ?? deps.host).toLowerCase();
80
+ const cfg = readCodehostConfig(deps.homeDir);
81
+ const key = repoKey({ host, owner: v.target.owner, name: v.target.repo });
82
+ if (!repoAllowed(key, cfg.allowlist)) return json(403, { error: `repo not allowlisted: ${key}` });
83
+
84
+ const wsPosix = resolveWorkspacePath(toPosixPath(deps.homeDir), cfg.workspace ?? "", v.target);
85
+ const headers = { "x-codehost-workspace": wsPosix };
86
+
87
+ const cmd = findSetupScript(deps.homeDir);
88
+ if (!cmd) return json(200, { workspace: wsPosix }, headers); // no script: hand back the path
89
+
90
+ const lockKey = fromPosixPath(wsPosix);
91
+ const existing = inFlight.get(lockKey);
92
+ const body = existing
93
+ ? coalescedBody(existing) // a provision is already running for this workspace
94
+ : freshBody(cmd, deps, v.target, host, lockKey);
95
+ return new Response(body, {
96
+ status: 200,
97
+ headers: { "content-type": "text/plain; charset=utf-8", "cache-control": "no-store", ...headers },
98
+ });
99
+ }
100
+
101
+ /** Spawn the setup script, streaming merged stdout+stderr, ending with an exit
102
+ * sentinel the browser parses for success/failure. */
103
+ function freshBody(
104
+ cmd: string[],
105
+ deps: ProvisionDeps,
106
+ target: ProvisionTarget,
107
+ host: string,
108
+ lockKey: string,
109
+ ): ReadableStream<Uint8Array> {
110
+ const enc = new TextEncoder();
111
+ return new ReadableStream<Uint8Array>({
112
+ async start(controller) {
113
+ let resolveDone!: (code: number) => void;
114
+ inFlight.set(lockKey, new Promise<number>((r) => (resolveDone = r)));
115
+ const say = (s: string) => controller.enqueue(enc.encode(s));
116
+ say(`[codehost] provisioning ${host}/${target.owner}/${target.repo}@${target.branch}\n`);
117
+ let code = 1;
118
+ try {
119
+ const proc = Bun.spawn(cmd, {
120
+ cwd: deps.homeDir,
121
+ env: {
122
+ ...process.env,
123
+ CODEHOST_OWNER: target.owner,
124
+ CODEHOST_REPO: target.repo,
125
+ CODEHOST_BRANCH: target.branch,
126
+ CODEHOST_HOST: host,
127
+ CODEHOST_HOME: deps.homeDir,
128
+ CODEHOST_WS: fromPosixPath(lockKey),
129
+ },
130
+ stdout: "pipe",
131
+ stderr: "pipe",
132
+ });
133
+ const timer = setTimeout(() => {
134
+ try {
135
+ proc.kill();
136
+ } catch {
137
+ // already gone
138
+ }
139
+ }, TIMEOUT_MS);
140
+ const pump = async (stream: ReadableStream<Uint8Array>) => {
141
+ const reader = stream.getReader();
142
+ for (;;) {
143
+ const { done, value } = await reader.read();
144
+ if (done) break;
145
+ controller.enqueue(value);
146
+ }
147
+ };
148
+ await Promise.all([pump(proc.stdout), pump(proc.stderr)]);
149
+ code = await proc.exited;
150
+ clearTimeout(timer);
151
+ } catch (err) {
152
+ say(`[codehost] provision error: ${String(err)}\n`);
153
+ } finally {
154
+ inFlight.delete(lockKey);
155
+ resolveDone(code);
156
+ say(`\n::codehost:exit=${code}\n`);
157
+ controller.close();
158
+ try {
159
+ deps.onProvisioned?.();
160
+ } catch {
161
+ // advertising is best-effort; never fail the provision response
162
+ }
163
+ }
164
+ },
165
+ });
166
+ }
167
+
168
+ /** Attach to a running provision: wait for it, then emit the exit sentinel. */
169
+ function coalescedBody(existing: Promise<number>): ReadableStream<Uint8Array> {
170
+ const enc = new TextEncoder();
171
+ return new ReadableStream<Uint8Array>({
172
+ async start(controller) {
173
+ controller.enqueue(enc.encode("[codehost] provision already running for this workspace; waiting…\n"));
174
+ const code = await existing.catch(() => 1);
175
+ controller.enqueue(enc.encode(`\n::codehost:exit=${code}\n`));
176
+ controller.close();
177
+ },
178
+ });
179
+ }
180
+
181
+ function json(status: number, obj: unknown, extra: Record<string, string> = {}): Response {
182
+ return new Response(JSON.stringify(obj), {
183
+ status,
184
+ headers: { "content-type": "application/json", "cache-control": "no-store", ...extra },
185
+ });
186
+ }
@@ -0,0 +1,51 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import {
6
+ clearDaemonPresence,
7
+ liveDaemon,
8
+ readRegisteredWorkspaces,
9
+ registerWorkspace,
10
+ writeDaemonPresence,
11
+ } from "./registry";
12
+
13
+ const tmp = () => mkdtempSync(join(tmpdir(), "codehost-registry-"));
14
+
15
+ describe("daemon presence", () => {
16
+ test("round-trips while the pid is alive; cleared file -> null", () => {
17
+ const file = join(tmp(), "daemon.json");
18
+ const p = { pid: process.pid, root: "/Users/x", token: "Str0ng-Token-99", startedAt: 1 };
19
+ writeDaemonPresence(p, file);
20
+ expect(liveDaemon(file)).toEqual(p);
21
+ clearDaemonPresence(file);
22
+ expect(liveDaemon(file)).toBeNull();
23
+ });
24
+
25
+ test("a dead pid (stale kill -9 leftover) reads as no daemon", () => {
26
+ const file = join(tmp(), "daemon.json");
27
+ writeDaemonPresence({ pid: 99999999, root: "/x", token: "t0k-En-Stronk", startedAt: 1 }, file);
28
+ expect(liveDaemon(file)).toBeNull();
29
+ });
30
+
31
+ test("garbage file reads as no daemon", () => {
32
+ const file = join(tmp(), "daemon.json");
33
+ writeFileSync(file, "not json");
34
+ expect(liveDaemon(file)).toBeNull();
35
+ });
36
+ });
37
+
38
+ describe("workspace registry", () => {
39
+ test("register is idempotent; missing dirs are filtered on read", () => {
40
+ const dir = tmp();
41
+ const file = join(dir, "workspaces.json");
42
+ const ws = join(dir, "proj");
43
+ mkdirSync(ws);
44
+ registerWorkspace(ws, file);
45
+ registerWorkspace(ws, file);
46
+ registerWorkspace(join(dir, "gone"), file); // never created on disk
47
+ const got = readRegisteredWorkspaces(file);
48
+ expect(got).toHaveLength(1);
49
+ expect(got[0].path).toBe(ws);
50
+ });
51
+ });
@@ -0,0 +1,93 @@
1
+ import { existsSync, readFileSync, rmSync, statSync, writeFileSync, mkdirSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { dirname, join, resolve } from "node:path";
4
+
5
+ // One-daemon-per-host coordination (Phase 5 of the resource-model redesign).
6
+ // A single VS Code serve-web opens ANY local path via ?folder=, so one root
7
+ // daemon can carry every workspace on the machine: a later `codehost dev`
8
+ // REGISTERS its directory here and exits instead of spawning a second peer +
9
+ // VS Code. The daemon advertises registered dirs in meta.workspaces and
10
+ // re-reads this registry on fs.watch + the slow refresh tick.
11
+
12
+ export const CODEHOST_DIR = join(homedir(), ".codehost");
13
+ const DAEMON_FILE = join(CODEHOST_DIR, "daemon.json");
14
+ const WORKSPACES_FILE = join(CODEHOST_DIR, "workspaces.json");
15
+
16
+ /** Written by a foreground root daemon while it runs; staleness is detected by
17
+ * pid liveness, so a kill -9 leftover never blocks anything. */
18
+ export interface DaemonPresence {
19
+ pid: number;
20
+ /** Real OS path of the served root. */
21
+ root: string;
22
+ /** Room token the daemon registered with — later `dev` runs print a link
23
+ * into THIS room, since that's where the workspace will appear. */
24
+ token: string;
25
+ startedAt: number;
26
+ }
27
+
28
+ export interface RegisteredWorkspace {
29
+ /** Real OS path. */
30
+ path: string;
31
+ addedAt: number;
32
+ }
33
+
34
+ export function writeDaemonPresence(p: DaemonPresence, file: string = DAEMON_FILE): void {
35
+ mkdirSync(dirname(file), { recursive: true });
36
+ writeFileSync(file, JSON.stringify(p, null, 2));
37
+ }
38
+
39
+ export function clearDaemonPresence(file: string = DAEMON_FILE): void {
40
+ try {
41
+ rmSync(file);
42
+ } catch {
43
+ // already gone
44
+ }
45
+ }
46
+
47
+ /** The live host daemon, or null (no file, unparsable, or its pid is dead). */
48
+ export function liveDaemon(file: string = DAEMON_FILE): DaemonPresence | null {
49
+ try {
50
+ const p = JSON.parse(readFileSync(file, "utf8")) as DaemonPresence;
51
+ if (typeof p.pid !== "number" || !p.root || !p.token) return null;
52
+ process.kill(p.pid, 0);
53
+ return p;
54
+ } catch {
55
+ return null;
56
+ }
57
+ }
58
+
59
+ /** Add a directory to the host's workspace registry (idempotent). */
60
+ export function registerWorkspace(path: string, file: string = WORKSPACES_FILE): void {
61
+ const dir = resolve(path);
62
+ const all = readRegistry(file);
63
+ if (all.some((w) => w.path === dir)) return;
64
+ all.push({ path: dir, addedAt: Date.now() });
65
+ mkdirSync(dirname(file), { recursive: true });
66
+ writeFileSync(file, JSON.stringify(all, null, 2));
67
+ }
68
+
69
+ /** Registered workspaces whose directories still exist. */
70
+ export function readRegisteredWorkspaces(file: string = WORKSPACES_FILE): RegisteredWorkspace[] {
71
+ return readRegistry(file).filter((w) => {
72
+ try {
73
+ return statSync(w.path).isDirectory();
74
+ } catch {
75
+ return false;
76
+ }
77
+ });
78
+ }
79
+
80
+ /** The registry path — daemons fs.watch its directory for instant re-advertise. */
81
+ export function workspacesFile(): string {
82
+ return WORKSPACES_FILE;
83
+ }
84
+
85
+ function readRegistry(file: string): RegisteredWorkspace[] {
86
+ try {
87
+ if (!existsSync(file)) return [];
88
+ const arr = JSON.parse(readFileSync(file, "utf8"));
89
+ return Array.isArray(arr) ? arr.filter((w) => w && typeof w.path === "string") : [];
90
+ } catch {
91
+ return [];
92
+ }
93
+ }
@@ -1,7 +1,11 @@
1
+ import { watch } from "node:fs";
2
+ import { basename, dirname } from "node:path";
1
3
  import { type PeerMeta, newPeerId } from "../shared/signaling";
2
4
  import { SignalingClient } from "../shared/signaling-client";
3
5
  import { RtcDaemon } from "./rtc-daemon";
4
- import { Tunnel } from "./tunnel";
6
+ import { type LocalRequest, Tunnel } from "./tunnel";
7
+ import { handleProvision, isProvisionPath, type ProvisionDeps } from "./provision-server";
8
+ import { type DaemonPlugin, routePlugins } from "./plugins/types";
5
9
 
6
10
  export interface LaunchResult {
7
11
  /** Local port to tunnel to. */
@@ -24,8 +28,25 @@ export interface RunServerOptions {
24
28
  label: string;
25
29
  /** Prepare the local target to tunnel, given the /vs/<peerId> base path. */
26
30
  launch: (basePath: string) => Promise<LaunchResult>;
31
+ /** Enables `/__codehost/provision` on the tunnel (serve only — runs the home's
32
+ * setup.sh). Omitted by `expose`, which has no home/workspace. */
33
+ provision?: ProvisionDeps;
34
+ /** Recompute the advertised meta (e.g. re-enumerate workspaces). Polled on a
35
+ * slow interval and right after each provision; pushed to the room only when
36
+ * it actually changed. */
37
+ refreshMeta?: () => PeerMeta;
38
+ /** Daemon plugins: tunneled routes under /__codehost/<name>/ (their meta
39
+ * contributions are the caller's job, inside `meta`/`refreshMeta`). */
40
+ plugins?: DaemonPlugin[];
41
+ /** Files whose change should trigger an immediate `refreshMeta` (e.g. the
42
+ * host workspace registry). Watched via their parent directory so the file
43
+ * may not exist yet. */
44
+ watchFiles?: string[];
27
45
  }
28
46
 
47
+ /** How often a daemon re-enumerates its workspaces (manual clones show up). */
48
+ const META_REFRESH_MS = 60_000;
49
+
29
50
  /**
30
51
  * Foreground server loop shared by `serve`, `dev`, and `expose`: register in the
31
52
  * signaling room with the given meta and bridge each viewer's data channel to a
@@ -60,15 +81,54 @@ export async function runServer(opts: RunServerOptions): Promise<never> {
60
81
  onSignal: (from, data) => rtc.handleSignal(from, data),
61
82
  });
62
83
 
84
+ // Re-advertise when the workspace set changes (provision, manual clone).
85
+ let lastMeta = JSON.stringify(opts.meta);
86
+ const refreshMeta = () => {
87
+ if (!opts.refreshMeta) return;
88
+ const meta = opts.refreshMeta();
89
+ const s = JSON.stringify(meta);
90
+ if (s === lastMeta) return;
91
+ lastMeta = s;
92
+ client.updateMeta(meta);
93
+ };
94
+ const provision: ProvisionDeps | undefined = opts.provision
95
+ ? { ...opts.provision, onProvisioned: refreshMeta }
96
+ : undefined;
97
+ const plugins = opts.plugins ?? [];
98
+ const onLocal =
99
+ provision || plugins.length > 0
100
+ ? (req: LocalRequest) => {
101
+ if (provision && isProvisionPath(req.path)) return handleProvision(req.path, provision);
102
+ return routePlugins(plugins, req);
103
+ }
104
+ : undefined;
105
+
63
106
  rtc = new RtcDaemon({
64
107
  sendSignal: (to, data) => client.sendSignal(to, data),
65
108
  onChannel: (viewerId, channel) => {
66
109
  console.log(`[codehost] viewer ${viewerId.slice(0, 8)} connected; bridging to :${target.port}`);
67
- new Tunnel(channel, target.port, target.stripBasePath);
110
+ new Tunnel(channel, target.port, target.stripBasePath, onLocal);
68
111
  },
69
112
  });
70
113
 
71
114
  client.connect();
115
+ if (opts.refreshMeta) {
116
+ setInterval(refreshMeta, META_REFRESH_MS);
117
+ // Instant re-advertise when a watched file changes (debounced — editors
118
+ // and fs.watch both fire in bursts).
119
+ let pending: ReturnType<typeof setTimeout> | null = null;
120
+ for (const file of opts.watchFiles ?? []) {
121
+ try {
122
+ watch(dirname(file), (_event, filename) => {
123
+ if (filename && filename !== basename(file)) return;
124
+ if (pending) clearTimeout(pending);
125
+ pending = setTimeout(refreshMeta, 300);
126
+ });
127
+ } catch {
128
+ // missing dir / unsupported platform — the interval still covers it
129
+ }
130
+ }
131
+ }
72
132
 
73
133
  const shutdown = () => {
74
134
  console.log("\n[codehost] shutting down");