@monster-orange/den 0.4.14 → 0.4.16

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 (57) hide show
  1. package/CHANGELOG.md +194 -0
  2. package/README.md +7 -7
  3. package/docs/distributions.md +226 -19
  4. package/package.json +1 -1
  5. package/src/shared/actor-token.ts +12 -0
  6. package/src/shared/agents/conversations.ts +155 -8
  7. package/src/shared/agents/den-claude.ts +25 -3
  8. package/src/shared/agents/records.ts +37 -0
  9. package/src/shared/agents/runtime.ts +11 -1
  10. package/src/shared/claim-code.ts +163 -0
  11. package/src/shared/cli-command.ts +31 -0
  12. package/src/shared/den-config.ts +12 -0
  13. package/src/shared/environment-manager/workspaces.ts +20 -0
  14. package/src/shared/github-app.ts +7 -0
  15. package/src/shared/web-session.ts +64 -5
  16. package/src/ui/cli/agent-ops.ts +372 -23
  17. package/src/ui/cli/agents-command.ts +71 -4
  18. package/src/ui/cli/doctor-command.ts +29 -3
  19. package/src/ui/cli/doctor-onboard.ts +26 -0
  20. package/src/ui/cli/flag-parser.ts +9 -1
  21. package/src/ui/cli/index.ts +29 -3
  22. package/src/ui/cli/onboard-command.ts +180 -0
  23. package/src/ui/cli/onboard-reads.ts +613 -0
  24. package/src/ui/cli/repo-command.ts +156 -10
  25. package/src/ui/cli/web-command.ts +92 -1
  26. package/src/ui/mcp/api.ts +14 -2
  27. package/src/ui/mcp/server.ts +12 -2
  28. package/src/ui/mcp/tools.ts +368 -1
  29. package/src/ui/server/agent-routes.ts +91 -7
  30. package/src/ui/server/approval-routes.ts +390 -0
  31. package/src/ui/server/approvals.ts +416 -0
  32. package/src/ui/server/github-routes.ts +13 -5
  33. package/src/ui/server/held-actions.ts +203 -0
  34. package/src/ui/server/http-helpers.ts +35 -0
  35. package/src/ui/server/index.ts +308 -21
  36. package/src/ui/server/lifecycle-exec.ts +70 -0
  37. package/src/ui/server/onboard-routes.ts +132 -0
  38. package/src/ui/server/pair-routes.ts +7 -4
  39. package/src/ui/server/public/agents-page.js +378 -30
  40. package/src/ui/server/public/agents.html +1 -0
  41. package/src/ui/server/public/error-page.js +97 -18
  42. package/src/ui/server/public/error.html +18 -0
  43. package/src/ui/server/public/header.js +1 -1
  44. package/src/ui/server/public/i18n/es.js +65 -5
  45. package/src/ui/server/public/i18n/ja.js +67 -5
  46. package/src/ui/server/public/index.html +1 -1
  47. package/src/ui/server/public/local-dev.html +5 -4
  48. package/src/ui/server/public/persona-ui.css +32 -0
  49. package/src/ui/server/public/release.html +0 -1
  50. package/src/ui/server/public/settings-registry.js +3 -1
  51. package/src/ui/server/public/settings.html +9 -1
  52. package/src/ui/server/public/shell.js +348 -5
  53. package/src/ui/server/request-guard.ts +2 -0
  54. package/src/ui/server/route-classes.ts +34 -1
  55. package/src/ui/server/session-routes.ts +14 -3
  56. package/src/ui/server/workspace-routes.ts +21 -10
  57. package/themes/den/den-theme.json +6 -6
@@ -565,6 +565,7 @@ async function refresh() {
565
565
  } catch {
566
566
  /* the last list stays; the next ask may answer */
567
567
  }
568
+ followAgents();
568
569
  st.scopeLabel = scopeLabelFor(viewWorkspaces());
569
570
  paintList();
570
571
  paintSub();
@@ -572,14 +573,78 @@ async function refresh() {
572
573
  const strip = st.root.querySelector("#agr > .agtabs");
573
574
  if (strip) paintTabs(strip);
574
575
  if (!st.root.isConnected) return;
575
- st.timer = setTimeout(() => {
576
- if (!document.hidden) void refresh();
577
- else st.timer = setTimeout(refresh, REFRESH_MS);
578
- }, REFRESH_MS);
576
+ // A pane whose session just ended may have a new one soon (a persona
577
+ // change, a resume): look again every second for a while.
578
+ const soon = Date.now() < (st.followUntil ?? 0);
579
+ st.timer = setTimeout(
580
+ () => {
581
+ if (!document.hidden) void refresh();
582
+ else st.timer = setTimeout(refresh, REFRESH_MS);
583
+ },
584
+ soon ? FOLLOW_MS : REFRESH_MS,
585
+ );
579
586
  }
580
587
 
581
588
  const rowOf = (id) => st.data?.sessions?.find((s) => s.id === id) ?? null;
582
589
 
590
+ /** How often, and how long, the list is read after a pane's session ends. */
591
+ const FOLLOW_MS = 1_000;
592
+ const FOLLOW_FOR_MS = 15_000;
593
+
594
+ /**
595
+ * A pane follows its agent, not a session (AG-18): when the agent's
596
+ * session changes (Change persona, Resume, a rotation), the pane moves to
597
+ * the new one in the same slot, its scrollback kept.
598
+ */
599
+ function followAgents() {
600
+ for (const p of [...st.panes.values()]) {
601
+ const r = rowOf(p.id);
602
+ if (r?.kind === "agent" && r.agent) {
603
+ p.agent = r.agent;
604
+ p.agentWs = r.workspace;
605
+ }
606
+ if (!p.agent || p.closed || r?.state === "running") continue;
607
+ const next = st.data?.sessions?.find(
608
+ (x) =>
609
+ x.kind === "agent" &&
610
+ x.agent === p.agent &&
611
+ x.workspace === p.agentWs &&
612
+ x.id !== p.id &&
613
+ x.state === "running",
614
+ );
615
+ if (next && !st.panes.has(next.id)) retarget(p, next);
616
+ }
617
+ }
618
+
619
+ /** Move pane `p` to session row `row`, where it was. */
620
+ function retarget(p, row) {
621
+ const old = p.id;
622
+ const sock = p.sock;
623
+ p.sock = null;
624
+ try {
625
+ sock?.close(1000, "switched");
626
+ } catch {
627
+ /* already closed */
628
+ }
629
+ st.panes.delete(old);
630
+ p.id = row.id;
631
+ st.panes.set(row.id, p);
632
+ st.open = st.open.map((x) => (x === old ? row.id : x));
633
+ if (st.side.delete(old)) st.side.add(row.id);
634
+ if (st.cur === old) st.cur = row.id;
635
+ p.el.dataset.id = row.id;
636
+ p.el.setAttribute("aria-label", row.label ?? row.id);
637
+ p.exited = false;
638
+ p.lost = false;
639
+ p.term?.write(
640
+ `\r\n\x1b[2m${t("ag.switched", { p: row.persona ?? row.agent })}\x1b[0m\r\n`,
641
+ );
642
+ paintBar(p);
643
+ paintLayout();
644
+ writeUrl();
645
+ void connect(p);
646
+ }
647
+
583
648
  // ── the panes ───────────────────────────────────────────────────────────
584
649
 
585
650
  async function loadXterm() {
@@ -871,13 +936,18 @@ function paintBar(p) {
871
936
  const reconnect = p.lost
872
937
  ? `<button type="button" class="den-btn sm" data-act="reconnect">${esc(t("ag.reconnect"))}</button>`
873
938
  : "";
939
+ // AG-18: ⋯, its menu: Change persona… (a runtime that can resume).
940
+ const more =
941
+ r?.kind === "agent" && r.personaChange
942
+ ? `<button type="button" class="den-btn sm" data-act="more" aria-haspopup="menu" aria-expanded="false" aria-label="${esc(t("ag.more", { id: r.agent }))}" title="${esc(t("ag.more", { id: r.agent }))}">⋯</button>`
943
+ : "";
874
944
  bar.innerHTML =
875
945
  `<span class="den-dot ${tone}" aria-hidden="true"></span>` +
876
946
  `<b>${esc(name)}</b><span class="where mono">${esc(where)}</span>` +
877
947
  (r?.tokenRevoked
878
948
  ? `<span class="tagx warn" title="${esc(t("ag.revokedTip"))}">${esc(t("ag.revoked"))}</span>`
879
949
  : "") +
880
- `<span class="x">${reconnect}${stop}${resume}<button type="button" class="den-btn sm" data-act="close" aria-label="${esc(t("ag.detachName", { id: name }))}" title="${esc(t("ag.detach"))}">×</button></span>`;
950
+ `<span class="x">${reconnect}${stop}${resume}${more}<button type="button" class="den-btn sm" data-act="close" aria-label="${esc(t("ag.detachName", { id: name }))}" title="${esc(t("ag.detach"))}">×</button></span>`;
881
951
  // Tabs: the tab's × closes it; the pane's bar has none.
882
952
  const x = bar.querySelector('[data-act="close"]');
883
953
  if (x) x.hidden = shownLayout() === "tabs";
@@ -1043,6 +1113,8 @@ async function connect(p) {
1043
1113
  paintBar(p);
1044
1114
  } else if (msg?.type === "exit") {
1045
1115
  p.exited = true;
1116
+ // Its agent may continue in a new session (followAgents).
1117
+ st.followUntil = Date.now() + FOLLOW_FOR_MS;
1046
1118
  const code =
1047
1119
  typeof msg.exitCode === "number" ? ` (${msg.exitCode})` : "";
1048
1120
  p.term?.write(`\r\n${t("ag.exitedLine", { code })}\r\n`);
@@ -1255,7 +1327,7 @@ async function openPane(id, { front = true } = {}) {
1255
1327
  ) {
1256
1328
  if (e.isComposing) return true;
1257
1329
  e.preventDefault();
1258
- if (softNewlineKey(e)) typeIn(softNewlineOf(id));
1330
+ if (softNewlineKey(e)) typeIn(softNewlineOf(p.id));
1259
1331
  return false;
1260
1332
  }
1261
1333
  return true;
@@ -1349,9 +1421,13 @@ async function restartRow(r, verb, button) {
1349
1421
  { id: r.agent },
1350
1422
  ),
1351
1423
  );
1352
- if (st.panes.has(r.id)) closePane(r.id, { quiet: true });
1424
+ // Its open pane follows it to the new session (followAgents, in
1425
+ // refresh); one that couldn't (not running yet) is closed for it.
1353
1426
  await refresh();
1354
- if (out?.agent?.sessionId) await openPane(out.agent.sessionId);
1427
+ const next = out?.agent?.sessionId;
1428
+ if (next && next !== r.id && st.panes.has(r.id))
1429
+ closePane(r.id, { quiet: true });
1430
+ if (next) await openPane(next);
1355
1431
  } catch (err) {
1356
1432
  window.denShell?.errorToast?.(err);
1357
1433
  await refresh();
@@ -1538,6 +1614,9 @@ const sd = {
1538
1614
  // AG-17.1, Bring in: null for Start; else the workspace's conversations,
1539
1615
  // the runtimes that can't bring one in, and the one picked.
1540
1616
  bring: null,
1617
+ // AG-18, Change persona: null; else the agent's row (id, workspace,
1618
+ // persona, model) whose persona changes.
1619
+ change: null,
1541
1620
  };
1542
1621
 
1543
1622
  /** The conversation picked in Bring in, when it can be brought in. */
@@ -1625,7 +1704,38 @@ function pickConversation(id) {
1625
1704
  sd.bring.pick = id;
1626
1705
  for (const b of st.root.querySelectorAll("#ag-conv [data-cid]"))
1627
1706
  b.setAttribute("aria-checked", String(b.dataset.cid === id));
1628
- paintRuntime(false);
1707
+ // AG-17.1c: its suggested persona, preselected (never submitted).
1708
+ if (c.suggest && (sd.data?.personas ?? []).some((p) => p.id === c.suggest))
1709
+ pickPersona(c.suggest);
1710
+ else paintRuntime(false);
1711
+ }
1712
+
1713
+ /**
1714
+ * AG-17.1c, AG-18: what the persona changes, in one short list under it:
1715
+ * the model kept (or the one picked), what it works as from now on, and
1716
+ * for Bring in the MCP servers it won't have.
1717
+ */
1718
+ function paintKeep() {
1719
+ const el = $d("#ag-keep");
1720
+ if (!el) return;
1721
+ const p = curPersona();
1722
+ const from = sd.bring ? bringPick() : sd.change;
1723
+ el.hidden = !p || !from;
1724
+ if (el.hidden) return void (el.innerHTML = "");
1725
+ const items = [];
1726
+ if (sd.model) items.push(t("ag.keep.modelNew", { m: sd.model }));
1727
+ else if (from.model) items.push(t("ag.keep.model", { m: from.model }));
1728
+ items.push(
1729
+ t("ag.keep.from", {
1730
+ p: personaName(p),
1731
+ mode: t(`pm.${p.permission || "ask"}`),
1732
+ }),
1733
+ );
1734
+ if (sd.bring && from.lost?.length)
1735
+ items.push(t("ag.keep.lost", { s: from.lost.join(", ") }));
1736
+ el.innerHTML = items
1737
+ .map((x, i) => `<li data-keep="${i}">${esc(x)}</li>`)
1738
+ .join("");
1629
1739
  }
1630
1740
 
1631
1741
  const $d = (sel) => st.root.querySelector(sel);
@@ -1636,6 +1746,10 @@ const curPersona = () =>
1636
1746
 
1637
1747
  /** The runtime the dialog will start: the one picked, else the persona's. */
1638
1748
  function chosenRuntime() {
1749
+ // Change persona and Bring in: the agent's, or the conversation's.
1750
+ if (sd.change?.runtime) return sd.change.runtime;
1751
+ const conv = sd.bring ? bringPick() : null;
1752
+ if (conv?.runtime) return conv.runtime;
1639
1753
  const p = curPersona();
1640
1754
  return $d("#ag-runtime").value || personaRuntime(sd.data, p);
1641
1755
  }
@@ -1657,8 +1771,9 @@ function plistHtml() {
1657
1771
  const rows = personaListHtml(sd.f, ps, cats, (p) =>
1658
1772
  personaRowHtml(p, p.id === sd.pid, sd.data),
1659
1773
  );
1660
- // Bring in continues a conversation: it needs a persona.
1661
- if (sd.bring) return rows;
1774
+ // Bring in continues a conversation, and a change keeps one: each needs
1775
+ // a persona.
1776
+ if (sd.bring || sd.change) return rows;
1662
1777
  return (
1663
1778
  rows +
1664
1779
  `<div class="pgh"><span>${esc(t("ag.session"))}</span><span></span></div>` +
@@ -1670,6 +1785,18 @@ function paintPlist(keepScroll = true) {
1670
1785
  const l = $d("#ag-persona");
1671
1786
  const top = l.scrollTop;
1672
1787
  l.innerHTML = plistHtml();
1788
+ // AG-18: the agent's persona now, marked.
1789
+ const cur =
1790
+ sd.change &&
1791
+ [...l.querySelectorAll("[data-pid]")].find(
1792
+ (b) => b.dataset.pid === sd.change.persona,
1793
+ );
1794
+ cur
1795
+ ?.querySelector(".pn")
1796
+ ?.insertAdjacentHTML(
1797
+ "beforeend",
1798
+ ` <span class="tagx" data-current>${esc(t("ag.pc.current"))}</span>`,
1799
+ );
1673
1800
  l.scrollTop = keepScroll ? top : 0;
1674
1801
  }
1675
1802
 
@@ -1702,17 +1829,35 @@ function paintDetails() {
1702
1829
  * and the dialog says so.
1703
1830
  */
1704
1831
  function paintRuntime(reset) {
1832
+ paintRuntimeFields(reset);
1833
+ paintKeep();
1834
+ // Last: a Name that names a running agent disables Start (AG-10.10b).
1835
+ paintStartsAs();
1836
+ }
1837
+
1838
+ function paintRuntimeFields(reset) {
1705
1839
  const rs = $d("#ag-runtime");
1706
1840
  const go = $d("#ag-go");
1707
1841
  const ready = $d("#ag-r-ready");
1708
1842
  const plain = sd.pid === PLAIN;
1709
1843
  // Bring in: the conversation's runtime, its own history; no first
1710
1844
  // 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();
1845
+ // Change persona: the agent's runtime and name stay. Advanced (the
1846
+ // model) shows for both (AG-17.1c, AG-18).
1847
+ const keeps = !!sd.bring || !!sd.change;
1848
+ $d("#ag-r-l").hidden = plain || keeps;
1849
+ $d("#ag-task-l").hidden = plain || keeps;
1850
+ $d("#ag-adv").hidden = plain;
1851
+ $d("#ag-name-l").hidden = plain || keeps;
1852
+ if (sd.change) {
1853
+ $d("#ag-r-miss").hidden = true;
1854
+ ready.hidden = true;
1855
+ const p = curPersona();
1856
+ const ok = !!p && (p.id !== sd.change.persona || !!sd.model);
1857
+ go.disabled = !ok;
1858
+ go.dataset.blocked = ok ? "" : "persona";
1859
+ return;
1860
+ }
1716
1861
  if (sd.bring) {
1717
1862
  $d("#ag-r-miss").hidden = true;
1718
1863
  ready.hidden = true;
@@ -1779,6 +1924,18 @@ function paintAdvanced() {
1779
1924
  const name = runtimeName(sd.data?.runtimes ?? [], rt);
1780
1925
  const def = personaModel();
1781
1926
  const models = rtv?.models ?? [];
1927
+ // AG-17.1c, AG-18: the conversation's model stays unless one is picked.
1928
+ const from = sd.bring ? bringPick() : sd.change;
1929
+ if (sd.bring || sd.change) {
1930
+ const keep = from?.model
1931
+ ? t("ag.keep.opt", { m: from.model })
1932
+ : t("ag.keep.optNone");
1933
+ body.innerHTML =
1934
+ `<p>${esc(t("ag.keep.advNote"))}</p>` +
1935
+ `<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, keep) : `<option value="">${esc(keep)}</option>`}</select></label>` +
1936
+ `<label class="fld"><span>${esc(t("ag.new.ver"))}</span><select id="ag-ver"${models.length && sd.model ? "" : " disabled"}>${versionOptionsHtml(sd.data, rt, sd.model, sd.ver, t("ag.new.verDef", { v: versionLabel("latest") }))}</select></label></div>`;
1937
+ return;
1938
+ }
1782
1939
  const model = sd.model || def.model;
1783
1940
  const first = t("ag.new.modelDef", {
1784
1941
  // No model of its own: just the version, so the label fits the select.
@@ -1859,8 +2016,14 @@ async function fillForWorkspace(ws) {
1859
2016
  return false;
1860
2017
  }
1861
2018
  if (!(await fillConversations(ws))) return false;
1862
- if (sd.bring && sd.pid === PLAIN) sd.pid = "";
2019
+ if ((sd.bring || sd.change) && sd.pid === PLAIN) sd.pid = "";
1863
2020
  const ps = sd.data?.personas ?? [];
2021
+ // AG-17.1c: the picked conversation's suggested persona, preselected.
2022
+ const sug = sd.bring ? bringPick()?.suggest : null;
2023
+ if (sug && ps.some((p) => p.id === sug)) sd.pid = sug;
2024
+ // AG-18: its persona now (marked; Change stays off until another).
2025
+ if (sd.change && ps.some((p) => p.id === sd.change.persona))
2026
+ sd.pid = sd.change.persona;
1864
2027
  if (sd.pid !== PLAIN && !ps.some((p) => p.id === sd.pid)) {
1865
2028
  // The first persona the list shows (grouped by category).
1866
2029
  const cats = (sd.data?.categories ?? []).map((c) => c.id);
@@ -1877,19 +2040,41 @@ async function fillForWorkspace(ws) {
1877
2040
  }
1878
2041
 
1879
2042
  /** The dialog's words for Start, or for Bring in (AG-17.1). */
1880
- function dialogMode(bring) {
2043
+ function dialogMode(bring, change = null) {
1881
2044
  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");
2045
+ sd.change = change;
2046
+ $d("#ag-dlg-h").textContent = change
2047
+ ? t("ag.pc.title", { id: change.name })
2048
+ : t(bring ? "ag.bring.title" : "ag.new.title");
2049
+ $d("#ag-note").textContent = t(
2050
+ change ? "ag.pc.note" : bring ? "ag.bring.note" : "ag.new.note",
2051
+ );
2052
+ $d("#ag-go").textContent = goLabel();
1885
2053
  $d("#ag-conv-f").hidden = !bring;
1886
2054
  }
1887
2055
 
1888
- async function openStart(bring = false) {
2056
+ /** The submit button's words for the dialog's mode. */
2057
+ function goLabel() {
2058
+ return t(sd.change ? "ag.pc.go" : sd.bring ? "ag.bring.go" : "ag.go");
2059
+ }
2060
+
2061
+ /** AG-18: Change persona… for agent row `r` (its persona, model, name). */
2062
+ function openPersonaChange(r) {
2063
+ return openStart(false, {
2064
+ id: r.agent,
2065
+ workspace: r.workspace,
2066
+ persona: r.persona,
2067
+ runtime: r.runtime,
2068
+ model: r.model || "",
2069
+ name: paneName(r),
2070
+ });
2071
+ }
2072
+
2073
+ async function openStart(bring = false, change = null) {
1889
2074
  const dlg = $d("#ag-dlg");
1890
2075
  if (!dlg || dlg.open) return;
1891
- dialogMode(bring);
1892
- const shown = viewWorkspaces();
2076
+ dialogMode(bring, change);
2077
+ const shown = change ? [change.workspace] : viewWorkspaces();
1893
2078
  const wsSel = $d("#ag-ws");
1894
2079
  wsSel.innerHTML = shown
1895
2080
  .map((w) => `<option value="${esc(w)}">${esc(w)}</option>`)
@@ -1958,14 +2143,94 @@ function showStartError(err) {
1958
2143
  el.hidden = false;
1959
2144
  }
1960
2145
 
1961
- /** AG-10.10: the id this start takes, so two of one persona are told apart. */
2146
+ /**
2147
+ * AG-10.10b: the id a Name gives, as the server's nameSlug: lowercase,
2148
+ * spaces to "-", only id characters, at most 64; null when none is left.
2149
+ */
2150
+ export function nameSlug(name) {
2151
+ const s = String(name)
2152
+ .trim()
2153
+ .toLowerCase()
2154
+ .replace(/\s+/g, "-")
2155
+ .replace(/[^a-z0-9._-]/g, "")
2156
+ .replace(/-{2,}/g, "-")
2157
+ .replace(/^[^a-z0-9]+/, "")
2158
+ .slice(0, 64)
2159
+ .replace(/[-._]+$/, "");
2160
+ return s && /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/.test(s) ? s : null;
2161
+ }
2162
+
2163
+ /**
2164
+ * The id this start takes (AG-10.10, 10.10b), said as you type: a Name is
2165
+ * the id as its slug. A running agent here with that id or title: "is
2166
+ * already running · Open it", Start off. A stopped one: "Resume it", or
2167
+ * Start takes the next suffix.
2168
+ */
1962
2169
  function paintStartsAs() {
1963
2170
  const el = $d("#ag-as");
1964
2171
  if (!el) return;
1965
- const p = sd.pid && sd.pid !== PLAIN && !sd.bring ? curPersona() : null;
2172
+ const p =
2173
+ sd.pid && sd.pid !== PLAIN && !sd.bring && !sd.change ? curPersona() : null;
1966
2174
  el.hidden = !p;
1967
- if (p)
1968
- el.textContent = t("ag.new.as", { id: sd.data?.startsAs?.[p.id] ?? p.id });
2175
+ el.textContent = "";
2176
+ if (!p) return;
2177
+ const name = ($d("#ag-name")?.value ?? "").trim();
2178
+ const slug = name ? nameSlug(name) : null;
2179
+ const ws = $d("#ag-ws")?.value || viewWorkspaces()[0];
2180
+ const here = (st.data?.sessions ?? []).filter(
2181
+ (r) => r.kind === "agent" && r.workspace === ws,
2182
+ );
2183
+ const live = (r) => r.state === "running" || r.state === "stopping";
2184
+ const match = name
2185
+ ? (here.find(
2186
+ (r) =>
2187
+ live(r) &&
2188
+ (r.agent === slug ||
2189
+ (r.displayName ?? "").toLowerCase() === name.toLowerCase()),
2190
+ ) ?? here.find((r) => r.agent === slug))
2191
+ : null;
2192
+ const act = (label, fn) => {
2193
+ const b = document.createElement("button");
2194
+ b.type = "button";
2195
+ b.className = "den-btn sm";
2196
+ b.textContent = label;
2197
+ b.addEventListener("click", fn);
2198
+ return b;
2199
+ };
2200
+ if (match && live(match)) {
2201
+ el.append(
2202
+ t("ag.new.isRunning", { id: match.agent }) + " · ",
2203
+ act(t("ag.new.openIt"), () => {
2204
+ $d("#ag-dlg").close?.();
2205
+ void openPane(match.id);
2206
+ }),
2207
+ );
2208
+ $d("#ag-go").disabled = true;
2209
+ return;
2210
+ }
2211
+ if (match) {
2212
+ el.append(
2213
+ t("ag.new.isStopped", { id: match.agent }) + " · ",
2214
+ act(t("ag.new.resumeIt"), () => {
2215
+ $d("#ag-dlg").close?.();
2216
+ void restartRow(match, "resume", null);
2217
+ }),
2218
+ " " + t("ag.new.orNext", { id: nextFree(slug) }),
2219
+ );
2220
+ return;
2221
+ }
2222
+ el.textContent = t("ag.new.as", {
2223
+ id: slug ?? sd.data?.startsAs?.[p.id] ?? p.id,
2224
+ });
2225
+ }
2226
+
2227
+ /** The first `<slug>-<n>` no agent on this page has (the server decides). */
2228
+ function nextFree(slug) {
2229
+ const ids = new Set((st.data?.sessions ?? []).map((r) => r.agent));
2230
+ for (let n = 2; n < 1000; n++)
2231
+ if (!ids.has(`${slug.slice(0, 58)}-${n}`))
2232
+ return `${slug.slice(0, 58)}-${n}`;
2233
+ return slug;
1969
2234
  }
1970
2235
 
1971
2236
  async function submitStart(form) {
@@ -1975,6 +2240,7 @@ async function submitStart(form) {
1975
2240
  const ws = form.querySelector("#ag-ws").value || viewWorkspaces()[0];
1976
2241
  if (!ws || !sd.pid) return;
1977
2242
  if (sd.bring) return submitBringIn(ws);
2243
+ if (sd.change) return submitPersonaChange();
1978
2244
  const bad = sd.extras.find((c) => commandProblem(c));
1979
2245
  if (sd.pid !== PLAIN && bad)
1980
2246
  return showStartError(new Error(t("pe.badCmd", { c: bad })));
@@ -2011,11 +2277,73 @@ async function submitStart(form) {
2011
2277
  showStartError(err);
2012
2278
  } finally {
2013
2279
  go.disabled = false;
2014
- go.textContent = t("ag.go");
2280
+ go.textContent = goLabel();
2281
+ if (dlg.open) paintRuntime(false);
2282
+ }
2283
+ }
2284
+
2285
+ /**
2286
+ * AG-18: change the agent's persona (the same conversation resumes as the
2287
+ * new one). Mid-turn the server asks: After this turn, or Stop it now.
2288
+ */
2289
+ async function submitPersonaChange(when) {
2290
+ const dlg = $d("#ag-dlg");
2291
+ const go = $d("#ag-go");
2292
+ const ch = sd.change;
2293
+ const p = curPersona();
2294
+ if (!ch || !p) return;
2295
+ const body = { persona: p.id };
2296
+ if (sd.model) body.model = sd.model;
2297
+ if (sd.model && sd.ver) body.version = sd.ver;
2298
+ if (when) body.when = when;
2299
+ go.disabled = true;
2300
+ go.textContent = t("ag.pc.going");
2301
+ $d("#ag-err").hidden = true;
2302
+ try {
2303
+ const r = await api(
2304
+ "POST",
2305
+ `/api/workspaces/${encodeURIComponent(ch.workspace)}/agents/${encodeURIComponent(ch.id)}/persona`,
2306
+ body,
2307
+ );
2308
+ window.denShell?.toast?.(
2309
+ t(r?.pending ? "ag.pc.pending" : "ag.pc.done", {
2310
+ id: ch.name,
2311
+ p: personaName(p),
2312
+ }),
2313
+ );
2314
+ for (const w of r?.warnings ?? []) window.denShell?.toast?.(w);
2315
+ dlg.close?.();
2316
+ await refresh();
2317
+ } catch (err) {
2318
+ if (err?.code === "busy") showBusy();
2319
+ else showStartError(err);
2320
+ } finally {
2321
+ go.textContent = goLabel();
2015
2322
  if (dlg.open) paintRuntime(false);
2016
2323
  }
2017
2324
  }
2018
2325
 
2326
+ /** It's mid-turn: never cut silently; the person picks (AG-18). */
2327
+ function showBusy() {
2328
+ const el = $d("#ag-err");
2329
+ el.textContent = t("ag.pc.busy") + " ";
2330
+ const b = (id, label, when) => {
2331
+ const x = document.createElement("button");
2332
+ x.type = "button";
2333
+ x.className = "den-btn sm";
2334
+ x.id = id;
2335
+ x.textContent = label;
2336
+ x.addEventListener("click", () => void submitPersonaChange(when));
2337
+ return x;
2338
+ };
2339
+ el.append(
2340
+ b("ag-pc-after", t("ag.pc.after"), "after-turn"),
2341
+ " ",
2342
+ b("ag-pc-now", t("ag.pc.now"), "now"),
2343
+ );
2344
+ el.hidden = false;
2345
+ }
2346
+
2019
2347
  /** Bring in (AG-17.1): the runtime's resume of the conversation picked. */
2020
2348
  async function submitBringIn(ws) {
2021
2349
  const dlg = $d("#ag-dlg");
@@ -2029,7 +2357,13 @@ async function submitBringIn(ws) {
2029
2357
  const r = await api(
2030
2358
  "POST",
2031
2359
  `/api/workspaces/${encodeURIComponent(ws)}/conversations/${encodeURIComponent(c.id)}/import`,
2032
- { persona: p.id, runtime: c.runtime },
2360
+ {
2361
+ persona: p.id,
2362
+ runtime: c.runtime,
2363
+ // AG-17.1c: the conversation's own model unless one is picked.
2364
+ ...(sd.model ? { model: sd.model } : {}),
2365
+ ...(sd.model && sd.ver ? { version: sd.ver } : {}),
2366
+ },
2033
2367
  );
2034
2368
  window.denShell?.toast?.(t("ag.bring.done", { id: r?.agent?.id ?? p.id }));
2035
2369
  for (const w of r?.warnings ?? []) window.denShell?.toast?.(w);
@@ -2131,6 +2465,9 @@ function wireStartDialog(root) {
2131
2465
  if (e.target.id === "ag-q") {
2132
2466
  sd.f.q = e.target.value;
2133
2467
  paintPlist(false);
2468
+ } else if (e.target.id === "ag-name") {
2469
+ // AG-10.10b: what the Name gives, as you type.
2470
+ paintRuntime(false);
2134
2471
  } else if (e.target.id === "ag-tools") {
2135
2472
  sd.extras = splitCommands(e.target.value);
2136
2473
  syncPermBlock(form, "ag", sd.data, {
@@ -2161,6 +2498,7 @@ function wireStartDialog(root) {
2161
2498
  sd.model = e.target.value;
2162
2499
  sd.ver = "";
2163
2500
  paintAdvanced();
2501
+ paintRuntime(false);
2164
2502
  } else if (id === "ag-ver") sd.ver = e.target.value;
2165
2503
  });
2166
2504
  // ↑ ↓ move through the persona list (a radio group).
@@ -2315,6 +2653,16 @@ export async function mountAgentsPage(root) {
2315
2653
  const r = rowOf(id);
2316
2654
  if (r) void restartRow(r, "resume", b);
2317
2655
  } else if (b.dataset.act === "reconnect") void connect(p);
2656
+ else if (b.dataset.act === "more") {
2657
+ const r = rowOf(id);
2658
+ if (!r) return;
2659
+ window.denShell?.openMenu?.(b, {
2660
+ label: t("ag.more", { id: r.agent }),
2661
+ items: [
2662
+ { label: t("ag.pc.item"), onSelect: () => void openPersonaChange(r) },
2663
+ ],
2664
+ });
2665
+ }
2318
2666
  });
2319
2667
  root.querySelector("#ag-start").addEventListener("click", () => {
2320
2668
  void openStart();
@@ -701,6 +701,7 @@
701
701
  aria-label="Persona"
702
702
  ></div>
703
703
  <div id="ag-detrow"></div>
704
+ <ul class="den-keep" id="ag-keep" role="status" hidden></ul>
704
705
  </div>
705
706
  <div class="two">
706
707
  <label class="fld" id="ag-r-l"