codehost 0.16.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 +17 -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 +54 -10
- package/src/cli/config.test.ts +31 -0
- package/src/cli/config.ts +20 -7
- package/src/cli/daemonize.ts +4 -0
- package/src/cli/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
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,20 @@
|
|
|
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
|
+
|
|
1
18
|
# [0.16.0](https://github.com/snomiao/codehost/compare/v0.15.0...v0.16.0) (2026-06-10)
|
|
2
19
|
|
|
3
20
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "codehost",
|
|
3
|
-
"version": "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",
|
package/src/cli/commands/dev.ts
CHANGED
|
@@ -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 {
|
|
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>
|
|
@@ -4,10 +4,22 @@ import type { CommandModule } from "yargs";
|
|
|
4
4
|
import type { PeerMeta } from "../../shared/signaling";
|
|
5
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,22 +96,54 @@ 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
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
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}`,
|
|
104
148
|
provision: { homeDir: dir, host: GITHUB_HOST },
|
|
105
149
|
launch: async (basePath) => {
|
|
@@ -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)
|
|
7
|
-
//
|
|
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(
|
|
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(
|
|
26
|
-
writeFileSync(
|
|
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
|
}
|
package/src/cli/daemonize.ts
CHANGED
|
@@ -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
|
|
|
@@ -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
|
+
});
|
|
@@ -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
|
+
}
|
|
@@ -18,9 +18,12 @@ export interface ProvisionDeps {
|
|
|
18
18
|
homeDir: string;
|
|
19
19
|
/** Git host advertised by this daemon (default github.com). */
|
|
20
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;
|
|
21
24
|
}
|
|
22
25
|
|
|
23
|
-
interface CodehostConfig {
|
|
26
|
+
export interface CodehostConfig {
|
|
24
27
|
workspace?: string; // layout template, e.g. "ws/{owner}/{repo}/tree/{branch}"
|
|
25
28
|
allowlist?: string[];
|
|
26
29
|
}
|
|
@@ -30,7 +33,7 @@ export function isProvisionPath(path: string): boolean {
|
|
|
30
33
|
return path.split("?")[0] === PROVISION_PATH;
|
|
31
34
|
}
|
|
32
35
|
|
|
33
|
-
function
|
|
36
|
+
export function readCodehostConfig(homeDir: string): CodehostConfig {
|
|
34
37
|
try {
|
|
35
38
|
const raw = readFileSync(join(homeDir, ".codehost", "config.yaml"), "utf8");
|
|
36
39
|
const c = (parseYaml(raw) ?? {}) as Record<string, unknown>;
|
|
@@ -74,7 +77,7 @@ export async function handleProvision(rawPath: string, deps: ProvisionDeps): Pro
|
|
|
74
77
|
if (!v.ok) return json(400, { error: v.reason });
|
|
75
78
|
|
|
76
79
|
const host = (url.searchParams.get("host") ?? deps.host).toLowerCase();
|
|
77
|
-
const cfg =
|
|
80
|
+
const cfg = readCodehostConfig(deps.homeDir);
|
|
78
81
|
const key = repoKey({ host, owner: v.target.owner, name: v.target.repo });
|
|
79
82
|
if (!repoAllowed(key, cfg.allowlist)) return json(403, { error: `repo not allowlisted: ${key}` });
|
|
80
83
|
|
|
@@ -152,6 +155,11 @@ function freshBody(
|
|
|
152
155
|
resolveDone(code);
|
|
153
156
|
say(`\n::codehost:exit=${code}\n`);
|
|
154
157
|
controller.close();
|
|
158
|
+
try {
|
|
159
|
+
deps.onProvisioned?.();
|
|
160
|
+
} catch {
|
|
161
|
+
// advertising is best-effort; never fail the provision response
|
|
162
|
+
}
|
|
155
163
|
}
|
|
156
164
|
},
|
|
157
165
|
});
|
|
@@ -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
|
+
});
|