@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
@@ -1371,6 +1371,34 @@
1371
1371
  }
1372
1372
  .agent-form select {
1373
1373
  max-width: 320px;
1374
+ min-width: 0;
1375
+ }
1376
+ .agent-form-help {
1377
+ flex-basis: 100%;
1378
+ margin: 0;
1379
+ font-size: 12px;
1380
+ color: var(--muted);
1381
+ overflow-wrap: anywhere;
1382
+ }
1383
+ .agent-form-help[hidden],
1384
+ .agent-form-ready[hidden] {
1385
+ display: none;
1386
+ }
1387
+ /* AG-0.2: the runtime it will run, or why it can't (the mock's .helpl). */
1388
+ .agent-form-ready {
1389
+ flex-basis: 100%;
1390
+ order: 1;
1391
+ margin: 0;
1392
+ font-size: 12px;
1393
+ line-height: 1.4;
1394
+ color: var(--faint);
1395
+ overflow-wrap: anywhere;
1396
+ }
1397
+ .agent-form-ready[data-tone="warn"] {
1398
+ color: var(--warn);
1399
+ }
1400
+ .agent-form-ready .mono {
1401
+ font-family: var(--mono);
1374
1402
  }
1375
1403
  .agent-cwd {
1376
1404
  max-width: 280px;
@@ -1459,8 +1487,8 @@
1459
1487
  initChangelog,
1460
1488
  copyToClipboard,
1461
1489
  computePrimaryByRepo,
1462
- requireUnlock,
1463
1490
  setServiceRunner,
1491
+ sudoRun,
1464
1492
  takePendingAction,
1465
1493
  problemCards,
1466
1494
  problemFix,
@@ -1517,13 +1545,12 @@
1517
1545
  .replace(/>/g, ">")
1518
1546
  .replace(/"/g, """);
1519
1547
 
1520
- // ── The lock (R8): the shell's lock chip and popover (topbar.js) ──────────
1521
- // requireSudo() is this page's lock rule, shared with every page: true
1522
- // when unlocked; otherwise the lock popover opens on its password
1523
- // field and the command doesn't run.
1524
- function requireSudo() {
1525
- return requireUnlock();
1526
- }
1548
+ // ── The admin password (R8, LK-0) ─────────────────────────────────────────
1549
+ // It is for sudo, and nothing else: no action here asks for it up
1550
+ // front. A run whose command is about to run sudo without it gets a
1551
+ // needs-sudo event from the server (sudoRun().see); the shell's
1552
+ // prompt then says what needs it, and after the password the action
1553
+ // runs again (sudoRun().done).
1527
1554
 
1528
1555
  // ── Bootstrap ─────────────────────────────────────────────────────────────
1529
1556
  // Systems with auto-start toggled OFF in the viewed workspace (`autostart:
@@ -1543,7 +1570,7 @@
1543
1570
  ]);
1544
1571
  initLogBadge();
1545
1572
  // Start and Restart from the status drawer and the ⌘K palette run
1546
- // here, with this page's lock rule and output (R15, R17).
1573
+ // here, with this page's output (R15, R17).
1547
1574
  setServiceRunner((cmd, s) =>
1548
1575
  window.runCmd(s.address, s.repo, s.service, cmd),
1549
1576
  );
@@ -3021,10 +3048,10 @@
3021
3048
 
3022
3049
  // ── Workspace-level command (SSE) ─────────────────────────────────────────
3023
3050
  // Routes output to each service's own tab as `label` events arrive.
3024
- window.runWorkspaceSync = function () {
3025
- if (!requireSudo()) return;
3051
+ window.runWorkspaceSync = async function () {
3026
3052
  setAllButtons(true);
3027
3053
  setWsStatus("syncing…");
3054
+ const sudo = sudoRun();
3028
3055
  const es = new EventSource("/api/workspace/sync");
3029
3056
  // Clear previous sync output then open/activate the tab (explicit action).
3030
3057
  clearDismissed("⟳ sync");
@@ -3033,6 +3060,7 @@
3033
3060
  setBusy("⟳ sync", true);
3034
3061
  es.onmessage = (e) => {
3035
3062
  const msg = JSON.parse(e.data);
3063
+ sudo.see(msg);
3036
3064
  if (msg.type === "line") appendToTab("⟳ sync", msg.text);
3037
3065
  if (msg.type === "error")
3038
3066
  appendToTab("⟳ sync", "Error: " + msg.text, "err");
@@ -3044,6 +3072,7 @@
3044
3072
  setTimeout(() => setWsStatus(""), 3000);
3045
3073
  // See refreshAfterSync — a sync that clones brings new commands with it.
3046
3074
  refreshAfterSync();
3075
+ void sudo.done(() => window.runWorkspaceSync());
3047
3076
  }
3048
3077
  };
3049
3078
  es.onerror = () => {
@@ -3073,10 +3102,10 @@
3073
3102
  // rather than a shared pane, so it sits with everything else for that
3074
3103
  // system. Buttons are not globally disabled: it is one repo.
3075
3104
  const _syncing = new Set();
3076
- window.runRepoSync = function (repo) {
3077
- if (!requireSudo()) return;
3105
+ window.runRepoSync = async function (repo) {
3078
3106
  if (_syncing.has(repo)) return; // already in flight — ignore re-entry
3079
3107
  _syncing.add(repo);
3108
+ const sudo = sudoRun();
3080
3109
 
3081
3110
  const route = tabRouteFor(repo);
3082
3111
  clearDismissed(route); // explicit action — undo any prior dismissal
@@ -3101,12 +3130,14 @@
3101
3130
  );
3102
3131
  es.onmessage = (e) => {
3103
3132
  const msg = JSON.parse(e.data);
3133
+ sudo.see(msg);
3104
3134
  if (msg.type === "line") appendToTab(route, msg.text);
3105
3135
  if (msg.type === "error")
3106
3136
  appendToTab(route, "Error: " + msg.text, "err");
3107
3137
  if (msg.type === "done") {
3108
3138
  es.close();
3109
3139
  finish("sync complete");
3140
+ void sudo.done(() => window.runRepoSync(repo));
3110
3141
  // A pull can replace this repo's health probes on disk. The health
3111
3142
  // response is cached ~8s, so force a fresh poll rather than leaving
3112
3143
  // dots that reflect the code we just replaced.
@@ -3126,8 +3157,8 @@
3126
3157
  };
3127
3158
  };
3128
3159
 
3129
- window.runWorkspaceCmd = function (cmd, addresses) {
3130
- if (!requireSudo()) return;
3160
+ window.runWorkspaceCmd = async function (cmd, addresses) {
3161
+ const sudo = sudoRun();
3131
3162
  const only =
3132
3163
  Array.isArray(addresses) && addresses.length
3133
3164
  ? `&addresses=${encodeURIComponent(addresses.join(","))}`
@@ -3145,6 +3176,7 @@
3145
3176
 
3146
3177
  esWs.onmessage = (e) => {
3147
3178
  const msg = JSON.parse(e.data);
3179
+ sudo.see(msg);
3148
3180
  if (msg.type === "label") {
3149
3181
  // New service section — route to the SYSTEM's primary tab (one tab
3150
3182
  // per system; sidecar output lands in its repo's tab with a header).
@@ -3210,6 +3242,7 @@
3210
3242
  idle();
3211
3243
  // Lifecycle changed run-state — sync dots now, don't wait for the poll.
3212
3244
  syncHealthNow();
3245
+ void sudo.done(() => window.runWorkspaceCmd(cmd, addresses));
3213
3246
  }
3214
3247
  };
3215
3248
  esWs.onerror = () => {
@@ -3240,8 +3273,7 @@
3240
3273
  // is derived on the SERVER (see the `seed` param in /api/workspace/exec):
3241
3274
  // its rules are load-bearing, and the already-healthy skip it relies on is
3242
3275
  // suppressed whenever an explicit address list is sent.
3243
- window.runUpWithDeps = function (repo) {
3244
- if (!requireSudo()) return;
3276
+ window.runUpWithDeps = async function (repo) {
3245
3277
  void runWorkspaceCmdAsync("up", null, {
3246
3278
  seed: repo,
3247
3279
  undismiss: true,
@@ -3252,16 +3284,14 @@
3252
3284
  // same closure as "up with deps", reversed. Derived server-side for the
3253
3285
  // same reason as its counterpart: the already-stopped skip is suppressed
3254
3286
  // when an explicit address list is sent.
3255
- window.runDownWithDeps = function (repo) {
3256
- if (!requireSudo()) return;
3287
+ window.runDownWithDeps = async function (repo) {
3257
3288
  void runWorkspaceCmdAsync("down", null, {
3258
3289
  seed: repo,
3259
3290
  undismiss: true,
3260
3291
  });
3261
3292
  };
3262
3293
 
3263
- window.runCmd = function (address, repo, service, cmd) {
3264
- if (!requireSudo()) return;
3294
+ window.runCmd = async function (address, repo, service, cmd) {
3265
3295
  // Explicit single-service action — undo any prior dismissal so its pane returns.
3266
3296
  clearDismissed(address);
3267
3297
  // up/restart/down go through the workspace-exec runner: it uses the full
@@ -3290,9 +3320,12 @@
3290
3320
  setTabStatus(address, { es: esCmd });
3291
3321
  const _cmdTab = _tabs.get(address);
3292
3322
  if (_cmdTab) _cmdTab.esKind = "exec";
3323
+ const sudo = sudoRun();
3324
+ const again = () => window.runCmd(address, repo, service, cmd);
3293
3325
 
3294
3326
  esCmd.onmessage = (e) => {
3295
3327
  const msg = JSON.parse(e.data);
3328
+ sudo.see(msg);
3296
3329
  if (msg.type === "cmd") {
3297
3330
  appendToTab(address, msg.text, "cmd");
3298
3331
  } else if (msg.type === "started") {
@@ -3320,6 +3353,7 @@
3320
3353
  "done-fail",
3321
3354
  );
3322
3355
  setTabStatus(address, { live: false, errored: true, es: null });
3356
+ void sudo.done(again);
3323
3357
  }
3324
3358
  } else if (msg.type === "line") {
3325
3359
  appendToTab(address, msg.text, "");
@@ -3340,6 +3374,7 @@
3340
3374
  );
3341
3375
  _tabs.get(address) && (_tabs.get(address).busy = false);
3342
3376
  setTabStatus(address, { live: false, errored: !ok, es: null });
3377
+ void sudo.done(again);
3343
3378
  }
3344
3379
  };
3345
3380
  esCmd.onerror = () => {
@@ -3824,9 +3859,11 @@
3824
3859
  // address filter — the filter suppresses the already-healthy skip and
3825
3860
  // the auto-start-off handling that make "with deps" behave.
3826
3861
  if (opts.seed) params.set("seed", opts.seed);
3862
+ const sudo = sudoRun();
3827
3863
  const es = new EventSource(`/api/workspace/exec?${params}`);
3828
3864
  es.onmessage = (e) => {
3829
3865
  const msg = JSON.parse(e.data);
3866
+ sudo.see(msg);
3830
3867
  if (msg.type === "label") {
3831
3868
  currentAddress = msg.text;
3832
3869
  // Route to the system's primary tab; skip dismissed panes on
@@ -3895,7 +3932,10 @@
3895
3932
  idle();
3896
3933
  // Lifecycle changed run-state — sync dots now, don't wait for the poll.
3897
3934
  syncHealthNow();
3898
- resolve(ok);
3935
+ // LK-0: after the admin password, the same run again.
3936
+ void sudo
3937
+ .done(() => runWorkspaceCmdAsync(cmd, addresses, opts))
3938
+ .then((again) => resolve(again ?? ok));
3899
3939
  }
3900
3940
  };
3901
3941
  es.onerror = () => {
@@ -333,6 +333,7 @@
333
333
  initChangelog,
334
334
  computePrimaryByRepo,
335
335
  envReady,
336
+ sudoRun,
336
337
  } from "/header.js";
337
338
  import { ownersLabel } from "/workspace-picker.js";
338
339
 
@@ -613,10 +614,16 @@
613
614
  const usableCmds = isSub
614
615
  ? s.commands.filter((cmd) => !LIFECYCLE_CMDS.has(cmd))
615
616
  : s.commands;
617
+ // The workspace the row was built from (the first in view that
618
+ // defines it, focus first): its command runs there, not in
619
+ // whichever workspace is focused.
620
+ const wsAttr = s.inWorkspaces?.[0]
621
+ ? ` data-workspace="${esc(s.inWorkspaces[0])}"`
622
+ : "";
616
623
  const cmdButtons = usableCmds
617
624
  .map(
618
625
  (cmd) =>
619
- `<button class="cmd-btn" data-repo="${s.repo}" data-service="${s.service}" data-cmd="${cmd}" data-type="exec">${cmd}</button>`,
626
+ `<button class="cmd-btn" data-repo="${s.repo}" data-service="${s.service}" data-cmd="${cmd}" data-type="exec"${wsAttr}>${cmd}</button>`,
620
627
  )
621
628
  .join("");
622
629
  const logsButton = s.logs
@@ -743,8 +750,9 @@
743
750
  // Attach click handlers
744
751
  pane.querySelectorAll(".cmd-btn").forEach((btn) => {
745
752
  btn.addEventListener("click", () => {
746
- const { repo, service, cmd, type } = btn.dataset;
747
- if (type === "exec") runExec(repo, service, cmd, btn);
753
+ const { repo, service, cmd, type, workspace } = btn.dataset;
754
+ if (type === "exec")
755
+ runExec(repo, service, cmd, btn, workspace);
748
756
  else runLogs(repo, service, btn);
749
757
  });
750
758
  });
@@ -765,7 +773,7 @@
765
773
  }
766
774
  }
767
775
 
768
- function runExec(repo, service, cmd, btn) {
776
+ function runExec(repo, service, cmd, btn, workspace) {
769
777
  endStream();
770
778
  const env = document.getElementById("env-sel").value;
771
779
 
@@ -781,8 +789,13 @@
781
789
  appendLine(`\n▶ ${repo}/${service} · ${cmd} · ${env}\n`, "cmd");
782
790
 
783
791
  const params = new URLSearchParams({ repo, service, cmd, env });
792
+ if (workspace) params.set("workspace", workspace);
784
793
  es = new EventSource(`/api/service/exec?${params}`);
785
- handleSSE(es, repo, service);
794
+ // LK-0: a command that needs sudo without the password asks,
795
+ // then runs again.
796
+ handleSSE(es, repo, service, {
797
+ again: () => runExec(repo, service, cmd, btn, workspace),
798
+ });
786
799
  }
787
800
 
788
801
  function runLogs(repo, service, btn) {
@@ -819,8 +832,10 @@
819
832
  const isStream = opts.stream === true;
820
833
  const id = runId;
821
834
  const appendLine = (text, cls) => runs().append(id, text, cls);
835
+ const sudo = sudoRun();
822
836
  evtSrc.onmessage = (e) => {
823
837
  const msg = JSON.parse(e.data);
838
+ sudo.see(msg);
824
839
  if (msg.type === "cmd") {
825
840
  appendLine(msg.text, "cmd");
826
841
  } else if (msg.type === "line") {
@@ -842,6 +857,7 @@
842
857
  } else runs().set(id, { running: false });
843
858
  evtSrc.close();
844
859
  es = null;
860
+ if (opts.again) void sudo.done(opts.again);
845
861
  }
846
862
  };
847
863
  evtSrc.onerror = () => {
@@ -300,7 +300,8 @@ export async function mountSettings(deps) {
300
300
  const did = `d-${id}`;
301
301
  const v = value(r);
302
302
  const t = r.control?.type || "readonly";
303
- const ro = !r.write;
303
+ // A custom control draws (and saves) itself: no write to read it by.
304
+ const ro = !r.write && t !== "custom";
304
305
  const single = ["number", "select", "toggle", "text"].includes(t);
305
306
  const label = single
306
307
  ? `<label class="l" id="${lid}" for="${cid}">${esc(r.label)}</label>`
@@ -499,7 +500,7 @@ export async function mountSettings(deps) {
499
500
  }, 2000);
500
501
  }
501
502
  function wireRow(r) {
502
- if (!r.write) return;
503
+ if (!r.write && r.control?.type !== "custom") return;
503
504
  const row = $(`setting-${slug(r.id)}`);
504
505
  if (!row) return;
505
506
  const t = r.control?.type;
@@ -64,6 +64,13 @@ export const DEN_SETTING_CATEGORIES = [
64
64
  description:
65
65
  "Settings for one workspace. They follow the Workspace you view in the top bar.",
66
66
  },
67
+ {
68
+ id: "agents",
69
+ label: "Agents",
70
+ order: 55,
71
+ description:
72
+ "The runtimes agents run on, installed first. Paths you set are this machine's only: everywhere (config/local/user.yml) or for one workspace (config/local/workspaces.yml), which wins.",
73
+ },
67
74
  {
68
75
  id: "cli",
69
76
  label: "Command line",
@@ -416,6 +423,29 @@ export const DEN_SETTINGS = [
416
423
  write: (v, ctx) => ctx.saveWorkspace({ editor: v || null }),
417
424
  },
418
425
 
426
+ // ── Agents ─────────────────────────────────────────────────────────────
427
+ {
428
+ id: "agents.runtimes",
429
+ category: "agents",
430
+ label: "Runtimes",
431
+ description:
432
+ "Each runtime's status and version, the path in use and where it came from. Set path… runs its version command first and refuses a path that isn't that runtime.",
433
+ keywords: [
434
+ "runtime",
435
+ "claude",
436
+ "codex",
437
+ "gemini",
438
+ "grok",
439
+ "path",
440
+ "bin",
441
+ "cli",
442
+ "agent",
443
+ ],
444
+ scope: "user",
445
+ control: { type: "custom", render: renderRuntimes },
446
+ read: () => null,
447
+ },
448
+
419
449
  // ── Command line ───────────────────────────────────────────────────────
420
450
  {
421
451
  id: "cli.local_dev",
@@ -472,7 +502,7 @@ export const DEN_SETTINGS = [
472
502
  category: "cli",
473
503
  label: "Browser sign-in",
474
504
  description:
475
- "Signing in is needed only when this server is reached beyond this machine (the edge, or a LAN address); on localhost nothing asks for it. __DEN_CLI__ web login prints a sign-in link; __DEN_CLI__ web sessions lists signed-in browsers.",
505
+ "Signing in is needed only when this server is reached from another machine (through the edge, or a LAN address); on localhost, and through the edge from this machine, nothing asks for it. __DEN_CLI__ web login prints a sign-in link; __DEN_CLI__ web sessions lists signed-in browsers.",
476
506
  keywords: ["sign in", "sign out", "login", "logout", "session", "browser"],
477
507
  scope: "user",
478
508
  control: {
@@ -498,6 +528,53 @@ export const DEN_SETTINGS = [
498
528
  },
499
529
  read: () => null,
500
530
  },
531
+ {
532
+ // R8, LK-0: the admin password is for sudo, and nothing else. Den asks
533
+ // for it only when a command it runs needs sudo; here is whether it is
534
+ // saved, and Forget. Reading never wakes the vault.
535
+ id: "cli.sudo",
536
+ category: "cli",
537
+ label: "Admin password",
538
+ description:
539
+ "Used only when a command Den runs needs sudo, and asked for only then. It is no sign-in: nothing else needs it.",
540
+ keywords: ["admin", "password", "sudo", "forget", "vault", "1password"],
541
+ scope: "user",
542
+ control: {
543
+ type: "custom",
544
+ render(host, ctx) {
545
+ const draw = (st) => {
546
+ const saved = st?.saved;
547
+ const where =
548
+ saved === "vault"
549
+ ? `Saved in ${st.vault?.label || "the vault"}`
550
+ : saved === "session"
551
+ ? "Saved for this session"
552
+ : "Not saved";
553
+ host.innerHTML =
554
+ '<output class="set-ro" id="set-sudo"></output>' +
555
+ (saved
556
+ ? ' <button type="button" class="btn" id="set-sudo-forget">Forget</button>'
557
+ : "");
558
+ host.querySelector("output").textContent = where;
559
+ host
560
+ .querySelector("#set-sudo-forget")
561
+ ?.addEventListener("click", () =>
562
+ ctx.shell
563
+ .api("POST", "/api/sudo-password/forget", {})
564
+ .then(load)
565
+ .catch(load),
566
+ );
567
+ };
568
+ const load = () =>
569
+ fetch("/api/sudo-password/status", { cache: "no-store" })
570
+ .then((r) => (r.ok ? r.json() : null))
571
+ .then(draw)
572
+ .catch(() => draw(null));
573
+ void load();
574
+ },
575
+ },
576
+ read: () => null,
577
+ },
501
578
 
502
579
  // ── Distribution (read-only) ───────────────────────────────────────────
503
580
  distRow(
@@ -547,6 +624,154 @@ export const DEN_SETTINGS = [
547
624
  ),
548
625
  ];
549
626
 
627
+ // ── Settings › Agents › Runtimes (AG-0.5) ────────────────────────────────
628
+
629
+ const escR = (s) =>
630
+ String(s ?? "").replace(
631
+ /[&<>"']/g,
632
+ (c) =>
633
+ ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[
634
+ c
635
+ ],
636
+ );
637
+
638
+ /** Where a runtime's path came from, as the row says it. */
639
+ export const RUNTIME_SOURCE = {
640
+ workspace: "workspace",
641
+ setting: "global",
642
+ "login-shell": "detected · login shell",
643
+ PATH: "detected · PATH",
644
+ "well-known": "detected · install directory",
645
+ "interactive-shell": "detected · interactive shell",
646
+ };
647
+
648
+ /** One runtime's row (r: GET /api/agents/runtimes' entry). */
649
+ export function runtimeRowHtml(r, scopeWs) {
650
+ const id = escR(r.id);
651
+ const name =
652
+ `<b>${escR(r.label || r.id)}</b>` +
653
+ (r.vendor ? ` <span class="rt-v">${escR(r.vendor)}</span>` : "") +
654
+ (r.denTools === false
655
+ ? ` <span class="set-tag" data-den-tools="coming">Den tools: coming</span>`
656
+ : "");
657
+ const set = r.source === "workspace" || r.source === "setting";
658
+ const status = r.available
659
+ ? `<span class="rt-ok">✓ ${escR(r.version ? r.version : "installed")}</span>`
660
+ : `<span class="rt-bad">${escR(set ? r.why : "Not installed")}</span>`;
661
+ const where = r.path
662
+ ? `<span class="den-mono rt-p">${escR(r.path)}</span> <span class="set-tag" data-src="${escR(r.source || "")}">${escR(RUNTIME_SOURCE[r.source] || r.source || "")}</span>`
663
+ : set
664
+ ? `<span class="set-tag" data-src="${escR(r.source)}">${escR(RUNTIME_SOURCE[r.source])}</span>`
665
+ : "";
666
+ // A path set at the scope shown can be cleared from here.
667
+ const mine = scopeWs ? r.source === "workspace" : r.source === "setting";
668
+ return (
669
+ `<div class="rt" data-runtime="${id}" data-available="${r.available ? "true" : "false"}">` +
670
+ `<div class="rt-n">${name}</div>` +
671
+ `<div class="rt-s">${status}</div>` +
672
+ `<div class="rt-w">${where}</div>` +
673
+ `<div class="rt-a"><button type="button" class="btn sm" data-set-path="${id}">Set path…</button>` +
674
+ (mine
675
+ ? ` <button type="button" class="btn sm" data-clear-path="${id}">Clear</button>`
676
+ : "") +
677
+ `</div>` +
678
+ `<form class="rt-f" data-path-form="${id}" hidden>` +
679
+ `<label class="rt-fl" for="rt-in-${id}">Path to ${escR(r.label || r.id)}${scopeWs ? ` for ${escR(scopeWs)}` : " (every workspace)"}</label>` +
680
+ `<input type="text" id="rt-in-${id}" class="den-mono" spellcheck="false" autocomplete="off" placeholder="/absolute/path/to/${escR(r.id === "den-claude" ? "claude" : r.id)}" value="${escR(mine ? r.path || "" : "")}">` +
681
+ `<button type="submit" class="btn sm primary">Save</button> <button type="button" class="btn sm" data-cancel-path>Cancel</button>` +
682
+ `<div class="rt-err" role="alert" hidden></div></form>` +
683
+ `</div>`
684
+ );
685
+ }
686
+
687
+ /** The Runtimes row: the list, a workspace selector, Set path… and Clear. */
688
+ function renderRuntimes(host, ctx) {
689
+ const wss = ctx.workspaces || [];
690
+ let ws = "";
691
+ const draw = (rows, note) => {
692
+ host.innerHTML =
693
+ `<div class="rt-scope"><label for="rt-ws">Paths for</label> <select id="rt-ws" data-focus-key="rt-ws">` +
694
+ `<option value="">every workspace (user.yml)</option>` +
695
+ wss
696
+ .map(
697
+ (w) =>
698
+ `<option value="${escR(w)}"${w === ws ? " selected" : ""}>${escR(w)} (workspaces.yml)</option>`,
699
+ )
700
+ .join("") +
701
+ `</select></div>` +
702
+ (note ? `<div class="set-note">${escR(note)}</div>` : "") +
703
+ `<div class="rt-list" role="list">${rows.map((r) => runtimeRowHtml(r, ws)).join("")}</div>`;
704
+ };
705
+ const load = () => {
706
+ // Under an invalid config the runtimes answer 422: say why, ask nothing.
707
+ if (ctx.shell?.configInvalid?.() === true)
708
+ return draw([], "Fix the config first: the runtimes are read from it.");
709
+ return ctx.shell
710
+ .api(
711
+ "GET",
712
+ `/api/agents/runtimes${ws ? `?workspace=${encodeURIComponent(ws)}` : ""}`,
713
+ )
714
+ .then((b) =>
715
+ // Only what has a CLI to find: generic is the persona's argv.
716
+ draw((b?.runtimes || []).filter((r) => r.setting)),
717
+ )
718
+ .catch((e) => draw([], e?.message || "The runtimes did not load."));
719
+ };
720
+ host.addEventListener("change", (e) => {
721
+ if (e.target.id !== "rt-ws") return;
722
+ ws = e.target.value;
723
+ void load();
724
+ });
725
+ host.addEventListener("click", async (e) => {
726
+ const b = e.target.closest("button");
727
+ if (!b) return;
728
+ const row = b.closest(".rt");
729
+ const form = row?.querySelector(".rt-f");
730
+ if (b.dataset.setPath && form) {
731
+ form.hidden = false;
732
+ form.querySelector("input").focus();
733
+ } else if (b.hasAttribute("data-cancel-path") && form) {
734
+ form.hidden = true;
735
+ } else if (b.dataset.clearPath) {
736
+ await savePath(b.dataset.clearPath, null, form);
737
+ }
738
+ });
739
+ host.addEventListener("submit", async (e) => {
740
+ e.preventDefault();
741
+ const form = e.target.closest(".rt-f");
742
+ if (!form) return;
743
+ const bin = form.querySelector("input").value.trim();
744
+ if (!bin) return;
745
+ await savePath(form.dataset.pathForm, bin, form);
746
+ });
747
+ async function savePath(id, bin, form) {
748
+ const err = form?.querySelector(".rt-err");
749
+ const btns = form ? [...form.querySelectorAll("button")] : [];
750
+ btns.forEach((x) => (x.disabled = true));
751
+ try {
752
+ await ctx.shell.api(
753
+ "POST",
754
+ `/api/agents/runtimes/${encodeURIComponent(id)}/path`,
755
+ {
756
+ scope: ws ? "workspace" : "global",
757
+ ...(ws ? { workspace: ws } : {}),
758
+ bin,
759
+ },
760
+ );
761
+ await load();
762
+ } catch (x) {
763
+ // Its refusal is shown here, under the field, not as a toast.
764
+ if (err) {
765
+ err.textContent = x?.message || "Not saved";
766
+ err.hidden = false;
767
+ }
768
+ } finally {
769
+ btns.forEach((x) => (x.disabled = false));
770
+ }
771
+ }
772
+ void load();
773
+ }
774
+
550
775
  /** Register Den's categories and rows with the shell's SETTINGS registry. */
551
776
  export function registerDenSettings(shell) {
552
777
  for (const c of DEN_SETTING_CATEGORIES) shell.registerSettingsCategory(c);