@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
@@ -11,22 +11,24 @@
11
11
  * shell.js's denShell.runs, dev.30), where every page's
12
12
  * output lands; an empty dock links to the page that ran
13
13
  * the last run
14
- * lock (R8) #den-lock: 🔒 Locked / 🔓 Unlocked, the admin-password
15
- * form in a popover (Local Dev's sudo banner, moved)
14
+ * sudo (R8, LK-0) #den-lock: shown only while a run waits for the admin
15
+ * password because a command it runs needs sudo; it
16
+ * opens the password prompt (in the top layer)
16
17
  *
17
- * Exports for pages (through header.js): requireUnlock() (Local Dev's lock
18
- * rule, async: true when unlocked, else the lock popover opens), and
19
- * checkUnlock() and unlockWith(password) for a modal that asks inline
20
- * (LK-1), isUnlocked(), onLockChange(fn), setServiceRunner(fn) (the page
21
- * that runs Start and Restart itself), takePendingAction().
18
+ * Exports for pages (through header.js): askSudo(need) (the prompt for a
19
+ * run's needs-sudo event: true once the password is set), sudoRun() (a
20
+ * run's needs-sudo, then the action again after the password),
21
+ * unlockWith(password), setServiceRunner(fn) (the page that runs Start
22
+ * and Restart itself), takePendingAction().
22
23
  *
23
- * The lock (LK, 0.4.3): the page's own idea of it is never the last word.
24
- * When it thinks it's locked, an action asks the server again first
25
- * (another tab or device may have unlocked it, LK-2), then, when a
26
- * password is stored in the vault, loads it from there (LK-3, once a page,
27
- * on any page; a page view alone never wakes the vault). The popover is in
28
- * the top layer (a popover, or its own modal <dialog> when a modal is
29
- * open), so no modal ever covers it (LK-1).
24
+ * The admin password is for sudo, and nothing else (LK-0): no action asks
25
+ * for it up front, and it is no access check. The server says when a
26
+ * command is about to run sudo without it; then the page asks the server
27
+ * again first (another tab or device may have set it, LK-2), then, when a
28
+ * password is stored in the vault, loads it from there (LK-3; a page view
29
+ * alone never wakes the vault), and only then shows the field. The prompt
30
+ * is in the top layer (a popover, or its own modal <dialog> when a modal
31
+ * is open), so no modal ever covers it (LK-1).
30
32
  *
31
33
  * The pure parts (statusSummary, statusChip, problemCards, drawerGroups,
32
34
  * checkLine, rankCommands, runOf) take plain data, so tests need no
@@ -418,7 +420,6 @@ export function runOf(url, base = "http://localhost/") {
418
420
  // ── the UI ──────────────────────────────────────────────────────────────────
419
421
 
420
422
  const LS_LAST_RUN = "__DEN_CLI__-last-run";
421
- const LS_LOCK = "__DEN_CLI__-lock";
422
423
  const SS_PENDING = "__DEN_CLI__-pending-action";
423
424
  const KIND = ["page", "service", "app", "agent", "action", "setting"];
424
425
  const kindLabel = (k) =>
@@ -738,7 +739,7 @@ let serviceRunner = null;
738
739
 
739
740
  /**
740
741
  * The page that runs a service command itself (Local Dev: its runCmd, with
741
- * its lock rule and its output pane) registers `fn(cmd, svc)`; `svc` is
742
+ * its output pane) registers `fn(cmd, svc)`; `svc` is
742
743
  * `{ repo, service, address, workspaces }`.
743
744
  */
744
745
  export function setServiceRunner(fn) {
@@ -747,16 +748,15 @@ export function setServiceRunner(fn) {
747
748
 
748
749
  /**
749
750
  * Start (`up`) or Restart from the drawer or the palette: an explicit
750
- * action. The page's runner does it where there is one. Elsewhere the lock
751
- * rule applies first (requireUnlock), then the action is handed to Local
752
- * Dev, where the command runs with its output (a run stops when its page
751
+ * action. The page's runner does it where there is one. Elsewhere the
752
+ * action is handed to Local Dev, where the command runs with its output
753
+ * (and asks for the admin password only if it runs sudo, LK-0) (a run stops when its page
753
754
  * closes, so it can't run on a page that is about to be left): the page
754
755
  * stores it for this tab (sessionStorage, not a link anyone could open)
755
756
  * and Local Dev takes it once (takePendingAction).
756
757
  */
757
- export async function runServiceAction(cmd, svc) {
758
+ export function runServiceAction(cmd, svc) {
758
759
  if (serviceRunner) return serviceRunner(cmd, svc);
759
- if (!(await requireUnlock())) return;
760
760
  try {
761
761
  sessionStorage.setItem(
762
762
  SS_PENDING,
@@ -1119,83 +1119,32 @@ function trackRuns() {
1119
1119
  window.EventSource = DenEventSource;
1120
1120
  }
1121
1121
 
1122
- // ── lock chip (R8) ──────────────────────────────────────────────────────────
1123
-
1124
- const lock = {
1125
- known: false,
1126
- unlocked: false,
1127
- passwordless: false,
1122
+ // ── the admin password, for sudo (R8, LK-0) ───────────────────────────────
1123
+
1124
+ /*
1125
+ * LK-0 (Ward, 2026-11-06): the admin password is for sudo, and nothing
1126
+ * else. Nothing asks for it up front: a run that is about to run sudo
1127
+ * without it streams a `needs-sudo` event (the server decides), and only
1128
+ * then does the page ask, saying "<command> needs your admin password".
1129
+ * The chip (#den-lock) shows only while such an ask is waiting. Asking
1130
+ * goes: the server first (another tab or device may have set it, LK-2),
1131
+ * then the vault when a password is stored there (LK-3, Touch ID), then
1132
+ * the field, in the top layer (LK-1).
1133
+ */
1134
+ const sudo = {
1128
1135
  vault: {
1129
1136
  backend: "none",
1130
1137
  label: "",
1131
1138
  backendAvailable: false,
1132
1139
  hasStored: false,
1133
1140
  },
1134
- subs: new Set(),
1141
+ /** The ask that is waiting: { need, promise, resolve }, or null. */
1142
+ waiting: null,
1135
1143
  };
1136
1144
 
1137
- export function isUnlocked() {
1138
- return lock.unlocked;
1139
- }
1140
- /** fn(unlocked) on every change. */
1141
- export function onLockChange(fn) {
1142
- lock.subs.add(fn);
1143
- return () => lock.subs.delete(fn);
1144
- }
1145
-
1146
- /**
1147
- * Whether the lock is open, asking before saying no (never prompts): the
1148
- * server's status (LK-2), then, when a password is stored, the vault, once
1149
- * a page (LK-3). Resolves true when unlocked.
1150
- */
1151
- let rechecking = null;
1152
- let vaultTried = false;
1153
- export function checkUnlock() {
1154
- if (lock.unlocked) return Promise.resolve(true);
1155
- rechecking ??= (async () => {
1156
- const st = await getJson("/api/sudo-password/status", null);
1157
- if (st && (st.set || st.passwordless)) {
1158
- setUnlocked(true, !!st.passwordless && !st.set);
1159
- return true;
1160
- }
1161
- if (!vaultTried) {
1162
- await checkVault();
1163
- if (
1164
- !lock.unlocked &&
1165
- lock.vault.backendAvailable &&
1166
- lock.vault.hasStored
1167
- ) {
1168
- vaultTried = true;
1169
- const loaded = await getJson(
1170
- "/api/sudo-password/load-from-vault",
1171
- { ok: false },
1172
- { method: "POST" },
1173
- );
1174
- if (loaded.ok && !lock.unlocked) setUnlocked(true);
1175
- }
1176
- }
1177
- return lock.unlocked;
1178
- })().finally(() => {
1179
- rechecking = null;
1180
- });
1181
- return rechecking;
1182
- }
1183
-
1184
- /**
1185
- * Local Dev's lock rule, for every page (async): true when unlocked, after
1186
- * checkUnlock; otherwise the lock popover opens on its password field and
1187
- * the action doesn't run.
1188
- */
1189
- export async function requireUnlock() {
1190
- if (await checkUnlock()) return true;
1191
- openLock();
1192
- return false;
1193
- }
1194
-
1195
1145
  /**
1196
- * Unlock with `password` (the popover's form, or a modal's own field):
1197
- * { ok, savedToVault?, vaultError?, error? }. On success every page
1198
- * subscriber hears it (onLockChange). Never logs the password.
1146
+ * Set the admin password (the prompt's form): { ok, savedToVault?,
1147
+ * vaultError?, error? }. Never logs the password.
1199
1148
  */
1200
1149
  export async function unlockWith(password, { saveToVault = false } = {}) {
1201
1150
  if (!password) return { ok: false, error: t("lock.wrong") };
@@ -1207,8 +1156,7 @@ export async function unlockWith(password, { saveToVault = false } = {}) {
1207
1156
  });
1208
1157
  const data = await r.json().catch(() => ({}));
1209
1158
  if (r.ok) {
1210
- if (data.savedToVault) lock.vault.hasStored = true;
1211
- setUnlocked(true);
1159
+ if (data.savedToVault) sudo.vault.hasStored = true;
1212
1160
  return {
1213
1161
  ok: true,
1214
1162
  savedToVault: !!data.savedToVault,
@@ -1226,36 +1174,89 @@ export async function unlockWith(password, { saveToVault = false } = {}) {
1226
1174
  }
1227
1175
  }
1228
1176
 
1229
- function setUnlocked(unlocked, passwordless = false) {
1230
- const changed = lock.unlocked !== unlocked || !lock.known;
1231
- lock.known = true;
1232
- lock.unlocked = unlocked;
1233
- lock.passwordless = passwordless;
1234
- try {
1235
- sessionStorage.setItem(LS_LOCK, unlocked ? "1" : "0");
1236
- } catch {
1237
- /* a convenience only */
1238
- }
1177
+ /** What needed sudo, for the prompt: the command, else the step. */
1178
+ function needWhat(need) {
1179
+ return need?.command || need?.step || t("lock.cmd");
1180
+ }
1181
+
1182
+ /**
1183
+ * The run's server said sudo needs the password (`need` is its needs-sudo
1184
+ * event: { command?, step? }). Resolves true once the password is set
1185
+ * (from another tab, the vault or the field), false when the person
1186
+ * closes the prompt. One ask at a time: a second need while one waits
1187
+ * shares it.
1188
+ */
1189
+ export function askSudo(need = {}) {
1190
+ if (sudo.waiting) return sudo.waiting.promise;
1191
+ let resolve;
1192
+ const promise = new Promise((r) => (resolve = r));
1193
+ sudo.waiting = { need, promise, resolve };
1239
1194
  paintLock();
1240
- if (overlay?.kind === "lock") overlay.redraw?.();
1241
- if (changed) for (const fn of lock.subs) fn(unlocked);
1195
+ void (async () => {
1196
+ const st = await getJson("/api/sudo-password/status", null);
1197
+ if (st?.set) return settleSudo(true);
1198
+ await checkVault();
1199
+ if (sudo.vault.backendAvailable && sudo.vault.hasStored) {
1200
+ const loaded = await getJson(
1201
+ "/api/sudo-password/load-from-vault",
1202
+ { ok: false },
1203
+ { method: "POST" },
1204
+ );
1205
+ if (loaded.ok) return settleSudo(true);
1206
+ }
1207
+ if (sudo.waiting) openLock();
1208
+ })();
1209
+ return promise;
1210
+ }
1211
+
1212
+ function settleSudo(ok) {
1213
+ const w = sudo.waiting;
1214
+ if (!w) return;
1215
+ sudo.waiting = null;
1216
+ if (overlay?.kind === "lock") closeOverlay(false);
1217
+ paintLock();
1218
+ w.resolve(ok);
1219
+ }
1220
+
1221
+ /**
1222
+ * For a page's run (an EventSource): `see(msg)` on every message (true
1223
+ * for a needs-sudo one, which starts the ask), and `done(retry)` when the
1224
+ * run ends: after the password, the action runs again (resolves to what
1225
+ * `retry()` returns; undefined when nothing needed sudo, or the person
1226
+ * said no).
1227
+ */
1228
+ export function sudoRun() {
1229
+ let asked = null;
1230
+ return {
1231
+ see(msg) {
1232
+ if (msg?.type !== "needs-sudo") return false;
1233
+ asked ??= askSudo(msg);
1234
+ return true;
1235
+ },
1236
+ async done(retry) {
1237
+ const a = asked;
1238
+ asked = null;
1239
+ return a && (await a) ? retry() : undefined;
1240
+ },
1241
+ };
1242
1242
  }
1243
1243
 
1244
+ /** The chip: only while an ask is waiting (LK-0). */
1244
1245
  function paintLock() {
1245
1246
  const chip = $("den-lock");
1246
1247
  if (!chip) return;
1247
- chip.title = lock.unlocked
1248
- ? lock.passwordless
1249
- ? t("lock.nopw")
1250
- : t("lock.session")
1251
- : t("lock.title");
1248
+ const w = sudo.waiting;
1249
+ chip.hidden = !w;
1250
+ if (!w) {
1251
+ chip.removeAttribute("title");
1252
+ return;
1253
+ }
1254
+ const label = t("lock.waiting");
1255
+ chip.title = t("lock.needs", { cmd: needWhat(w.need) });
1252
1256
  chip.innerHTML =
1253
- `<span class="den-lock-ic" aria-hidden="true">${lock.unlocked ? "🔓" : "🔒"}</span>` +
1254
- `<span class="den-lbl-l">${esc(lock.unlocked ? t("lock.unlocked") : t("lock.locked"))}</span>`;
1255
- chip.setAttribute(
1256
- "aria-label",
1257
- lock.unlocked ? t("lock.unlocked") : t("lock.locked"),
1258
- );
1257
+ '<span class="den-lock-ic" aria-hidden="true">🔑</span>' +
1258
+ `<span class="den-lbl-l">${esc(label)}</span>`;
1259
+ chip.setAttribute("aria-label", label);
1259
1260
  }
1260
1261
 
1261
1262
  async function getJson(url, fallback, init) {
@@ -1267,28 +1268,24 @@ async function getJson(url, fallback, init) {
1267
1268
  }
1268
1269
  }
1269
1270
 
1270
- /**
1271
- * Local Dev's bootstrap, moved: the in-memory status first (fast), then,
1272
- * when locked, the vault's stored password in the background (op can be
1273
- * slow: Touch ID, a cold session) so nothing waits on it.
1274
- */
1275
1271
  /**
1276
1272
  * What the secrets backend can do here (`backend`, `backendAvailable`,
1277
- * `reason`): asked once a page, then kept. Not configured or unavailable
1278
- * means no save option and no auto-load.
1273
+ * `reason`): asked once a page, when sudo first needs the password (a
1274
+ * page view never wakes the vault), then kept. Not configured or
1275
+ * unavailable means no save option and no auto-load.
1279
1276
  */
1280
1277
  let vaultAsked = null;
1281
1278
  function checkVault() {
1282
1279
  // 0.4.1: it reads the config's secrets refs; an invalid one answers 422.
1283
1280
  if (window.denShell?.configInvalid?.() === true)
1284
- return Promise.resolve(lock.vault);
1281
+ return Promise.resolve(sudo.vault);
1285
1282
  vaultAsked ??= getJson("/api/sudo-password/vault-status", {
1286
1283
  backend: "none",
1287
1284
  label: "",
1288
1285
  backendAvailable: false,
1289
1286
  hasStored: false,
1290
1287
  }).then((v) => {
1291
- lock.vault = v;
1288
+ sudo.vault = v;
1292
1289
  // Only the save option changes: what's typed stays.
1293
1290
  const label = document.querySelector("#den-lock-pop .den-lock-save");
1294
1291
  const save = saveLabel();
@@ -1306,64 +1303,44 @@ function checkVault() {
1306
1303
  return vaultAsked;
1307
1304
  }
1308
1305
 
1309
- async function initLock() {
1310
- // A refusal page (SEC-1b): /api would answer the same refusal.
1311
- if (sh()?.apiOff?.() === true) return;
1312
- try {
1313
- if (sessionStorage.getItem(LS_LOCK) === "1") {
1314
- lock.unlocked = true;
1315
- paintLock();
1316
- }
1317
- } catch {
1318
- /* none */
1319
- }
1320
- const st = await getJson("/api/sudo-password/status", {
1321
- set: false,
1322
- passwordless: false,
1323
- });
1324
- if (st.set || st.passwordless) {
1325
- setUnlocked(true, !!st.passwordless && !st.set);
1326
- return;
1327
- }
1328
- setUnlocked(false);
1329
- // The vault (op) only where Local Dev always asked it, on load; any
1330
- // other page asks when the popover opens, so a page view never wakes op.
1331
- if (location.pathname !== "/local-dev") return;
1332
- await checkVault();
1333
- if (!lock.vault.backendAvailable || !lock.vault.hasStored || lock.unlocked)
1334
- return;
1335
- vaultTried = true;
1336
- const loaded = await getJson(
1337
- "/api/sudo-password/load-from-vault",
1338
- { ok: false },
1339
- { method: "POST" },
1340
- );
1341
- if (loaded.ok && !lock.unlocked) setUnlocked(true);
1342
- }
1343
-
1344
1306
  /**
1345
1307
  * Why there is no save option, when the sudo ref is not configured: the
1346
1308
  * server's reason, which names the distribution's real config file
1347
1309
  * (`configFile`, e.g. config/acme.yml), never a placeholder.
1348
1310
  */
1349
1311
  function notConfiguredNote() {
1350
- const v = lock.vault;
1312
+ const v = sudo.vault;
1351
1313
  if (!v || v.configured !== false || !v.configFile) return null;
1352
1314
  return t("lock.cfg", { file: v.configFile });
1353
1315
  }
1354
1316
 
1355
1317
  function saveLabel() {
1356
- if (!lock.vault.backendAvailable) return null;
1357
- return lock.vault.hasStored
1318
+ if (!sudo.vault.backendAvailable) return null;
1319
+ return sudo.vault.hasStored
1358
1320
  ? t("lock.update")
1359
- : t("lock.saveTo", { s: lock.vault.label || lock.vault.backend });
1321
+ : t("lock.saveTo", { s: sudo.vault.label || sudo.vault.backend });
1322
+ }
1323
+
1324
+ /**
1325
+ * "<command> needs your admin password", one string (so a language can
1326
+ * put the command where it reads right), then the step it ran in.
1327
+ */
1328
+ function needsHtml(need) {
1329
+ const step =
1330
+ need?.command && need?.step
1331
+ ? `<span class="den-note den-lock-step">${esc(need.step)}</span>`
1332
+ : "";
1333
+ return (
1334
+ `<b id="den-lock-t">${esc(t("lock.needs", { cmd: needWhat(need) }))}</b>` +
1335
+ step
1336
+ );
1360
1337
  }
1361
1338
 
1362
- function lockFormHtml() {
1339
+ function lockFormHtml(need) {
1363
1340
  const save = saveLabel();
1364
1341
  const note = notConfiguredNote();
1365
1342
  return (
1366
- `<form class="den-lock-form"><b id="den-lock-t">${esc(t("lock.title"))}</b>` +
1343
+ `<form class="den-lock-form">${needsHtml(need)}` +
1367
1344
  `<input type="password" id="den-lock-pw" placeholder="${esc(t("lock.pw"))}" aria-label="${esc(t("lock.pw"))}" autocomplete="current-password">` +
1368
1345
  `<label class="den-lock-save"${save ? "" : " hidden"}><input type="checkbox" id="den-lock-vault"> <span>${esc(save ?? "")}</span></label>` +
1369
1346
  '<span class="den-lock-err" id="den-lock-err" role="alert" hidden></span>' +
@@ -1375,14 +1352,15 @@ function lockFormHtml() {
1375
1352
 
1376
1353
  function openLock() {
1377
1354
  const chip = $("den-lock");
1378
- if (!chip) return;
1355
+ const w = sudo.waiting;
1356
+ if (!chip || !w) return;
1379
1357
  if (overlay?.kind === "lock") {
1380
1358
  overlay.el.querySelector("input,button")?.focus();
1381
1359
  return;
1382
1360
  }
1383
1361
  closeOverlay(false);
1384
1362
  sh()?.closeMenu?.();
1385
- if (!lock.unlocked) void checkVault();
1363
+ void checkVault();
1386
1364
  // LK-1: in the top layer, so no modal covers it. Under an open modal
1387
1365
  // <dialog> only another modal is reachable (a popover there is inert),
1388
1366
  // so it opens as its own modal; elsewhere it is a popover.
@@ -1390,24 +1368,8 @@ function openLock() {
1390
1368
  pop.className = "den-pop den-lock-pop";
1391
1369
  pop.id = "den-lock-pop";
1392
1370
  pop.setAttribute("role", "dialog");
1393
- pop.setAttribute("aria-label", t("lock.title"));
1394
- let forceForm = false;
1395
- const draw = () => {
1396
- if (!lock.unlocked || forceForm) pop.innerHTML = lockFormHtml();
1397
- else
1398
- pop.innerHTML =
1399
- `<div class="den-ml">${esc(t("lock.unlocked"))}</div>` +
1400
- `<div class="den-lock-desc">${esc(lock.passwordless ? `${t("lock.nopw")}.` : t("lock.sessionDesc"))}</div>` +
1401
- (lock.passwordless
1402
- ? ""
1403
- : `<button type="button" class="den-mi" data-reenter>${esc(t("lock.again"))}</button>`);
1404
- pop.querySelector("form")?.addEventListener("submit", onSubmit);
1405
- pop.querySelector("[data-reenter]")?.addEventListener("click", () => {
1406
- forceForm = true;
1407
- draw();
1408
- pop.querySelector("input")?.focus();
1409
- });
1410
- };
1371
+ pop.setAttribute("aria-labelledby", "den-lock-t");
1372
+ pop.innerHTML = lockFormHtml(w.need);
1411
1373
  const onSubmit = async (e) => {
1412
1374
  e.preventDefault();
1413
1375
  const input = $("den-lock-pw");
@@ -1419,21 +1381,20 @@ function openLock() {
1419
1381
  btn.textContent = t("lock.checking");
1420
1382
  btn.disabled = true;
1421
1383
  err.hidden = true;
1422
- const saveToVault = !!(lock.vault.backendAvailable && saveCb?.checked);
1384
+ const saveToVault = !!(sudo.vault.backendAvailable && saveCb?.checked);
1423
1385
  try {
1424
1386
  const res = await unlockWith(pw, { saveToVault });
1425
1387
  if (res.ok) {
1426
1388
  input.value = "";
1427
- forceForm = false;
1428
- closeOverlay(true);
1429
1389
  toast(
1430
1390
  saveToVault && res.vaultError
1431
1391
  ? t("lock.saveFailed", {
1432
- s: lock.vault.label || lock.vault.backend,
1392
+ s: sudo.vault.label || sudo.vault.backend,
1433
1393
  err: res.vaultError,
1434
1394
  })
1435
- : t("lock.session"),
1395
+ : t("lock.saved"),
1436
1396
  );
1397
+ settleSudo(true);
1437
1398
  return;
1438
1399
  }
1439
1400
  err.textContent = res.error;
@@ -1447,7 +1408,7 @@ function openLock() {
1447
1408
  }
1448
1409
  }
1449
1410
  };
1450
- draw();
1411
+ pop.querySelector("form").addEventListener("submit", onSubmit);
1451
1412
  document.body.appendChild(pop);
1452
1413
  const underModal = [...document.querySelectorAll("dialog")].some(
1453
1414
  (d) => d !== pop && d.open && d.matches(":modal"),
@@ -1461,37 +1422,37 @@ function openLock() {
1461
1422
  } catch {
1462
1423
  pop.setAttribute("open", "");
1463
1424
  }
1464
- // A cancel (Escape on a modal) closes it as the chip would.
1425
+ // Escape (or a modal's cancel) says no: the action doesn't run again
1426
+ // (its run already said why it failed), and the chip goes.
1427
+ const decline = () => {
1428
+ closeOverlay(true);
1429
+ settleSudo(false);
1430
+ };
1465
1431
  pop.addEventListener("cancel", (e) => {
1466
1432
  e.preventDefault();
1467
- closeOverlay(true);
1433
+ decline();
1468
1434
  });
1469
1435
  const r = chip.getBoundingClientRect();
1470
1436
  pop.style.top = `${r.bottom + 6}px`;
1471
1437
  pop.style.left = `${Math.max(8, Math.min(r.left, window.innerWidth - pop.offsetWidth - 8))}px`;
1472
1438
  chip.setAttribute("aria-expanded", "true");
1473
1439
  chip.setAttribute("aria-controls", pop.id);
1474
- const onDown = (e) => {
1475
- if (!pop.contains(e.target) && !chip.contains(e.target))
1476
- closeOverlay(false);
1477
- };
1478
1440
  pop.addEventListener("keydown", (e) => {
1479
1441
  if (e.key === "Escape") {
1480
1442
  e.preventDefault();
1481
1443
  e.stopPropagation();
1482
- closeOverlay(true);
1444
+ decline();
1483
1445
  }
1484
1446
  });
1485
- document.addEventListener("mousedown", onDown, true);
1486
1447
  overlay = {
1487
1448
  kind: "lock",
1488
1449
  el: pop,
1489
- redraw: draw,
1450
+ // Another overlay closing it leaves the ask waiting: the chip
1451
+ // stays, and opens the prompt again.
1490
1452
  close(refocus) {
1491
- document.removeEventListener("mousedown", onDown, true);
1492
1453
  pop.remove();
1493
1454
  chip.setAttribute("aria-expanded", "false");
1494
- if (refocus && chip.isConnected) chip.focus();
1455
+ if (refocus && chip.isConnected && !chip.hidden) chip.focus();
1495
1456
  },
1496
1457
  };
1497
1458
  (
@@ -1531,13 +1492,13 @@ export function initTopbar(opts = {}) {
1531
1492
  $("den-lock")?.addEventListener("click", () =>
1532
1493
  overlay?.kind === "lock" ? closeOverlay(true) : openLock(),
1533
1494
  );
1534
- // LK-2: back on this tab, the lock may have been opened elsewhere.
1495
+ // LK-2: back on this tab while an ask waits, the password may have
1496
+ // been given elsewhere (another tab or device): the action goes on.
1535
1497
  document.addEventListener("visibilitychange", () => {
1536
- if (document.visibilityState !== "visible" || lock.unlocked) return;
1498
+ if (document.visibilityState !== "visible" || !sudo.waiting) return;
1537
1499
  if (sh()?.apiOff?.() === true) return;
1538
1500
  void getJson("/api/sudo-password/status", null).then((st) => {
1539
- if (st && (st.set || st.passwordless))
1540
- setUnlocked(true, !!st.passwordless && !st.set);
1501
+ if (st?.set) settleSudo(true);
1541
1502
  });
1542
1503
  });
1543
1504
  document.addEventListener("keydown", (e) => {
@@ -1549,7 +1510,6 @@ export function initTopbar(opts = {}) {
1549
1510
  closeOverlay(true);
1550
1511
  }
1551
1512
  });
1552
- void initLock();
1553
1513
  }
1554
1514
 
1555
1515
  /** For tests and plugins: open an overlay by name. */
@@ -20,14 +20,20 @@
20
20
  */
21
21
  import * as http from "http";
22
22
  import { readEdgeConfig } from "../../shared/edge/config.js";
23
- import { edgeStatus, type EdgeStatus } from "../../shared/edge/status.js";
23
+ import {
24
+ edgeStatus,
25
+ withHttpsServing,
26
+ type EdgeStatus,
27
+ } from "../../shared/edge/status.js";
24
28
  import { getWorkspaceByName } from "../../shared/environment-manager/workspaces.js";
25
29
  import {
30
+ emptyStacksReason,
26
31
  loadStackContext,
27
32
  resolveStackWorkspaces,
28
33
  stackStatuses,
29
34
  type StackStatus,
30
35
  } from "../../shared/stacks/status.js";
36
+ import { readStacks } from "../../shared/stacks/config.js";
31
37
  import { send400, sendJson } from "./http-helpers.js";
32
38
  import { recentActions, type AuditEntry } from "./audit.js";
33
39
  import { WORKSPACE_NAME_RE } from "../../shared/agent-actor.js";
@@ -50,8 +56,11 @@ export interface ReadonlyRouteDeps {
50
56
  workspace: string;
51
57
  stackWorkspace: string;
52
58
  stacks: StackStatus[];
59
+ /** With no stacks: why the list is empty (MCP-2.4). */
60
+ why?: string;
53
61
  };
54
- edge: () => EdgeStatus;
62
+ /** The edge's status; https.serving probed (MCP-2.5). */
63
+ edge: () => EdgeStatus | Promise<EdgeStatus>;
55
64
  /** The edge block reads (no Docker asked); throws with the reason if not. */
56
65
  edgeConfig: () => void;
57
66
  actions: () => AuditEntry[];
@@ -63,13 +72,28 @@ export const defaultReadonlyRouteDeps: ReadonlyRouteDeps = {
63
72
  stacks: ({ workspace, health }) => {
64
73
  const ws = resolveStackWorkspaces(workspace);
65
74
  const ctx = loadStackContext(ws.denWorkspace);
75
+ const stacks = stackStatuses(ctx, { health });
66
76
  return {
67
77
  workspace: ws.denWorkspace,
68
78
  stackWorkspace: ws.stackWorkspace,
69
- stacks: stackStatuses(ctx, { health }),
79
+ stacks,
80
+ ...(stacks.length
81
+ ? {}
82
+ : {
83
+ why: emptyStacksReason({
84
+ workspace: ws.denWorkspace,
85
+ workspaceExists: !!getWorkspaceByName(ws.denWorkspace),
86
+ configStacks: Object.keys(
87
+ readStacks(undefined, { manifests: false }),
88
+ ).length,
89
+ }),
90
+ }),
70
91
  };
71
92
  },
72
- edge: () => edgeStatus(readEdgeConfig()),
93
+ edge: () => {
94
+ const cfg = readEdgeConfig();
95
+ return withHttpsServing(edgeStatus(cfg), cfg);
96
+ },
73
97
  edgeConfig: () => void readEdgeConfig(),
74
98
  actions: recentActions,
75
99
  securityPlan: async ({ env, mode, service }) => {
@@ -195,7 +219,7 @@ export async function handleReadonlyRoutes(
195
219
  sendJson(res, { configured: true });
196
220
  return true;
197
221
  }
198
- sendJson(res, { configured: true, ...deps.edge() });
222
+ sendJson(res, { configured: true, ...(await deps.edge()) });
199
223
  } catch (e) {
200
224
  // No edge configured is a state, not a server error.
201
225
  sendJson(res, { configured: false, error: (e as Error).message });
@@ -612,7 +612,7 @@ export const AGENT_FORBIDDEN: readonly string[] = [
612
612
  ];
613
613
 
614
614
  /**
615
- * Routes an agent's token may read but never change: the lock (R8).
615
+ * Routes an agent's token may read but never change: the admin password (R8).
616
616
  * Privileged actions stay with people (AG-5); its status is a read.
617
617
  */
618
618
  export const AGENT_READ_ONLY: readonly string[] = [