@monster-orange/den 0.4.2 → 0.4.4

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 (64) hide show
  1. package/CHANGELOG.md +273 -0
  2. package/README.md +9 -9
  3. package/docs/distributions.md +313 -50
  4. package/package.json +1 -1
  5. package/src/shared/agents/catalog-runtime.ts +178 -0
  6. package/src/shared/agents/catalog.ts +343 -0
  7. package/src/shared/agents/den-claude.ts +53 -12
  8. package/src/shared/agents/generic.ts +1 -0
  9. package/src/shared/agents/runtime-search.ts +543 -0
  10. package/src/shared/agents/runtime.ts +43 -1
  11. package/src/shared/agents/runtimes.ts +53 -4
  12. package/src/shared/auth/sudo-vault.ts +16 -0
  13. package/src/shared/den-config.ts +92 -2
  14. package/src/shared/edge/render.ts +31 -0
  15. package/src/shared/edge/status.ts +134 -0
  16. package/src/shared/environment-manager/health.ts +82 -8
  17. package/src/shared/environment-manager/workspaces.ts +54 -0
  18. package/src/shared/health/not-checked.ts +42 -0
  19. package/src/shared/health/rollup.ts +4 -0
  20. package/src/shared/process/spawn-streaming.ts +8 -3
  21. package/src/shared/sessions/client.ts +5 -1
  22. package/src/shared/sessions/host.ts +29 -1
  23. package/src/shared/sessions/protocol.ts +6 -1
  24. package/src/shared/sessions/types.ts +6 -0
  25. package/src/shared/stacks/status.ts +24 -0
  26. package/src/shared/strategies/registry.ts +5 -0
  27. package/src/shared/types/index.ts +6 -0
  28. package/src/ui/cli/agent-ops.ts +298 -19
  29. package/src/ui/cli/agents-command.ts +129 -4
  30. package/src/ui/cli/edge-command.ts +16 -3
  31. package/src/ui/cli/health-display.ts +4 -1
  32. package/src/ui/cli/sessions-command.ts +9 -4
  33. package/src/ui/cli/web-command.ts +53 -8
  34. package/src/ui/mcp/scope.ts +23 -3
  35. package/src/ui/mcp/server.ts +31 -7
  36. package/src/ui/mcp/tools.ts +327 -20
  37. package/src/ui/mcp/views.ts +450 -0
  38. package/src/ui/server/agent-routes.ts +123 -26
  39. package/src/ui/server/edge-exposure.ts +143 -0
  40. package/src/ui/server/index.ts +171 -25
  41. package/src/ui/server/public/agent-start.js +241 -0
  42. package/src/ui/server/public/agents-page.js +246 -45
  43. package/src/ui/server/public/agents-section.js +7 -21
  44. package/src/ui/server/public/agents.html +55 -1
  45. package/src/ui/server/public/graph-model.js +2 -0
  46. package/src/ui/server/public/header.js +2 -3
  47. package/src/ui/server/public/health-rollup.js +20 -8
  48. package/src/ui/server/public/i18n/es.js +26 -13
  49. package/src/ui/server/public/i18n/ja.js +25 -13
  50. package/src/ui/server/public/index.html +17 -4
  51. package/src/ui/server/public/local-dev.html +62 -22
  52. package/src/ui/server/public/services.html +21 -5
  53. package/src/ui/server/public/settings-page.js +3 -2
  54. package/src/ui/server/public/settings-registry.js +226 -1
  55. package/src/ui/server/public/settings.html +90 -1
  56. package/src/ui/server/public/shell.js +67 -22
  57. package/src/ui/server/public/topbar.js +217 -158
  58. package/src/ui/server/readonly-routes.ts +29 -5
  59. package/src/ui/server/request-guard.ts +123 -8
  60. package/src/ui/server/route-classes.ts +14 -2
  61. package/src/ui/server/session-routes.ts +9 -1
  62. package/src/ui/server/sudo-store.ts +170 -6
  63. package/src/ui/server/ui-assets.ts +2 -0
  64. package/src/ui/server/workspace-routes.ts +10 -3
@@ -0,0 +1,543 @@
1
+ /**
2
+ * Where a runtime's CLI is found (AG-0.1): the way the person's terminal
3
+ * would find it, not only on the server's own PATH. A web server run as a
4
+ * launchd service gets a short PATH that leaves out `~/.local/bin`, where
5
+ * Claude Code's native installer puts `claude`.
6
+ *
7
+ * The search, first match wins:
8
+ * 0. workspace `<ws>.agents.runtimes.<id>.bin` in
9
+ * config/local/workspaces.yml (AG-0.5), for an agent of
10
+ * that workspace;
11
+ * 1. setting `agents.runtimes.<id>.bin` in config/local/user.yml
12
+ * (per machine, so never in the distribution's config):
13
+ * an absolute path to an executable file; a bad one (at
14
+ * either scope) is refused with its reason, never skipped.
15
+ * 2. login-shell the login shell's PATH (`$SHELL -lc`), resolved once
16
+ * at server start, async, under a 3-second timeout; on
17
+ * any failure it is left out (the current PATH remains).
18
+ * 3. PATH the caller's own PATH.
19
+ * 4. well-known the runtime's install directories (its `find.dirs`:
20
+ * for den-claude ~/.local/bin, ~/.claude/local,
21
+ * /opt/homebrew/bin, /usr/local/bin and npm's global
22
+ * bin, derived from npm_config_prefix and node's own
23
+ * directory, never by running npm).
24
+ * 5. interactive-shell only when a runtime is still missing: the
25
+ * interactive login shell's PATH (`$SHELL -ilc`, where
26
+ * some people set PATH in .zshrc/.bashrc), asked once per
27
+ * process with stdin closed under the same 3-second
28
+ * timeout, and kept (ensureInteractivePath).
29
+ *
30
+ * Sessions and agents Den starts get the same directories on their PATH
31
+ * (sessionPath), so the runtime's own tools are found too.
32
+ *
33
+ * Two engine overrides, for tests and diagnosis (names only, never
34
+ * secrets), read from the env given, else this process's: DEN_LOGIN_SHELL,
35
+ * the shell to ask instead of $SHELL ("off": none), and DEN_RUNTIME_DIRS,
36
+ * a PATH-style list that replaces every runtime's well-known directories
37
+ * (empty: none).
38
+ */
39
+ import { spawn } from "child_process";
40
+ import * as fs from "fs";
41
+ import * as path from "path";
42
+
43
+ export type BinSource =
44
+ | "workspace"
45
+ | "setting"
46
+ | "login-shell"
47
+ | "well-known"
48
+ | "PATH"
49
+ | "interactive-shell";
50
+
51
+ /** The shell asked for the login PATH instead of $SHELL; "off" for none. */
52
+ export const LOGIN_SHELL_ENV = "DEN_LOGIN_SHELL";
53
+ /** A PATH-style list replacing every runtime's well-known directories. */
54
+ export const RUNTIME_DIRS_ENV = "DEN_RUNTIME_DIRS";
55
+ export const LOGIN_PATH_TIMEOUT_MS = 3000;
56
+
57
+ /** How a runtime's CLI is found and its version asked (catalog-shaped). */
58
+ export interface RuntimeFind {
59
+ /** Executable names, in order ("claude"). */
60
+ bins: readonly string[];
61
+ /** Its well-known install directories, from HOME and the env. */
62
+ dirs?: (home: string | undefined, env: NodeJS.ProcessEnv) => string[];
63
+ /** The arguments that print its version ("--version"). */
64
+ versionArgs?: readonly string[];
65
+ }
66
+
67
+ export interface SearchDir {
68
+ dir: string;
69
+ source: Exclude<BinSource, "setting" | "workspace">;
70
+ }
71
+
72
+ export interface Located {
73
+ available: boolean;
74
+ path?: string;
75
+ source?: BinSource;
76
+ /** Why it is unavailable. */
77
+ why?: string;
78
+ /** The directories searched, in order (none when a setting decided). */
79
+ searched: string[];
80
+ }
81
+
82
+ /**
83
+ * An engine override: the env given, else this process's (a test run sets
84
+ * them once for every env it builds).
85
+ */
86
+ function engineOverride(
87
+ name: string,
88
+ env: NodeJS.ProcessEnv,
89
+ ): string | undefined {
90
+ return env[name] ?? process.env[name];
91
+ }
92
+
93
+ // ── the login shell's PATH ──────────────────────────────────────────────────
94
+
95
+ let loginDirs: string[] | null = null;
96
+
97
+ /** The login shell's PATH as resolved at start, or undefined. */
98
+ export function loginPath(): string | undefined {
99
+ return loginDirs ? loginDirs.join(path.delimiter) : undefined;
100
+ }
101
+
102
+ /** Set (or, with undefined, forget) the resolved login PATH. */
103
+ export function setLoginPath(p: string | undefined): void {
104
+ loginDirs = p ? absoluteDirs(p) : null;
105
+ }
106
+
107
+ const BEGIN = "__DEN_PATH_BEGIN__";
108
+ const END = "__DEN_PATH_END__";
109
+
110
+ export interface LoginPathOptions {
111
+ env?: NodeJS.ProcessEnv;
112
+ timeoutMs?: number;
113
+ /** The shell's flags: "-lc" (a login shell), "-ilc" (interactive too). */
114
+ flags?: "-lc" | "-ilc";
115
+ }
116
+
117
+ /**
118
+ * Ask the login shell for its PATH: `<shell> -lc 'printf …"$PATH"…'`,
119
+ * between markers so anything a startup file prints is ignored. Resolves
120
+ * undefined on no shell, a timeout (the shell is killed), a non-absolute
121
+ * shell, or nothing between the markers. Never throws, never blocks.
122
+ */
123
+ export function resolveLoginPath(
124
+ o: LoginPathOptions = {},
125
+ ): Promise<string | undefined> {
126
+ const env = o.env ?? process.env;
127
+ const shell = (
128
+ engineOverride(LOGIN_SHELL_ENV, env) ??
129
+ env.SHELL ??
130
+ ""
131
+ ).trim();
132
+ if (!shell || shell === "off" || !path.isAbsolute(shell))
133
+ return Promise.resolve(undefined);
134
+ const timeoutMs = o.timeoutMs ?? LOGIN_PATH_TIMEOUT_MS;
135
+ return new Promise((resolve) => {
136
+ let out = "";
137
+ let done = false;
138
+ const finish = (v: string | undefined) => {
139
+ if (done) return;
140
+ done = true;
141
+ clearTimeout(timer);
142
+ resolve(v);
143
+ };
144
+ let child: ReturnType<typeof spawn>;
145
+ try {
146
+ child = spawn(
147
+ shell,
148
+ [o.flags ?? "-lc", `printf '${BEGIN}%s${END}' "$PATH"`],
149
+ {
150
+ env,
151
+ stdio: ["ignore", "pipe", "ignore"],
152
+ },
153
+ );
154
+ } catch {
155
+ resolve(undefined);
156
+ return;
157
+ }
158
+ const timer = setTimeout(() => {
159
+ try {
160
+ child.kill("SIGKILL");
161
+ } catch {
162
+ /* gone */
163
+ }
164
+ finish(undefined);
165
+ }, timeoutMs);
166
+ timer.unref?.();
167
+ child.on("error", () => finish(undefined));
168
+ child.stdout?.on("data", (d: Buffer) => {
169
+ out += d.toString("utf-8");
170
+ if (out.length > 256 * 1024) out = out.slice(-128 * 1024);
171
+ const m = new RegExp(`${BEGIN}([^]*?)${END}`).exec(out);
172
+ if (m) {
173
+ const dirs = absoluteDirs(m[1]);
174
+ finish(dirs.length ? dirs.join(path.delimiter) : undefined);
175
+ }
176
+ });
177
+ child.on("close", () => finish(undefined));
178
+ });
179
+ }
180
+
181
+ /**
182
+ * Resolve the login PATH once and keep it (the server's start, `agents
183
+ * doctor`). Never throws; on failure the current PATH stays the search.
184
+ */
185
+ export async function initLoginPath(o: LoginPathOptions = {}): Promise<void> {
186
+ const p = await resolveLoginPath(o);
187
+ if (p) setLoginPath(p);
188
+ }
189
+
190
+ // ── the interactive shell's PATH (the fallback) ─────────────────────────────
191
+
192
+ let interactiveDirs: string[] | null = null;
193
+ let interactiveAsked: Promise<void> | null = null;
194
+
195
+ /**
196
+ * Ask the interactive login shell (`$SHELL -ilc`) for its PATH, once per
197
+ * process (stdin closed, the same timeout), and keep its directories as
198
+ * the last place a runtime is looked for. Only for a runtime still missing
199
+ * after the login PATH and the install directories; a second call waits
200
+ * on (or returns) the first. Never throws.
201
+ */
202
+ export function ensureInteractivePath(
203
+ o: Omit<LoginPathOptions, "flags"> = {},
204
+ ): Promise<void> {
205
+ interactiveAsked ??= resolveLoginPath({ ...o, flags: "-ilc" }).then((p) => {
206
+ if (p) interactiveDirs = absoluteDirs(p);
207
+ });
208
+ return interactiveAsked;
209
+ }
210
+
211
+ /** Whether the interactive shell was asked already (the answer is kept). */
212
+ export function interactivePathAsked(): boolean {
213
+ return interactiveAsked !== null;
214
+ }
215
+
216
+ /** Forget the interactive shell's PATH and that it was asked (tests). */
217
+ export function resetInteractivePath(): void {
218
+ interactiveDirs = null;
219
+ interactiveAsked = null;
220
+ }
221
+
222
+ // ── the directories ─────────────────────────────────────────────────────────
223
+
224
+ /** A PATH-style list's absolute entries, in order, once each. */
225
+ function absoluteDirs(list: string): string[] {
226
+ const out: string[] = [];
227
+ for (const d of list.split(path.delimiter)) {
228
+ const dir = d.trim();
229
+ if (dir && path.isAbsolute(dir) && !out.includes(dir)) out.push(dir);
230
+ }
231
+ return out;
232
+ }
233
+
234
+ /**
235
+ * npm's global bin, without running npm: `$npm_config_prefix/bin`, then
236
+ * the directory of the node binary (npm's default global prefix puts its
237
+ * bin there).
238
+ */
239
+ export function npmGlobalBins(
240
+ env: NodeJS.ProcessEnv,
241
+ execPath: string = process.execPath,
242
+ ): string[] {
243
+ const out: string[] = [];
244
+ const prefix = env.npm_config_prefix ?? env.NPM_CONFIG_PREFIX;
245
+ if (prefix && path.isAbsolute(prefix)) out.push(path.join(prefix, "bin"));
246
+ if (execPath && path.isAbsolute(execPath)) out.push(path.dirname(execPath));
247
+ return out;
248
+ }
249
+
250
+ /** The common install directories: Homebrew's, /usr/local's, npm's. */
251
+ export function systemBinDirs(env: NodeJS.ProcessEnv): string[] {
252
+ return ["/opt/homebrew/bin", "/usr/local/bin", ...npmGlobalBins(env)];
253
+ }
254
+
255
+ /** A runtime's well-known directories, or DEN_RUNTIME_DIRS when set. */
256
+ export function wellKnownDirs(
257
+ find: RuntimeFind | undefined,
258
+ env: NodeJS.ProcessEnv,
259
+ ): string[] {
260
+ const override = engineOverride(RUNTIME_DIRS_ENV, env);
261
+ if (override !== undefined) return absoluteDirs(override);
262
+ const home = env.HOME && path.isAbsolute(env.HOME) ? env.HOME : undefined;
263
+ return find?.dirs
264
+ ? absoluteDirs(find.dirs(home, env).join(path.delimiter))
265
+ : [];
266
+ }
267
+
268
+ /**
269
+ * Where a runtime is looked for, in order: login shell, PATH, well-known,
270
+ * then (once asked) the interactive shell's PATH.
271
+ */
272
+ export function searchDirs(
273
+ find: RuntimeFind | undefined,
274
+ env: NodeJS.ProcessEnv,
275
+ ): SearchDir[] {
276
+ const out: SearchDir[] = [];
277
+ const add = (dirs: string[], source: SearchDir["source"]) => {
278
+ for (const dir of dirs)
279
+ if (!out.some((x) => x.dir === dir)) out.push({ dir, source });
280
+ };
281
+ add(loginDirs ?? [], "login-shell");
282
+ add(absoluteDirs(env.PATH ?? ""), "PATH");
283
+ add(wellKnownDirs(find, env), "well-known");
284
+ add(interactiveDirs ?? [], "interactive-shell");
285
+ return out;
286
+ }
287
+
288
+ function isExecutableFile(file: string): boolean {
289
+ try {
290
+ fs.accessSync(file, fs.constants.X_OK);
291
+ return fs.statSync(file).isFile();
292
+ } catch {
293
+ return false;
294
+ }
295
+ }
296
+
297
+ /** The setting's key for runtime `id`. */
298
+ export const binSettingKey = (id: string) => `agents.runtimes.${id}.bin`;
299
+
300
+ /**
301
+ * A configured bin, checked: an absolute path to an executable file.
302
+ * Returns its path, or why it is refused (naming the key, never guessing).
303
+ */
304
+ export function checkBinSetting(
305
+ id: string,
306
+ value: unknown,
307
+ key: string = binSettingKey(id),
308
+ ): { path: string } | { why: string } {
309
+ if (
310
+ value &&
311
+ typeof value === "object" &&
312
+ typeof (value as { why?: unknown }).why === "string"
313
+ )
314
+ return { why: (value as { why: string }).why };
315
+ if (typeof value !== "string" || !value.trim())
316
+ return { why: `${key} must be an absolute path (got ${typeof value})` };
317
+ const p = value.trim();
318
+ const shown = p.replace(/[^\x20-\x7e]/g, "?").slice(0, 200);
319
+ if (!path.isAbsolute(p))
320
+ return { why: `${key}: "${shown}" is not an absolute path` };
321
+ if (!fs.existsSync(p)) return { why: `${key}: ${shown} does not exist` };
322
+ if (!isExecutableFile(p))
323
+ return { why: `${key}: ${shown} is not an executable file` };
324
+ return { path: p };
325
+ }
326
+
327
+ /** A path someone set: where (global or the workspace's) and its key. */
328
+ export interface BinSetting {
329
+ value: unknown;
330
+ source: "setting" | "workspace";
331
+ /** The key it is under, for a refusal ("agents.runtimes.codex.bin"). */
332
+ key?: string;
333
+ }
334
+
335
+ /**
336
+ * Find runtime `id`'s CLI: the setting when there is one (refused with its
337
+ * reason when bad), else the first of `find.bins` in searchDirs. Runs
338
+ * nothing. `setting` is a value (global, as before) or a BinSetting.
339
+ */
340
+ export function locateRuntime(
341
+ id: string,
342
+ find: RuntimeFind,
343
+ env: NodeJS.ProcessEnv,
344
+ setting?: unknown,
345
+ ): Located {
346
+ const set: BinSetting | null =
347
+ setting === undefined || setting === null
348
+ ? null
349
+ : isBinSetting(setting)
350
+ ? setting
351
+ : { value: setting, source: "setting" };
352
+ if (set && set.value !== undefined && set.value !== null) {
353
+ const c = checkBinSetting(id, set.value, set.key);
354
+ return "path" in c
355
+ ? { available: true, path: c.path, source: set.source, searched: [] }
356
+ : { available: false, why: c.why, source: set.source, searched: [] };
357
+ }
358
+ const dirs = searchDirs(find, env);
359
+ for (const { dir, source } of dirs)
360
+ for (const bin of find.bins) {
361
+ const file = path.join(dir, bin);
362
+ if (isExecutableFile(file))
363
+ return {
364
+ available: true,
365
+ path: file,
366
+ source,
367
+ searched: dirs.map((d) => d.dir),
368
+ };
369
+ }
370
+ return {
371
+ available: false,
372
+ why: `${find.bins.join(" or ")} not found (searched ${dirs.length} ${dirs.length === 1 ? "directory" : "directories"})`,
373
+ searched: dirs.map((d) => d.dir),
374
+ };
375
+ }
376
+
377
+ function isBinSetting(v: unknown): v is BinSetting {
378
+ return (
379
+ !!v &&
380
+ typeof v === "object" &&
381
+ "source" in v &&
382
+ "value" in v &&
383
+ ((v as BinSetting).source === "setting" ||
384
+ (v as BinSetting).source === "workspace")
385
+ );
386
+ }
387
+
388
+ /**
389
+ * The PATH a session Den starts gets: the login shell's, the caller's,
390
+ * then each runtime's well-known directories that exist, and `extra` (a
391
+ * configured bin's directory). The caller's PATH is kept as it is when
392
+ * nothing is added.
393
+ */
394
+ export function sessionPath(
395
+ env: NodeJS.ProcessEnv,
396
+ finds: readonly (RuntimeFind | undefined)[],
397
+ extra: readonly string[] = [],
398
+ ): string | undefined {
399
+ const own = env.PATH;
400
+ const ownDirs = absoluteDirs(own ?? "");
401
+ const before = (loginDirs ?? []).filter((d) => !ownDirs.includes(d));
402
+ const after: string[] = [];
403
+ const seen = new Set([...before, ...ownDirs]);
404
+ const candidates = [
405
+ ...finds.flatMap((f) => wellKnownDirs(f, env)),
406
+ ...extra.filter((d) => path.isAbsolute(d)),
407
+ ];
408
+ for (const d of candidates) {
409
+ if (seen.has(d)) continue;
410
+ seen.add(d);
411
+ try {
412
+ if (fs.statSync(d).isDirectory()) after.push(d);
413
+ } catch {
414
+ /* not on this machine */
415
+ }
416
+ }
417
+ if (!before.length && !after.length) return own;
418
+ return [...before, ...(own ? [own] : []), ...after].join(path.delimiter);
419
+ }
420
+
421
+ // ── the version ─────────────────────────────────────────────────────────────
422
+
423
+ const VERSION_TIMEOUT_MS = 3000;
424
+ const versions = new Map<string, Promise<string | undefined>>();
425
+
426
+ /** The first version-shaped token of a CLI's `--version` output. */
427
+ export function parseVersion(out: string): string | undefined {
428
+ const m = /\bv?(\d+\.\d+(?:\.\d+)?(?:[-+][0-9A-Za-z.-]+)?)\b/.exec(out);
429
+ return m?.[1];
430
+ }
431
+
432
+ /**
433
+ * Run `<bin> <versionArgs>` once (under a timeout; stdin closed, the env
434
+ * as given) and keep the answer, per path and file identity, so a page
435
+ * asking again runs nothing. Undefined when it fails or prints none.
436
+ */
437
+ export function runtimeVersion(
438
+ bin: string,
439
+ args: readonly string[],
440
+ env: NodeJS.ProcessEnv,
441
+ timeoutMs: number = VERSION_TIMEOUT_MS,
442
+ ): Promise<string | undefined> {
443
+ let key: string;
444
+ try {
445
+ const st = fs.statSync(bin);
446
+ key = `${bin}\0${st.ino}\0${st.size}\0${st.mtimeMs}\0${args.join(" ")}`;
447
+ } catch {
448
+ return Promise.resolve(undefined);
449
+ }
450
+ const known = versions.get(key);
451
+ if (known) return known;
452
+ const p = new Promise<string | undefined>((resolve) => {
453
+ let out = "";
454
+ let done = false;
455
+ const finish = (v: string | undefined) => {
456
+ if (done) return;
457
+ done = true;
458
+ clearTimeout(timer);
459
+ resolve(v);
460
+ };
461
+ let child: ReturnType<typeof spawn>;
462
+ try {
463
+ child = spawn(bin, [...args], {
464
+ env,
465
+ stdio: ["ignore", "pipe", "pipe"],
466
+ });
467
+ } catch {
468
+ resolve(undefined);
469
+ return;
470
+ }
471
+ const timer = setTimeout(() => {
472
+ try {
473
+ child.kill("SIGKILL");
474
+ } catch {
475
+ /* gone */
476
+ }
477
+ finish(undefined);
478
+ }, timeoutMs);
479
+ timer.unref?.();
480
+ const take = (d: Buffer) => {
481
+ if (out.length < 4096) out += d.toString("utf-8");
482
+ };
483
+ child.stdout?.on("data", take);
484
+ child.stderr?.on("data", take);
485
+ child.on("error", () => finish(undefined));
486
+ child.on("close", (code) =>
487
+ finish(code === 0 ? parseVersion(out) : undefined),
488
+ );
489
+ });
490
+ versions.set(key, p);
491
+ return p;
492
+ }
493
+
494
+ /**
495
+ * Run `<bin> <args>` (stdin closed, under a timeout, never cached) and
496
+ * return what it printed and whether it exited 0: to check a path someone
497
+ * sets is the runtime it claims to be. Never throws.
498
+ */
499
+ export function versionOutput(
500
+ bin: string,
501
+ args: readonly string[],
502
+ env: NodeJS.ProcessEnv,
503
+ timeoutMs: number = VERSION_TIMEOUT_MS,
504
+ ): Promise<{ ok: boolean; out: string }> {
505
+ return new Promise((resolve) => {
506
+ let out = "";
507
+ let done = false;
508
+ const finish = (ok: boolean) => {
509
+ if (done) return;
510
+ done = true;
511
+ clearTimeout(timer);
512
+ resolve({ ok, out });
513
+ };
514
+ let child: ReturnType<typeof spawn>;
515
+ try {
516
+ child = spawn(bin, [...args], { env, stdio: ["ignore", "pipe", "pipe"] });
517
+ } catch {
518
+ resolve({ ok: false, out: "" });
519
+ return;
520
+ }
521
+ const timer = setTimeout(() => {
522
+ try {
523
+ child.kill("SIGKILL");
524
+ } catch {
525
+ /* gone */
526
+ }
527
+ finish(false);
528
+ }, timeoutMs);
529
+ timer.unref?.();
530
+ const take = (d: Buffer) => {
531
+ if (out.length < 4096) out += d.toString("utf-8");
532
+ };
533
+ child.stdout?.on("data", take);
534
+ child.stderr?.on("data", take);
535
+ child.on("error", () => finish(false));
536
+ child.on("close", (code) => finish(code === 0));
537
+ });
538
+ }
539
+
540
+ /** Forget every version asked (tests). */
541
+ export function clearVersionCache(): void {
542
+ versions.clear();
543
+ }
@@ -13,9 +13,12 @@ import {
13
13
  getStrategy,
14
14
  registerStrategy,
15
15
  strategyIds,
16
+ unregisterStrategy,
16
17
  type StrategyEntry,
17
18
  } from "../strategies/registry.js";
19
+ import type { CatalogEntry } from "./catalog.js";
18
20
  import type { Persona } from "./personas.js";
21
+ import type { BinSource, RuntimeFind } from "./runtime-search.js";
19
22
 
20
23
  export const RUNTIME_KIND = "runtime";
21
24
 
@@ -27,6 +30,21 @@ export interface RuntimeDetect {
27
30
  version?: string;
28
31
  /** Why it is unavailable. */
29
32
  why?: string;
33
+ /** The CLI it runs, when found (AG-0.1). */
34
+ path?: string;
35
+ /** Where that path came from. */
36
+ source?: BinSource;
37
+ /** The directories searched, in order (names only, never values). */
38
+ searched?: string[];
39
+ }
40
+
41
+ export interface DetectOptions {
42
+ /**
43
+ * `agents.runtimes.<id>.bin` as configured in user.yml (undefined:
44
+ * read it there; null: none), or a BinSetting (runtime-search.ts) that
45
+ * says where it was set (the workspace's, or global).
46
+ */
47
+ bin?: unknown;
30
48
  }
31
49
 
32
50
  /** The MCP server an agent acts through: Den's, scoped to its workspace. */
@@ -69,6 +87,8 @@ export interface LaunchInput {
69
87
  * run that was never prompted starts afresh with the id it had.
70
88
  */
71
89
  resumeToken?: string;
90
+ /** The CLI detect found (a setting, the login PATH, …); else searched again. */
91
+ bin?: string;
72
92
  }
73
93
 
74
94
  export interface LaunchOutput {
@@ -88,7 +108,24 @@ export type ResumeInput = LaunchInput;
88
108
  export interface Runtime {
89
109
  /** "den-claude", "generic". */
90
110
  id: string;
91
- detect(env?: NodeJS.ProcessEnv): RuntimeDetect;
111
+ /** Its name for people ("Claude Code"), in the Start agent form. */
112
+ label?: string;
113
+ /** Who makes it ("Anthropic"): a catalog runtime's vendor (AG-0.5). */
114
+ vendor?: string;
115
+ /** Its catalog entry (catalog.ts), when it is one. */
116
+ catalog?: CatalogEntry;
117
+ /**
118
+ * Whether it runs with Den's tools (its MCP config and guards). false:
119
+ * it runs as its own CLI, tagged "Den tools: coming" (AG-0.5).
120
+ */
121
+ denTools?: boolean;
122
+ /**
123
+ * How its CLI is found and its version asked (AG-0.1): the search's
124
+ * binary names and install directories, per runtime, so a catalog can
125
+ * supply them (AG-0.5). None: nothing to find (generic).
126
+ */
127
+ find?: RuntimeFind;
128
+ detect(env?: NodeJS.ProcessEnv, opts?: DetectOptions): RuntimeDetect;
92
129
  /** Throws LaunchError when this persona or this machine can't run it. */
93
130
  launch(input: LaunchInput): LaunchOutput;
94
131
  /** A new run that continues the last one; null when there is nothing to resume. */
@@ -118,6 +155,11 @@ export class LaunchError extends Error {
118
155
  }
119
156
  }
120
157
 
158
+ /** Remove a runtime (a configured catalog entry that is gone). */
159
+ export function unregisterRuntime(id: string): void {
160
+ unregisterStrategy(RUNTIME_KIND, id);
161
+ }
162
+
121
163
  /** Register a runtime as strategy kind `runtime` (doctor lists it). */
122
164
  export function registerRuntime(rt: Runtime): void {
123
165
  const entry: StrategyEntry<typeof RUNTIME_KIND, Runtime> = {