@monster-orange/den 0.4.13 → 0.4.15

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 +176 -0
  2. package/README.md +7 -7
  3. package/docs/distributions.md +185 -7
  4. package/package.json +1 -1
  5. package/src/shared/actor-token.ts +12 -0
  6. package/src/shared/agents/conversations.ts +256 -0
  7. package/src/shared/agents/den-claude.ts +19 -0
  8. package/src/shared/agents/records.ts +23 -0
  9. package/src/shared/agents/runtime.ts +14 -0
  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 +14 -0
  13. package/src/shared/environment-manager/service-runner.ts +31 -12
  14. package/src/shared/environment-manager/workspaces.ts +31 -0
  15. package/src/shared/web-session.ts +64 -5
  16. package/src/ui/cli/agent-ops.ts +320 -18
  17. package/src/ui/cli/agents-command.ts +89 -2
  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 +26 -3
  27. package/src/ui/mcp/server.ts +12 -2
  28. package/src/ui/mcp/tools.ts +427 -1
  29. package/src/ui/server/agent-resume-grant.ts +35 -0
  30. package/src/ui/server/agent-routes.ts +149 -1
  31. package/src/ui/server/approval-routes.ts +390 -0
  32. package/src/ui/server/approvals.ts +416 -0
  33. package/src/ui/server/github-routes.ts +12 -4
  34. package/src/ui/server/held-actions.ts +203 -0
  35. package/src/ui/server/http-helpers.ts +35 -0
  36. package/src/ui/server/index.ts +286 -16
  37. package/src/ui/server/lifecycle-exec.ts +70 -0
  38. package/src/ui/server/onboard-routes.ts +132 -0
  39. package/src/ui/server/pair-routes.ts +7 -4
  40. package/src/ui/server/public/agents-page.js +288 -8
  41. package/src/ui/server/public/agents.html +18 -0
  42. package/src/ui/server/public/error-page.js +27 -3
  43. package/src/ui/server/public/header.js +1 -1
  44. package/src/ui/server/public/i18n/es.js +52 -0
  45. package/src/ui/server/public/i18n/ja.js +55 -0
  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 +45 -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 +336 -0
  53. package/src/ui/server/request-guard.ts +19 -4
  54. package/src/ui/server/route-classes.ts +41 -1
  55. package/src/ui/server/session-routes.ts +11 -3
  56. package/src/ui/server/workspace-routes.ts +21 -10
  57. package/themes/den/den-theme.json +6 -6
@@ -22,8 +22,8 @@ export interface PairRouteDeps {
22
22
  pairs: PairRequests;
23
23
  sendJson: (res: http.ServerResponse, body: unknown, status?: number) => void;
24
24
  readBody: (req: http.IncomingMessage) => Promise<string>;
25
- /** A new browser session for this request (via "code"); its Set-Cookie line. */
26
- signIn: (req: http.IncomingMessage) => string;
25
+ /** A new browser session for this request (via "code"): its Set-Cookie line and approval key. */
26
+ signIn: (req: http.IncomingMessage) => { cookie: string; approveKey: string };
27
27
  /** The client's address for a new request's record, when Den knows it. */
28
28
  client: (req: http.IncomingMessage) => string | undefined;
29
29
  /** The commands that approve `code`: on the server, and by ssh from the browser's machine. */
@@ -132,8 +132,11 @@ export async function handlePairRoutes(
132
132
  deps.sendJson(res, { ok: false, state: "gone" }, 404);
133
133
  return true;
134
134
  }
135
- res.setHeader("Set-Cookie", deps.signIn(req));
136
- deps.sendJson(res, { ok: true, state: "ok" });
135
+ const s = deps.signIn(req);
136
+ res.setHeader("Set-Cookie", s.cookie);
137
+ res.setHeader("Cache-Control", "no-store");
138
+ // The approval key goes to this answer only (web-session.ts).
139
+ deps.sendJson(res, { ok: true, state: "ok", approveKey: s.approveKey });
137
140
  return true;
138
141
  }
139
142
  deps.sendJson(res, {
@@ -1535,8 +1535,99 @@ const sd = {
1535
1535
  intent: "ask",
1536
1536
  extras: [],
1537
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,
1538
1541
  };
1539
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;
1604
+ try {
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 ?? "";
1612
+ } catch (err) {
1613
+ window.denShell?.errorToast?.(err);
1614
+ return false;
1615
+ }
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
+
1540
1631
  const $d = (sel) => st.root.querySelector(sel);
1541
1632
  const curPersona = () =>
1542
1633
  sd.pid === PLAIN
@@ -1563,10 +1654,13 @@ function personaModel() {
1563
1654
  function plistHtml() {
1564
1655
  const ps = sd.data?.personas ?? [];
1565
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;
1566
1662
  return (
1567
- personaListHtml(sd.f, ps, cats, (p) =>
1568
- personaRowHtml(p, p.id === sd.pid, sd.data),
1569
- ) +
1663
+ rows +
1570
1664
  `<div class="pgh"><span>${esc(t("ag.session"))}</span><span></span></div>` +
1571
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>`
1572
1666
  );
@@ -1608,13 +1702,30 @@ function paintDetails() {
1608
1702
  * and the dialog says so.
1609
1703
  */
1610
1704
  function paintRuntime(reset) {
1705
+ paintRuntimeFields(reset);
1706
+ // Last: a Name that names a running agent disables Start (AG-10.10b).
1707
+ paintStartsAs();
1708
+ }
1709
+
1710
+ function paintRuntimeFields(reset) {
1611
1711
  const rs = $d("#ag-runtime");
1612
1712
  const go = $d("#ag-go");
1613
1713
  const ready = $d("#ag-r-ready");
1614
1714
  const plain = sd.pid === PLAIN;
1615
- $d("#ag-r-l").hidden = plain;
1616
- $d("#ag-task-l").hidden = plain;
1617
- $d("#ag-adv").hidden = plain;
1715
+ // Bring in: the conversation's runtime, its own history; no first
1716
+ // instruction, no Advanced (it keeps what the persona gives).
1717
+ $d("#ag-r-l").hidden = plain || !!sd.bring;
1718
+ $d("#ag-task-l").hidden = plain || !!sd.bring;
1719
+ $d("#ag-adv").hidden = plain || !!sd.bring;
1720
+ $d("#ag-name-l").hidden = plain || !!sd.bring;
1721
+ if (sd.bring) {
1722
+ $d("#ag-r-miss").hidden = true;
1723
+ ready.hidden = true;
1724
+ const ok = !!bringPick() && !!curPersona();
1725
+ go.disabled = !ok;
1726
+ go.dataset.blocked = ok ? "" : "conversation";
1727
+ return;
1728
+ }
1618
1729
  const fb = $d("#ag-r-fb");
1619
1730
  fb.hidden = true;
1620
1731
  if (plain) {
@@ -1675,7 +1786,10 @@ function paintAdvanced() {
1675
1786
  const models = rtv?.models ?? [];
1676
1787
  const model = sd.model || def.model;
1677
1788
  const first = t("ag.new.modelDef", {
1678
- m: `${def.model || t("pe.rtDefault")} · ${versionLabel(def.model ? def.version : "latest")}`,
1789
+ // No model of its own: just the version, so the label fits the select.
1790
+ m: def.model
1791
+ ? `${def.model} · ${versionLabel(def.version)}`
1792
+ : versionLabel("latest"),
1679
1793
  });
1680
1794
  const verFirst = t("ag.new.verDef", {
1681
1795
  v: versionLabel(
@@ -1749,6 +1863,8 @@ async function fillForWorkspace(ws) {
1749
1863
  window.denShell?.errorToast?.(err);
1750
1864
  return false;
1751
1865
  }
1866
+ if (!(await fillConversations(ws))) return false;
1867
+ if (sd.bring && sd.pid === PLAIN) sd.pid = "";
1752
1868
  const ps = sd.data?.personas ?? [];
1753
1869
  if (sd.pid !== PLAIN && !ps.some((p) => p.id === sd.pid)) {
1754
1870
  // The first persona the list shows (grouped by category).
@@ -1765,9 +1881,19 @@ async function fillForWorkspace(ws) {
1765
1881
  return true;
1766
1882
  }
1767
1883
 
1768
- async function openStart() {
1884
+ /** The dialog's words for Start, or for Bring in (AG-17.1). */
1885
+ function dialogMode(bring) {
1886
+ sd.bring = bring ? { list: [], runtimes: [], root: "", pick: "" } : null;
1887
+ $d("#ag-dlg-h").textContent = t(bring ? "ag.bring.title" : "ag.new.title");
1888
+ $d("#ag-note").textContent = t(bring ? "ag.bring.note" : "ag.new.note");
1889
+ $d("#ag-go").textContent = t(bring ? "ag.bring.go" : "ag.go");
1890
+ $d("#ag-conv-f").hidden = !bring;
1891
+ }
1892
+
1893
+ async function openStart(bring = false) {
1769
1894
  const dlg = $d("#ag-dlg");
1770
1895
  if (!dlg || dlg.open) return;
1896
+ dialogMode(bring);
1771
1897
  const shown = viewWorkspaces();
1772
1898
  const wsSel = $d("#ag-ws");
1773
1899
  wsSel.innerHTML = shown
@@ -1812,21 +1938,127 @@ function startBody() {
1812
1938
  }
1813
1939
  const task = $d("#ag-task").value.trim();
1814
1940
  if (task) body.instruction = task;
1941
+ const name = $d("#ag-name").value.trim();
1942
+ if (name) body.name = name;
1815
1943
  return body;
1816
1944
  }
1817
1945
 
1818
1946
  function showStartError(err) {
1819
1947
  const el = $d("#ag-err");
1820
1948
  el.textContent = err?.message || String(err);
1949
+ // AG-10.10: that agent is running: offer to open it.
1950
+ const running = err?.data?.running;
1951
+ if (running?.sessionId) {
1952
+ const b = document.createElement("button");
1953
+ b.type = "button";
1954
+ b.className = "den-btn sm";
1955
+ b.id = "ag-open-running";
1956
+ b.textContent = t("ag.new.openRunning");
1957
+ b.addEventListener("click", () => {
1958
+ $d("#ag-dlg").close?.();
1959
+ void openPane(running.sessionId);
1960
+ });
1961
+ el.append(" ", b);
1962
+ }
1821
1963
  el.hidden = false;
1822
1964
  }
1823
1965
 
1966
+ /**
1967
+ * AG-10.10b: the id a Name gives, as the server's nameSlug: lowercase,
1968
+ * spaces to "-", only id characters, at most 64; null when none is left.
1969
+ */
1970
+ export function nameSlug(name) {
1971
+ const s = String(name)
1972
+ .trim()
1973
+ .toLowerCase()
1974
+ .replace(/\s+/g, "-")
1975
+ .replace(/[^a-z0-9._-]/g, "")
1976
+ .replace(/-{2,}/g, "-")
1977
+ .replace(/^[^a-z0-9]+/, "")
1978
+ .slice(0, 64)
1979
+ .replace(/[-._]+$/, "");
1980
+ return s && /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/.test(s) ? s : null;
1981
+ }
1982
+
1983
+ /**
1984
+ * The id this start takes (AG-10.10, 10.10b), said as you type: a Name is
1985
+ * the id as its slug. A running agent here with that id or title: "is
1986
+ * already running · Open it", Start off. A stopped one: "Resume it", or
1987
+ * Start takes the next suffix.
1988
+ */
1989
+ function paintStartsAs() {
1990
+ const el = $d("#ag-as");
1991
+ if (!el) return;
1992
+ const p = sd.pid && sd.pid !== PLAIN && !sd.bring ? curPersona() : null;
1993
+ el.hidden = !p;
1994
+ el.textContent = "";
1995
+ if (!p) return;
1996
+ const name = ($d("#ag-name")?.value ?? "").trim();
1997
+ const slug = name ? nameSlug(name) : null;
1998
+ const ws = $d("#ag-ws")?.value || viewWorkspaces()[0];
1999
+ const here = (st.data?.sessions ?? []).filter(
2000
+ (r) => r.kind === "agent" && r.workspace === ws,
2001
+ );
2002
+ const live = (r) => r.state === "running" || r.state === "stopping";
2003
+ const match = name
2004
+ ? (here.find(
2005
+ (r) =>
2006
+ live(r) &&
2007
+ (r.agent === slug ||
2008
+ (r.displayName ?? "").toLowerCase() === name.toLowerCase()),
2009
+ ) ?? here.find((r) => r.agent === slug))
2010
+ : null;
2011
+ const act = (label, fn) => {
2012
+ const b = document.createElement("button");
2013
+ b.type = "button";
2014
+ b.className = "den-btn sm";
2015
+ b.textContent = label;
2016
+ b.addEventListener("click", fn);
2017
+ return b;
2018
+ };
2019
+ if (match && live(match)) {
2020
+ el.append(
2021
+ t("ag.new.isRunning", { id: match.agent }) + " · ",
2022
+ act(t("ag.new.openIt"), () => {
2023
+ $d("#ag-dlg").close?.();
2024
+ void openPane(match.id);
2025
+ }),
2026
+ );
2027
+ $d("#ag-go").disabled = true;
2028
+ return;
2029
+ }
2030
+ if (match) {
2031
+ el.append(
2032
+ t("ag.new.isStopped", { id: match.agent }) + " · ",
2033
+ act(t("ag.new.resumeIt"), () => {
2034
+ $d("#ag-dlg").close?.();
2035
+ void restartRow(match, "resume", null);
2036
+ }),
2037
+ " " + t("ag.new.orNext", { id: nextFree(slug) }),
2038
+ );
2039
+ return;
2040
+ }
2041
+ el.textContent = t("ag.new.as", {
2042
+ id: slug ?? sd.data?.startsAs?.[p.id] ?? p.id,
2043
+ });
2044
+ }
2045
+
2046
+ /** The first `<slug>-<n>` no agent on this page has (the server decides). */
2047
+ function nextFree(slug) {
2048
+ const ids = new Set((st.data?.sessions ?? []).map((r) => r.agent));
2049
+ for (let n = 2; n < 1000; n++)
2050
+ if (!ids.has(`${slug.slice(0, 58)}-${n}`))
2051
+ return `${slug.slice(0, 58)}-${n}`;
2052
+ return slug;
2053
+ }
2054
+
1824
2055
  async function submitStart(form) {
1825
2056
  const dlg = $d("#ag-dlg");
1826
2057
  const go = $d("#ag-go");
1827
2058
  if (go.disabled) return;
1828
2059
  const ws = form.querySelector("#ag-ws").value || viewWorkspaces()[0];
1829
2060
  if (!ws || !sd.pid) return;
2061
+ if (sd.bring) return submitBringIn(ws);
1830
2062
  const bad = sd.extras.find((c) => commandProblem(c));
1831
2063
  if (sd.pid !== PLAIN && bad)
1832
2064
  return showStartError(new Error(t("pe.badCmd", { c: bad })));
@@ -1852,6 +2084,9 @@ async function submitStart(form) {
1852
2084
  startBody(),
1853
2085
  );
1854
2086
  window.denShell?.toast?.(t("ag.started", { id: r?.agent?.id ?? sd.pid }));
2087
+ // AG-10.10: the next start of this persona takes another id.
2088
+ personasAsked.delete(ws);
2089
+ $d("#ag-name").value = "";
1855
2090
  for (const w of r?.warnings ?? []) window.denShell?.toast?.(w);
1856
2091
  dlg.close?.();
1857
2092
  await refresh();
@@ -1865,6 +2100,36 @@ async function submitStart(form) {
1865
2100
  }
1866
2101
  }
1867
2102
 
2103
+ /** Bring in (AG-17.1): the runtime's resume of the conversation picked. */
2104
+ async function submitBringIn(ws) {
2105
+ const dlg = $d("#ag-dlg");
2106
+ const go = $d("#ag-go");
2107
+ const c = bringPick();
2108
+ const p = curPersona();
2109
+ if (!c || !p) return;
2110
+ go.disabled = true;
2111
+ go.textContent = t("ag.bring.going");
2112
+ try {
2113
+ const r = await api(
2114
+ "POST",
2115
+ `/api/workspaces/${encodeURIComponent(ws)}/conversations/${encodeURIComponent(c.id)}/import`,
2116
+ { persona: p.id, runtime: c.runtime },
2117
+ );
2118
+ window.denShell?.toast?.(t("ag.bring.done", { id: r?.agent?.id ?? p.id }));
2119
+ for (const w of r?.warnings ?? []) window.denShell?.toast?.(w);
2120
+ dlg.close?.();
2121
+ await refresh();
2122
+ if (r?.agent?.sessionId) await openPane(r.agent.sessionId);
2123
+ } catch (err) {
2124
+ showStartError(err);
2125
+ // Still open elsewhere, or taken since: the list says so now.
2126
+ await fillConversations(ws);
2127
+ } finally {
2128
+ go.textContent = t("ag.bring.go");
2129
+ if (dlg.open) paintRuntime(false);
2130
+ }
2131
+ }
2132
+
1868
2133
  /** New persona, Edit or Duplicate from the dialog; back to it after. */
1869
2134
  async function editFromStart(kind) {
1870
2135
  const p = curPersona();
@@ -1892,6 +2157,12 @@ async function editFromStart(kind) {
1892
2157
  function wireStartDialog(root) {
1893
2158
  const form = root.querySelector("#ag-form");
1894
2159
  form.addEventListener("click", (e) => {
2160
+ const cr = e.target.closest("#ag-conv [data-cid]");
2161
+ if (cr) {
2162
+ e.preventDefault();
2163
+ pickConversation(cr.dataset.cid);
2164
+ return;
2165
+ }
1895
2166
  const pr = e.target.closest("#ag-persona [data-pid]");
1896
2167
  if (pr) {
1897
2168
  e.preventDefault();
@@ -1944,6 +2215,9 @@ function wireStartDialog(root) {
1944
2215
  if (e.target.id === "ag-q") {
1945
2216
  sd.f.q = e.target.value;
1946
2217
  paintPlist(false);
2218
+ } else if (e.target.id === "ag-name") {
2219
+ // AG-10.10b: what the Name gives, as you type.
2220
+ paintRuntime(false);
1947
2221
  } else if (e.target.id === "ag-tools") {
1948
2222
  sd.extras = splitCommands(e.target.value);
1949
2223
  syncPermBlock(form, "ag", sd.data, {
@@ -2032,6 +2306,8 @@ function translateStatic(root) {
2032
2306
  };
2033
2307
  set("#ag-h", "n.agents");
2034
2308
  set("#ag-start", "ag.start");
2309
+ set("#ag-bring", "ag.bring");
2310
+ set("#ag-conv-t", "ag.bring.conv");
2035
2311
  set("#ag-clear", "ag.clear");
2036
2312
  set("#ag-dlg-h", "ag.new.title");
2037
2313
  set("#ag-ws-t", "ag.ws");
@@ -2041,6 +2317,7 @@ function translateStatic(root) {
2041
2317
  set("#ag-newp", "ag.new.newP");
2042
2318
  set("#ag-what-p", "pe.what");
2043
2319
  set("#ag-task-t", "ag.new.task");
2320
+ set("#ag-name-t", "ag.new.name");
2044
2321
  set("#ag-adv-s", "ag.new.adv");
2045
2322
  set("#ag-note", "ag.new.note");
2046
2323
  const task = root.querySelector("#ag-task");
@@ -2129,6 +2406,9 @@ export async function mountAgentsPage(root) {
2129
2406
  root.querySelector("#ag-start").addEventListener("click", () => {
2130
2407
  void openStart();
2131
2408
  });
2409
+ root.querySelector("#ag-bring")?.addEventListener("click", () => {
2410
+ void openStart(true);
2411
+ });
2132
2412
  root.querySelector("#ag-clear")?.addEventListener("click", (e) => {
2133
2413
  void clearStopped(e.currentTarget);
2134
2414
  });
@@ -642,6 +642,7 @@
642
642
  <button type="button" class="den-btn" id="ag-clear" hidden>
643
643
  Clear stopped
644
644
  </button>
645
+ <button type="button" class="den-btn" id="ag-bring">Bring in</button>
645
646
  <button type="button" class="den-btn primary" id="ag-start">
646
647
  + Start agent
647
648
  </button>
@@ -660,6 +661,18 @@
660
661
  <dialog class="ag-dlg pe-dlg" id="ag-dlg" aria-labelledby="ag-dlg-h">
661
662
  <form method="dialog" id="ag-form" novalidate>
662
663
  <h2 id="ag-dlg-h">Start an agent</h2>
664
+ <div class="fld" id="ag-conv-f" hidden>
665
+ <div class="fldh">
666
+ <span class="k" id="ag-conv-t">Conversation</span>
667
+ </div>
668
+ <p class="helpl" id="ag-conv-h"></p>
669
+ <div
670
+ class="plist"
671
+ id="ag-conv"
672
+ role="radiogroup"
673
+ aria-labelledby="ag-conv-t"
674
+ ></div>
675
+ </div>
663
676
  <div class="fld" id="ag-pfld">
664
677
  <div class="fldh">
665
678
  <span class="k" id="ag-p-t">Persona</span
@@ -712,6 +725,11 @@
712
725
  ><span id="ag-task-t">First instruction (optional)</span>
713
726
  <textarea id="ag-task" rows="4" maxlength="8000"></textarea>
714
727
  </label>
728
+ <label class="fld" id="ag-name-l"
729
+ ><span id="ag-name-t">Name (optional)</span>
730
+ <input id="ag-name" maxlength="40" autocomplete="off" />
731
+ </label>
732
+ <p class="helpl" id="ag-as" role="status"></p>
715
733
  <details id="ag-adv">
716
734
  <summary id="ag-adv-s">Advanced: model and permissions</summary>
717
735
  <div class="adv-body" id="ag-adv-b"></div>
@@ -139,7 +139,7 @@ function withCmd(text, cmd) {
139
139
  function withHost(t, key, vars) {
140
140
  const marks = {};
141
141
  const v = { ...vars };
142
- for (const k of ["host", "target"])
142
+ for (const k of ["host", "target", "user"])
143
143
  if (k in v) {
144
144
  marks[k] = v[k];
145
145
  v[k] = `\u0001${k}\u0001`;
@@ -203,6 +203,20 @@ export function countdownText(ms) {
203
203
  * Where a signed-in page goes: the page that was asked for. On /signin,
204
204
  * its same-origin `next` (else `/`); anywhere else, the same URL.
205
205
  */
206
+ /**
207
+ * 0.4.15: keep a sign-in's approval key (web-session.ts) in this origin's
208
+ * localStorage, where shell.js sends it from. It comes in the sign-in's
209
+ * own answer only.
210
+ */
211
+ function keepApproveKey(b) {
212
+ if (!b || typeof b.approveKey !== "string") return;
213
+ try {
214
+ localStorage.setItem("den.approveKey", b.approveKey);
215
+ } catch {
216
+ /* storage blocked: this browser signs in again to approve */
217
+ }
218
+ }
219
+
206
220
  export function landingAfterSignIn(loc) {
207
221
  if (loc.pathname !== "/signin") return loc.pathname + loc.search + loc.hash;
208
222
  const n = new URLSearchParams(loc.search).get("next") || "/";
@@ -263,6 +277,7 @@ function wireGithub(root, t, s) {
263
277
  return;
264
278
  }
265
279
  if (b.state === "ok") {
280
+ keepApproveKey(b);
266
281
  show(
267
282
  `<p class="lead" style="margin-top:12px">${esc(t("err.signin.githubDone"))}</p>`,
268
283
  );
@@ -351,6 +366,9 @@ function wirePair(root, t, s) {
351
366
  if (!box) return;
352
367
  const host = s.machine || "";
353
368
  const target = s.ssh?.target || "";
369
+ // SEC-2a-UX: the account to log in as, so the SSH app doesn't guess one.
370
+ const user = s.ssh?.user || "";
371
+ const login = user ? `${user}@${target}` : target;
354
372
  let poll = null;
355
373
  let tick = null;
356
374
  let gen = 0;
@@ -376,7 +394,7 @@ function wirePair(root, t, s) {
376
394
  const show = (b) => {
377
395
  const term =
378
396
  s.ssh?.name && target
379
- ? `<a class="btn" id="den-pair-term" href="ssh://${esc(target)}">${esc(t("err.signin.openTerminal"))}</a>`
397
+ ? `<a class="btn" id="den-pair-term" href="ssh://${esc(login)}">${esc(t("err.signin.openTerminal"))}</a>`
380
398
  : "";
381
399
  box.innerHTML =
382
400
  `<ol class="den-steps">` +
@@ -389,7 +407,11 @@ function wirePair(root, t, s) {
389
407
  `<p>${withHost(t, "err.signin.noTerminal", { host })}</p>` +
390
408
  cmdLine("den-pair-ssh", b.ssh, t, term) +
391
409
  (term
392
- ? `<p class="den-pair-note">${withHost(t, "err.signin.sshNote", { target })}</p>`
410
+ ? `<p class="den-pair-note" id="den-pair-note">${
411
+ user
412
+ ? withHost(t, "err.signin.sshUserNote", { host, user })
413
+ : withHost(t, "err.signin.sshNote", { target })
414
+ }</p>`
393
415
  : "");
394
416
  wireCopy(box, t);
395
417
  };
@@ -432,6 +454,7 @@ function wirePair(root, t, s) {
432
454
  return;
433
455
  }
434
456
  if (p.state === "ok") {
457
+ keepApproveKey(p);
435
458
  stop();
436
459
  box.innerHTML = `<p class="lead" id="den-pair-in">${esc(t("err.signin.youreIn"))}</p>`;
437
460
  location.replace(landingAfterSignIn(location));
@@ -524,6 +547,7 @@ function mountSignedOut(root, sh, d) {
524
547
  .then((b) => {
525
548
  go.disabled = false;
526
549
  if (b.ok) {
550
+ keepApproveKey(b);
527
551
  err.textContent = "";
528
552
  form.innerHTML = `<p class="lead" id="den-confirm-in">${esc(t("err.signin.youreIn"))}</p>`;
529
553
  location.replace(typeof b.next === "string" ? b.next : "/");
@@ -251,7 +251,7 @@ function _injectChangelogStyles() {
251
251
  padding: 5px 16px; border-radius: 5px; font-size: 12px;
252
252
  font-weight: 600; cursor: pointer;
253
253
  }
254
- .cl-got-it:hover { opacity: .85; }
254
+ .cl-got-it:hover { filter: brightness(0.92); }
255
255
  `;
256
256
  document.head.appendChild(style);
257
257
 
@@ -271,6 +271,8 @@
271
271
  "err.signin.noTerminal":
272
272
  "¿No tienes una terminal en {host}? Desde este ordenador, ejecuta:",
273
273
  "err.signin.openTerminal": "Abrir Terminal",
274
+ "err.signin.sshUserNote":
275
+ "Inicia sesión con tu cuenta de {host} ({user}) y su contraseña, o con una clave SSH.",
274
276
  "err.signin.sshNote":
275
277
  "Abrir Terminal abre ssh://{target} en tu aplicación de terminal. Necesita acceso SSH a {target}.",
276
278
  "err.signin.whyTitle": "¿Por qué?",
@@ -456,6 +458,14 @@
456
458
  "ag.new.plainP":
457
459
  "Una terminal en la carpeta del espacio de trabajo. Sin agente.",
458
460
  "ag.new.task": "Primera instrucción (opcional)",
461
+ "ag.new.name": "Nombre (opcional)",
462
+ "ag.new.as": "Se inicia como {id}",
463
+ "ag.new.openRunning": "Abrir el que está en ejecución",
464
+ "ag.new.isRunning": "{id} ya está en ejecución",
465
+ "ag.new.openIt": "Abrirlo",
466
+ "ag.new.isStopped": "{id} está detenido",
467
+ "ag.new.resumeIt": "Reanudarlo",
468
+ "ag.new.orNext": "o Iniciar crea {id}",
459
469
  "ag.new.taskPh":
460
470
  "p. ej., Toma la historia del estado vacío. Sigue el diseño y añade una prueba.",
461
471
  "ag.new.adv": "Avanzado: modelo y permisos",
@@ -465,6 +475,48 @@
465
475
  "ag.new.modelDef": "Predeterminado de la persona ({m})",
466
476
  "ag.new.ver": "Versión",
467
477
  "ag.new.verDef": "Predeterminado de la persona ({v})",
478
+ // AG-17.1, Bring in.
479
+ "ag.bring": "Traer",
480
+ "ag.bring.title": "Traer una conversación",
481
+ "ag.bring.conv": "Conversación",
482
+ "ag.bring.help":
483
+ "Conversaciones iniciadas fuera de Den en la carpeta de este espacio de trabajo o en las de sus repositorios, la más reciente primero.",
484
+ "ag.bring.none":
485
+ "No hay conversaciones iniciadas fuera de Den en la carpeta de este espacio de trabajo ni en las de sus repositorios.",
486
+ "ag.bring.untitled": "Conversación sin título",
487
+ "ag.bring.open": "Abierta en otro lugar",
488
+ "ag.bring.openTip": "Sigue abierta en otra terminal. Ciérrala allí primero",
489
+ "ag.bring.agent": "Agente {id}",
490
+ "ag.bring.note":
491
+ "La continúa en la carpeta donde se ejecutó, con las herramientas y protecciones de Den desde ahora. Sigue en ejecución si cierras el panel. Ciérrala antes en cualquier otro terminal: Den solo la ve abierta si se escribió en el último minuto.",
492
+ "ag.bring.go": "Traer",
493
+ "ag.bring.going": "Trayendo…",
494
+ "ag.bring.done": "Traída como {id}",
495
+ "ag.bring.now": "ahora mismo",
496
+ "ag.bring.min": "hace {n} min",
497
+ "ag.bring.h": "hace {n} h",
498
+ "ap.title": "{agent} quiere:",
499
+ "ap.agent": "El agente {name}",
500
+ "ap.noAgent": "Algo en esta máquina (sin identidad de agente)",
501
+ "ap.noAgentWarn": "Si no le pediste esto a un agente, deniégalo.",
502
+ "ap.signin.title": "Algo espera tu aprobación.",
503
+ "ap.signin": "Inicia sesión para verlo: ejecuta {cmd} y abre el enlace.",
504
+ "ap.do.workspace_create": 'Crear el espacio "{name}"',
505
+ "ap.do.repo_add":
506
+ 'Añadir el repositorio de {path} al espacio "{workspace}"',
507
+ "ap.do.repo_add.new":
508
+ 'Añadir el repositorio de {path} a un espacio nuevo "{workspace}"',
509
+ "ap.do.service_try": "Iniciar {service}",
510
+ "ap.says": "El agente dice:",
511
+ "ap.review": "Revisar",
512
+ "ap.hide": "Ocultar detalles",
513
+ "ap.later": "Más tarde",
514
+ "ap.deny": "Denegar",
515
+ "ap.allow": "Permitir",
516
+ "ap.waits": "Nada cambia hasta que lo permitas. Espera hasta las {time}.",
517
+ "ap.more": "{n} más en espera",
518
+ "ap.failed": "Se ejecutó y falló: {error}",
519
+ "ap.done": "Hecho: {what}",
468
520
  "ag.new.note":
469
521
  "Se inicia en la carpeta del espacio de trabajo. Sigue en ejecución si cierras el panel.",
470
522
  "ag.new.started": "{p} iniciado en {w}",