@monster-orange/den 0.4.2 → 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 (64) hide show
  1. package/CHANGELOG.md +273 -0
  2. package/README.md +9 -9
  3. package/docs/distributions.md +313 -50
  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 +53 -12
  8. package/src/shared/agents/generic.ts +1 -0
  9. package/src/shared/agents/runtime-search.ts +543 -0
  10. package/src/shared/agents/runtime.ts +43 -1
  11. package/src/shared/agents/runtimes.ts +53 -4
  12. package/src/shared/auth/sudo-vault.ts +16 -0
  13. package/src/shared/den-config.ts +92 -2
  14. package/src/shared/edge/render.ts +31 -0
  15. package/src/shared/edge/status.ts +134 -0
  16. package/src/shared/environment-manager/health.ts +82 -8
  17. package/src/shared/environment-manager/workspaces.ts +54 -0
  18. package/src/shared/health/not-checked.ts +42 -0
  19. package/src/shared/health/rollup.ts +4 -0
  20. package/src/shared/process/spawn-streaming.ts +8 -3
  21. package/src/shared/sessions/client.ts +5 -1
  22. package/src/shared/sessions/host.ts +29 -1
  23. package/src/shared/sessions/protocol.ts +6 -1
  24. package/src/shared/sessions/types.ts +6 -0
  25. package/src/shared/stacks/status.ts +24 -0
  26. package/src/shared/strategies/registry.ts +5 -0
  27. package/src/shared/types/index.ts +6 -0
  28. package/src/ui/cli/agent-ops.ts +298 -19
  29. package/src/ui/cli/agents-command.ts +129 -4
  30. package/src/ui/cli/edge-command.ts +16 -3
  31. package/src/ui/cli/health-display.ts +4 -1
  32. package/src/ui/cli/sessions-command.ts +9 -4
  33. package/src/ui/cli/web-command.ts +53 -8
  34. package/src/ui/mcp/scope.ts +23 -3
  35. package/src/ui/mcp/server.ts +31 -7
  36. package/src/ui/mcp/tools.ts +327 -20
  37. package/src/ui/mcp/views.ts +450 -0
  38. package/src/ui/server/agent-routes.ts +123 -26
  39. package/src/ui/server/edge-exposure.ts +143 -0
  40. package/src/ui/server/index.ts +171 -25
  41. package/src/ui/server/public/agent-start.js +241 -0
  42. package/src/ui/server/public/agents-page.js +246 -45
  43. package/src/ui/server/public/agents-section.js +7 -21
  44. package/src/ui/server/public/agents.html +55 -1
  45. package/src/ui/server/public/graph-model.js +2 -0
  46. package/src/ui/server/public/header.js +2 -3
  47. package/src/ui/server/public/health-rollup.js +20 -8
  48. package/src/ui/server/public/i18n/es.js +26 -13
  49. package/src/ui/server/public/i18n/ja.js +25 -13
  50. package/src/ui/server/public/index.html +17 -4
  51. package/src/ui/server/public/local-dev.html +62 -22
  52. package/src/ui/server/public/services.html +21 -5
  53. package/src/ui/server/public/settings-page.js +3 -2
  54. package/src/ui/server/public/settings-registry.js +226 -1
  55. package/src/ui/server/public/settings.html +90 -1
  56. package/src/ui/server/public/shell.js +67 -22
  57. package/src/ui/server/public/topbar.js +217 -158
  58. package/src/ui/server/readonly-routes.ts +29 -5
  59. package/src/ui/server/request-guard.ts +123 -8
  60. package/src/ui/server/route-classes.ts +14 -2
  61. package/src/ui/server/session-routes.ts +9 -1
  62. package/src/ui/server/sudo-store.ts +170 -6
  63. package/src/ui/server/ui-assets.ts +2 -0
  64. package/src/ui/server/workspace-routes.ts +10 -3
@@ -0,0 +1,241 @@
1
+ /**
2
+ * The Start agent form's choices (AG-10.0), shared by the Agents tab's
3
+ * dialog and the Workspaces page's agents section. DOM-free.
4
+ *
5
+ * A persona option shows its id only: its role sentence is the help line
6
+ * under the select (personaHelp), so a long role can't widen the select
7
+ * past the dialog. The runtime's first option names what it resolves to,
8
+ * "Claude Code (persona default)", from the persona picked; a runtime that
9
+ * can't run here says why, and one that can shows its version.
10
+ *
11
+ * AG-0.2, readiness before Start: the line under the runtime names the CLI
12
+ * it will run (its version and path); when that runtime can't run here,
13
+ * Start is disabled and the line says why and how to fix it (install it,
14
+ * or set agents.runtimes.<id>.bin in the user.yml it names, or run
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.
21
+ */
22
+
23
+ function escHtml(s) {
24
+ return String(s ?? "").replace(
25
+ /[&<>"']/g,
26
+ (c) =>
27
+ ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[
28
+ c
29
+ ],
30
+ );
31
+ }
32
+
33
+ /** The persona select's options: the id only. */
34
+ export function personaOptions(personas) {
35
+ return (personas ?? [])
36
+ .map((p) => `<option value="${escHtml(p.id)}">${escHtml(p.id)}</option>`)
37
+ .join("");
38
+ }
39
+
40
+ /** The help line under the persona select: the picked persona's role. */
41
+ export function personaHelp(personas, id) {
42
+ return (personas ?? []).find((p) => p.id === id)?.role ?? "";
43
+ }
44
+
45
+ /** A runtime's name for people ("Claude Code"); its id when it has none. */
46
+ export function runtimeName(runtimes, id) {
47
+ const r = (runtimes ?? []).find((x) => x.id === id);
48
+ return r?.label || r?.id || id || "";
49
+ }
50
+
51
+ /**
52
+ * The runtime a persona runs on when the agent names none: the persona's
53
+ * own, else the default runtime.
54
+ */
55
+ export function defaultRuntimeOf(personas, runtimes, personaId) {
56
+ const p = (personas ?? []).find((x) => x.id === personaId);
57
+ if (p?.runtime) return p.runtime;
58
+ return (runtimes ?? []).find((r) => r.default)?.id ?? "";
59
+ }
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
+
79
+ /**
80
+ * The runtime select's options. The first ("" = the persona's) reads
81
+ * "<name> (persona default)"; one that can't run here is disabled and
82
+ * says why; one without Den's tools yet says so.
83
+ */
84
+ export function runtimeOptions(personas, runtimes, personaId, t) {
85
+ const name = runtimeName(
86
+ runtimes,
87
+ defaultRuntimeOf(personas, runtimes, personaId),
88
+ );
89
+ const first = name ? t("ag.runtime.default", { name }) : t("ag.runtime.auto");
90
+ return (
91
+ `<option value="">${escHtml(first)}</option>` +
92
+ (runtimes ?? [])
93
+ .map((r) => {
94
+ const label = runtimeName(runtimes, r.id);
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}`,
104
+ )}</option>`
105
+ : `<option value="${escHtml(r.id)}">${escHtml(
106
+ parts.filter(Boolean).join(" · "),
107
+ )}</option>`;
108
+ })
109
+ .join("")
110
+ );
111
+ }
112
+
113
+ /** The most searched directories named inline; the rest are counted. */
114
+ const SHOWN_DIRS = 4;
115
+
116
+ /**
117
+ * Whether Start can run, and the line that says so (AG-0.2): for the
118
+ * runtime picked (`runtimeId`, "" = the persona's), { ok, tone, html }.
119
+ * `tone` is "" (ready), "warn" (can't start) or "none" (nothing to say).
120
+ */
121
+ export function readiness(data, personaId, runtimeId, t) {
122
+ const personas = data?.personas ?? [];
123
+ const runtimes = data?.runtimes ?? [];
124
+ const id = runtimeId || defaultRuntimeOf(personas, runtimes, personaId);
125
+ const r = runtimes.find((x) => x.id === id);
126
+ if (!r)
127
+ return {
128
+ ok: false,
129
+ tone: "warn",
130
+ html: escHtml(t("ag.rt.unknown", { id: id || "?" })),
131
+ };
132
+ const name = runtimeName(runtimes, r.id);
133
+ const mono = (x) => `<span class="mono">${escHtml(x)}</span>`;
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
+ };
142
+ if (!r.path) return { ok: true, tone: "none", html: "" };
143
+ return {
144
+ ok: true,
145
+ tone: "",
146
+ html:
147
+ escHtml(
148
+ r.version
149
+ ? t("ag.rt.ready", { name, version: r.version })
150
+ : t("ag.rt.readyNoVer", { name }),
151
+ ) +
152
+ " " +
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
+ : ""),
157
+ };
158
+ }
159
+ const file = data?.userConfig || "config/local/user.yml";
160
+ const fix = t("ag.rt.fix", {
161
+ key: "\u0000k",
162
+ file: "\u0000f",
163
+ cmd: "\u0000c",
164
+ });
165
+ const fixHtml = escHtml(fix)
166
+ .replace("\u0000k", mono(r.setting || `agents.runtimes.${r.id}.bin`))
167
+ .replace("\u0000f", mono(file))
168
+ .replace("\u0000c", mono("__DEN_CLI__ agents doctor"));
169
+ let what;
170
+ if (r.source === "setting" || !r.searched)
171
+ what = escHtml(t("ag.rt.refused", { name, why: r.why || "" }));
172
+ else {
173
+ const dirs = r.searched;
174
+ const shown = dirs.slice(0, SHOWN_DIRS).map(mono).join(", ");
175
+ const more =
176
+ dirs.length > SHOWN_DIRS
177
+ ? " " + escHtml(t("ag.rt.more", { n: dirs.length - SHOWN_DIRS }))
178
+ : "";
179
+ what =
180
+ escHtml(t("ag.rt.notFound", { name })) +
181
+ " " +
182
+ escHtml(t("ag.rt.searched")) +
183
+ " " +
184
+ (shown || escHtml(t("ag.rt.nowhere"))) +
185
+ more +
186
+ ".";
187
+ }
188
+ return { ok: false, tone: "warn", html: `${what} ${fixHtml}` };
189
+ }
190
+
191
+ /**
192
+ * Fill a form's persona and runtime selects, its help line and its
193
+ * readiness line ([data-runtime-ready]), keep them in step when the
194
+ * persona or the runtime changes, and disable its submit button while
195
+ * the runtime can't run. Returns nothing; idempotent per form (the
196
+ * change listeners are bound once).
197
+ */
198
+ export function fillStartForm(form, data, t) {
199
+ const personas = data?.personas ?? [];
200
+ const runtimes = data?.runtimes ?? [];
201
+ const ps = form.querySelector('select[name="persona"]');
202
+ const rs = form.querySelector('select[name="runtime"]');
203
+ const help = form.querySelector("[data-persona-help]");
204
+ const ready = form.querySelector("[data-runtime-ready]");
205
+ const go = form.querySelector('button[type="submit"]');
206
+ ps.innerHTML = personaOptions(personas);
207
+ const check = () => {
208
+ const r = readiness(data, ps.value, rs.value, t);
209
+ if (ready) {
210
+ ready.innerHTML = r.html;
211
+ ready.hidden = !r.html;
212
+ ready.dataset.tone = r.tone;
213
+ }
214
+ if (go) {
215
+ go.disabled = !r.ok;
216
+ go.dataset.blocked = r.ok ? "" : "runtime";
217
+ }
218
+ };
219
+ const sync = () => {
220
+ const keep = rs.value;
221
+ rs.innerHTML = runtimeOptions(personas, runtimes, ps.value, t);
222
+ if ([...rs.options].some((o) => o.value === keep && !o.disabled))
223
+ rs.value = keep;
224
+ if (help) {
225
+ const role = personaHelp(personas, ps.value);
226
+ help.textContent = role;
227
+ help.hidden = !role;
228
+ }
229
+ check();
230
+ };
231
+ ps.onchange = sync;
232
+ rs.onchange = check;
233
+ sync();
234
+ }
235
+
236
+ /** Whether a form's Start is held back by its runtime (readiness). */
237
+ export function blockedByRuntime(form) {
238
+ return (
239
+ form.querySelector('button[type="submit"]')?.dataset.blocked === "runtime"
240
+ );
241
+ }
@@ -8,15 +8,31 @@
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.
29
+ *
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).
18
34
  */
19
- import { requireUnlock } from "/topbar.js";
35
+ import { fillStartForm } from "/agent-start.js";
20
36
 
21
37
  const t = (key, vars) => window.denShell?.t?.(key, vars) ?? key;
22
38
  const esc = (s) =>
@@ -45,6 +61,7 @@ const st = {
45
61
  timer: null,
46
62
  xterm: null,
47
63
  csrf: null,
64
+ docWired: false,
48
65
  };
49
66
 
50
67
  // ── the URL ─────────────────────────────────────────────────────────────
@@ -324,6 +341,100 @@ function send(p, obj) {
324
341
  if (p.sock?.readyState === WebSocket.OPEN) p.sock.send(JSON.stringify(obj));
325
342
  }
326
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
+
327
438
  async function connect(p) {
328
439
  p.lost = false;
329
440
  paintBar(p);
@@ -345,8 +456,10 @@ async function connect(p) {
345
456
  let opened = false;
346
457
  sock.onopen = () => {
347
458
  opened = true;
348
- if (p.term)
349
- 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);
350
463
  };
351
464
  sock.onmessage = (e) => {
352
465
  if (typeof e.data === "string") {
@@ -356,7 +469,13 @@ async function connect(p) {
356
469
  } catch {
357
470
  return;
358
471
  }
359
- 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") {
360
479
  p.label = msg.label;
361
480
  p.workspace = msg.workspace;
362
481
  p.exited = !!msg.exited;
@@ -403,7 +522,16 @@ async function openPane(id) {
403
522
  el.className = "pane";
404
523
  el.dataset.id = id;
405
524
  el.innerHTML = `<div class="bar2"></div><div class="term"></div>`;
406
- 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
+ };
407
535
  st.panes.set(id, p);
408
536
  st.open.push(id);
409
537
  paintBar(p);
@@ -436,19 +564,21 @@ async function openPane(id) {
436
564
  p.fit = fit;
437
565
  const enc = new TextEncoder();
438
566
  term.onData((d) => {
439
- 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));
440
571
  });
441
- term.onResize(({ cols, rows }) => send(p, { type: "resize", cols, rows }));
442
- const refit = () => {
443
- try {
444
- if (el.isConnected && el.clientWidth > 0) fit.fit();
445
- } catch {
446
- /* not laid out yet */
447
- }
448
- };
449
- 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));
450
576
  p.ro.observe(el.querySelector(".term"));
451
- 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
+ });
452
582
  void connect(p);
453
583
  }
454
584
 
@@ -477,7 +607,7 @@ function closePane(id, { quiet = false } = {}) {
477
607
 
478
608
  async function stopAgent(p, button) {
479
609
  const r = rowOf(p.id);
480
- if (!r?.agent || !requireUnlock()) return;
610
+ if (!r?.agent) return;
481
611
  button.disabled = true;
482
612
  try {
483
613
  await api(
@@ -493,7 +623,54 @@ async function stopAgent(p, button) {
493
623
 
494
624
  // ── Start agent ─────────────────────────────────────────────────────────
495
625
 
496
- 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
+ }
497
674
 
498
675
  async function openStart() {
499
676
  const dlg = st.root.querySelector("#ag-dlg");
@@ -504,39 +681,16 @@ async function openStart() {
504
681
  .map((w) => `<option value="${esc(w)}">${esc(w)}</option>`)
505
682
  .join("");
506
683
  st.root.querySelector("#ag-ws-l").hidden = shown.length <= 1;
507
- personasAsked ??= api("GET", "/api/personas").catch((err) => {
508
- personasAsked = null;
509
- throw err;
510
- });
511
- let data;
512
- try {
513
- data = await personasAsked;
514
- } catch (err) {
515
- window.denShell?.errorToast?.(err);
516
- return;
517
- }
518
- st.root.querySelector("#ag-persona").innerHTML = (data.personas ?? [])
519
- .map(
520
- (p) =>
521
- `<option value="${esc(p.id)}">${esc(p.id)}${p.role ? ` — ${esc(p.role)}` : ""}</option>`,
522
- )
523
- .join("");
524
- st.root.querySelector("#ag-runtime").innerHTML =
525
- `<option value="">${esc(t("ag.runtime.auto"))}</option>` +
526
- (data.runtimes ?? [])
527
- .map(
528
- (r) =>
529
- `<option value="${esc(r.id)}"${r.available ? "" : " disabled"}>${esc(r.id)}${r.available ? "" : ` — ${esc(t("ag.unavailable"))}`}</option>`,
530
- )
531
- .join("");
684
+ if (!(await fillForWorkspace(wsSel.value || shown[0]))) return;
685
+ if (dlg.open) return;
532
686
  if (typeof dlg.showModal === "function") dlg.showModal();
533
687
  else dlg.setAttribute("open", "");
534
688
  }
535
689
 
536
690
  async function submitStart(form) {
537
- if (!requireUnlock()) return;
538
691
  const dlg = st.root.querySelector("#ag-dlg");
539
692
  const go = st.root.querySelector("#ag-go");
693
+ if (go.disabled) return;
540
694
  const ws = form.querySelector("#ag-ws").value || viewWorkspaces()[0];
541
695
  const persona = form.querySelector("#ag-persona").value;
542
696
  const runtime = form.querySelector("#ag-runtime").value;
@@ -564,6 +718,40 @@ async function submitStart(form) {
564
718
  }
565
719
  }
566
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
+
567
755
  // ── mount ───────────────────────────────────────────────────────────────
568
756
 
569
757
  function translateStatic(root) {
@@ -614,6 +802,9 @@ export async function mountAgentsPage(root) {
614
802
  root.querySelector("#ag-start").addEventListener("click", () => {
615
803
  void openStart();
616
804
  });
805
+ root.querySelector("#ag-ws").addEventListener("change", (e) => {
806
+ void fillForWorkspace(e.currentTarget.value);
807
+ });
617
808
  root.querySelector("#ag-cancel").addEventListener("click", () => {
618
809
  root.querySelector("#ag-dlg").close?.();
619
810
  });
@@ -621,6 +812,15 @@ export async function mountAgentsPage(root) {
621
812
  e.preventDefault();
622
813
  void submitStart(e.currentTarget);
623
814
  });
815
+ // Enter starts from any field (a select doesn't submit on its own); Esc
816
+ // cancels (the dialog's own cancel). A button keeps its own Enter.
817
+ root.querySelector("#ag-form").addEventListener("keydown", (e) => {
818
+ if (e.key !== "Enter" || e.isComposing) return;
819
+ if (e.target.closest("button, textarea")) return;
820
+ e.preventDefault();
821
+ if (!st.root.querySelector("#ag-go").disabled)
822
+ e.currentTarget.requestSubmit();
823
+ });
624
824
  // The terminals follow the mode (light, dark) and the theme.
625
825
  new MutationObserver(() => {
626
826
  for (const p of st.panes.values())
@@ -629,6 +829,7 @@ export async function mountAgentsPage(root) {
629
829
  attributes: true,
630
830
  attributeFilter: ["data-mode", "data-theme", "class"],
631
831
  });
832
+ if (!st.docWired) wireDocument();
632
833
  window.addEventListener("pagehide", () => {
633
834
  for (const p of st.panes.values()) {
634
835
  p.closed = true;