@monster-orange/den 0.4.2 → 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.
@@ -7,7 +7,10 @@
7
7
  * what happened, or throws an AgentError saying why it didn't.
8
8
  *
9
9
  * start resolves the persona (Den's default pack, then
10
- * agents.persona_packs) and the runtime, asks the runtime for argv,
10
+ * agents.persona_packs) and the runtime (its CLI from
11
+ * agents.runtimes.<id>.bin in user.yml, else the search:
12
+ * runtime-search.ts, AG-0.1; unavailable, it is refused with the
13
+ * fix), asks the runtime for argv,
11
14
  * env and files (its MCP config: `<cli> mcp --workspace <ws>
12
15
  * --actor agent:<id>`, plus a `--grant` per agents.<id>.grants),
13
16
  * starts `<dist>-<ws>:<id>` in the workspace's dir, and writes the
@@ -38,7 +41,9 @@ import * as fs from "fs";
38
41
  import * as path from "path";
39
42
  import {
40
43
  AGENTS_RESERVED_KEYS,
44
+ configuredRuntimeBin,
41
45
  readAgentsConfig,
46
+ userConfigPath,
42
47
  type ResolvedAgentsConfig,
43
48
  } from "../../shared/den-config.js";
44
49
  import { cliEntrypoint } from "../../shared/engine.js";
@@ -69,6 +74,12 @@ import {
69
74
  type LaunchInput,
70
75
  type Runtime,
71
76
  } from "../../shared/agents/runtimes.js";
77
+ import type { RuntimeDetect } from "../../shared/agents/runtime.js";
78
+ import {
79
+ binSettingKey,
80
+ runtimeVersion,
81
+ type BinSource,
82
+ } from "../../shared/agents/runtime-search.js";
72
83
  import { WORKSPACE_NAME_RE } from "../../shared/agent-actor.js";
73
84
  import {
74
85
  bindActorSession,
@@ -103,6 +114,10 @@ export interface AgentsDeps extends SessionsDeps {
103
114
  * exit (default 3000 ms; 0: not at all).
104
115
  */
105
116
  exitWatchMs?: number;
117
+ /** `agents.runtimes.<id>.bin` (default: user.yml's; undefined: unset). */
118
+ runtimeBin?: (id: string) => unknown;
119
+ /** The user.yml a person edits to set it (default: config/local/user.yml). */
120
+ userConfigFile?: () => string;
106
121
  }
107
122
 
108
123
  export interface AgentsResolved extends Resolved {
@@ -111,6 +126,8 @@ export interface AgentsResolved extends Resolved {
111
126
  workspaceDir: (ws: string) => string;
112
127
  mcpServer: () => { command: string[] | string; env: Record<string, string> };
113
128
  exitWatchMs: number;
129
+ runtimeBin: (id: string) => unknown;
130
+ userConfigFile: () => string;
114
131
  }
115
132
 
116
133
  /** The early-exit watch: at most this long, polling the host. */
@@ -199,6 +216,8 @@ export function resolveAgentsDeps(d: AgentsDeps): AgentsResolved {
199
216
  }),
200
217
  mcpServer: d.mcpServer ?? (() => defaultMcpServer(base.cli)),
201
218
  exitWatchMs: d.exitWatchMs ?? EXIT_WATCH_MS,
219
+ runtimeBin: d.runtimeBin ?? ((id) => configuredRuntimeBin(id)),
220
+ userConfigFile: d.userConfigFile ?? userConfigPath,
202
221
  };
203
222
  }
204
223
 
@@ -394,29 +413,84 @@ export function listPersonas(d: AgentsResolved): ListedPersona[] {
394
413
 
395
414
  export interface RuntimeChoice {
396
415
  id: string;
416
+ /** Its name for people ("Claude Code"). */
417
+ label?: string;
397
418
  available: boolean;
398
419
  /** Why it is unavailable. */
399
420
  why?: string;
400
421
  version?: string;
422
+ /** The CLI it runs (AG-0.2). */
423
+ path?: string;
424
+ /** Where that path came from: setting, login-shell, well-known or PATH. */
425
+ source?: BinSource;
426
+ /** The directories searched, in order (a runtime with a CLI to find). */
427
+ searched?: string[];
428
+ /** The user.yml key that sets its path (a runtime with a CLI to find). */
429
+ setting?: string;
401
430
  /** It declares resume. */
402
431
  resume: boolean;
403
432
  default: boolean;
404
433
  }
405
434
 
406
- /** Every registered runtime, and whether it can run here (detect runs nothing). */
407
- export function listRuntimes(d: AgentsResolved): RuntimeChoice[] {
408
- return runtimeIds().map((id) => {
409
- const rt = getRuntime(id)!;
410
- const det = rt.detect(d.env);
411
- return {
412
- id,
413
- available: det.available,
414
- ...(det.available ? {} : { why: det.why ?? "unavailable" }),
415
- ...(det.version ? { version: det.version } : {}),
416
- resume: Boolean(rt.resume),
417
- default: id === DEFAULT_RUNTIME_ID,
418
- };
419
- });
435
+ /**
436
+ * Whether runtime `rt` can run here (AG-0.1): its setting
437
+ * (`agents.runtimes.<id>.bin`, from d.runtimeBin), else the search. Runs
438
+ * nothing.
439
+ */
440
+ export function detectRuntime(d: AgentsResolved, rt: Runtime): RuntimeDetect {
441
+ return rt.detect(d.env, { bin: d.runtimeBin(rt.id) ?? null });
442
+ }
443
+
444
+ /**
445
+ * Every registered runtime, and whether it can run here, with the path it
446
+ * runs and its version: its version command (`claude --version`), run once
447
+ * under a timeout and kept, else what its install says.
448
+ */
449
+ export async function listRuntimes(
450
+ d: AgentsResolved,
451
+ ): Promise<RuntimeChoice[]> {
452
+ return Promise.all(
453
+ runtimeIds().map(async (id) => {
454
+ const rt = getRuntime(id)!;
455
+ const det = detectRuntime(d, rt);
456
+ const asked =
457
+ det.available && det.path && rt.find?.versionArgs
458
+ ? await runtimeVersion(det.path, rt.find.versionArgs, d.env)
459
+ : undefined;
460
+ const version = asked ?? det.version;
461
+ return {
462
+ id,
463
+ ...(rt.label ? { label: rt.label } : {}),
464
+ available: det.available,
465
+ ...(det.available ? {} : { why: det.why ?? "unavailable" }),
466
+ ...(version ? { version } : {}),
467
+ ...(det.path ? { path: det.path } : {}),
468
+ ...(det.source ? { source: det.source } : {}),
469
+ ...(rt.find
470
+ ? { searched: det.searched ?? [], setting: binSettingKey(id) }
471
+ : {}),
472
+ resume: Boolean(rt.resume),
473
+ default: id === DEFAULT_RUNTIME_ID,
474
+ };
475
+ }),
476
+ );
477
+ }
478
+
479
+ /** Refused unless `rt` can run here; its detect result when it can. */
480
+ function availableOrRefuse(d: AgentsResolved, rt: Runtime): RuntimeDetect {
481
+ const det = detectRuntime(d, rt);
482
+ if (!det.available)
483
+ throw refused(
484
+ `${rt.id} is unavailable: ${det.why ?? "unknown"}`,
485
+ rt.find
486
+ ? [
487
+ `Install it, or set ${binSettingKey(rt.id)} to its absolute path in ${d.userConfigFile()}.`,
488
+ `\`${d.cli} agents doctor\` shows where it looked.`,
489
+ ]
490
+ : [],
491
+ 503,
492
+ );
493
+ return det;
420
494
  }
421
495
 
422
496
  function runtimeFor(id: string): Runtime {
@@ -460,6 +534,15 @@ interface Launch {
460
534
  passthrough?: readonly string[];
461
535
  /** agents.<id>.grants, recorded with its actor token. */
462
536
  grants: readonly string[];
537
+ /** Added to the session's PATH: a configured CLI's own directory. */
538
+ binDir?: string;
539
+ }
540
+
541
+ /** A configured CLI's directory, for its session's PATH (its own tools). */
542
+ function binDirOf(det: RuntimeDetect): { binDir?: string } {
543
+ return det.source === "setting" && det.path
544
+ ? { binDir: path.dirname(det.path) }
545
+ : {};
463
546
  }
464
547
 
465
548
  /** The host's cap refusal as an AgentError (its hint says what to do). */
@@ -511,6 +594,7 @@ async function startSession(
511
594
  launch.env,
512
595
  launch.rec.id,
513
596
  minted.token,
597
+ launch.binDir ? [launch.binDir] : [],
514
598
  ),
515
599
  cols: (tty && d.stdout.columns) || 80,
516
600
  rows: (tty && d.stdout.rows) || 24,
@@ -601,6 +685,7 @@ function launchInput(
601
685
  ws: string,
602
686
  id: string,
603
687
  model: string | undefined,
688
+ bin?: string,
604
689
  ): LaunchInput {
605
690
  const grants = cfg.agents[id]?.grants ?? [];
606
691
  for (const g of grants)
@@ -623,6 +708,7 @@ function launchInput(
623
708
  filesDir: agentFilesDir(d.stateDir(), id),
624
709
  stateDir: d.stateDir(),
625
710
  env: d.env,
711
+ ...(bin ? { bin } : {}),
626
712
  };
627
713
  }
628
714
 
@@ -681,9 +767,7 @@ export async function startAgent(
681
767
  const id = req.id ?? defaultId(d.stateDir(), persona.id, ws);
682
768
  checkId(where, id);
683
769
  const rt = runtimeFor(req.runtime ?? persona.runtime ?? DEFAULT_RUNTIME_ID);
684
- const det = rt.detect(d.env);
685
- if (!det.available)
686
- throw refused(`${rt.id} is unavailable: ${det.why ?? "unknown"}`, [], 503);
770
+ const det = availableOrRefuse(d, rt);
687
771
  const model = req.model ?? persona.model;
688
772
 
689
773
  const prev = readRecord(d.stateDir(), id);
@@ -692,7 +776,7 @@ export async function startAgent(
692
776
  `agent ${id} belongs to workspace ${prev.workspace}; give this one another --id`,
693
777
  );
694
778
  ptyOrRefuse(d);
695
- const input = launchInput(d, cfg, persona, ws, id, model);
779
+ const input = launchInput(d, cfg, persona, ws, id, model, det.path);
696
780
 
697
781
  return withClient(d, async (c) => {
698
782
  const sessions = await c.list();
@@ -721,6 +805,7 @@ export async function startAgent(
721
805
  env: out.env,
722
806
  passthrough: rt.envPassthrough,
723
807
  grants: cfg.agents[id]?.grants ?? [],
808
+ ...binDirOf(det),
724
809
  },
725
810
  old,
726
811
  {},
@@ -795,6 +880,7 @@ export async function resumeAgent(
795
880
  const persona = personas(d, cfg).get(rec.persona);
796
881
  if (!persona)
797
882
  throw refused(`agent ${rec.id}'s persona "${rec.persona}" is gone`);
883
+ const det = availableOrRefuse(d, rt);
798
884
  ptyOrRefuse(d);
799
885
  return withClient(d, async (c) => {
800
886
  const old = sessionOf(await c.list(), rec);
@@ -809,6 +895,7 @@ export async function resumeAgent(
809
895
  rec.workspace,
810
896
  rec.id,
811
897
  rec.model,
898
+ det.path,
812
899
  );
813
900
  // Resume in the cwd the agent had, wherever the workspace points now.
814
901
  const at = {
@@ -833,6 +920,7 @@ export async function resumeAgent(
833
920
  env: out.env,
834
921
  passthrough: rt.envPassthrough,
835
922
  grants: cfg.agents[rec.id]?.grants ?? [],
923
+ ...binDirOf(det),
836
924
  },
837
925
  old,
838
926
  fresh ? {} : { resumedAt: new Date().toISOString() },
@@ -24,16 +24,25 @@
24
24
  * running: a new session, in the same cwd, continuing the last run;
25
25
  * when that session was never prompted, a fresh start under the
26
26
  * same conversation id (there is nothing to continue).
27
+ * doctor (AG-0.3) each runtime: its status, the CLI it runs and where
28
+ * that came from (setting, login-shell, PATH, well-known), its
29
+ * version (its version command, once, under a timeout), the
30
+ * directories searched (names only, never an env value), and the
31
+ * fix when it is missing. Exit 1 when the default runtime is
32
+ * unavailable. It resolves the login shell's PATH first, as the
33
+ * web server does at start.
27
34
  *
28
35
  * Nothing here takes a grant: grants come from the org config only. The
29
36
  * host enforces agents.max_concurrent on every start, counting agents only.
30
37
  */
31
38
  import { DEFAULT_RUNTIME_ID } from "../../shared/agents/runtimes.js";
39
+ import { initLoginPath } from "../../shared/agents/runtime-search.js";
32
40
  import { HostError } from "../../shared/sessions/client.js";
33
41
  import {
34
42
  AgentError,
35
43
  listAgents,
36
44
  listPersonas as personaList,
45
+ listRuntimes,
37
46
  loadAgentWorkspaces,
38
47
  resolveAgentsDeps,
39
48
  resumeAgent,
@@ -42,6 +51,7 @@ import {
42
51
  type AgentsDeps,
43
52
  type AgentsResolved,
44
53
  type EarlyExit,
54
+ type RuntimeChoice,
45
55
  type Started,
46
56
  } from "./agent-ops.js";
47
57
  import {
@@ -63,7 +73,7 @@ export {
63
73
  } from "./agent-ops.js";
64
74
 
65
75
  export const AGENTS_HELP = {
66
- usage: "agents <list|start|stop|resume|personas> ...",
76
+ usage: "agents <list|start|stop|resume|personas|doctor> ...",
67
77
  lines: [
68
78
  "Agents: a persona and a workspace, run by a runtime in a session that",
69
79
  "outlives the web server (see `{cli} sessions`).",
@@ -78,9 +88,16 @@ export const AGENTS_HELP = {
78
88
  " a session never prompted starts fresh under the same conversation id",
79
89
  "{cli} agents personas [--json]",
80
90
  " Den's default pack, then agents.persona_packs (a later pack wins by id)",
91
+ "{cli} agents doctor [--json]",
92
+ " each runtime: status, the CLI it runs and where it came from, its version,",
93
+ " the directories searched, and the fix; exit 1 when the default is missing",
81
94
  "",
82
- "Runtimes: den-claude (default; needs claude on PATH) and generic (the",
95
+ "Runtimes: den-claude (default; Claude Code's claude) and generic (the",
83
96
  "persona's argv template). Attach with `{cli} sessions attach <ws>:<id>`.",
97
+ "A runtime's CLI is found as your terminal finds it: agents.runtimes.<id>.bin",
98
+ "in config/local/user.yml (an absolute path) wins, then the login shell's PATH,",
99
+ "this PATH, and its install directories (~/.local/bin, ~/.claude/local,",
100
+ "/opt/homebrew/bin, /usr/local/bin, npm's global bin).",
84
101
  "An agent's env is PATH, HOME, USER, LOGNAME, SHELL, TERM, COLORTERM, LANG,",
85
102
  "LC_*, TZ and TMPDIR, plus what its runtime declares: nothing else of yours.",
86
103
  ],
@@ -250,10 +267,87 @@ async function listPersonas(
250
267
  return 0;
251
268
  }
252
269
 
270
+ export interface AgentsDoctorDeps {
271
+ /** Resolve the login shell's PATH first (default: initLoginPath). */
272
+ loginPath?: () => Promise<void>;
273
+ }
274
+
275
+ const SOURCE_WORD: Record<string, string> = {
276
+ setting: "agents.runtimes setting",
277
+ "login-shell": "login shell PATH",
278
+ PATH: "PATH",
279
+ "well-known": "install directory",
280
+ };
281
+
282
+ /** `agents doctor`'s lines for one runtime (names and paths, never env values). */
283
+ export function doctorLines(
284
+ r: RuntimeChoice,
285
+ cli: string,
286
+ userConfig: string,
287
+ ): string[] {
288
+ const lines = [
289
+ `${r.id}${r.label ? ` (${r.label})` : ""}${r.default ? " [default]" : ""}`,
290
+ r.available
291
+ ? " status ✓ available"
292
+ : ` status ✗ unavailable: ${r.why ?? "unknown"}`,
293
+ ];
294
+ if (r.path)
295
+ lines.push(
296
+ ` path ${r.path}${r.source ? ` (${SOURCE_WORD[r.source] ?? r.source})` : ""}`,
297
+ );
298
+ if (r.available && r.setting)
299
+ lines.push(` version ${r.version ?? "unknown"}`);
300
+ if (!r.setting) lines.push(" path none to find (the persona's argv)");
301
+ else if (r.searched?.length)
302
+ r.searched.forEach((dir, i) =>
303
+ lines.push(` ${i === 0 ? "searched " : " "}${dir}`),
304
+ );
305
+ else if (r.source !== "setting") lines.push(" searched (no directories)");
306
+ if (!r.available && r.setting)
307
+ lines.push(
308
+ ` fix install ${r.label ?? r.id}, or set ${r.setting} to its absolute path in ${userConfig}`,
309
+ ` then run \`${cli} agents doctor\` again`,
310
+ );
311
+ return lines;
312
+ }
313
+
314
+ async function doctor(
315
+ args: string[],
316
+ d: AgentsResolved,
317
+ deps: AgentsDoctorDeps,
318
+ ): Promise<number> {
319
+ const where = "agents doctor";
320
+ const p = parseArgs(where, args, { "--json": false });
321
+ noExtra(where, p);
322
+ await (deps.loginPath ?? (() => initLoginPath({ env: d.env })))();
323
+ const rts = await listRuntimes(d);
324
+ const def = rts.find((r) => r.default);
325
+ const code = def?.available ? 0 : 1;
326
+ if (p.flags["--json"]) {
327
+ d.out(
328
+ JSON.stringify(
329
+ { runtimes: rts, userConfig: d.userConfigFile() },
330
+ null,
331
+ 2,
332
+ ),
333
+ );
334
+ return code;
335
+ }
336
+ rts.forEach((r, i) => {
337
+ if (i) d.out("");
338
+ for (const l of doctorLines(r, d.cli, d.userConfigFile())) d.out(l);
339
+ });
340
+ d.out("");
341
+ d.out(
342
+ `\`${d.cli} doctor --probe\` checks the env each runtime's session gets.`,
343
+ );
344
+ return code;
345
+ }
346
+
253
347
  /** `<cli> agents ...`; returns the exit code. */
254
348
  export async function cmdAgents(
255
349
  args: string[],
256
- deps: AgentsDeps = {},
350
+ deps: AgentsDeps & AgentsDoctorDeps = {},
257
351
  ): Promise<number> {
258
352
  const d = resolveAgentsDeps(deps);
259
353
  const sub =
@@ -274,6 +368,8 @@ export async function cmdAgents(
274
368
  return await resume(rest, d);
275
369
  case "personas":
276
370
  return await listPersonas(rest, d);
371
+ case "doctor":
372
+ return await doctor(rest, d, deps);
277
373
  default:
278
374
  throw new UsageError(`unknown agents command "${sub}"`);
279
375
  }
@@ -27,7 +27,7 @@ import { cliEntrypoint } from "../../shared/engine.js";
27
27
  import { getDistRoot, getInvokeCwd, getStateDir } from "../../shared/paths.js";
28
28
  import { product } from "../../shared/product.js";
29
29
  import { ENV_NAME_RE, isCredentialShaped } from "../../shared/secret-env.js";
30
- import { allScrubEnv } from "../../shared/agents/runtimes.js";
30
+ import { allScrubEnv, withSessionPath } from "../../shared/agents/runtimes.js";
31
31
  import {
32
32
  ensureHost,
33
33
  connectHost,
@@ -320,14 +320,19 @@ export function contractInfo(s: SessionInfo): SessionInfo {
320
320
  // ── subcommands ───────────────────────────────────────────────────────────────
321
321
 
322
322
  /**
323
- * A plain session's env (AG spec §5a-env): the allowlist, the names the
324
- * user kept, then every runtime's scrubEnv. `doctor --probe` checks it.
323
+ * A plain session's env (AG spec §5a-env): the allowlist (its PATH with
324
+ * the runtime search's directories, AG-0.1), the names the user kept, then
325
+ * every runtime's scrubEnv. `doctor --probe` checks it.
325
326
  */
326
327
  export function plainSessionEnv(
327
328
  caller: NodeJS.ProcessEnv,
328
329
  keep: readonly string[] = [],
329
330
  ): Record<string, string> {
330
- return buildSessionEnv({ caller, keep, scrub: allScrubEnv() });
331
+ return buildSessionEnv({
332
+ caller: withSessionPath(caller),
333
+ keep,
334
+ scrub: allScrubEnv(),
335
+ });
331
336
  }
332
337
 
333
338
  /** Who ran the CLI, for an audit line: a login name, printable. */
@@ -33,7 +33,8 @@ import {
33
33
  } from "../../shared/web-session.js";
34
34
  import { revokeAllActorTokens } from "../../shared/actor-token.js";
35
35
  import { engineBin, engineModuleFile } from "../../shared/engine.js";
36
- import { readWebPort } from "../../shared/edge/config.js";
36
+ import { readEdgeConfig, readWebPort } from "../../shared/edge/config.js";
37
+ import { webRouteHosts } from "../../shared/edge/render.js";
37
38
  import {
38
39
  ownServer,
39
40
  processCwd,
@@ -1081,7 +1082,7 @@ export function displayWebHelp(): void {
1081
1082
  console.log(` ${product().cli} web --setup-browser-access`);
1082
1083
  console.log(` ${product().cli} web token --print`);
1083
1084
  console.log(
1084
- ` ${product().cli} web login [--next <path>] [--origin <url>] [--port <port>]`,
1085
+ ` ${product().cli} web login [--next <path>] [--host <name> | --origin <url>] [--port <port>]`,
1085
1086
  );
1086
1087
  console.log(` ${product().cli} web sessions [--json] [--port <port>]`);
1087
1088
  console.log(
@@ -1392,20 +1393,24 @@ function takePort(args: string[]): { port: number; rest: string[] } {
1392
1393
  }
1393
1394
 
1394
1395
  /**
1395
- * `<cli> web login [--next <path>] [--origin <url>]`: a fresh sign-in link
1396
+ * `<cli> web login [--next <path>] [--host <name> | --origin <url>]`: a fresh sign-in link
1396
1397
  * on stdout, alone (the only place a login code is ever printed); a note on
1397
- * stderr. `--origin` for the edge hostname or a LAN name, where the cookie
1398
- * must be set (the signed-out page there says which).
1398
+ * stderr. `--host` for an edge hostname (its URL from the edge config),
1399
+ * `--origin` for any allowed origin (a LAN name), where the cookie must be
1400
+ * set (the signed-out page there says which).
1399
1401
  */
1400
1402
  export function cmdWebLogin(args: string[], io: CliIo = stdIo): number {
1401
- const usage = `Usage: ${product().cli} web login [--next <path>] [--origin <url>] [--port <port>]\n`;
1403
+ const usage = `Usage: ${product().cli} web login [--next <path>] [--host <name> | --origin <url>] [--port <port>]\n`;
1402
1404
  if (isHelp(args)) {
1403
1405
  io.out(
1404
1406
  usage +
1405
1407
  " Prints a one-time sign-in link (it works once, within 60 s) for one browser.\n" +
1406
1408
  " On localhost nothing asks for a sign-in; it is needed when this server is\n" +
1407
- " reached beyond this machine (the edge, or a LAN address: pass its --origin).\n" +
1409
+ " reached from another machine (pass the edge hostname as --host, or a LAN\n" +
1410
+ " address as --origin).\n" +
1408
1411
  " --next <path> where the browser lands after signing in (same origin only)\n" +
1412
+ " --host <name> an edge hostname that routes here (an edge domain or alias,\n" +
1413
+ " a VPN name among them): the link uses its edge URL\n" +
1409
1414
  " --origin <url> the address the browser uses, http(s)://host[:port]\n",
1410
1415
  );
1411
1416
  return 0;
@@ -1430,8 +1435,16 @@ export function cmdWebLogin(args: string[], io: CliIo = stdIo): number {
1430
1435
  : undefined;
1431
1436
  const n = val("--next");
1432
1437
  const o = val("--origin");
1438
+ const h = val("--host");
1433
1439
  if (n !== undefined) next = n;
1434
- else if (o !== undefined) {
1440
+ else if (h !== undefined) {
1441
+ const found = edgeOriginFor(h, port);
1442
+ if (typeof found !== "string") {
1443
+ io.err(found.error);
1444
+ return 2;
1445
+ }
1446
+ origin = found;
1447
+ } else if (o !== undefined) {
1435
1448
  origin = parseOrigin(o);
1436
1449
  if (!origin) {
1437
1450
  io.err(
@@ -1457,6 +1470,38 @@ export function cmdWebLogin(args: string[], io: CliIo = stdIo): number {
1457
1470
  return 0;
1458
1471
  }
1459
1472
 
1473
+ /**
1474
+ * `web login --host <name>`: the origin a browser uses for an edge hostname
1475
+ * that routes to this server (edge.routes under any edge domain or alias),
1476
+ * or why there is none and what to add.
1477
+ */
1478
+ export function edgeOriginFor(
1479
+ name: string,
1480
+ port: number,
1481
+ ): string | { error: string } {
1482
+ const want = name.trim().toLowerCase().replace(/\.$/, "");
1483
+ let routes: { host: string; url: string }[] = [];
1484
+ try {
1485
+ routes = webRouteHosts(readEdgeConfig(), new Set([port, readWebPort()]));
1486
+ } catch {
1487
+ routes = [];
1488
+ }
1489
+ const hit = routes.find((r) => r.host === want);
1490
+ if (hit) return hit.url;
1491
+ const known = routes.map((r) => r.host);
1492
+ return {
1493
+ error:
1494
+ `--host ${name} is not a hostname this server answers to` +
1495
+ (known.length
1496
+ ? ` (its edge hostnames: ${known.join(", ")}).\n`
1497
+ : " (no edge route reaches this server).\n") +
1498
+ " Add it to the config's edge block: its domain to edge.aliases (a VPN\n" +
1499
+ " name), or a route to this server in edge.routes, then `edge up`. For a\n" +
1500
+ " LAN name (DEN_BIND_HOST and web.allow_plain_http_remote) pass\n" +
1501
+ " --origin http://<name>:<port> instead.\n",
1502
+ };
1503
+ }
1504
+
1460
1505
  interface SessionRow {
1461
1506
  id: string;
1462
1507
  createdAt: string;
@@ -1,7 +1,9 @@
1
1
  /**
2
2
  * The Workspaces page's Agents section (AG-4a, AG spec §5c), its routes:
3
3
  *
4
- * GET /api/personas personas and runtimes
4
+ * GET /api/personas personas and runtimes (each
5
+ * with its path, where it came
6
+ * from and its version: AG-0.2)
5
7
  * GET /api/workspaces/<ws>/agents the workspace's agents
6
8
  * POST /api/workspaces/<ws>/agents start { persona, runtime?, model?, id? }
7
9
  * POST /api/workspaces/<ws>/agents/<id>/stop
@@ -204,7 +206,12 @@ export async function handleAgentRoutes(
204
206
  if (!m) {
205
207
  if (method !== "GET") return methodNotAllowed(res, "GET");
206
208
  try {
207
- sendJson(res, { personas: listPersonas(d), runtimes: listRuntimes(d) });
209
+ sendJson(res, {
210
+ personas: listPersonas(d),
211
+ runtimes: await listRuntimes(d),
212
+ // AG-0.2: the file a person edits to set a runtime's path.
213
+ userConfig: d.userConfigFile(),
214
+ });
208
215
  } catch (e) {
209
216
  sendError(res, e);
210
217
  }