@monster-orange/den 0.4.3 → 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 (58) hide show
  1. package/CHANGELOG.md +179 -0
  2. package/README.md +9 -9
  3. package/docs/distributions.md +215 -65
  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 +14 -18
  8. package/src/shared/agents/runtime-search.ts +142 -15
  9. package/src/shared/agents/runtime.ts +18 -1
  10. package/src/shared/agents/runtimes.ts +23 -1
  11. package/src/shared/auth/sudo-vault.ts +16 -0
  12. package/src/shared/den-config.ts +57 -3
  13. package/src/shared/edge/status.ts +134 -0
  14. package/src/shared/environment-manager/health.ts +82 -8
  15. package/src/shared/environment-manager/workspaces.ts +54 -0
  16. package/src/shared/health/not-checked.ts +42 -0
  17. package/src/shared/health/rollup.ts +4 -0
  18. package/src/shared/process/spawn-streaming.ts +8 -3
  19. package/src/shared/sessions/client.ts +5 -1
  20. package/src/shared/sessions/host.ts +29 -1
  21. package/src/shared/sessions/protocol.ts +6 -1
  22. package/src/shared/sessions/types.ts +6 -0
  23. package/src/shared/stacks/status.ts +24 -0
  24. package/src/shared/strategies/registry.ts +5 -0
  25. package/src/shared/types/index.ts +6 -0
  26. package/src/ui/cli/agent-ops.ts +210 -19
  27. package/src/ui/cli/agents-command.ts +50 -21
  28. package/src/ui/cli/edge-command.ts +16 -3
  29. package/src/ui/cli/health-display.ts +4 -1
  30. package/src/ui/mcp/scope.ts +23 -3
  31. package/src/ui/mcp/server.ts +31 -7
  32. package/src/ui/mcp/tools.ts +327 -20
  33. package/src/ui/mcp/views.ts +450 -0
  34. package/src/ui/server/agent-routes.ts +118 -28
  35. package/src/ui/server/index.ts +77 -6
  36. package/src/ui/server/public/agent-start.js +46 -7
  37. package/src/ui/server/public/agents-page.js +232 -97
  38. package/src/ui/server/public/agents-section.js +3 -5
  39. package/src/ui/server/public/agents.html +12 -28
  40. package/src/ui/server/public/graph-model.js +2 -0
  41. package/src/ui/server/public/header.js +2 -3
  42. package/src/ui/server/public/health-rollup.js +20 -8
  43. package/src/ui/server/public/i18n/es.js +15 -13
  44. package/src/ui/server/public/i18n/ja.js +14 -13
  45. package/src/ui/server/public/index.html +17 -4
  46. package/src/ui/server/public/local-dev.html +28 -17
  47. package/src/ui/server/public/services.html +21 -5
  48. package/src/ui/server/public/settings-page.js +3 -2
  49. package/src/ui/server/public/settings-registry.js +225 -0
  50. package/src/ui/server/public/settings.html +90 -1
  51. package/src/ui/server/public/shell.js +28 -23
  52. package/src/ui/server/public/topbar.js +166 -206
  53. package/src/ui/server/readonly-routes.ts +29 -5
  54. package/src/ui/server/request-guard.ts +1 -1
  55. package/src/ui/server/route-classes.ts +14 -2
  56. package/src/ui/server/session-routes.ts +9 -1
  57. package/src/ui/server/sudo-store.ts +170 -6
  58. package/src/ui/server/workspace-routes.ts +10 -3
@@ -0,0 +1,343 @@
1
+ /**
2
+ * The runtime catalog (AG-0.5): every vendor CLI Den knows how to find,
3
+ * shipped as data with each release. An entry says:
4
+ *
5
+ * id the runtime's id ("den-claude", "codex")
6
+ * vendor who makes it ("Anthropic")
7
+ * name its name for people ("Claude Code")
8
+ * bins its executable names, in order
9
+ * version args: what prints its version ("--version"); identity: how
10
+ * a path is known to be this runtime, not just any program that
11
+ * prints a version (its output names it, it lives in the
12
+ * vendor's npm package, or under the vendor's install dir).
13
+ * A path someone sets is refused unless one of these holds.
14
+ * dirs its well-known install directories: "~/…", an absolute
15
+ * path, "$NAME" (an install-dir variable the vendor's installer
16
+ * honours; skipped when unset) or "{system}" (Homebrew's,
17
+ * /usr/local/bin and npm's global bin)
18
+ * launch argv: its interactive CLI, "{bin}" first (the path found)
19
+ *
20
+ * Each built-in entry was checked against the vendor's own docs or
21
+ * installer (the URLs beside it). A vendor with no official CLI is left
22
+ * out (docs/distributions.md, Runtimes, says which, and why); nothing is
23
+ * guessed.
24
+ *
25
+ * A distribution (`agents.runtime_catalog` in its config) and a person
26
+ * (`agents.runtime_catalog` in config/local/user.yml) add entries, checked
27
+ * by the same validator (validateCatalogEntry). An entry can't replace a
28
+ * built-in one: a path is set with agents.runtimes.<id>.bin instead.
29
+ */
30
+ import * as path from "path";
31
+
32
+ export interface CatalogIdentity {
33
+ /** A case-insensitive pattern the version command's output matches. */
34
+ output?: string;
35
+ /** The npm package the binary resolves into (its package.json name). */
36
+ package?: string;
37
+ /** Directories ("~/…" or absolute) the binary resolves under. */
38
+ realpath_under?: string[];
39
+ }
40
+
41
+ export interface CatalogEntry {
42
+ id: string;
43
+ vendor: string;
44
+ name: string;
45
+ bins: string[];
46
+ version: { args: string[]; identity: CatalogIdentity };
47
+ dirs: string[];
48
+ launch: { argv: string[] };
49
+ }
50
+
51
+ /** The keys an entry takes (the validator refuses any other). */
52
+ const ENTRY_KEYS = [
53
+ "id",
54
+ "vendor",
55
+ "name",
56
+ "bins",
57
+ "version",
58
+ "dirs",
59
+ "launch",
60
+ ] as const;
61
+
62
+ /** Den's built-in catalog, in the order lists show them. */
63
+ export const BUILTIN_CATALOG: readonly CatalogEntry[] = [
64
+ // Claude Code. https://code.claude.com/docs/en/setup: the native
65
+ // installer's launcher is ~/.local/bin/claude; `claude --version` prints
66
+ // "2.1.211 (Claude Code)"; npm package @anthropic-ai/claude-code;
67
+ // Homebrew cask claude-code. ~/.claude/local is the older local install.
68
+ {
69
+ id: "den-claude",
70
+ vendor: "Anthropic",
71
+ name: "Claude Code",
72
+ bins: ["claude"],
73
+ version: {
74
+ args: ["--version"],
75
+ identity: {
76
+ output: "\\(Claude Code\\)",
77
+ package: "@anthropic-ai/claude-code",
78
+ realpath_under: ["~/.local/share/claude"],
79
+ },
80
+ },
81
+ dirs: ["~/.local/bin", "~/.claude/local", "{system}"],
82
+ launch: { argv: ["{bin}"] },
83
+ },
84
+ // Codex CLI. https://github.com/openai/codex: binary `codex`, npm
85
+ // @openai/codex, Homebrew cask codex. The standalone installer
86
+ // (https://chatgpt.com/codex/install.sh → releases.openai.com) installs
87
+ // to ${CODEX_INSTALL_DIR:-$HOME/.local/bin}/codex and reads the version
88
+ // from `codex --version`; the CLI declares clap's version flag
89
+ // (codex-rs/cli/src/main.rs, bin_name "codex").
90
+ {
91
+ id: "codex",
92
+ vendor: "OpenAI",
93
+ name: "Codex CLI",
94
+ bins: ["codex"],
95
+ version: {
96
+ args: ["--version"],
97
+ identity: { output: "codex", package: "@openai/codex" },
98
+ },
99
+ dirs: ["$CODEX_INSTALL_DIR", "~/.local/bin", "{system}"],
100
+ launch: { argv: ["{bin}"] },
101
+ },
102
+ // Gemini CLI. https://github.com/google-gemini/gemini-cli: binary
103
+ // `gemini`, npm @google/gemini-cli, Homebrew formula gemini-cli;
104
+ // docs/cli/cli-reference.md: `--version`/`-v` "Show CLI version number
105
+ // and exit" (the number only, so the package names it), and no
106
+ // arguments starts the interactive REPL.
107
+ {
108
+ id: "gemini",
109
+ vendor: "Google",
110
+ name: "Gemini CLI",
111
+ bins: ["gemini"],
112
+ version: {
113
+ args: ["--version"],
114
+ identity: { output: "gemini", package: "@google/gemini-cli" },
115
+ },
116
+ dirs: ["{system}"],
117
+ launch: { argv: ["{bin}"] },
118
+ },
119
+ // Grok Build. https://docs.x.ai/build/overview: install with
120
+ // https://x.ai/cli/install.sh, run `grok` in a project directory. The
121
+ // installer puts the binary in ~/.grok/downloads, links it as
122
+ // ${GROK_BIN_DIR:-$HOME/.grok/bin}/grok (and into ~/.local/bin or
123
+ // /usr/local/bin when that dir isn't on PATH), and checks it with
124
+ // `grok --version`.
125
+ {
126
+ id: "grok",
127
+ vendor: "xAI",
128
+ name: "Grok Build",
129
+ bins: ["grok"],
130
+ version: {
131
+ args: ["--version"],
132
+ identity: { output: "grok", realpath_under: ["~/.grok"] },
133
+ },
134
+ dirs: ["$GROK_BIN_DIR", "~/.grok/bin", "~/.local/bin", "/usr/local/bin"],
135
+ launch: { argv: ["{bin}"] },
136
+ },
137
+ ];
138
+
139
+ /** Den's own runtime ids that a catalog entry can't take. */
140
+ const RESERVED_IDS = ["generic"];
141
+ const ID_RE = /^[a-z][a-z0-9-]{0,31}$/;
142
+ const BIN_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
143
+ const ENV_DIR_RE = /^\$[A-Z_][A-Z0-9_]*$/;
144
+ const PKG_RE = /^(@[a-z0-9-~][a-z0-9-._~]*\/)?[a-z0-9-~][a-z0-9-._~]*$/;
145
+
146
+ const isMapping = (v: unknown): v is Record<string, unknown> =>
147
+ v !== null && typeof v === "object" && !Array.isArray(v);
148
+ const shown = (v: unknown) =>
149
+ JSON.stringify(v)
150
+ ?.replace(/[^\x20-\x7e]/g, "?")
151
+ .slice(0, 60) ?? "undefined";
152
+
153
+ function text(where: string, v: unknown, max = 60): string {
154
+ if (typeof v !== "string" || !v.trim() || v.length > max)
155
+ throw new Error(`${where}: must be a name of 1-${max} characters`);
156
+ return v.trim();
157
+ }
158
+
159
+ function list(
160
+ where: string,
161
+ v: unknown,
162
+ each: (x: unknown, at: string) => string,
163
+ { min = 1, max = 16 } = {},
164
+ ): string[] {
165
+ if (!Array.isArray(v) || v.length < min || v.length > max)
166
+ throw new Error(`${where}: must be a list of ${min}-${max} entries`);
167
+ return v.map((x, i) => each(x, `${where}[${i}]`));
168
+ }
169
+
170
+ function dirOk(at: string, x: unknown): string {
171
+ if (
172
+ typeof x === "string" &&
173
+ (x === "{system}" ||
174
+ ENV_DIR_RE.test(x) ||
175
+ (x.startsWith("~/") && !x.includes("..")) ||
176
+ (path.isAbsolute(x) && !x.includes("..")))
177
+ )
178
+ return x;
179
+ throw new Error(
180
+ `${at}: ${shown(x)} must be "~/…", an absolute path, "$NAME" or "{system}"`,
181
+ );
182
+ }
183
+
184
+ /**
185
+ * One catalog entry, checked; throws naming the key (`where`, e.g.
186
+ * "agents.runtime_catalog[0]"). Never guesses a missing field.
187
+ */
188
+ export function validateCatalogEntry(
189
+ raw: unknown,
190
+ where: string,
191
+ ): CatalogEntry {
192
+ if (!isMapping(raw)) throw new Error(`${where}: must be a mapping`);
193
+ for (const k of Object.keys(raw))
194
+ if (!(ENTRY_KEYS as readonly string[]).includes(k))
195
+ throw new Error(
196
+ `${where}.${k}: unknown key (an entry takes ${ENTRY_KEYS.join(", ")})`,
197
+ );
198
+ const id = raw.id;
199
+ if (typeof id !== "string" || !ID_RE.test(id) || RESERVED_IDS.includes(id))
200
+ throw new Error(
201
+ `${where}.id: ${shown(id)} must be lowercase letters, digits and "-" (1-32), not ${RESERVED_IDS.join(", ")}`,
202
+ );
203
+ const bins = list(`${where}.bins`, raw.bins, (x, at) => {
204
+ if (typeof x === "string" && BIN_RE.test(x)) return x;
205
+ throw new Error(`${at}: ${shown(x)} must be an executable's name, no path`);
206
+ });
207
+ const version = raw.version;
208
+ if (!isMapping(version))
209
+ throw new Error(`${where}.version: must be a mapping { args, identity }`);
210
+ for (const k of Object.keys(version))
211
+ if (k !== "args" && k !== "identity")
212
+ throw new Error(
213
+ `${where}.version.${k}: unknown key (version takes args, identity)`,
214
+ );
215
+ const args = list(
216
+ `${where}.version.args`,
217
+ version.args,
218
+ (x, at) => {
219
+ if (typeof x === "string" && x.length <= 40 && !/[\0\n]/.test(x))
220
+ return x;
221
+ throw new Error(`${at}: must be an argument of up to 40 characters`);
222
+ },
223
+ { min: 1, max: 4 },
224
+ );
225
+ const idn = version.identity;
226
+ if (!isMapping(idn))
227
+ throw new Error(
228
+ `${where}.version.identity: must be a mapping with output, package or realpath_under`,
229
+ );
230
+ const identity: CatalogIdentity = {};
231
+ for (const [k, v] of Object.entries(idn)) {
232
+ const at = `${where}.version.identity.${k}`;
233
+ if (k === "output") {
234
+ if (typeof v !== "string" || !v || v.length > 100)
235
+ throw new Error(`${at}: must be a pattern of 1-100 characters`);
236
+ try {
237
+ new RegExp(v, "i");
238
+ } catch {
239
+ throw new Error(`${at}: ${shown(v)} is not a valid pattern`);
240
+ }
241
+ identity.output = v;
242
+ } else if (k === "package") {
243
+ if (typeof v !== "string" || !PKG_RE.test(v))
244
+ throw new Error(`${at}: ${shown(v)} is not an npm package name`);
245
+ identity.package = v;
246
+ } else if (k === "realpath_under") {
247
+ identity.realpath_under = list(at, v, (x, a) => dirOk(a, x)).map((d) => {
248
+ if (d === "{system}" || d.startsWith("$"))
249
+ throw new Error(`${at}: ${shown(d)} must be "~/…" or absolute`);
250
+ return d;
251
+ });
252
+ } else
253
+ throw new Error(
254
+ `${at}: unknown key (identity takes output, package, realpath_under)`,
255
+ );
256
+ }
257
+ if (!identity.output && !identity.package && !identity.realpath_under)
258
+ throw new Error(
259
+ `${where}.version.identity: needs output, package or realpath_under (how a path is known to be ${id})`,
260
+ );
261
+ const dirs = list(`${where}.dirs`, raw.dirs, (x, at) => dirOk(at, x), {
262
+ min: 0,
263
+ max: 16,
264
+ });
265
+ const launch = raw.launch;
266
+ if (!isMapping(launch) || Object.keys(launch).some((k) => k !== "argv"))
267
+ throw new Error(`${where}.launch: must be a mapping { argv: [...] }`);
268
+ const argv = list(
269
+ `${where}.launch.argv`,
270
+ launch.argv,
271
+ (x, at) => {
272
+ if (typeof x === "string" && x.length <= 200 && !/[\0\n]/.test(x))
273
+ return x;
274
+ throw new Error(`${at}: must be an argument of up to 200 characters`);
275
+ },
276
+ { min: 1, max: 16 },
277
+ );
278
+ if (argv[0] !== "{bin}")
279
+ throw new Error(
280
+ `${where}.launch.argv[0]: must be "{bin}" (the path Den found)`,
281
+ );
282
+ return {
283
+ id,
284
+ vendor: text(`${where}.vendor`, raw.vendor),
285
+ name: text(`${where}.name`, raw.name),
286
+ bins,
287
+ version: { args, identity },
288
+ dirs,
289
+ launch: { argv },
290
+ };
291
+ }
292
+
293
+ /**
294
+ * Extra entries (a distribution's, then user.yml's), checked; an id that
295
+ * is already in the catalog (a built-in, or an earlier entry) is refused.
296
+ */
297
+ export function validateCatalog(
298
+ raw: unknown,
299
+ where: string,
300
+ taken: readonly string[] = BUILTIN_CATALOG.map((e) => e.id),
301
+ ): CatalogEntry[] {
302
+ if (raw === undefined || raw === null) return [];
303
+ if (!Array.isArray(raw))
304
+ throw new Error(`${where}: must be a list of runtime entries`);
305
+ const ids = [...taken];
306
+ return raw.map((x, i) => {
307
+ const e = validateCatalogEntry(x, `${where}[${i}]`);
308
+ if (ids.includes(e.id))
309
+ throw new Error(
310
+ `${where}[${i}].id: "${e.id}" is already in the catalog (set its path with agents.runtimes.${e.id}.bin instead)`,
311
+ );
312
+ ids.push(e.id);
313
+ return e;
314
+ });
315
+ }
316
+
317
+ /**
318
+ * An entry's install directories on this machine: "~/" from HOME, "$NAME"
319
+ * from the env (left out when unset), "{system}" as `system` gives them.
320
+ */
321
+ export function entryDirs(
322
+ e: Pick<CatalogEntry, "dirs">,
323
+ home: string | undefined,
324
+ env: NodeJS.ProcessEnv,
325
+ system: (env: NodeJS.ProcessEnv) => string[],
326
+ ): string[] {
327
+ const out: string[] = [];
328
+ for (const d of e.dirs) {
329
+ if (d === "{system}") out.push(...system(env));
330
+ else if (d.startsWith("$")) {
331
+ const v = env[d.slice(1)];
332
+ if (v && path.isAbsolute(v)) out.push(v);
333
+ } else if (d.startsWith("~/")) {
334
+ if (home) out.push(path.join(home, d.slice(2)));
335
+ } else out.push(d);
336
+ }
337
+ return out;
338
+ }
339
+
340
+ /** A built-in entry by id. */
341
+ export function builtinEntry(id: string): CatalogEntry | undefined {
342
+ return BUILTIN_CATALOG.find((e) => e.id === id);
343
+ }
@@ -51,27 +51,20 @@ import {
51
51
  type Runtime,
52
52
  type RuntimeDetect,
53
53
  } from "./runtime.js";
54
- import {
55
- locateRuntime,
56
- systemBinDirs,
57
- type RuntimeFind,
58
- } from "./runtime-search.js";
54
+ import { builtinEntry } from "./catalog.js";
55
+ import { catalogFind } from "./catalog-runtime.js";
56
+ import { locateRuntime, type RuntimeFind } from "./runtime-search.js";
59
57
 
60
58
  export const DEN_CLAUDE_ID = "den-claude";
61
59
  const BIN = "claude";
62
60
 
63
- /** Where Claude Code is found, and how its version is asked. */
64
- export const DEN_CLAUDE_FIND: RuntimeFind = {
65
- bins: [BIN],
66
- dirs: (home, env) => [
67
- // The native installer's, then the older local install's.
68
- ...(home
69
- ? [path.join(home, ".local", "bin"), path.join(home, ".claude", "local")]
70
- : []),
71
- ...systemBinDirs(env),
72
- ],
73
- versionArgs: ["--version"],
74
- };
61
+ /**
62
+ * Where Claude Code is found, and how its version is asked: its catalog
63
+ * entry (catalog.ts): the native installer's ~/.local/bin, the older
64
+ * local install's ~/.claude/local, Homebrew's, /usr/local/bin, npm's.
65
+ */
66
+ export const DEN_CLAUDE_CATALOG = builtinEntry(DEN_CLAUDE_ID)!;
67
+ export const DEN_CLAUDE_FIND: RuntimeFind = catalogFind(DEN_CLAUDE_CATALOG);
75
68
  const PACKAGE = "@anthropic-ai/claude-code";
76
69
  const SEMVER = /^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/;
77
70
 
@@ -281,7 +274,10 @@ function binFor(input: LaunchInput): string {
281
274
 
282
275
  export const denClaude: Runtime = {
283
276
  id: DEN_CLAUDE_ID,
284
- label: "Claude Code",
277
+ label: DEN_CLAUDE_CATALOG.name,
278
+ vendor: DEN_CLAUDE_CATALOG.vendor,
279
+ catalog: DEN_CLAUDE_CATALOG,
280
+ denTools: true,
285
281
  find: DEN_CLAUDE_FIND,
286
282
  detect,
287
283
  launch(input): LaunchOutput {
@@ -5,10 +5,13 @@
5
5
  * Claude Code's native installer puts `claude`.
6
6
  *
7
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;
8
11
  * 1. setting `agents.runtimes.<id>.bin` in config/local/user.yml
9
12
  * (per machine, so never in the distribution's config):
10
- * an absolute path to an executable file; a bad one is
11
- * refused with its reason, never skipped.
13
+ * an absolute path to an executable file; a bad one (at
14
+ * either scope) is refused with its reason, never skipped.
12
15
  * 2. login-shell the login shell's PATH (`$SHELL -lc`), resolved once
13
16
  * at server start, async, under a 3-second timeout; on
14
17
  * any failure it is left out (the current PATH remains).
@@ -18,6 +21,11 @@
18
21
  * /opt/homebrew/bin, /usr/local/bin and npm's global
19
22
  * bin, derived from npm_config_prefix and node's own
20
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).
21
29
  *
22
30
  * Sessions and agents Den starts get the same directories on their PATH
23
31
  * (sessionPath), so the runtime's own tools are found too.
@@ -32,7 +40,13 @@ import { spawn } from "child_process";
32
40
  import * as fs from "fs";
33
41
  import * as path from "path";
34
42
 
35
- export type BinSource = "setting" | "login-shell" | "well-known" | "PATH";
43
+ export type BinSource =
44
+ | "workspace"
45
+ | "setting"
46
+ | "login-shell"
47
+ | "well-known"
48
+ | "PATH"
49
+ | "interactive-shell";
36
50
 
37
51
  /** The shell asked for the login PATH instead of $SHELL; "off" for none. */
38
52
  export const LOGIN_SHELL_ENV = "DEN_LOGIN_SHELL";
@@ -52,7 +66,7 @@ export interface RuntimeFind {
52
66
 
53
67
  export interface SearchDir {
54
68
  dir: string;
55
- source: Exclude<BinSource, "setting">;
69
+ source: Exclude<BinSource, "setting" | "workspace">;
56
70
  }
57
71
 
58
72
  export interface Located {
@@ -96,6 +110,8 @@ const END = "__DEN_PATH_END__";
96
110
  export interface LoginPathOptions {
97
111
  env?: NodeJS.ProcessEnv;
98
112
  timeoutMs?: number;
113
+ /** The shell's flags: "-lc" (a login shell), "-ilc" (interactive too). */
114
+ flags?: "-lc" | "-ilc";
99
115
  }
100
116
 
101
117
  /**
@@ -127,10 +143,14 @@ export function resolveLoginPath(
127
143
  };
128
144
  let child: ReturnType<typeof spawn>;
129
145
  try {
130
- child = spawn(shell, ["-lc", `printf '${BEGIN}%s${END}' "$PATH"`], {
131
- env,
132
- stdio: ["ignore", "pipe", "ignore"],
133
- });
146
+ child = spawn(
147
+ shell,
148
+ [o.flags ?? "-lc", `printf '${BEGIN}%s${END}' "$PATH"`],
149
+ {
150
+ env,
151
+ stdio: ["ignore", "pipe", "ignore"],
152
+ },
153
+ );
134
154
  } catch {
135
155
  resolve(undefined);
136
156
  return;
@@ -167,6 +187,38 @@ export async function initLoginPath(o: LoginPathOptions = {}): Promise<void> {
167
187
  if (p) setLoginPath(p);
168
188
  }
169
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
+
170
222
  // ── the directories ─────────────────────────────────────────────────────────
171
223
 
172
224
  /** A PATH-style list's absolute entries, in order, once each. */
@@ -213,7 +265,10 @@ export function wellKnownDirs(
213
265
  : [];
214
266
  }
215
267
 
216
- /** Where a runtime is looked for, in order: login shell, PATH, well-known. */
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
+ */
217
272
  export function searchDirs(
218
273
  find: RuntimeFind | undefined,
219
274
  env: NodeJS.ProcessEnv,
@@ -226,6 +281,7 @@ export function searchDirs(
226
281
  add(loginDirs ?? [], "login-shell");
227
282
  add(absoluteDirs(env.PATH ?? ""), "PATH");
228
283
  add(wellKnownDirs(find, env), "well-known");
284
+ add(interactiveDirs ?? [], "interactive-shell");
229
285
  return out;
230
286
  }
231
287
 
@@ -248,8 +304,8 @@ export const binSettingKey = (id: string) => `agents.runtimes.${id}.bin`;
248
304
  export function checkBinSetting(
249
305
  id: string,
250
306
  value: unknown,
307
+ key: string = binSettingKey(id),
251
308
  ): { path: string } | { why: string } {
252
- const key = binSettingKey(id);
253
309
  if (
254
310
  value &&
255
311
  typeof value === "object" &&
@@ -268,10 +324,18 @@ export function checkBinSetting(
268
324
  return { path: p };
269
325
  }
270
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
+
271
335
  /**
272
336
  * Find runtime `id`'s CLI: the setting when there is one (refused with its
273
337
  * reason when bad), else the first of `find.bins` in searchDirs. Runs
274
- * nothing.
338
+ * nothing. `setting` is a value (global, as before) or a BinSetting.
275
339
  */
276
340
  export function locateRuntime(
277
341
  id: string,
@@ -279,11 +343,17 @@ export function locateRuntime(
279
343
  env: NodeJS.ProcessEnv,
280
344
  setting?: unknown,
281
345
  ): Located {
282
- if (setting !== undefined && setting !== null) {
283
- const c = checkBinSetting(id, setting);
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);
284
354
  return "path" in c
285
- ? { available: true, path: c.path, source: "setting", searched: [] }
286
- : { available: false, why: c.why, source: "setting", searched: [] };
355
+ ? { available: true, path: c.path, source: set.source, searched: [] }
356
+ : { available: false, why: c.why, source: set.source, searched: [] };
287
357
  }
288
358
  const dirs = searchDirs(find, env);
289
359
  for (const { dir, source } of dirs)
@@ -304,6 +374,17 @@ export function locateRuntime(
304
374
  };
305
375
  }
306
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
+
307
388
  /**
308
389
  * The PATH a session Den starts gets: the login shell's, the caller's,
309
390
  * then each runtime's well-known directories that exist, and `extra` (a
@@ -410,6 +491,52 @@ export function runtimeVersion(
410
491
  return p;
411
492
  }
412
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
+
413
540
  /** Forget every version asked (tests). */
414
541
  export function clearVersionCache(): void {
415
542
  versions.clear();