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 CHANGED
@@ -1,3 +1,30 @@
1
+ # [0.17.0](https://github.com/snomiao/codehost/compare/v0.16.0...v0.17.0) (2026-06-11)
2
+
3
+
4
+ ### Bug Fixes
5
+
6
+ * **vscode:** reject the desktop bin/code wrapper — probe serve-web support, not just --version ([e5b133d](https://github.com/snomiao/codehost/commit/e5b133d0ff38f4e1052f87c22279c7850eb98968))
7
+
8
+
9
+ ### Features
10
+
11
+ * **history:** durable history via hostId + machine-preferring deep-link resolution ([1935579](https://github.com/snomiao/codehost/commit/1935579a40fb7c42f46b8f1596497dac812cdc2b))
12
+ * **host:** one daemon per host — `dev` registers with a live root daemon instead of spawning a second peer ([43051de](https://github.com/snomiao/codehost/commit/43051deaae33f647b26bdcf0facb0bac9507471f))
13
+ * **identity:** stable per-machine hostId, advertised in PeerMeta; web groups workspaces by host ([43a48a2](https://github.com/snomiao/codehost/commit/43a48a2630534c36c399f0cce294a2e96235b5fb))
14
+ * **lib:** embeddable room-client bundle for external consoles (agent-yes.com) ([fd0afd1](https://github.com/snomiao/codehost/commit/fd0afd19234816127b640c397eeac5cc7968f484))
15
+ * **plugins:** daemon plugin layer + agent-yes plugin (agents[] in meta, ay API proxy over the tunnel) ([60f77b2](https://github.com/snomiao/codehost/commit/60f77b2dbbe3b649bbc8a34982b028d5a432ddb7))
16
+ * **tree:** root daemons advertise enumerated workspaces; live meta updates; exact deep-link matching ([81493be](https://github.com/snomiao/codehost/commit/81493be6694d85110a0e9f1244bd6604875c02a2))
17
+
18
+ # [0.16.0](https://github.com/snomiao/codehost/compare/v0.15.0...v0.16.0) (2026-06-10)
19
+
20
+
21
+ ### Features
22
+
23
+ * **provision:** codehost init scaffolds .codehost/ (config + setup hooks) ([b1a2e9c](https://github.com/snomiao/codehost/commit/b1a2e9c23230e09dc6a291dbcab098083bb59f4d))
24
+ * **provision:** daemon-side provision handler over the tunnel ([c297e95](https://github.com/snomiao/codehost/commit/c297e9577b3e1a65260ab105478feb63186fce46))
25
+ * **provision:** tested security core for workspace provisioning ([ca7508c](https://github.com/snomiao/codehost/commit/ca7508c48e78eb46fbf8bf42ef398360bea38f86))
26
+ * **web:** provisioning browser flow — run setup.sh on repo open, stream log ([9bd8742](https://github.com/snomiao/codehost/commit/9bd8742b143e84d386f8af138df73e57cac70e46))
27
+
1
28
  # [0.15.0](https://github.com/snomiao/codehost/compare/v0.14.0...v0.15.0) (2026-06-10)
2
29
 
3
30
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "codehost",
3
- "version": "0.15.0",
3
+ "version": "0.17.0",
4
4
  "type": "module",
5
5
  "repository": {
6
6
  "type": "git",
@@ -18,6 +18,7 @@
18
18
  "dev:signal": "cd worker && wrangler dev",
19
19
  "cli": "bun src/cli/index.ts",
20
20
  "build": "vite build && vite build --config vite.sw.config.ts",
21
+ "build:lib": "bun build src/web/room-client.ts --target browser --format esm --minify --outfile dist/room-client.js",
21
22
  "deploy:signal": "cd worker && wrangler deploy",
22
23
  "deploy:pages": "vite build && wrangler pages deploy dist/public --project-name codehost",
23
24
  "start": "bun src/server.ts",
@@ -38,6 +39,7 @@
38
39
  "oxmgr": "^0.4.0",
39
40
  "react": "^19.1.1",
40
41
  "react-dom": "^19.1.1",
42
+ "yaml": "^2.9.0",
41
43
  "yargs": "^17.7.2"
42
44
  },
43
45
  "devDependencies": {
@@ -3,12 +3,14 @@ import { resolve } from "node:path";
3
3
  import type { CommandModule } from "yargs";
4
4
  import type { PeerMeta } from "../../shared/signaling";
5
5
  import { TOKEN_REQUIREMENTS, validateToken } from "../../shared/token";
6
+ import { ensureHostId } from "../config";
6
7
  import { launchServeDaemon } from "../daemonize";
7
8
  import { announceConnect } from "../open-url";
8
9
  import { runServer } from "../run-server";
9
10
  import { launchVscode } from "../vscode";
10
11
  import { repoIdentity } from "../git";
11
- import { toPosixPath } from "../../shared/repo";
12
+ import { liveDaemon, registerWorkspace } from "../registry";
13
+ import { shareableDeepLink, toPosixPath } from "../../shared/repo";
12
14
  import { DEFAULT_SIGNAL_URL } from "./serve";
13
15
 
14
16
  interface DevArgs {
@@ -17,6 +19,7 @@ interface DevArgs {
17
19
  name?: string;
18
20
  signal: string;
19
21
  daemon: boolean;
22
+ standalone: boolean;
20
23
  port?: number;
21
24
  }
22
25
 
@@ -52,6 +55,11 @@ export const devCommand: CommandModule<{}, DevArgs> = {
52
55
  type: "boolean",
53
56
  default: false,
54
57
  })
58
+ .option("standalone", {
59
+ describe: "Always spawn an own peer + VS Code, even when a host daemon already runs",
60
+ type: "boolean",
61
+ default: false,
62
+ })
55
63
  .option("port", {
56
64
  describe: "Fixed port for the local VS Code server (default: ephemeral)",
57
65
  type: "number",
@@ -68,6 +76,28 @@ export const devCommand: CommandModule<{}, DevArgs> = {
68
76
  const dir = resolve(process.cwd(), argv.dir);
69
77
  const host = hostname();
70
78
 
79
+ // One daemon per host: a single VS Code serve-web opens any local path via
80
+ // ?folder=, so when a root daemon already runs here, just REGISTER this
81
+ // directory with it (it re-advertises within moments) instead of spawning a
82
+ // second peer + VS Code. --standalone restores the old behavior.
83
+ const daemon = argv.standalone ? null : liveDaemon();
84
+ if (daemon) {
85
+ registerWorkspace(dir);
86
+ const id = repoIdentity(dir);
87
+ const path =
88
+ (id.repo
89
+ ? shareableDeepLink({ repo: id.repo, branch: id.branch })
90
+ : shareableDeepLink({ folder: toPosixPath(dir), host })) ?? "/";
91
+ console.log(`[codehost] host daemon already serving "${daemon.root}" (pid ${daemon.pid})`);
92
+ console.log(`[codehost] registered ${dir} with it — no second daemon needed`);
93
+ console.log(`[codehost] open: https://codehost.dev${path}#t=${encodeURIComponent(daemon.token)}`);
94
+ if (daemon.token !== argv.token) {
95
+ console.log("[codehost] note: the host daemon's room differs from your -t; the link above uses the daemon's room");
96
+ }
97
+ console.log("[codehost] (use --standalone to force an own daemon instead)");
98
+ return;
99
+ }
100
+
71
101
  if (argv.daemon) {
72
102
  const { ok } = await launchServeDaemon({
73
103
  command: "dev",
@@ -90,6 +120,7 @@ export const devCommand: CommandModule<{}, DevArgs> = {
90
120
  // the real OS path for the local VS Code working dir.
91
121
  cwd: toPosixPath(dir),
92
122
  host,
123
+ hostId: ensureHostId(),
93
124
  kind: "repo",
94
125
  repo: id.repo,
95
126
  branch: id.branch,
@@ -2,6 +2,7 @@ import { hostname } from "node:os";
2
2
  import type { CommandModule } from "yargs";
3
3
  import type { PeerMeta } from "../../shared/signaling";
4
4
  import { TOKEN_REQUIREMENTS, validateToken } from "../../shared/token";
5
+ import { ensureHostId } from "../config";
5
6
  import { launchServeDaemon } from "../daemonize";
6
7
  import { runServer } from "../run-server";
7
8
  import { DEFAULT_SIGNAL_URL } from "./serve";
@@ -78,6 +79,7 @@ export const exposeCommand: CommandModule<{}, ExposeArgs> = {
78
79
  name: argv.name ?? `localhost:${argv.port}`,
79
80
  cwd: `localhost:${argv.port}`,
80
81
  host,
82
+ hostId: ensureHostId(),
81
83
  };
82
84
 
83
85
  // No VS Code: tunnel directly to the given port, stripping the /vs/<peerId>
@@ -0,0 +1,27 @@
1
+ import { resolve } from "node:path";
2
+ import type { CommandModule } from "yargs";
3
+ import { scaffoldCodehost } from "../init";
4
+
5
+ interface InitArgs {
6
+ dir: string;
7
+ force: boolean;
8
+ }
9
+
10
+ export const initCommand: CommandModule<{}, InitArgs> = {
11
+ command: "init [dir]",
12
+ describe: "Scaffold .codehost/ (config.yaml + setup.sh) so repo links auto-provision",
13
+ builder: (y) =>
14
+ y
15
+ .positional("dir", { describe: "Home dir to scaffold (defaults to cwd)", type: "string", default: "." })
16
+ .option("force", { alias: "f", describe: "Overwrite existing files", type: "boolean", default: false }) as any,
17
+ handler: (argv) => {
18
+ const dir = resolve(process.cwd(), argv.dir);
19
+ const written = scaffoldCodehost(dir, argv.force);
20
+ if (written.length === 0) {
21
+ console.log(`[codehost] .codehost/ already set up in ${dir} (use --force to overwrite)`);
22
+ return;
23
+ }
24
+ console.log(`[codehost] scaffolded:\n${written.map((p) => ` ${p}`).join("\n")}`);
25
+ console.log(`[codehost] edit .codehost/setup.sh, then run: codehost serve ${dir}`);
26
+ },
27
+ };
@@ -2,12 +2,24 @@ import { hostname } from "node:os";
2
2
  import { resolve } from "node:path";
3
3
  import type { CommandModule } from "yargs";
4
4
  import type { PeerMeta } from "../../shared/signaling";
5
- import { DEFAULT_LAYOUT, toPosixPath } from "../../shared/repo";
5
+ import { DEFAULT_LAYOUT, GITHUB_HOST, toPosixPath } from "../../shared/repo";
6
6
  import { TOKEN_REQUIREMENTS, validateToken } from "../../shared/token";
7
+ import { ensureHostId } from "../config";
7
8
  import { launchServeDaemon } from "../daemonize";
8
9
  import { announceConnect } from "../open-url";
10
+ import { agentYesPlugin } from "../plugins/agent-yes";
11
+ import { withPluginMeta } from "../plugins/types";
12
+ import { readCodehostConfig } from "../provision-server";
13
+ import {
14
+ clearDaemonPresence,
15
+ readRegisteredWorkspaces,
16
+ workspacesFile,
17
+ writeDaemonPresence,
18
+ } from "../registry";
19
+ import { repoIdentity } from "../git";
9
20
  import { runServer } from "../run-server";
10
21
  import { launchVscode } from "../vscode";
22
+ import { enumerateWorkspaces } from "../workspaces";
11
23
 
12
24
  export const DEFAULT_SIGNAL_URL = "wss://signal.codehost.dev";
13
25
 
@@ -84,23 +96,56 @@ export const serveCommand: CommandModule<{}, ServeArgs> = {
84
96
  }
85
97
 
86
98
  // A workspace root: repos under it open by GitHub-shaped deep link, mapped
87
- // onto subfolders via VS Code's ?folder= using this layout.
88
- const meta: PeerMeta = {
89
- name: argv.name ?? host,
90
- // VS Code-web ?folder= form for the browser (C:\ws -> /C:/ws); the real
91
- // OS path `dir` is still what we spawn VS Code in.
92
- cwd: toPosixPath(dir),
93
- host,
94
- kind: "root",
95
- layout: DEFAULT_LAYOUT,
99
+ // onto subfolders via VS Code's ?folder= using this layout. The layout is
100
+ // the same template provisioning uses (.codehost/config.yaml `workspace`),
101
+ // so the advertised list and the provisioned paths agree.
102
+ const layout = readCodehostConfig(dir).workspace || DEFAULT_LAYOUT;
103
+ const plugins = [agentYesPlugin()].filter((p) => p != null);
104
+ const buildMeta = (): PeerMeta => {
105
+ // Layout-enumerated checkouts plus directories other `codehost dev` runs
106
+ // registered with this host daemon (git-identified best-effort).
107
+ const workspaces = enumerateWorkspaces(dir, layout);
108
+ for (const w of readRegisteredWorkspaces()) {
109
+ const path = toPosixPath(w.path);
110
+ if (workspaces.some((x) => x.path === path)) continue;
111
+ const id = repoIdentity(w.path);
112
+ workspaces.push({
113
+ path,
114
+ ...(id.repo ? { repo: id.repo } : {}),
115
+ ...(id.branch ? { branch: id.branch } : {}),
116
+ });
117
+ }
118
+ return withPluginMeta(
119
+ {
120
+ name: argv.name ?? host,
121
+ // VS Code-web ?folder= form for the browser (C:\ws -> /C:/ws); the
122
+ // real OS path `dir` is still what we spawn VS Code in.
123
+ cwd: toPosixPath(dir),
124
+ host,
125
+ hostId: ensureHostId(),
126
+ kind: "root",
127
+ layout,
128
+ workspaces,
129
+ },
130
+ plugins,
131
+ );
96
132
  };
97
133
 
134
+ // Mark this process as the host daemon, so later `codehost dev` runs
135
+ // register their directory with it instead of spawning a second peer.
136
+ writeDaemonPresence({ pid: process.pid, root: dir, token: argv.token, startedAt: Date.now() });
137
+ process.on("exit", () => clearDaemonPresence());
138
+
98
139
  announceConnect(argv.token);
99
140
  await runServer({
100
141
  token: argv.token,
101
142
  signal: argv.signal,
102
- meta,
143
+ meta: buildMeta(),
144
+ refreshMeta: buildMeta,
145
+ watchFiles: [workspacesFile()],
146
+ plugins,
103
147
  label: `serving workspace root ${dir}`,
148
+ provision: { homeDir: dir, host: GITHUB_HOST },
104
149
  launch: async (basePath) => {
105
150
  const v = await launchVscode({ dir, basePath, port: argv.port });
106
151
  return { port: v.port, stop: v.stop };
@@ -0,0 +1,31 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { mkdtempSync, readFileSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { ensureHostId, readConfig, writeConfig } from "./config";
6
+
7
+ const tmpConfig = () => join(mkdtempSync(join(tmpdir(), "codehost-config-")), "config.json");
8
+
9
+ describe("ensureHostId", () => {
10
+ test("mints a UUID once and returns the same id on later calls", () => {
11
+ const file = tmpConfig();
12
+ const first = ensureHostId(file);
13
+ expect(first).toMatch(/^[0-9a-f-]{36}$/);
14
+ expect(ensureHostId(file)).toBe(first);
15
+ expect(readConfig(file).hostId).toBe(first);
16
+ });
17
+
18
+ test("preserves other config fields when minting", () => {
19
+ const file = tmpConfig();
20
+ writeConfig({ token: "Str0ng-Token-99" }, file);
21
+ const id = ensureHostId(file);
22
+ const config = JSON.parse(readFileSync(file, "utf8"));
23
+ expect(config).toEqual({ token: "Str0ng-Token-99", hostId: id });
24
+ });
25
+
26
+ test("returns an existing hostId without rewriting", () => {
27
+ const file = tmpConfig();
28
+ writeConfig({ hostId: "pre-existing-id" }, file);
29
+ expect(ensureHostId(file)).toBe("pre-existing-id");
30
+ });
31
+ });
package/src/cli/config.ts CHANGED
@@ -3,25 +3,38 @@ import { homedir } from "node:os";
3
3
  import { dirname, join } from "node:path";
4
4
 
5
5
  // Persistent CLI config under ~/.codehost (same root as the managed VS Code
6
- // cache in vscode-install.ts). Currently just the reusable room token so
7
- // `codehost setup` lands on a stable room URL across runs.
6
+ // cache in vscode-install.ts): the reusable room token and this machine's
7
+ // stable hostId.
8
8
 
9
9
  const CONFIG_FILE = join(homedir(), ".codehost", "config.json");
10
10
 
11
11
  export interface CliConfig {
12
12
  /** Room token reused by `setup` until --new-token or an explicit -t. */
13
13
  token?: string;
14
+ /** Stable machine identity (UUID), minted once on first use. Every daemon on
15
+ * this machine advertises it, so the web UI can group peers by host and
16
+ * history entries survive daemon restarts (peerIds are per-process). */
17
+ hostId?: string;
14
18
  }
15
19
 
16
- export function readConfig(): CliConfig {
20
+ export function readConfig(file: string = CONFIG_FILE): CliConfig {
17
21
  try {
18
- return JSON.parse(readFileSync(CONFIG_FILE, "utf8")) as CliConfig;
22
+ return JSON.parse(readFileSync(file, "utf8")) as CliConfig;
19
23
  } catch {
20
24
  return {};
21
25
  }
22
26
  }
23
27
 
24
- export function writeConfig(config: CliConfig): void {
25
- mkdirSync(dirname(CONFIG_FILE), { recursive: true });
26
- writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2));
28
+ export function writeConfig(config: CliConfig, file: string = CONFIG_FILE): void {
29
+ mkdirSync(dirname(file), { recursive: true });
30
+ writeFileSync(file, JSON.stringify(config, null, 2));
31
+ }
32
+
33
+ /** This machine's persistent hostId, minting + saving it on first call. */
34
+ export function ensureHostId(file: string = CONFIG_FILE): string {
35
+ const config = readConfig(file);
36
+ if (config.hostId) return config.hostId;
37
+ const hostId = crypto.randomUUID();
38
+ writeConfig({ ...config, hostId }, file);
39
+ return hostId;
27
40
  }
@@ -75,6 +75,10 @@ function buildForegroundArgv(opts: ServeDaemonOptions): string[] {
75
75
  const parts = [process.execPath, process.argv[1], opts.command ?? "serve", opts.arg ?? opts.dir, "-t", opts.token, "--signal", opts.signal];
76
76
  if (opts.name) parts.push("--name", opts.name);
77
77
  if (opts.port) parts.push("--port", String(opts.port));
78
+ // An oxmgr-managed `dev` must stay a real daemon across restarts/reboots —
79
+ // never collapse into register-with-the-host-daemon-and-exit (oxmgr would
80
+ // see an instant exit and thrash).
81
+ if ((opts.command ?? "serve") === "dev") parts.push("--standalone");
78
82
  return parts;
79
83
  }
80
84
 
package/src/cli/index.ts CHANGED
@@ -2,6 +2,7 @@
2
2
  import yargs from "yargs";
3
3
  import { hideBin } from "yargs/helpers";
4
4
  import { setupCommand } from "./commands/setup";
5
+ import { initCommand } from "./commands/init";
5
6
  import { serveCommand } from "./commands/serve";
6
7
  import { devCommand } from "./commands/dev";
7
8
  import { exposeCommand } from "./commands/expose";
@@ -14,6 +15,7 @@ yargs(hideBin(process.argv))
14
15
  .scriptName("codehost")
15
16
  .usage("$0 <command> [options]")
16
17
  .command(setupCommand)
18
+ .command(initCommand)
17
19
  .command(serveCommand)
18
20
  .command(devCommand)
19
21
  .command(exposeCommand)
@@ -0,0 +1,48 @@
1
+ import { afterAll, describe, expect, test } from "bun:test";
2
+ import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { parse as parseYaml } from "yaml";
6
+ import { scaffoldCodehost } from "./init";
7
+
8
+ const homes: string[] = [];
9
+ afterAll(() => homes.forEach((h) => rmSync(h, { recursive: true, force: true })));
10
+ function mkHome(): string {
11
+ const h = mkdtempSync(join(tmpdir(), "codehost-init-"));
12
+ homes.push(h);
13
+ return h;
14
+ }
15
+
16
+ describe("scaffoldCodehost", () => {
17
+ test("writes config.yaml + setup.sh + setup.ps1", () => {
18
+ const home = mkHome();
19
+ const written = scaffoldCodehost(home);
20
+ expect(written).toHaveLength(3);
21
+ expect(existsSync(join(home, ".codehost", "config.yaml"))).toBe(true);
22
+ expect(existsSync(join(home, ".codehost", "setup.sh"))).toBe(true);
23
+ expect(existsSync(join(home, ".codehost", "setup.ps1"))).toBe(true);
24
+ });
25
+
26
+ test("config.yaml is valid YAML with a workspace template", () => {
27
+ const home = mkHome();
28
+ scaffoldCodehost(home);
29
+ const cfg = parseYaml(readFileSync(join(home, ".codehost", "config.yaml"), "utf8"));
30
+ expect(cfg.workspace).toBe("ws/{owner}/{repo}/tree/{branch}");
31
+ });
32
+
33
+ test("setup.sh keeps shell vars literal (no JS interpolation leaked)", () => {
34
+ const home = mkHome();
35
+ scaffoldCodehost(home);
36
+ const sh = readFileSync(join(home, ".codehost", "setup.sh"), "utf8");
37
+ expect(sh).toContain("$CODEHOST_WS");
38
+ expect(sh).toContain("${ws%/tree/$CODEHOST_BRANCH}");
39
+ expect(sh).toContain("git -C \"$repo\" worktree add");
40
+ });
41
+
42
+ test("idempotent: a second run writes nothing; --force overwrites", () => {
43
+ const home = mkHome();
44
+ expect(scaffoldCodehost(home)).toHaveLength(3);
45
+ expect(scaffoldCodehost(home)).toHaveLength(0);
46
+ expect(scaffoldCodehost(home, true)).toHaveLength(3);
47
+ });
48
+ });
@@ -0,0 +1,103 @@
1
+ import { chmodSync, existsSync, mkdirSync, writeFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+
4
+ // Scaffolds a home's `.codehost/` config dir: an editable, idempotent setup hook
5
+ // (run on every repo open) plus a config.yaml. Provisioning is opt-in by these
6
+ // files existing — `codehost init` is how you opt in.
7
+
8
+ const CONFIG_YAML = `# codehost workspace config — see docs/provisioning.md
9
+
10
+ # Where /gh/<owner>/<repo>/tree/<branch> lands, relative to this home dir.
11
+ # Default (if omitted): {owner}/{repo}/tree/{branch}
12
+ workspace: "ws/{owner}/{repo}/tree/{branch}"
13
+
14
+ # Optional: only these repos may auto-provision (a trailing /* is an owner
15
+ # wildcard). Empty/absent = allow all. The room token already grants access, so
16
+ # this is extra hardening, not the primary gate.
17
+ # allowlist:
18
+ # - github.com/snomiao/*
19
+ `;
20
+
21
+ const SETUP_SH = `#!/usr/bin/env bash
22
+ # codehost provisioning hook. Runs on every open of /gh/<owner>/<repo>/tree/<branch>
23
+ # BEFORE the editor opens CODEHOST_WS. Keep it idempotent: clone/worktree if
24
+ # missing, fast-skip if present. Edit freely (install deps, pull/rebase policy…).
25
+ #
26
+ # Env in: CODEHOST_OWNER CODEHOST_REPO CODEHOST_BRANCH CODEHOST_HOST
27
+ # CODEHOST_HOME (this dir) CODEHOST_WS (the path the editor opens)
28
+ set -euo pipefail
29
+
30
+ ws="$CODEHOST_WS"
31
+ if [ -e "$ws/.git" ]; then
32
+ echo "[setup] $ws already provisioned"
33
+ exit 0
34
+ fi
35
+
36
+ # Primary clone = the workspace path minus the /tree/<branch> tail (layout-agnostic).
37
+ repo="\${ws%/tree/$CODEHOST_BRANCH}"
38
+ url="https://$CODEHOST_HOST/$CODEHOST_OWNER/$CODEHOST_REPO.git"
39
+
40
+ if [ ! -e "$repo/.git" ]; then
41
+ echo "[setup] cloning $url"
42
+ mkdir -p "$(dirname "$repo")"
43
+ git clone "$url" "$repo"
44
+ fi
45
+
46
+ echo "[setup] adding worktree $ws @ $CODEHOST_BRANCH"
47
+ mkdir -p "$(dirname "$ws")"
48
+ git -C "$repo" fetch --quiet origin "$CODEHOST_BRANCH" || true
49
+ git -C "$repo" worktree add "$ws" "$CODEHOST_BRANCH" \\
50
+ || git -C "$repo" worktree add -b "$CODEHOST_BRANCH" "$ws" "origin/$CODEHOST_BRANCH"
51
+
52
+ # Example: install deps for this worktree (uncomment / edit).
53
+ # ( cd "$ws" && bun install )
54
+
55
+ echo "[setup] ready: $ws"
56
+ `;
57
+
58
+ const SETUP_PS1 = `# codehost provisioning hook (Windows). See setup.sh for the contract.
59
+ $ErrorActionPreference = "Stop"
60
+ $ws = $env:CODEHOST_WS
61
+ if (Test-Path (Join-Path $ws ".git")) { Write-Host "[setup] $ws already provisioned"; exit 0 }
62
+
63
+ $repo = $ws -replace "[\\\\/]tree[\\\\/]$([regex]::Escape($env:CODEHOST_BRANCH))$", ""
64
+ $url = "https://$($env:CODEHOST_HOST)/$($env:CODEHOST_OWNER)/$($env:CODEHOST_REPO).git"
65
+
66
+ if (-not (Test-Path (Join-Path $repo ".git"))) {
67
+ Write-Host "[setup] cloning $url"
68
+ New-Item -ItemType Directory -Force -Path (Split-Path $repo) | Out-Null
69
+ git clone $url $repo
70
+ }
71
+ Write-Host "[setup] adding worktree $ws @ $($env:CODEHOST_BRANCH)"
72
+ New-Item -ItemType Directory -Force -Path (Split-Path $ws) | Out-Null
73
+ git -C $repo worktree add $ws $env:CODEHOST_BRANCH
74
+ Write-Host "[setup] ready: $ws"
75
+ `;
76
+
77
+ const FILES: Array<{ rel: string; body: string; exec?: boolean }> = [
78
+ { rel: "config.yaml", body: CONFIG_YAML },
79
+ { rel: "setup.sh", body: SETUP_SH, exec: true },
80
+ { rel: "setup.ps1", body: SETUP_PS1 },
81
+ ];
82
+
83
+ /** Write `<homeDir>/.codehost/{config.yaml,setup.sh,setup.ps1}`. Existing files
84
+ * are kept unless `force`. Returns the list of files actually written. */
85
+ export function scaffoldCodehost(homeDir: string, force = false): string[] {
86
+ const dir = join(homeDir, ".codehost");
87
+ mkdirSync(dir, { recursive: true });
88
+ const written: string[] = [];
89
+ for (const f of FILES) {
90
+ const path = join(dir, f.rel);
91
+ if (existsSync(path) && !force) continue;
92
+ writeFileSync(path, f.body);
93
+ if (f.exec) {
94
+ try {
95
+ chmodSync(path, 0o755);
96
+ } catch {
97
+ // non-POSIX fs — ignore
98
+ }
99
+ }
100
+ written.push(path);
101
+ }
102
+ return written;
103
+ }
@@ -0,0 +1,71 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { mkdtempSync, writeFileSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { agentYesPlugin, readAgents } from "./agent-yes";
6
+ import { routePlugins, withPluginMeta } from "./types";
7
+
8
+ function makeAyDir(lines: object[]): string {
9
+ const dir = mkdtempSync(join(tmpdir(), "codehost-ay-"));
10
+ writeFileSync(join(dir, "pids.jsonl"), lines.map((l) => JSON.stringify(l)).join("\n") + "\n");
11
+ return dir;
12
+ }
13
+
14
+ describe("readAgents", () => {
15
+ test("last line per pid wins; exited and dead pids are dropped", () => {
16
+ const dir = makeAyDir([
17
+ { pid: process.pid, cli: "claude", prompt: "fix the bug", cwd: "/tmp", status: "idle", started_at: 5 },
18
+ { pid: 99999999, cli: "gemini", cwd: "/tmp", status: "active" }, // dead pid
19
+ { pid: process.pid, cli: "claude", status: "exited", exit_code: 0 }, // last line per pid wins
20
+ ]);
21
+ // The final line marks our (live) pid exited, and the other pid is dead.
22
+ expect(readAgents(dir)).toHaveLength(0);
23
+ });
24
+
25
+ test("maps a live record to AgentInfo", () => {
26
+ const dir = makeAyDir([
27
+ { pid: process.pid, cli: "claude", prompt: "do things", cwd: "/tmp/x", status: "active", started_at: 7 },
28
+ ]);
29
+ expect(readAgents(dir)).toEqual([
30
+ { pid: process.pid, tool: "claude", title: "do things", cwd: "/tmp/x", state: "active", startedAt: 7 },
31
+ ]);
32
+ });
33
+
34
+ test("missing registry -> empty", () => {
35
+ expect(readAgents(mkdtempSync(join(tmpdir(), "codehost-ay-empty-")))).toEqual([]);
36
+ });
37
+ });
38
+
39
+ describe("plugin routing + meta", () => {
40
+ test("routePlugins dispatches under /__codehost/<name>/ with the sub-path", async () => {
41
+ const seen: string[] = [];
42
+ const plugin = {
43
+ name: "agent-yes",
44
+ route: async (path: string) => {
45
+ seen.push(path);
46
+ return new Response("ok");
47
+ },
48
+ };
49
+ const res = routePlugins([plugin], {
50
+ method: "GET",
51
+ path: "/__codehost/agent-yes/api/ls?active=1",
52
+ headers: new Headers(),
53
+ });
54
+ expect(res).not.toBeNull();
55
+ await res;
56
+ expect(seen).toEqual(["/api/ls?active=1"]);
57
+ expect(
58
+ routePlugins([plugin], { method: "GET", path: "/__codehost/other/x", headers: new Headers() }),
59
+ ).toBeNull();
60
+ });
61
+
62
+ test("withPluginMeta merges contributions over the base", () => {
63
+ const base = { name: "x", cwd: "/", host: "mac" };
64
+ const merged = withPluginMeta(base, [{ name: "agent-yes", meta: () => ({ agents: [] }) }]);
65
+ expect(merged).toEqual({ name: "x", cwd: "/", host: "mac", agents: [] });
66
+ });
67
+
68
+ test("agentYesPlugin returns null when the dir doesn't exist", () => {
69
+ expect(agentYesPlugin("/nonexistent/definitely-not-here")).toBeNull();
70
+ });
71
+ });