@tsconfigdotjson/vibe-os 0.2.0-rc.1 → 0.3.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.
Files changed (45) hide show
  1. package/README.md +19 -847
  2. package/THIRD_PARTY_NOTICES.md +28 -0
  3. package/dist/web/assets/index-82MhMOB4.css +1 -0
  4. package/dist/web/assets/index-82MhMOB4.css.br +0 -0
  5. package/dist/web/assets/index-82MhMOB4.css.gz +0 -0
  6. package/dist/web/assets/index-DCB2nO0u.js +36 -0
  7. package/dist/web/assets/index-DCB2nO0u.js.br +0 -0
  8. package/dist/web/assets/index-DCB2nO0u.js.gz +0 -0
  9. package/dist/web/index.html +2 -2
  10. package/package.json +2 -1
  11. package/scripts/notices.ts +3 -1
  12. package/server/api.ts +449 -20
  13. package/server/assets.generated.ts +13 -13
  14. package/server/attach.ts +65 -16
  15. package/server/browser.ts +1 -65
  16. package/server/cli.ts +48 -150
  17. package/server/client.ts +440 -0
  18. package/server/config.ts +64 -2
  19. package/server/db.ts +19 -0
  20. package/server/doctor.ts +176 -123
  21. package/server/harness.ts +378 -590
  22. package/server/harnesses/claude.ts +248 -0
  23. package/server/harnesses/codex.ts +241 -0
  24. package/server/harnesses/cursor.ts +202 -0
  25. package/server/harnesses/hermes.ts +259 -0
  26. package/server/harnesses/util.ts +181 -0
  27. package/server/headless.ts +358 -0
  28. package/server/index.ts +47 -14
  29. package/server/memory.ts +418 -0
  30. package/server/profiles.ts +27 -15
  31. package/server/projects.ts +52 -3
  32. package/server/reaper.ts +5 -3
  33. package/server/screens.ts +372 -0
  34. package/server/session.ts +84 -95
  35. package/server/setup.ts +73 -7
  36. package/server/windows.ts +51 -1
  37. package/shared/blanks.ts +113 -0
  38. package/shared/harness.ts +375 -0
  39. package/shared/wire.ts +105 -94
  40. package/dist/web/assets/index-B77Qk8B5.js +0 -35
  41. package/dist/web/assets/index-B77Qk8B5.js.br +0 -0
  42. package/dist/web/assets/index-B77Qk8B5.js.gz +0 -0
  43. package/dist/web/assets/index-CH5Em-Mo.css +0 -1
  44. package/dist/web/assets/index-CH5Em-Mo.css.br +0 -0
  45. package/dist/web/assets/index-CH5Em-Mo.css.gz +0 -0
package/server/harness.ts CHANGED
@@ -1,655 +1,443 @@
1
- // What the agent CLIs on this machine can be told to do.
1
+ // The harnesses a profile can launch, and what each one can be told to do.
2
2
  //
3
- // The profile editor offers models and permission modes as dropdowns, which
4
- // means something has to know what the valid values are. Hardcoding them would
5
- // be wrong within a release or two — Claude ships new models faster than this
6
- // project ships anything — so they are read off the binary that is actually
7
- // installed. Update Claude Code and the dropdowns follow.
3
+ // A harness is an adapter in `harnesses/`: a spec (the command, the flags the
4
+ // editor offers, how to resume) plus optional code for discovery, session
5
+ // setup and doctor checks. Adding one is one file and one line in `BUILT_IN`.
8
6
  //
9
- // Hermes answers a narrower version of the same question. It has no listing
10
- // mode at all (`hermes model` is an interactive wizard), so what is discoverable
11
- // is its own configuration: which providers it has been set up with, what it
12
- // would use by default, and where its browser tools will land.
13
- //
14
- // Cursor sits between the two. `cursor-agent models` is a real listing mode,
15
- // but it lists the account's models rather than the binary's — so the answer
16
- // exists only while the box is logged in, and an empty list means "could not
17
- // read" rather than "nothing to offer".
18
-
19
- import { execFile } from "node:child_process";
20
- import { readFile, stat } from "node:fs/promises";
21
- import { promisify } from "node:util";
22
- import { CHROME_UNIT_PATH, cdpUrlMatches, portsFromUnits } from "./browser.ts";
7
+ // A box can add its own without a fork, as data: every
8
+ // `<state dir>/harnesses/*.json` is a spec. Those get the generic discovery
9
+ // (found on PATH, `--version`, and a login check if the spec names one) and no
10
+ // code of their own.
11
+
12
+ import { readdirSync, readFileSync } from "node:fs";
13
+ import path from "node:path";
14
+ import {
15
+ CUSTOM,
16
+ type HarnessReport,
17
+ type HarnessSpec,
18
+ SHELL,
19
+ } from "../shared/harness.ts";
20
+ import { claude } from "./harnesses/claude.ts";
21
+ import { codex } from "./harnesses/codex.ts";
22
+ import { cursor } from "./harnesses/cursor.ts";
23
+ import { hermes } from "./harnesses/hermes.ts";
24
+ import {
25
+ type Discovery,
26
+ type HarnessAdapter,
27
+ loggedIn,
28
+ output,
29
+ resolveBinary,
30
+ run,
31
+ versionToken,
32
+ } from "./harnesses/util.ts";
23
33
  import { log } from "./log.ts";
24
34
 
25
- const run = promisify(execFile);
26
-
27
- import type { CursorInfo, HarnessInfo, HermesInfo } from "../shared/wire.ts";
28
-
29
- export type { CursorInfo, HarnessInfo, HermesInfo };
30
-
31
- /**
32
- * The effort ladder as of writing, used only when the binary cannot be read.
33
- *
34
- * Unlike the model aliases, this is not merged with what was discovered. Effort
35
- * is ordinal — the list *is* the scale, low to max — so a union of two sources
36
- * would put a newly-added level in the wrong place and quietly mislabel how
37
- * hard a role thinks. Either the binary's order or this one, never a blend.
38
- */
39
- const KNOWN_EFFORT = ["low", "medium", "high", "xhigh", "max"];
40
-
41
- const EMPTY: HarnessInfo = {
42
- available: false,
43
- version: null,
44
- aliases: [],
45
- models: [],
46
- permissionModes: [],
47
- effortLevels: KNOWN_EFFORT,
48
- };
49
-
50
- /**
51
- * Aliases the CLI documents but which do not appear as model ids.
52
- *
53
- * Kept as a floor rather than the source of truth: whatever is found in the
54
- * binary is merged over the top, so a new tier appears on its own.
55
- */
56
- const KNOWN_ALIASES = [
57
- "default",
58
- "opus",
59
- "sonnet",
60
- "haiku",
61
- "fable",
62
- "opusplan",
35
+ export { CLAUDE_INSTALL } from "./harnesses/claude.ts";
36
+ export { CODEX_INSTALL } from "./harnesses/codex.ts";
37
+ export { CURSOR_INSTALL } from "./harnesses/cursor.ts";
38
+ export { HERMES_INSTALL } from "./harnesses/hermes.ts";
39
+ export type { HarnessAdapter, HarnessCheck } from "./harnesses/util.ts";
40
+
41
+ const BUILT_IN: HarnessAdapter[] = [
42
+ claude as HarnessAdapter,
43
+ hermes as HarnessAdapter,
44
+ cursor,
45
+ codex,
63
46
  ];
64
47
 
65
- /**
66
- * Where the native installers put things, when PATH does not say so.
67
- *
68
- * A login shell picks `~/.local/bin` up from `.profile`; a systemd service does
69
- * not, and vibe-os is meant to run as one. Without this the server cannot find
70
- * a Claude that the harness will happily launch a moment later, because the
71
- * harness runs under a login shell and the server does not — so the dropdowns
72
- * come up empty on exactly the deployment the docs recommend.
73
- */
74
- const CLAUDE_PATHS = [
75
- `${process.env.HOME ?? ""}/.local/bin/claude`,
76
- "/usr/local/bin/claude",
77
- "/opt/claude/.local/bin/claude",
78
- ];
48
+ /** Ids a user file cannot take: the built-ins and the two pseudo-harnesses. */
49
+ const RESERVED = new Set([SHELL, CUSTOM, ...BUILT_IN.map((a) => a.spec.id)]);
79
50
 
80
- /**
81
- * The same problem for Hermes, whose installer offers two layouts.
82
- *
83
- * `/usr/local/bin/hermes` is its FHS default, chosen to match Claude Code and
84
- * the Codex CLI; `~/.local/bin` is what it uses when it cannot write there.
85
- */
86
- const HERMES_PATHS = [
87
- `${process.env.HOME ?? ""}/.local/bin/hermes`,
88
- "/usr/local/bin/hermes",
89
- ];
51
+ let userAdapters: HarnessAdapter[] = [];
52
+ let problems: { file: string; error: string }[] = [];
90
53
 
91
- /**
92
- * And again for the two commands that make Browser Use mode engage.
93
- *
94
- * `uv`'s installer writes to `~/.local/bin` and nowhere else, which is the same
95
- * directory that neither a systemd service nor an `ssh host 'command'` has on
96
- * PATH. Without this the check reported a missing uvx on a box that had one, and
97
- * offered to install what was already installed.
98
- */
99
- const home = process.env.HOME ?? "";
100
- const BROWSER_USE_PATHS = [
101
- `${home}/.local/bin/browser-use`,
102
- "/usr/local/bin/browser-use",
103
- ];
104
- const UVX_PATHS = [`${home}/.local/bin/uvx`, "/usr/local/bin/uvx"];
54
+ /** Where a box's own harness specs live. */
55
+ export const harnessDir = (stateDir: string): string =>
56
+ path.join(stateDir, "harnesses");
105
57
 
106
58
  /**
107
- * The commands that install what Hermes needs, named where they are checked.
59
+ * Reads `<state dir>/harnesses/*.json`. Called once per process, by whatever
60
+ * resolves the config, so the server, `vibe-os attach` and doctor all see the
61
+ * same list.
108
62
  *
109
- * doctor prints them under a failed check and `connect-hermes` prints the first
110
- * one when there is nothing to configure. Both used to be about to grow their
111
- * own copy of a URL that has to be right.
63
+ * Synchronous on purpose: it is a handful of small files read once, and the
64
+ * callers that need the answer (validating a profile, building a window's
65
+ * command) are synchronous. A file that does not parse or validate is skipped
66
+ * and reported by doctor; it never stops the server.
112
67
  */
113
- export const CLAUDE_INSTALL = "curl -fsSL https://claude.ai/install.sh | bash";
114
- export const HERMES_INSTALL =
115
- "curl -fsSL https://hermes-agent.nousresearch.com/install.sh | bash";
116
- export const UV_INSTALL = "curl -LsSf https://astral.sh/uv/install.sh | sh";
117
-
118
- /** Resolves a command through any symlinks to the real executable. */
119
- async function resolveBinary(
120
- command: string,
121
- extraPaths: string[],
122
- ): Promise<string | null> {
123
- const candidates: string[] = [];
68
+ export function loadHarnesses(stateDir: string): void {
69
+ const dir = harnessDir(stateDir);
70
+ const loaded: HarnessAdapter[] = [];
71
+ const found: { file: string; error: string }[] = [];
72
+ let files: string[] = [];
124
73
  try {
125
- const { stdout } = await run(
126
- "/bin/sh",
127
- ["-c", 'command -v "$1"', "sh", command],
128
- { timeout: 5_000 },
129
- );
130
- if (stdout.trim()) candidates.push(stdout.trim());
74
+ files = readdirSync(dir)
75
+ .filter((f) => f.endsWith(".json"))
76
+ .sort();
131
77
  } catch {
132
- // not on PATH — the explicit locations below may still have it
78
+ // no directory is no user harnesses
133
79
  }
134
- candidates.push(...extraPaths.filter(Boolean));
135
-
136
- for (const candidate of candidates) {
80
+ for (const file of files) {
81
+ const full = path.join(dir, file);
137
82
  try {
138
- await stat(candidate);
139
- const { stdout: real } = await run(
140
- "/bin/sh",
141
- ["-c", `readlink -f ${JSON.stringify(candidate)}`],
142
- {
143
- timeout: 5_000,
144
- },
145
- );
146
- return real.trim() || candidate;
147
- } catch {
148
- // next
83
+ const spec = validateSpec(JSON.parse(readFileSync(full, "utf8")));
84
+ if (RESERVED.has(spec.id)) throw new Error(`id ${spec.id} is taken`);
85
+ if (loaded.some((a) => a.spec.id === spec.id))
86
+ throw new Error(`id ${spec.id} is already defined by another file`);
87
+ loaded.push({ spec });
88
+ } catch (err) {
89
+ const error = err instanceof Error ? err.message : String(err);
90
+ found.push({ file: full, error });
91
+ log.warn(`skipping harness ${full}: ${error}`);
149
92
  }
150
93
  }
151
- return null;
152
- }
153
-
154
- /**
155
- * Model ids embedded in the executable.
156
- *
157
- * grep rather than reading the file in-process: the binary is ~270MB and the
158
- * answer is a few hundred bytes, so streaming it through a matcher costs
159
- * nothing while `readFile` would cost the whole thing in memory. Measured at
160
- * about 0.13s, which is why this is done once at startup rather than cached to
161
- * disk or fetched over the network.
162
- */
163
- async function embeddedModels(binary: string): Promise<string[]> {
164
- try {
165
- const { stdout } = await run(
166
- "grep",
167
- ["-aoE", "claude-(opus|sonnet|haiku|fable)-[0-9]+(-[0-9]+)?", binary],
168
- { timeout: 30_000, maxBuffer: 4 * 1024 * 1024 },
169
- );
170
- const seen = new Set(
171
- stdout
172
- .split("\n")
173
- .map((l) => l.trim())
174
- .filter(Boolean),
175
- );
176
- // Newest first: people reach for the latest far more often than a pin to
177
- // something old, and a sorted-ascending list buries it.
178
- return [...seen].sort((a, b) =>
179
- b.localeCompare(a, "en", { numeric: true }),
180
- );
181
- } catch {
182
- return [];
183
- }
94
+ userAdapters = loaded;
95
+ problems = found;
96
+ cache.clear();
184
97
  }
185
98
 
186
- /**
187
- * `claude --help`, or empty if it cannot be run.
188
- *
189
- * Read once and handed to every parser below. Each `claude` invocation costs a
190
- * couple of seconds of its own startup, and the help text answers more than one
191
- * question — asking twice would double the slowest part of discovery to learn
192
- * nothing new.
193
- */
194
- async function helpText(binary: string): Promise<string> {
195
- try {
196
- const { stdout } = await run(binary, ["--help"], {
197
- timeout: 15_000,
198
- maxBuffer: 4 * 1024 * 1024,
199
- });
200
- return stdout;
201
- } catch {
202
- return "";
203
- }
204
- }
99
+ /** Every harness, built-ins first. */
100
+ export const harnesses = (): HarnessAdapter[] => [...BUILT_IN, ...userAdapters];
205
101
 
206
- /** The choices `--permission-mode` lists in its own help output. */
207
- function permissionModes(help: string): string[] {
208
- // Help text wraps, so the list is matched across newlines and whitespace.
209
- const section =
210
- /--permission-mode[\s\S]{0,400}?\(choices:([\s\S]{0,300}?)\)/.exec(help);
211
- if (!section) return [];
212
- return [...section[1].matchAll(/"([^"]+)"/g)].map((m) => m[1]);
213
- }
102
+ export const harness = (id: string): HarnessAdapter | undefined =>
103
+ harnesses().find((a) => a.spec.id === id);
214
104
 
215
- /**
216
- * The levels `--effort` lists in its own help output.
217
- *
218
- * `--permission-mode` prints a `(choices: "a", "b")` block this can anchor on;
219
- * `--effort` prints a bare `(low, medium, high, xhigh, max)`, so the only
220
- * anchor available is "the first parenthesis after the flag". That is loose
221
- * enough to catch a sentence in some future release, which is what the shape
222
- * check is for: every token has to look like a level, or the whole match is
223
- * discarded and the caller falls back to the known ladder. Half a list read out
224
- * of a paragraph would be worse than not reading one at all.
225
- */
226
- function effortLevels(help: string): string[] {
227
- const section = /--effort[\s\S]{0,400}?\(([^)]{0,200})\)/.exec(help);
228
- if (!section) return [];
229
- const levels = section[1].split(",").map((s) => s.trim());
230
- if (levels.length < 2 || !levels.every((l) => /^[a-z][a-z0-9-]*$/.test(l)))
231
- return [];
232
- return levels;
233
- }
105
+ /** Whether a profile may name this harness. */
106
+ export const isHarness = (id: string): boolean =>
107
+ id === SHELL || id === CUSTOM || harness(id) !== undefined;
234
108
 
235
- let cached: Promise<HarnessInfo> | null = null;
109
+ /** User files that were skipped, and why. */
110
+ export const harnessProblems = () => problems;
236
111
 
237
- /**
238
- * Everything the editor needs to offer, discovered once.
239
- *
240
- * Cached for the life of the process: it describes an installed binary, which
241
- * does not change under a running server, and a restart is already what
242
- * happens when someone upgrades it.
243
- */
244
- export function discoverClaude(): Promise<HarnessInfo> {
245
- cached ??= (async (): Promise<HarnessInfo> => {
246
- const binary = await resolveBinary("claude", CLAUDE_PATHS);
247
- if (!binary) {
248
- log.debug(
249
- "claude is not on PATH — the profile editor will offer aliases only",
250
- );
251
- return { ...EMPTY, aliases: KNOWN_ALIASES };
252
- }
253
-
254
- // Concurrently, because each `claude` invocation costs a couple of seconds
255
- // of its own startup and there is no reason to pay for them in series.
256
- const [version, models, help] = await Promise.all([
257
- run(binary, ["--version"], { timeout: 15_000 })
258
- .then(({ stdout }) => stdout.trim().split(/\s+/)[0] ?? null)
259
- .catch(() => null),
260
- embeddedModels(binary),
261
- helpText(binary),
262
- ]);
263
-
264
- const modes = permissionModes(help);
265
- const effort = effortLevels(help);
112
+ // ── validation ───────────────────────────────────────────────────────────────
266
113
 
267
- // A tier that shows up in the binary but not in the list above still gets
268
- // an alias, because that is how Claude names them.
269
- const tiers = new Set(models.map((m) => m.split("-")[1]).filter(Boolean));
270
- const aliases = [...new Set([...KNOWN_ALIASES, ...tiers])];
114
+ const ID = /^[a-z][a-z0-9-]{0,31}$/;
271
115
 
272
- log.debug(
273
- `claude ${version ?? "?"}: ${models.length} models, ${modes.length} permission modes, ` +
274
- `${effort.length > 0 ? effort.join("/") : "no"} effort levels`,
275
- );
276
- return {
277
- available: true,
278
- version,
279
- aliases,
280
- models,
281
- permissionModes: modes,
282
- effortLevels: effort.length > 0 ? effort : KNOWN_EFFORT,
283
- };
284
- })();
285
- return cached;
116
+ function fail(where: string, what: string): never {
117
+ throw new Error(`${where} ${what}`);
286
118
  }
287
119
 
288
- // ── Hermes ───────────────────────────────────────────────────────────────────
289
-
290
- /**
291
- * The reasoning ladder as of writing, used only when the help cannot be read.
292
- *
293
- * Not merged with what was discovered, for the reason `KNOWN_EFFORT` gives: the
294
- * list is the scale, so a union of two sources would put a newly-added level in
295
- * the wrong place and mislabel how hard a role thinks.
296
- */
297
- const KNOWN_REASONING = [
298
- "none",
299
- "minimal",
300
- "low",
301
- "medium",
302
- "high",
303
- "xhigh",
304
- "max",
305
- "ultra",
306
- ];
307
-
308
- const NO_HERMES: HermesInfo = {
309
- available: false,
310
- version: null,
311
- providers: [],
312
- defaultProvider: null,
313
- defaultModel: null,
314
- reasoningLevels: KNOWN_REASONING,
315
- browser: {
316
- cdpUrl: null,
317
- backend: null,
318
- cdpPort: null,
319
- connected: false,
320
- browserUse: false,
321
- },
322
- };
323
-
324
- /**
325
- * One `hermes config get <key> --json`, parsed, or null.
326
- *
327
- * The one machine-readable read Hermes offers. Everything downstream treats a
328
- * null as "could not tell", never as "not set": a key that has never been
329
- * written, a build that spells it differently and a Python traceback all arrive
330
- * here the same way, and only the first of those is worth reporting as a fact.
331
- */
332
- async function hermesConfig(
333
- binary: string,
334
- key: string,
335
- ): Promise<Record<string, unknown> | null> {
336
- try {
337
- const { stdout } = await run(binary, ["config", "get", key, "--json"], {
338
- timeout: 20_000,
339
- maxBuffer: 4 * 1024 * 1024,
340
- });
341
- const parsed: unknown = JSON.parse(stdout);
342
- return parsed && typeof parsed === "object" && !Array.isArray(parsed)
343
- ? (parsed as Record<string, unknown>)
344
- : null;
345
- } catch {
346
- return null;
347
- }
120
+ function string(
121
+ value: unknown,
122
+ where: string,
123
+ required = false,
124
+ ): string | undefined {
125
+ if (value === undefined && !required) return undefined;
126
+ if (typeof value !== "string" || value.trim() === "")
127
+ fail(where, "must be a non-empty string");
128
+ if (/[\0\r\n]/.test(value)) fail(where, "cannot span lines");
129
+ return value;
348
130
  }
349
131
 
350
- /**
351
- * The levels `--reasoning` lists in its own help output.
352
- *
353
- * The same job `effortLevels` does for Claude, against a different sentence:
354
- * Hermes writes "Reasoning effort for this session: none, minimal, low, medium,
355
- * high, xhigh, max, or ultra." across two wrapped lines, so the anchor is the
356
- * first colon after the flag and the terminator is the full stop.
357
- *
358
- * Same shape check, for the same reason. That anchor is loose enough to catch a
359
- * paragraph in some future release, so every token has to look like a level or
360
- * the whole match is discarded and the caller falls back to the known ladder.
361
- */
362
- export function reasoningLevels(help: string): string[] {
363
- const section = /--reasoning[\s\S]{0,200}?:\s*([\s\S]{0,200}?)\./.exec(help);
364
- if (!section) return [];
365
- const levels = section[1]
366
- .split(/,|\bor\b/)
367
- .map((s) => s.trim())
368
- .filter(Boolean);
369
- if (levels.length < 2 || !levels.every((l) => /^[a-z][a-z0-9-]*$/.test(l)))
370
- return [];
371
- return levels;
132
+ function strings(value: unknown, where: string): string[] | undefined {
133
+ if (value === undefined) return undefined;
134
+ if (!Array.isArray(value)) fail(where, "must be a list of strings");
135
+ return value.map((v, i) => string(v, `${where}[${i}]`, true) as string);
372
136
  }
373
137
 
374
- /**
375
- * The version out of `hermes --version`, which is not one line.
376
- *
377
- * Claude prints `1.2.3 (Claude Code)` and the first token is the answer. Hermes
378
- * prints a five-line block — its own version, the install directory, the Python
379
- * it built against, the OpenAI SDK, and a sentence suggesting `hermes version` —
380
- * so taking the last token of the whole thing yields "status." and taking the
381
- * first yields "Hermes". Only the first line is about the agent, and the answer
382
- * is the version-shaped token in it.
383
- */
384
- export function hermesVersion(stdout: string): string | null {
385
- const first = stdout.split("\n")[0] ?? "";
386
- const match = /\bv?(\d+(?:\.\d+)+[^\s()]*)/.exec(first);
387
- return match ? match[1] : null;
138
+ function flag(
139
+ value: unknown,
140
+ where: string,
141
+ required = false,
142
+ ): string | undefined {
143
+ const f = string(value, where, required);
144
+ if (f !== undefined && !f.startsWith("-")) fail(where, "must start with -");
145
+ return f;
388
146
  }
389
147
 
390
- /** A field of a config block, when it is a non-empty string. */
391
- function str(
392
- block: Record<string, unknown> | null,
393
- key: string,
394
- ): string | null {
395
- const value = block?.[key];
396
- return typeof value === "string" && value.trim() ? value.trim() : null;
148
+ function flags(value: unknown, where: string): string[] | undefined {
149
+ return strings(value, where)?.map((f) => flag(f, where, true) as string);
397
150
  }
398
151
 
399
- /**
400
- * The CDP port the installed Chrome unit actually opened.
401
- *
402
- * Read from the unit rather than from a default, for the same reason doctor
403
- * does it: `install-browser` may have been given `--cdp-port` months ago, and
404
- * comparing a configured URL against a port nothing is listening on would call a
405
- * working box broken.
406
- */
407
- async function installedCdpPort(): Promise<number | null> {
408
- const unit = await readFile(CHROME_UNIT_PATH, "utf8").catch(() => null);
409
- if (!unit) return null;
410
- return portsFromUnits({ chrome: unit }).cdpPort;
152
+ function object(value: unknown, where: string): Record<string, unknown> {
153
+ if (!value || typeof value !== "object" || Array.isArray(value))
154
+ fail(where, "must be an object");
155
+ return value as Record<string, unknown>;
411
156
  }
412
157
 
413
- let hermesCached: Promise<HermesInfo> | null = null;
158
+ /** Only the keys a spec knows, so a typo is an error rather than ignored. */
159
+ function only(o: Record<string, unknown>, keys: string[], where: string) {
160
+ for (const k of Object.keys(o))
161
+ if (!keys.includes(k)) fail(where, `has an unknown key "${k}"`);
162
+ }
414
163
 
415
164
  /**
416
- * Everything the Hermes side of the editor needs, discovered once.
417
- *
418
- * Deliberately thinner than the Claude side. There is no model list here and
419
- * there is not going to be one: `hermes model` is an interactive wizard, and a
420
- * hardcoded list would be a guess about somebody else's account. What is real is
421
- * what the box is configured for, so that is what is offered, and the model and
422
- * provider fields stay free text with the discovered values as suggestions.
165
+ * Checks a user-written spec, and returns only what it checked.
423
166
  *
424
- * No floor of well-known provider names either, unlike `KNOWN_ALIASES` above.
425
- * A Claude alias works wherever Claude is installed; a Hermes provider id is
426
- * account configuration, so offering one that is not on this box would suggest a
427
- * choice that can only fail at launch.
167
+ * Strict, because the alternative is a profile that launches the wrong argv
168
+ * with no error anywhere. The mcp field kind and the code hooks are left to
169
+ * built-ins: a JSON file is a command plus its flags.
428
170
  */
429
- export function discoverHermes(): Promise<HermesInfo> {
430
- hermesCached ??= (async (): Promise<HermesInfo> => {
431
- const binary = await resolveBinary("hermes", HERMES_PATHS);
432
- if (!binary) {
433
- log.debug("hermes is not on PATH — Hermes profiles will not launch");
434
- return NO_HERMES;
435
- }
436
-
437
- // Concurrently: `hermes` is Python, so each invocation costs seconds of
438
- // interpreter and import time before it does anything.
439
- const [version, help, model, browser, providers, browserUse, cdpPort] =
440
- await Promise.all([
441
- run(binary, ["--version"], { timeout: 30_000 })
442
- .then(({ stdout }) => hermesVersion(stdout))
443
- .catch(() => null),
444
- // `chat`, not the bare command: `--reasoning` is a flag of that
445
- // subcommand, which is also the one a profile actually launches.
446
- run(binary, ["chat", "--help"], {
447
- timeout: 30_000,
448
- maxBuffer: 4 * 1024 * 1024,
449
- })
450
- .then(({ stdout }) => stdout)
451
- .catch(() => ""),
452
- hermesConfig(binary, "model"),
453
- hermesConfig(binary, "browser"),
454
- hermesConfig(binary, "providers"),
455
- // Presence, never a run. A cold `uvx browser-use` downloads the package
456
- // before it prints anything, which would hold up startup for minutes on
457
- // the one box where the answer matters least.
458
- Promise.all([
459
- resolveBinary("browser-use", BROWSER_USE_PATHS),
460
- resolveBinary("uvx", UVX_PATHS),
461
- ]).then(([direct, viaUvx]) => Boolean(direct || viaUvx)),
462
- installedCdpPort(),
463
- ]);
464
-
465
- const defaultProvider = str(model, "provider");
466
- const names = [
467
- ...Object.keys(providers ?? {}).filter((n) => n.trim()),
468
- ...(defaultProvider ? [defaultProvider] : []),
469
- ];
470
- const cdpUrl = str(browser, "cdp_url");
471
- const reasoning = reasoningLevels(help);
472
-
473
- log.debug(
474
- `hermes ${version ?? "?"}: ${names.length} providers, ` +
475
- `${reasoning.length > 0 ? reasoning.join("/") : "no"} reasoning levels, ` +
476
- `browser ${cdpUrl ?? "unset"}, browser-use ${browserUse ? "runnable" : "missing"}`,
477
- );
171
+ export function validateSpec(raw: unknown): HarnessSpec {
172
+ const o = object(raw, "the spec");
173
+ only(
174
+ o,
175
+ [
176
+ "id",
177
+ "label",
178
+ "command",
179
+ "paths",
180
+ "leading",
181
+ "resume",
182
+ "install",
183
+ "version",
184
+ "login",
185
+ "defaults",
186
+ "fields",
187
+ "valued",
188
+ "optional",
189
+ "extraPlaceholder",
190
+ ],
191
+ "the spec",
192
+ );
193
+ const id = string(o.id, "id", true) as string;
194
+ if (!ID.test(id))
195
+ fail("id", "must be lowercase letters, digits and dashes, 32 at most");
196
+
197
+ let resume: HarnessSpec["resume"];
198
+ if (o.resume !== undefined) {
199
+ const r = object(o.resume, "resume");
200
+ only(r, ["args", "subcommand", "unless"], "resume");
201
+ if (r.subcommand !== undefined && typeof r.subcommand !== "boolean")
202
+ fail("resume.subcommand", "must be true or false");
203
+ resume = {
204
+ args:
205
+ strings(r.args, "resume.args") ?? fail("resume.args", "is required"),
206
+ subcommand: r.subcommand as boolean | undefined,
207
+ unless: flags(r.unless, "resume.unless"),
208
+ };
209
+ }
478
210
 
479
- return {
480
- available: true,
481
- version,
482
- providers: [...new Set(names)].sort(),
483
- defaultProvider,
484
- defaultModel: str(model, "default"),
485
- reasoningLevels: reasoning.length > 0 ? reasoning : KNOWN_REASONING,
486
- browser: {
487
- cdpUrl,
488
- backend: str(browser, "backend"),
489
- cdpPort,
490
- connected: cdpPort !== null && cdpUrlMatches(cdpUrl, cdpPort),
491
- browserUse,
492
- },
211
+ let login: HarnessSpec["login"];
212
+ if (o.login !== undefined) {
213
+ const l = object(o.login, "login");
214
+ only(l, ["args", "fix"], "login");
215
+ login = {
216
+ args: strings(l.args, "login.args") ?? fail("login.args", "is required"),
217
+ fix: string(l.fix, "login.fix", true) as string,
493
218
  };
494
- })();
495
- return hermesCached;
219
+ }
220
+
221
+ if (!Array.isArray(o.fields)) fail("fields", "must be a list");
222
+ const keys = new Set<string>();
223
+ const fields = o.fields.map((rawField, i): HarnessSpec["fields"][number] => {
224
+ const where = `fields[${i}]`;
225
+ const f = object(rawField, where);
226
+ if (f.kind === "toggle") {
227
+ only(f, ["kind", "flag", "aliases", "label", "hint", "warn"], where);
228
+ return {
229
+ kind: "toggle",
230
+ flag: flag(f.flag, `${where}.flag`, true) as string,
231
+ aliases: flags(f.aliases, `${where}.aliases`),
232
+ label: string(f.label, `${where}.label`, true) as string,
233
+ hint: string(f.hint, `${where}.hint`),
234
+ warn: string(f.warn, `${where}.warn`),
235
+ };
236
+ }
237
+ if (f.kind === "select") {
238
+ only(
239
+ f,
240
+ [
241
+ "kind",
242
+ "key",
243
+ "label",
244
+ "flag",
245
+ "aliases",
246
+ "prefix",
247
+ "options",
248
+ "specials",
249
+ "free",
250
+ "none",
251
+ "placeholder",
252
+ "hint",
253
+ ],
254
+ where,
255
+ );
256
+ const key = string(f.key, `${where}.key`, true) as string;
257
+ if (!/^[a-z][a-z0-9-]*$/.test(key))
258
+ fail(`${where}.key`, "must be lowercase letters, digits and dashes");
259
+ if (keys.has(key)) fail(`${where}.key`, `repeats "${key}"`);
260
+ keys.add(key);
261
+ if (f.free !== undefined && typeof f.free !== "boolean")
262
+ fail(`${where}.free`, "must be true or false");
263
+ const options =
264
+ f.options === undefined
265
+ ? undefined
266
+ : (Array.isArray(f.options)
267
+ ? f.options
268
+ : fail(`${where}.options`, "must be a list")
269
+ ).map((opt, j) => {
270
+ const ow = `${where}.options[${j}]`;
271
+ if (typeof opt === "string")
272
+ return { value: string(opt, ow, true) as string };
273
+ const oo = object(opt, ow);
274
+ only(oo, ["value", "label", "group"], ow);
275
+ return {
276
+ value: string(oo.value, `${ow}.value`, true) as string,
277
+ label: string(oo.label, `${ow}.label`),
278
+ group: string(oo.group, `${ow}.group`),
279
+ };
280
+ });
281
+ const specials =
282
+ f.specials === undefined
283
+ ? undefined
284
+ : (Array.isArray(f.specials)
285
+ ? f.specials
286
+ : fail(`${where}.specials`, "must be a list")
287
+ ).map((s, j) => {
288
+ const sw = `${where}.specials[${j}]`;
289
+ const so = object(s, sw);
290
+ only(so, ["value", "label", "flag", "aliases", "warn"], sw);
291
+ return {
292
+ value: string(so.value, `${sw}.value`, true) as string,
293
+ label: string(so.label, `${sw}.label`, true) as string,
294
+ flag: flag(so.flag, `${sw}.flag`, true) as string,
295
+ aliases: flags(so.aliases, `${sw}.aliases`),
296
+ warn: string(so.warn, `${sw}.warn`),
297
+ };
298
+ });
299
+ const field = {
300
+ kind: "select" as const,
301
+ key,
302
+ label: string(f.label, `${where}.label`, true) as string,
303
+ flag: flag(f.flag, `${where}.flag`),
304
+ aliases: flags(f.aliases, `${where}.aliases`),
305
+ prefix: string(f.prefix, `${where}.prefix`),
306
+ options,
307
+ specials,
308
+ free: f.free as boolean | undefined,
309
+ none: string(f.none, `${where}.none`),
310
+ placeholder: string(f.placeholder, `${where}.placeholder`),
311
+ hint: string(f.hint, `${where}.hint`),
312
+ };
313
+ if (!field.flag && !field.specials?.length)
314
+ fail(where, "needs a flag, specials, or both");
315
+ return field;
316
+ }
317
+ return fail(`${where}.kind`, 'must be "select" or "toggle"');
318
+ });
319
+
320
+ return {
321
+ id,
322
+ label: string(o.label, "label", true) as string,
323
+ command: string(o.command, "command", true) as string,
324
+ paths: strings(o.paths, "paths"),
325
+ leading: strings(o.leading, "leading"),
326
+ resume,
327
+ install: string(o.install, "install"),
328
+ version: strings(o.version, "version"),
329
+ login,
330
+ defaults: strings(o.defaults, "defaults"),
331
+ fields,
332
+ valued: flags(o.valued, "valued"),
333
+ optional: flags(o.optional, "optional"),
334
+ extraPlaceholder: string(o.extraPlaceholder, "extraPlaceholder"),
335
+ };
496
336
  }
497
337
 
498
- // ── Cursor ───────────────────────────────────────────────────────────────────
338
+ // ── discovery ────────────────────────────────────────────────────────────────
499
339
 
500
340
  /**
501
- * Where Cursor's installer puts things, when PATH does not say so.
341
+ * How long an adapter's own discovery gets before the editor goes without it.
502
342
  *
503
- * `curl https://cursor.com/install | bash` unpacks a versioned bundle under
504
- * `~/.local/share/cursor-agent/versions/` and symlinks `cursor-agent` (and a
505
- * bare `agent`) into `~/.local/bin` — the same directory that neither a systemd
506
- * service nor an `ssh host 'command'` has on PATH, for the same reason the
507
- * Claude and Hermes lists above exist. `cursor-agent` is the name checked, not
508
- * `agent`: both point at the same binary, and only one of them is unambiguous.
343
+ * Some of it goes to the network (Cursor's model list is the account's), and
344
+ * a hung CLI must not hold up the editor or doctor forever. Past this the
345
+ * report carries what the generic part found and every field falls back to
346
+ * its spec's own options, or free text.
509
347
  */
510
- const CURSOR_PATHS = [
511
- `${home}/.local/bin/cursor-agent`,
512
- "/usr/local/bin/cursor-agent",
513
- ];
348
+ const DISCOVERY_TIMEOUT = 60_000;
514
349
 
515
- /** The command that installs it, named where doctor checks for it. */
516
- export const CURSOR_INSTALL = "curl https://cursor.com/install -fsS | bash";
350
+ interface Found {
351
+ report: HarnessReport;
352
+ detail: unknown;
353
+ }
517
354
 
518
- const NO_CURSOR: CursorInfo = {
355
+ const cache = new Map<string, Promise<Found>>();
356
+
357
+ const empty = (id: string): HarnessReport => ({
358
+ id,
519
359
  available: false,
520
360
  version: null,
521
- models: [],
522
- defaultModel: null,
523
361
  loggedIn: null,
524
- };
525
-
526
- /**
527
- * The version out of `cursor-agent --version`.
528
- *
529
- * Cursor's CLI versions are date-shaped — `2026.08.11-e8db854`, one line and
530
- * nothing else on a real install — rather than semver, which is still "digits,
531
- * dots, then whatever" and the same token shape
532
- * `hermesVersion` reads. Kept to the first line for the same reason as there:
533
- * only the first line is a claim about the binary itself, and a wrapper or
534
- * update notice printed after it must not become the answer.
535
- */
536
- export function cursorVersion(stdout: string): string | null {
537
- const first = stdout.split("\n").find((l) => l.trim()) ?? "";
538
- const match = /\bv?(\d+(?:\.\d+)+[^\s()]*)/.exec(first);
539
- return match ? match[1] : null;
362
+ options: {},
363
+ defaults: {},
364
+ notes: [],
365
+ });
366
+
367
+ function withTimeout<T>(p: Promise<T>, ms: number, fallback: T): Promise<T> {
368
+ let timer: ReturnType<typeof setTimeout> | undefined;
369
+ return Promise.race([
370
+ p.finally(() => clearTimeout(timer)),
371
+ new Promise<T>((resolve) => {
372
+ timer = setTimeout(() => resolve(fallback), ms);
373
+ }),
374
+ ]);
540
375
  }
541
376
 
542
- /**
543
- * Model ids out of `cursor-agent models`.
544
- *
545
- * The format is verbatim from a real box: an `Available models` header, then
546
- * one `id - Display Name` per line, then a `Tip:` sentence about `--model`.
547
- * The default is marked inside the display half — `auto - Auto (default)`.
548
- *
549
- * The id is the machine half and the only part kept: it is what `--model`
550
- * takes, and the display name repeats it in prose. A line has to open with an
551
- * id-shaped token followed by the ` - ` separator (or nothing at all), which
552
- * is what keeps the header and the tip out — both contain spaces where the
553
- * separator would have to be. Half-reading prose would put "Tip" in a model
554
- * dropdown, which is worse than missing a model.
555
- *
556
- * Order is kept as printed. Cursor leads with `auto` and its recommendations,
557
- * which is a better sort than alphabetical for a 200-entry list.
558
- */
559
- export function parseCursorModels(stdout: string): {
560
- models: string[];
561
- defaultModel: string | null;
562
- } {
563
- const models: string[] = [];
564
- let defaultModel: string | null = null;
565
- for (const raw of stdout.split("\n")) {
566
- const line = raw.trim();
567
- if (!line) continue;
568
- const match = /^([A-Za-z0-9][\w./-]*)(?:\s+-\s+(.*))?$/.exec(line);
569
- if (!match) continue;
570
- const [, id, label] = match;
571
- if (!models.includes(id)) models.push(id);
572
- // The marker word can share its parens: one account prints `(default)`,
573
- // another `(current, default)`, so the test is for the word, anchored
574
- // inside a parenthesised note rather than to the whole note.
575
- if (
576
- label &&
577
- /\([^)]*\b(default|current|selected)\b[^)]*\)/i.test(label) &&
578
- !defaultModel
579
- )
580
- defaultModel = id;
377
+ async function find(adapter: HarnessAdapter): Promise<Found> {
378
+ const { spec } = adapter;
379
+ const binary = await resolveBinary(spec.command, spec.paths ?? []);
380
+ if (!binary) {
381
+ log.debug(
382
+ `${spec.command} is not on PATH — ${spec.label} profiles will not launch`,
383
+ );
384
+ return { report: empty(spec.id), detail: undefined };
581
385
  }
582
- return { models, defaultModel };
583
- }
584
386
 
585
- /**
586
- * What `cursor-agent status` says about being logged in, when it says anything.
587
- *
588
- * "Not logged in" contains "logged in", so the negative is checked first. A
589
- * status this cannot read returns null rather than false: only a definite "no"
590
- * should make the editor warn that a window will sit at a login prompt.
591
- */
592
- export function cursorLoggedIn(stdout: string): boolean | null {
593
- const text = stdout.toLowerCase();
594
- if (
595
- /not\s+logged\s+in|logged\s+out|unauthenticated|no.*credentials/.test(text)
596
- )
597
- return false;
598
- if (/logged\s+in|signed\s+in|authenticated/.test(text)) return true;
599
- return null;
387
+ // Concurrently: each of these is its own process start, and some harnesses
388
+ // (Hermes is Python) spend seconds getting to the first byte.
389
+ const [version, login, own] = await Promise.all([
390
+ run(binary, spec.version ?? ["--version"], { timeout: 30_000 })
391
+ .then(({ stdout }) => versionToken(stdout))
392
+ .catch(() => null),
393
+ spec.login
394
+ ? output(binary, spec.login.args).then((t) =>
395
+ t === null ? null : loggedIn(t),
396
+ )
397
+ : Promise.resolve(null),
398
+ adapter.discover
399
+ ? withTimeout<Discovery>(
400
+ adapter.discover(binary).catch((err: unknown) => {
401
+ log.warn(`${spec.id} discovery failed: ${String(err)}`);
402
+ return {};
403
+ }),
404
+ DISCOVERY_TIMEOUT,
405
+ {},
406
+ )
407
+ : Promise.resolve<Discovery>({}),
408
+ ]);
409
+
410
+ return {
411
+ report: {
412
+ id: spec.id,
413
+ available: true,
414
+ version: own.version !== undefined ? own.version : version,
415
+ loggedIn: login,
416
+ options: own.options ?? {},
417
+ defaults: own.defaults ?? {},
418
+ notes: own.notes ?? [],
419
+ },
420
+ detail: own.detail,
421
+ };
600
422
  }
601
423
 
602
- let cursorCached: Promise<CursorInfo> | null = null;
603
-
604
424
  /**
605
- * Everything the Cursor side of the editor needs, discovered once.
425
+ * What the box has for one harness, discovered once.
606
426
  *
607
- * Between the other two in what it can know. Unlike Hermes there is a listing
608
- * mode — `cursor-agent models` — so the model field gets real suggestions. But
609
- * the list is the account's rather than the binary's: logged out, or offline,
610
- * it cannot be read, so an empty list is "could not tell" and the field stays
611
- * free text. No floor of well-known model names, for the reason the Hermes
612
- * provider list has none: offering a model this account does not have would
613
- * suggest a choice that can only fail after launch.
427
+ * Cached for the life of the process: it describes an installed binary, which
428
+ * does not change under a running server, and a restart is already what
429
+ * happens when someone upgrades it.
614
430
  */
615
- export function discoverCursor(): Promise<CursorInfo> {
616
- cursorCached ??= (async (): Promise<CursorInfo> => {
617
- const binary = await resolveBinary("cursor-agent", CURSOR_PATHS);
618
- if (!binary) {
619
- log.debug(
620
- "cursor-agent is not on PATH — Cursor profiles will not launch",
621
- );
622
- return NO_CURSOR;
623
- }
624
-
625
- // Concurrently, as ever: `models` and `status` may each go to the network,
626
- // and there is no reason to pay for the round trips in series.
627
- const [version, listed, loggedIn] = await Promise.all([
628
- run(binary, ["--version"], { timeout: 15_000 })
629
- .then(({ stdout }) => cursorVersion(stdout))
630
- .catch(() => null),
631
- run(binary, ["models"], { timeout: 30_000, maxBuffer: 4 * 1024 * 1024 })
632
- .then(({ stdout }) => parseCursorModels(stdout))
633
- // Logged out or offline it exits complaining; that is "could not
634
- // read", which the empty list already says.
635
- .catch(() => ({ models: [], defaultModel: null })),
636
- run(binary, ["status"], { timeout: 30_000, maxBuffer: 4 * 1024 * 1024 })
637
- .then(({ stdout }) => cursorLoggedIn(stdout))
638
- .catch(() => null),
639
- ]);
640
-
641
- log.debug(
642
- `cursor-agent ${version ?? "?"}: ${listed.models.length} models, ` +
643
- `logged ${loggedIn === null ? "in?" : loggedIn ? "in" : "out"}`,
644
- );
645
-
646
- return {
647
- available: true,
648
- version,
649
- models: listed.models,
650
- defaultModel: listed.defaultModel,
651
- loggedIn,
652
- };
653
- })();
654
- return cursorCached;
431
+ export function discover(id: string): Promise<Found> | undefined {
432
+ const adapter = harness(id);
433
+ if (!adapter) return undefined;
434
+ let found = cache.get(id);
435
+ if (!found) {
436
+ found = find(adapter);
437
+ cache.set(id, found);
438
+ }
439
+ return found;
655
440
  }
441
+
442
+ /** Every harness's spec, for the editor. */
443
+ export const specs = (): HarnessSpec[] => harnesses().map((a) => a.spec);