@tsconfigdotjson/vibe-os 0.0.0-stage → 0.1.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/LICENSE +21 -0
- package/README.md +928 -2
- package/THIRD_PARTY_NOTICES.md +2467 -0
- package/bin/vibe-os.mjs +13 -0
- package/dist/web/apple-touch-icon.png +0 -0
- package/dist/web/assets/index-B77Qk8B5.js +35 -0
- package/dist/web/assets/index-B77Qk8B5.js.br +0 -0
- package/dist/web/assets/index-B77Qk8B5.js.gz +0 -0
- package/dist/web/assets/index-CH5Em-Mo.css +1 -0
- package/dist/web/assets/index-CH5Em-Mo.css.br +0 -0
- package/dist/web/assets/index-CH5Em-Mo.css.gz +0 -0
- package/dist/web/icon-192.png +0 -0
- package/dist/web/icon-512.png +0 -0
- package/dist/web/icon-app.svg +19 -0
- package/dist/web/icon-maskable-512.png +0 -0
- package/dist/web/icon-maskable.svg +13 -0
- package/dist/web/icon.svg +15 -0
- package/dist/web/index.html +36 -0
- package/dist/web/ssh.wasm +0 -0
- package/dist/web/ssh.wasm.br +0 -0
- package/dist/web/ssh.wasm.gz +0 -0
- package/dist/web/sw.js +29 -0
- package/dist/web/wasm_exec.js +575 -0
- package/dist/web/wasm_exec.js.br +0 -0
- package/dist/web/wasm_exec.js.gz +0 -0
- package/package.json +82 -4
- package/scripts/compile.ts +62 -0
- package/scripts/compress.mjs +54 -0
- package/scripts/fetch-wasm.mjs +242 -0
- package/scripts/gen-assets.ts +76 -0
- package/scripts/lib/walk.ts +18 -0
- package/scripts/notices.ts +189 -0
- package/server/acme.ts +182 -0
- package/server/api.ts +515 -0
- package/server/assets.generated.ts +53 -0
- package/server/attach.ts +524 -0
- package/server/auth.ts +194 -0
- package/server/bridge.ts +145 -0
- package/server/browser.ts +519 -0
- package/server/cli.ts +843 -0
- package/server/config.ts +362 -0
- package/server/db.ts +262 -0
- package/server/doctor.ts +966 -0
- package/server/fsx.ts +88 -0
- package/server/harness.ts +654 -0
- package/server/http.ts +53 -0
- package/server/index.ts +469 -0
- package/server/log.ts +40 -0
- package/server/main.ts +7 -0
- package/server/mcp.ts +291 -0
- package/server/preflight.ts +87 -0
- package/server/profiles.ts +238 -0
- package/server/projects.ts +721 -0
- package/server/reaper.ts +303 -0
- package/server/runtime.ts +64 -0
- package/server/session.ts +330 -0
- package/server/ssh-ca.ts +368 -0
- package/server/static.ts +147 -0
- package/server/wallpapers.ts +248 -0
- package/server/windows.ts +237 -0
- package/shared/args.ts +91 -0
- package/shared/grid.ts +32 -0
- package/shared/wire.ts +226 -0
|
@@ -0,0 +1,654 @@
|
|
|
1
|
+
// What the agent CLIs on this machine can be told to do.
|
|
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.
|
|
8
|
+
//
|
|
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";
|
|
23
|
+
import { log } from "./log.ts";
|
|
24
|
+
|
|
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",
|
|
63
|
+
];
|
|
64
|
+
|
|
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
|
+
];
|
|
79
|
+
|
|
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
|
+
];
|
|
90
|
+
|
|
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"];
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* The commands that install what Hermes needs, named where they are checked.
|
|
108
|
+
*
|
|
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.
|
|
112
|
+
*/
|
|
113
|
+
export const HERMES_INSTALL =
|
|
114
|
+
"curl -fsSL https://hermes-agent.nousresearch.com/install.sh | bash";
|
|
115
|
+
export const UV_INSTALL = "curl -LsSf https://astral.sh/uv/install.sh | sh";
|
|
116
|
+
|
|
117
|
+
/** Resolves a command through any symlinks to the real executable. */
|
|
118
|
+
async function resolveBinary(
|
|
119
|
+
command: string,
|
|
120
|
+
extraPaths: string[],
|
|
121
|
+
): Promise<string | null> {
|
|
122
|
+
const candidates: string[] = [];
|
|
123
|
+
try {
|
|
124
|
+
const { stdout } = await run(
|
|
125
|
+
"/bin/sh",
|
|
126
|
+
["-c", 'command -v "$1"', "sh", command],
|
|
127
|
+
{ timeout: 5_000 },
|
|
128
|
+
);
|
|
129
|
+
if (stdout.trim()) candidates.push(stdout.trim());
|
|
130
|
+
} catch {
|
|
131
|
+
// not on PATH — the explicit locations below may still have it
|
|
132
|
+
}
|
|
133
|
+
candidates.push(...extraPaths.filter(Boolean));
|
|
134
|
+
|
|
135
|
+
for (const candidate of candidates) {
|
|
136
|
+
try {
|
|
137
|
+
await stat(candidate);
|
|
138
|
+
const { stdout: real } = await run(
|
|
139
|
+
"/bin/sh",
|
|
140
|
+
["-c", `readlink -f ${JSON.stringify(candidate)}`],
|
|
141
|
+
{
|
|
142
|
+
timeout: 5_000,
|
|
143
|
+
},
|
|
144
|
+
);
|
|
145
|
+
return real.trim() || candidate;
|
|
146
|
+
} catch {
|
|
147
|
+
// next
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
return null;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Model ids embedded in the executable.
|
|
155
|
+
*
|
|
156
|
+
* grep rather than reading the file in-process: the binary is ~270MB and the
|
|
157
|
+
* answer is a few hundred bytes, so streaming it through a matcher costs
|
|
158
|
+
* nothing while `readFile` would cost the whole thing in memory. Measured at
|
|
159
|
+
* about 0.13s, which is why this is done once at startup rather than cached to
|
|
160
|
+
* disk or fetched over the network.
|
|
161
|
+
*/
|
|
162
|
+
async function embeddedModels(binary: string): Promise<string[]> {
|
|
163
|
+
try {
|
|
164
|
+
const { stdout } = await run(
|
|
165
|
+
"grep",
|
|
166
|
+
["-aoE", "claude-(opus|sonnet|haiku|fable)-[0-9]+(-[0-9]+)?", binary],
|
|
167
|
+
{ timeout: 30_000, maxBuffer: 4 * 1024 * 1024 },
|
|
168
|
+
);
|
|
169
|
+
const seen = new Set(
|
|
170
|
+
stdout
|
|
171
|
+
.split("\n")
|
|
172
|
+
.map((l) => l.trim())
|
|
173
|
+
.filter(Boolean),
|
|
174
|
+
);
|
|
175
|
+
// Newest first: people reach for the latest far more often than a pin to
|
|
176
|
+
// something old, and a sorted-ascending list buries it.
|
|
177
|
+
return [...seen].sort((a, b) =>
|
|
178
|
+
b.localeCompare(a, "en", { numeric: true }),
|
|
179
|
+
);
|
|
180
|
+
} catch {
|
|
181
|
+
return [];
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* `claude --help`, or empty if it cannot be run.
|
|
187
|
+
*
|
|
188
|
+
* Read once and handed to every parser below. Each `claude` invocation costs a
|
|
189
|
+
* couple of seconds of its own startup, and the help text answers more than one
|
|
190
|
+
* question — asking twice would double the slowest part of discovery to learn
|
|
191
|
+
* nothing new.
|
|
192
|
+
*/
|
|
193
|
+
async function helpText(binary: string): Promise<string> {
|
|
194
|
+
try {
|
|
195
|
+
const { stdout } = await run(binary, ["--help"], {
|
|
196
|
+
timeout: 15_000,
|
|
197
|
+
maxBuffer: 4 * 1024 * 1024,
|
|
198
|
+
});
|
|
199
|
+
return stdout;
|
|
200
|
+
} catch {
|
|
201
|
+
return "";
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/** The choices `--permission-mode` lists in its own help output. */
|
|
206
|
+
function permissionModes(help: string): string[] {
|
|
207
|
+
// Help text wraps, so the list is matched across newlines and whitespace.
|
|
208
|
+
const section =
|
|
209
|
+
/--permission-mode[\s\S]{0,400}?\(choices:([\s\S]{0,300}?)\)/.exec(help);
|
|
210
|
+
if (!section) return [];
|
|
211
|
+
return [...section[1].matchAll(/"([^"]+)"/g)].map((m) => m[1]);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* The levels `--effort` lists in its own help output.
|
|
216
|
+
*
|
|
217
|
+
* `--permission-mode` prints a `(choices: "a", "b")` block this can anchor on;
|
|
218
|
+
* `--effort` prints a bare `(low, medium, high, xhigh, max)`, so the only
|
|
219
|
+
* anchor available is "the first parenthesis after the flag". That is loose
|
|
220
|
+
* enough to catch a sentence in some future release, which is what the shape
|
|
221
|
+
* check is for: every token has to look like a level, or the whole match is
|
|
222
|
+
* discarded and the caller falls back to the known ladder. Half a list read out
|
|
223
|
+
* of a paragraph would be worse than not reading one at all.
|
|
224
|
+
*/
|
|
225
|
+
function effortLevels(help: string): string[] {
|
|
226
|
+
const section = /--effort[\s\S]{0,400}?\(([^)]{0,200})\)/.exec(help);
|
|
227
|
+
if (!section) return [];
|
|
228
|
+
const levels = section[1].split(",").map((s) => s.trim());
|
|
229
|
+
if (levels.length < 2 || !levels.every((l) => /^[a-z][a-z0-9-]*$/.test(l)))
|
|
230
|
+
return [];
|
|
231
|
+
return levels;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
let cached: Promise<HarnessInfo> | null = null;
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Everything the editor needs to offer, discovered once.
|
|
238
|
+
*
|
|
239
|
+
* Cached for the life of the process: it describes an installed binary, which
|
|
240
|
+
* does not change under a running server, and a restart is already what
|
|
241
|
+
* happens when someone upgrades it.
|
|
242
|
+
*/
|
|
243
|
+
export function discoverClaude(): Promise<HarnessInfo> {
|
|
244
|
+
cached ??= (async (): Promise<HarnessInfo> => {
|
|
245
|
+
const binary = await resolveBinary("claude", CLAUDE_PATHS);
|
|
246
|
+
if (!binary) {
|
|
247
|
+
log.debug(
|
|
248
|
+
"claude is not on PATH — the profile editor will offer aliases only",
|
|
249
|
+
);
|
|
250
|
+
return { ...EMPTY, aliases: KNOWN_ALIASES };
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// Concurrently, because each `claude` invocation costs a couple of seconds
|
|
254
|
+
// of its own startup and there is no reason to pay for them in series.
|
|
255
|
+
const [version, models, help] = await Promise.all([
|
|
256
|
+
run(binary, ["--version"], { timeout: 15_000 })
|
|
257
|
+
.then(({ stdout }) => stdout.trim().split(/\s+/)[0] ?? null)
|
|
258
|
+
.catch(() => null),
|
|
259
|
+
embeddedModels(binary),
|
|
260
|
+
helpText(binary),
|
|
261
|
+
]);
|
|
262
|
+
|
|
263
|
+
const modes = permissionModes(help);
|
|
264
|
+
const effort = effortLevels(help);
|
|
265
|
+
|
|
266
|
+
// A tier that shows up in the binary but not in the list above still gets
|
|
267
|
+
// an alias, because that is how Claude names them.
|
|
268
|
+
const tiers = new Set(models.map((m) => m.split("-")[1]).filter(Boolean));
|
|
269
|
+
const aliases = [...new Set([...KNOWN_ALIASES, ...tiers])];
|
|
270
|
+
|
|
271
|
+
log.debug(
|
|
272
|
+
`claude ${version ?? "?"}: ${models.length} models, ${modes.length} permission modes, ` +
|
|
273
|
+
`${effort.length > 0 ? effort.join("/") : "no"} effort levels`,
|
|
274
|
+
);
|
|
275
|
+
return {
|
|
276
|
+
available: true,
|
|
277
|
+
version,
|
|
278
|
+
aliases,
|
|
279
|
+
models,
|
|
280
|
+
permissionModes: modes,
|
|
281
|
+
effortLevels: effort.length > 0 ? effort : KNOWN_EFFORT,
|
|
282
|
+
};
|
|
283
|
+
})();
|
|
284
|
+
return cached;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// ── Hermes ───────────────────────────────────────────────────────────────────
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* The reasoning ladder as of writing, used only when the help cannot be read.
|
|
291
|
+
*
|
|
292
|
+
* Not merged with what was discovered, for the reason `KNOWN_EFFORT` gives: the
|
|
293
|
+
* list is the scale, so a union of two sources would put a newly-added level in
|
|
294
|
+
* the wrong place and mislabel how hard a role thinks.
|
|
295
|
+
*/
|
|
296
|
+
const KNOWN_REASONING = [
|
|
297
|
+
"none",
|
|
298
|
+
"minimal",
|
|
299
|
+
"low",
|
|
300
|
+
"medium",
|
|
301
|
+
"high",
|
|
302
|
+
"xhigh",
|
|
303
|
+
"max",
|
|
304
|
+
"ultra",
|
|
305
|
+
];
|
|
306
|
+
|
|
307
|
+
const NO_HERMES: HermesInfo = {
|
|
308
|
+
available: false,
|
|
309
|
+
version: null,
|
|
310
|
+
providers: [],
|
|
311
|
+
defaultProvider: null,
|
|
312
|
+
defaultModel: null,
|
|
313
|
+
reasoningLevels: KNOWN_REASONING,
|
|
314
|
+
browser: {
|
|
315
|
+
cdpUrl: null,
|
|
316
|
+
backend: null,
|
|
317
|
+
cdpPort: null,
|
|
318
|
+
connected: false,
|
|
319
|
+
browserUse: false,
|
|
320
|
+
},
|
|
321
|
+
};
|
|
322
|
+
|
|
323
|
+
/**
|
|
324
|
+
* One `hermes config get <key> --json`, parsed, or null.
|
|
325
|
+
*
|
|
326
|
+
* The one machine-readable read Hermes offers. Everything downstream treats a
|
|
327
|
+
* null as "could not tell", never as "not set": a key that has never been
|
|
328
|
+
* written, a build that spells it differently and a Python traceback all arrive
|
|
329
|
+
* here the same way, and only the first of those is worth reporting as a fact.
|
|
330
|
+
*/
|
|
331
|
+
async function hermesConfig(
|
|
332
|
+
binary: string,
|
|
333
|
+
key: string,
|
|
334
|
+
): Promise<Record<string, unknown> | null> {
|
|
335
|
+
try {
|
|
336
|
+
const { stdout } = await run(binary, ["config", "get", key, "--json"], {
|
|
337
|
+
timeout: 20_000,
|
|
338
|
+
maxBuffer: 4 * 1024 * 1024,
|
|
339
|
+
});
|
|
340
|
+
const parsed: unknown = JSON.parse(stdout);
|
|
341
|
+
return parsed && typeof parsed === "object" && !Array.isArray(parsed)
|
|
342
|
+
? (parsed as Record<string, unknown>)
|
|
343
|
+
: null;
|
|
344
|
+
} catch {
|
|
345
|
+
return null;
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
/**
|
|
350
|
+
* The levels `--reasoning` lists in its own help output.
|
|
351
|
+
*
|
|
352
|
+
* The same job `effortLevels` does for Claude, against a different sentence:
|
|
353
|
+
* Hermes writes "Reasoning effort for this session: none, minimal, low, medium,
|
|
354
|
+
* high, xhigh, max, or ultra." across two wrapped lines, so the anchor is the
|
|
355
|
+
* first colon after the flag and the terminator is the full stop.
|
|
356
|
+
*
|
|
357
|
+
* Same shape check, for the same reason. That anchor is loose enough to catch a
|
|
358
|
+
* paragraph in some future release, so every token has to look like a level or
|
|
359
|
+
* the whole match is discarded and the caller falls back to the known ladder.
|
|
360
|
+
*/
|
|
361
|
+
export function reasoningLevels(help: string): string[] {
|
|
362
|
+
const section = /--reasoning[\s\S]{0,200}?:\s*([\s\S]{0,200}?)\./.exec(help);
|
|
363
|
+
if (!section) return [];
|
|
364
|
+
const levels = section[1]
|
|
365
|
+
.split(/,|\bor\b/)
|
|
366
|
+
.map((s) => s.trim())
|
|
367
|
+
.filter(Boolean);
|
|
368
|
+
if (levels.length < 2 || !levels.every((l) => /^[a-z][a-z0-9-]*$/.test(l)))
|
|
369
|
+
return [];
|
|
370
|
+
return levels;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
/**
|
|
374
|
+
* The version out of `hermes --version`, which is not one line.
|
|
375
|
+
*
|
|
376
|
+
* Claude prints `1.2.3 (Claude Code)` and the first token is the answer. Hermes
|
|
377
|
+
* prints a five-line block — its own version, the install directory, the Python
|
|
378
|
+
* it built against, the OpenAI SDK, and a sentence suggesting `hermes version` —
|
|
379
|
+
* so taking the last token of the whole thing yields "status." and taking the
|
|
380
|
+
* first yields "Hermes". Only the first line is about the agent, and the answer
|
|
381
|
+
* is the version-shaped token in it.
|
|
382
|
+
*/
|
|
383
|
+
export function hermesVersion(stdout: string): string | null {
|
|
384
|
+
const first = stdout.split("\n")[0] ?? "";
|
|
385
|
+
const match = /\bv?(\d+(?:\.\d+)+[^\s()]*)/.exec(first);
|
|
386
|
+
return match ? match[1] : null;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
/** A field of a config block, when it is a non-empty string. */
|
|
390
|
+
function str(
|
|
391
|
+
block: Record<string, unknown> | null,
|
|
392
|
+
key: string,
|
|
393
|
+
): string | null {
|
|
394
|
+
const value = block?.[key];
|
|
395
|
+
return typeof value === "string" && value.trim() ? value.trim() : null;
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
/**
|
|
399
|
+
* The CDP port the installed Chrome unit actually opened.
|
|
400
|
+
*
|
|
401
|
+
* Read from the unit rather than from a default, for the same reason doctor
|
|
402
|
+
* does it: `install-browser` may have been given `--cdp-port` months ago, and
|
|
403
|
+
* comparing a configured URL against a port nothing is listening on would call a
|
|
404
|
+
* working box broken.
|
|
405
|
+
*/
|
|
406
|
+
async function installedCdpPort(): Promise<number | null> {
|
|
407
|
+
const unit = await readFile(CHROME_UNIT_PATH, "utf8").catch(() => null);
|
|
408
|
+
if (!unit) return null;
|
|
409
|
+
return portsFromUnits({ chrome: unit }).cdpPort;
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
let hermesCached: Promise<HermesInfo> | null = null;
|
|
413
|
+
|
|
414
|
+
/**
|
|
415
|
+
* Everything the Hermes side of the editor needs, discovered once.
|
|
416
|
+
*
|
|
417
|
+
* Deliberately thinner than the Claude side. There is no model list here and
|
|
418
|
+
* there is not going to be one: `hermes model` is an interactive wizard, and a
|
|
419
|
+
* hardcoded list would be a guess about somebody else's account. What is real is
|
|
420
|
+
* what the box is configured for, so that is what is offered, and the model and
|
|
421
|
+
* provider fields stay free text with the discovered values as suggestions.
|
|
422
|
+
*
|
|
423
|
+
* No floor of well-known provider names either, unlike `KNOWN_ALIASES` above.
|
|
424
|
+
* A Claude alias works wherever Claude is installed; a Hermes provider id is
|
|
425
|
+
* account configuration, so offering one that is not on this box would suggest a
|
|
426
|
+
* choice that can only fail at launch.
|
|
427
|
+
*/
|
|
428
|
+
export function discoverHermes(): Promise<HermesInfo> {
|
|
429
|
+
hermesCached ??= (async (): Promise<HermesInfo> => {
|
|
430
|
+
const binary = await resolveBinary("hermes", HERMES_PATHS);
|
|
431
|
+
if (!binary) {
|
|
432
|
+
log.debug("hermes is not on PATH — Hermes profiles will not launch");
|
|
433
|
+
return NO_HERMES;
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
// Concurrently: `hermes` is Python, so each invocation costs seconds of
|
|
437
|
+
// interpreter and import time before it does anything.
|
|
438
|
+
const [version, help, model, browser, providers, browserUse, cdpPort] =
|
|
439
|
+
await Promise.all([
|
|
440
|
+
run(binary, ["--version"], { timeout: 30_000 })
|
|
441
|
+
.then(({ stdout }) => hermesVersion(stdout))
|
|
442
|
+
.catch(() => null),
|
|
443
|
+
// `chat`, not the bare command: `--reasoning` is a flag of that
|
|
444
|
+
// subcommand, which is also the one a profile actually launches.
|
|
445
|
+
run(binary, ["chat", "--help"], {
|
|
446
|
+
timeout: 30_000,
|
|
447
|
+
maxBuffer: 4 * 1024 * 1024,
|
|
448
|
+
})
|
|
449
|
+
.then(({ stdout }) => stdout)
|
|
450
|
+
.catch(() => ""),
|
|
451
|
+
hermesConfig(binary, "model"),
|
|
452
|
+
hermesConfig(binary, "browser"),
|
|
453
|
+
hermesConfig(binary, "providers"),
|
|
454
|
+
// Presence, never a run. A cold `uvx browser-use` downloads the package
|
|
455
|
+
// before it prints anything, which would hold up startup for minutes on
|
|
456
|
+
// the one box where the answer matters least.
|
|
457
|
+
Promise.all([
|
|
458
|
+
resolveBinary("browser-use", BROWSER_USE_PATHS),
|
|
459
|
+
resolveBinary("uvx", UVX_PATHS),
|
|
460
|
+
]).then(([direct, viaUvx]) => Boolean(direct || viaUvx)),
|
|
461
|
+
installedCdpPort(),
|
|
462
|
+
]);
|
|
463
|
+
|
|
464
|
+
const defaultProvider = str(model, "provider");
|
|
465
|
+
const names = [
|
|
466
|
+
...Object.keys(providers ?? {}).filter((n) => n.trim()),
|
|
467
|
+
...(defaultProvider ? [defaultProvider] : []),
|
|
468
|
+
];
|
|
469
|
+
const cdpUrl = str(browser, "cdp_url");
|
|
470
|
+
const reasoning = reasoningLevels(help);
|
|
471
|
+
|
|
472
|
+
log.debug(
|
|
473
|
+
`hermes ${version ?? "?"}: ${names.length} providers, ` +
|
|
474
|
+
`${reasoning.length > 0 ? reasoning.join("/") : "no"} reasoning levels, ` +
|
|
475
|
+
`browser ${cdpUrl ?? "unset"}, browser-use ${browserUse ? "runnable" : "missing"}`,
|
|
476
|
+
);
|
|
477
|
+
|
|
478
|
+
return {
|
|
479
|
+
available: true,
|
|
480
|
+
version,
|
|
481
|
+
providers: [...new Set(names)].sort(),
|
|
482
|
+
defaultProvider,
|
|
483
|
+
defaultModel: str(model, "default"),
|
|
484
|
+
reasoningLevels: reasoning.length > 0 ? reasoning : KNOWN_REASONING,
|
|
485
|
+
browser: {
|
|
486
|
+
cdpUrl,
|
|
487
|
+
backend: str(browser, "backend"),
|
|
488
|
+
cdpPort,
|
|
489
|
+
connected: cdpPort !== null && cdpUrlMatches(cdpUrl, cdpPort),
|
|
490
|
+
browserUse,
|
|
491
|
+
},
|
|
492
|
+
};
|
|
493
|
+
})();
|
|
494
|
+
return hermesCached;
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
// ── Cursor ───────────────────────────────────────────────────────────────────
|
|
498
|
+
|
|
499
|
+
/**
|
|
500
|
+
* Where Cursor's installer puts things, when PATH does not say so.
|
|
501
|
+
*
|
|
502
|
+
* `curl https://cursor.com/install | bash` unpacks a versioned bundle under
|
|
503
|
+
* `~/.local/share/cursor-agent/versions/` and symlinks `cursor-agent` (and a
|
|
504
|
+
* bare `agent`) into `~/.local/bin` — the same directory that neither a systemd
|
|
505
|
+
* service nor an `ssh host 'command'` has on PATH, for the same reason the
|
|
506
|
+
* Claude and Hermes lists above exist. `cursor-agent` is the name checked, not
|
|
507
|
+
* `agent`: both point at the same binary, and only one of them is unambiguous.
|
|
508
|
+
*/
|
|
509
|
+
const CURSOR_PATHS = [
|
|
510
|
+
`${home}/.local/bin/cursor-agent`,
|
|
511
|
+
"/usr/local/bin/cursor-agent",
|
|
512
|
+
];
|
|
513
|
+
|
|
514
|
+
/** The command that installs it, named where doctor checks for it. */
|
|
515
|
+
export const CURSOR_INSTALL = "curl https://cursor.com/install -fsS | bash";
|
|
516
|
+
|
|
517
|
+
const NO_CURSOR: CursorInfo = {
|
|
518
|
+
available: false,
|
|
519
|
+
version: null,
|
|
520
|
+
models: [],
|
|
521
|
+
defaultModel: null,
|
|
522
|
+
loggedIn: null,
|
|
523
|
+
};
|
|
524
|
+
|
|
525
|
+
/**
|
|
526
|
+
* The version out of `cursor-agent --version`.
|
|
527
|
+
*
|
|
528
|
+
* Cursor's CLI versions are date-shaped — `2026.08.11-e8db854`, one line and
|
|
529
|
+
* nothing else on a real install — rather than semver, which is still "digits,
|
|
530
|
+
* dots, then whatever" and the same token shape
|
|
531
|
+
* `hermesVersion` reads. Kept to the first line for the same reason as there:
|
|
532
|
+
* only the first line is a claim about the binary itself, and a wrapper or
|
|
533
|
+
* update notice printed after it must not become the answer.
|
|
534
|
+
*/
|
|
535
|
+
export function cursorVersion(stdout: string): string | null {
|
|
536
|
+
const first = stdout.split("\n").find((l) => l.trim()) ?? "";
|
|
537
|
+
const match = /\bv?(\d+(?:\.\d+)+[^\s()]*)/.exec(first);
|
|
538
|
+
return match ? match[1] : null;
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
/**
|
|
542
|
+
* Model ids out of `cursor-agent models`.
|
|
543
|
+
*
|
|
544
|
+
* The format is verbatim from a real box: an `Available models` header, then
|
|
545
|
+
* one `id - Display Name` per line, then a `Tip:` sentence about `--model`.
|
|
546
|
+
* The default is marked inside the display half — `auto - Auto (default)`.
|
|
547
|
+
*
|
|
548
|
+
* The id is the machine half and the only part kept: it is what `--model`
|
|
549
|
+
* takes, and the display name repeats it in prose. A line has to open with an
|
|
550
|
+
* id-shaped token followed by the ` - ` separator (or nothing at all), which
|
|
551
|
+
* is what keeps the header and the tip out — both contain spaces where the
|
|
552
|
+
* separator would have to be. Half-reading prose would put "Tip" in a model
|
|
553
|
+
* dropdown, which is worse than missing a model.
|
|
554
|
+
*
|
|
555
|
+
* Order is kept as printed. Cursor leads with `auto` and its recommendations,
|
|
556
|
+
* which is a better sort than alphabetical for a 200-entry list.
|
|
557
|
+
*/
|
|
558
|
+
export function parseCursorModels(stdout: string): {
|
|
559
|
+
models: string[];
|
|
560
|
+
defaultModel: string | null;
|
|
561
|
+
} {
|
|
562
|
+
const models: string[] = [];
|
|
563
|
+
let defaultModel: string | null = null;
|
|
564
|
+
for (const raw of stdout.split("\n")) {
|
|
565
|
+
const line = raw.trim();
|
|
566
|
+
if (!line) continue;
|
|
567
|
+
const match = /^([A-Za-z0-9][\w./-]*)(?:\s+-\s+(.*))?$/.exec(line);
|
|
568
|
+
if (!match) continue;
|
|
569
|
+
const [, id, label] = match;
|
|
570
|
+
if (!models.includes(id)) models.push(id);
|
|
571
|
+
// The marker word can share its parens: one account prints `(default)`,
|
|
572
|
+
// another `(current, default)`, so the test is for the word, anchored
|
|
573
|
+
// inside a parenthesised note rather than to the whole note.
|
|
574
|
+
if (
|
|
575
|
+
label &&
|
|
576
|
+
/\([^)]*\b(default|current|selected)\b[^)]*\)/i.test(label) &&
|
|
577
|
+
!defaultModel
|
|
578
|
+
)
|
|
579
|
+
defaultModel = id;
|
|
580
|
+
}
|
|
581
|
+
return { models, defaultModel };
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
/**
|
|
585
|
+
* What `cursor-agent status` says about being logged in, when it says anything.
|
|
586
|
+
*
|
|
587
|
+
* "Not logged in" contains "logged in", so the negative is checked first. A
|
|
588
|
+
* status this cannot read returns null rather than false: only a definite "no"
|
|
589
|
+
* should make the editor warn that a window will sit at a login prompt.
|
|
590
|
+
*/
|
|
591
|
+
export function cursorLoggedIn(stdout: string): boolean | null {
|
|
592
|
+
const text = stdout.toLowerCase();
|
|
593
|
+
if (
|
|
594
|
+
/not\s+logged\s+in|logged\s+out|unauthenticated|no.*credentials/.test(text)
|
|
595
|
+
)
|
|
596
|
+
return false;
|
|
597
|
+
if (/logged\s+in|signed\s+in|authenticated/.test(text)) return true;
|
|
598
|
+
return null;
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
let cursorCached: Promise<CursorInfo> | null = null;
|
|
602
|
+
|
|
603
|
+
/**
|
|
604
|
+
* Everything the Cursor side of the editor needs, discovered once.
|
|
605
|
+
*
|
|
606
|
+
* Between the other two in what it can know. Unlike Hermes there is a listing
|
|
607
|
+
* mode — `cursor-agent models` — so the model field gets real suggestions. But
|
|
608
|
+
* the list is the account's rather than the binary's: logged out, or offline,
|
|
609
|
+
* it cannot be read, so an empty list is "could not tell" and the field stays
|
|
610
|
+
* free text. No floor of well-known model names, for the reason the Hermes
|
|
611
|
+
* provider list has none: offering a model this account does not have would
|
|
612
|
+
* suggest a choice that can only fail after launch.
|
|
613
|
+
*/
|
|
614
|
+
export function discoverCursor(): Promise<CursorInfo> {
|
|
615
|
+
cursorCached ??= (async (): Promise<CursorInfo> => {
|
|
616
|
+
const binary = await resolveBinary("cursor-agent", CURSOR_PATHS);
|
|
617
|
+
if (!binary) {
|
|
618
|
+
log.debug(
|
|
619
|
+
"cursor-agent is not on PATH — Cursor profiles will not launch",
|
|
620
|
+
);
|
|
621
|
+
return NO_CURSOR;
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
// Concurrently, as ever: `models` and `status` may each go to the network,
|
|
625
|
+
// and there is no reason to pay for the round trips in series.
|
|
626
|
+
const [version, listed, loggedIn] = await Promise.all([
|
|
627
|
+
run(binary, ["--version"], { timeout: 15_000 })
|
|
628
|
+
.then(({ stdout }) => cursorVersion(stdout))
|
|
629
|
+
.catch(() => null),
|
|
630
|
+
run(binary, ["models"], { timeout: 30_000, maxBuffer: 4 * 1024 * 1024 })
|
|
631
|
+
.then(({ stdout }) => parseCursorModels(stdout))
|
|
632
|
+
// Logged out or offline it exits complaining; that is "could not
|
|
633
|
+
// read", which the empty list already says.
|
|
634
|
+
.catch(() => ({ models: [], defaultModel: null })),
|
|
635
|
+
run(binary, ["status"], { timeout: 30_000, maxBuffer: 4 * 1024 * 1024 })
|
|
636
|
+
.then(({ stdout }) => cursorLoggedIn(stdout))
|
|
637
|
+
.catch(() => null),
|
|
638
|
+
]);
|
|
639
|
+
|
|
640
|
+
log.debug(
|
|
641
|
+
`cursor-agent ${version ?? "?"}: ${listed.models.length} models, ` +
|
|
642
|
+
`logged ${loggedIn === null ? "in?" : loggedIn ? "in" : "out"}`,
|
|
643
|
+
);
|
|
644
|
+
|
|
645
|
+
return {
|
|
646
|
+
available: true,
|
|
647
|
+
version,
|
|
648
|
+
models: listed.models,
|
|
649
|
+
defaultModel: listed.defaultModel,
|
|
650
|
+
loggedIn,
|
|
651
|
+
};
|
|
652
|
+
})();
|
|
653
|
+
return cursorCached;
|
|
654
|
+
}
|