@monster-orange/den 0.4.3 → 0.4.5

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 (85) hide show
  1. package/CHANGELOG.md +348 -0
  2. package/README.md +12 -11
  3. package/docs/distributions.md +438 -91
  4. package/docs/reference.md +8 -3
  5. package/package.json +1 -1
  6. package/src/shared/agents/catalog-runtime.ts +178 -0
  7. package/src/shared/agents/catalog.ts +343 -0
  8. package/src/shared/agents/den-claude.ts +14 -18
  9. package/src/shared/agents/runtime-search.ts +142 -15
  10. package/src/shared/agents/runtime.ts +18 -1
  11. package/src/shared/agents/runtimes.ts +23 -1
  12. package/src/shared/auth/sudo-vault.ts +131 -11
  13. package/src/shared/cli-command.ts +186 -0
  14. package/src/shared/confirm-code.ts +34 -0
  15. package/src/shared/den-config.ts +64 -3
  16. package/src/shared/dev-metrics/emit.ts +31 -1
  17. package/src/shared/edge/config.ts +39 -0
  18. package/src/shared/edge/render.ts +92 -18
  19. package/src/shared/edge/runtime.ts +57 -1
  20. package/src/shared/edge/status.ts +134 -0
  21. package/src/shared/edge/types.ts +11 -1
  22. package/src/shared/environment-manager/health.ts +82 -8
  23. package/src/shared/environment-manager/workspaces.ts +54 -0
  24. package/src/shared/github-app.ts +94 -0
  25. package/src/shared/github-device.ts +286 -0
  26. package/src/shared/health/not-checked.ts +42 -0
  27. package/src/shared/health/rollup.ts +4 -0
  28. package/src/shared/process/spawn-streaming.ts +8 -3
  29. package/src/shared/secrets/onepassword.ts +27 -4
  30. package/src/shared/sessions/client.ts +5 -1
  31. package/src/shared/sessions/host.ts +29 -1
  32. package/src/shared/sessions/protocol.ts +6 -1
  33. package/src/shared/sessions/types.ts +6 -0
  34. package/src/shared/stacks/status.ts +24 -0
  35. package/src/shared/strategies/registry.ts +5 -0
  36. package/src/shared/types/index.ts +6 -0
  37. package/src/shared/web-owner.ts +91 -0
  38. package/src/shared/web-session.ts +135 -1
  39. package/src/ui/cli/agent-ops.ts +210 -19
  40. package/src/ui/cli/agents-command.ts +50 -21
  41. package/src/ui/cli/edge-command.ts +16 -3
  42. package/src/ui/cli/flag-parser.ts +1 -1
  43. package/src/ui/cli/health-display.ts +4 -1
  44. package/src/ui/cli/web-command.ts +512 -33
  45. package/src/ui/mcp/scope.ts +23 -3
  46. package/src/ui/mcp/server.ts +31 -7
  47. package/src/ui/mcp/tools.ts +327 -20
  48. package/src/ui/mcp/views.ts +450 -0
  49. package/src/ui/server/agent-routes.ts +118 -28
  50. package/src/ui/server/device-flows.ts +274 -0
  51. package/src/ui/server/edge-exposure.ts +16 -4
  52. package/src/ui/server/github-routes.ts +321 -0
  53. package/src/ui/server/http-errors.ts +10 -0
  54. package/src/ui/server/index.ts +540 -33
  55. package/src/ui/server/login-waits.ts +203 -0
  56. package/src/ui/server/pair-requests.ts +237 -0
  57. package/src/ui/server/pair-routes.ts +196 -0
  58. package/src/ui/server/public/agent-start.js +46 -7
  59. package/src/ui/server/public/agents-page.js +232 -97
  60. package/src/ui/server/public/agents-section.js +3 -5
  61. package/src/ui/server/public/agents.html +12 -28
  62. package/src/ui/server/public/error-page.js +452 -40
  63. package/src/ui/server/public/error.html +59 -0
  64. package/src/ui/server/public/graph-model.js +2 -0
  65. package/src/ui/server/public/header.js +2 -3
  66. package/src/ui/server/public/health-rollup.js +20 -8
  67. package/src/ui/server/public/i18n/es.js +89 -21
  68. package/src/ui/server/public/i18n/ja.js +88 -21
  69. package/src/ui/server/public/index.html +17 -4
  70. package/src/ui/server/public/local-dev.html +44 -21
  71. package/src/ui/server/public/qr.js +317 -0
  72. package/src/ui/server/public/services.html +21 -5
  73. package/src/ui/server/public/settings-page.js +10 -2
  74. package/src/ui/server/public/settings-registry.js +617 -1
  75. package/src/ui/server/public/settings.html +90 -1
  76. package/src/ui/server/public/shell.js +151 -33
  77. package/src/ui/server/public/topbar.js +170 -206
  78. package/src/ui/server/public/workspace-picker.js +1 -1
  79. package/src/ui/server/readonly-routes.ts +29 -5
  80. package/src/ui/server/request-guard.ts +103 -36
  81. package/src/ui/server/route-classes.ts +116 -4
  82. package/src/ui/server/session-routes.ts +9 -1
  83. package/src/ui/server/sudo-store.ts +170 -6
  84. package/src/ui/server/ui-assets.ts +2 -0
  85. package/src/ui/server/workspace-routes.ts +10 -3
@@ -13,6 +13,11 @@
13
13
  * Start is disabled and the line says why and how to fix it (install it,
14
14
  * or set agents.runtimes.<id>.bin in the user.yml it names, or run
15
15
  * `<cli> agents doctor`). Nothing fails only after Start.
16
+ *
17
+ * AG-0.5: the runtimes come installed first (the server's order). One
18
+ * that runs as its own CLI, without Den's tools yet, says "Den tools:
19
+ * coming"; one not installed is disabled; generic is startable only for a
20
+ * persona with an argv template. Nothing is offered that can't start.
16
21
  */
17
22
 
18
23
  function escHtml(s) {
@@ -53,10 +58,28 @@ export function defaultRuntimeOf(personas, runtimes, personaId) {
53
58
  return (runtimes ?? []).find((r) => r.default)?.id ?? "";
54
59
  }
55
60
 
61
+ /** A catalog runtime the search didn't find (not a path someone set). */
62
+ const notFound = (r) =>
63
+ !r.available &&
64
+ r.denTools !== undefined &&
65
+ r.source !== "setting" &&
66
+ r.source !== "workspace";
67
+
68
+ /** Why runtime `r` can't start for persona `personaId`, or "" when it can. */
69
+ export function cantStart(personas, r, personaId, t) {
70
+ if (!r.available) return r.why || t("ag.unavailable");
71
+ // generic runs the persona's own argv: none, nothing to run.
72
+ if (r.id === "generic") {
73
+ const p = (personas ?? []).find((x) => x.id === personaId);
74
+ if (!p?.argv?.length) return t("ag.rt.needsArgv");
75
+ }
76
+ return "";
77
+ }
78
+
56
79
  /**
57
80
  * The runtime select's options. The first ("" = the persona's) reads
58
81
  * "<name> (persona default)"; one that can't run here is disabled and
59
- * says why.
82
+ * says why; one without Den's tools yet says so.
60
83
  */
61
84
  export function runtimeOptions(personas, runtimes, personaId, t) {
62
85
  const name = runtimeName(
@@ -69,12 +92,18 @@ export function runtimeOptions(personas, runtimes, personaId, t) {
69
92
  (runtimes ?? [])
70
93
  .map((r) => {
71
94
  const label = runtimeName(runtimes, r.id);
72
- return r.available
73
- ? `<option value="${escHtml(r.id)}">${escHtml(
74
- r.version ? `${label} · ${r.version}` : label,
95
+ const no = cantStart(personas, r, personaId, t);
96
+ const parts = [
97
+ label,
98
+ r.version,
99
+ r.denTools === false ? t("ag.rt.denTools") : "",
100
+ ];
101
+ return no
102
+ ? `<option value="${escHtml(r.id)}" disabled>${escHtml(
103
+ `${label} — ${notFound(r) ? t("ag.rt.notInstalled") : no}`,
75
104
  )}</option>`
76
- : `<option value="${escHtml(r.id)}" disabled>${escHtml(
77
- `${label} — ${r.why || t("ag.unavailable")}`,
105
+ : `<option value="${escHtml(r.id)}">${escHtml(
106
+ parts.filter(Boolean).join(" · "),
78
107
  )}</option>`;
79
108
  })
80
109
  .join("")
@@ -103,6 +132,13 @@ export function readiness(data, personaId, runtimeId, t) {
103
132
  const name = runtimeName(runtimes, r.id);
104
133
  const mono = (x) => `<span class="mono">${escHtml(x)}</span>`;
105
134
  if (r.available) {
135
+ const no = cantStart(personas, r, personaId, t);
136
+ if (no)
137
+ return {
138
+ ok: false,
139
+ tone: "warn",
140
+ html: escHtml(t("ag.rt.refused", { name, why: no })),
141
+ };
106
142
  if (!r.path) return { ok: true, tone: "none", html: "" };
107
143
  return {
108
144
  ok: true,
@@ -114,7 +150,10 @@ export function readiness(data, personaId, runtimeId, t) {
114
150
  : t("ag.rt.readyNoVer", { name }),
115
151
  ) +
116
152
  " " +
117
- mono(r.path),
153
+ mono(r.path) +
154
+ (r.denTools === false
155
+ ? ` <span class="tagx" data-den-tools="coming">${escHtml(t("ag.rt.denTools"))}</span> ${escHtml(t("ag.rt.ownCli"))}`
156
+ : ""),
118
157
  };
119
158
  }
120
159
  const file = data?.userConfig || "config/local/user.yml";
@@ -8,26 +8,30 @@
8
8
  * the scrollback first, then the live output; keystrokes go to the
9
9
  * session and the pane's size follows the pane. Closing a pane (or
10
10
  * leaving the page) detaches: the session keeps running. Stop (an agent
11
- * only, behind the lock) is the Workspaces page's own agent route.
11
+ * only) is the Workspaces page's own agent route.
12
12
  *
13
13
  * The open panes are the URL (?open=<id>,<id>), so a reload, Back or a
14
14
  * link reopens them, each reattaching with its scrollback.
15
15
  *
16
+ * The size. A session has one PTY size and every viewer shares it (the
17
+ * last resize wins: another browser, a CLI attach). The server says the
18
+ * PTY's size on attach and whenever anyone changes it (a `size` frame), so
19
+ * a pane knows when it is out of step, and the pane in use wins: it says
20
+ * its own size when it is focused, when the page is shown again, and
21
+ * before the first keystroke after the PTY's size changed under it. A
22
+ * hidden pane never resizes the PTY, nor does one under 2x2. The cells
23
+ * are measured again once the theme's fonts have loaded (a web font
24
+ * arriving after the first fit would leave the cell width the fallback
25
+ * font's) and after the theme changes.
26
+ *
16
27
  * Nothing here keeps a session's state: the list is GET /api/sessions,
17
28
  * asked again every few seconds while the page is shown.
18
29
  *
19
- * Start agent (AG-0.2, LK-1): the lock is checked before the dialog opens
20
- * (the server, then the vault: checkUnlock). Locked, the dialog asks for
21
- * the password inline at its top, and Start unlocks, then starts, without
22
- * closing anything. The runtime line says what will run, or why it
23
- * can't, before Start (agent-start.js readiness).
30
+ * Start agent (AG-0.2): the runtime line says what will run, or why it
31
+ * can't, before Start (agent-start.js readiness). Starting, stopping and
32
+ * resuming an agent never ask for the admin password: it is for sudo, and
33
+ * no agent action runs sudo (LK-0).
24
34
  */
25
- import {
26
- checkUnlock,
27
- onLockChange,
28
- requireUnlock,
29
- unlockWith,
30
- } from "/topbar.js";
31
35
  import { fillStartForm } from "/agent-start.js";
32
36
 
33
37
  const t = (key, vars) => window.denShell?.t?.(key, vars) ?? key;
@@ -57,6 +61,7 @@ const st = {
57
61
  timer: null,
58
62
  xterm: null,
59
63
  csrf: null,
64
+ docWired: false,
60
65
  };
61
66
 
62
67
  // ── the URL ─────────────────────────────────────────────────────────────
@@ -336,6 +341,100 @@ function send(p, obj) {
336
341
  if (p.sock?.readyState === WebSocket.OPEN) p.sock.send(JSON.stringify(obj));
337
342
  }
338
343
 
344
+ /** The smallest size a pane ever asks for (a pane not laid out yet). */
345
+ const MIN_COLS = 2;
346
+ const MIN_ROWS = 2;
347
+
348
+ /** Shown: the page is visible and the pane has a box. */
349
+ function paneShown(p) {
350
+ return (
351
+ !document.hidden &&
352
+ p.el.isConnected &&
353
+ p.el.offsetWidth > 0 &&
354
+ p.el.offsetHeight > 0
355
+ );
356
+ }
357
+
358
+ /** Fit the terminal to its pane (its own cols and rows; nothing is sent). */
359
+ function refit(p) {
360
+ const box = p.el.querySelector(".term");
361
+ if (!p.fit || !p.el.isConnected || !box || box.clientWidth <= 0) return;
362
+ try {
363
+ p.fit.fit();
364
+ } catch {
365
+ /* not laid out yet */
366
+ }
367
+ markSize(p);
368
+ }
369
+
370
+ /** The pane's size and the PTY's, on the pane (data-cols, data-pty-cols). */
371
+ function markSize(p) {
372
+ if (!p.term) return;
373
+ p.el.dataset.cols = String(p.term.cols);
374
+ p.el.dataset.rows = String(p.term.rows);
375
+ if (p.pty) {
376
+ p.el.dataset.ptyCols = String(p.pty.cols);
377
+ p.el.dataset.ptyRows = String(p.pty.rows);
378
+ }
379
+ }
380
+
381
+ /**
382
+ * Whether this pane must say its size before a keystroke: the PTY is known
383
+ * to have another size, or the host never says the PTY's size (an older
384
+ * session host that outlived an upgrade sends no size frames), so another
385
+ * viewer may have resized it unseen. Saying the same size again is free:
386
+ * the PTY only redraws when the size really changes.
387
+ */
388
+ function outOfStep(p) {
389
+ if (!p.term) return false;
390
+ if (!p.heardSize) return true;
391
+ return !!p.pty && (p.pty.cols !== p.term.cols || p.pty.rows !== p.term.rows);
392
+ }
393
+
394
+ /**
395
+ * Say this pane's size to the session (the PTY takes it: last writer
396
+ * wins). Never from a hidden pane, never under 2x2.
397
+ */
398
+ function sendSize(p) {
399
+ const term = p.term;
400
+ if (!term || p.sock?.readyState !== WebSocket.OPEN || !paneShown(p))
401
+ return false;
402
+ if (term.cols < MIN_COLS || term.rows < MIN_ROWS) return false;
403
+ send(p, { type: "resize", cols: term.cols, rows: term.rows });
404
+ // What the PTY will be: the server's frame follows (or another
405
+ // viewer's, if it resized after).
406
+ p.pty = { cols: term.cols, rows: term.rows };
407
+ markSize(p);
408
+ return true;
409
+ }
410
+
411
+ /** This pane is in use: fit it and make the PTY its size. */
412
+ function claimSize(p) {
413
+ refit(p);
414
+ sendSize(p);
415
+ }
416
+
417
+ /**
418
+ * Measure the cells again (xterm measures them once, when the font is
419
+ * set) and refit: a web font that arrived after the first fit, or a theme
420
+ * whose --mono is another font. A change the PTY hears via onResize.
421
+ */
422
+ function remeasure(p) {
423
+ const term = p.term;
424
+ if (!term) return;
425
+ const family = cssVar("--mono") ?? "monospace";
426
+ // Setting the same family is a no-op in xterm: set another first (never
427
+ // drawn: the theme's family is back before the refit).
428
+ if (term.options["fontFamily"] === family)
429
+ Object.assign(term.options, { fontFamily: `${family}, monospace` });
430
+ Object.assign(term.options, { fontFamily: family });
431
+ refit(p);
432
+ }
433
+
434
+ function remeasureAll() {
435
+ for (const p of st.panes.values()) remeasure(p);
436
+ }
437
+
339
438
  async function connect(p) {
340
439
  p.lost = false;
341
440
  paintBar(p);
@@ -357,8 +456,10 @@ async function connect(p) {
357
456
  let opened = false;
358
457
  sock.onopen = () => {
359
458
  opened = true;
360
- if (p.term)
361
- send(p, { type: "resize", cols: p.term.cols, rows: p.term.rows });
459
+ p.pty = null;
460
+ p.heardSize = false;
461
+ refit(p);
462
+ sendSize(p);
362
463
  };
363
464
  sock.onmessage = (e) => {
364
465
  if (typeof e.data === "string") {
@@ -368,7 +469,13 @@ async function connect(p) {
368
469
  } catch {
369
470
  return;
370
471
  }
371
- if (msg?.type === "session") {
472
+ if (msg?.type === "size") {
473
+ if (Number.isInteger(msg.cols) && Number.isInteger(msg.rows)) {
474
+ p.heardSize = true;
475
+ p.pty = { cols: msg.cols, rows: msg.rows };
476
+ markSize(p);
477
+ }
478
+ } else if (msg?.type === "session") {
372
479
  p.label = msg.label;
373
480
  p.workspace = msg.workspace;
374
481
  p.exited = !!msg.exited;
@@ -415,7 +522,16 @@ async function openPane(id) {
415
522
  el.className = "pane";
416
523
  el.dataset.id = id;
417
524
  el.innerHTML = `<div class="bar2"></div><div class="term"></div>`;
418
- const p = { id, el, term: null, fit: null, sock: null, closed: false };
525
+ const p = {
526
+ id,
527
+ el,
528
+ term: null,
529
+ fit: null,
530
+ sock: null,
531
+ closed: false,
532
+ // The PTY's size as last heard (the server's size frame), or null.
533
+ pty: null,
534
+ };
419
535
  st.panes.set(id, p);
420
536
  st.open.push(id);
421
537
  paintBar(p);
@@ -448,19 +564,21 @@ async function openPane(id) {
448
564
  p.fit = fit;
449
565
  const enc = new TextEncoder();
450
566
  term.onData((d) => {
451
- if (p.sock?.readyState === WebSocket.OPEN) p.sock.send(enc.encode(d));
567
+ if (p.sock?.readyState !== WebSocket.OPEN) return;
568
+ // The one typing wins: another viewer resized the PTY since.
569
+ if (outOfStep(p)) claimSize(p);
570
+ p.sock.send(enc.encode(d));
452
571
  });
453
- term.onResize(({ cols, rows }) => send(p, { type: "resize", cols, rows }));
454
- const refit = () => {
455
- try {
456
- if (el.isConnected && el.clientWidth > 0) fit.fit();
457
- } catch {
458
- /* not laid out yet */
459
- }
460
- };
461
- p.ro = new ResizeObserver(() => refit());
572
+ term.onResize(() => sendSize(p));
573
+ // Focused (a click, the window coming back): this pane's size.
574
+ el.addEventListener("focusin", () => claimSize(p));
575
+ p.ro = new ResizeObserver(() => refit(p));
462
576
  p.ro.observe(el.querySelector(".term"));
463
- refit();
577
+ refit(p);
578
+ // The theme's font may still be loading: measure again once it has.
579
+ document.fonts?.ready?.then(() => {
580
+ if (!p.closed) remeasure(p);
581
+ });
464
582
  void connect(p);
465
583
  }
466
584
 
@@ -489,7 +607,7 @@ function closePane(id, { quiet = false } = {}) {
489
607
 
490
608
  async function stopAgent(p, button) {
491
609
  const r = rowOf(p.id);
492
- if (!r?.agent || !(await requireUnlock())) return;
610
+ if (!r?.agent) return;
493
611
  button.disabled = true;
494
612
  try {
495
613
  await api(
@@ -505,7 +623,54 @@ async function stopAgent(p, button) {
505
623
 
506
624
  // ── Start agent ─────────────────────────────────────────────────────────
507
625
 
508
- let personasAsked = null;
626
+ // Personas and runtimes, per workspace (AG-0.5: a workspace's own runtime
627
+ // paths win), asked once per page each.
628
+ const personasAsked = new Map();
629
+
630
+ function personasFor(ws) {
631
+ const key = ws || "";
632
+ if (!personasAsked.has(key))
633
+ personasAsked.set(
634
+ key,
635
+ api(
636
+ "GET",
637
+ `/api/personas${ws ? `?workspace=${encodeURIComponent(ws)}` : ""}`,
638
+ ).catch((err) => {
639
+ personasAsked.delete(key);
640
+ throw err;
641
+ }),
642
+ );
643
+ return personasAsked.get(key);
644
+ }
645
+
646
+ /** Fill the form for the workspace picked; false when the ask failed. */
647
+ async function fillForWorkspace(ws) {
648
+ let data;
649
+ try {
650
+ data = await personasFor(ws);
651
+ } catch (err) {
652
+ window.denShell?.errorToast?.(err);
653
+ return false;
654
+ }
655
+ const form = st.root.querySelector("#ag-form");
656
+ const keep = [
657
+ form.querySelector("#ag-persona").value,
658
+ form.querySelector("#ag-runtime").value,
659
+ ];
660
+ fillStartForm(form, data, t);
661
+ // The persona and runtime picked stay, where the new list still has them.
662
+ const ps = form.querySelector("#ag-persona");
663
+ if (keep[0] && [...ps.options].some((o) => o.value === keep[0])) {
664
+ ps.value = keep[0];
665
+ ps.onchange?.();
666
+ }
667
+ const rs = form.querySelector("#ag-runtime");
668
+ if ([...rs.options].some((o) => o.value === keep[1] && !o.disabled)) {
669
+ rs.value = keep[1];
670
+ rs.onchange?.();
671
+ }
672
+ return true;
673
+ }
509
674
 
510
675
  async function openStart() {
511
676
  const dlg = st.root.querySelector("#ag-dlg");
@@ -516,78 +681,16 @@ async function openStart() {
516
681
  .map((w) => `<option value="${esc(w)}">${esc(w)}</option>`)
517
682
  .join("");
518
683
  st.root.querySelector("#ag-ws-l").hidden = shown.length <= 1;
519
- personasAsked ??= api("GET", "/api/personas").catch((err) => {
520
- personasAsked = null;
521
- throw err;
522
- });
523
- let data;
524
- try {
525
- data = await personasAsked;
526
- } catch (err) {
527
- window.denShell?.errorToast?.(err);
528
- return;
529
- }
530
- fillStartForm(st.root.querySelector("#ag-form"), data, t);
531
- // LK-1: the lock before the modal. Locked, its password is asked here.
532
- paintInlineLock(!(await checkUnlock()));
684
+ if (!(await fillForWorkspace(wsSel.value || shown[0]))) return;
533
685
  if (dlg.open) return;
534
686
  if (typeof dlg.showModal === "function") dlg.showModal();
535
687
  else dlg.setAttribute("open", "");
536
- st.root.querySelector("#ag-lock:not([hidden]) input")?.focus();
537
- }
538
-
539
- /** Show (or hide) the dialog's inline password field (LK-1). */
540
- function paintInlineLock(locked) {
541
- const box = st.root?.querySelector("#ag-lock");
542
- if (!box) return;
543
- box.hidden = !locked;
544
- const err = box.querySelector("#ag-lock-err");
545
- if (!locked) {
546
- box.querySelector("#ag-lock-pw").value = "";
547
- err.hidden = true;
548
- }
549
- }
550
-
551
- /**
552
- * The lock, for Start: the dialog's own field when it shows one (unlock,
553
- * then go on), else the shell's rule (its popover is in the top layer).
554
- */
555
- async function unlockForStart(go) {
556
- const box = st.root.querySelector("#ag-lock");
557
- if (box.hidden) return requireUnlock();
558
- if (await checkUnlock()) {
559
- paintInlineLock(false);
560
- return true;
561
- }
562
- const input = box.querySelector("#ag-lock-pw");
563
- const err = box.querySelector("#ag-lock-err");
564
- if (!input.value) {
565
- err.textContent = t("lock.title");
566
- err.hidden = false;
567
- input.focus();
568
- return false;
569
- }
570
- go.disabled = true;
571
- go.textContent = t("lock.checking");
572
- const r = await unlockWith(input.value);
573
- go.disabled = false;
574
- go.textContent = t("ag.go");
575
- if (r.ok) {
576
- paintInlineLock(false);
577
- return true;
578
- }
579
- err.textContent = r.error;
580
- err.hidden = false;
581
- input.value = "";
582
- input.focus();
583
- return false;
584
688
  }
585
689
 
586
690
  async function submitStart(form) {
587
691
  const dlg = st.root.querySelector("#ag-dlg");
588
692
  const go = st.root.querySelector("#ag-go");
589
693
  if (go.disabled) return;
590
- if (!(await unlockForStart(go))) return;
591
694
  const ws = form.querySelector("#ag-ws").value || viewWorkspaces()[0];
592
695
  const persona = form.querySelector("#ag-persona").value;
593
696
  const runtime = form.querySelector("#ag-runtime").value;
@@ -615,6 +718,40 @@ async function submitStart(form) {
615
718
  }
616
719
  }
617
720
 
721
+ /**
722
+ * Once per document: a font that loads later (the theme's --mono as a web
723
+ * font) and a theme applied without a reload (its sheet, then
724
+ * den:appearance) measure the cells again; the page shown again makes
725
+ * each pane say its size (another viewer may have taken the PTY while it
726
+ * was hidden).
727
+ */
728
+ function wireDocument() {
729
+ st.docWired = true;
730
+ document.fonts?.addEventListener?.("loadingdone", remeasureAll);
731
+ window.addEventListener("den:appearance", () => {
732
+ for (const p of st.panes.values())
733
+ if (p.term) p.term.options.theme = termTheme();
734
+ remeasureAll();
735
+ });
736
+ document.addEventListener(
737
+ "load",
738
+ (e) => {
739
+ if (e.target?.id === "theme-css") {
740
+ for (const p of st.panes.values())
741
+ if (p.term) p.term.options.theme = termTheme();
742
+ remeasureAll();
743
+ }
744
+ },
745
+ true,
746
+ );
747
+ // Shown again: each pane says its size (another viewer may have taken
748
+ // the PTY while this page was hidden).
749
+ document.addEventListener("visibilitychange", () => {
750
+ if (document.hidden) return;
751
+ for (const p of st.panes.values()) claimSize(p);
752
+ });
753
+ }
754
+
618
755
  // ── mount ───────────────────────────────────────────────────────────────
619
756
 
620
757
  function translateStatic(root) {
@@ -630,8 +767,6 @@ function translateStatic(root) {
630
767
  set("#ag-r-t", "ag.runtime");
631
768
  set("#ag-cancel", "ag.cancel");
632
769
  set("#ag-go", "ag.go");
633
- set("#ag-lock-t", "lock.pw");
634
- set("#ag-lock-note", "lock.title");
635
770
  const h = root.querySelector("#ag-dlg-h");
636
771
  if (h) h.textContent = h.textContent.replace(/^\+\s*/, "");
637
772
  }
@@ -667,9 +802,8 @@ export async function mountAgentsPage(root) {
667
802
  root.querySelector("#ag-start").addEventListener("click", () => {
668
803
  void openStart();
669
804
  });
670
- // Unlocked elsewhere (the chip, another tab): the field goes.
671
- onLockChange((unlocked) => {
672
- if (unlocked) paintInlineLock(false);
805
+ root.querySelector("#ag-ws").addEventListener("change", (e) => {
806
+ void fillForWorkspace(e.currentTarget.value);
673
807
  });
674
808
  root.querySelector("#ag-cancel").addEventListener("click", () => {
675
809
  root.querySelector("#ag-dlg").close?.();
@@ -695,6 +829,7 @@ export async function mountAgentsPage(root) {
695
829
  attributes: true,
696
830
  attributeFilter: ["data-mode", "data-theme", "class"],
697
831
  });
832
+ if (!st.docWired) wireDocument();
698
833
  window.addEventListener("pagehide", () => {
699
834
  for (const p of st.panes.values()) {
700
835
  p.closed = true;
@@ -7,15 +7,15 @@
7
7
  *
8
8
  * Every call is the server's agent routes (agent-routes.ts), which are the
9
9
  * CLI's own operations: nothing here keeps an agent's state. Each action
10
- * goes through denShell.api() (a refusal is its error toast) after the
11
- * lock rule (requireUnlock, R8), as a service action does. A row with a
10
+ * goes through denShell.api() (a refusal is its error toast). None asks
11
+ * for the admin password: it is for sudo, and no agent action runs sudo
12
+ * (LK-0). A row with a
12
13
  * session has Open (its terminal on the Agents page, AG-4a-term), and the
13
14
  * header links to the Agents page for the workspace.
14
15
  *
15
16
  * The page's table styles (table.svc, .tablewrap, .tbl-*) and the shell's
16
17
  * (.den-sec, .den-btn, .den-dot) are reused as they are.
17
18
  */
18
- import { requireUnlock } from "/topbar.js";
19
19
  import { fillStartForm } from "/agent-start.js";
20
20
 
21
21
  const t = (key, vars) => window.denShell?.t?.(key, vars) ?? key;
@@ -217,7 +217,6 @@ async function openForm(sec) {
217
217
  }
218
218
 
219
219
  async function act(ws, verb, id, button) {
220
- if (!(await requireUnlock())) return;
221
220
  button.disabled = true;
222
221
  try {
223
222
  await api("POST", `${base(ws)}/${encodeURIComponent(id)}/${verb}`);
@@ -231,7 +230,6 @@ async function act(ws, verb, id, button) {
231
230
  }
232
231
 
233
232
  async function start(ws, form) {
234
- if (!(await requireUnlock())) return;
235
233
  const go = form.querySelector("#agent-start-go");
236
234
  const persona = form.querySelector('select[name="persona"]').value;
237
235
  const runtime = form.querySelector('select[name="runtime"]').value;
@@ -298,7 +298,17 @@
298
298
  font-size: 12.5px;
299
299
  }
300
300
  dialog.ag-dlg {
301
- width: min(440px, calc(100vw - 32px));
301
+ /* The approved mock's Start agent dialog (AG-0.5), measured on
302
+ den-shell.html: .dlg.wide, min(600px, 100%) of content in a
303
+ content-box, so 600 + 2 x 18 padding + 2 x 1 border = 638
304
+ outside at 1440. Here the box is border-box: 638, and never
305
+ wider than the viewport less 16px a side (at 375 the mock's
306
+ box is 381 and overflows; this one is 343). */
307
+ box-sizing: border-box;
308
+ width: min(638px, calc(100vw - 32px));
309
+ max-width: none;
310
+ max-height: calc(100vh - 32px);
311
+ overflow-y: auto;
302
312
  background: var(--raised);
303
313
  color: var(--text);
304
314
  border: 1px solid var(--border-strong);
@@ -324,8 +334,7 @@
324
334
  }
325
335
  .ag-dlg .help[hidden],
326
336
  .ag-dlg .ready[hidden],
327
- .ag-dlg .err[hidden],
328
- .ag-dlg .ag-lock[hidden] {
337
+ .ag-dlg .err[hidden] {
329
338
  display: none;
330
339
  }
331
340
  /* AG-0.2: the runtime it will run, or why it can't, before Start
@@ -342,20 +351,6 @@
342
351
  .ag-dlg .ready .mono {
343
352
  font-family: var(--mono);
344
353
  }
345
- /* LK-1: locked, the password is asked here, at the top of the dialog,
346
- and Start unlocks then starts. */
347
- .ag-dlg .ag-lock {
348
- display: grid;
349
- gap: 6px;
350
- padding: 10px 12px;
351
- border: 1px solid
352
- color-mix(in srgb, var(--warn) 45%, var(--border));
353
- border-radius: var(--radius-button);
354
- background: color-mix(in srgb, var(--warn) 8%, var(--raised));
355
- }
356
- .ag-dlg .ag-lock .help {
357
- margin: 0;
358
- }
359
354
  .ag-dlg .err {
360
355
  margin: 0;
361
356
  font: 12.5px/1.45 var(--sans);
@@ -447,17 +442,6 @@
447
442
  <dialog class="ag-dlg" id="ag-dlg" aria-labelledby="ag-dlg-h">
448
443
  <form method="dialog" id="ag-form">
449
444
  <h2 id="ag-dlg-h">Start an agent</h2>
450
- <div class="ag-lock" id="ag-lock" hidden>
451
- <label
452
- ><span id="ag-lock-t">Admin password</span>
453
- <input
454
- type="password"
455
- id="ag-lock-pw"
456
- autocomplete="current-password"
457
- /></label>
458
- <p class="help" id="ag-lock-note"></p>
459
- <p class="err" id="ag-lock-err" role="alert" hidden></p>
460
- </div>
461
445
  <label id="ag-ws-l"
462
446
  ><span id="ag-ws-t">Workspace</span>
463
447
  <select name="ws" id="ag-ws"></select