@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.
- package/README.md +19 -847
- package/THIRD_PARTY_NOTICES.md +28 -0
- package/dist/web/assets/index-82MhMOB4.css +1 -0
- package/dist/web/assets/index-82MhMOB4.css.br +0 -0
- package/dist/web/assets/index-82MhMOB4.css.gz +0 -0
- package/dist/web/assets/index-DCB2nO0u.js +36 -0
- package/dist/web/assets/index-DCB2nO0u.js.br +0 -0
- package/dist/web/assets/index-DCB2nO0u.js.gz +0 -0
- package/dist/web/index.html +2 -2
- package/package.json +2 -1
- package/scripts/notices.ts +3 -1
- package/server/api.ts +449 -20
- package/server/assets.generated.ts +13 -13
- package/server/attach.ts +65 -16
- package/server/browser.ts +1 -65
- package/server/cli.ts +48 -150
- package/server/client.ts +440 -0
- package/server/config.ts +64 -2
- package/server/db.ts +19 -0
- package/server/doctor.ts +176 -123
- package/server/harness.ts +378 -590
- package/server/harnesses/claude.ts +248 -0
- package/server/harnesses/codex.ts +241 -0
- package/server/harnesses/cursor.ts +202 -0
- package/server/harnesses/hermes.ts +259 -0
- package/server/harnesses/util.ts +181 -0
- package/server/headless.ts +358 -0
- package/server/index.ts +47 -14
- package/server/memory.ts +418 -0
- package/server/profiles.ts +27 -15
- package/server/projects.ts +52 -3
- package/server/reaper.ts +5 -3
- package/server/screens.ts +372 -0
- package/server/session.ts +84 -95
- package/server/setup.ts +73 -7
- package/server/windows.ts +51 -1
- package/shared/blanks.ts +113 -0
- package/shared/harness.ts +375 -0
- package/shared/wire.ts +105 -94
- package/dist/web/assets/index-B77Qk8B5.js +0 -35
- 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 +0 -1
- package/dist/web/assets/index-CH5Em-Mo.css.br +0 -0
- package/dist/web/assets/index-CH5Em-Mo.css.gz +0 -0
package/server/harness.ts
CHANGED
|
@@ -1,655 +1,443 @@
|
|
|
1
|
-
//
|
|
1
|
+
// The harnesses a profile can launch, and what each one can be told to do.
|
|
2
2
|
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
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
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
import {
|
|
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
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
export type {
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
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
|
-
*
|
|
110
|
-
*
|
|
111
|
-
*
|
|
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
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
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
|
-
|
|
126
|
-
"
|
|
127
|
-
|
|
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
|
-
//
|
|
78
|
+
// no directory is no user harnesses
|
|
133
79
|
}
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
for (const candidate of candidates) {
|
|
80
|
+
for (const file of files) {
|
|
81
|
+
const full = path.join(dir, file);
|
|
137
82
|
try {
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
);
|
|
146
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
207
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
273
|
-
|
|
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
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
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
|
-
|
|
352
|
-
|
|
353
|
-
|
|
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
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
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
|
-
|
|
391
|
-
|
|
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
|
-
|
|
401
|
-
|
|
402
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
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
|
-
*
|
|
425
|
-
*
|
|
426
|
-
*
|
|
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
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
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
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
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
|
-
|
|
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
|
-
// ──
|
|
338
|
+
// ── discovery ────────────────────────────────────────────────────────────────
|
|
499
339
|
|
|
500
340
|
/**
|
|
501
|
-
*
|
|
341
|
+
* How long an adapter's own discovery gets before the editor goes without it.
|
|
502
342
|
*
|
|
503
|
-
*
|
|
504
|
-
*
|
|
505
|
-
*
|
|
506
|
-
*
|
|
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
|
|
511
|
-
`${home}/.local/bin/cursor-agent`,
|
|
512
|
-
"/usr/local/bin/cursor-agent",
|
|
513
|
-
];
|
|
348
|
+
const DISCOVERY_TIMEOUT = 60_000;
|
|
514
349
|
|
|
515
|
-
|
|
516
|
-
|
|
350
|
+
interface Found {
|
|
351
|
+
report: HarnessReport;
|
|
352
|
+
detail: unknown;
|
|
353
|
+
}
|
|
517
354
|
|
|
518
|
-
const
|
|
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
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
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
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
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
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
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
|
-
*
|
|
425
|
+
* What the box has for one harness, discovered once.
|
|
606
426
|
*
|
|
607
|
-
*
|
|
608
|
-
*
|
|
609
|
-
*
|
|
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
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
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);
|