@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
@@ -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,9 +41,23 @@ import * as fs from "fs";
38
41
  import * as path from "path";
39
42
  import {
40
43
  AGENTS_RESERVED_KEYS,
44
+ configuredRuntimeBin,
45
+ configuredRuntimeCatalog,
41
46
  readAgentsConfig,
47
+ setUserRuntimeBin,
48
+ userConfigPath,
42
49
  type ResolvedAgentsConfig,
43
50
  } from "../../shared/den-config.js";
51
+ import {
52
+ setWorkspaceRuntimeBin,
53
+ workspaceBinKey,
54
+ workspaceRuntimeBin,
55
+ } from "../../shared/environment-manager/workspaces.js";
56
+ import {
57
+ BUILTIN_CATALOG,
58
+ type CatalogEntry,
59
+ } from "../../shared/agents/catalog.js";
60
+ import { checkRuntimeBin } from "../../shared/agents/catalog-runtime.js";
44
61
  import { cliEntrypoint } from "../../shared/engine.js";
45
62
  import { getDistRoot } from "../../shared/paths.js";
46
63
  import { envKey } from "../../shared/product.js";
@@ -65,10 +82,20 @@ import {
65
82
  getRuntime,
66
83
  LaunchError,
67
84
  runtimeIds,
85
+ syncConfiguredRuntimes,
68
86
  type AgentMcp,
69
87
  type LaunchInput,
70
88
  type Runtime,
71
89
  } from "../../shared/agents/runtimes.js";
90
+ import type { RuntimeDetect } from "../../shared/agents/runtime.js";
91
+ import {
92
+ binSettingKey,
93
+ ensureInteractivePath,
94
+ interactivePathAsked,
95
+ runtimeVersion,
96
+ type BinSetting,
97
+ type BinSource,
98
+ } from "../../shared/agents/runtime-search.js";
72
99
  import { WORKSPACE_NAME_RE } from "../../shared/agent-actor.js";
73
100
  import {
74
101
  bindActorSession,
@@ -103,6 +130,24 @@ export interface AgentsDeps extends SessionsDeps {
103
130
  * exit (default 3000 ms; 0: not at all).
104
131
  */
105
132
  exitWatchMs?: number;
133
+ /** `agents.runtimes.<id>.bin` (default: user.yml's; undefined: unset). */
134
+ runtimeBin?: (id: string) => unknown;
135
+ /**
136
+ * Workspace `ws`'s `agents.runtimes.<id>.bin` (default: workspaces.yml's;
137
+ * undefined: unset). It wins over runtimeBin (AG-0.5).
138
+ */
139
+ workspaceRuntimeBin?: (ws: string, id: string) => unknown;
140
+ /** user.yml's runtime catalog entries, checked (default: user.yml's). */
141
+ userCatalog?: (taken: readonly string[]) => CatalogEntry[];
142
+ /** Save a checked path (null clears it): global or the workspace's. */
143
+ saveRuntimeBin?: (
144
+ scope: "global" | "workspace",
145
+ ws: string | undefined,
146
+ id: string,
147
+ bin: string | null,
148
+ ) => void;
149
+ /** The user.yml a person edits to set it (default: config/local/user.yml). */
150
+ userConfigFile?: () => string;
106
151
  }
107
152
 
108
153
  export interface AgentsResolved extends Resolved {
@@ -111,6 +156,11 @@ export interface AgentsResolved extends Resolved {
111
156
  workspaceDir: (ws: string) => string;
112
157
  mcpServer: () => { command: string[] | string; env: Record<string, string> };
113
158
  exitWatchMs: number;
159
+ runtimeBin: (id: string) => unknown;
160
+ workspaceRuntimeBin: (ws: string, id: string) => unknown;
161
+ userCatalog: (taken: readonly string[]) => CatalogEntry[];
162
+ saveRuntimeBin: NonNullable<AgentsDeps["saveRuntimeBin"]>;
163
+ userConfigFile: () => string;
114
164
  }
115
165
 
116
166
  /** The early-exit watch: at most this long, polling the host. */
@@ -199,6 +249,17 @@ export function resolveAgentsDeps(d: AgentsDeps): AgentsResolved {
199
249
  }),
200
250
  mcpServer: d.mcpServer ?? (() => defaultMcpServer(base.cli)),
201
251
  exitWatchMs: d.exitWatchMs ?? EXIT_WATCH_MS,
252
+ runtimeBin: d.runtimeBin ?? ((id) => configuredRuntimeBin(id)),
253
+ workspaceRuntimeBin:
254
+ d.workspaceRuntimeBin ?? ((ws, id) => workspaceRuntimeBin(ws, id)),
255
+ userCatalog: d.userCatalog ?? ((taken) => configuredRuntimeCatalog(taken)),
256
+ saveRuntimeBin:
257
+ d.saveRuntimeBin ??
258
+ ((scope, ws, id, bin) =>
259
+ scope === "workspace" && ws
260
+ ? setWorkspaceRuntimeBin(ws, id, bin)
261
+ : setUserRuntimeBin(id, bin)),
262
+ userConfigFile: d.userConfigFile ?? userConfigPath,
202
263
  };
203
264
  }
204
265
 
@@ -394,29 +455,225 @@ export function listPersonas(d: AgentsResolved): ListedPersona[] {
394
455
 
395
456
  export interface RuntimeChoice {
396
457
  id: string;
458
+ /** Its name for people ("Claude Code"). */
459
+ label?: string;
460
+ /** Who makes it ("Anthropic"): a catalog runtime (AG-0.5). */
461
+ vendor?: string;
462
+ /**
463
+ * false: it runs as its own CLI, without Den's tools yet ("Den tools:
464
+ * coming"). Absent for a runtime that isn't from the catalog.
465
+ */
466
+ denTools?: boolean;
397
467
  available: boolean;
398
468
  /** Why it is unavailable. */
399
469
  why?: string;
400
470
  version?: string;
471
+ /** The CLI it runs (AG-0.2). */
472
+ path?: string;
473
+ /**
474
+ * Where that path came from: workspace (its workspaces.yml entry),
475
+ * setting (user.yml), login-shell, PATH, well-known, interactive-shell.
476
+ */
477
+ source?: BinSource;
478
+ /** The directories searched, in order (a runtime with a CLI to find). */
479
+ searched?: string[];
480
+ /** The user.yml key that sets its path (a runtime with a CLI to find). */
481
+ setting?: string;
482
+ /** The workspace's key for it, when listed for a workspace. */
483
+ workspaceSetting?: string;
401
484
  /** It declares resume. */
402
485
  resume: boolean;
403
486
  default: boolean;
404
487
  }
405
488
 
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
- });
489
+ /**
490
+ * The path set for runtime `id` (AG-0.5): the workspace's (when `ws` is
491
+ * given and it sets one), else user.yml's; null when neither does.
492
+ */
493
+ export function runtimeSetting(
494
+ d: AgentsResolved,
495
+ id: string,
496
+ ws?: string,
497
+ ): BinSetting | null {
498
+ if (ws) {
499
+ const v = d.workspaceRuntimeBin(ws, id);
500
+ if (v !== undefined && v !== null)
501
+ return { value: v, source: "workspace", key: workspaceBinKey(ws, id) };
502
+ }
503
+ const g = d.runtimeBin(id);
504
+ return g === undefined || g === null ? null : { value: g, source: "setting" };
505
+ }
506
+
507
+ /**
508
+ * Whether runtime `rt` can run here (AG-0.1, AG-0.5): a path set for the
509
+ * workspace, else user.yml's (`agents.runtimes.<id>.bin`), else the
510
+ * search. Runs nothing.
511
+ */
512
+ export function detectRuntime(
513
+ d: AgentsResolved,
514
+ rt: Runtime,
515
+ ws?: string,
516
+ ): RuntimeDetect {
517
+ return rt.detect(d.env, { bin: runtimeSetting(d, rt.id, ws) });
518
+ }
519
+
520
+ /**
521
+ * The catalog entries the distribution and user.yml add, registered as
522
+ * runtimes (AG-0.5). A malformed entry is a refusal naming its key.
523
+ */
524
+ export function syncCatalog(d: AgentsResolved): void {
525
+ const cfg = agentsConfig(d);
526
+ let extra: CatalogEntry[];
527
+ try {
528
+ extra = [
529
+ ...cfg.runtimeCatalog,
530
+ ...d.userCatalog([
531
+ ...BUILTIN_CATALOG.map((e) => e.id),
532
+ ...cfg.runtimeCatalog.map((e) => e.id),
533
+ ]),
534
+ ];
535
+ } catch (e) {
536
+ throw refused((e as Error).message, [], 422);
537
+ }
538
+ syncConfiguredRuntimes(extra);
539
+ }
540
+
541
+ /**
542
+ * When a catalog runtime is still missing (nothing set, not found on the
543
+ * login PATH, PATH or its install directories), ask the interactive shell
544
+ * for its PATH: once per process, then kept (AG-0.5).
545
+ */
546
+ async function fallbackSearch(
547
+ d: AgentsResolved,
548
+ rts: Runtime[],
549
+ ws?: string,
550
+ ): Promise<void> {
551
+ if (interactivePathAsked()) return;
552
+ const missing = rts.some(
553
+ (rt) =>
554
+ rt.catalog &&
555
+ !runtimeSetting(d, rt.id, ws) &&
556
+ !detectRuntime(d, rt, ws).available,
557
+ );
558
+ if (missing) await ensureInteractivePath({ env: d.env });
559
+ }
560
+
561
+ /**
562
+ * The catalog's installed runtimes first, then the ones not found, then
563
+ * the rest (generic: no CLI of its own to find).
564
+ */
565
+ function rank(r: RuntimeChoice): number {
566
+ if (r.denTools === undefined) return 2;
567
+ return r.available ? 0 : 1;
568
+ }
569
+
570
+ /**
571
+ * Every registered runtime, and whether it can run here (for an agent of
572
+ * `ws`, when given: its workspace's path wins), with the path it runs and
573
+ * its version: its version command (`claude --version`), run once under a
574
+ * timeout and kept, else what its install says. Installed ones first.
575
+ */
576
+ export async function listRuntimes(
577
+ d: AgentsResolved,
578
+ ws?: string,
579
+ ): Promise<RuntimeChoice[]> {
580
+ syncCatalog(d);
581
+ const ids = runtimeIds();
582
+ await fallbackSearch(
583
+ d,
584
+ ids.map((id) => getRuntime(id)!),
585
+ ws,
586
+ );
587
+ const rows = await Promise.all(
588
+ ids.map(async (id) => {
589
+ const rt = getRuntime(id)!;
590
+ const det = detectRuntime(d, rt, ws);
591
+ const asked =
592
+ det.available && det.path && rt.find?.versionArgs
593
+ ? await runtimeVersion(det.path, rt.find.versionArgs, d.env)
594
+ : undefined;
595
+ const version = asked ?? det.version;
596
+ return {
597
+ id,
598
+ ...(rt.label ? { label: rt.label } : {}),
599
+ ...(rt.vendor ? { vendor: rt.vendor } : {}),
600
+ ...(rt.catalog ? { denTools: rt.denTools === true } : {}),
601
+ available: det.available,
602
+ ...(det.available ? {} : { why: det.why ?? "unavailable" }),
603
+ ...(version ? { version } : {}),
604
+ ...(det.path ? { path: det.path } : {}),
605
+ ...(det.source ? { source: det.source } : {}),
606
+ ...(rt.find
607
+ ? {
608
+ searched: det.searched ?? [],
609
+ setting: binSettingKey(id),
610
+ ...(ws ? { workspaceSetting: workspaceBinKey(ws, id) } : {}),
611
+ }
612
+ : {}),
613
+ resume: Boolean(rt.resume),
614
+ default: id === DEFAULT_RUNTIME_ID,
615
+ };
616
+ }),
617
+ );
618
+ return rows
619
+ .map((r, i) => ({ r, i }))
620
+ .sort((a, b) => rank(a.r) - rank(b.r) || a.i - b.i)
621
+ .map((x) => x.r);
622
+ }
623
+
624
+ /**
625
+ * Set (or, with null, clear) runtime `id`'s path, global or for workspace
626
+ * `ws` (AG-0.5): its version command runs first, and a path that isn't
627
+ * that runtime is refused. Returns its row as listed after.
628
+ */
629
+ export async function setRuntimePath(
630
+ d: AgentsResolved,
631
+ req: {
632
+ id: string;
633
+ scope: "global" | "workspace";
634
+ workspace?: string;
635
+ bin: string | null;
636
+ },
637
+ ): Promise<RuntimeChoice> {
638
+ syncCatalog(d);
639
+ const rt = runtimeFor(req.id);
640
+ if (!rt.catalog)
641
+ throw usage(`${req.id} has no CLI to find: its path can't be set`);
642
+ let ws: string | undefined;
643
+ if (req.scope === "workspace") {
644
+ ws = checkWorkspaceName("runtime path", req.workspace);
645
+ if (!d.workspaceExists(ws))
646
+ throw usage(`workspace "${ws}" does not exist`, 404);
647
+ }
648
+ if (req.bin !== null) {
649
+ const c = await checkRuntimeBin(rt.catalog, req.bin, d.env);
650
+ if (!c.ok) throw refused(c.why, [], 422);
651
+ d.saveRuntimeBin(req.scope, ws, rt.id, c.path);
652
+ } else d.saveRuntimeBin(req.scope, ws, rt.id, null);
653
+ const rows = await listRuntimes(d, ws);
654
+ return rows.find((r) => r.id === rt.id)!;
655
+ }
656
+
657
+ /** Refused unless `rt` can run here; its detect result when it can. */
658
+ async function availableOrRefuse(
659
+ d: AgentsResolved,
660
+ rt: Runtime,
661
+ ws?: string,
662
+ ): Promise<RuntimeDetect> {
663
+ await fallbackSearch(d, [rt], ws);
664
+ const det = detectRuntime(d, rt, ws);
665
+ if (!det.available)
666
+ throw refused(
667
+ `${rt.id} is unavailable: ${det.why ?? "unknown"}`,
668
+ rt.find
669
+ ? [
670
+ `Install it, or set ${binSettingKey(rt.id)} to its absolute path in ${d.userConfigFile()}.`,
671
+ `\`${d.cli} agents doctor\` shows where it looked.`,
672
+ ]
673
+ : [],
674
+ 503,
675
+ );
676
+ return det;
420
677
  }
421
678
 
422
679
  function runtimeFor(id: string): Runtime {
@@ -460,6 +717,21 @@ interface Launch {
460
717
  passthrough?: readonly string[];
461
718
  /** agents.<id>.grants, recorded with its actor token. */
462
719
  grants: readonly string[];
720
+ /** Added to the session's PATH: a configured CLI's own directory. */
721
+ binDir?: string;
722
+ }
723
+
724
+ /**
725
+ * A CLI's directory when the session's PATH wouldn't have it (a path set
726
+ * for it, or found by the interactive shell), for its own tools.
727
+ */
728
+ function binDirOf(det: RuntimeDetect): { binDir?: string } {
729
+ return (det.source === "setting" ||
730
+ det.source === "workspace" ||
731
+ det.source === "interactive-shell") &&
732
+ det.path
733
+ ? { binDir: path.dirname(det.path) }
734
+ : {};
463
735
  }
464
736
 
465
737
  /** The host's cap refusal as an AgentError (its hint says what to do). */
@@ -511,6 +783,7 @@ async function startSession(
511
783
  launch.env,
512
784
  launch.rec.id,
513
785
  minted.token,
786
+ launch.binDir ? [launch.binDir] : [],
514
787
  ),
515
788
  cols: (tty && d.stdout.columns) || 80,
516
789
  rows: (tty && d.stdout.rows) || 24,
@@ -601,6 +874,7 @@ function launchInput(
601
874
  ws: string,
602
875
  id: string,
603
876
  model: string | undefined,
877
+ bin?: string,
604
878
  ): LaunchInput {
605
879
  const grants = cfg.agents[id]?.grants ?? [];
606
880
  for (const g of grants)
@@ -623,6 +897,7 @@ function launchInput(
623
897
  filesDir: agentFilesDir(d.stateDir(), id),
624
898
  stateDir: d.stateDir(),
625
899
  env: d.env,
900
+ ...(bin ? { bin } : {}),
626
901
  };
627
902
  }
628
903
 
@@ -680,10 +955,9 @@ export async function startAgent(
680
955
  );
681
956
  const id = req.id ?? defaultId(d.stateDir(), persona.id, ws);
682
957
  checkId(where, id);
958
+ syncCatalog(d);
683
959
  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);
960
+ const det = await availableOrRefuse(d, rt, ws);
687
961
  const model = req.model ?? persona.model;
688
962
 
689
963
  const prev = readRecord(d.stateDir(), id);
@@ -692,7 +966,7 @@ export async function startAgent(
692
966
  `agent ${id} belongs to workspace ${prev.workspace}; give this one another --id`,
693
967
  );
694
968
  ptyOrRefuse(d);
695
- const input = launchInput(d, cfg, persona, ws, id, model);
969
+ const input = launchInput(d, cfg, persona, ws, id, model, det.path);
696
970
 
697
971
  return withClient(d, async (c) => {
698
972
  const sessions = await c.list();
@@ -721,6 +995,7 @@ export async function startAgent(
721
995
  env: out.env,
722
996
  passthrough: rt.envPassthrough,
723
997
  grants: cfg.agents[id]?.grants ?? [],
998
+ ...binDirOf(det),
724
999
  },
725
1000
  old,
726
1001
  {},
@@ -786,6 +1061,7 @@ export async function resumeAgent(
786
1061
  ws?: string,
787
1062
  ): Promise<Resumed> {
788
1063
  const rec = agentRecord(d, id, ws);
1064
+ syncCatalog(d);
789
1065
  const rt = runtimeFor(rec.runtime);
790
1066
  if (!rt.resume)
791
1067
  throw refused(`runtime ${rt.id} does not declare resume`, [
@@ -795,6 +1071,7 @@ export async function resumeAgent(
795
1071
  const persona = personas(d, cfg).get(rec.persona);
796
1072
  if (!persona)
797
1073
  throw refused(`agent ${rec.id}'s persona "${rec.persona}" is gone`);
1074
+ const det = await availableOrRefuse(d, rt, rec.workspace);
798
1075
  ptyOrRefuse(d);
799
1076
  return withClient(d, async (c) => {
800
1077
  const old = sessionOf(await c.list(), rec);
@@ -809,6 +1086,7 @@ export async function resumeAgent(
809
1086
  rec.workspace,
810
1087
  rec.id,
811
1088
  rec.model,
1089
+ det.path,
812
1090
  );
813
1091
  // Resume in the cwd the agent had, wherever the workspace points now.
814
1092
  const at = {
@@ -833,6 +1111,7 @@ export async function resumeAgent(
833
1111
  env: out.env,
834
1112
  passthrough: rt.envPassthrough,
835
1113
  grants: cfg.agents[rec.id]?.grants ?? [],
1114
+ ...binDirOf(det),
836
1115
  },
837
1116
  old,
838
1117
  fresh ? {} : { resumedAt: new Date().toISOString() },
@@ -24,16 +24,27 @@
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, AG-0.5) each runtime of the catalog, installed
28
+ * first: its status, the CLI it runs and where that came from
29
+ * (workspace or user.yml setting, login shell, PATH, install
30
+ * directory, interactive shell), its
31
+ * version (its version command, once, under a timeout), the
32
+ * directories searched (names only, never an env value), and the
33
+ * fix when it is missing. Exit 1 when the default runtime is
34
+ * unavailable. It resolves the login shell's PATH first, as the
35
+ * web server does at start.
27
36
  *
28
37
  * Nothing here takes a grant: grants come from the org config only. The
29
38
  * host enforces agents.max_concurrent on every start, counting agents only.
30
39
  */
31
40
  import { DEFAULT_RUNTIME_ID } from "../../shared/agents/runtimes.js";
41
+ import { initLoginPath } from "../../shared/agents/runtime-search.js";
32
42
  import { HostError } from "../../shared/sessions/client.js";
33
43
  import {
34
44
  AgentError,
35
45
  listAgents,
36
46
  listPersonas as personaList,
47
+ listRuntimes,
37
48
  loadAgentWorkspaces,
38
49
  resolveAgentsDeps,
39
50
  resumeAgent,
@@ -42,6 +53,7 @@ import {
42
53
  type AgentsDeps,
43
54
  type AgentsResolved,
44
55
  type EarlyExit,
56
+ type RuntimeChoice,
45
57
  type Started,
46
58
  } from "./agent-ops.js";
47
59
  import {
@@ -63,7 +75,7 @@ export {
63
75
  } from "./agent-ops.js";
64
76
 
65
77
  export const AGENTS_HELP = {
66
- usage: "agents <list|start|stop|resume|personas> ...",
78
+ usage: "agents <list|start|stop|resume|personas|doctor> ...",
67
79
  lines: [
68
80
  "Agents: a persona and a workspace, run by a runtime in a session that",
69
81
  "outlives the web server (see `{cli} sessions`).",
@@ -78,9 +90,20 @@ export const AGENTS_HELP = {
78
90
  " a session never prompted starts fresh under the same conversation id",
79
91
  "{cli} agents personas [--json]",
80
92
  " Den's default pack, then agents.persona_packs (a later pack wins by id)",
93
+ "{cli} agents doctor [-w <ws>] [--json]",
94
+ " each runtime, installed first: status, the CLI it runs and where it came",
95
+ " from, its version, the directories searched, and the fix; exit 1 when the",
96
+ " default is missing. -w: as that workspace's agents find them",
81
97
  "",
82
- "Runtimes: den-claude (default; needs claude on PATH) and generic (the",
83
- "persona's argv template). Attach with `{cli} sessions attach <ws>:<id>`.",
98
+ "Runtimes: den-claude (default; Claude Code's claude), codex (OpenAI's Codex",
99
+ "CLI), gemini (Google's Gemini CLI), grok (xAI's Grok Build), and generic (the",
100
+ "persona's argv template). codex, gemini and grok run as their own CLI in the",
101
+ "workspace, without Den's tools yet. Attach with `{cli} sessions attach <ws>:<id>`.",
102
+ "A runtime's CLI is found as your terminal finds it: <ws>.agents.runtimes.<id>.bin",
103
+ "in config/local/workspaces.yml, then agents.runtimes.<id>.bin in",
104
+ "config/local/user.yml (absolute paths), then the login shell's PATH, this PATH,",
105
+ "its install directories (~/.local/bin, /opt/homebrew/bin, /usr/local/bin, npm's",
106
+ "global bin, the vendor's own), and last the interactive shell's PATH.",
84
107
  "An agent's env is PATH, HOME, USER, LOGNAME, SHELL, TERM, COLORTERM, LANG,",
85
108
  "LC_*, TZ and TMPDIR, plus what its runtime declares: nothing else of yours.",
86
109
  ],
@@ -250,10 +273,108 @@ async function listPersonas(
250
273
  return 0;
251
274
  }
252
275
 
276
+ export interface AgentsDoctorDeps {
277
+ /** Resolve the login shell's PATH first (default: initLoginPath). */
278
+ loginPath?: () => Promise<void>;
279
+ }
280
+
281
+ const SOURCE_WORD: Record<string, string> = {
282
+ workspace: "the workspace's agents.runtimes setting",
283
+ setting: "agents.runtimes setting",
284
+ "login-shell": "login shell PATH",
285
+ PATH: "PATH",
286
+ "well-known": "install directory",
287
+ "interactive-shell": "interactive shell PATH",
288
+ };
289
+
290
+ /** `agents doctor`'s lines for one runtime (names and paths, never env values). */
291
+ export function doctorLines(
292
+ r: RuntimeChoice,
293
+ cli: string,
294
+ userConfig: string,
295
+ /** Directory lists already printed (a repeat is counted, not listed). */
296
+ printed: string[] = [],
297
+ ): string[] {
298
+ const lines = [
299
+ `${r.id}${r.label ? ` (${r.label}${r.vendor ? `, ${r.vendor}` : ""})` : ""}${r.default ? " [default]" : ""}${r.denTools === false ? " [Den tools: coming]" : ""}`,
300
+ r.available
301
+ ? " status ✓ available"
302
+ : ` status ✗ unavailable: ${r.why ?? "unknown"}`,
303
+ ];
304
+ if (r.path)
305
+ lines.push(
306
+ ` path ${r.path}${r.source ? ` (${SOURCE_WORD[r.source] ?? r.source})` : ""}`,
307
+ );
308
+ if (r.available && r.setting)
309
+ lines.push(` version ${r.version ?? "unknown"}`);
310
+ if (!r.setting) lines.push(" path none to find (the persona's argv)");
311
+ else if (r.searched?.length) {
312
+ const key = r.searched.join("\0");
313
+ if (printed.includes(key))
314
+ lines.push(
315
+ ` searched the same ${r.searched.length} ${r.searched.length === 1 ? "directory" : "directories"} as above`,
316
+ );
317
+ else {
318
+ printed.push(key);
319
+ r.searched.forEach((dir, i) =>
320
+ lines.push(` ${i === 0 ? "searched " : " "}${dir}`),
321
+ );
322
+ }
323
+ } else if (r.source !== "setting") lines.push(" searched (no directories)");
324
+ if (!r.available && r.setting)
325
+ lines.push(
326
+ ` fix install ${r.label ?? r.id}, or set ${r.setting} to its absolute path in ${userConfig}`,
327
+ ` then run \`${cli} agents doctor\` again`,
328
+ );
329
+ return lines;
330
+ }
331
+
332
+ async function doctor(
333
+ args: string[],
334
+ d: AgentsResolved,
335
+ deps: AgentsDoctorDeps,
336
+ ): Promise<number> {
337
+ const where = "agents doctor";
338
+ const p = parseArgs(where, args, { "--workspace": true, "--json": false });
339
+ noExtra(where, p);
340
+ const ws = workspaceFlag(where, p);
341
+ if (ws && !d.workspaceExists(ws))
342
+ throw new UsageError(`${where}: workspace "${ws}" does not exist`);
343
+ await (deps.loginPath ?? (() => initLoginPath({ env: d.env })))();
344
+ const rts = await listRuntimes(d, ws);
345
+ const def = rts.find((r) => r.default);
346
+ const code = def?.available ? 0 : 1;
347
+ if (p.flags["--json"]) {
348
+ d.out(
349
+ JSON.stringify(
350
+ {
351
+ runtimes: rts,
352
+ ...(ws ? { workspace: ws } : {}),
353
+ userConfig: d.userConfigFile(),
354
+ },
355
+ null,
356
+ 2,
357
+ ),
358
+ );
359
+ return code;
360
+ }
361
+ const printed: string[] = [];
362
+ rts.forEach((r, i) => {
363
+ if (i) d.out("");
364
+ for (const l of doctorLines(r, d.cli, d.userConfigFile(), printed))
365
+ d.out(l);
366
+ });
367
+ d.out("");
368
+ d.out(
369
+ `\`${d.cli} doctor --probe\` checks the env each runtime's session gets.`,
370
+ );
371
+ return code;
372
+ }
373
+
253
374
  /** `<cli> agents ...`; returns the exit code. */
254
375
  export async function cmdAgents(
255
376
  args: string[],
256
- deps: AgentsDeps = {},
377
+ deps: AgentsDeps & AgentsDoctorDeps = {},
257
378
  ): Promise<number> {
258
379
  const d = resolveAgentsDeps(deps);
259
380
  const sub =
@@ -274,6 +395,10 @@ export async function cmdAgents(
274
395
  return await resume(rest, d);
275
396
  case "personas":
276
397
  return await listPersonas(rest, d);
398
+ case "doctor":
399
+ // -w checks the workspace exists, and reads its runtime paths.
400
+ await loadAgentWorkspaces(deps);
401
+ return await doctor(rest, d, deps);
277
402
  default:
278
403
  throw new UsageError(`unknown agents command "${sub}"`);
279
404
  }
@@ -25,7 +25,11 @@ import {
25
25
  routerUrls,
26
26
  type IncludedRouter,
27
27
  } from "../../shared/edge/included.js";
28
- import { edgeRoutes, edgeStatus } from "../../shared/edge/status.js";
28
+ import {
29
+ edgeRoutes,
30
+ edgeStatus,
31
+ withHttpsServing,
32
+ } from "../../shared/edge/status.js";
29
33
  import { tlsYaml } from "../../shared/edge/tls.js";
30
34
  import { rejoinRunningStacks } from "../../shared/stacks/status.js";
31
35
 
@@ -102,7 +106,7 @@ export async function cmdEdge(args: string[]): Promise<void> {
102
106
  return;
103
107
  }
104
108
  case "status": {
105
- const st = edgeStatus(cfg);
109
+ const st = await withHttpsServing(edgeStatus(cfg), cfg);
106
110
  console.log(chalk.bold(`${readBrand().name} edge — ${st.org}`));
107
111
  console.log(` container ${st.container}: ${st.state}`);
108
112
  console.log(` ports ${published}`);
@@ -116,7 +120,16 @@ export async function cmdEdge(args: string[]): Promise<void> {
116
120
  // browser trust (BOOT-1), so this machine's trust is moot.
117
121
  ` CA ${caDir(cfg)} — ${!st.ports ? "not needed: fronted" : st.caTrusted === null ? "unknown" : st.caTrusted ? "trusted" : "NOT trusted"}`,
118
122
  );
119
- if (st.certIssued) console.log(` cert ${st.certNames.join(", ")}`);
123
+ if (st.certIssuedByDen)
124
+ console.log(` cert ${st.certNames.join(", ")}`);
125
+ else if (st.https.certs === "distribution")
126
+ console.log(
127
+ " cert the distribution's (tls: files): not minted by Den",
128
+ );
129
+ // MCP-2.5: whether HTTPS is actually serving, not only configured.
130
+ console.log(
131
+ ` https ${st.https.serving === null ? "not probed" : st.https.serving ? "serving" : "NOT serving"} — ${st.https.detail}`,
132
+ );
120
133
  return;
121
134
  }
122
135
  default:
@@ -77,11 +77,14 @@ export function formatRollupLabel(r: RepoRollup): string {
77
77
 
78
78
  /**
79
79
  * Why, for a repo row: the down services ("mailpit down"), and how many
80
- * services aren't checked ("25 not checked"). Unchecked never means down.
80
+ * services aren't checked ("25 not checked"), and how many answered with a
81
+ * redirect ("1 redirect": answering, so neither down nor unchecked).
82
+ * Unchecked never means down.
81
83
  */
82
84
  export function rollupReasonText(r: RepoRollup): string {
83
85
  return [
84
86
  r.down.length ? `${r.down.join(", ")} down` : "",
87
+ r.redirected ? `${r.redirected} redirect` : "",
85
88
  r.unchecked ? `${r.unchecked} not checked` : "",
86
89
  ]
87
90
  .filter(Boolean)