@monster-orange/den 0.2.8 → 0.3.0

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 (127) hide show
  1. package/CHANGELOG.md +1547 -0
  2. package/README.md +4 -2
  3. package/bin/den +2 -1
  4. package/docs/distributions.md +507 -4
  5. package/make/den.mk +19 -4
  6. package/make/journeys.mk +2 -1
  7. package/make/local_dev.mk +6 -13
  8. package/make/secrets-1password.mk +18 -0
  9. package/make/test.mk +1 -1
  10. package/package.json +9 -2
  11. package/schemas/den.plugin.v1.json +156 -0
  12. package/schemas/den.theme.v1.json +269 -0
  13. package/scripts/auth-configure.ts +16 -8
  14. package/scripts/heal-config.ts +0 -5
  15. package/scripts/setup-1password.ts +41 -19
  16. package/scripts/setup-secrets.ts +67 -101
  17. package/src/pillars/api/global-setup.ts +1 -1
  18. package/src/pillars/journeys/sandbox.ts +23 -18
  19. package/src/pillars/journeys/tools/browser-session.ts +2 -2
  20. package/src/scripts/setup-default-workspace.ts +2 -2
  21. package/src/shared/auth/headless-login.ts +1 -1
  22. package/src/shared/auth/sudo-vault.ts +93 -31
  23. package/src/shared/auth/token-providers/index.ts +7 -3
  24. package/src/shared/brand.ts +7 -0
  25. package/src/shared/config-error.ts +33 -3
  26. package/src/shared/constants.ts +3 -24
  27. package/src/shared/den-config.ts +141 -5
  28. package/src/shared/edge/config.ts +147 -21
  29. package/src/shared/edge/exec.ts +31 -0
  30. package/src/shared/edge/included.ts +169 -0
  31. package/src/shared/edge/render.ts +69 -14
  32. package/src/shared/edge/runtime.ts +128 -121
  33. package/src/shared/edge/status.ts +68 -8
  34. package/src/shared/edge/tls.ts +328 -0
  35. package/src/shared/edge/types.ts +25 -9
  36. package/src/shared/environment-manager/default-workspace.ts +16 -1
  37. package/src/shared/environment-manager/health.ts +73 -38
  38. package/src/shared/environment-manager/scope.ts +168 -0
  39. package/src/shared/environment-manager/service-runner.ts +789 -43
  40. package/src/shared/environment-manager/up-targets.ts +132 -0
  41. package/src/shared/health/rollup.ts +48 -0
  42. package/src/shared/open-url.ts +21 -2
  43. package/src/shared/org-sources.ts +78 -59
  44. package/src/shared/org.ts +53 -5
  45. package/src/shared/plugins/manifest.ts +406 -0
  46. package/src/shared/plugins/store.ts +358 -0
  47. package/src/shared/process/legacy-adopt.ts +285 -0
  48. package/src/shared/process/process-group.ts +202 -0
  49. package/src/shared/process/spawn-streaming.ts +7 -0
  50. package/src/shared/secrets/doppler.ts +1 -1
  51. package/src/shared/secrets/index.ts +279 -50
  52. package/src/shared/secrets/none.ts +56 -0
  53. package/src/shared/secrets/strategies.ts +202 -0
  54. package/src/shared/secrets/types.ts +1 -1
  55. package/src/shared/smoke/git-remote.ts +40 -0
  56. package/src/shared/stacks/manifest-commands.ts +1 -1
  57. package/src/shared/stacks/namespace.ts +178 -0
  58. package/src/shared/stacks/owners.ts +303 -0
  59. package/src/shared/stacks/render.ts +8 -3
  60. package/src/shared/stacks/runtime.ts +100 -16
  61. package/src/shared/stacks/status.ts +63 -12
  62. package/src/shared/strategies/registry.ts +89 -0
  63. package/src/shared/theme/contract.ts +538 -0
  64. package/src/shared/types/index.ts +12 -3
  65. package/src/ui/cli/create.ts +24 -7
  66. package/src/ui/cli/doctor-command.ts +131 -0
  67. package/src/ui/cli/edge-command.ts +56 -10
  68. package/src/ui/cli/flag-parser.ts +7 -2
  69. package/src/ui/cli/health-display.ts +25 -0
  70. package/src/ui/cli/index.ts +548 -60
  71. package/src/ui/cli/notice.ts +20 -0
  72. package/src/ui/cli/plugin-command.ts +849 -0
  73. package/src/ui/cli/stack-command.ts +63 -2
  74. package/src/ui/cli/test-runner.ts +11 -9
  75. package/src/ui/cli/theme-command.ts +136 -0
  76. package/src/ui/cli/web-command.ts +47 -23
  77. package/src/ui/mcp/api.ts +28 -2
  78. package/src/ui/mcp/scope.ts +22 -1
  79. package/src/ui/mcp/tools.ts +50 -7
  80. package/src/ui/server/appearance-prefs.ts +95 -0
  81. package/src/ui/server/daemon-reaper.ts +29 -1
  82. package/src/ui/server/engine-stamp.ts +83 -0
  83. package/src/ui/server/github-service.ts +23 -44
  84. package/src/ui/server/graph-page.ts +20 -0
  85. package/src/ui/server/graph-prefs.ts +44 -1
  86. package/src/ui/server/http-errors.ts +293 -0
  87. package/src/ui/server/http-helpers.ts +22 -3
  88. package/src/ui/server/index.ts +532 -51
  89. package/src/ui/server/public/appearance.js +531 -0
  90. package/src/ui/server/public/edge-page.js +248 -0
  91. package/src/ui/server/public/edge.html +251 -0
  92. package/src/ui/server/public/environments.html +65 -83
  93. package/src/ui/server/public/error-page.js +247 -0
  94. package/src/ui/server/public/error.html +260 -0
  95. package/src/ui/server/public/graph-model.js +332 -14
  96. package/src/ui/server/public/graph.css +141 -47
  97. package/src/ui/server/public/graph.js +223 -44
  98. package/src/ui/server/public/header.js +396 -600
  99. package/src/ui/server/public/health-fit.js +248 -0
  100. package/src/ui/server/public/health-rollup.js +128 -0
  101. package/src/ui/server/public/i18n/es.js +287 -0
  102. package/src/ui/server/public/i18n/ja.js +276 -0
  103. package/src/ui/server/public/index.html +139 -438
  104. package/src/ui/server/public/local-dev.html +3468 -4044
  105. package/src/ui/server/public/logs.html +71 -208
  106. package/src/ui/server/public/panel.js +14 -2
  107. package/src/ui/server/public/release.html +175 -389
  108. package/src/ui/server/public/services.html +596 -770
  109. package/src/ui/server/public/settings-page.js +759 -0
  110. package/src/ui/server/public/settings-registry.js +521 -0
  111. package/src/ui/server/public/settings.html +590 -0
  112. package/src/ui/server/public/shell.js +3869 -0
  113. package/src/ui/server/public/topbar.js +1413 -0
  114. package/src/ui/server/public/version-label.js +12 -0
  115. package/src/ui/server/public/workspace-picker.js +74 -34
  116. package/src/ui/server/readonly-routes.ts +12 -1
  117. package/src/ui/server/services-list.ts +94 -17
  118. package/src/ui/server/settings-distribution.ts +74 -0
  119. package/src/ui/server/shell-prefs.ts +192 -0
  120. package/src/ui/server/theme-routes.ts +292 -0
  121. package/src/ui/server/theme-runtime.ts +407 -0
  122. package/src/ui/server/ui-assets.ts +16 -1
  123. package/src/ui/server/ui-language.ts +50 -0
  124. package/src/ui/server/workspace-routes.ts +45 -3
  125. package/themes/den/den-theme.json +72 -0
  126. package/themes/den/favicon.ico +0 -0
  127. package/themes/den/mark.svg +6 -0
@@ -55,6 +55,13 @@ import {
55
55
  parseAddress,
56
56
  readManifestAnyBranch,
57
57
  } from "../../shared/environment-manager/workspaces.js";
58
+ import {
59
+ parseScope,
60
+ scopeKey,
61
+ scopePayload,
62
+ scopeView,
63
+ storedView,
64
+ } from "../../shared/environment-manager/scope.js";
58
65
  import { findServiceContainer } from "../../shared/environment-manager/service-container.js";
59
66
  import {
60
67
  resolveHistorySource,
@@ -82,6 +89,8 @@ import {
82
89
  loadDaemonsFromFile,
83
90
  pruneDeadDaemonsAsync,
84
91
  daemonRootOf,
92
+ daemonStaleReason,
93
+ runningContainerBasesAsync,
85
94
  isInteractiveServiceCommand,
86
95
  rewriteLegacyOpctlKillCommandForContainer,
87
96
  shouldDetachServiceCommand,
@@ -113,8 +122,12 @@ import {
113
122
  configErrorBody,
114
123
  isConfigError,
115
124
  } from "../../shared/config-error.js";
116
- import { readOrg } from "../../shared/org.js";
117
- import { engineVersion, getDistRoot } from "../../shared/paths.js";
125
+ import { readOrgView } from "../../shared/org.js";
126
+ import {
127
+ engineVersion,
128
+ getDistRoot,
129
+ getEngineRoot,
130
+ } from "../../shared/paths.js";
118
131
  import { enginePath } from "../../shared/engine.js";
119
132
  import {
120
133
  releaseService,
@@ -138,11 +151,16 @@ import {
138
151
  type Placement,
139
152
  } from "../../shared/manifest/placement.js";
140
153
  import {
141
- isOpSignedIn,
142
- hasSudoInVault,
143
154
  readSudoFromVault,
144
155
  saveSudoToVault,
156
+ sudoRef,
157
+ sudoVaultStatus,
158
+ SUDO_REF_NAME,
145
159
  } from "../../shared/auth/sudo-vault.js";
160
+ import {
161
+ notConfiguredMessage,
162
+ secretsConfigWarnings,
163
+ } from "../../shared/secrets/index.js";
146
164
  import { DEN_BANNER_LINES, DEN_SUBTITLE } from "../../shared/banner.js";
147
165
  import { actorLabel } from "../../shared/agent-actor.js";
148
166
  import {
@@ -155,7 +173,27 @@ import {
155
173
  } from "./audit.js";
156
174
  import { handleReadonlyRoutes } from "./readonly-routes.js";
157
175
  import { serveUiAsset } from "./ui-assets.js";
158
- import { graphPrefs, mergeGraphPrefs } from "./graph-prefs.js";
176
+ import { graphPrefs, patchStoredGraph } from "./graph-prefs.js";
177
+ import { graphPageHtml } from "./graph-page.js";
178
+ import { mergeShellPrefs, shellPrefs, storedShell } from "./shell-prefs.js";
179
+ import { distributionSettings, userWebPort } from "./settings-distribution.js";
180
+ import {
181
+ appearancePrefs,
182
+ mergeAppearancePrefs,
183
+ storedAppearance,
184
+ } from "./appearance-prefs.js";
185
+ import {
186
+ activeTheme,
187
+ handleThemeRoutes,
188
+ type ThemeRouteDeps,
189
+ } from "./theme-routes.js";
190
+ import { findTheme } from "./theme-runtime.js";
191
+ import {
192
+ UI_LANGUAGES,
193
+ isUiLanguage,
194
+ localizePage,
195
+ uiLanguage,
196
+ } from "./ui-language.js";
159
197
  import { singleFlight } from "./single-flight.js";
160
198
  import { graphOrder } from "./graph-order.js";
161
199
  import { clearReadCache, enableReadCache } from "../../shared/read-cache.js";
@@ -197,6 +235,13 @@ interface HealthResult {
197
235
  /** Where it runs for this env, and where a remote one is (R13). */
198
236
  runs: Placement["runs"];
199
237
  target: Placement["target"];
238
+ /** From the focused workspace: the header sorts these first. */
239
+ focused?: boolean;
240
+ /**
241
+ * The workspaces in view that define this service (focus first). More
242
+ * than one means a shared service: it is listed once, with its owners.
243
+ */
244
+ workspaces?: string[];
200
245
  }
201
246
 
202
247
  /**
@@ -255,9 +300,12 @@ const SERVICES_CACHE_TTL_MS = 2_000;
255
300
  const READ_CACHE_TTL_MS = 5_000;
256
301
  enableReadCache(READ_CACHE_TTL_MS);
257
302
  const servicesCache = singleFlight<ServicesList>(SERVICES_CACHE_TTL_MS);
258
- /** A workspace-scoped run covers a different service set than the default one. */
303
+ /**
304
+ * A workspace-scoped run covers a different service set than the default
305
+ * one, and the default one covers whatever the view scope has in view.
306
+ */
259
307
  function healthCacheKey(env: Environment, workspace: string | null): string {
260
- return `${env}|${workspace ?? "*"}`;
308
+ return `${env}|${workspace ?? `*:${scopeKey(scopeView())}`}`;
261
309
  }
262
310
  import {
263
311
  LOG_SWEEP_INTERVAL_MS,
@@ -389,6 +437,98 @@ function serverLog(msg: string, kind: string = "info", actor?: string): void {
389
437
  if (!SILENT) process.stdout.write(msg + "\n");
390
438
  }
391
439
 
440
+ // ── Theme ────────────────────────────────────────────────────────────────────
441
+ // The active theme (user's, else the org config's `theme:`, else den). A
442
+ // theme switch is a write of user.yml `appearance:` and a new /theme.css:
443
+ // nothing else (no edge, no services, no restart).
444
+ const themeDeps: ThemeRouteDeps = {
445
+ roots: () => ({ distRoot: getDistRoot(), engineRoot: getEngineRoot() }),
446
+ prefs: () => appearancePrefs(readUserConfig().appearance),
447
+ distributionTheme: () => readBrand().theme,
448
+ warn: (line) => serverLog(line, "warn"),
449
+ denFavicon: path.join(PUBLIC_DIR, "favicon.ico"),
450
+ };
451
+ // The pre-paint script in each page reads the user's mode and the theme's
452
+ // default mode from these placeholders, so the first paint is right.
453
+ // The UI language (R19): a page in another language than English loads
454
+ // its catalog before shell.js, so the first paint is in it.
455
+ setHtmlFill((html) => {
456
+ let mode = "system";
457
+ let def = "dark";
458
+ let lang = uiLanguage(undefined);
459
+ try {
460
+ const user = readUserConfig();
461
+ lang = uiLanguage(user.language);
462
+ mode = appearancePrefs(user.appearance).mode;
463
+ def = activeTheme(themeDeps)?.theme.default_mode ?? "dark";
464
+ } catch {
465
+ /* defaults */
466
+ }
467
+ let out = localizePage(html, lang)
468
+ .split("@@DEN_MODE@@")
469
+ .join(mode)
470
+ .split("@@DEN_DEFAULT_MODE@@")
471
+ .join(def);
472
+ // MD-2: every page says when the engine on disk is not the one running.
473
+ const engine = engineCheck();
474
+ if (engine.stale)
475
+ out = out.replace(
476
+ '<div id="den-shell"',
477
+ () =>
478
+ `<div id="den-shell" data-engine-stale="${escAttr(JSON.stringify(engineBanner(engine)))}"`,
479
+ );
480
+ return out;
481
+ });
482
+
483
+ // ── MD-2: the engine this server loaded, against the one on disk ─────────────
484
+ const ENGINE = engineWatch(getEngineRoot);
485
+ function engineCheck(): EngineCheck {
486
+ return ENGINE.check();
487
+ }
488
+ /** What the shell's banner and the error pages say on a mismatch. */
489
+ function engineBanner(c: EngineCheck): {
490
+ loaded: string | null;
491
+ onDisk: string | null;
492
+ message: string;
493
+ } {
494
+ return {
495
+ loaded: c.loaded.version,
496
+ onDisk: c.onDisk.version,
497
+ message: engineStaleMessage(c, product().cli),
498
+ };
499
+ }
500
+ function escAttr(v: string): string {
501
+ return v.replace(/&/g, "&amp;").replace(/"/g, "&quot;").replace(/</g, "&lt;");
502
+ }
503
+
504
+ /**
505
+ * R26a: the shell's error page for a failed page request (404 or 500).
506
+ * HEAD gets the status and headers only.
507
+ */
508
+ function sendErrorPage(
509
+ req: http.IncomingMessage,
510
+ res: http.ServerResponse,
511
+ data: Omit<ErrorPageData, "time" | "version" | "engine">,
512
+ ): void {
513
+ const engine = engineCheck();
514
+ const full: ErrorPageData = {
515
+ ...data,
516
+ time: new Date().toISOString(),
517
+ version: engine.loaded.version,
518
+ ...(engine.stale ? { engine: engineBanner(engine) } : {}),
519
+ };
520
+ if (res.headersSent) {
521
+ res.end();
522
+ return;
523
+ }
524
+ serveFile(
525
+ res,
526
+ path.join(PUBLIC_DIR, "error.html"),
527
+ (html) => fillErrorPage(html, full),
528
+ data.status,
529
+ );
530
+ }
531
+
392
532
  // Every audited action (exec, run, sync) lands in the log as a lifecycle
393
533
  // line; an agent's carries its label so the /logs page can mark it.
394
534
  setAuditSink((line, actor) =>
@@ -436,15 +576,35 @@ function allWorkspaceCheckouts(): ResolvedService[] {
436
576
  /**
437
577
  * API routes that answer whatever the config holds: the identity probe the
438
578
  * CLI and MCP use to find this server, its own log (where a config error is
439
- * also written), the changelog, and restarting it.
579
+ * also written), the changelog, and restarting it. And (0.3.0-dev.40, R26a's
580
+ * 422 page) what the shell and the Settings and Logs pages need, so the
581
+ * rail stays usable to reach the fix: the UI prefs (theme, mode, language,
582
+ * the rail's pins) and editor settings (/api/settings: user.yml, and the
583
+ * org config's Distribution rows read without validation), the installed
584
+ * themes, the view scope and a workspace's editor (workspaces.yml), and
585
+ * whether a sudo password is held. None of them reads org data.
440
586
  */
441
587
  const CONFIG_FREE_ROUTES = new Set([
442
588
  "/api/server-info",
443
589
  "/api/server-log",
444
590
  "/api/changelog",
445
591
  "/api/web/restart",
592
+ "/api/settings",
593
+ "/api/themes",
594
+ "/api/scope",
595
+ "/api/workspaces/settings",
596
+ "/api/sudo-password/status",
597
+ // The env picker on every page (falls back to local; a consumer's dev.40 finding).
598
+ "/api/environments",
446
599
  ]);
447
600
 
601
+ /**
602
+ * Pages that load whatever the config holds (0.3.0-dev.40): the rail's way
603
+ * to the fix (Settings, Logs). Any other page navigation with a config that
604
+ * fails validation gets the 422 "Config is invalid" page.
605
+ */
606
+ const CONFIG_FREE_PAGES = new Set(["/settings", "/settings.html", "/logs"]);
607
+
448
608
  // ── Sudo password store ───────────────────────────────────────────────────────
449
609
  import {
450
610
  setSudoPassword,
@@ -463,8 +623,23 @@ import {
463
623
  serveFile,
464
624
  send400,
465
625
  send404,
626
+ setHtmlFill,
466
627
  } from "./http-helpers.js";
467
628
  import { orgOf, listOrgs } from "../../shared/manifest/service-links.js";
629
+ import {
630
+ engineStaleMessage,
631
+ engineWatch,
632
+ type EngineCheck,
633
+ } from "./engine-stamp.js";
634
+ import {
635
+ fillErrorPage,
636
+ jsonApiErrors,
637
+ newRequestId,
638
+ pluginForPath,
639
+ routeTemplate,
640
+ wantsHtmlPage,
641
+ type ErrorPageData,
642
+ } from "./http-errors.js";
468
643
  import {
469
644
  readOrgContext,
470
645
  resolveAllWorkspaceOrgs,
@@ -484,14 +659,17 @@ import { EditorManager, SUPPORTED_LANGUAGES } from "./editor-manager.js";
484
659
  import { handleWorkspaceRoutes } from "./workspace-routes.js";
485
660
  import { isLifecycleExecCmd, streamLifecycleExec } from "./lifecycle-exec.js";
486
661
  import { readEdgeConfig, readWebPort } from "../../shared/edge/config.js";
662
+ import { edgeHttps } from "../../shared/edge/render.js";
487
663
  import {
488
664
  linksFor,
489
665
  openUrlFor,
490
666
  rehost,
667
+ type EdgeOrigin,
491
668
  type OpenLink,
492
669
  } from "../../shared/open-url.js";
493
670
 
494
671
  import { product } from "../../shared/product.js";
672
+ import { readBrand } from "../../shared/brand.js";
495
673
  /** config `web.port` (user > team), falling back to the built-in default. */
496
674
  function readWebPortSafe(): number {
497
675
  try {
@@ -605,6 +783,12 @@ async function handleRequest(
605
783
  ): Promise<void> {
606
784
  const url = new URL(req.url ?? "/", `http://localhost:${PORT}`);
607
785
  const pathname = url.pathname;
786
+ // R26: one id per request, on every response and on its failure's log line.
787
+ const requestId = requestIdOf(req);
788
+ if (!res.headersSent) res.setHeader("X-Request-Id", requestId);
789
+ // R26b: every /api error answers JSON, whatever the route sends.
790
+ if (pathname === "/api" || pathname.startsWith("/api/"))
791
+ jsonApiErrors(res, requestId);
608
792
  // A mutating request may change what /api/services would list (exec and
609
793
  // sync stream over GET, for EventSource).
610
794
  if (
@@ -630,7 +814,8 @@ async function handleRequest(
630
814
  res.on("finish", () => {
631
815
  const ms = dim(`${Date.now() - start}ms`);
632
816
  serverLog(
633
- ` ${logTimestamp()} ${method} ${pathname} → ${logStatus(res.statusCode)} ${ms}`,
817
+ ` ${logTimestamp()} ${method} ${pathname} → ${logStatus(res.statusCode)} ${ms}` +
818
+ (res.statusCode >= 400 ? dim(` req ${requestId}`) : ""),
634
819
  "request",
635
820
  );
636
821
  });
@@ -643,8 +828,31 @@ async function handleRequest(
643
828
  // catch can turn it into a 400, a 500 or an empty list. Pages still load.
644
829
  if (pathname.startsWith("/api/") && !CONFIG_FREE_ROUTES.has(pathname))
645
830
  assertDenConfigValid();
831
+ // A page navigation, likewise: the shell frame with the 422 page, not a
832
+ // page whose every call then fails (R26a). Static files never get here
833
+ // as a navigation (wantsHtmlPage needs Accept: text/html).
834
+ if (
835
+ wantsHtmlPage(req, pathname) &&
836
+ !CONFIG_FREE_PAGES.has(pathname) &&
837
+ (path.extname(pathname) === ".html" ||
838
+ !STATIC_EXTS.has(path.extname(pathname)))
839
+ )
840
+ assertDenConfigValid();
646
841
 
647
842
  // ── Static UI ──────────────────────────────────────────────────────────────
843
+ // The Settings page (R21). Local Dev's old settings panel link
844
+ // (/local-dev?settings=1) lands on its category there.
845
+ if (pathname === "/settings" || pathname === "/settings.html") {
846
+ return serveFile(res, path.join(PUBLIC_DIR, "settings.html"));
847
+ }
848
+ if (
849
+ (pathname === "/local-dev" || pathname === "/local-dev.html") &&
850
+ url.searchParams.has("settings")
851
+ ) {
852
+ res.writeHead(302, { Location: "/settings#editor" });
853
+ res.end();
854
+ return;
855
+ }
648
856
  // Local Dev is the default page when local environment is enabled.
649
857
  if (
650
858
  pathname === "/" ||
@@ -659,6 +867,20 @@ async function handleRequest(
659
867
  return serveFile(res, path.join(PUBLIC_DIR, "local-dev.html"));
660
868
  }
661
869
 
870
+ // The Graph page (R22): Local Dev's page in its full-width graph view.
871
+ if (pathname === "/graph" || pathname === "/graph.html") {
872
+ if (!isLocalEnabled()) {
873
+ res.writeHead(302, { Location: "/tests" });
874
+ res.end();
875
+ return;
876
+ }
877
+ return serveFile(
878
+ res,
879
+ path.join(PUBLIC_DIR, "local-dev.html"),
880
+ graphPageHtml,
881
+ );
882
+ }
883
+
662
884
  if (pathname === "/tests" || pathname === "/index.html") {
663
885
  return serveFile(res, path.join(PUBLIC_DIR, "index.html"));
664
886
  }
@@ -674,6 +896,11 @@ async function handleRequest(
674
896
  return serveFile(res, path.join(PUBLIC_DIR, "release.html"));
675
897
  }
676
898
 
899
+ // The Edge page (PL-SPEC §10): read-only, from /api/edge.
900
+ if (pathname === "/edge" || pathname === "/edge.html") {
901
+ return serveFile(res, path.join(PUBLIC_DIR, "edge.html"));
902
+ }
903
+
677
904
  if (pathname === "/logs") {
678
905
  return serveFile(res, path.join(PUBLIC_DIR, "logs.html"));
679
906
  }
@@ -682,6 +909,9 @@ async function handleRequest(
682
909
  return serveFile(res, path.join(PUBLIC_DIR, "header.js"));
683
910
  }
684
911
 
912
+ // The active theme as CSS, its mark and fonts, and the theme list.
913
+ if (handleThemeRoutes(req, res, pathname, themeDeps)) return;
914
+
685
915
  // The graph's modules (graph.js, panel.js, …) and vendored libraries (ELK).
686
916
  if (serveUiAsset(res, pathname, PUBLIC_DIR)) return;
687
917
 
@@ -935,9 +1165,11 @@ async function handleRequest(
935
1165
 
936
1166
  // ── API: health check ─────────────────────────────────────────────────────
937
1167
  /**
938
- * Workspaces a health run covers: one named workspace, else EVERY active one
939
- * (focus first). Health is machine-level — a service running under a
940
- * non-focused active workspace is still up, and its card must say so.
1168
+ * Workspaces a health run covers: one named workspace, else the view
1169
+ * scope's (every active one by default, focus first). Health is
1170
+ * machine-level: a service running under a non-focused active workspace
1171
+ * is still up, and its card must say so. The pages and `status` read the
1172
+ * same scope (src/shared/environment-manager/scope.ts).
941
1173
  */
942
1174
  function healthWorkspaces(
943
1175
  workspaceName: string | null,
@@ -946,12 +1178,7 @@ async function handleRequest(
946
1178
  const named = getWorkspaceByName(workspaceName);
947
1179
  return named ? [named] : [];
948
1180
  }
949
- const focus = getFocusedWorkspaceName();
950
- const names = [
951
- ...(focus ? [focus] : []),
952
- ...getActiveWorkspaces().filter((n) => n !== focus),
953
- ];
954
- return names.flatMap((n) => getWorkspaceByName(n) ?? []);
1181
+ return scopeView().workspaces.filter((w) => w.config);
955
1182
  }
956
1183
 
957
1184
  /**
@@ -978,11 +1205,16 @@ async function handleRequest(
978
1205
  string,
979
1206
  { svc: ResolvedService; ws: { name: string; config: WorkspaceConfig } }
980
1207
  >();
1208
+ const owners = new Map<string, string[]>();
981
1209
  for (const ws of workspaces)
982
- for (const svc of listWorkspaceServices(ws.config, catalog, ws.name))
1210
+ for (const svc of listWorkspaceServices(ws.config, catalog, ws.name)) {
983
1211
  // Modules and libraries have no runtime, so no health dot.
984
- if (!byAddress.has(svc.address) && hasRuntime(repoKind(svc.repoPath)))
1212
+ if (!hasRuntime(repoKind(svc.repoPath))) continue;
1213
+ if (!byAddress.has(svc.address))
985
1214
  byAddress.set(svc.address, { svc, ws });
1215
+ const o = owners.get(svc.address) ?? [];
1216
+ if (!o.includes(ws.name)) owners.set(svc.address, [...o, ws.name]);
1217
+ }
986
1218
  let services = [...byAddress.values()];
987
1219
  if (addressFilter) {
988
1220
  services = services.filter((s) => s.svc.address === addressFilter);
@@ -1011,11 +1243,19 @@ async function handleRequest(
1011
1243
  compose: ComposeHealthTarget | null;
1012
1244
  /** A `type: container` check: answered from the shared listing. */
1013
1245
  container: ContainerHealthTarget | null;
1014
- meta: Pick<HealthResult, "runs" | "target">;
1246
+ meta: Pick<HealthResult, "runs" | "target" | "focused" | "workspaces">;
1015
1247
  }> = [];
1248
+ const focusName = getFocusedWorkspaceName();
1016
1249
  let edgeDomain: string | null = null;
1250
+ // The links follow the edge's TLS: http on its http port for `tls: none`
1251
+ // (0.3.0-dev.40, DOC-1: the panel's Opens said https).
1252
+ let edgeOrigin: EdgeOrigin | undefined;
1017
1253
  try {
1018
- edgeDomain = readEdgeConfig().domain;
1254
+ const edge = readEdgeConfig();
1255
+ edgeDomain = edge.domain;
1256
+ edgeOrigin = edgeHttps(edge)
1257
+ ? { https: true, port: edge.ports?.https ?? 443 }
1258
+ : { https: false, port: edge.ports?.http ?? 80 };
1019
1259
  } catch {
1020
1260
  /* no edge configured: local links come from manifests only */
1021
1261
  }
@@ -1045,7 +1285,12 @@ async function handleRequest(
1045
1285
  org: orgOf(resolved.repoName, catalog),
1046
1286
  compose: null,
1047
1287
  container: null,
1048
- meta: { runs: "none", target: null },
1288
+ meta: {
1289
+ runs: "none",
1290
+ target: null,
1291
+ focused: ws.name === focusName,
1292
+ workspaces: owners.get(resolved.address) ?? [ws.name],
1293
+ },
1049
1294
  });
1050
1295
  continue;
1051
1296
  }
@@ -1123,6 +1368,7 @@ async function handleRequest(
1123
1368
  env,
1124
1369
  healthUrl: healthCfg && "url" in healthCfg ? healthCfg.url : null,
1125
1370
  edgeDomain,
1371
+ edgeOrigin,
1126
1372
  }),
1127
1373
  links: linksFor(manifest, resolved.subsystem, env),
1128
1374
  org: orgOf(resolved.repoName, catalog),
@@ -1130,7 +1376,12 @@ async function handleRequest(
1130
1376
  compose,
1131
1377
  container:
1132
1378
  healthCfg?.type === "container" ? containerTarget(healthCfg) : null,
1133
- meta: { runs: plan.placement.runs, target: plan.placement.target },
1379
+ meta: {
1380
+ runs: plan.placement.runs,
1381
+ target: plan.placement.target,
1382
+ focused: ws.name === focusName,
1383
+ workspaces: owners.get(resolved.address) ?? [ws.name],
1384
+ },
1134
1385
  });
1135
1386
  }
1136
1387
 
@@ -1367,7 +1618,8 @@ async function handleRequest(
1367
1618
  return sendJson(
1368
1619
  res,
1369
1620
  await servicesCache.get(
1370
- `${env}|${wsOverride ?? ""}`,
1621
+ // No workspace named: what the view scope has in view.
1622
+ `${env}|${wsOverride ?? `*:${scopeKey(scopeView())}`}`,
1371
1623
  () => buildServicesList(env, wsOverride),
1372
1624
  url.searchParams.get("refresh") === "1",
1373
1625
  ),
@@ -2441,10 +2693,10 @@ async function handleRequest(
2441
2693
  // `org` resolved (id defaults to edge.org) for the header's org label.
2442
2694
  const config = readDenConfig();
2443
2695
  // `default_org` (ORG-1a) is the default work org, `github_org`; `org`
2444
- // stays the instance org (readOrg).
2696
+ // stays the instance org (readOrgView).
2445
2697
  return sendJson(res, {
2446
2698
  ...config,
2447
- org: readOrg(),
2699
+ org: readOrgView(),
2448
2700
  default_org: config.github_org?.trim() || null,
2449
2701
  });
2450
2702
  }
@@ -2452,11 +2704,17 @@ async function handleRequest(
2452
2704
  // ── API: server info + staleness (drives the header "stale server" pill) ────
2453
2705
  if (pathname === "/api/server-info" && req.method === "GET") {
2454
2706
  const { stale, scanned } = computeStale();
2707
+ const engine = engineCheck();
2455
2708
  return sendJson(res, {
2456
2709
  pid: process.pid,
2457
2710
  port: PORT,
2458
- // Den's version: the MCP server compares it with the routes it needs.
2459
- version: engineVersion() ?? null,
2711
+ // The Den this server runs (its routes): the MCP server compares it
2712
+ // with the routes it needs. loaded and onDisk (MD-2): the engine at
2713
+ // startup and on disk now; engineStale when they differ.
2714
+ version: ENGINE.loaded.version ?? engineVersion() ?? null,
2715
+ loaded: engine.loaded,
2716
+ onDisk: engine.onDisk,
2717
+ engineStale: engine.stale,
2460
2718
  // Whose server this is: `<cli> web up|down`, `mcp connect` and
2461
2719
  // `service install` compare both with their own and never act on
2462
2720
  // another distribution's server (see cli/server-identity.ts).
@@ -2856,8 +3114,9 @@ async function handleRequest(
2856
3114
  // ── API: sudo password ────────────────────────────────────────────────────
2857
3115
  // POST /api/sudo-password body: { password: string, saveToVault?: boolean }
2858
3116
  // GET /api/sudo-password/status → { set: boolean }
2859
- // GET /api/sudo-password/vault-status → { opAvailable, hasStored }
2860
- // POST /api/sudo-password/load-from-vault → { ok, reason? }
3117
+ // GET /api/sudo-password/vault-status → { backend, label, configured,
3118
+ // backendAvailable, reason?, hasStored, opAvailable (deprecated) }
3119
+ // POST /api/sudo-password/load-from-vault → { ok, reason?, message? }
2861
3120
  if (pathname === "/api/sudo-password") {
2862
3121
  if (req.method !== "POST") {
2863
3122
  res.writeHead(405);
@@ -2909,11 +3168,10 @@ async function handleRequest(
2909
3168
  }
2910
3169
 
2911
3170
  // Advertises vault capability to the UI so it knows whether to show the
2912
- // "Save to 1Password" checkbox and whether auto-load is worth trying.
3171
+ // "Save to <backend>" checkbox and whether auto-load is worth trying. With
3172
+ // no secrets.refs.sudo it says "not configured" and spawns nothing.
2913
3173
  if (pathname === "/api/sudo-password/vault-status") {
2914
- const opAvailable = await isOpSignedIn();
2915
- const hasStored = opAvailable ? await hasSudoInVault() : false;
2916
- return sendJson(res, { opAvailable, hasStored });
3174
+ return sendJson(res, await sudoVaultStatus());
2917
3175
  }
2918
3176
 
2919
3177
  // Auto-unlock path: UI calls this when status.set is false. We read from
@@ -2926,6 +3184,13 @@ async function handleRequest(
2926
3184
  res.end();
2927
3185
  return;
2928
3186
  }
3187
+ if (!sudoRef()) {
3188
+ return sendJson(res, {
3189
+ ok: false,
3190
+ reason: "not-configured",
3191
+ message: notConfiguredMessage(SUDO_REF_NAME),
3192
+ });
3193
+ }
2929
3194
  const password = await readSudoFromVault();
2930
3195
  if (!password) {
2931
3196
  return sendJson(res, { ok: false, reason: "not-found" });
@@ -2947,9 +3212,43 @@ async function handleRequest(
2947
3212
  }
2948
3213
 
2949
3214
  // ── API: user settings ────────────────────────────────────────────────────
2950
- // GET /api/settings → { editor, editors, terminal, terminals, editor_overrides, languages }
2951
- // POST /api/settings body: { editor?, terminal?, editor_overrides?, graph? }
3215
+ // GET /api/settings → { editor, editors, terminal, terminals, editor_overrides, languages,
3216
+ // log_window_lines, graph, appearance, shell, distribution,
3217
+ // user_web_port, local_dev, cli_tabs, language, ui_languages }
3218
+ // POST /api/settings body: { editor?, terminal?, editor_overrides?, graph?, appearance?, shell?,
3219
+ // language? }
2952
3220
  // graph: { direction, group, density, panelWidth } (see graph-prefs.ts)
3221
+ // appearance: { theme: id | null, mode: system|dark|light } (appearance-prefs.ts)
3222
+ // shell: { pins, collapsed, hidden, order, dock, dockHeight } (shell-prefs.ts)
3223
+ // language: the UI's language, en | ja | es (ui-language.ts); null or
3224
+ // "en" clears it (English is the default)
3225
+ // It writes config/local/user.yml only. `distribution` is read-only: what
3226
+ // the org config (tracked) sets, for the Settings page's Distribution rows.
3227
+ // Every key POST accepts has a row in the Settings page's registry
3228
+ // (settings-registry.js; settings-inventory.test.ts checks).
3229
+ // The view scope: which active workspaces the pages, the health row and
3230
+ // `status` show. A view preference only (user.yml `view`): it changes no
3231
+ // edge route, container or service, and never the focus.
3232
+ if (pathname === "/api/scope") {
3233
+ if (req.method === "GET") return sendJson(res, scopePayload(scopeView()));
3234
+ if (req.method === "POST") {
3235
+ let body: { scope?: unknown };
3236
+ try {
3237
+ body = JSON.parse(await readBody(req)) as { scope?: unknown };
3238
+ } catch {
3239
+ return send400(res, "invalid JSON");
3240
+ }
3241
+ const parsed = parseScope(body.scope, getActiveWorkspaces());
3242
+ if (!parsed.ok) return send400(res, parsed.error);
3243
+ const user = readUserConfig();
3244
+ const view = storedView(parsed.value);
3245
+ if (view) user.view = view;
3246
+ else delete user.view;
3247
+ writeUserConfig(user);
3248
+ return sendJson(res, scopePayload(scopeView(parsed.value)));
3249
+ }
3250
+ }
3251
+
2953
3252
  if (pathname === "/api/settings") {
2954
3253
  if (req.method === "GET") {
2955
3254
  const user = readUserConfig();
@@ -2964,6 +3263,16 @@ async function handleRequest(
2964
3263
  languages: SUPPORTED_LANGUAGES,
2965
3264
  log_window_lines: user.log_window_lines ?? 20_000,
2966
3265
  graph: graphPrefs(user.graph),
3266
+ appearance: appearancePrefs(user.appearance),
3267
+ shell: shellPrefs(user.shell),
3268
+ distribution: distributionSettings(getDistRoot()),
3269
+ // Set from the command line (`<cli> config`), shown read-only.
3270
+ user_web_port: userWebPort(user as Record<string, unknown>),
3271
+ local_dev: user.local_dev !== false,
3272
+ cli_tabs: user.cli_tabs === true,
3273
+ // The UI's language (R19), and the ones it has catalogs for.
3274
+ language: uiLanguage(user.language),
3275
+ ui_languages: UI_LANGUAGES,
2967
3276
  });
2968
3277
  }
2969
3278
  if (req.method === "POST") {
@@ -2973,12 +3282,57 @@ async function handleRequest(
2973
3282
  terminal?: string | null;
2974
3283
  editor_overrides?: Record<string, string> | null;
2975
3284
  graph?: unknown;
3285
+ appearance?: unknown;
3286
+ shell?: unknown;
3287
+ language?: unknown;
2976
3288
  };
2977
3289
  const user = readUserConfig();
2978
- if (body.graph !== undefined) {
2979
- const g = mergeGraphPrefs(graphPrefs(user.graph), body.graph);
3290
+ // null clears (0.3.0-dev.32, a consumer's nit): a block or a key
3291
+ // set to null is removed from user.yml, and a block left empty
3292
+ // goes too, so setting a pref and clearing it again leaves the
3293
+ // file byte-identical.
3294
+ // The rail's pins and collapse (R14): a view preference only.
3295
+ if (body.shell === null) delete user.shell;
3296
+ else if (body.shell !== undefined) {
3297
+ const sh = mergeShellPrefs(shellPrefs(user.shell), body.shell);
3298
+ if (!sh.ok) return send400(res, sh.error);
3299
+ const stored = storedShell(sh.value);
3300
+ if (stored) user.shell = stored;
3301
+ else delete user.shell;
3302
+ }
3303
+ if (body.graph === null) delete user.graph;
3304
+ else if (body.graph !== undefined) {
3305
+ const g = patchStoredGraph(user.graph, body.graph);
2980
3306
  if (!g.ok) return send400(res, g.error);
2981
- user.graph = g.value;
3307
+ if (g.value) user.graph = g.value as UserConfig["graph"];
3308
+ else delete user.graph;
3309
+ }
3310
+ // A UI preference only: the page swaps /theme.css and data-mode.
3311
+ if (body.appearance === null) delete user.appearance;
3312
+ else if (body.appearance !== undefined) {
3313
+ const a = mergeAppearancePrefs(
3314
+ appearancePrefs(user.appearance),
3315
+ body.appearance,
3316
+ (id) => !!findTheme(themeDeps.roots(), id)?.ok,
3317
+ );
3318
+ if (!a.ok) return send400(res, a.error);
3319
+ const stored = storedAppearance(a.value);
3320
+ if (stored) user.appearance = stored;
3321
+ else delete user.appearance;
3322
+ }
3323
+ // The UI's language (R19): stored only when it isn't English.
3324
+ if (body.language !== undefined) {
3325
+ const clear =
3326
+ body.language === null ||
3327
+ body.language === "" ||
3328
+ body.language === "en";
3329
+ if (!clear && !isUiLanguage(body.language))
3330
+ return send400(
3331
+ res,
3332
+ `language must be one of ${UI_LANGUAGES.map((l) => `"${l}"`).join(", ")}`,
3333
+ );
3334
+ if (clear) delete user.language;
3335
+ else user.language = body.language as string;
2982
3336
  }
2983
3337
  if (body.editor === null || body.editor === "") {
2984
3338
  delete user.editor;
@@ -3024,6 +3378,9 @@ async function handleRequest(
3024
3378
  terminal: user.terminal ?? null,
3025
3379
  editor_overrides: user.editor_overrides ?? {},
3026
3380
  graph: graphPrefs(user.graph),
3381
+ appearance: appearancePrefs(user.appearance),
3382
+ shell: shellPrefs(user.shell),
3383
+ language: uiLanguage(user.language),
3027
3384
  });
3028
3385
  } catch (e) {
3029
3386
  return send400(res, `Invalid request: ${(e as Error).message}`);
@@ -3191,37 +3548,157 @@ async function handleRequest(
3191
3548
  }
3192
3549
  }
3193
3550
 
3551
+ // A route that throws, for the error frame's tests and screenshots only
3552
+ // (DEN_DEBUG_ROUTES=1): a page and an API call.
3553
+ if (
3554
+ process.env.DEN_DEBUG_ROUTES === "1" &&
3555
+ (pathname === "/_den/throw" || pathname === "/api/_den/throw")
3556
+ ) {
3557
+ throw new Error("a debug route threw (DEN_DEBUG_ROUTES)");
3558
+ }
3559
+
3194
3560
  // ── 404 ────────────────────────────────────────────────────────────────────
3561
+ sendNotFound(req, res, pathname, requestId);
3562
+ }
3563
+
3564
+ /**
3565
+ * No route answered. /api: the R26b JSON (an `engine_stale` code naming
3566
+ * both versions when the engine on disk is newer: its routes are the ones
3567
+ * that 404 here). A page: the shell's 404 page (R26a). Anything else (an
3568
+ * asset): plain text, as before.
3569
+ */
3570
+ function sendNotFound(
3571
+ req: http.IncomingMessage,
3572
+ res: http.ServerResponse,
3573
+ pathname: string,
3574
+ requestId: string,
3575
+ ): void {
3576
+ if (pathname === "/api" || pathname.startsWith("/api/")) {
3577
+ const engine = engineCheck();
3578
+ if (engine.stale)
3579
+ return sendJson(
3580
+ res,
3581
+ {
3582
+ ok: false,
3583
+ error: engineStaleMessage(engine, product().cli),
3584
+ code: "engine_stale",
3585
+ loaded: engine.loaded.version,
3586
+ onDisk: engine.onDisk.version,
3587
+ },
3588
+ 404,
3589
+ );
3590
+ return sendJson(
3591
+ res,
3592
+ {
3593
+ ok: false,
3594
+ error: `No such API route: ${req.method ?? "GET"} ${pathname}`,
3595
+ code: "not_found",
3596
+ },
3597
+ 404,
3598
+ );
3599
+ }
3600
+ if (wantsHtmlPage(req, pathname))
3601
+ return sendErrorPage(req, res, {
3602
+ status: 404,
3603
+ path: pathname,
3604
+ requestId,
3605
+ plugin: pluginForPath(pathname, ROOT),
3606
+ });
3195
3607
  res.writeHead(404, { "Content-Type": "text/plain" });
3196
3608
  res.end("Not found");
3197
3609
  }
3198
3610
 
3611
+ /** The request's id: assigned once (the handler and its catch share it). */
3612
+ const REQUEST_IDS = new WeakMap<http.IncomingMessage, string>();
3613
+ function requestIdOf(req: http.IncomingMessage): string {
3614
+ let id = REQUEST_IDS.get(req);
3615
+ if (!id) {
3616
+ id = newRequestId();
3617
+ REQUEST_IDS.set(req, id);
3618
+ }
3619
+ return id;
3620
+ }
3621
+
3199
3622
  const server = http.createServer((req, res) => {
3200
3623
  // An async handler with no catch turns any route throw into an unhandled
3201
3624
  // rejection — which CRASHES the whole server on modern Node (freezing every
3202
3625
  // health dot) and leaves the request hanging. Fail the one request instead.
3203
- handleRequest(req, res).catch((err) => {
3204
- // A config that fails validation is the user's to fix: name it to the
3205
- // caller as JSON (the {ok, error, code} frame), not a bare 500.
3626
+ handleRequest(req, res).catch((err: unknown) => {
3627
+ // R26: the log has the stack under the request id; the browser gets the
3628
+ // id, never the stack. The route is its template: no query values.
3629
+ const requestId = requestIdOf(req);
3630
+ const pathname = new URL(req.url ?? "/", "http://localhost").pathname;
3631
+ const route = routeTemplate(pathname);
3632
+ const method = req.method ?? "GET";
3633
+ // A config that fails validation is the user's to fix, not a crash: the
3634
+ // /api frame answers 422 config_invalid (jsonApiErrors adds requestId), a
3635
+ // page gets the error page saying so, anything else the message as text.
3206
3636
  if (isConfigError(err)) {
3207
3637
  serverLog(
3208
- ` ⚠ ${req.method} ${(req.url ?? "").split("?")[0]}: ${err.message}`,
3638
+ ` ⚠ ${method} ${route} (req ${requestId}): ${err.message}`,
3639
+ "error",
3209
3640
  );
3210
3641
  try {
3211
- if (!res.headersSent)
3212
- return sendJson(res, configErrorBody(err), CONFIG_ERROR_STATUS);
3213
- res.end();
3642
+ if (res.headersSent) {
3643
+ res.end();
3644
+ return;
3645
+ }
3646
+ if (pathname === "/api" || pathname.startsWith("/api/")) {
3647
+ sendJson(res, configErrorBody(err), CONFIG_ERROR_STATUS);
3648
+ return;
3649
+ }
3650
+ if (wantsHtmlPage(req, pathname)) {
3651
+ sendErrorPage(req, res, {
3652
+ status: CONFIG_ERROR_STATUS,
3653
+ path: pathname,
3654
+ message: err.message,
3655
+ issues: configErrorBody(err).issues,
3656
+ cli: product().cli,
3657
+ requestId,
3658
+ });
3659
+ return;
3660
+ }
3661
+ res.writeHead(CONFIG_ERROR_STATUS, { "Content-Type": "text/plain" });
3662
+ res.end(`${err.message} (request ${requestId})`);
3214
3663
  } catch {
3215
3664
  /* socket already gone */
3216
3665
  }
3217
3666
  return;
3218
3667
  }
3219
- console.error(`[server] ${req.method} ${req.url} failed:`, err);
3668
+ const stack =
3669
+ err instanceof Error ? (err.stack ?? err.message) : String(err);
3670
+ serverLog(
3671
+ ` ✗ ${method} ${route} failed (req ${requestId}): ${stack}`,
3672
+ "error",
3673
+ );
3674
+ if (SILENT)
3675
+ console.error(
3676
+ `[server] ${method} ${route} failed (req ${requestId}):`,
3677
+ err,
3678
+ );
3220
3679
  try {
3221
- if (!res.headersSent) {
3222
- res.writeHead(500, { "Content-Type": "text/plain" });
3680
+ if (res.headersSent) {
3681
+ res.end();
3682
+ return;
3683
+ }
3684
+ if (pathname === "/api" || pathname.startsWith("/api/")) {
3685
+ sendJson(
3686
+ res,
3687
+ {
3688
+ ok: false,
3689
+ error: `${method} ${route} failed on the server`,
3690
+ code: "internal",
3691
+ },
3692
+ 500,
3693
+ );
3694
+ return;
3695
+ }
3696
+ if (wantsHtmlPage(req, pathname)) {
3697
+ sendErrorPage(req, res, { status: 500, method, route, requestId });
3698
+ return;
3223
3699
  }
3224
- res.end("internal server error");
3700
+ res.writeHead(500, { "Content-Type": "text/plain" });
3701
+ res.end(`internal server error (request ${requestId})`);
3225
3702
  } catch {
3226
3703
  /* socket already gone */
3227
3704
  }
@@ -3279,6 +3756,8 @@ void (async () => {
3279
3756
  delay: (ms) => new Promise<void>((resolve) => setTimeout(resolve, ms)),
3280
3757
  retryDelayMs: DAEMON_RETRY_DELAY_MS,
3281
3758
  rootOf: daemonRootOf,
3759
+ staleReason: daemonStaleReason,
3760
+ runningContainers: runningContainerBasesAsync,
3282
3761
  });
3283
3762
  if (reaped.removed.length > 0)
3284
3763
  console.error(
@@ -3356,6 +3835,8 @@ server.listen(PORT, BIND_HOST, () => {
3356
3835
  console.log();
3357
3836
  console.log(` Web UI → http://${host}:${PORT}`);
3358
3837
  console.log();
3838
+ // Once per start: a secrets config that leaves Den's own features off.
3839
+ for (const w of secretsConfigWarnings()) serverLog(` ⚠ ${w}`);
3359
3840
  });
3360
3841
 
3361
3842
  // ── Graceful shutdown ─────────────────────────────────────────────────────────