@monster-orange/den 0.4.3 → 0.4.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. package/CHANGELOG.md +179 -0
  2. package/README.md +9 -9
  3. package/docs/distributions.md +215 -65
  4. package/package.json +1 -1
  5. package/src/shared/agents/catalog-runtime.ts +178 -0
  6. package/src/shared/agents/catalog.ts +343 -0
  7. package/src/shared/agents/den-claude.ts +14 -18
  8. package/src/shared/agents/runtime-search.ts +142 -15
  9. package/src/shared/agents/runtime.ts +18 -1
  10. package/src/shared/agents/runtimes.ts +23 -1
  11. package/src/shared/auth/sudo-vault.ts +16 -0
  12. package/src/shared/den-config.ts +57 -3
  13. package/src/shared/edge/status.ts +134 -0
  14. package/src/shared/environment-manager/health.ts +82 -8
  15. package/src/shared/environment-manager/workspaces.ts +54 -0
  16. package/src/shared/health/not-checked.ts +42 -0
  17. package/src/shared/health/rollup.ts +4 -0
  18. package/src/shared/process/spawn-streaming.ts +8 -3
  19. package/src/shared/sessions/client.ts +5 -1
  20. package/src/shared/sessions/host.ts +29 -1
  21. package/src/shared/sessions/protocol.ts +6 -1
  22. package/src/shared/sessions/types.ts +6 -0
  23. package/src/shared/stacks/status.ts +24 -0
  24. package/src/shared/strategies/registry.ts +5 -0
  25. package/src/shared/types/index.ts +6 -0
  26. package/src/ui/cli/agent-ops.ts +210 -19
  27. package/src/ui/cli/agents-command.ts +50 -21
  28. package/src/ui/cli/edge-command.ts +16 -3
  29. package/src/ui/cli/health-display.ts +4 -1
  30. package/src/ui/mcp/scope.ts +23 -3
  31. package/src/ui/mcp/server.ts +31 -7
  32. package/src/ui/mcp/tools.ts +327 -20
  33. package/src/ui/mcp/views.ts +450 -0
  34. package/src/ui/server/agent-routes.ts +118 -28
  35. package/src/ui/server/index.ts +77 -6
  36. package/src/ui/server/public/agent-start.js +46 -7
  37. package/src/ui/server/public/agents-page.js +232 -97
  38. package/src/ui/server/public/agents-section.js +3 -5
  39. package/src/ui/server/public/agents.html +12 -28
  40. package/src/ui/server/public/graph-model.js +2 -0
  41. package/src/ui/server/public/header.js +2 -3
  42. package/src/ui/server/public/health-rollup.js +20 -8
  43. package/src/ui/server/public/i18n/es.js +15 -13
  44. package/src/ui/server/public/i18n/ja.js +14 -13
  45. package/src/ui/server/public/index.html +17 -4
  46. package/src/ui/server/public/local-dev.html +28 -17
  47. package/src/ui/server/public/services.html +21 -5
  48. package/src/ui/server/public/settings-page.js +3 -2
  49. package/src/ui/server/public/settings-registry.js +225 -0
  50. package/src/ui/server/public/settings.html +90 -1
  51. package/src/ui/server/public/shell.js +28 -23
  52. package/src/ui/server/public/topbar.js +166 -206
  53. package/src/ui/server/readonly-routes.ts +29 -5
  54. package/src/ui/server/request-guard.ts +1 -1
  55. package/src/ui/server/route-classes.ts +14 -2
  56. package/src/ui/server/session-routes.ts +9 -1
  57. package/src/ui/server/sudo-store.ts +170 -6
  58. package/src/ui/server/workspace-routes.ts +10 -3
@@ -24,8 +24,10 @@
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
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
29
31
  * version (its version command, once, under a timeout), the
30
32
  * directories searched (names only, never an env value), and the
31
33
  * fix when it is missing. Exit 1 when the default runtime is
@@ -88,16 +90,20 @@ export const AGENTS_HELP = {
88
90
  " a session never prompted starts fresh under the same conversation id",
89
91
  "{cli} agents personas [--json]",
90
92
  " 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",
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",
94
97
  "",
95
- "Runtimes: den-claude (default; Claude Code's claude) and generic (the",
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).",
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.",
101
107
  "An agent's env is PATH, HOME, USER, LOGNAME, SHELL, TERM, COLORTERM, LANG,",
102
108
  "LC_*, TZ and TMPDIR, plus what its runtime declares: nothing else of yours.",
103
109
  ],
@@ -273,10 +279,12 @@ export interface AgentsDoctorDeps {
273
279
  }
274
280
 
275
281
  const SOURCE_WORD: Record<string, string> = {
282
+ workspace: "the workspace's agents.runtimes setting",
276
283
  setting: "agents.runtimes setting",
277
284
  "login-shell": "login shell PATH",
278
285
  PATH: "PATH",
279
286
  "well-known": "install directory",
287
+ "interactive-shell": "interactive shell PATH",
280
288
  };
281
289
 
282
290
  /** `agents doctor`'s lines for one runtime (names and paths, never env values). */
@@ -284,9 +292,11 @@ export function doctorLines(
284
292
  r: RuntimeChoice,
285
293
  cli: string,
286
294
  userConfig: string,
295
+ /** Directory lists already printed (a repeat is counted, not listed). */
296
+ printed: string[] = [],
287
297
  ): string[] {
288
298
  const lines = [
289
- `${r.id}${r.label ? ` (${r.label})` : ""}${r.default ? " [default]" : ""}`,
299
+ `${r.id}${r.label ? ` (${r.label}${r.vendor ? `, ${r.vendor}` : ""})` : ""}${r.default ? " [default]" : ""}${r.denTools === false ? " [Den tools: coming]" : ""}`,
290
300
  r.available
291
301
  ? " status ✓ available"
292
302
  : ` status ✗ unavailable: ${r.why ?? "unknown"}`,
@@ -298,11 +308,19 @@ export function doctorLines(
298
308
  if (r.available && r.setting)
299
309
  lines.push(` version ${r.version ?? "unknown"}`);
300
310
  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)");
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)");
306
324
  if (!r.available && r.setting)
307
325
  lines.push(
308
326
  ` fix install ${r.label ?? r.id}, or set ${r.setting} to its absolute path in ${userConfig}`,
@@ -317,25 +335,34 @@ async function doctor(
317
335
  deps: AgentsDoctorDeps,
318
336
  ): Promise<number> {
319
337
  const where = "agents doctor";
320
- const p = parseArgs(where, args, { "--json": false });
338
+ const p = parseArgs(where, args, { "--workspace": true, "--json": false });
321
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`);
322
343
  await (deps.loginPath ?? (() => initLoginPath({ env: d.env })))();
323
- const rts = await listRuntimes(d);
344
+ const rts = await listRuntimes(d, ws);
324
345
  const def = rts.find((r) => r.default);
325
346
  const code = def?.available ? 0 : 1;
326
347
  if (p.flags["--json"]) {
327
348
  d.out(
328
349
  JSON.stringify(
329
- { runtimes: rts, userConfig: d.userConfigFile() },
350
+ {
351
+ runtimes: rts,
352
+ ...(ws ? { workspace: ws } : {}),
353
+ userConfig: d.userConfigFile(),
354
+ },
330
355
  null,
331
356
  2,
332
357
  ),
333
358
  );
334
359
  return code;
335
360
  }
361
+ const printed: string[] = [];
336
362
  rts.forEach((r, i) => {
337
363
  if (i) d.out("");
338
- for (const l of doctorLines(r, d.cli, d.userConfigFile())) d.out(l);
364
+ for (const l of doctorLines(r, d.cli, d.userConfigFile(), printed))
365
+ d.out(l);
339
366
  });
340
367
  d.out("");
341
368
  d.out(
@@ -369,6 +396,8 @@ export async function cmdAgents(
369
396
  case "personas":
370
397
  return await listPersonas(rest, d);
371
398
  case "doctor":
399
+ // -w checks the workspace exists, and reads its runtime paths.
400
+ await loadAgentWorkspaces(deps);
372
401
  return await doctor(rest, d, deps);
373
402
  default:
374
403
  throw new UsageError(`unknown agents command "${sub}"`);
@@ -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)
@@ -191,6 +191,20 @@ function grantsNote(scope: McpScope): string {
191
191
  : "no grants";
192
192
  }
193
193
 
194
+ /**
195
+ * How a person gives `scope`'s agent a grant to `other` (MCP-2.3, AG spec
196
+ * §5b): config in git, then a restart. An agent never grants itself one.
197
+ */
198
+ export function grantHint(scope: McpScope, other: string): string {
199
+ const id = scope.actor.slice("agent:".length);
200
+ return (
201
+ `To reach it, a person adds "${printable(other)}" to agents.${id}.grants ` +
202
+ `in the org config and restarts the agent (agents start passes --grant); ` +
203
+ "an agent can't grant itself one. For names and status counts of every " +
204
+ "workspace, call org_summary."
205
+ );
206
+ }
207
+
194
208
  /** The refusal for a call that names `other`; it names the agent's grants. */
195
209
  export function otherWorkspace(
196
210
  tool: string,
@@ -199,7 +213,8 @@ export function otherWorkspace(
199
213
  ): ScopeRefusal {
200
214
  return new ScopeRefusal(
201
215
  `${tool}: this MCP server is scoped to workspace "${scope.workspace}" ` +
202
- `(${scope.actor}; ${grantsNote(scope)}); "${printable(other)}" is another workspace, so nothing was read or run.`,
216
+ `(${scope.actor}; ${grantsNote(scope)}); "${printable(other)}" is another workspace, so nothing was read or run. ` +
217
+ grantHint(scope, other),
203
218
  );
204
219
  }
205
220
 
@@ -299,8 +314,10 @@ function focusedOf(body: unknown): string | null {
299
314
  export async function assertScopeExists(
300
315
  api: DenApi,
301
316
  scope: McpScope,
317
+ /** An /api/workspaces body already read (else it is read now). */
318
+ read?: unknown,
302
319
  ): Promise<unknown> {
303
- const body = await api.get("/api/workspaces");
320
+ const body = read ?? (await api.get("/api/workspaces"));
304
321
  if (!(scope.workspace in asRecord(asRecord(body).workspaces)))
305
322
  throw new ScopeRefusal(
306
323
  `this MCP server is scoped to workspace "${scope.workspace}", which does not exist in this distribution.`,
@@ -375,7 +392,10 @@ export async function assertServicesInScope(
375
392
  if (outside.length)
376
393
  throw new ScopeRefusal(
377
394
  `${tool}: ${outside.map(printable).join(", ")} ${outside.length === 1 ? "is" : "are"} not a service ` +
378
- `of workspace "${scope.workspace}", the only one this MCP server serves; nothing was read.`,
395
+ `of workspace "${scope.workspace}", the only one this MCP server serves; nothing was read. ` +
396
+ "Logs and service details stay inside each workspace (logs can hold " +
397
+ "secrets), and a grant does not widen them; org_summary has every " +
398
+ "workspace's status counts.",
379
399
  );
380
400
  }
381
401
 
@@ -49,7 +49,9 @@ export function createDenMcpServer(opts: DenMcpOptions): McpServer {
49
49
  const p = product();
50
50
  const session = opts.session ?? randomUUID();
51
51
  const log = opts.log ?? ((l: string) => process.stderr.write(`${l}\n`));
52
- const tools = toolsFor(opts.cfg);
52
+ // Scoped, each workspace tool is registered pinned (MCP-2.2): "this
53
+ // workspace only", and no workspace parameter unless there are grants.
54
+ const tools = toolsFor(opts.cfg, opts.scope);
53
55
  const server = new McpServer(
54
56
  { name: p.name, version: engineVersion() ?? "0.0.0" },
55
57
  {
@@ -61,8 +63,11 @@ export function createDenMcpServer(opts: DenMcpOptions): McpServer {
61
63
  : "Read-only: the org has enabled no actions (mcp.actions).") +
62
64
  (opts.scope
63
65
  ? ` Scoped to workspace "${opts.scope.workspace}" as ${opts.scope.actor}: ` +
64
- "a call naming another workspace is refused, and lists show this one only."
65
- : ""),
66
+ "a call naming another workspace is refused, and lists show this one only, " +
67
+ "each with a `scope` block saying how much is hidden. org_summary has " +
68
+ "every workspace and repo (names and status counts), the graph, the " +
69
+ "edge and the environments: start there for the whole picture."
70
+ : " org_summary has the whole org at a glance: start there."),
66
71
  },
67
72
  );
68
73
 
@@ -222,8 +227,10 @@ export async function runOneShot(
222
227
  opts: OneShotOptions,
223
228
  ): Promise<number> {
224
229
  const out = opts.out ?? ((t: string) => process.stdout.write(t));
230
+ // The tools exactly as the stdio server registers them: pinned when scoped.
231
+ const tools = toolsFor(opts.cfg, opts.scope);
225
232
  if (shot.kind === "list") {
226
- const names = toolsFor(opts.cfg).map((t) => t.name);
233
+ const names = tools.map((t) => t.name);
227
234
  out(names.length ? `${names.join("\n")}\n` : "");
228
235
  return 0;
229
236
  }
@@ -235,11 +242,18 @@ export async function runOneShot(
235
242
  };
236
243
  // The stdio server validates arguments against each tool's schema (the
237
244
  // SDK does it); a one-shot must too, and strictly: it is typed by hand, so
238
- // an unknown or misspelt key is an error, not silently dropped.
245
+ // an unknown or misspelt key is an error, not silently dropped. Scoped,
246
+ // the schema is the pinned one; a `workspace` it dropped (no grants) is
247
+ // still taken, so naming another workspace gets Den's refusal (callTool),
248
+ // never a silent answer about the scope's own.
239
249
  let args = shot.args;
240
- const tool = toolsFor(opts.cfg).find((t) => t.name === shot.tool);
250
+ const tool = tools.find((t) => t.name === shot.tool);
241
251
  if (tool) {
242
- const parsed = z.object(tool.shape).strict().safeParse(args);
252
+ const shape =
253
+ opts.scope && !("workspace" in tool.shape) && "workspace" in args
254
+ ? { ...tool.shape, workspace: z.string().optional() }
255
+ : tool.shape;
256
+ const parsed = z.object(shape).strict().safeParse(args);
243
257
  if (!parsed.success) {
244
258
  const err = opts.err ?? ((t: string) => process.stderr.write(t));
245
259
  const lines = parsed.error.issues.map((i) => {
@@ -384,6 +398,16 @@ export async function runMcp(rawArgv: string[]): Promise<void> {
384
398
  ...scopeInfo(scope ?? undefined),
385
399
  }),
386
400
  });
401
+ // Say the scope on stderr (stdout is the result): a one-shot that ran
402
+ // unscoped must never pass for a scoped one.
403
+ if (scope)
404
+ process.stderr.write(
405
+ `${cli} mcp --call: scoped to ${scope.workspace} as ${scope.actor}` +
406
+ (scope.grants?.length
407
+ ? ` · grants: ${scope.grants.join(", ")}`
408
+ : "") +
409
+ "\n",
410
+ );
387
411
  process.exitCode = await runOneShot(shot, {
388
412
  cfg,
389
413
  cli,