@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
@@ -17,6 +17,7 @@ import {
17
17
  type ContainerHealthTarget,
18
18
  } from "../../shared/stacks/compose-health.js";
19
19
  import { healthCheckFields, type HealthCheckType } from "./health-row.js";
20
+ import { notCheckedReason } from "../../shared/health/not-checked.js";
20
21
  import { hasRuntime, repoKind } from "../../shared/repo-kind.js";
21
22
  import * as http from "http";
22
23
  import * as net from "net";
@@ -162,6 +163,7 @@ import {
162
163
  saveSudoToVault,
163
164
  sudoRef,
164
165
  sudoVaultStatus,
166
+ sudoVaultWhere,
165
167
  SUDO_REF_NAME,
166
168
  } from "../../shared/auth/sudo-vault.js";
167
169
  import {
@@ -275,6 +277,10 @@ interface HealthResult {
275
277
  pending?: boolean;
276
278
  status?: number;
277
279
  error?: string;
280
+ /** A 3xx answer's target (MCP-2.4): ok null, not down. */
281
+ redirect?: string;
282
+ /** With ok null and nothing checked: why not (MCP-2.4). */
283
+ why?: string;
278
284
  /** Where the app opens for this env (open-in-new-tab), or null. */
279
285
  openUrl?: string | null;
280
286
  /** Extra named links for this env. */
@@ -716,12 +722,14 @@ const CONFIG_FREE_PAGES = new Set(["/settings", "/settings.html", "/logs"]);
716
722
  import {
717
723
  setSudoPassword,
718
724
  clearSudoPassword,
725
+ clearSudoShims,
719
726
  closeLockForThisRequest,
720
727
  inRequestLockScope,
721
728
  getSudoEnv,
722
729
  prewarmSudo,
723
730
  hasSudoPassword,
724
731
  hasPasswordlessSudo,
732
+ watchSudo,
725
733
  } from "./sudo-store.js";
726
734
  import {
727
735
  guardStdio,
@@ -730,6 +738,9 @@ import {
730
738
  requestShutdown,
731
739
  } from "./shutdown-guard.js";
732
740
 
741
+ /** This server saved the admin password to the vault or loaded it from there. */
742
+ let sudoInVault = false;
743
+
733
744
  // ── HTTP helpers ─────────────────────────────────────────────────────────────
734
745
  import { sweepLogs } from "../../shared/process/log-rotation.js";
735
746
  import {
@@ -1843,6 +1854,8 @@ async function handleRequest(
1843
1854
  compose: ComposeHealthTarget | null;
1844
1855
  /** A `type: container` check: answered from the shared listing. */
1845
1856
  container: ContainerHealthTarget | null;
1857
+ /** The repo has no manifest (the row is kept, unchecked). */
1858
+ noManifest?: boolean;
1846
1859
  meta: Pick<HealthResult, "runs" | "target" | "focused" | "workspaces">;
1847
1860
  }> = [];
1848
1861
  const focusName = getFocusedWorkspaceName();
@@ -1885,6 +1898,7 @@ async function handleRequest(
1885
1898
  org: orgOf(resolved.repoName, catalog),
1886
1899
  compose: null,
1887
1900
  container: null,
1901
+ noManifest: true,
1888
1902
  meta: {
1889
1903
  runs: "none",
1890
1904
  target: null,
@@ -2012,6 +2026,7 @@ async function handleRequest(
2012
2026
  expect,
2013
2027
  compose,
2014
2028
  container,
2029
+ noManifest,
2015
2030
  }) => {
2016
2031
  if (checkType !== "none") {
2017
2032
  const started = Date.now();
@@ -2078,6 +2093,13 @@ async function handleRequest(
2078
2093
  isEntrypoint,
2079
2094
  ...meta,
2080
2095
  ok: null,
2096
+ why: notCheckedReason({
2097
+ env,
2098
+ runs: meta.runs,
2099
+ target: meta.target,
2100
+ manifest: !noManifest,
2101
+ conventional,
2102
+ }),
2081
2103
  };
2082
2104
  };
2083
2105
  // Stream each result the moment it lands. The checks run concurrently but
@@ -2244,9 +2266,10 @@ async function handleRequest(
2244
2266
  Connection: "keep-alive",
2245
2267
  "X-Accel-Buffering": "no",
2246
2268
  });
2247
- const emit = (obj: unknown): void => {
2269
+ // LK-0: the command's sudo without the password is a needs-sudo event.
2270
+ const emit = watchSudo((obj: unknown): void => {
2248
2271
  res.write(`data: ${JSON.stringify(obj)}\n\n`);
2249
- };
2272
+ }, `${repoName}/${svcName} ${cmdName}`);
2250
2273
  // Every fail here is before anything ran: a refusal or a lookup miss.
2251
2274
  // Recorded, so failed attempts show in the activity trail (F2).
2252
2275
  const fail = (msg: string): void => {
@@ -3864,8 +3887,13 @@ async function handleRequest(
3864
3887
  }
3865
3888
 
3866
3889
  // ── API: sudo password ────────────────────────────────────────────────────
3890
+ // LK-0: the admin password is for sudo, and nothing else: no route asks
3891
+ // for it as an access check. A run that is about to run sudo without it
3892
+ // streams a needs-sudo event (watchSudo); the page asks, then runs again.
3867
3893
  // POST /api/sudo-password body: { password: string, saveToVault?: boolean }
3868
- // GET /api/sudo-password/status → { set: boolean }
3894
+ // POST /api/sudo-password/forget → { ok, vaultCleared, vaultError? }
3895
+ // GET /api/sudo-password/status → { set, passwordless,
3896
+ // saved: "vault" | "session" | null, vault: { configured, label } }
3869
3897
  // GET /api/sudo-password/vault-status → { backend, label, configured,
3870
3898
  // backendAvailable, reason?, hasStored, opAvailable (deprecated) }
3871
3899
  // POST /api/sudo-password/load-from-vault → { ok, reason?, message? }
@@ -3901,6 +3929,7 @@ async function handleRequest(
3901
3929
  try {
3902
3930
  await saveSudoToVault(password);
3903
3931
  savedToVault = true;
3932
+ sudoInVault = true;
3904
3933
  } catch (err) {
3905
3934
  vaultError = (err as Error).message;
3906
3935
  }
@@ -3912,10 +3941,51 @@ async function handleRequest(
3912
3941
  }
3913
3942
 
3914
3943
  if (pathname === "/api/sudo-password/status") {
3915
- // passwordless: NOPASSWD sudoer (or warm cache) — no admin login needed.
3944
+ // passwordless: NOPASSWD sudoer (or warm cache) — sudo needs none.
3945
+ // saved (Settings, LK-0): "vault" once this server saved it there or
3946
+ // loaded it from there, "session" when it is held in memory only.
3947
+ const set = hasSudoPassword();
3916
3948
  return sendJson(res, {
3917
- set: hasSudoPassword(),
3949
+ set,
3918
3950
  passwordless: hasPasswordlessSudo(),
3951
+ saved: sudoInVault ? "vault" : set ? "session" : null,
3952
+ vault: sudoVaultWhere(),
3953
+ });
3954
+ }
3955
+
3956
+ // Forget (Settings, LK-0): the password this server holds goes, and the
3957
+ // vault's copy when this server knows there is one (written empty, which
3958
+ // every read takes as none). sudo's own cache is dropped too.
3959
+ if (pathname === "/api/sudo-password/forget") {
3960
+ if (req.method !== "POST") {
3961
+ res.writeHead(405);
3962
+ res.end();
3963
+ return;
3964
+ }
3965
+ clearSudoPassword();
3966
+ try {
3967
+ execSync("sudo -k", {
3968
+ stdio: ["ignore", "ignore", "ignore"],
3969
+ timeout: CLI_SPAWN_TIMEOUT_MS,
3970
+ });
3971
+ } catch {
3972
+ /* best-effort */
3973
+ }
3974
+ let vaultCleared = false;
3975
+ let vaultError: string | undefined;
3976
+ if (sudoInVault) {
3977
+ try {
3978
+ await saveSudoToVault("");
3979
+ vaultCleared = true;
3980
+ sudoInVault = false;
3981
+ } catch (err) {
3982
+ vaultError = (err as Error).message;
3983
+ }
3984
+ }
3985
+ return sendJson(res, {
3986
+ ok: true,
3987
+ vaultCleared,
3988
+ ...(vaultError ? { vaultError } : {}),
3919
3989
  });
3920
3990
  }
3921
3991
 
@@ -3960,6 +4030,7 @@ async function handleRequest(
3960
4030
  return sendJson(res, { ok: false, reason: "stale" });
3961
4031
  }
3962
4032
  setSudoPassword(password);
4033
+ sudoInVault = true;
3963
4034
  return sendJson(res, { ok: true });
3964
4035
  }
3965
4036
 
@@ -4747,7 +4818,7 @@ async function stopDaemons(): Promise<void> {
4747
4818
  }
4748
4819
 
4749
4820
  async function handleShutdown(signal: string): Promise<void> {
4750
- clearSudoPassword();
4821
+ clearSudoShims();
4751
4822
  // The registry lives in the state dir, which is resolved from the
4752
4823
  // distribution root on every call: if that can no longer be found (den.yml
4753
4824
  // moved or removed while the server ran), still exit. Never product() here:
@@ -13,6 +13,11 @@
13
13
  * Start is disabled and the line says why and how to fix it (install it,
14
14
  * or set agents.runtimes.<id>.bin in the user.yml it names, or run
15
15
  * `<cli> agents doctor`). Nothing fails only after Start.
16
+ *
17
+ * AG-0.5: the runtimes come installed first (the server's order). One
18
+ * that runs as its own CLI, without Den's tools yet, says "Den tools:
19
+ * coming"; one not installed is disabled; generic is startable only for a
20
+ * persona with an argv template. Nothing is offered that can't start.
16
21
  */
17
22
 
18
23
  function escHtml(s) {
@@ -53,10 +58,28 @@ export function defaultRuntimeOf(personas, runtimes, personaId) {
53
58
  return (runtimes ?? []).find((r) => r.default)?.id ?? "";
54
59
  }
55
60
 
61
+ /** A catalog runtime the search didn't find (not a path someone set). */
62
+ const notFound = (r) =>
63
+ !r.available &&
64
+ r.denTools !== undefined &&
65
+ r.source !== "setting" &&
66
+ r.source !== "workspace";
67
+
68
+ /** Why runtime `r` can't start for persona `personaId`, or "" when it can. */
69
+ export function cantStart(personas, r, personaId, t) {
70
+ if (!r.available) return r.why || t("ag.unavailable");
71
+ // generic runs the persona's own argv: none, nothing to run.
72
+ if (r.id === "generic") {
73
+ const p = (personas ?? []).find((x) => x.id === personaId);
74
+ if (!p?.argv?.length) return t("ag.rt.needsArgv");
75
+ }
76
+ return "";
77
+ }
78
+
56
79
  /**
57
80
  * The runtime select's options. The first ("" = the persona's) reads
58
81
  * "<name> (persona default)"; one that can't run here is disabled and
59
- * says why.
82
+ * says why; one without Den's tools yet says so.
60
83
  */
61
84
  export function runtimeOptions(personas, runtimes, personaId, t) {
62
85
  const name = runtimeName(
@@ -69,12 +92,18 @@ export function runtimeOptions(personas, runtimes, personaId, t) {
69
92
  (runtimes ?? [])
70
93
  .map((r) => {
71
94
  const label = runtimeName(runtimes, r.id);
72
- return r.available
73
- ? `<option value="${escHtml(r.id)}">${escHtml(
74
- r.version ? `${label} · ${r.version}` : label,
95
+ const no = cantStart(personas, r, personaId, t);
96
+ const parts = [
97
+ label,
98
+ r.version,
99
+ r.denTools === false ? t("ag.rt.denTools") : "",
100
+ ];
101
+ return no
102
+ ? `<option value="${escHtml(r.id)}" disabled>${escHtml(
103
+ `${label} — ${notFound(r) ? t("ag.rt.notInstalled") : no}`,
75
104
  )}</option>`
76
- : `<option value="${escHtml(r.id)}" disabled>${escHtml(
77
- `${label} — ${r.why || t("ag.unavailable")}`,
105
+ : `<option value="${escHtml(r.id)}">${escHtml(
106
+ parts.filter(Boolean).join(" · "),
78
107
  )}</option>`;
79
108
  })
80
109
  .join("")
@@ -103,6 +132,13 @@ export function readiness(data, personaId, runtimeId, t) {
103
132
  const name = runtimeName(runtimes, r.id);
104
133
  const mono = (x) => `<span class="mono">${escHtml(x)}</span>`;
105
134
  if (r.available) {
135
+ const no = cantStart(personas, r, personaId, t);
136
+ if (no)
137
+ return {
138
+ ok: false,
139
+ tone: "warn",
140
+ html: escHtml(t("ag.rt.refused", { name, why: no })),
141
+ };
106
142
  if (!r.path) return { ok: true, tone: "none", html: "" };
107
143
  return {
108
144
  ok: true,
@@ -114,7 +150,10 @@ export function readiness(data, personaId, runtimeId, t) {
114
150
  : t("ag.rt.readyNoVer", { name }),
115
151
  ) +
116
152
  " " +
117
- mono(r.path),
153
+ mono(r.path) +
154
+ (r.denTools === false
155
+ ? ` <span class="tagx" data-den-tools="coming">${escHtml(t("ag.rt.denTools"))}</span> ${escHtml(t("ag.rt.ownCli"))}`
156
+ : ""),
118
157
  };
119
158
  }
120
159
  const file = data?.userConfig || "config/local/user.yml";