@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.
Files changed (36) hide show
  1. package/CHANGELOG.md +142 -0
  2. package/README.md +88 -158
  3. package/docs/distributions.md +151 -28
  4. package/docs/reference.md +177 -0
  5. package/package.json +1 -1
  6. package/src/shared/agents/den-claude.ts +57 -12
  7. package/src/shared/agents/generic.ts +1 -0
  8. package/src/shared/agents/runtime-search.ts +416 -0
  9. package/src/shared/agents/runtime.ts +26 -1
  10. package/src/shared/agents/runtimes.ts +30 -3
  11. package/src/shared/den-config.ts +88 -3
  12. package/src/shared/edge/render.ts +31 -0
  13. package/src/shared/sessions/client.ts +7 -0
  14. package/src/shared/sessions/den-pty.ts +10 -2
  15. package/src/shared/sessions/host.ts +33 -8
  16. package/src/shared/sessions/types.ts +8 -0
  17. package/src/ui/cli/agent-ops.ts +109 -21
  18. package/src/ui/cli/agents-command.ts +100 -4
  19. package/src/ui/cli/index.ts +33 -1
  20. package/src/ui/cli/sessions-command.ts +26 -9
  21. package/src/ui/cli/web-command.ts +60 -9
  22. package/src/ui/server/agent-routes.ts +11 -3
  23. package/src/ui/server/edge-exposure.ts +143 -0
  24. package/src/ui/server/index.ts +105 -23
  25. package/src/ui/server/public/agent-start.js +202 -0
  26. package/src/ui/server/public/agents-page.js +90 -18
  27. package/src/ui/server/public/agents-section.js +6 -18
  28. package/src/ui/server/public/agents.html +70 -0
  29. package/src/ui/server/public/i18n/es.js +11 -0
  30. package/src/ui/server/public/i18n/ja.js +11 -0
  31. package/src/ui/server/public/local-dev.html +43 -14
  32. package/src/ui/server/public/settings-registry.js +1 -1
  33. package/src/ui/server/public/shell.js +40 -0
  34. package/src/ui/server/public/topbar.js +127 -28
  35. package/src/ui/server/request-guard.ts +122 -7
  36. package/src/ui/server/ui-assets.ts +2 -0
@@ -16,6 +16,7 @@ import type {
16
16
  AttachHandlers,
17
17
  Attachment,
18
18
  SessionInfo,
19
+ SessionPool,
19
20
  SessionSignal,
20
21
  SessionSpec,
21
22
  } from "./types.js";
@@ -125,6 +126,11 @@ export class HostClient extends EventEmitter {
125
126
  spec: SessionSpec,
126
127
  opts: {
127
128
  limit?: number;
129
+ /**
130
+ * Which running sessions `limit` counts: agents (agents.max_concurrent)
131
+ * or plain ones (sessions.max_concurrent). Without it, all of them.
132
+ */
133
+ pool?: SessionPool;
128
134
  /** `--keep-env` names, for the host's audit line (never values). */
129
135
  keptEnv?: { name: string; credentialShaped: boolean }[];
130
136
  /** Who asked, for that line. */
@@ -139,6 +145,7 @@ export class HostClient extends EventEmitter {
139
145
  const r = await this.request("start", {
140
146
  spec,
141
147
  ...(opts.limit !== undefined ? { limit: opts.limit } : {}),
148
+ ...(opts.pool !== undefined ? { pool: opts.pool } : {}),
142
149
  ...(opts.keptEnv?.length || opts.refusedEnv?.length
143
150
  ? { actor: opts.actor ?? "unknown" }
144
151
  : {}),
@@ -21,6 +21,7 @@ import type {
21
21
  Attachment,
22
22
  SessionBackend,
23
23
  SessionInfo,
24
+ SessionPool,
24
25
  SessionRef,
25
26
  SessionSignal,
26
27
  SessionSpec,
@@ -32,8 +33,10 @@ export { BACKEND_ID as DEN_PTY_ID };
32
33
  export interface DenPtyOptions {
33
34
  /** A client of the running host (ensureHost). */
34
35
  client: HostClient;
35
- /** Applied to every start: agents.max_concurrent. */
36
+ /** Applied to every start, counting `pool`'s running sessions. */
36
37
  limit?: number;
38
+ /** agents (agents.max_concurrent) or sessions (sessions.max_concurrent). */
39
+ pool?: SessionPool;
37
40
  }
38
41
 
39
42
  export class DenPtyBackend implements SessionBackend {
@@ -49,7 +52,12 @@ export class DenPtyBackend implements SessionBackend {
49
52
  }
50
53
 
51
54
  async start(spec: SessionSpec): Promise<SessionRef> {
52
- return (await this.opts.client.start(spec, { limit: this.opts.limit })).ref;
55
+ return (
56
+ await this.opts.client.start(spec, {
57
+ limit: this.opts.limit,
58
+ ...(this.opts.pool ? { pool: this.opts.pool } : {}),
59
+ })
60
+ ).ref;
53
61
  }
54
62
  list(): Promise<SessionInfo[]> {
55
63
  return this.opts.client.list();
@@ -29,7 +29,12 @@ import {
29
29
  type Request,
30
30
  } from "./protocol.js";
31
31
  import type { PtyLoad, PtyProcess } from "./pty.js";
32
- import { SESSION_SIGNALS, type SessionInfo } from "./types.js";
32
+ import {
33
+ SESSION_POOLS,
34
+ SESSION_SIGNALS,
35
+ type SessionInfo,
36
+ type SessionPool,
37
+ } from "./types.js";
33
38
 
34
39
  export const BACKEND_ID = "den-pty";
35
40
 
@@ -58,6 +63,8 @@ interface Session {
58
63
  log: ScrollbackLog;
59
64
  /** aid → its connection. */
60
65
  attachments: Map<string, Conn>;
66
+ /** The cap it counts against (start's `pool`), when one was named. */
67
+ pool?: SessionPool;
61
68
  }
62
69
 
63
70
  class Conn {
@@ -82,6 +89,17 @@ class Refusal extends Error {
82
89
  const isInt = (n: unknown, min: number, max: number): n is number =>
83
90
  typeof n === "number" && Number.isInteger(n) && n >= min && n <= max;
84
91
 
92
+ /**
93
+ * The cap refusal: "2 agents are running and agents.max_concurrent is 2".
94
+ * With no pool (an older client's limit) every session counts, and the key
95
+ * is agents.max_concurrent, as it was.
96
+ */
97
+ function capMessage(live: number, limit: number, pool?: unknown): string {
98
+ const noun = pool === "agents" ? "agent" : "session";
99
+ const key = pool === "sessions" ? "sessions" : "agents";
100
+ return `${live} ${noun}${live === 1 ? " is" : "s are"} running and ${key}.max_concurrent is ${limit}`;
101
+ }
102
+
85
103
  function pidAlive(pid: number): boolean {
86
104
  try {
87
105
  process.kill(pid, 0);
@@ -528,20 +546,26 @@ export class SessionHost {
528
546
  );
529
547
  if (same.some((s) => !s.info.exited))
530
548
  throw new Refusal("exists", `${name} is already running`);
531
- const live = [...this.sessions.values()].filter(
532
- (s) => !s.info.exited,
533
- ).length;
549
+ // A cap counts its own pool's running sessions: agents.max_concurrent
550
+ // the agents, sessions.max_concurrent the plain ones. A limit with no
551
+ // pool (an older client) counts every running session, as before.
552
+ const pool = req.pool;
553
+ if (pool !== undefined && !SESSION_POOLS.includes(pool as SessionPool))
554
+ throw new Refusal(
555
+ "bad-request",
556
+ `pool must be ${SESSION_POOLS.join(" or ")}`,
557
+ );
534
558
  if (req.limit !== undefined) {
535
559
  if (!isInt(req.limit, 1, 100_000))
536
560
  throw new Refusal(
537
561
  "bad-request",
538
562
  "limit must be a whole number of at least 1",
539
563
  );
564
+ const live = [...this.sessions.values()].filter(
565
+ (s) => !s.info.exited && (pool === undefined || s.pool === pool),
566
+ ).length;
540
567
  if (live >= req.limit)
541
- throw new Refusal(
542
- "cap",
543
- `${live} session${live === 1 ? " is" : "s are"} running and agents.max_concurrent is ${req.limit}`,
544
- );
568
+ throw new Refusal("cap", capMessage(live, req.limit, pool));
545
569
  }
546
570
  const load = this.opts.pty();
547
571
  if (!load.available) throw new Refusal("unavailable", load.reason);
@@ -582,6 +606,7 @@ export class SessionHost {
582
606
  },
583
607
  ),
584
608
  attachments: new Map(),
609
+ ...(pool !== undefined ? { pool: pool as SessionPool } : {}),
585
610
  };
586
611
  this.sessions.set(id, s);
587
612
  this.persist(s);
@@ -32,6 +32,14 @@ export interface SessionInfo {
32
32
  prompted?: boolean;
33
33
  }
34
34
 
35
+ /**
36
+ * The cap a start counts against: `agents` (agents.max_concurrent) or
37
+ * `sessions` (sessions.max_concurrent, the plain ones), each counting only
38
+ * its own running sessions.
39
+ */
40
+ export const SESSION_POOLS = ["agents", "sessions"] as const;
41
+ export type SessionPool = (typeof SESSION_POOLS)[number];
42
+
35
43
  /** What to run. `env` is the whole environment: nothing is inherited. */
36
44
  export interface SessionSpec {
37
45
  name: string;
@@ -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
@@ -32,13 +35,15 @@
32
35
  * actor token; the old one stops working.
33
36
  *
34
37
  * Nothing here takes a grant: grants come from the org config only. The
35
- * host enforces agents.max_concurrent on every start.
38
+ * host enforces agents.max_concurrent on every start, counting agents only.
36
39
  */
37
40
  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,11 +594,12 @@ 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,
517
601
  },
518
- { limit: d.maxConcurrent() },
602
+ { limit: d.maxConcurrent(), pool: "agents" },
519
603
  )
520
604
  .catch((e: unknown) => capOrThrow(d, e));
521
605
  } catch (e) {
@@ -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
- * host enforces agents.max_concurrent on every start.
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
  }
@@ -2711,8 +2711,11 @@ class DenCLI {
2711
2711
  );
2712
2712
  console.log();
2713
2713
  console.log(chalk.bold(" Flags:"));
2714
+ // Two spaces at least between a flag and its text, a long one too.
2714
2715
  const row = (f: string, d: string) =>
2715
- console.log(` ${chalk.cyan(f.padEnd(20))}${d}`);
2716
+ console.log(
2717
+ ` ${chalk.cyan(f.padEnd(Math.max(20, f.length + 2)))}${d}`,
2718
+ );
2716
2719
  row(
2717
2720
  "-x, --exclude",
2718
2721
  "start only the dependencies, not the current/seed repo",
@@ -4660,6 +4663,35 @@ class DenCLI {
4660
4663
  if (!d.available) warn(`edge.tls ${edge.tls.strategy}: ${d.reason}`);
4661
4664
  }
4662
4665
  }
4666
+ // The web keys: a wrong value would stop `web up`; one read and
4667
+ // ignored (the distribution's web.allow_plain_http_remote: only
4668
+ // config/local/user.yml counts) is flagged, as `web up` says it.
4669
+ {
4670
+ const { ignoredConfigWarning, readWebAuthConfig } =
4671
+ await import("../../shared/web-session.js");
4672
+ try {
4673
+ const w = ignoredConfigWarning(
4674
+ readWebAuthConfig(getDistRoot(), product().configFile),
4675
+ );
4676
+ if (w) console.warn(chalk.yellow(`\n ⚠ ${w}`));
4677
+ } catch (e) {
4678
+ console.error(chalk.red(`\n ✗ web: ${(e as Error).message}`));
4679
+ process.exitCode = 1;
4680
+ }
4681
+ }
4682
+ // The caps: agents.max_concurrent (agents) and sessions.max_concurrent
4683
+ // (plain sessions). A wrong value would refuse every start.
4684
+ {
4685
+ const { readAgentsConfig, readSessionsConfig } =
4686
+ await import("../../shared/den-config.js");
4687
+ for (const read of [readAgentsConfig, readSessionsConfig])
4688
+ try {
4689
+ read();
4690
+ } catch (e) {
4691
+ console.error(chalk.red(`\n ✗ ${(e as Error).message}`));
4692
+ process.exitCode = 1;
4693
+ }
4694
+ }
4663
4695
  const positional = args.filter((a) => !a.startsWith("-"));
4664
4696
  const held = args.includes("--held");
4665
4697
  // Bare `validate` in the distribution root (which has no manifest of its