codehost 0.7.1 → 0.9.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,17 @@
1
+ # [0.9.0](https://github.com/snomiao/codehost/compare/v0.8.0...v0.9.0) (2026-06-08)
2
+
3
+
4
+ ### Features
5
+
6
+ * Windows fallback daemon via Scheduled Task (persists + auto-starts at logon) ([cd72bb7](https://github.com/snomiao/codehost/commit/cd72bb7954d05ddf093337b627bcf6b9e9003c53))
7
+
8
+ # [0.8.0](https://github.com/snomiao/codehost/compare/v0.7.1...v0.8.0) (2026-06-08)
9
+
10
+
11
+ ### Features
12
+
13
+ * **web:** filterable workspace list with fake-tags (ay-ls style) ([83dff4f](https://github.com/snomiao/codehost/commit/83dff4ffb813ac93afddf6bb4118b5ffb5278c3b))
14
+
1
15
  ## [0.7.1](https://github.com/snomiao/codehost/compare/v0.7.0...v0.7.1) (2026-06-08)
2
16
 
3
17
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "codehost",
3
- "version": "0.7.1",
3
+ "version": "0.9.0",
4
4
  "type": "module",
5
5
  "repository": {
6
6
  "type": "git",
@@ -3,22 +3,18 @@ import { runSupervisor } from "../fallback-daemon";
3
3
 
4
4
  interface SuperviseArgs {
5
5
  name: string;
6
- argv: string;
7
6
  }
8
7
 
9
- // Hidden internal command: the supervisor process behind a detached fallback
10
- // daemon (see fallback-daemon.ts). Not meant to be run by hand — `serve -d` /
11
- // `setup` spawn it when oxmgr isn't available.
8
+ // Hidden internal command: the supervisor process behind a fallback daemon (see
9
+ // fallback-daemon.ts). Not meant to be run by hand — `serve -d` / `setup` launch
10
+ // it (detached child on POSIX, scheduled task on Windows) when oxmgr isn't
11
+ // available. It reads its serve argv from the registry by name.
12
12
  export const superviseCommand: CommandModule<{}, SuperviseArgs> = {
13
13
  command: "__supervise",
14
14
  describe: false, // hidden from help
15
- builder: (y) =>
16
- y
17
- .option("name", { type: "string", demandOption: true })
18
- .option("argv", { type: "string", demandOption: true, describe: "JSON-encoded serve argv" }) as any,
15
+ builder: (y) => y.option("name", { type: "string", demandOption: true }) as any,
19
16
  handler: async (a) => {
20
- const argv = JSON.parse(a.argv) as string[];
21
- const code = await runSupervisor(a.name, argv);
17
+ const code = await runSupervisor(a.name);
22
18
  process.exit(code);
23
19
  },
24
20
  };
@@ -1,30 +1,38 @@
1
1
  import { spawn, type Subprocess } from "bun";
2
+ import { spawnSync } from "node:child_process";
2
3
  import { mkdirSync, openSync, readFileSync, writeFileSync } from "node:fs";
3
4
  import { homedir } from "node:os";
4
5
  import { dirname, join } from "node:path";
6
+ import { killProcessTree } from "./proc";
5
7
 
6
- // A minimal, non-oxmgr daemon manager: a detached, self-restarting child that
7
- // survives the shell. Used as a fallback when oxmgr's native binary can't run
8
- // here (e.g. broken on a Windows box) so `-d`/`setup` still leave a running
9
- // server instead of failing or re-download-looping. Tracked in a JSON registry
10
- // so `codehost list`/`stop` can see and manage these alongside oxmgr daemons.
11
- //
12
- // Tradeoff vs oxmgr: no login auto-start (that's oxmgr's per-OS service bit). It
13
- // does survive the launching shell and restarts the server on crash.
8
+ // A non-oxmgr daemon manager: keeps a server running across the shell without
9
+ // depending on oxmgr's flaky native binary. Two backends:
10
+ // - Windows: a Scheduled Task (`schtasks`) built in, always runnable, and it
11
+ // also auto-starts the server at logon. Unref'd child processes don't
12
+ // survive their launcher exiting on Windows, so a task is required.
13
+ // - POSIX: a detached, unref'd supervisor child (reparents to init).
14
+ // Both run the same `__supervise` loop (restart-on-failure) and read their serve
15
+ // argv from this registry by name, so the task/launch command stays short.
14
16
 
15
17
  const ROOT = join(homedir(), ".codehost");
16
18
  const REGISTRY = join(ROOT, "daemons.json");
17
19
  const LOG_DIR = join(ROOT, "logs");
20
+ const TASK_DIR = join(ROOT, "tasks");
21
+ const isWindows = process.platform === "win32";
18
22
 
19
23
  export interface FallbackDaemon {
20
24
  name: string;
21
- /** Supervisor process pid. */
22
- pid: number;
23
25
  cwd: string;
24
26
  /** The foreground serve argv the supervisor (re)spawns. */
25
27
  argv: string[];
26
28
  log: string;
27
29
  startedAt: number;
30
+ /** POSIX: supervisor process pid. */
31
+ pid?: number;
32
+ /** Windows: scheduled-task name (equals `name`). */
33
+ task?: string;
34
+ /** Pid of the serve child the supervisor last spawned (for tree-kill on stop). */
35
+ servePid?: number;
28
36
  }
29
37
 
30
38
  function readRegistry(): FallbackDaemon[] {
@@ -40,6 +48,18 @@ function writeRegistry(list: FallbackDaemon[]): void {
40
48
  writeFileSync(REGISTRY, JSON.stringify(list, null, 2));
41
49
  }
42
50
 
51
+ function upsert(entry: FallbackDaemon): void {
52
+ writeRegistry([...readRegistry().filter((d) => d.name !== entry.name), entry]);
53
+ }
54
+
55
+ function patch(name: string, fields: Partial<FallbackDaemon>): void {
56
+ const list = readRegistry();
57
+ const hit = list.find((d) => d.name === name);
58
+ if (!hit) return;
59
+ Object.assign(hit, fields);
60
+ writeRegistry(list);
61
+ }
62
+
43
63
  /** True if a pid is currently alive (signal 0 probe). */
44
64
  export function isAlive(pid: number): boolean {
45
65
  try {
@@ -50,67 +70,113 @@ export function isAlive(pid: number): boolean {
50
70
  }
51
71
  }
52
72
 
73
+ function schtasks(args: string[]): number {
74
+ return spawnSync("schtasks", args, { stdio: "ignore" }).status ?? 1;
75
+ }
76
+
77
+ /** True if a Windows scheduled task with this name is registered. */
78
+ function taskExists(name: string): boolean {
79
+ return spawnSync("schtasks", ["/query", "/tn", name], { stdio: "ignore" }).status === 0;
80
+ }
81
+
53
82
  /**
54
- * Start (replacing any same-named instance) a detached, self-restarting daemon
55
- * that runs `argv` from `cwd`, with output appended to a per-daemon log. Returns
56
- * false if the supervisor couldn't be spawned.
83
+ * Start (replacing any same-named instance) a daemon that runs `argv` from
84
+ * `cwd`. Returns false if it couldn't be started. The registry entry is written
85
+ * first so the supervisor can read its argv by name.
57
86
  */
58
87
  export function startFallbackDaemon(opts: { name: string; argv: string[]; cwd: string }): boolean {
59
- stopFallbackDaemon(opts.name); // replace any previous instance with this name
88
+ stopFallbackDaemon(opts.name); // replace any previous instance
60
89
 
61
90
  mkdirSync(LOG_DIR, { recursive: true });
62
91
  const log = join(LOG_DIR, `${opts.name}.log`);
92
+ upsert({ name: opts.name, cwd: opts.cwd, argv: opts.argv, log, startedAt: Date.now() });
93
+
94
+ return isWindows ? startWindowsTask(opts.name, log) : startUnixSupervisor(opts.name, log);
95
+ }
96
+
97
+ /** POSIX: detached, unref'd supervisor child that survives as an orphan. */
98
+ function startUnixSupervisor(name: string, log: string): boolean {
63
99
  const fd = openSync(log, "a");
64
- const proc = spawn(
65
- [process.execPath, process.argv[1], "__supervise", "--name", opts.name, "--argv", JSON.stringify(opts.argv)],
66
- { cwd: opts.cwd, stdin: "ignore", stdout: fd, stderr: fd },
67
- );
68
- // Detach so the launching process (setup / serve -d) can exit while this keeps
69
- // running as an orphan.
100
+ const proc = spawn([process.execPath, process.argv[1], "__supervise", "--name", name], {
101
+ stdin: "ignore",
102
+ stdout: fd,
103
+ stderr: fd,
104
+ });
70
105
  proc.unref();
71
106
  if (!proc.pid) return false;
72
-
73
- const list = readRegistry().filter((d) => d.name !== opts.name);
74
- list.push({ name: opts.name, pid: proc.pid, cwd: opts.cwd, argv: opts.argv, log, startedAt: Date.now() });
75
- writeRegistry(list);
107
+ patch(name, { pid: proc.pid });
76
108
  return true;
77
109
  }
78
110
 
79
- /** Live detached daemons (dead registry entries are pruned as a side effect). */
111
+ /** Windows: a Scheduled Task running the supervisor, with output redirected to
112
+ * the log via a small launcher .cmd (avoids schtasks /tr quoting limits). It
113
+ * auto-starts at logon and is started immediately. */
114
+ function startWindowsTask(name: string, log: string): boolean {
115
+ mkdirSync(TASK_DIR, { recursive: true });
116
+ const cmdPath = join(TASK_DIR, `${name}.cmd`);
117
+ const cmd = `@echo off\r\n"${process.execPath}" "${process.argv[1]}" __supervise --name "${name}" >> "${log}" 2>&1\r\n`;
118
+ writeFileSync(cmdPath, cmd);
119
+
120
+ // Create the task (onlogon for a normal user; onstart when running elevated as
121
+ // SYSTEM, where there's no interactive logon), then run it now.
122
+ let created = schtasks(["/create", "/tn", name, "/tr", cmdPath, "/sc", "onlogon", "/f"]);
123
+ if (created !== 0) created = schtasks(["/create", "/tn", name, "/tr", cmdPath, "/sc", "onstart", "/f"]);
124
+ if (created !== 0) return false;
125
+ patch(name, { task: name });
126
+ return schtasks(["/run", "/tn", name]) === 0;
127
+ }
128
+
129
+ /** Live daemons. Dead POSIX supervisors are pruned; Windows entries persist as
130
+ * long as their task is still registered (a Ready task is a valid auto-start). */
80
131
  export function listFallbackDaemons(): FallbackDaemon[] {
81
132
  const list = readRegistry();
82
- const alive = list.filter((d) => isAlive(d.pid));
133
+ const alive = list.filter((d) => (d.task ? taskExists(d.task) : d.pid != null && isAlive(d.pid)));
83
134
  if (alive.length !== list.length) writeRegistry(alive);
84
135
  return alive;
85
136
  }
86
137
 
87
- /** Stop and deregister a detached daemon by name. Returns false if not found. */
138
+ /** Stop and deregister a daemon by name. Returns false if not found. */
88
139
  export function stopFallbackDaemon(name: string): boolean {
89
140
  const list = readRegistry();
90
141
  const hit = list.find((d) => d.name === name);
91
142
  if (!hit) return false;
92
- try {
93
- process.kill(hit.pid); // SIGTERM -> supervisor kills its child, then exits
94
- } catch {
95
- // already gone
143
+ if (hit.task) {
144
+ schtasks(["/end", "/tn", hit.task]);
145
+ schtasks(["/delete", "/tn", hit.task, "/f"]);
146
+ // /end hard-terminates the task's top process; kill the serve subtree (VS
147
+ // Code) too so it doesn't orphan.
148
+ if (hit.servePid) killProcessTree(hit.servePid);
149
+ } else if (hit.pid != null) {
150
+ try {
151
+ process.kill(hit.pid); // SIGTERM -> supervisor kills its child, then exits
152
+ } catch {
153
+ // already gone
154
+ }
96
155
  }
97
156
  writeRegistry(list.filter((d) => d.name !== name));
98
157
  return true;
99
158
  }
100
159
 
101
160
  /**
102
- * Supervisor body (run via the hidden `__supervise` command). Runs the serve
103
- * argv, restarting it on a non-zero exit with capped exponential backoff; stops
104
- * when the child exits cleanly or when this process receives SIGTERM/SIGINT
105
- * (killing the child first). Output goes to the inherited log fd.
161
+ * Supervisor body (run via the hidden `__supervise` command). Loads its serve
162
+ * argv + cwd from the registry by name, runs it, and restarts it on a non-zero
163
+ * exit with capped exponential backoff; stops when the child exits cleanly or on
164
+ * SIGTERM/SIGINT (killing the child first).
106
165
  */
107
- export async function runSupervisor(name: string, argv: string[]): Promise<number> {
166
+ export async function runSupervisor(name: string): Promise<number> {
167
+ const entry = readRegistry().find((d) => d.name === name);
168
+ if (!entry) {
169
+ console.error(`[codehost:${name}] no registry entry; nothing to supervise.`);
170
+ return 1;
171
+ }
172
+ const { argv, cwd } = entry;
173
+
108
174
  let child: Subprocess | null = null;
109
175
  let stopping = false;
110
176
  const onSignal = () => {
111
177
  stopping = true;
112
178
  try {
113
- child?.kill();
179
+ if (child?.pid) killProcessTree(child.pid);
114
180
  } catch {
115
181
  // ignore
116
182
  }
@@ -121,7 +187,8 @@ export async function runSupervisor(name: string, argv: string[]): Promise<numbe
121
187
  let attempt = 0;
122
188
  while (!stopping) {
123
189
  console.log(`[codehost:${name}] starting: ${argv.join(" ")}`);
124
- child = spawn(argv, { cwd: process.cwd(), stdin: "ignore", stdout: "inherit", stderr: "inherit" });
190
+ child = spawn(argv, { cwd, stdin: "ignore", stdout: "inherit", stderr: "inherit" });
191
+ patch(name, { servePid: child.pid });
125
192
  const code = await child.exited;
126
193
  if (stopping) break;
127
194
  if (code === 0) {
@@ -0,0 +1,56 @@
1
+ import { expect, test } from "bun:test";
2
+ import { deriveTags, matchQuery, matchToken, shortRoomLabel } from "./tags";
3
+
4
+ test("deriveTags builds mnemonic tags from meta", () => {
5
+ const tags = deriveTags(
6
+ { name: "x", host: "mbp", cwd: "/ws/a", kind: "repo", repo: "github.com/o/r", branch: "main" },
7
+ { roomLabel: "ab12" },
8
+ );
9
+ expect(tags).toContain("room:ab12");
10
+ expect(tags).toContain("host:mbp");
11
+ expect(tags).toContain("kind:repo");
12
+ expect(tags).toContain("repo:github.com/o/r");
13
+ expect(tags).toContain("wt:main");
14
+ expect(tags).toContain("cwd:/ws/a");
15
+ });
16
+
17
+ test("deriveTags defaults kind to repo and omits absent fields", () => {
18
+ const tags = deriveTags({ name: "x", host: "h", cwd: "" });
19
+ expect(tags).toContain("kind:repo");
20
+ expect(tags).not.toContain("cwd:");
21
+ expect(tags.some((t) => t.startsWith("repo:"))).toBe(false);
22
+ });
23
+
24
+ test("key:value matches by tag key + substring value", () => {
25
+ const e = { name: "server", tags: ["repo:github.com/snomiao/codehost", "host:mbp"] };
26
+ expect(matchToken(e, "repo:codehost")).toBe(true);
27
+ expect(matchToken(e, "repo:other")).toBe(false);
28
+ expect(matchToken(e, "host:mbp")).toBe(true);
29
+ });
30
+
31
+ test("bare text is a substring across name and tags", () => {
32
+ const e = { name: "my-laptop", tags: ["repo:github.com/snomiao/codehost"] };
33
+ expect(matchToken(e, "snomiao")).toBe(true);
34
+ expect(matchToken(e, "laptop")).toBe(true);
35
+ expect(matchToken(e, "nope")).toBe(false);
36
+ });
37
+
38
+ test("bare numeric is an exact pid match", () => {
39
+ const e = { name: "x", pid: "1234", tags: [] };
40
+ expect(matchToken(e, "1234")).toBe(true);
41
+ expect(matchToken(e, "123")).toBe(false);
42
+ });
43
+
44
+ test("matchQuery ANDs all tokens", () => {
45
+ const e = { name: "x", tags: ["host:mbp", "repo:github.com/o/codehost", "kind:repo"] };
46
+ expect(matchQuery(e, "codehost host:mbp")).toBe(true);
47
+ expect(matchQuery(e, "codehost host:other")).toBe(false);
48
+ expect(matchQuery(e, "")).toBe(true);
49
+ });
50
+
51
+ test("shortRoomLabel is stable and exactly 4 chars", () => {
52
+ const t = "super-secret-token-value-1234567890";
53
+ expect(shortRoomLabel(t)).toBe(shortRoomLabel(t));
54
+ expect(shortRoomLabel(t)).toHaveLength(4);
55
+ expect(shortRoomLabel("another-token")).not.toBe(shortRoomLabel(t));
56
+ });
@@ -0,0 +1,83 @@
1
+ // "Fake-tags": searchable mnemonics derived from a server's advertised meta
2
+ // (host, cwd, repo, branch, room). They exist only so a workspace list is easy
3
+ // to scan, filter, and remember — they are user-mutable and may collide across
4
+ // machines, so the system never addresses anything by them. Canonical identity
5
+ // stays in the peerId + room token.
6
+ //
7
+ // The matcher mirrors `ay ls`: identity-ish fields match exactly, human text
8
+ // matches as a substring, and a `key:value` token matches a tag by key.
9
+
10
+ import type { PeerMeta } from "./signaling";
11
+
12
+ /**
13
+ * A short, non-secret label for a room token. The token is a bearer secret —
14
+ * it lives only in the URL fragment and is never sent to a server (see
15
+ * `tokenFromHash`) — so it must never be rendered into the DOM. This derives a
16
+ * stable 4-char tag from it (FNV-1a, base36) for display/filtering instead.
17
+ */
18
+ export function shortRoomLabel(token: string): string {
19
+ let h = 0x811c9dc5;
20
+ for (let i = 0; i < token.length; i++) {
21
+ h ^= token.charCodeAt(i);
22
+ h = Math.imul(h, 0x01000193);
23
+ }
24
+ return (h >>> 0).toString(36).slice(0, 4).padStart(4, "0");
25
+ }
26
+
27
+ /** Build the mnemonic tag list for a server from its advertised metadata. */
28
+ export function deriveTags(meta: PeerMeta | null, opts: { roomLabel?: string } = {}): string[] {
29
+ const tags: string[] = [];
30
+ if (opts.roomLabel) tags.push(`room:${opts.roomLabel}`);
31
+ if (meta?.host) tags.push(`host:${meta.host}`);
32
+ tags.push(`kind:${meta?.kind ?? "repo"}`);
33
+ if (meta?.repo) tags.push(`repo:${meta.repo}`);
34
+ if (meta?.branch) tags.push(`wt:${meta.branch}`);
35
+ if (meta?.cwd) tags.push(`cwd:${meta.cwd}`);
36
+ return tags;
37
+ }
38
+
39
+ /** The key part of a `key:value` tag (empty string for an unkeyed tag). */
40
+ export function tagKey(tag: string): string {
41
+ const i = tag.indexOf(":");
42
+ return i < 0 ? "" : tag.slice(0, i);
43
+ }
44
+
45
+ function tagValue(tag: string): string {
46
+ const i = tag.indexOf(":");
47
+ return i < 0 ? tag : tag.slice(i + 1);
48
+ }
49
+
50
+ export interface Filterable {
51
+ tags: string[];
52
+ name: string;
53
+ /** Optional numeric id (e.g. a pid); matched exactly by a bare-numeric token. */
54
+ pid?: string;
55
+ }
56
+
57
+ /**
58
+ * Match one whitespace-delimited query token against an entry, `ay ls` style:
59
+ * - `key:value` → some tag with that key whose value contains `value` (substring)
60
+ * - bare numeric → exact pid
61
+ * - bare text → substring over the name or any tag (value or key)
62
+ */
63
+ export function matchToken(e: Filterable, token: string): boolean {
64
+ const tok = token.toLowerCase();
65
+ if (!tok) return true;
66
+ const ci = tok.indexOf(":");
67
+ if (ci > 0) {
68
+ const k = tok.slice(0, ci);
69
+ const v = tok.slice(ci + 1);
70
+ return e.tags.some(
71
+ (t) => tagKey(t).toLowerCase() === k && (v === "" || tagValue(t).toLowerCase().includes(v)),
72
+ );
73
+ }
74
+ if (/^\d+$/.test(tok)) return e.pid === tok; // identity: exact
75
+ if (e.name.toLowerCase().includes(tok)) return true;
76
+ return e.tags.some((t) => t.toLowerCase().includes(tok)); // text: substring
77
+ }
78
+
79
+ /** Every whitespace-separated token must match (AND), like `ay ls [keyword]`. */
80
+ export function matchQuery(e: Filterable, query: string): boolean {
81
+ const tokens = query.trim().toLowerCase().split(/\s+/).filter(Boolean);
82
+ return tokens.every((t) => matchToken(e, t));
83
+ }
@@ -18,6 +18,7 @@ import {
18
18
  shareableDeepLink,
19
19
  } from "../shared/repo";
20
20
  import { addRoom, getRooms, historyFor, recordConnection } from "./history";
21
+ import { deriveTags, matchQuery, shortRoomLabel, tagKey } from "../shared/tags";
21
22
 
22
23
  const TOKEN_KEY = "codehost.token";
23
24
 
@@ -107,6 +108,11 @@ export function Discovery() {
107
108
  const [connected, setConnected] = useState(false);
108
109
  const [servers, setServers] = useState<PeerInfo[]>([]);
109
110
 
111
+ // Fake-tag filter over the workspace list: a free-text box plus a set of
112
+ // pinned tag tokens (chips). Both feed the same `ay ls`-style AND matcher.
113
+ const [filter, setFilter] = useState("");
114
+ const [activeTags, setActiveTags] = useState<string[]>([]);
115
+
110
116
  // Active WebRTC connection to one server (Phase 2: echo test).
111
117
  const [activePeerId, setActivePeerId] = useState<string | null>(null);
112
118
  const [connState, setConnState] = useState<ConnState>("idle");
@@ -355,6 +361,28 @@ export function Discovery() {
355
361
 
356
362
  const activeServer = servers.find((s) => s.peerId === activePeerId);
357
363
 
364
+ // Annotate each server with its mnemonic fake-tags, then filter. The room
365
+ // token is hashed to a short label — never rendered raw (it's a bearer secret).
366
+ const roomLabel = token ? shortRoomLabel(token) : "";
367
+ const tagged = servers.map((s) => ({
368
+ server: s,
369
+ name: s.meta?.name ?? s.peerId.slice(0, 8),
370
+ tags: deriveTags(s.meta, { roomLabel }),
371
+ }));
372
+ const query = [...activeTags, filter].join(" ");
373
+ const filtered = tagged.filter((t) => matchQuery({ name: t.name, tags: t.tags }, query));
374
+ const toggleTag = (t: string) =>
375
+ setActiveTags((a) => (a.includes(t) ? a.filter((x) => x !== t) : [...a, t]));
376
+ const addTag = (t: string) => setActiveTags((a) => (a.includes(t) ? a : [...a, t]));
377
+ // Suggested chips: the most common identity/location tags across the list.
378
+ const tagFreq = new Map<string, number>();
379
+ for (const t of tagged) for (const tag of t.tags) tagFreq.set(tag, (tagFreq.get(tag) ?? 0) + 1);
380
+ const suggestedTags = [...tagFreq.entries()]
381
+ .sort((a, b) => b[1] - a[1])
382
+ .map(([t]) => t)
383
+ .filter((t) => ["host", "repo", "wt", "kind", "room"].includes(tagKey(t)))
384
+ .slice(0, 12);
385
+
358
386
  // Connected view: VS Code in an iframe, served over the tunnel.
359
387
  if (iframeSrc && connState === "connected") {
360
388
  return (
@@ -421,25 +449,64 @@ export function Discovery() {
421
449
  <p style={styles.tokenHint}>Token requires {TOKEN_REQUIREMENTS}.</p>
422
450
  )}
423
451
 
424
- <h2 style={styles.h2}>VS Code servers</h2>
425
- {!token && <p style={styles.dim}>Enter a token to see your servers.</p>}
452
+ <div style={styles.listHead}>
453
+ <h2 style={styles.h2}>Workspaces</h2>
454
+ {token && servers.length > 0 && (
455
+ <span style={styles.count}>
456
+ {filtered.length === tagged.length
457
+ ? `${tagged.length}`
458
+ : `${filtered.length} / ${tagged.length}`}
459
+ </span>
460
+ )}
461
+ </div>
462
+ {!token && <p style={styles.dim}>Enter a token to see your workspaces.</p>}
426
463
  {token && servers.length === 0 && (
427
464
  <p style={styles.dim}>
428
465
  No servers online. Run{" "}
429
466
  <code style={styles.code}>bunx codehost serve -t {token || "<token>"}</code> on a machine.
430
467
  </p>
431
468
  )}
469
+ {token && servers.length > 0 && (
470
+ <>
471
+ <input
472
+ value={filter}
473
+ onChange={(e) => setFilter(e.target.value)}
474
+ placeholder="filter… e.g. repo:codehost host:mbp (space = AND)"
475
+ style={styles.search}
476
+ />
477
+ {(activeTags.length > 0 || suggestedTags.length > 0) && (
478
+ <div style={styles.chipRow}>
479
+ {activeTags.map((t) => (
480
+ <button key={t} style={{ ...styles.chip, ...styles.chipActive }} onClick={() => toggleTag(t)}>
481
+ {t} ✕
482
+ </button>
483
+ ))}
484
+ {suggestedTags
485
+ .filter((t) => !activeTags.includes(t))
486
+ .map((t) => (
487
+ <button key={t} style={styles.chip} onClick={() => toggleTag(t)}>
488
+ {t}
489
+ </button>
490
+ ))}
491
+ </div>
492
+ )}
493
+ </>
494
+ )}
432
495
  <ul style={styles.list}>
433
- {servers.map((s) => {
496
+ {filtered.map(({ server: s, name, tags }) => {
434
497
  const isActive = s.peerId === activePeerId;
435
498
  return (
436
499
  <li key={s.peerId} style={styles.card}>
437
500
  <div style={styles.cardMain}>
438
- <div style={styles.cardName}>{s.meta?.name ?? s.peerId.slice(0, 8)}</div>
439
- <div style={styles.cardSub}>
440
- {s.meta?.host && <span>{s.meta.host}</span>}
441
- {s.meta?.cwd && <span style={styles.cwd}>{s.meta.cwd}</span>}
501
+ <div style={styles.cardName}>{name}</div>
502
+ <div style={styles.tagRow}>
503
+ {tags.map((tag) => (
504
+ <button key={tag} style={styles.tag} onClick={() => addTag(tag)} title={`filter by ${tag}`}>
505
+ {tag}
506
+ </button>
507
+ ))}
442
508
  </div>
509
+ <div style={styles.idLine}>peer {s.peerId.slice(0, 8)}</div>
443
510
  {isActive && (
444
511
  <div style={styles.echo}>
445
512
  {connState === "connecting" && "negotiating WebRTC…"}
@@ -457,6 +524,9 @@ export function Discovery() {
457
524
  </li>
458
525
  );
459
526
  })}
527
+ {token && servers.length > 0 && filtered.length === 0 && (
528
+ <p style={styles.dim}>No workspace matches your filter.</p>
529
+ )}
460
530
  </ul>
461
531
  </main>
462
532
  </div>
@@ -476,7 +546,26 @@ const styles: Record<string, React.CSSProperties> = {
476
546
  label: { fontSize: 12, color: "#888" },
477
547
  input: { flex: 1, background: "#252525", border: "1px solid #3d3d3d", color: "#eee", padding: "8px 10px", borderRadius: 6, fontSize: 13, outline: "none" },
478
548
  button: { background: "#0e639c", border: "none", color: "#fff", padding: "8px 16px", borderRadius: 6, cursor: "pointer", fontSize: 13 },
479
- h2: { fontSize: 14, color: "#aaa", fontWeight: 600, margin: "0 0 12px" },
549
+ listHead: { display: "flex", alignItems: "baseline", gap: 10, margin: "0 0 12px" },
550
+ h2: { fontSize: 14, color: "#aaa", fontWeight: 600, margin: 0 },
551
+ count: { fontSize: 12, color: "#888", fontFamily: "monospace" },
552
+ search: {
553
+ width: "100%", boxSizing: "border-box", background: "#252525", border: "1px solid #3d3d3d",
554
+ color: "#eee", padding: "8px 10px", borderRadius: 6, fontSize: 13, outline: "none",
555
+ fontFamily: "monospace", marginBottom: 10,
556
+ },
557
+ chipRow: { display: "flex", flexWrap: "wrap", gap: 6, marginBottom: 14 },
558
+ chip: {
559
+ fontFamily: "monospace", fontSize: 11.5, padding: "2px 8px", borderRadius: 999,
560
+ border: "1px solid #3d3d3d", background: "transparent", color: "#9aa4af", cursor: "pointer",
561
+ },
562
+ chipActive: { background: "#0e639c", borderColor: "#0e639c", color: "#fff" },
563
+ tagRow: { display: "flex", flexWrap: "wrap", gap: 5, marginTop: 6 },
564
+ tag: {
565
+ fontFamily: "monospace", fontSize: 11, padding: "1px 7px", borderRadius: 999,
566
+ border: "1px solid #3d3d3d", background: "transparent", color: "#9aa4af", cursor: "pointer",
567
+ },
568
+ idLine: { fontFamily: "monospace", fontSize: 11, color: "#666", marginTop: 6 },
480
569
  code: { background: "#252525", padding: "2px 6px", borderRadius: 4, fontFamily: "monospace", fontSize: 12 },
481
570
  list: { listStyle: "none", margin: 0, padding: 0, display: "flex", flexDirection: "column", gap: 8 },
482
571
  card: { display: "flex", alignItems: "center", gap: 12, background: "#252525", border: "1px solid #3d3d3d", borderRadius: 8, padding: "12px 14px" },