@monster-orange/den 0.4.12 → 0.4.14

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 (46) hide show
  1. package/CHANGELOG.md +151 -0
  2. package/README.md +7 -7
  3. package/docs/distributions.md +189 -11
  4. package/package.json +1 -1
  5. package/src/shared/agents/conversations.ts +227 -0
  6. package/src/shared/agents/default-pack/be-dev.md +1 -0
  7. package/src/shared/agents/default-pack/devops.md +1 -0
  8. package/src/shared/agents/default-pack/fe-dev.md +1 -0
  9. package/src/shared/agents/default-pack/orchestrator.md +3 -1
  10. package/src/shared/agents/default-pack/planner.md +1 -0
  11. package/src/shared/agents/default-pack/reviewer.md +1 -0
  12. package/src/shared/agents/default-pack/tester.md +16 -0
  13. package/src/shared/agents/default-pack/ux-designer.md +1 -0
  14. package/src/shared/agents/den-claude.ts +128 -13
  15. package/src/shared/agents/permissions.ts +108 -0
  16. package/src/shared/agents/persona-store.ts +286 -0
  17. package/src/shared/agents/personas.ts +116 -12
  18. package/src/shared/agents/records.ts +33 -0
  19. package/src/shared/agents/roles.ts +288 -0
  20. package/src/shared/agents/runtime.ts +50 -0
  21. package/src/shared/den-config.ts +22 -1
  22. package/src/shared/environment-manager/service-runner.ts +31 -12
  23. package/src/shared/environment-manager/workspaces.ts +11 -0
  24. package/src/ui/cli/agent-ops.ts +796 -29
  25. package/src/ui/cli/agents-command.ts +97 -3
  26. package/src/ui/cli/env-probe.ts +4 -1
  27. package/src/ui/mcp/api.ts +12 -1
  28. package/src/ui/mcp/tools.ts +60 -1
  29. package/src/ui/server/agent-resume-grant.ts +35 -0
  30. package/src/ui/server/agent-routes.ts +412 -22
  31. package/src/ui/server/public/agent-start.js +3 -2
  32. package/src/ui/server/public/agents-page.js +693 -49
  33. package/src/ui/server/public/agents.html +76 -15
  34. package/src/ui/server/public/i18n/es.js +143 -0
  35. package/src/ui/server/public/i18n/ja.js +144 -0
  36. package/src/ui/server/public/persona-ui.css +563 -0
  37. package/src/ui/server/public/persona-ui.js +796 -0
  38. package/src/ui/server/public/settings-page.js +4 -9
  39. package/src/ui/server/public/settings-registry.js +22 -0
  40. package/src/ui/server/public/settings.html +1 -0
  41. package/src/ui/server/public/shell.js +140 -0
  42. package/src/ui/server/public/topbar.js +2 -2
  43. package/src/ui/server/request-guard.ts +17 -4
  44. package/src/ui/server/route-classes.ts +31 -0
  45. package/src/ui/server/session-routes.ts +21 -5
  46. package/src/ui/server/ui-assets.ts +3 -0
@@ -27,8 +27,17 @@
27
27
  * Nothing here keeps a session's state: the list is GET /api/sessions,
28
28
  * asked again every few seconds while the page is shown.
29
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
30
+ * Start agent (AG-10.1): a searchable persona list (category and source
31
+ * chips, grouped by category, Plain session pinned last), What's a
32
+ * persona?, Details, + New persona, Duplicate or Edit (persona-ui.js),
33
+ * Runtime and Workspace, a first instruction (typed into the session once
34
+ * it is ready, by the server: never an argument), and Advanced: model and
35
+ * version (AG-12), permission mode and extra allowed commands with the
36
+ * preview of what the runtime is given (AG-13). What differs from the
37
+ * persona tags the session's row (AG-10.9). The runtime line says what
38
+ * will run, or why it can't, before Start (agent-start.js readiness); a
39
+ * persona whose runtime isn't installed starts on the first one that is,
40
+ * and the dialog says so (AG-12.2). Starting, stopping and
32
41
  * resuming an agent never ask for the admin password: it is for sudo, and
33
42
  * no agent action runs sudo (LK-0).
34
43
  *
@@ -74,7 +83,27 @@
74
83
  * and Start fresh. A running one says "Handoff due" or "Ready to relaunch"
75
84
  * as a tag.
76
85
  */
77
- import { fillStartForm } from "/agent-start.js";
86
+ import { readiness, runtimeName, runtimeOptions } from "/agent-start.js";
87
+ import {
88
+ commandProblem,
89
+ filterBarHtml,
90
+ modelOptionsHtml,
91
+ normalizeData,
92
+ openPersonaEditor,
93
+ overrideTagsHtml,
94
+ permBlockHtml,
95
+ personaDetail,
96
+ personaDetailHtml,
97
+ personaListHtml,
98
+ personaName,
99
+ personaRowHtml,
100
+ personaRuntime,
101
+ runtimeInstalled,
102
+ splitCommands,
103
+ syncPermBlock,
104
+ versionLabel,
105
+ versionOptionsHtml,
106
+ } from "/persona-ui.js";
78
107
  import {
79
108
  editorUrl,
80
109
  findPaths,
@@ -240,7 +269,7 @@ function nameHtml(r) {
240
269
  * persona, not the name already shown. Else "".
241
270
  */
242
271
  function sessionExtra(r) {
243
- const n = r?.kind === "agent" ? (r.sessionName ?? "").trim() : "";
272
+ const n = r?.kind === "agent" ? (r.sessionTitle ?? "").trim() : "";
244
273
  if (!n || n === r.persona || n === paneName(r) || n === displayName(r))
245
274
  return "";
246
275
  return n;
@@ -377,9 +406,10 @@ function rowHtml(r) {
377
406
  const revokedTag = r.tokenRevoked
378
407
  ? `<span class="tagx warn" title="${esc(t("ag.revokedTip"))}">${esc(t("ag.revoked"))}</span>`
379
408
  : "";
409
+ const ovTags = r.kind === "agent" ? overrideTagsHtml(r) : "";
380
410
  const tags =
381
- rotTag || revokedTag
382
- ? `<span class="agtags">${revokedTag}${rotTag}</span>`
411
+ rotTag || revokedTag || ovTags
412
+ ? `<span class="agtags">${revokedTag}${rotTag}${ovTags}</span>`
383
413
  : "";
384
414
  return (
385
415
  `<button type="button" class="agr" data-id="${esc(r.id)}" data-kind="${esc(r.kind)}" data-state="${esc(r.state)}"` +
@@ -883,11 +913,26 @@ function refit(p) {
883
913
  markSize(p);
884
914
  }
885
915
 
886
- /** The pane's size and the PTY's, on the pane (data-cols, data-pty-cols). */
916
+ /**
917
+ * The pane's size and the PTY's, on the pane (data-cols, data-pty-cols),
918
+ * and the cell size xterm drew with (data-cell-w, data-cell-h: its
919
+ * renderer's CSS cell, sub-pixel), so a check can tell a missed row from
920
+ * a fit's remainder.
921
+ */
887
922
  function markSize(p) {
888
923
  if (!p.term) return;
889
924
  p.el.dataset.cols = String(p.term.cols);
890
925
  p.el.dataset.rows = String(p.term.rows);
926
+ let cell = null;
927
+ try {
928
+ cell = p.term._core?._renderService?.dimensions?.css?.cell ?? null;
929
+ } catch {
930
+ cell = null;
931
+ }
932
+ if (cell?.width > 0 && cell?.height > 0) {
933
+ p.el.dataset.cellW = String(cell.width);
934
+ p.el.dataset.cellH = String(cell.height);
935
+ }
891
936
  if (p.pty) {
892
937
  p.el.dataset.ptyCols = String(p.pty.cols);
893
938
  p.el.dataset.ptyRows = String(p.pty.rows);
@@ -1448,7 +1493,7 @@ function review() {
1448
1493
  )?.focus();
1449
1494
  }
1450
1495
 
1451
- // ── Start agent ─────────────────────────────────────────────────────────
1496
+ // ── Start agent (AG-10.1) ───────────────────────────────────────────────
1452
1497
 
1453
1498
  // Personas and runtimes, per workspace (AG-0.5: a workspace's own runtime
1454
1499
  // paths win), asked once per page each.
@@ -1470,81 +1515,667 @@ function personasFor(ws) {
1470
1515
  return personasAsked.get(key);
1471
1516
  }
1472
1517
 
1473
- /** Fill the form for the workspace picked; false when the ask failed. */
1474
- async function fillForWorkspace(ws) {
1475
- let data;
1518
+ /**
1519
+ * "Plain session": no persona, a shell in the workspace (AG-10.1). A ":"
1520
+ * is never in a persona's id.
1521
+ */
1522
+ const PLAIN = ":plain";
1523
+
1524
+ /** The dialog's state while it is open. */
1525
+ const sd = {
1526
+ data: null,
1527
+ f: { q: "", cat: "", src: "" },
1528
+ pid: "",
1529
+ det: false,
1530
+ what: false,
1531
+ detail: null,
1532
+ // Advanced: "" = the persona's.
1533
+ model: "",
1534
+ ver: "",
1535
+ intent: "ask",
1536
+ extras: [],
1537
+ extrasOpen: false,
1538
+ // AG-17.1, Bring in: null for Start; else the workspace's conversations,
1539
+ // the runtimes that can't bring one in, and the one picked.
1540
+ bring: null,
1541
+ };
1542
+
1543
+ /** The conversation picked in Bring in, when it can be brought in. */
1544
+ function bringPick() {
1545
+ const c = (sd.bring?.list ?? []).find((x) => x.id === sd.bring.pick);
1546
+ return c && !c.open && !c.agent ? c : null;
1547
+ }
1548
+
1549
+ /** "3 min ago", "2 h ago", or the date. */
1550
+ function agoText(iso) {
1551
+ const ms = Date.now() - Date.parse(iso);
1552
+ if (!Number.isFinite(ms)) return "";
1553
+ const m = Math.round(ms / 60000);
1554
+ if (m < 1) return t("ag.bring.now");
1555
+ if (m < 60) return t("ag.bring.min", { n: m });
1556
+ const h = Math.round(m / 60);
1557
+ if (h < 48) return t("ag.bring.h", { n: h });
1558
+ return iso.slice(0, 10);
1559
+ }
1560
+
1561
+ /** The folder a conversation ran in, inside the workspace's when it is. */
1562
+ function convFolder(c) {
1563
+ const root = sd.bring?.root;
1564
+ if (root && c.cwd === root) return ".";
1565
+ if (root && c.cwd.startsWith(`${root}/`)) return c.cwd.slice(root.length + 1);
1566
+ return c.cwd;
1567
+ }
1568
+
1569
+ function convRowHtml(c) {
1570
+ const taken = c.agent
1571
+ ? ` <span class="tagx">${esc(t("ag.bring.agent", { id: c.agent }))}</span>`
1572
+ : c.open
1573
+ ? ` <span class="tagx warn" title="${esc(t("ag.bring.openTip"))}">${esc(t("ag.bring.open"))}</span>`
1574
+ : "";
1575
+ const off = c.agent || c.open;
1576
+ return (
1577
+ `<button type="button" class="pr" role="radio" data-cid="${esc(c.id)}" aria-checked="${c.id === sd.bring.pick}"${off ? ' aria-disabled="true"' : ""}>` +
1578
+ `<span class="rd" aria-hidden="true"></span><span class="pn pnc"><span class="pt">${esc(c.title || t("ag.bring.untitled"))}</span>${taken}</span><span class="mono">${esc(agoText(c.lastAt))}</span>` +
1579
+ `<span class="pd mono">${esc(convFolder(c))} · ${esc(c.id.slice(0, 8))}</span></button>`
1580
+ );
1581
+ }
1582
+
1583
+ /** Bring in's conversation list and its help line. */
1584
+ function paintConversations() {
1585
+ const f = $d("#ag-conv-f");
1586
+ f.hidden = !sd.bring;
1587
+ if (!sd.bring) return;
1588
+ const list = sd.bring.list;
1589
+ const cant = (sd.bring.runtimes ?? [])
1590
+ .filter((r) => !r.bringIn && r.id !== "generic")
1591
+ .map((r) => r.why);
1592
+ $d("#ag-conv-h").textContent = [
1593
+ list.length ? t("ag.bring.help") : t("ag.bring.none"),
1594
+ // Each its own sentence (they read as one run-on line before).
1595
+ ...cant.map((w) => (/[.!?]$/.test(w) ? w : `${w}.`)),
1596
+ ].join(" ");
1597
+ $d("#ag-conv").innerHTML = list.map(convRowHtml).join("");
1598
+ $d("#ag-conv").hidden = !list.length;
1599
+ }
1600
+
1601
+ /** Ask for the workspace's conversations (Bring in). */
1602
+ async function fillConversations(ws) {
1603
+ if (!sd.bring) return true;
1476
1604
  try {
1477
- data = await personasFor(ws);
1605
+ const r = await api(
1606
+ "GET",
1607
+ `/api/workspaces/${encodeURIComponent(ws)}/conversations`,
1608
+ );
1609
+ sd.bring.list = r?.conversations ?? [];
1610
+ sd.bring.runtimes = r?.runtimes ?? [];
1611
+ sd.bring.root = r?.folder ?? "";
1478
1612
  } catch (err) {
1479
1613
  window.denShell?.errorToast?.(err);
1480
1614
  return false;
1481
1615
  }
1482
- const form = st.root.querySelector("#ag-form");
1483
- const keep = [
1484
- form.querySelector("#ag-persona").value,
1485
- form.querySelector("#ag-runtime").value,
1486
- ];
1487
- fillStartForm(form, data, t);
1488
- // The persona and runtime picked stay, where the new list still has them.
1489
- const ps = form.querySelector("#ag-persona");
1490
- if (keep[0] && [...ps.options].some((o) => o.value === keep[0])) {
1491
- ps.value = keep[0];
1492
- ps.onchange?.();
1616
+ if (!sd.bring.list.some((c) => c.id === sd.bring.pick))
1617
+ sd.bring.pick = sd.bring.list.find((c) => !c.open && !c.agent)?.id ?? "";
1618
+ paintConversations();
1619
+ return true;
1620
+ }
1621
+
1622
+ function pickConversation(id) {
1623
+ const c = (sd.bring?.list ?? []).find((x) => x.id === id);
1624
+ if (!c || c.open || c.agent) return;
1625
+ sd.bring.pick = id;
1626
+ for (const b of st.root.querySelectorAll("#ag-conv [data-cid]"))
1627
+ b.setAttribute("aria-checked", String(b.dataset.cid === id));
1628
+ paintRuntime(false);
1629
+ }
1630
+
1631
+ const $d = (sel) => st.root.querySelector(sel);
1632
+ const curPersona = () =>
1633
+ sd.pid === PLAIN
1634
+ ? null
1635
+ : (sd.data?.personas ?? []).find((p) => p.id === sd.pid) || null;
1636
+
1637
+ /** The runtime the dialog will start: the one picked, else the persona's. */
1638
+ function chosenRuntime() {
1639
+ const p = curPersona();
1640
+ return $d("#ag-runtime").value || personaRuntime(sd.data, p);
1641
+ }
1642
+
1643
+ /** The persona's own model and version, on the runtime chosen (AG-12.3). */
1644
+ function personaModel() {
1645
+ const p = curPersona();
1646
+ const same = p && personaRuntime(sd.data, p) === chosenRuntime();
1647
+ return {
1648
+ model: same ? p.model || "" : "",
1649
+ version: same ? p.version || "latest" : "latest",
1650
+ };
1651
+ }
1652
+
1653
+ /** The persona list's rows, and Plain session pinned at the bottom. */
1654
+ function plistHtml() {
1655
+ const ps = sd.data?.personas ?? [];
1656
+ const cats = sd.data?.categories ?? [];
1657
+ const rows = personaListHtml(sd.f, ps, cats, (p) =>
1658
+ personaRowHtml(p, p.id === sd.pid, sd.data),
1659
+ );
1660
+ // Bring in continues a conversation: it needs a persona.
1661
+ if (sd.bring) return rows;
1662
+ return (
1663
+ rows +
1664
+ `<div class="pgh"><span>${esc(t("ag.session"))}</span><span></span></div>` +
1665
+ `<button type="button" class="pr" role="radio" data-pid="${esc(PLAIN)}" aria-checked="${sd.pid === PLAIN}"><span class="rd" aria-hidden="true"></span><span class="pn">${esc(t("ag.new.plain"))}</span><span></span><span class="pd">${esc(t("ag.new.plainP"))}</span></button>`
1666
+ );
1667
+ }
1668
+
1669
+ function paintPlist(keepScroll = true) {
1670
+ const l = $d("#ag-persona");
1671
+ const top = l.scrollTop;
1672
+ l.innerHTML = plistHtml();
1673
+ l.scrollTop = keepScroll ? top : 0;
1674
+ }
1675
+
1676
+ /** The Details row (and Duplicate or Edit) under the list, and Details. */
1677
+ function paintDetails() {
1678
+ const p = curPersona();
1679
+ const el = $d("#ag-detrow");
1680
+ if (!p) {
1681
+ el.innerHTML = "";
1682
+ return;
1683
+ }
1684
+ const mine = p.origin === "local";
1685
+ el.innerHTML =
1686
+ `<div class="fldh"><button type="button" class="lnk" data-x="det" aria-expanded="${sd.det}" aria-controls="ag-det">${esc(sd.det ? t("ag.new.hide") : t("ag.new.details", { n: personaName(p) }))}</button><span class="sp"></span>` +
1687
+ (mine
1688
+ ? `<button type="button" class="lnk" data-x="edit">${esc(t("pe.editB"))}</button>`
1689
+ : "") +
1690
+ `<button type="button" class="lnk" data-x="dup">${esc(t("pe.dup"))}</button></div>` +
1691
+ (sd.det
1692
+ ? personaDetailHtml(
1693
+ sd.detail?.id === p.id ? sd.detail : { ...p, instructions: null },
1694
+ sd.data,
1695
+ )
1696
+ : "");
1697
+ }
1698
+
1699
+ /**
1700
+ * The runtime select and its readiness line (AG-0.2), and AG-12.2: a
1701
+ * persona whose runtime isn't installed starts on the first one that is,
1702
+ * and the dialog says so.
1703
+ */
1704
+ function paintRuntime(reset) {
1705
+ const rs = $d("#ag-runtime");
1706
+ const go = $d("#ag-go");
1707
+ const ready = $d("#ag-r-ready");
1708
+ const plain = sd.pid === PLAIN;
1709
+ // Bring in: the conversation's runtime, its own history; no first
1710
+ // instruction, no Advanced (it keeps what the persona gives).
1711
+ $d("#ag-r-l").hidden = plain || !!sd.bring;
1712
+ $d("#ag-task-l").hidden = plain || !!sd.bring;
1713
+ $d("#ag-adv").hidden = plain || !!sd.bring;
1714
+ $d("#ag-name-l").hidden = plain || !!sd.bring;
1715
+ paintStartsAs();
1716
+ if (sd.bring) {
1717
+ $d("#ag-r-miss").hidden = true;
1718
+ ready.hidden = true;
1719
+ const ok = !!bringPick() && !!curPersona();
1720
+ go.disabled = !ok;
1721
+ go.dataset.blocked = ok ? "" : "conversation";
1722
+ return;
1493
1723
  }
1494
- const rs = form.querySelector("#ag-runtime");
1495
- if ([...rs.options].some((o) => o.value === keep[1] && !o.disabled)) {
1496
- rs.value = keep[1];
1497
- rs.onchange?.();
1724
+ const fb = $d("#ag-r-fb");
1725
+ fb.hidden = true;
1726
+ if (plain) {
1727
+ $d("#ag-r-miss").hidden = true;
1728
+ ready.hidden = true;
1729
+ go.disabled = false;
1730
+ go.dataset.blocked = "";
1731
+ return;
1498
1732
  }
1733
+ const personas = sd.data?.personas ?? [];
1734
+ const runtimes = sd.data?.runtimes ?? [];
1735
+ const keep = reset ? "" : rs.value;
1736
+ rs.innerHTML = runtimeOptions(personas, runtimes, sd.pid, t);
1737
+ if ([...rs.options].some((o) => o.value === keep && !o.disabled))
1738
+ rs.value = keep;
1739
+ const p = curPersona();
1740
+ if (reset && p && !runtimeInstalled(sd.data, p)) {
1741
+ const first = [...rs.options].find((o) => o.value && !o.disabled);
1742
+ if (first) {
1743
+ rs.value = first.value;
1744
+ fb.textContent = t("rt.fallback", {
1745
+ p: personaName(p),
1746
+ r: runtimeName(runtimes, personaRuntime(sd.data, p)),
1747
+ f: runtimeName(runtimes, first.value),
1748
+ });
1749
+ fb.hidden = false;
1750
+ }
1751
+ }
1752
+ // AG-12.1: what isn't installed here, and Install… (later).
1753
+ const miss = runtimes.filter(
1754
+ (x) => !x.available && x.denTools !== undefined && x.source !== "setting",
1755
+ );
1756
+ const ml = $d("#ag-r-miss");
1757
+ ml.innerHTML = miss.length
1758
+ ? `${esc(t("rt.missingLine"))} ${miss.map((x) => `<span class="mono">${esc(x.label || x.id)}</span>`).join(", ")} · <span class="lnk" aria-disabled="true" title="${esc(t("rt.later"))}">${esc(t("rt.install"))}</span> <span class="tagx">${esc(t("rt.later"))}</span>`
1759
+ : "";
1760
+ ml.hidden = !miss.length;
1761
+ const r = readiness(sd.data, sd.pid, rs.value, t);
1762
+ ready.innerHTML = r.html;
1763
+ ready.hidden = !r.html;
1764
+ ready.dataset.tone = r.tone;
1765
+ go.disabled = !r.ok;
1766
+ go.dataset.blocked = r.ok ? "" : "runtime";
1767
+ }
1768
+
1769
+ /** Advanced (AG-12, AG-13): model, version and the permission block. */
1770
+ function paintAdvanced() {
1771
+ const p = curPersona();
1772
+ const body = $d("#ag-adv-b");
1773
+ if (!p) {
1774
+ body.innerHTML = "";
1775
+ return;
1776
+ }
1777
+ const rt = chosenRuntime();
1778
+ const rtv = (sd.data?.runtimes ?? []).find((r) => r.id === rt);
1779
+ const name = runtimeName(sd.data?.runtimes ?? [], rt);
1780
+ const def = personaModel();
1781
+ const models = rtv?.models ?? [];
1782
+ const model = sd.model || def.model;
1783
+ const first = t("ag.new.modelDef", {
1784
+ // No model of its own: just the version, so the label fits the select.
1785
+ m: def.model
1786
+ ? `${def.model} · ${versionLabel(def.version)}`
1787
+ : versionLabel("latest"),
1788
+ });
1789
+ const verFirst = t("ag.new.verDef", {
1790
+ v: versionLabel(
1791
+ sd.model && sd.model !== def.model ? "latest" : def.version,
1792
+ ),
1793
+ });
1794
+ body.innerHTML =
1795
+ `<p>${esc(t("ag.new.advNote"))}</p>` +
1796
+ `<div class="two"><label class="fld"><span>${esc(t("ag.new.model"))}</span><select id="ag-model"${models.length ? "" : " disabled"}>${models.length ? modelOptionsHtml(sd.data, rt, sd.model, first) : `<option value="">${esc(t("pe.nsModel", { r: name }))}</option>`}</select></label>` +
1797
+ `<label class="fld"><span>${esc(t("ag.new.ver"))}</span><select id="ag-ver"${models.length && model ? "" : " disabled"}>${versionOptionsHtml(sd.data, rt, model, sd.ver, verFirst)}</select></label></div>` +
1798
+ permBlockHtml("ag", sd.data, {
1799
+ intent: sd.intent,
1800
+ role: p.role,
1801
+ extras: sd.extras,
1802
+ runtime: rt,
1803
+ extrasOpen: sd.extrasOpen,
1804
+ disabled: !rtv?.permissions,
1805
+ });
1806
+ $d("#ag-tools").disabled = false;
1807
+ }
1808
+
1809
+ /** Pick persona `id` (or PLAIN): its own choices fill Advanced in. */
1810
+ function pickPersona(id) {
1811
+ sd.pid = id;
1812
+ sd.det = sd.det && id !== PLAIN;
1813
+ const p = curPersona();
1814
+ sd.model = "";
1815
+ sd.ver = "";
1816
+ sd.intent = p?.permission || "ask";
1817
+ sd.extras = [...(p?.allowedCommands ?? [])];
1818
+ sd.extrasOpen = sd.extras.length > 0;
1819
+ for (const b of st.root.querySelectorAll("#ag-persona [data-pid]"))
1820
+ b.setAttribute("aria-checked", String(b.dataset.pid === id));
1821
+ paintDetails();
1822
+ paintRuntime(true);
1823
+ paintAdvanced();
1824
+ if (sd.det) void loadDetail();
1825
+ }
1826
+
1827
+ async function loadDetail() {
1828
+ const p = curPersona();
1829
+ if (!p) return;
1830
+ try {
1831
+ const d = await personaDetail(p.id);
1832
+ if (curPersona()?.id !== d.id) return;
1833
+ sd.detail = d;
1834
+ paintDetails();
1835
+ } catch (err) {
1836
+ window.denShell?.errorToast?.(err);
1837
+ }
1838
+ }
1839
+
1840
+ /** The whole persona field, for the data asked. */
1841
+ function paintPersonaField() {
1842
+ const ps = sd.data?.personas ?? [];
1843
+ $d("#ag-pfbar").innerHTML = filterBarHtml(
1844
+ sd.f,
1845
+ "ag",
1846
+ ps,
1847
+ sd.data?.categories ?? [],
1848
+ );
1849
+ $d("#ag-what").hidden = !sd.what;
1850
+ paintPlist(false);
1851
+ }
1852
+
1853
+ /** Ask for the workspace's personas and runtimes; false when it failed. */
1854
+ async function fillForWorkspace(ws) {
1855
+ try {
1856
+ sd.data = normalizeData(await personasFor(ws));
1857
+ } catch (err) {
1858
+ window.denShell?.errorToast?.(err);
1859
+ return false;
1860
+ }
1861
+ if (!(await fillConversations(ws))) return false;
1862
+ if (sd.bring && sd.pid === PLAIN) sd.pid = "";
1863
+ const ps = sd.data?.personas ?? [];
1864
+ if (sd.pid !== PLAIN && !ps.some((p) => p.id === sd.pid)) {
1865
+ // The first persona the list shows (grouped by category).
1866
+ const cats = (sd.data?.categories ?? []).map((c) => c.id);
1867
+ const sorted = [...ps].sort(
1868
+ (a, b) =>
1869
+ (cats.indexOf(a.category) + 1 || 99) -
1870
+ (cats.indexOf(b.category) + 1 || 99),
1871
+ );
1872
+ sd.pid = sorted[0]?.id ?? PLAIN;
1873
+ }
1874
+ paintPersonaField();
1875
+ pickPersona(sd.pid);
1499
1876
  return true;
1500
1877
  }
1501
1878
 
1502
- async function openStart() {
1503
- const dlg = st.root.querySelector("#ag-dlg");
1879
+ /** The dialog's words for Start, or for Bring in (AG-17.1). */
1880
+ function dialogMode(bring) {
1881
+ sd.bring = bring ? { list: [], runtimes: [], root: "", pick: "" } : null;
1882
+ $d("#ag-dlg-h").textContent = t(bring ? "ag.bring.title" : "ag.new.title");
1883
+ $d("#ag-note").textContent = t(bring ? "ag.bring.note" : "ag.new.note");
1884
+ $d("#ag-go").textContent = t(bring ? "ag.bring.go" : "ag.go");
1885
+ $d("#ag-conv-f").hidden = !bring;
1886
+ }
1887
+
1888
+ async function openStart(bring = false) {
1889
+ const dlg = $d("#ag-dlg");
1504
1890
  if (!dlg || dlg.open) return;
1891
+ dialogMode(bring);
1505
1892
  const shown = viewWorkspaces();
1506
- const wsSel = st.root.querySelector("#ag-ws");
1893
+ const wsSel = $d("#ag-ws");
1507
1894
  wsSel.innerHTML = shown
1508
1895
  .map((w) => `<option value="${esc(w)}">${esc(w)}</option>`)
1509
1896
  .join("");
1510
- st.root.querySelector("#ag-ws-l").hidden = shown.length <= 1;
1897
+ wsSel.disabled = shown.length <= 1;
1898
+ sd.f = { q: "", cat: "", src: "" };
1899
+ sd.det = false;
1900
+ sd.what = false;
1901
+ $d("#ag-err").hidden = true;
1902
+ $d("#ag-task").value = "";
1903
+ $d("#ag-adv").open = false;
1511
1904
  if (!(await fillForWorkspace(wsSel.value || shown[0]))) return;
1512
1905
  if (dlg.open) return;
1513
1906
  if (typeof dlg.showModal === "function") dlg.showModal();
1514
1907
  else dlg.setAttribute("open", "");
1908
+ $d("#ag-q")?.focus();
1909
+ }
1910
+
1911
+ /** What Start sends (agent-routes.ts checks each field again). */
1912
+ function startBody() {
1913
+ const p = curPersona();
1914
+ const rt = chosenRuntime();
1915
+ const rtv = (sd.data?.runtimes ?? []).find((r) => r.id === rt);
1916
+ const runtime = $d("#ag-runtime").value;
1917
+ const body = { persona: p.id, ...(runtime ? { runtime } : {}) };
1918
+ const def = personaModel();
1919
+ if (rtv?.models?.length) {
1920
+ if (sd.model) body.model = sd.model;
1921
+ if (sd.ver) {
1922
+ body.version = sd.ver;
1923
+ body.model ??= def.model || undefined;
1924
+ if (!body.model) delete body.version;
1925
+ }
1926
+ }
1927
+ if (rtv?.permissions) {
1928
+ // A persona's own mode Den doesn't name (Claude Code's plan) stays its
1929
+ // own unless a mode is picked.
1930
+ if (p.permission !== undefined || sd.intent !== "ask")
1931
+ body.permission = sd.intent;
1932
+ body.allow = sd.extras;
1933
+ }
1934
+ const task = $d("#ag-task").value.trim();
1935
+ if (task) body.instruction = task;
1936
+ const name = $d("#ag-name").value.trim();
1937
+ if (name) body.name = name;
1938
+ return body;
1939
+ }
1940
+
1941
+ function showStartError(err) {
1942
+ const el = $d("#ag-err");
1943
+ el.textContent = err?.message || String(err);
1944
+ // AG-10.10: that agent is running: offer to open it.
1945
+ const running = err?.data?.running;
1946
+ if (running?.sessionId) {
1947
+ const b = document.createElement("button");
1948
+ b.type = "button";
1949
+ b.className = "den-btn sm";
1950
+ b.id = "ag-open-running";
1951
+ b.textContent = t("ag.new.openRunning");
1952
+ b.addEventListener("click", () => {
1953
+ $d("#ag-dlg").close?.();
1954
+ void openPane(running.sessionId);
1955
+ });
1956
+ el.append(" ", b);
1957
+ }
1958
+ el.hidden = false;
1959
+ }
1960
+
1961
+ /** AG-10.10: the id this start takes, so two of one persona are told apart. */
1962
+ function paintStartsAs() {
1963
+ const el = $d("#ag-as");
1964
+ if (!el) return;
1965
+ const p = sd.pid && sd.pid !== PLAIN && !sd.bring ? curPersona() : null;
1966
+ el.hidden = !p;
1967
+ if (p)
1968
+ el.textContent = t("ag.new.as", { id: sd.data?.startsAs?.[p.id] ?? p.id });
1515
1969
  }
1516
1970
 
1517
1971
  async function submitStart(form) {
1518
- const dlg = st.root.querySelector("#ag-dlg");
1519
- const go = st.root.querySelector("#ag-go");
1972
+ const dlg = $d("#ag-dlg");
1973
+ const go = $d("#ag-go");
1520
1974
  if (go.disabled) return;
1521
1975
  const ws = form.querySelector("#ag-ws").value || viewWorkspaces()[0];
1522
- const persona = form.querySelector("#ag-persona").value;
1523
- const runtime = form.querySelector("#ag-runtime").value;
1524
- if (!ws || !persona) return;
1976
+ if (!ws || !sd.pid) return;
1977
+ if (sd.bring) return submitBringIn(ws);
1978
+ const bad = sd.extras.find((c) => commandProblem(c));
1979
+ if (sd.pid !== PLAIN && bad)
1980
+ return showStartError(new Error(t("pe.badCmd", { c: bad })));
1525
1981
  go.disabled = true;
1526
1982
  go.textContent = t("ag.starting");
1527
1983
  try {
1984
+ if (sd.pid === PLAIN) {
1985
+ const r = await api(
1986
+ "POST",
1987
+ `/api/workspaces/${encodeURIComponent(ws)}/sessions`,
1988
+ );
1989
+ window.denShell?.toast?.(
1990
+ t("ag.new.started", { p: t("ag.session"), w: ws }),
1991
+ );
1992
+ dlg.close?.();
1993
+ await refresh();
1994
+ if (r?.session?.id) await openPane(r.session.id);
1995
+ return;
1996
+ }
1528
1997
  const r = await api(
1529
1998
  "POST",
1530
1999
  `/api/workspaces/${encodeURIComponent(ws)}/agents`,
1531
- {
1532
- persona,
1533
- ...(runtime ? { runtime } : {}),
1534
- },
2000
+ startBody(),
1535
2001
  );
1536
- window.denShell?.toast?.(t("ag.started", { id: r?.agent?.id ?? persona }));
2002
+ window.denShell?.toast?.(t("ag.started", { id: r?.agent?.id ?? sd.pid }));
2003
+ // AG-10.10: the next start of this persona takes another id.
2004
+ personasAsked.delete(ws);
2005
+ $d("#ag-name").value = "";
2006
+ for (const w of r?.warnings ?? []) window.denShell?.toast?.(w);
1537
2007
  dlg.close?.();
1538
2008
  await refresh();
1539
2009
  if (r?.agent?.sessionId) await openPane(r.agent.sessionId);
1540
2010
  } catch (err) {
1541
- window.denShell?.errorToast?.(err);
2011
+ showStartError(err);
1542
2012
  } finally {
1543
2013
  go.disabled = false;
1544
2014
  go.textContent = t("ag.go");
2015
+ if (dlg.open) paintRuntime(false);
2016
+ }
2017
+ }
2018
+
2019
+ /** Bring in (AG-17.1): the runtime's resume of the conversation picked. */
2020
+ async function submitBringIn(ws) {
2021
+ const dlg = $d("#ag-dlg");
2022
+ const go = $d("#ag-go");
2023
+ const c = bringPick();
2024
+ const p = curPersona();
2025
+ if (!c || !p) return;
2026
+ go.disabled = true;
2027
+ go.textContent = t("ag.bring.going");
2028
+ try {
2029
+ const r = await api(
2030
+ "POST",
2031
+ `/api/workspaces/${encodeURIComponent(ws)}/conversations/${encodeURIComponent(c.id)}/import`,
2032
+ { persona: p.id, runtime: c.runtime },
2033
+ );
2034
+ window.denShell?.toast?.(t("ag.bring.done", { id: r?.agent?.id ?? p.id }));
2035
+ for (const w of r?.warnings ?? []) window.denShell?.toast?.(w);
2036
+ dlg.close?.();
2037
+ await refresh();
2038
+ if (r?.agent?.sessionId) await openPane(r.agent.sessionId);
2039
+ } catch (err) {
2040
+ showStartError(err);
2041
+ // Still open elsewhere, or taken since: the list says so now.
2042
+ await fillConversations(ws);
2043
+ } finally {
2044
+ go.textContent = t("ag.bring.go");
2045
+ if (dlg.open) paintRuntime(false);
1545
2046
  }
1546
2047
  }
1547
2048
 
2049
+ /** New persona, Edit or Duplicate from the dialog; back to it after. */
2050
+ async function editFromStart(kind) {
2051
+ const p = curPersona();
2052
+ let src = null;
2053
+ if (kind !== "new" && p) {
2054
+ try {
2055
+ src = await personaDetail(p.id);
2056
+ } catch (err) {
2057
+ return void window.denShell?.errorToast?.(err);
2058
+ }
2059
+ }
2060
+ openPersonaEditor({
2061
+ data: sd.data,
2062
+ src,
2063
+ dup: kind === "dup",
2064
+ onSaved: async (saved) => {
2065
+ personasAsked.clear();
2066
+ if (saved) sd.pid = saved.id;
2067
+ await fillForWorkspace($d("#ag-ws").value);
2068
+ },
2069
+ });
2070
+ }
2071
+
2072
+ /** Wire the dialog once (mountAgentsPage). */
2073
+ function wireStartDialog(root) {
2074
+ const form = root.querySelector("#ag-form");
2075
+ form.addEventListener("click", (e) => {
2076
+ const cr = e.target.closest("#ag-conv [data-cid]");
2077
+ if (cr) {
2078
+ e.preventDefault();
2079
+ pickConversation(cr.dataset.cid);
2080
+ return;
2081
+ }
2082
+ const pr = e.target.closest("#ag-persona [data-pid]");
2083
+ if (pr) {
2084
+ e.preventDefault();
2085
+ pickPersona(pr.dataset.pid);
2086
+ pr.focus();
2087
+ return;
2088
+ }
2089
+ const chip = e.target.closest("[data-pf]");
2090
+ if (chip) {
2091
+ e.preventDefault();
2092
+ sd.f[chip.dataset.pf] = chip.dataset.v;
2093
+ for (const c of form.querySelectorAll(`[data-pf="${chip.dataset.pf}"]`))
2094
+ c.setAttribute("aria-pressed", String(c.dataset.v === chip.dataset.v));
2095
+ return paintPlist(false);
2096
+ }
2097
+ const pm = e.target.closest("[data-pm]");
2098
+ if (pm) {
2099
+ e.preventDefault();
2100
+ if (pm.disabled) return;
2101
+ sd.intent = pm.dataset.pm;
2102
+ return syncPermBlock(form, "ag", sd.data, {
2103
+ intent: sd.intent,
2104
+ role: curPersona()?.role,
2105
+ extras: sd.extras,
2106
+ runtime: chosenRuntime(),
2107
+ });
2108
+ }
2109
+ const x = e.target.closest("[data-x]")?.dataset.x;
2110
+ if (!x) return;
2111
+ e.preventDefault();
2112
+ if (x === "what") {
2113
+ sd.what = !sd.what;
2114
+ $d("#ag-what").hidden = !sd.what;
2115
+ e.target
2116
+ .closest("[data-x]")
2117
+ .setAttribute("aria-expanded", String(sd.what));
2118
+ } else if (x === "det") {
2119
+ sd.det = !sd.det;
2120
+ paintDetails();
2121
+ if (sd.det) void loadDetail();
2122
+ } else if (x === "clear") {
2123
+ sd.f = { q: "", cat: "", src: "" };
2124
+ paintPersonaField();
2125
+ } else if (x === "raw") window.denShell?.toast?.(t("pe.rawLater"));
2126
+ else if (x === "newp") void editFromStart("new");
2127
+ else if (x === "dup") void editFromStart("dup");
2128
+ else if (x === "edit") void editFromStart("edit");
2129
+ });
2130
+ form.addEventListener("input", (e) => {
2131
+ if (e.target.id === "ag-q") {
2132
+ sd.f.q = e.target.value;
2133
+ paintPlist(false);
2134
+ } else if (e.target.id === "ag-tools") {
2135
+ sd.extras = splitCommands(e.target.value);
2136
+ syncPermBlock(form, "ag", sd.data, {
2137
+ intent: sd.intent,
2138
+ role: curPersona()?.role,
2139
+ extras: sd.extras,
2140
+ runtime: chosenRuntime(),
2141
+ });
2142
+ }
2143
+ });
2144
+ form.addEventListener(
2145
+ "toggle",
2146
+ (e) => {
2147
+ if (e.target.id === "ag-xtra") sd.extrasOpen = e.target.open;
2148
+ },
2149
+ true,
2150
+ );
2151
+ form.addEventListener("change", (e) => {
2152
+ const id = e.target.id;
2153
+ if (id === "ag-ws") void fillForWorkspace(e.target.value);
2154
+ else if (id === "ag-runtime") {
2155
+ sd.model = "";
2156
+ sd.ver = "";
2157
+ $d("#ag-r-fb").hidden = true;
2158
+ paintRuntime(false);
2159
+ paintAdvanced();
2160
+ } else if (id === "ag-model") {
2161
+ sd.model = e.target.value;
2162
+ sd.ver = "";
2163
+ paintAdvanced();
2164
+ } else if (id === "ag-ver") sd.ver = e.target.value;
2165
+ });
2166
+ // ↑ ↓ move through the persona list (a radio group).
2167
+ root.querySelector("#ag-persona").addEventListener("keydown", (e) => {
2168
+ if (e.key !== "ArrowDown" && e.key !== "ArrowUp") return;
2169
+ const rows = [...e.currentTarget.querySelectorAll("[data-pid]")];
2170
+ const i = rows.indexOf(document.activeElement);
2171
+ const next = rows[i + (e.key === "ArrowDown" ? 1 : -1)];
2172
+ if (!next) return;
2173
+ e.preventDefault();
2174
+ pickPersona(next.dataset.pid);
2175
+ next.focus();
2176
+ });
2177
+ }
2178
+
1548
2179
  /**
1549
2180
  * Once per document: a font that loads later (the theme's --mono as a web
1550
2181
  * font) and a theme applied without a reload (its sheet, then
@@ -1588,11 +2219,25 @@ function translateStatic(root) {
1588
2219
  };
1589
2220
  set("#ag-h", "n.agents");
1590
2221
  set("#ag-start", "ag.start");
2222
+ set("#ag-bring", "ag.bring");
2223
+ set("#ag-conv-t", "ag.bring.conv");
1591
2224
  set("#ag-clear", "ag.clear");
1592
- set("#ag-dlg-h", "ag.start");
2225
+ set("#ag-dlg-h", "ag.new.title");
1593
2226
  set("#ag-ws-t", "ag.ws");
1594
2227
  set("#ag-p-t", "ag.persona");
1595
2228
  set("#ag-r-t", "ag.runtime");
2229
+ set("#ag-what-b", "ag.new.what");
2230
+ set("#ag-newp", "ag.new.newP");
2231
+ set("#ag-what-p", "pe.what");
2232
+ set("#ag-task-t", "ag.new.task");
2233
+ set("#ag-name-t", "ag.new.name");
2234
+ set("#ag-adv-s", "ag.new.adv");
2235
+ set("#ag-note", "ag.new.note");
2236
+ const task = root.querySelector("#ag-task");
2237
+ if (task) task.placeholder = t("ag.new.taskPh");
2238
+ root
2239
+ .querySelector("#ag-persona")
2240
+ ?.setAttribute("aria-label", t("ag.persona"));
1596
2241
  set("#ag-cancel", "ag.cancel");
1597
2242
  set("#ag-go", "ag.go");
1598
2243
  for (const b of root.querySelectorAll("[data-layouts] button")) {
@@ -1605,8 +2250,6 @@ function translateStatic(root) {
1605
2250
  root
1606
2251
  .querySelector("[data-layouts]")
1607
2252
  ?.setAttribute("aria-label", t("ag.layout"));
1608
- const h = root.querySelector("#ag-dlg-h");
1609
- if (h) h.textContent = h.textContent.replace(/^\+\s*/, "");
1610
2253
  }
1611
2254
 
1612
2255
  /** Fill the page drawn by agents.html and wire it. */
@@ -1676,12 +2319,13 @@ export async function mountAgentsPage(root) {
1676
2319
  root.querySelector("#ag-start").addEventListener("click", () => {
1677
2320
  void openStart();
1678
2321
  });
2322
+ root.querySelector("#ag-bring")?.addEventListener("click", () => {
2323
+ void openStart(true);
2324
+ });
1679
2325
  root.querySelector("#ag-clear")?.addEventListener("click", (e) => {
1680
2326
  void clearStopped(e.currentTarget);
1681
2327
  });
1682
- root.querySelector("#ag-ws").addEventListener("change", (e) => {
1683
- void fillForWorkspace(e.currentTarget.value);
1684
- });
2328
+ wireStartDialog(root);
1685
2329
  root.querySelector("#ag-cancel").addEventListener("click", () => {
1686
2330
  root.querySelector("#ag-dlg").close?.();
1687
2331
  });
@@ -1693,7 +2337,7 @@ export async function mountAgentsPage(root) {
1693
2337
  // cancels (the dialog's own cancel). A button keeps its own Enter.
1694
2338
  root.querySelector("#ag-form").addEventListener("keydown", (e) => {
1695
2339
  if (e.key !== "Enter" || e.isComposing) return;
1696
- if (e.target.closest("button, textarea")) return;
2340
+ if (e.target.closest("button, textarea, summary")) return;
1697
2341
  e.preventDefault();
1698
2342
  if (!st.root.querySelector("#ag-go").disabled)
1699
2343
  e.currentTarget.requestSubmit();