@monster-orange/den 0.4.1 → 0.4.3
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/CHANGELOG.md +142 -0
- package/README.md +88 -158
- package/docs/distributions.md +151 -28
- package/docs/reference.md +177 -0
- package/package.json +1 -1
- package/src/shared/agents/den-claude.ts +57 -12
- package/src/shared/agents/generic.ts +1 -0
- package/src/shared/agents/runtime-search.ts +416 -0
- package/src/shared/agents/runtime.ts +26 -1
- package/src/shared/agents/runtimes.ts +30 -3
- package/src/shared/den-config.ts +88 -3
- package/src/shared/edge/render.ts +31 -0
- package/src/shared/sessions/client.ts +7 -0
- package/src/shared/sessions/den-pty.ts +10 -2
- package/src/shared/sessions/host.ts +33 -8
- package/src/shared/sessions/types.ts +8 -0
- package/src/ui/cli/agent-ops.ts +109 -21
- package/src/ui/cli/agents-command.ts +100 -4
- package/src/ui/cli/index.ts +33 -1
- package/src/ui/cli/sessions-command.ts +26 -9
- package/src/ui/cli/web-command.ts +60 -9
- package/src/ui/server/agent-routes.ts +11 -3
- package/src/ui/server/edge-exposure.ts +143 -0
- package/src/ui/server/index.ts +105 -23
- package/src/ui/server/public/agent-start.js +202 -0
- package/src/ui/server/public/agents-page.js +90 -18
- package/src/ui/server/public/agents-section.js +6 -18
- package/src/ui/server/public/agents.html +70 -0
- package/src/ui/server/public/i18n/es.js +11 -0
- package/src/ui/server/public/i18n/ja.js +11 -0
- package/src/ui/server/public/local-dev.html +43 -14
- package/src/ui/server/public/settings-registry.js +1 -1
- package/src/ui/server/public/shell.js +40 -0
- package/src/ui/server/public/topbar.js +127 -28
- package/src/ui/server/request-guard.ts +122 -7
- package/src/ui/server/ui-assets.ts +2 -0
|
@@ -0,0 +1,416 @@
|
|
|
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
|
+
* 1. setting `agents.runtimes.<id>.bin` in config/local/user.yml
|
|
9
|
+
* (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.
|
|
12
|
+
* 2. login-shell the login shell's PATH (`$SHELL -lc`), resolved once
|
|
13
|
+
* at server start, async, under a 3-second timeout; on
|
|
14
|
+
* any failure it is left out (the current PATH remains).
|
|
15
|
+
* 3. PATH the caller's own PATH.
|
|
16
|
+
* 4. well-known the runtime's install directories (its `find.dirs`:
|
|
17
|
+
* for den-claude ~/.local/bin, ~/.claude/local,
|
|
18
|
+
* /opt/homebrew/bin, /usr/local/bin and npm's global
|
|
19
|
+
* bin, derived from npm_config_prefix and node's own
|
|
20
|
+
* directory, never by running npm).
|
|
21
|
+
*
|
|
22
|
+
* Sessions and agents Den starts get the same directories on their PATH
|
|
23
|
+
* (sessionPath), so the runtime's own tools are found too.
|
|
24
|
+
*
|
|
25
|
+
* Two engine overrides, for tests and diagnosis (names only, never
|
|
26
|
+
* secrets), read from the env given, else this process's: DEN_LOGIN_SHELL,
|
|
27
|
+
* the shell to ask instead of $SHELL ("off": none), and DEN_RUNTIME_DIRS,
|
|
28
|
+
* a PATH-style list that replaces every runtime's well-known directories
|
|
29
|
+
* (empty: none).
|
|
30
|
+
*/
|
|
31
|
+
import { spawn } from "child_process";
|
|
32
|
+
import * as fs from "fs";
|
|
33
|
+
import * as path from "path";
|
|
34
|
+
|
|
35
|
+
export type BinSource = "setting" | "login-shell" | "well-known" | "PATH";
|
|
36
|
+
|
|
37
|
+
/** The shell asked for the login PATH instead of $SHELL; "off" for none. */
|
|
38
|
+
export const LOGIN_SHELL_ENV = "DEN_LOGIN_SHELL";
|
|
39
|
+
/** A PATH-style list replacing every runtime's well-known directories. */
|
|
40
|
+
export const RUNTIME_DIRS_ENV = "DEN_RUNTIME_DIRS";
|
|
41
|
+
export const LOGIN_PATH_TIMEOUT_MS = 3000;
|
|
42
|
+
|
|
43
|
+
/** How a runtime's CLI is found and its version asked (catalog-shaped). */
|
|
44
|
+
export interface RuntimeFind {
|
|
45
|
+
/** Executable names, in order ("claude"). */
|
|
46
|
+
bins: readonly string[];
|
|
47
|
+
/** Its well-known install directories, from HOME and the env. */
|
|
48
|
+
dirs?: (home: string | undefined, env: NodeJS.ProcessEnv) => string[];
|
|
49
|
+
/** The arguments that print its version ("--version"). */
|
|
50
|
+
versionArgs?: readonly string[];
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface SearchDir {
|
|
54
|
+
dir: string;
|
|
55
|
+
source: Exclude<BinSource, "setting">;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export interface Located {
|
|
59
|
+
available: boolean;
|
|
60
|
+
path?: string;
|
|
61
|
+
source?: BinSource;
|
|
62
|
+
/** Why it is unavailable. */
|
|
63
|
+
why?: string;
|
|
64
|
+
/** The directories searched, in order (none when a setting decided). */
|
|
65
|
+
searched: string[];
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* An engine override: the env given, else this process's (a test run sets
|
|
70
|
+
* them once for every env it builds).
|
|
71
|
+
*/
|
|
72
|
+
function engineOverride(
|
|
73
|
+
name: string,
|
|
74
|
+
env: NodeJS.ProcessEnv,
|
|
75
|
+
): string | undefined {
|
|
76
|
+
return env[name] ?? process.env[name];
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// ── the login shell's PATH ──────────────────────────────────────────────────
|
|
80
|
+
|
|
81
|
+
let loginDirs: string[] | null = null;
|
|
82
|
+
|
|
83
|
+
/** The login shell's PATH as resolved at start, or undefined. */
|
|
84
|
+
export function loginPath(): string | undefined {
|
|
85
|
+
return loginDirs ? loginDirs.join(path.delimiter) : undefined;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Set (or, with undefined, forget) the resolved login PATH. */
|
|
89
|
+
export function setLoginPath(p: string | undefined): void {
|
|
90
|
+
loginDirs = p ? absoluteDirs(p) : null;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const BEGIN = "__DEN_PATH_BEGIN__";
|
|
94
|
+
const END = "__DEN_PATH_END__";
|
|
95
|
+
|
|
96
|
+
export interface LoginPathOptions {
|
|
97
|
+
env?: NodeJS.ProcessEnv;
|
|
98
|
+
timeoutMs?: number;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Ask the login shell for its PATH: `<shell> -lc 'printf …"$PATH"…'`,
|
|
103
|
+
* between markers so anything a startup file prints is ignored. Resolves
|
|
104
|
+
* undefined on no shell, a timeout (the shell is killed), a non-absolute
|
|
105
|
+
* shell, or nothing between the markers. Never throws, never blocks.
|
|
106
|
+
*/
|
|
107
|
+
export function resolveLoginPath(
|
|
108
|
+
o: LoginPathOptions = {},
|
|
109
|
+
): Promise<string | undefined> {
|
|
110
|
+
const env = o.env ?? process.env;
|
|
111
|
+
const shell = (
|
|
112
|
+
engineOverride(LOGIN_SHELL_ENV, env) ??
|
|
113
|
+
env.SHELL ??
|
|
114
|
+
""
|
|
115
|
+
).trim();
|
|
116
|
+
if (!shell || shell === "off" || !path.isAbsolute(shell))
|
|
117
|
+
return Promise.resolve(undefined);
|
|
118
|
+
const timeoutMs = o.timeoutMs ?? LOGIN_PATH_TIMEOUT_MS;
|
|
119
|
+
return new Promise((resolve) => {
|
|
120
|
+
let out = "";
|
|
121
|
+
let done = false;
|
|
122
|
+
const finish = (v: string | undefined) => {
|
|
123
|
+
if (done) return;
|
|
124
|
+
done = true;
|
|
125
|
+
clearTimeout(timer);
|
|
126
|
+
resolve(v);
|
|
127
|
+
};
|
|
128
|
+
let child: ReturnType<typeof spawn>;
|
|
129
|
+
try {
|
|
130
|
+
child = spawn(shell, ["-lc", `printf '${BEGIN}%s${END}' "$PATH"`], {
|
|
131
|
+
env,
|
|
132
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
133
|
+
});
|
|
134
|
+
} catch {
|
|
135
|
+
resolve(undefined);
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
const timer = setTimeout(() => {
|
|
139
|
+
try {
|
|
140
|
+
child.kill("SIGKILL");
|
|
141
|
+
} catch {
|
|
142
|
+
/* gone */
|
|
143
|
+
}
|
|
144
|
+
finish(undefined);
|
|
145
|
+
}, timeoutMs);
|
|
146
|
+
timer.unref?.();
|
|
147
|
+
child.on("error", () => finish(undefined));
|
|
148
|
+
child.stdout?.on("data", (d: Buffer) => {
|
|
149
|
+
out += d.toString("utf-8");
|
|
150
|
+
if (out.length > 256 * 1024) out = out.slice(-128 * 1024);
|
|
151
|
+
const m = new RegExp(`${BEGIN}([^]*?)${END}`).exec(out);
|
|
152
|
+
if (m) {
|
|
153
|
+
const dirs = absoluteDirs(m[1]);
|
|
154
|
+
finish(dirs.length ? dirs.join(path.delimiter) : undefined);
|
|
155
|
+
}
|
|
156
|
+
});
|
|
157
|
+
child.on("close", () => finish(undefined));
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Resolve the login PATH once and keep it (the server's start, `agents
|
|
163
|
+
* doctor`). Never throws; on failure the current PATH stays the search.
|
|
164
|
+
*/
|
|
165
|
+
export async function initLoginPath(o: LoginPathOptions = {}): Promise<void> {
|
|
166
|
+
const p = await resolveLoginPath(o);
|
|
167
|
+
if (p) setLoginPath(p);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// ── the directories ─────────────────────────────────────────────────────────
|
|
171
|
+
|
|
172
|
+
/** A PATH-style list's absolute entries, in order, once each. */
|
|
173
|
+
function absoluteDirs(list: string): string[] {
|
|
174
|
+
const out: string[] = [];
|
|
175
|
+
for (const d of list.split(path.delimiter)) {
|
|
176
|
+
const dir = d.trim();
|
|
177
|
+
if (dir && path.isAbsolute(dir) && !out.includes(dir)) out.push(dir);
|
|
178
|
+
}
|
|
179
|
+
return out;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* npm's global bin, without running npm: `$npm_config_prefix/bin`, then
|
|
184
|
+
* the directory of the node binary (npm's default global prefix puts its
|
|
185
|
+
* bin there).
|
|
186
|
+
*/
|
|
187
|
+
export function npmGlobalBins(
|
|
188
|
+
env: NodeJS.ProcessEnv,
|
|
189
|
+
execPath: string = process.execPath,
|
|
190
|
+
): string[] {
|
|
191
|
+
const out: string[] = [];
|
|
192
|
+
const prefix = env.npm_config_prefix ?? env.NPM_CONFIG_PREFIX;
|
|
193
|
+
if (prefix && path.isAbsolute(prefix)) out.push(path.join(prefix, "bin"));
|
|
194
|
+
if (execPath && path.isAbsolute(execPath)) out.push(path.dirname(execPath));
|
|
195
|
+
return out;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** The common install directories: Homebrew's, /usr/local's, npm's. */
|
|
199
|
+
export function systemBinDirs(env: NodeJS.ProcessEnv): string[] {
|
|
200
|
+
return ["/opt/homebrew/bin", "/usr/local/bin", ...npmGlobalBins(env)];
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** A runtime's well-known directories, or DEN_RUNTIME_DIRS when set. */
|
|
204
|
+
export function wellKnownDirs(
|
|
205
|
+
find: RuntimeFind | undefined,
|
|
206
|
+
env: NodeJS.ProcessEnv,
|
|
207
|
+
): string[] {
|
|
208
|
+
const override = engineOverride(RUNTIME_DIRS_ENV, env);
|
|
209
|
+
if (override !== undefined) return absoluteDirs(override);
|
|
210
|
+
const home = env.HOME && path.isAbsolute(env.HOME) ? env.HOME : undefined;
|
|
211
|
+
return find?.dirs
|
|
212
|
+
? absoluteDirs(find.dirs(home, env).join(path.delimiter))
|
|
213
|
+
: [];
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/** Where a runtime is looked for, in order: login shell, PATH, well-known. */
|
|
217
|
+
export function searchDirs(
|
|
218
|
+
find: RuntimeFind | undefined,
|
|
219
|
+
env: NodeJS.ProcessEnv,
|
|
220
|
+
): SearchDir[] {
|
|
221
|
+
const out: SearchDir[] = [];
|
|
222
|
+
const add = (dirs: string[], source: SearchDir["source"]) => {
|
|
223
|
+
for (const dir of dirs)
|
|
224
|
+
if (!out.some((x) => x.dir === dir)) out.push({ dir, source });
|
|
225
|
+
};
|
|
226
|
+
add(loginDirs ?? [], "login-shell");
|
|
227
|
+
add(absoluteDirs(env.PATH ?? ""), "PATH");
|
|
228
|
+
add(wellKnownDirs(find, env), "well-known");
|
|
229
|
+
return out;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function isExecutableFile(file: string): boolean {
|
|
233
|
+
try {
|
|
234
|
+
fs.accessSync(file, fs.constants.X_OK);
|
|
235
|
+
return fs.statSync(file).isFile();
|
|
236
|
+
} catch {
|
|
237
|
+
return false;
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/** The setting's key for runtime `id`. */
|
|
242
|
+
export const binSettingKey = (id: string) => `agents.runtimes.${id}.bin`;
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* A configured bin, checked: an absolute path to an executable file.
|
|
246
|
+
* Returns its path, or why it is refused (naming the key, never guessing).
|
|
247
|
+
*/
|
|
248
|
+
export function checkBinSetting(
|
|
249
|
+
id: string,
|
|
250
|
+
value: unknown,
|
|
251
|
+
): { path: string } | { why: string } {
|
|
252
|
+
const key = binSettingKey(id);
|
|
253
|
+
if (
|
|
254
|
+
value &&
|
|
255
|
+
typeof value === "object" &&
|
|
256
|
+
typeof (value as { why?: unknown }).why === "string"
|
|
257
|
+
)
|
|
258
|
+
return { why: (value as { why: string }).why };
|
|
259
|
+
if (typeof value !== "string" || !value.trim())
|
|
260
|
+
return { why: `${key} must be an absolute path (got ${typeof value})` };
|
|
261
|
+
const p = value.trim();
|
|
262
|
+
const shown = p.replace(/[^\x20-\x7e]/g, "?").slice(0, 200);
|
|
263
|
+
if (!path.isAbsolute(p))
|
|
264
|
+
return { why: `${key}: "${shown}" is not an absolute path` };
|
|
265
|
+
if (!fs.existsSync(p)) return { why: `${key}: ${shown} does not exist` };
|
|
266
|
+
if (!isExecutableFile(p))
|
|
267
|
+
return { why: `${key}: ${shown} is not an executable file` };
|
|
268
|
+
return { path: p };
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* Find runtime `id`'s CLI: the setting when there is one (refused with its
|
|
273
|
+
* reason when bad), else the first of `find.bins` in searchDirs. Runs
|
|
274
|
+
* nothing.
|
|
275
|
+
*/
|
|
276
|
+
export function locateRuntime(
|
|
277
|
+
id: string,
|
|
278
|
+
find: RuntimeFind,
|
|
279
|
+
env: NodeJS.ProcessEnv,
|
|
280
|
+
setting?: unknown,
|
|
281
|
+
): Located {
|
|
282
|
+
if (setting !== undefined && setting !== null) {
|
|
283
|
+
const c = checkBinSetting(id, setting);
|
|
284
|
+
return "path" in c
|
|
285
|
+
? { available: true, path: c.path, source: "setting", searched: [] }
|
|
286
|
+
: { available: false, why: c.why, source: "setting", searched: [] };
|
|
287
|
+
}
|
|
288
|
+
const dirs = searchDirs(find, env);
|
|
289
|
+
for (const { dir, source } of dirs)
|
|
290
|
+
for (const bin of find.bins) {
|
|
291
|
+
const file = path.join(dir, bin);
|
|
292
|
+
if (isExecutableFile(file))
|
|
293
|
+
return {
|
|
294
|
+
available: true,
|
|
295
|
+
path: file,
|
|
296
|
+
source,
|
|
297
|
+
searched: dirs.map((d) => d.dir),
|
|
298
|
+
};
|
|
299
|
+
}
|
|
300
|
+
return {
|
|
301
|
+
available: false,
|
|
302
|
+
why: `${find.bins.join(" or ")} not found (searched ${dirs.length} ${dirs.length === 1 ? "directory" : "directories"})`,
|
|
303
|
+
searched: dirs.map((d) => d.dir),
|
|
304
|
+
};
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* The PATH a session Den starts gets: the login shell's, the caller's,
|
|
309
|
+
* then each runtime's well-known directories that exist, and `extra` (a
|
|
310
|
+
* configured bin's directory). The caller's PATH is kept as it is when
|
|
311
|
+
* nothing is added.
|
|
312
|
+
*/
|
|
313
|
+
export function sessionPath(
|
|
314
|
+
env: NodeJS.ProcessEnv,
|
|
315
|
+
finds: readonly (RuntimeFind | undefined)[],
|
|
316
|
+
extra: readonly string[] = [],
|
|
317
|
+
): string | undefined {
|
|
318
|
+
const own = env.PATH;
|
|
319
|
+
const ownDirs = absoluteDirs(own ?? "");
|
|
320
|
+
const before = (loginDirs ?? []).filter((d) => !ownDirs.includes(d));
|
|
321
|
+
const after: string[] = [];
|
|
322
|
+
const seen = new Set([...before, ...ownDirs]);
|
|
323
|
+
const candidates = [
|
|
324
|
+
...finds.flatMap((f) => wellKnownDirs(f, env)),
|
|
325
|
+
...extra.filter((d) => path.isAbsolute(d)),
|
|
326
|
+
];
|
|
327
|
+
for (const d of candidates) {
|
|
328
|
+
if (seen.has(d)) continue;
|
|
329
|
+
seen.add(d);
|
|
330
|
+
try {
|
|
331
|
+
if (fs.statSync(d).isDirectory()) after.push(d);
|
|
332
|
+
} catch {
|
|
333
|
+
/* not on this machine */
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
if (!before.length && !after.length) return own;
|
|
337
|
+
return [...before, ...(own ? [own] : []), ...after].join(path.delimiter);
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
// ── the version ─────────────────────────────────────────────────────────────
|
|
341
|
+
|
|
342
|
+
const VERSION_TIMEOUT_MS = 3000;
|
|
343
|
+
const versions = new Map<string, Promise<string | undefined>>();
|
|
344
|
+
|
|
345
|
+
/** The first version-shaped token of a CLI's `--version` output. */
|
|
346
|
+
export function parseVersion(out: string): string | undefined {
|
|
347
|
+
const m = /\bv?(\d+\.\d+(?:\.\d+)?(?:[-+][0-9A-Za-z.-]+)?)\b/.exec(out);
|
|
348
|
+
return m?.[1];
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/**
|
|
352
|
+
* Run `<bin> <versionArgs>` once (under a timeout; stdin closed, the env
|
|
353
|
+
* as given) and keep the answer, per path and file identity, so a page
|
|
354
|
+
* asking again runs nothing. Undefined when it fails or prints none.
|
|
355
|
+
*/
|
|
356
|
+
export function runtimeVersion(
|
|
357
|
+
bin: string,
|
|
358
|
+
args: readonly string[],
|
|
359
|
+
env: NodeJS.ProcessEnv,
|
|
360
|
+
timeoutMs: number = VERSION_TIMEOUT_MS,
|
|
361
|
+
): Promise<string | undefined> {
|
|
362
|
+
let key: string;
|
|
363
|
+
try {
|
|
364
|
+
const st = fs.statSync(bin);
|
|
365
|
+
key = `${bin}\0${st.ino}\0${st.size}\0${st.mtimeMs}\0${args.join(" ")}`;
|
|
366
|
+
} catch {
|
|
367
|
+
return Promise.resolve(undefined);
|
|
368
|
+
}
|
|
369
|
+
const known = versions.get(key);
|
|
370
|
+
if (known) return known;
|
|
371
|
+
const p = new Promise<string | undefined>((resolve) => {
|
|
372
|
+
let out = "";
|
|
373
|
+
let done = false;
|
|
374
|
+
const finish = (v: string | undefined) => {
|
|
375
|
+
if (done) return;
|
|
376
|
+
done = true;
|
|
377
|
+
clearTimeout(timer);
|
|
378
|
+
resolve(v);
|
|
379
|
+
};
|
|
380
|
+
let child: ReturnType<typeof spawn>;
|
|
381
|
+
try {
|
|
382
|
+
child = spawn(bin, [...args], {
|
|
383
|
+
env,
|
|
384
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
385
|
+
});
|
|
386
|
+
} catch {
|
|
387
|
+
resolve(undefined);
|
|
388
|
+
return;
|
|
389
|
+
}
|
|
390
|
+
const timer = setTimeout(() => {
|
|
391
|
+
try {
|
|
392
|
+
child.kill("SIGKILL");
|
|
393
|
+
} catch {
|
|
394
|
+
/* gone */
|
|
395
|
+
}
|
|
396
|
+
finish(undefined);
|
|
397
|
+
}, timeoutMs);
|
|
398
|
+
timer.unref?.();
|
|
399
|
+
const take = (d: Buffer) => {
|
|
400
|
+
if (out.length < 4096) out += d.toString("utf-8");
|
|
401
|
+
};
|
|
402
|
+
child.stdout?.on("data", take);
|
|
403
|
+
child.stderr?.on("data", take);
|
|
404
|
+
child.on("error", () => finish(undefined));
|
|
405
|
+
child.on("close", (code) =>
|
|
406
|
+
finish(code === 0 ? parseVersion(out) : undefined),
|
|
407
|
+
);
|
|
408
|
+
});
|
|
409
|
+
versions.set(key, p);
|
|
410
|
+
return p;
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
/** Forget every version asked (tests). */
|
|
414
|
+
export function clearVersionCache(): void {
|
|
415
|
+
versions.clear();
|
|
416
|
+
}
|
|
@@ -16,6 +16,7 @@ import {
|
|
|
16
16
|
type StrategyEntry,
|
|
17
17
|
} from "../strategies/registry.js";
|
|
18
18
|
import type { Persona } from "./personas.js";
|
|
19
|
+
import type { BinSource, RuntimeFind } from "./runtime-search.js";
|
|
19
20
|
|
|
20
21
|
export const RUNTIME_KIND = "runtime";
|
|
21
22
|
|
|
@@ -27,6 +28,20 @@ export interface RuntimeDetect {
|
|
|
27
28
|
version?: string;
|
|
28
29
|
/** Why it is unavailable. */
|
|
29
30
|
why?: string;
|
|
31
|
+
/** The CLI it runs, when found (AG-0.1). */
|
|
32
|
+
path?: string;
|
|
33
|
+
/** Where that path came from. */
|
|
34
|
+
source?: BinSource;
|
|
35
|
+
/** The directories searched, in order (names only, never values). */
|
|
36
|
+
searched?: string[];
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface DetectOptions {
|
|
40
|
+
/**
|
|
41
|
+
* `agents.runtimes.<id>.bin` as configured in user.yml (undefined:
|
|
42
|
+
* read it there; null: none).
|
|
43
|
+
*/
|
|
44
|
+
bin?: unknown;
|
|
30
45
|
}
|
|
31
46
|
|
|
32
47
|
/** The MCP server an agent acts through: Den's, scoped to its workspace. */
|
|
@@ -69,6 +84,8 @@ export interface LaunchInput {
|
|
|
69
84
|
* run that was never prompted starts afresh with the id it had.
|
|
70
85
|
*/
|
|
71
86
|
resumeToken?: string;
|
|
87
|
+
/** The CLI detect found (a setting, the login PATH, …); else searched again. */
|
|
88
|
+
bin?: string;
|
|
72
89
|
}
|
|
73
90
|
|
|
74
91
|
export interface LaunchOutput {
|
|
@@ -88,7 +105,15 @@ export type ResumeInput = LaunchInput;
|
|
|
88
105
|
export interface Runtime {
|
|
89
106
|
/** "den-claude", "generic". */
|
|
90
107
|
id: string;
|
|
91
|
-
|
|
108
|
+
/** Its name for people ("Claude Code"), in the Start agent form. */
|
|
109
|
+
label?: string;
|
|
110
|
+
/**
|
|
111
|
+
* How its CLI is found and its version asked (AG-0.1): the search's
|
|
112
|
+
* binary names and install directories, per runtime, so a catalog can
|
|
113
|
+
* supply them (AG-0.5). None: nothing to find (generic).
|
|
114
|
+
*/
|
|
115
|
+
find?: RuntimeFind;
|
|
116
|
+
detect(env?: NodeJS.ProcessEnv, opts?: DetectOptions): RuntimeDetect;
|
|
92
117
|
/** Throws LaunchError when this persona or this machine can't run it. */
|
|
93
118
|
launch(input: LaunchInput): LaunchOutput;
|
|
94
119
|
/** A new run that continues the last one; null when there is nothing to resume. */
|
|
@@ -4,7 +4,14 @@
|
|
|
4
4
|
*/
|
|
5
5
|
import { denClaude } from "./den-claude.js";
|
|
6
6
|
import { generic } from "./generic.js";
|
|
7
|
-
import {
|
|
7
|
+
import {
|
|
8
|
+
allScrubEnv,
|
|
9
|
+
getRuntime,
|
|
10
|
+
registerRuntime,
|
|
11
|
+
runtimeIds,
|
|
12
|
+
type Runtime,
|
|
13
|
+
} from "./runtime.js";
|
|
14
|
+
import { sessionPath, type RuntimeFind } from "./runtime-search.js";
|
|
8
15
|
import { ACTOR_TOKEN_ENV } from "../actor-token.js";
|
|
9
16
|
import { AGENT_ACTOR_ENV, buildSessionEnv } from "../sessions/env.js";
|
|
10
17
|
|
|
@@ -25,9 +32,27 @@ export {
|
|
|
25
32
|
type Runtime,
|
|
26
33
|
} from "./runtime.js";
|
|
27
34
|
|
|
35
|
+
/** Every registered runtime's find (its well-known directories). */
|
|
36
|
+
export function allRuntimeFinds(): (RuntimeFind | undefined)[] {
|
|
37
|
+
return runtimeIds().map((id) => getRuntime(id)?.find);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* The caller's env with the PATH a session gets (AG-0.1): the login
|
|
42
|
+
* shell's PATH, the caller's, then every runtime's install directories
|
|
43
|
+
* that exist, and `extraDirs` (a configured CLI's directory).
|
|
44
|
+
*/
|
|
45
|
+
export function withSessionPath(
|
|
46
|
+
caller: NodeJS.ProcessEnv,
|
|
47
|
+
extraDirs: readonly string[] = [],
|
|
48
|
+
): NodeJS.ProcessEnv {
|
|
49
|
+
const PATH = sessionPath(caller, allRuntimeFinds(), extraDirs);
|
|
50
|
+
return PATH === caller.PATH ? caller : { ...caller, PATH };
|
|
51
|
+
}
|
|
52
|
+
|
|
28
53
|
/**
|
|
29
54
|
* An agent's session env, as `agents start` builds it and `doctor --probe`
|
|
30
|
-
* checks it: the allowlist, the runtime's passthrough and launch env, Den's
|
|
55
|
+
* checks it: the allowlist (its PATH the search's, withSessionPath), the runtime's passthrough and launch env, Den's
|
|
31
56
|
* markers (DEN_AGENT=agent:<id>, and SEC-1c's DEN_ACTOR_TOKEN when one was
|
|
32
57
|
* minted), then every runtime's scrubEnv and any person credential.
|
|
33
58
|
*/
|
|
@@ -38,9 +63,11 @@ export function agentSessionEnv(
|
|
|
38
63
|
agentId: string,
|
|
39
64
|
/** SEC-1c: the agent's actor token (`agents start` mints it; the probe has none). */
|
|
40
65
|
actorToken?: string,
|
|
66
|
+
/** Added to its PATH: the directory of the CLI it runs. */
|
|
67
|
+
extraDirs: readonly string[] = [],
|
|
41
68
|
): Record<string, string> {
|
|
42
69
|
return buildSessionEnv({
|
|
43
|
-
caller,
|
|
70
|
+
caller: withSessionPath(caller, extraDirs),
|
|
44
71
|
passthrough: rt.envPassthrough,
|
|
45
72
|
launch,
|
|
46
73
|
// The seam for a persona's or runtime's declared env: none yet.
|
package/src/shared/den-config.ts
CHANGED
|
@@ -244,10 +244,16 @@ export interface DenConfig {
|
|
|
244
244
|
*/
|
|
245
245
|
mcp?: McpConfig;
|
|
246
246
|
/**
|
|
247
|
-
* Agents: `max_concurrent` caps the
|
|
248
|
-
* distribution (default 4). See
|
|
247
|
+
* Agents: `max_concurrent` caps the agents running at once in this
|
|
248
|
+
* distribution (default 4); plain sessions never count. See
|
|
249
|
+
* readAgentsConfig.
|
|
249
250
|
*/
|
|
250
251
|
agents?: AgentsConfig;
|
|
252
|
+
/**
|
|
253
|
+
* Plain sessions (`<cli> sessions start`): `max_concurrent` caps those
|
|
254
|
+
* running at once (default 8); agents never count. See readSessionsConfig.
|
|
255
|
+
*/
|
|
256
|
+
sessions?: SessionsConfig;
|
|
251
257
|
/**
|
|
252
258
|
* The web UI: `port` (read by edge/config.ts readWebPort) and `service`,
|
|
253
259
|
* the launchd service `<cli> service` installs. See readWebServiceConfig.
|
|
@@ -373,6 +379,13 @@ export interface UserConfig {
|
|
|
373
379
|
* src/shared/environment-manager/scope.ts.
|
|
374
380
|
*/
|
|
375
381
|
view?: { scope?: string };
|
|
382
|
+
/**
|
|
383
|
+
* Agents on this machine (AG-0.1): `runtimes.<id>.bin`, the absolute path
|
|
384
|
+
* of a runtime's CLI, which wins over the search (login shell PATH, PATH,
|
|
385
|
+
* install directories). Per machine, so user.yml only: the distribution's
|
|
386
|
+
* config never sets a path. See shared/agents/runtime-search.ts.
|
|
387
|
+
*/
|
|
388
|
+
agents?: { runtimes?: Record<string, { bin?: unknown } | null> };
|
|
376
389
|
}
|
|
377
390
|
|
|
378
391
|
const ALL_ENVIRONMENTS: Environment[] = [
|
|
@@ -394,6 +407,35 @@ const DEFAULT_CONFIG: DenConfig = {
|
|
|
394
407
|
environments: ALL_ENVIRONMENTS,
|
|
395
408
|
};
|
|
396
409
|
|
|
410
|
+
/** The per-engineer user config's path (config/local/user.yml), absolute. */
|
|
411
|
+
export function userConfigPath(): string {
|
|
412
|
+
return USER_CONFIG_PATH;
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
/**
|
|
416
|
+
* `agents.runtimes.<id>.bin` from user.yml, as written (checked where it is
|
|
417
|
+
* used: runtime-search.ts checkBinSetting; a block that is no mapping is
|
|
418
|
+
* `{ why }`); undefined when unset or when user.yml can't be read.
|
|
419
|
+
*/
|
|
420
|
+
export function configuredRuntimeBin(
|
|
421
|
+
id: string,
|
|
422
|
+
read: () => UserConfig = readUserConfig,
|
|
423
|
+
): unknown {
|
|
424
|
+
try {
|
|
425
|
+
const rts = read().agents?.runtimes;
|
|
426
|
+
if (!rts || typeof rts !== "object") return undefined;
|
|
427
|
+
const block = (rts as Record<string, unknown>)[id];
|
|
428
|
+
if (block === undefined || block === null) return undefined;
|
|
429
|
+
if (typeof block !== "object" || Array.isArray(block))
|
|
430
|
+
return {
|
|
431
|
+
why: `agents.runtimes.${id} must be a mapping ({ bin: <absolute path> })`,
|
|
432
|
+
};
|
|
433
|
+
return (block as { bin?: unknown }).bin;
|
|
434
|
+
} catch {
|
|
435
|
+
return undefined;
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
|
|
397
439
|
/** Read the per-engineer user config (config/local/user.yml). Returns {} if absent. */
|
|
398
440
|
export function readUserConfig(): UserConfig {
|
|
399
441
|
try {
|
|
@@ -558,6 +600,7 @@ export function readDenConfig(): DenConfig {
|
|
|
558
600
|
security: parsed?.security,
|
|
559
601
|
mcp: parsed?.mcp,
|
|
560
602
|
agents: parsed?.agents,
|
|
603
|
+
sessions: parsed?.sessions,
|
|
561
604
|
web: parsed?.web,
|
|
562
605
|
test_accounts: parsed?.test_accounts,
|
|
563
606
|
defaults: parsed?.defaults,
|
|
@@ -739,7 +782,7 @@ export function readMcpConfig(
|
|
|
739
782
|
|
|
740
783
|
/** `agents:` in the org config. */
|
|
741
784
|
export interface AgentsConfig {
|
|
742
|
-
/**
|
|
785
|
+
/** Agents that may run at once (default 4); plain sessions never count. */
|
|
743
786
|
max_concurrent?: number;
|
|
744
787
|
/**
|
|
745
788
|
* Persona packs (AG-2a): directories of persona files, relative to the
|
|
@@ -845,6 +888,48 @@ export function readAgentsConfig(
|
|
|
845
888
|
return out;
|
|
846
889
|
}
|
|
847
890
|
|
|
891
|
+
/** `sessions:` in the org config: plain sessions (`<cli> sessions start`). */
|
|
892
|
+
export interface SessionsConfig {
|
|
893
|
+
/** Plain sessions that may run at once (default 8); agents never count. */
|
|
894
|
+
max_concurrent?: number;
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
export const DEFAULT_SESSIONS_MAX_CONCURRENT = 8;
|
|
898
|
+
|
|
899
|
+
/** `sessions:` with its defaults applied. */
|
|
900
|
+
export interface ResolvedSessionsConfig {
|
|
901
|
+
maxConcurrent: number;
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
/**
|
|
905
|
+
* The effective sessions config. Anything malformed throws, naming the key,
|
|
906
|
+
* as readAgentsConfig does: a typo must not lift the cap.
|
|
907
|
+
*/
|
|
908
|
+
export function readSessionsConfig(
|
|
909
|
+
config: DenConfig = readDenConfig(),
|
|
910
|
+
): ResolvedSessionsConfig {
|
|
911
|
+
const raw = (config.sessions ?? {}) as unknown;
|
|
912
|
+
if (!isMapping(raw))
|
|
913
|
+
throw new Error(`sessions: must be a mapping (got ${JSON.stringify(raw)})`);
|
|
914
|
+
for (const k of Object.keys(raw))
|
|
915
|
+
if (k !== "max_concurrent")
|
|
916
|
+
throw new Error(
|
|
917
|
+
`sessions.${k}: unknown key (sessions: takes max_concurrent)`,
|
|
918
|
+
);
|
|
919
|
+
const out: ResolvedSessionsConfig = {
|
|
920
|
+
maxConcurrent: DEFAULT_SESSIONS_MAX_CONCURRENT,
|
|
921
|
+
};
|
|
922
|
+
const max = raw.max_concurrent;
|
|
923
|
+
if (max !== undefined) {
|
|
924
|
+
if (typeof max !== "number" || !Number.isInteger(max) || max < 1)
|
|
925
|
+
throw new Error(
|
|
926
|
+
`sessions.max_concurrent: must be a whole number of at least 1 (got ${JSON.stringify(max)})`,
|
|
927
|
+
);
|
|
928
|
+
out.maxConcurrent = max;
|
|
929
|
+
}
|
|
930
|
+
return out;
|
|
931
|
+
}
|
|
932
|
+
|
|
848
933
|
// ── Web UI service (`<cli> service`) ──────────────────────────────────────────
|
|
849
934
|
|
|
850
935
|
/** `web:` in the org config. */
|
|
@@ -70,6 +70,37 @@ export function routeUrl(
|
|
|
70
70
|
return `https://${routeHost(name, domain)}${p === 443 ? "" : `:${p}`}`;
|
|
71
71
|
}
|
|
72
72
|
|
|
73
|
+
/**
|
|
74
|
+
* The edge routes to a web server on one of `ports` (an upstream on the
|
|
75
|
+
* docker host or loopback), under every edge domain: `{ host, url }`, the
|
|
76
|
+
* hostname (lowercase) and the URL a browser uses there.
|
|
77
|
+
*/
|
|
78
|
+
export function webRouteHosts(
|
|
79
|
+
cfg: EdgeConfig,
|
|
80
|
+
ports: ReadonlySet<number>,
|
|
81
|
+
): { host: string; url: string }[] {
|
|
82
|
+
return Object.entries(cfg.routes)
|
|
83
|
+
.filter(([, upstream]) => {
|
|
84
|
+
try {
|
|
85
|
+
const u = new URL(upstream);
|
|
86
|
+
return (
|
|
87
|
+
ports.has(Number(u.port || (u.protocol === "https:" ? 443 : 80))) &&
|
|
88
|
+
["host.docker.internal", "localhost", "127.0.0.1"].includes(
|
|
89
|
+
u.hostname,
|
|
90
|
+
)
|
|
91
|
+
);
|
|
92
|
+
} catch {
|
|
93
|
+
return false;
|
|
94
|
+
}
|
|
95
|
+
})
|
|
96
|
+
.flatMap(([name]) =>
|
|
97
|
+
edgeDomains(cfg).map((d) => ({
|
|
98
|
+
host: routeHost(name, d).toLowerCase(),
|
|
99
|
+
url: routeUrl(name, cfg, d),
|
|
100
|
+
})),
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
|
|
73
104
|
/** Names that go on the org's certificate. */
|
|
74
105
|
export function certNames(cfg: EdgeConfig): string[] {
|
|
75
106
|
const zones = [...cfg.workspaces, ...(cfg.subzones ?? [])];
|