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