@monster-orange/den 0.4.23 → 0.4.25

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.
@@ -0,0 +1,150 @@
1
+ /**
2
+ * A searchable picker (D30): a text field (role=combobox) over a listbox.
3
+ * Type to filter (the command palette's ranking, rankCommands), ↑/↓ to
4
+ * move, Enter (or a click) picks, Esc closes. What is picked is an id; what
5
+ * is shown is its name. One shared control, so a form never asks a person
6
+ * to type an id.
7
+ *
8
+ * comboHtml({ id, label, placeholder }) the markup (a <label>, the
9
+ * field, its list)
10
+ * wireCombo(root, id, { items, onPick }) makes it work; items() is read
11
+ * each time it opens, so it
12
+ * follows live data. Returns
13
+ * { value(), set(id), clear() }.
14
+ *
15
+ * An item: { id, label, sub?, keywords?, off? } (off: shown, not pickable).
16
+ */
17
+ import { rankCommands } from "./topbar.js";
18
+
19
+ const esc = (s) =>
20
+ String(s ?? "").replace(
21
+ /[&<>"']/g,
22
+ (c) =>
23
+ ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[
24
+ c
25
+ ],
26
+ );
27
+
28
+ export function comboHtml({ id, label, placeholder = "" }) {
29
+ return (
30
+ `<span class="den-combo" data-combo="${esc(id)}">` +
31
+ `<label for="${esc(id)}">${esc(label)}</label>` +
32
+ `<span class="den-combo-box">` +
33
+ `<input type="text" id="${esc(id)}" role="combobox" aria-expanded="false" aria-controls="${esc(id)}-list" aria-autocomplete="list" autocomplete="off" spellcheck="false" placeholder="${esc(placeholder)}">` +
34
+ `<ul id="${esc(id)}-list" role="listbox" aria-label="${esc(label)}" class="den-combo-list" hidden></ul>` +
35
+ `</span></span>`
36
+ );
37
+ }
38
+
39
+ /** Rows ranked for `q` (every item when it is empty). */
40
+ export function comboMatches(items, q) {
41
+ const list = items.map((x) => ({
42
+ ...x,
43
+ keywords: [x.id, x.sub ?? "", ...(x.keywords ?? [])],
44
+ }));
45
+ return rankCommands(list, q, { limit: 50, emptyLimit: 50 });
46
+ }
47
+
48
+ export function wireCombo(root, id, { items, onPick }) {
49
+ const input = root.querySelector(`#${CSS.escape(id)}`);
50
+ const ul = root.querySelector(`#${CSS.escape(id)}-list`);
51
+ if (!input || !ul) return null;
52
+ let picked = "";
53
+ let shown = [];
54
+ let sel = 0;
55
+ const close = () => {
56
+ ul.hidden = true;
57
+ input.setAttribute("aria-expanded", "false");
58
+ input.removeAttribute("aria-activedescendant");
59
+ };
60
+ const draw = () => {
61
+ shown = comboMatches(
62
+ items(),
63
+ input.value === labelOf(picked) ? "" : input.value,
64
+ );
65
+ sel = Math.min(sel, Math.max(0, shown.length - 1));
66
+ ul.innerHTML = shown.length
67
+ ? shown
68
+ .map(
69
+ (x, i) =>
70
+ `<li role="option" id="${esc(id)}-o-${i}" data-i="${i}" aria-selected="${i === sel}"${x.off ? ' aria-disabled="true"' : ""}>` +
71
+ `<span class="den-combo-l">${esc(x.label)}</span>` +
72
+ (x.sub ? `<span class="den-combo-s">${esc(x.sub)}</span>` : "") +
73
+ `</li>`,
74
+ )
75
+ .join("")
76
+ : `<li class="den-combo-none" aria-disabled="true">No match</li>`;
77
+ ul.hidden = false;
78
+ input.setAttribute("aria-expanded", "true");
79
+ if (shown.length)
80
+ input.setAttribute("aria-activedescendant", `${id}-o-${sel}`);
81
+ ul.querySelector(`[data-i="${sel}"]`)?.scrollIntoView?.({
82
+ block: "nearest",
83
+ });
84
+ };
85
+ const labelOf = (v) => items().find((x) => x.id === v)?.label ?? "";
86
+ const pick = (x) => {
87
+ if (!x || x.off) return;
88
+ picked = x.id;
89
+ input.value = x.label;
90
+ input.dataset.value = x.id;
91
+ close();
92
+ onPick?.(x);
93
+ };
94
+ input.addEventListener("focus", () => {
95
+ sel = 0;
96
+ draw();
97
+ });
98
+ input.addEventListener("input", () => {
99
+ picked = "";
100
+ delete input.dataset.value;
101
+ sel = 0;
102
+ draw();
103
+ });
104
+ input.addEventListener("keydown", (e) => {
105
+ if (e.key === "ArrowDown" || e.key === "ArrowUp") {
106
+ e.preventDefault();
107
+ if (ul.hidden) return draw();
108
+ const n = shown.length;
109
+ if (!n) return;
110
+ sel = (sel + (e.key === "ArrowDown" ? 1 : n - 1)) % n;
111
+ draw();
112
+ } else if (e.key === "Enter") {
113
+ if (ul.hidden) return;
114
+ e.preventDefault();
115
+ pick(shown[sel]);
116
+ } else if (e.key === "Escape") {
117
+ if (!ul.hidden) {
118
+ e.preventDefault();
119
+ e.stopPropagation();
120
+ close();
121
+ }
122
+ }
123
+ });
124
+ // mousedown, not click: the field keeps its focus (no blur close first).
125
+ ul.addEventListener("mousedown", (e) => {
126
+ const li = e.target.closest?.("[data-i]");
127
+ if (!li) return;
128
+ e.preventDefault();
129
+ pick(shown[Number(li.dataset.i)]);
130
+ });
131
+ input.addEventListener("blur", () => {
132
+ close();
133
+ // Leaving with a half-typed name: back to what was picked.
134
+ if (input.value !== labelOf(picked)) input.value = labelOf(picked);
135
+ });
136
+ return {
137
+ value: () => picked,
138
+ set: (v) => {
139
+ picked = v;
140
+ input.value = labelOf(v);
141
+ if (v) input.dataset.value = v;
142
+ else delete input.dataset.value;
143
+ },
144
+ clear: () => {
145
+ picked = "";
146
+ input.value = "";
147
+ delete input.dataset.value;
148
+ },
149
+ };
150
+ }
@@ -305,10 +305,15 @@ function githubBlock(s, t) {
305
305
 
306
306
  /**
307
307
  * D17: a Den with no owner yet that a rule lets someone claim. It leads the
308
- * page: Connect your GitHub account. "This is my computer" (with
309
- * web.allowed_github_org) goes to localhost and back, which only works in a
310
- * browser on the machine Den runs on, then starts the GitHub sign-in with
311
- * that proof; "Sign in as @login" (web.owner_github) works from anywhere.
308
+ * page: Connect your GitHub account.
309
+ *
310
+ * - "Sign in as @login" (web.owner_github) works from any browser: when
311
+ * it is there, it is the primary button.
312
+ * - The localhost trip (web.allowed_github_org) only works in a browser
313
+ * on the machine Den runs on, so its button says so ("On <host>
314
+ * itself: connect GitHub"), never a footnote (D29).
315
+ * - Only the org rule: the page says to open it on <host>, or to set
316
+ * web.owner_github to sign in from anywhere.
312
317
  */
313
318
  function claimBlock(s, t) {
314
319
  const g = s.github;
@@ -316,24 +321,31 @@ function claimBlock(s, t) {
316
321
  const host = s.machine || "";
317
322
  const back = location.origin + location.pathname + location.search;
318
323
  const here = g.attest ? `${g.attest}?return=${encodeURIComponent(back)}` : "";
324
+ const as = g.ownerGithub
325
+ ? `<button type="button" class="btn primary" id="den-gh-start">${esc(t("err.signin.claimAs", { login: g.ownerGithub }))}</button>`
326
+ : "";
327
+ const hereBtn = here
328
+ ? `<a class="btn${as ? "" : " primary"}" id="den-claim-here" href="${esc(here)}">${
329
+ host
330
+ ? withHost(t, "err.signin.claimHereOn", { host })
331
+ : esc(t("err.signin.claimHere"))
332
+ }</a>`
333
+ : "";
319
334
  return (
320
335
  `<div id="den-gh" class="den-claim">` +
321
336
  `<h2 class="den-h" id="den-claim-title">${esc(t("err.signin.claimTitle"))}</h2>` +
322
337
  `<p class="lead" id="den-claim-lead">${esc(t("err.signin.claimLead", { name: s.name || "Den" }))}</p>` +
323
338
  (g.org
324
- ? `<p id="den-claim-org">${withHost(t, "err.signin.claimOrg", { org: g.org, host })}</p>`
339
+ ? `<p id="den-claim-org">${withHost(
340
+ t,
341
+ g.ownerGithub ? "err.signin.claimOrg" : "err.signin.claimOrgOnly",
342
+ { org: g.org, host },
343
+ )}</p>`
325
344
  : "") +
326
345
  `<div class="acts" style="margin-top:0">` +
327
- (here
328
- ? `<a class="btn primary" id="den-claim-here" href="${esc(here)}">${esc(t("err.signin.claimHere"))}</a>`
329
- : "") +
330
- (g.ownerGithub
331
- ? `<button type="button" class="btn${here ? "" : " primary"}" id="den-gh-start">${esc(t("err.signin.claimAs", { login: g.ownerGithub }))}</button>`
332
- : "") +
346
+ as +
347
+ hereBtn +
333
348
  `</div><div id="den-gh-flow" aria-live="polite"></div>` +
334
- (here
335
- ? `<p class="den-gh-note" id="den-claim-note">${withHost(t, "err.signin.claimHereNote", { host })}</p>`
336
- : "") +
337
349
  `</div>`
338
350
  );
339
351
  }
@@ -311,9 +311,10 @@
311
311
  "err.signin.claimOrg":
312
312
  "Los miembros de la organización {org} de GitHub pueden reclamarlo, en un navegador en {host}.",
313
313
  "err.signin.claimHere": "Este es mi ordenador: conectar GitHub",
314
+ "err.signin.claimHereOn": "En {host}: conectar GitHub",
315
+ "err.signin.claimOrgOnly":
316
+ "Los miembros de la organización {org} de GitHub pueden reclamarlo desde un navegador en {host}. Abre esta página en {host}, o define web.owner_github para iniciar sesión desde cualquier sitio.",
314
317
  "err.signin.claimAs": "Iniciar sesión como @{login}",
315
- "err.signin.claimHereNote":
316
- "«Este es mi ordenador» solo funciona en un navegador en {host}. En otro sitio no abre nada.",
317
318
  "err.signin.claimChecking": "Comprobando que esto es {host}…",
318
319
  "err.signin.githubRefused":
319
320
  "@{login} no es el propietario de este {name}. Solo la cuenta del propietario puede usar Iniciar sesión con GitHub. Usa «Aprueba este ordenador» en su lugar.",
@@ -747,6 +748,7 @@
747
748
  "ver.usedLatest": "{n} ahora sigue la versión más reciente",
748
749
  "set.agents.personas": "Personas",
749
750
  "set.agents.rotation": "Rotación",
751
+ "set.agents.hub": "Hub",
750
752
  // The Agents tab (AG-4a-term, agents-page.js).
751
753
  "n.agents": "Agentes",
752
754
  "kind.agent": "Agente",
@@ -299,9 +299,10 @@
299
299
  "err.signin.claimOrg":
300
300
  "GitHub の {org} 組織のメンバーは、{host} 上のブラウザーでこれを登録できます。",
301
301
  "err.signin.claimHere": "これは自分のコンピューター: GitHub を接続",
302
+ "err.signin.claimHereOn": "{host} 上で: GitHub を接続",
303
+ "err.signin.claimOrgOnly":
304
+ "GitHub の {org} 組織のメンバーは、{host} 上のブラウザーからこれを登録できます。このページを {host} で開くか、どこからでもサインインできるよう web.owner_github を設定してください。",
302
305
  "err.signin.claimAs": "@{login} としてサインイン",
303
- "err.signin.claimHereNote":
304
- "「これは自分のコンピューター」は {host} 上のブラウザーでだけ使えます。ほかの場所では何も開きません。",
305
306
  "err.signin.claimChecking": "ここが {host} か確認しています…",
306
307
  "err.signin.githubRefused":
307
308
  "@{login} はこの {name} のオーナーではありません。GitHub でサインインを使えるのはオーナーのアカウントだけです。代わりに「このコンピューターを承認」を使ってください。",
@@ -729,6 +730,7 @@
729
730
  "ver.usedLatest": "{n} は最新バージョンに追従します",
730
731
  "set.agents.personas": "ペルソナ",
731
732
  "set.agents.rotation": "交代",
733
+ "set.agents.hub": "Hub",
732
734
  // The Agents tab (AG-4a-term, agents-page.js).
733
735
  "n.agents": "エージェント",
734
736
  "kind.agent": "エージェント",
@@ -30,6 +30,7 @@
30
30
  import { themeNotice, themeOptions } from "./appearance.js";
31
31
  import { qrSvg } from "./qr.js";
32
32
  import { renderPersonasSettings } from "./persona-ui.js";
33
+ import { comboHtml, wireCombo } from "./combobox.js";
33
34
 
34
35
  export const DEN_SETTING_CATEGORIES = [
35
36
  {
@@ -911,6 +912,20 @@ export const DEN_SETTINGS = [
911
912
  read: () => null,
912
913
  },
913
914
 
915
+ {
916
+ id: "agents.hub",
917
+ category: "agents",
918
+ label: "Hub",
919
+ description:
920
+ "Every agent gets Hub's tools: search Hub, publish docs and mocks there. Its team and project come from this install's token and the workspace's repos. Set hub.url and hub.token (a secret ref in this install's store) in config/local/user.yml.",
921
+ keywords: ["hub", "den hub", "mcp", "team", "token", "docs", "mocks"],
922
+ scope: "user",
923
+ control: { type: "custom", render: renderHub },
924
+ keys: ["hub_allow_tools"],
925
+ read: () => null,
926
+ write: (v, ctx) => ctx.post({ hub_allow_tools: v }),
927
+ },
928
+
914
929
  {
915
930
  id: "agents.personas",
916
931
  category: "agents",
@@ -1275,6 +1290,9 @@ function renderRuntimes(host, ctx) {
1275
1290
  void load();
1276
1291
  }
1277
1292
 
1293
+ /** "an agent", "a persona": the article for a lowercase noun. */
1294
+ export const withArticle = (w) => `${/^[aeiou]/.test(w) ? "an" : "a"} ${w}`;
1295
+
1278
1296
  const ROT_SCOPES = [
1279
1297
  ["agent", "Agent", "agent id"],
1280
1298
  ["persona-workspace", "Persona in a workspace", "persona@workspace"],
@@ -1288,6 +1306,67 @@ const ROT_KEYS = [
1288
1306
  ["on_pr_merged", "When its PR merges", "true or false"],
1289
1307
  ];
1290
1308
  const rotVal = (v) => (v === undefined ? "" : String(v));
1309
+ /** A value as a person reads it (D30): "never", "Auto", "Relaunch". */
1310
+ function rotValText(k, v) {
1311
+ if (k === "on_due")
1312
+ return { auto: "Auto", ask: "Ask", off: "Off" }[v] ?? rotVal(v);
1313
+ if (k === "on_pr_merged")
1314
+ return v === true ? "Relaunch" : v === false ? "Keep going" : rotVal(v);
1315
+ return rotVal(v);
1316
+ }
1317
+
1318
+ /**
1319
+ * D30: an override's target as a name, from the pickers' choices. One that
1320
+ * no longer exists: its id, and gone.
1321
+ */
1322
+ export function rotTargetName(scope, target, c = {}) {
1323
+ const find = (list, id) => (list || []).find((x) => x.id === id);
1324
+ if (scope === "agent") {
1325
+ const a = find(c.agents, target);
1326
+ return a
1327
+ ? { name: a.workspace ? `${a.name} (${a.workspace})` : a.name }
1328
+ : { name: target, gone: true };
1329
+ }
1330
+ if (scope === "persona-workspace") {
1331
+ const [pid, ws] = String(target).split("@");
1332
+ const p = find(c.personas, pid);
1333
+ const w = find(c.workspaces, ws);
1334
+ return p && w
1335
+ ? { name: `${p.name} in ${w.name}` }
1336
+ : { name: target, gone: true };
1337
+ }
1338
+ const list =
1339
+ scope === "persona"
1340
+ ? c.personas
1341
+ : scope === "workspace"
1342
+ ? c.workspaces
1343
+ : c.services;
1344
+ const x = find(list, target);
1345
+ return x ? { name: x.name } : { name: target, gone: true };
1346
+ }
1347
+
1348
+ /** D30: each picker's rows, from GET /api/agents/rotation?choices=1. */
1349
+ export function rotPickItems(kind, c = {}) {
1350
+ if (kind === "agent")
1351
+ return (c.agents || []).map((a) => ({
1352
+ id: a.id,
1353
+ label: a.name,
1354
+ sub: [a.workspace, a.state].filter(Boolean).join(" · "),
1355
+ }));
1356
+ if (kind === "persona")
1357
+ return (c.personas || []).map((p) => ({
1358
+ id: p.id,
1359
+ label: p.name,
1360
+ sub: p.category,
1361
+ }));
1362
+ if (kind === "workspace")
1363
+ return (c.workspaces || []).map((w) => ({ id: w.id, label: w.name }));
1364
+ return (c.services || []).map((x) => ({
1365
+ id: x.id,
1366
+ label: x.name,
1367
+ sub: x.workspace,
1368
+ }));
1369
+ }
1291
1370
 
1292
1371
  /** A value typed in the overrides form, as the key takes it. */
1293
1372
  export function rotationValue(key, raw) {
@@ -1314,7 +1393,7 @@ export function rotationHtml(b) {
1314
1393
  const field = (k, control) =>
1315
1394
  `<div class="rot-f" data-rot-key="${k}"><label for="rot-${k}">${escR(ROT_KEYS.find((x) => x[0] === k)[1])}</label>${control}<span class="set-tag" data-rot-src>${escR(inst[k] !== undefined ? "this install" : inherited(k))}</span></div>`;
1316
1395
  const sel = (k, opts) =>
1317
- `<select id="rot-${k}" data-focus-key="rot-${k}"><option value=""${inst[k] === undefined ? " selected" : ""}>inherit (${escR(inherited(k))})</option>` +
1396
+ `<select id="rot-${k}" data-focus-key="rot-${k}"><option value=""${inst[k] === undefined ? " selected" : ""}>inherit (${escR(rotVal(b.org?.[k] !== undefined ? b.org[k] : b.defaults?.[k]))})</option>` +
1318
1397
  opts
1319
1398
  .map(
1320
1399
  ([v, l]) =>
@@ -1325,11 +1404,16 @@ export function rotationHtml(b) {
1325
1404
  const rows = [];
1326
1405
  for (const [scope, byTarget] of Object.entries(b.overrides || {}))
1327
1406
  for (const [target, vals] of Object.entries(byTarget || {}))
1328
- for (const [k, v] of Object.entries(vals || {}))
1407
+ for (const [k, v] of Object.entries(vals || {})) {
1408
+ const who = rotTargetName(scope, target, b.choices);
1409
+ const setting = (ROT_KEYS.find((x) => x[0] === k) || [k, k])[1];
1329
1410
  rows.push(
1330
- `<tr data-rot-row><td>${escR((ROT_SCOPES.find((x) => x[0] === scope) || [scope, scope])[1])}</td><td class="den-mono">${escR(target)}</td><td>${escR(k)}</td><td class="den-mono">${escR(rotVal(v))}</td>` +
1331
- `<td><button type="button" class="btn sm" data-rot-clear data-scope="${escR(scope)}" data-target="${escR(target)}" data-key="${escR(k)}" aria-label="Clear ${escR(k)} for ${escR(scope)} ${escR(target)}">Clear</button></td></tr>`,
1411
+ `<tr data-rot-row${who.gone ? " data-rot-gone" : ""}><td data-l="Scope">${escR((ROT_SCOPES.find((x) => x[0] === scope) || [scope, scope])[1])}</td>` +
1412
+ `<td data-l="For"${who.gone ? ' class="rot-gone"' : ""}>${who.gone ? `<span class="den-mono">${escR(who.name)}</span> (gone)` : escR(who.name)}</td>` +
1413
+ `<td data-l="Setting">${escR(setting)}</td><td data-l="Value">${escR(rotValText(k, v))}</td>` +
1414
+ `<td class="rot-act"><button type="button" class="btn sm" data-rot-clear data-scope="${escR(scope)}" data-target="${escR(target)}" data-key="${escR(k)}" aria-label="${who.gone ? "Remove" : "Clear"} ${escR(setting)} for ${escR(who.name)}">${who.gone ? "Remove" : "Clear"}</button></td></tr>`,
1332
1415
  );
1416
+ }
1333
1417
  return (
1334
1418
  `<div class="rot" data-rotation-settings>` +
1335
1419
  `<div class="rot-inst">` +
@@ -1359,16 +1443,84 @@ export function rotationHtml(b) {
1359
1443
  : `<tr><td colspan="5" class="set-note">No overrides: every agent uses this install's values.</td></tr>`) +
1360
1444
  `</tbody></table></div>` +
1361
1445
  `<form class="rot-add" data-rot-add>` +
1362
- `<label for="rot-a-scope">Scope</label> <select id="rot-a-scope">${ROT_SCOPES.map(([v, l]) => `<option value="${v}">${escR(l)}</option>`).join("")}</select> ` +
1363
- `<label for="rot-a-target">For</label> <input type="text" id="rot-a-target" class="den-mono" spellcheck="false" autocomplete="off" placeholder="agent id" required> ` +
1364
- `<label for="rot-a-key">Setting</label> <select id="rot-a-key">${ROT_KEYS.map(([v, l]) => `<option value="${v}">${escR(l)}</option>`).join("")}</select> ` +
1365
- `<label for="rot-a-value">Value</label> <input type="text" id="rot-a-value" class="den-mono" spellcheck="false" autocomplete="off" placeholder="a number, or never" required> ` +
1446
+ `<span class="rot-pick"><label for="rot-a-scope">Scope</label> <select id="rot-a-scope">${ROT_SCOPES.map(([v, l]) => `<option value="${v}">${escR(l)}</option>`).join("")}</select></span>` +
1447
+ // The target: picked by name, never typed as an id (D30).
1448
+ `<span class="rot-pick" data-for="agent">${comboHtml({ id: "rot-a-agent", label: "Agent", placeholder: "Type to find an agent" })}</span>` +
1449
+ `<span class="rot-pick" data-for="persona persona-workspace" hidden>${comboHtml({ id: "rot-a-persona", label: "Persona", placeholder: "Type to find a persona" })}</span>` +
1450
+ `<span class="rot-pick" data-for="workspace persona-workspace" hidden>${comboHtml({ id: "rot-a-ws", label: "Workspace", placeholder: "Type to find a workspace" })}</span>` +
1451
+ `<span class="rot-pick" data-for="service" hidden>${comboHtml({ id: "rot-a-service", label: "Service", placeholder: "Type to find a service" })}</span>` +
1452
+ `<span class="rot-pick"><label for="rot-a-key">Setting</label> <select id="rot-a-key">${ROT_KEYS.map(([v, l]) => `<option value="${v}">${escR(l)}</option>`).join("")}</select></span>` +
1453
+ `<span class="rot-pick" data-val="after_compactions"><label for="rot-a-n">Value</label> <input type="number" id="rot-a-n" min="1" max="100" step="1" value="3"> <label class="rot-never"><input type="checkbox" id="rot-a-never"> never</label></span>` +
1454
+ `<span class="rot-pick" data-val="on_due" hidden><label for="rot-a-due">Value</label> <select id="rot-a-due"><option value="auto">Auto</option><option value="ask">Ask</option><option value="off">Off</option></select></span>` +
1455
+ `<span class="rot-pick" data-val="on_pr_merged" hidden><label for="rot-a-pr">Value</label> <select id="rot-a-pr"><option value="true">Relaunch</option><option value="false">Keep going</option></select></span>` +
1366
1456
  `<button type="submit" class="btn sm primary">Add override</button></form>` +
1367
1457
  `<div class="rt-err" role="alert" hidden></div>` +
1368
1458
  `</div>`
1369
1459
  );
1370
1460
  }
1371
1461
 
1462
+ /** HUB-1.5: Settings › Agents › Den Hub (b: GET /api/hub). */
1463
+ export function hubHtml(b) {
1464
+ if (!b || !b.configured) return "";
1465
+ const rows = [
1466
+ ["URL", `<span class="den-mono">${escR(b.url || "not set")}</span>`],
1467
+ [
1468
+ "Status",
1469
+ b.connected
1470
+ ? `<span class="set-tag ok" data-hub-state="connected">Connected</span>`
1471
+ : `<span class="set-tag warn" data-hub-state="down">Not connected</span>`,
1472
+ ],
1473
+ ...(b.team ? [["Team", escR(b.team)]] : []),
1474
+ ...(b.label ? [["This install", escR(b.label)]] : []),
1475
+ ];
1476
+ const tools =
1477
+ `<label class="hub-tools"><input type="checkbox" id="hub-allow-tools"${b.allowTools !== false ? " checked" : ""}> Agents use Hub's tools without asking</label>` +
1478
+ `<p class="set-note" data-hub-tools-note>${
1479
+ b.allowTools !== false
1480
+ ? "Agents can publish and comment in Hub without asking; writes are versioned and stay in this install's team."
1481
+ : "Agents ask before each Hub tool runs."
1482
+ }</p>`;
1483
+ return (
1484
+ `<dl class="hub-st" data-hub>` +
1485
+ rows.map(([k, v]) => `<dt>${escR(k)}</dt><dd>${v}</dd>`).join("") +
1486
+ `</dl>` +
1487
+ tools +
1488
+ (!b.connected && b.problem
1489
+ ? `<p class="set-note" data-hub-problem>${escR(b.problem)}</p>`
1490
+ : "")
1491
+ );
1492
+ }
1493
+
1494
+ /** HUB-1.5: the Den Hub row; with no hub.url, the row isn't shown at all. */
1495
+ function renderHub(host, ctx) {
1496
+ const row = host.closest(".set-row");
1497
+ if (row) row.hidden = true;
1498
+ // Under an invalid config /api/hub answers 422: ask nothing, show nothing.
1499
+ if (ctx.shell?.configInvalid?.() === true) return Promise.resolve();
1500
+ return ctx.shell
1501
+ .api("GET", "/api/hub")
1502
+ .then((b) => {
1503
+ const html = hubHtml(b);
1504
+ host.innerHTML = html;
1505
+ if (row) row.hidden = !html;
1506
+ const box = host.querySelector("#hub-allow-tools");
1507
+ if (box)
1508
+ box.addEventListener("change", () => {
1509
+ box.disabled = true;
1510
+ ctx.shell
1511
+ .api("POST", "/api/settings", { hub_allow_tools: box.checked })
1512
+ .then(() => renderHub(host, ctx))
1513
+ .catch(() => {
1514
+ box.checked = !box.checked;
1515
+ box.disabled = false;
1516
+ });
1517
+ });
1518
+ })
1519
+ .catch(() => {
1520
+ host.innerHTML = "";
1521
+ });
1522
+ }
1523
+
1372
1524
  /** The Rotation row: this install's values, the overrides, + Add override. */
1373
1525
  function renderRotation(host, ctx) {
1374
1526
  const err = () => host.querySelector(".rt-err");
@@ -1385,14 +1537,54 @@ function renderRotation(host, ctx) {
1385
1537
  return Promise.resolve();
1386
1538
  }
1387
1539
  return ctx.shell
1388
- .api("GET", "/api/agents/rotation")
1540
+ .api("GET", "/api/agents/rotation?choices=1")
1389
1541
  .then((b) => {
1390
1542
  host.innerHTML = rotationHtml(b || {});
1543
+ wireAdd(b?.choices || {});
1391
1544
  })
1392
1545
  .catch((x) => {
1393
1546
  host.innerHTML = `<div class="set-note">${escR(x?.message || "The rotation settings did not load.")}</div>`;
1394
1547
  });
1395
1548
  };
1549
+ // D30: the add form's pickers, filled from live data.
1550
+ let picks = {};
1551
+ const wireAdd = (c) => {
1552
+ picks = {};
1553
+ for (const [kind, id] of [
1554
+ ["agent", "rot-a-agent"],
1555
+ ["persona", "rot-a-persona"],
1556
+ ["workspace", "rot-a-ws"],
1557
+ ["service", "rot-a-service"],
1558
+ ])
1559
+ picks[kind] = wireCombo(host, id, { items: () => rotPickItems(kind, c) });
1560
+ showFor();
1561
+ };
1562
+ const showFor = () => {
1563
+ const scope = host.querySelector("#rot-a-scope")?.value;
1564
+ const key = host.querySelector("#rot-a-key")?.value;
1565
+ for (const el of host.querySelectorAll(".rot-add [data-for]"))
1566
+ el.hidden = !el.dataset.for.split(" ").includes(scope);
1567
+ for (const el of host.querySelectorAll(".rot-add [data-val]"))
1568
+ el.hidden = el.dataset.val !== key;
1569
+ const n = host.querySelector("#rot-a-n");
1570
+ const never = host.querySelector("#rot-a-never");
1571
+ if (n && never) n.disabled = never.checked;
1572
+ };
1573
+ const targetOf = (scope) => {
1574
+ const v = (k) => picks[k]?.value() || "";
1575
+ if (scope === "persona-workspace")
1576
+ return v("persona") && v("workspace")
1577
+ ? `${v("persona")}@${v("workspace")}`
1578
+ : "";
1579
+ return v(scope);
1580
+ };
1581
+ const valueOf = (key) => {
1582
+ if (key === "on_due") return host.querySelector("#rot-a-due").value;
1583
+ if (key === "on_pr_merged")
1584
+ return host.querySelector("#rot-a-pr").value === "true";
1585
+ if (host.querySelector("#rot-a-never").checked) return "never";
1586
+ return Number(host.querySelector("#rot-a-n").value);
1587
+ };
1396
1588
  const put = async (body) => {
1397
1589
  try {
1398
1590
  await ctx.shell.api("PUT", "/api/agents/rotation", body);
@@ -1403,16 +1595,11 @@ function renderRotation(host, ctx) {
1403
1595
  };
1404
1596
  host.addEventListener("change", (e) => {
1405
1597
  const k = e.target.closest("[data-rot-key]")?.dataset.rotKey;
1406
- if (e.target.id === "rot-a-scope") {
1407
- const s = ROT_SCOPES.find((x) => x[0] === e.target.value);
1408
- const t = host.querySelector("#rot-a-target");
1409
- if (s && t) t.placeholder = s[2];
1410
- return;
1411
- }
1412
- if (e.target.id === "rot-a-key") {
1413
- const s = ROT_KEYS.find((x) => x[0] === e.target.value);
1414
- const v = host.querySelector("#rot-a-value");
1415
- if (s && v) v.placeholder = s[2];
1598
+ if (
1599
+ ["rot-a-scope", "rot-a-key", "rot-a-never"].includes(e.target.id) ||
1600
+ e.target.closest?.(".rot-add")
1601
+ ) {
1602
+ showFor();
1416
1603
  return;
1417
1604
  }
1418
1605
  if (!k || k === "after_compactions" || e.target.tagName !== "SELECT")
@@ -1447,12 +1634,18 @@ function renderRotation(host, ctx) {
1447
1634
  if (!f) return;
1448
1635
  e.preventDefault();
1449
1636
  const key = f.querySelector("#rot-a-key").value;
1450
- void put({
1451
- scope: f.querySelector("#rot-a-scope").value,
1452
- target: f.querySelector("#rot-a-target").value.trim(),
1453
- key,
1454
- value: rotationValue(key, f.querySelector("#rot-a-value").value),
1455
- });
1637
+ const scope = f.querySelector("#rot-a-scope").value;
1638
+ const target = targetOf(scope);
1639
+ if (!target) {
1640
+ fail({
1641
+ message:
1642
+ scope === "persona-workspace"
1643
+ ? "Pick a persona and a workspace."
1644
+ : `Pick ${withArticle((ROT_SCOPES.find((x) => x[0] === scope) || [scope, scope])[1].toLowerCase())}.`,
1645
+ });
1646
+ return;
1647
+ }
1648
+ void put({ scope, target, key, value: valueOf(key) });
1456
1649
  });
1457
1650
  void load();
1458
1651
  }