@monster-orange/den 0.4.21 → 0.4.23

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 (37) hide show
  1. package/CHANGELOG.md +115 -0
  2. package/docs/distributions.md +186 -42
  3. package/package.json +1 -1
  4. package/service/install.sh +213 -63
  5. package/src/shared/agents/den-claude.ts +13 -1
  6. package/src/shared/agents/guardrails/rotation-hook.ts +5 -0
  7. package/src/shared/agents/records.ts +6 -0
  8. package/src/shared/agents/rotate-settings.ts +309 -0
  9. package/src/shared/agents/rotation.ts +110 -5
  10. package/src/shared/agents/runtime.ts +6 -0
  11. package/src/shared/agents/session-name.ts +21 -1
  12. package/src/shared/den-config.ts +27 -12
  13. package/src/shared/launchd-session.ts +92 -0
  14. package/src/shared/sessions/client.ts +24 -0
  15. package/src/shared/sessions/host.ts +114 -5
  16. package/src/shared/sessions/types.ts +5 -0
  17. package/src/ui/cli/agent-ops.ts +469 -17
  18. package/src/ui/cli/agents-command.ts +200 -3
  19. package/src/ui/cli/doctor-command.ts +72 -0
  20. package/src/ui/cli/doctor-where.ts +203 -0
  21. package/src/ui/cli/index.ts +1 -1
  22. package/src/ui/cli/server-agent-ops.ts +2 -2
  23. package/src/ui/cli/service-command.ts +217 -11
  24. package/src/ui/cli/sessions-command.ts +46 -12
  25. package/src/ui/cli/web-command.ts +36 -5
  26. package/src/ui/cli/web-service.ts +160 -0
  27. package/src/ui/server/agent-routes.ts +149 -5
  28. package/src/ui/server/index.ts +17 -5
  29. package/src/ui/server/public/agents-page.js +316 -27
  30. package/src/ui/server/public/agents.html +62 -0
  31. package/src/ui/server/public/i18n/es.js +58 -2
  32. package/src/ui/server/public/i18n/ja.js +54 -2
  33. package/src/ui/server/public/settings-registry.js +204 -0
  34. package/src/ui/server/public/settings.html +43 -4
  35. package/src/ui/server/public/shell.js +56 -2
  36. package/src/ui/server/route-classes.ts +21 -0
  37. package/src/ui/server/session-routes.ts +27 -5
@@ -347,12 +347,119 @@ function wsLinkHtml(ws) {
347
347
  return `<a class="wslink" href="${esc(wsHref(ws))}" title="${esc(t("ag.goWs", { ws }))}">${esc(ws)}</a>`;
348
348
  }
349
349
 
350
+ /**
351
+ * D20: where its session runs, as a tag: "Stops at logout" (the desktop
352
+ * session) or "No GUI" (outside it: kept across logout). `button`: a
353
+ * button that says why when tapped (the pane bar); else a span (a row,
354
+ * itself a button). "" when the host didn't say (off macOS, older host).
355
+ */
356
+ function whereTag(r, button = false) {
357
+ const s = r?.launchd;
358
+ const logout = s === "Aqua" || s === "LoginWindow";
359
+ if (!logout && s !== "Background" && s !== "System") return "";
360
+ const key = logout ? "ag.where.logout" : "ag.where.bg";
361
+ const tip = t(`${key}Tip`, { cli: CLI });
362
+ const cls = `tagx${logout ? " warn" : ""}`;
363
+ const where = logout ? "logout" : "bg";
364
+ return button
365
+ ? `<button type="button" class="${cls}" data-act="where" data-where="${where}" title="${esc(tip)}" aria-label="${esc(`${t(key)}: ${tip}`)}">${logout ? "⚠ " : ""}${esc(t(key))}</button>`
366
+ : `<span class="${cls}" data-where="${where}" title="${esc(tip)}">${logout ? "⚠ " : ""}${esc(t(key))}</span>`;
367
+ }
368
+
350
369
  /** Its rotation, when one is due: "ready" or "due" (den#4). */
351
370
  function rotationOf(r) {
352
- if (!r.rotation?.due) return "";
371
+ // D28: on_due off: nothing will happen, so nothing is shown.
372
+ if (!r.rotation?.due || r.rotation.onDue === "off") return "";
353
373
  return r.rotation.ready ? "ready" : "due";
354
374
  }
355
375
 
376
+ /** D28: Settings › Agents (its Rotation row). */
377
+ function settingsHref() {
378
+ const nav = window.denShell?.nav?.() ?? [];
379
+ const base = nav.find((n) => n.id === "settings")?.href ?? "/settings";
380
+ return `${base}?q=rotation#agents`;
381
+ }
382
+
383
+ /**
384
+ * D28: what happens to a due agent, in words, from its on_due (auto: Den
385
+ * asks for the handoff at idle and relaunches it; ask; auto that gave up
386
+ * asking). Older servers send no on_due: as ask.
387
+ */
388
+ function rotMessage(r) {
389
+ const rot = r.rotation;
390
+ const why = rotationWhy(rot);
391
+ const auto = rot.onDue === "auto" && !rot.fellBack;
392
+ if (rot.relaunchWanted && !rot.ready) return t("ag.rc.wanted", { why });
393
+ if (auto && !isStopped(r))
394
+ return t(rot.ready ? "ag.rc.autoReady" : "ag.rc.autoAsk", { why });
395
+ if (rot.fellBack && !rot.ready)
396
+ return t(
397
+ rot.handoff === "invalid" ? "ag.rc.fellBackBad" : "ag.rc.fellBack",
398
+ { why },
399
+ );
400
+ return t(
401
+ rot.ready
402
+ ? "ag.rc.ready"
403
+ : rot.handoff === "invalid"
404
+ ? "ag.rc.bad"
405
+ : "ag.rc.missing",
406
+ { why },
407
+ );
408
+ }
409
+
410
+ /**
411
+ * D28: the rotation callout's buttons, wherever it shows (a row, the pane
412
+ * bar): Relaunch now (from the handoff, or asking for it first), Write
413
+ * handoff (a running agent without one), Keep this conversation with
414
+ * "don't ask again", and Settings. "" when nothing is due or on_due is off.
415
+ */
416
+ function rotActionsHtml(r) {
417
+ const rot = r.rotation;
418
+ if (!rot?.due || rot.onDue === "off") return "";
419
+ const id = r.agent;
420
+ const now = `<button type="button" class="den-btn sm primary" data-act="relaunch" aria-label="${esc(t("ag.relaunchName", { id }))}">${esc(t("ag.relaunch"))}</button>`;
421
+ const write =
422
+ !isStopped(r) && !rot.ready && !rot.relaunchWanted
423
+ ? `<button type="button" class="den-btn sm" data-act="rot-write" aria-label="${esc(t("ag.rc.writeName", { id }))}">${esc(t("ag.rc.write"))}</button>`
424
+ : "";
425
+ const keep = `<button type="button" class="den-btn sm" data-act="rot-keep" aria-label="${esc(t("ag.rc.keepName", { id }))}">${esc(t("ag.rc.keep"))}</button>`;
426
+ const never = `<label class="agnever"><input type="checkbox" data-rot-never> ${esc(t("ag.rc.never"))}</label>`;
427
+ const settings = `<a class="lnk" data-rot-settings href="${esc(settingsHref())}">${esc(t("ag.rc.settings"))}</a>`;
428
+ return now + write + keep + never + settings;
429
+ }
430
+
431
+ /** D28: the effective settings and where each comes from, in words. */
432
+ function rotFromText(rot) {
433
+ if (!rot?.from) return "";
434
+ return t(
435
+ rot.after === 0
436
+ ? "ag.rc.fromNever"
437
+ : rot.after === 1
438
+ ? "ag.rc.from.one"
439
+ : "ag.rc.from",
440
+ {
441
+ n: rot.after,
442
+ src: rot.from.after_compactions,
443
+ mode: t(`ag.rc.mode.${rot.onDue}`),
444
+ src2: rot.from.on_due,
445
+ },
446
+ );
447
+ }
448
+
449
+ /** D28: the callout under a row: the message, its line of settings, the buttons. */
450
+ function rotCalloutHtml(r) {
451
+ const acts = rotActionsHtml(r);
452
+ if (!acts) return "";
453
+ const from = rotFromText(r.rotation);
454
+ return (
455
+ `<span class="agcant" data-rot-msg data-handoff="${esc(r.rotation.handoff)}">${esc(rotMessage(r))}</span>` +
456
+ (from
457
+ ? `<span class="agcant agfrom" data-rot-from>${esc(from)}</span>`
458
+ : "") +
459
+ acts
460
+ );
461
+ }
462
+
356
463
  /** A stopped agent's row: its button (it opens an ended pane) and actions. */
357
464
  function stoppedHtml(r) {
358
465
  const open = st.open.includes(r.id);
@@ -361,27 +468,15 @@ function stoppedHtml(r) {
361
468
  const meta = [r.runtime, r.cwd].filter(Boolean).join(" · ");
362
469
  const who = esc(r.agent);
363
470
  const rot = rotationOf(r);
364
- const relaunch =
365
- rot === "ready"
366
- ? `<button type="button" class="den-btn sm primary" data-act="relaunch" aria-label="${esc(t("ag.relaunchName", { id: r.agent }))}">${esc(t("ag.relaunch"))}</button>`
367
- : "";
471
+ // D28: a due agent's callout (unless on_due is off).
472
+ const callout = rotCalloutHtml(r);
368
473
  const resume = r.resumable
369
474
  ? `<button type="button" class="den-btn sm primary" data-act="resume" aria-label="${esc(t("ag.resumeName", { id: r.agent }))}">${esc(t("ag.resume"))}</button>`
370
475
  : "";
371
476
  const fresh = `<button type="button" class="den-btn sm" data-act="fresh" aria-label="${esc(t("ag.freshName", { id: r.agent }))}">${esc(t("ag.fresh"))}</button>`;
372
477
  const remove = `<button type="button" class="den-btn sm" data-act="remove" aria-label="${esc(t("ag.removeName", { id: r.agent }))}">${esc(t("ag.remove"))}</button>`;
373
- // A rotation due without a handoff that passes: say so (den#4).
374
- const handoff =
375
- rot === "due"
376
- ? `<span class="agcant" data-handoff="${esc(r.rotation.handoff)}">${esc(
377
- t(
378
- r.rotation.handoff === "missing"
379
- ? "ag.rot.noHandoff"
380
- : "ag.rot.badHandoff",
381
- { why: rotationWhy(r.rotation) },
382
- ),
383
- )}</span>`
384
- : "";
478
+ // D25: an agent with no pane open can be renamed too.
479
+ const rename = `<button type="button" class="den-btn sm" data-act="rename" aria-label="${esc(t("ag.renameName", { id: r.agent }))}">${esc(t("ag.rn.act"))}</button>`;
385
480
  const cant =
386
481
  !r.resumable && r.noResume && rot !== "ready"
387
482
  ? `<span class="agcant" data-no-resume>${esc(r.noResume.startsWith(`${r.runtime} `) ? t("ag.noResume", { runtime: r.runtime }) : r.noResume)}</span>`
@@ -400,7 +495,7 @@ function stoppedHtml(r) {
400
495
  (meta ? `<span class="meta">${esc(meta)}</span>` : "") +
401
496
  `<span class="why">${esc(stopReason(r))}</span>` +
402
497
  `</button>` +
403
- `<div class="agacts">${handoff}${cant}${relaunch}${resume}${fresh}${remove}</div>` +
498
+ `<div class="agacts">${callout}${cant}${resume}${fresh}${rename}${remove}</div>` +
404
499
  `</div>`
405
500
  );
406
501
  }
@@ -428,11 +523,12 @@ function rowHtml(r) {
428
523
  ? `<span class="tagx warn" title="${esc(t("ag.revokedTip"))}">${esc(t("ag.revoked"))}</span>`
429
524
  : "";
430
525
  const ovTags = r.kind === "agent" ? overrideTagsHtml(r) : "";
526
+ const wTag = whereTag(r);
431
527
  const tags =
432
- rotTag || revokedTag || ovTags
433
- ? `<span class="agtags">${revokedTag}${rotTag}${ovTags}</span>`
528
+ rotTag || revokedTag || ovTags || wTag
529
+ ? `<span class="agtags">${revokedTag}${rotTag}${wTag}${ovTags}</span>`
434
530
  : "";
435
- return (
531
+ const button =
436
532
  `<button type="button" class="agr" data-id="${esc(r.id)}" data-kind="${esc(r.kind)}" data-state="${esc(r.state)}"` +
437
533
  ` aria-pressed="${open}"${r.attachable ? "" : ' aria-disabled="true"'}` +
438
534
  ` aria-label="${esc(t("ag.openName", { id: `${r.workspace} / ${displayName(r)}` }))}">` +
@@ -440,7 +536,16 @@ function rowHtml(r) {
440
536
  `<span class="nm">${nm}</span>${right}` +
441
537
  (meta ? `<span class="meta">${esc(meta)}</span>` : "") +
442
538
  tags +
443
- `</button>`
539
+ `</button>`;
540
+ // D28: a running agent whose rotation is due: its callout, with buttons
541
+ // (before, only a tag: nothing to press).
542
+ const callout = r.kind === "agent" ? rotCalloutHtml(r) : "";
543
+ if (!callout) return button;
544
+ return (
545
+ `<div class="agst" data-agent="${esc(r.agent)}" data-state="${esc(r.state)}" data-ws="${esc(r.workspace)}" data-rotation="${rot}">` +
546
+ button +
547
+ `<div class="agacts">${callout}</div>` +
548
+ `</div>`
444
549
  );
445
550
  }
446
551
 
@@ -586,6 +691,7 @@ async function refresh() {
586
691
  } else if (r.ok) {
587
692
  st.data = await r.json();
588
693
  st.signedOut = false;
694
+ toastAutoRelaunches(st.data?.sessions ?? []);
589
695
  }
590
696
  } catch {
591
697
  /* the last list stays; the next ask may answer */
@@ -976,9 +1082,10 @@ function paintBar(p) {
976
1082
  const reconnect = p.lost
977
1083
  ? `<button type="button" class="den-btn sm" data-act="reconnect">${esc(t("ag.reconnect"))}</button>`
978
1084
  : "";
979
- // AG-18: ⋯, its menu: Change persona… (a runtime that can resume).
1085
+ // AG-18: ⋯, its menu: Rename… (D25, every agent), Change persona… (a
1086
+ // runtime that can resume).
980
1087
  const more =
981
- r?.kind === "agent" && r.personaChange
1088
+ r?.kind === "agent"
982
1089
  ? `<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>`
983
1090
  : "";
984
1091
  bar.innerHTML =
@@ -987,6 +1094,10 @@ function paintBar(p) {
987
1094
  (r?.tokenRevoked
988
1095
  ? `<span class="tagx warn" title="${esc(t("ag.revokedTip"))}">${esc(t("ag.revoked"))}</span>`
989
1096
  : "") +
1097
+ whereTag(r, true) +
1098
+ (r?.kind === "agent" && rotActionsHtml(r)
1099
+ ? `<span class="agrot" data-rot-bar title="${esc(rotMessage(r))}"><span class="tagx warn" data-rotation="${rotationOf(r)}">${esc(t(rotationOf(r) === "ready" ? "ag.rot.ready" : "ag.rot.due", { why: rotationWhy(r.rotation) }))}</span>${rotActionsHtml(r)}</span>`
1100
+ : "") +
990
1101
  `<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>`;
991
1102
  // Tabs: the tab's × closes it; the pane's bar has none.
992
1103
  const x = bar.querySelector('[data-act="close"]');
@@ -1494,6 +1605,93 @@ async function restartRow(r, verb, button) {
1494
1605
  }
1495
1606
  }
1496
1607
 
1608
+ /**
1609
+ * D28 Relaunch now: from the handoff when it passes; else Den asks for it
1610
+ * first (a stopped agent resumes to write it) and relaunches it once it
1611
+ * passes, by itself.
1612
+ */
1613
+ async function relaunchNow(r, button) {
1614
+ if (r.rotation?.ready) return restartRow(r, "relaunch", button);
1615
+ if (button) button.disabled = true;
1616
+ try {
1617
+ const out = await api("POST", agentPath(r, "handoff"), { relaunch: true });
1618
+ window.denShell?.toast?.(
1619
+ t(out?.resumed ? "ag.rc.askedResumed" : "ag.rc.askedRelaunch", {
1620
+ id: r.agent,
1621
+ }),
1622
+ );
1623
+ } catch (err) {
1624
+ window.denShell?.errorToast?.(err);
1625
+ }
1626
+ await refresh();
1627
+ }
1628
+
1629
+ /** D28 Write handoff: the prompt, typed into its session at idle. */
1630
+ async function writeHandoff(r, button) {
1631
+ if (button) button.disabled = true;
1632
+ try {
1633
+ await api("POST", agentPath(r, "handoff"), {});
1634
+ window.denShell?.toast?.(t("ag.rc.asked", { id: r.agent }));
1635
+ } catch (err) {
1636
+ // Mid-turn is expected, not an error: say when to try again.
1637
+ if (/mid-turn/.test(String(err?.message ?? "")))
1638
+ window.denShell?.toast?.(t("ag.rc.midTurn", { id: r.agent }));
1639
+ else window.denShell?.errorToast?.(err);
1640
+ }
1641
+ await refresh();
1642
+ }
1643
+
1644
+ /** D28 Keep this conversation (and "don't ask again": never, for it). */
1645
+ async function keepConversation(r, button) {
1646
+ const never = !!button
1647
+ ?.closest(".agacts, .agrot")
1648
+ ?.querySelector("[data-rot-never]")?.checked;
1649
+ if (button) button.disabled = true;
1650
+ try {
1651
+ await api("POST", agentPath(r, "keep"), never ? { never: true } : {});
1652
+ window.denShell?.toast?.(
1653
+ t(never ? "ag.rc.keptNever" : "ag.rc.kept", { id: r.agent }),
1654
+ );
1655
+ } catch (err) {
1656
+ window.denShell?.errorToast?.(err);
1657
+ }
1658
+ await refresh();
1659
+ }
1660
+
1661
+ /**
1662
+ * D28: a relaunch Den did by itself since the last look: a toast saying
1663
+ * so, and that the old conversation is kept. The first look only notes
1664
+ * what is there.
1665
+ */
1666
+ function toastAutoRelaunches(rows) {
1667
+ const seen = (st.rotSeen ??= new Map());
1668
+ const first = !st.rotLooked;
1669
+ st.rotLooked = true;
1670
+ for (const r of rows) {
1671
+ const last = r.kind === "agent" ? r.lastRotation : undefined;
1672
+ if (!last) continue;
1673
+ const key = `${r.workspace}/${r.agent}`;
1674
+ if (seen.get(key) === last.at) continue;
1675
+ seen.set(key, last.at);
1676
+ if (first || !last.auto) continue;
1677
+ window.denShell?.toast?.(
1678
+ t("ag.rc.autoRelaunched", {
1679
+ id: r.agent,
1680
+ why:
1681
+ last.reason === "pr-merged"
1682
+ ? t("ag.rot.merged")
1683
+ : t(
1684
+ last.compactions === 1
1685
+ ? "ag.rot.compactions.one"
1686
+ : "ag.rot.compactions",
1687
+ { n: last.compactions },
1688
+ ),
1689
+ old: last.conversation ?? "?",
1690
+ }),
1691
+ );
1692
+ }
1693
+ }
1694
+
1497
1695
  /** Undo: the removed agents come back (POST /api/agents/restore). */
1498
1696
  async function undoRemove(ids) {
1499
1697
  try {
@@ -2126,6 +2324,63 @@ function goLabel() {
2126
2324
  return t(sd.change ? "ag.pc.go" : sd.bring ? "ag.bring.go" : "ag.go");
2127
2325
  }
2128
2326
 
2327
+ /**
2328
+ * D25: the Rename dialog for agent row `r`: its name (empty: back to its
2329
+ * id), and the name other sessions will see it by (D24).
2330
+ */
2331
+ function openRename(r, opener) {
2332
+ const dlg = $d("#ag-rn");
2333
+ if (!dlg || dlg.open) return;
2334
+ const input = $d("#ag-rn-name");
2335
+ const help = $d("#ag-rn-help");
2336
+ const err = $d("#ag-rn-err");
2337
+ $d("#ag-rn-h").textContent = t("ag.rn.title", { id: r.agent });
2338
+ $d("#ag-rn-t").textContent = t("ag.rn.name");
2339
+ $d("#ag-rn-go").textContent = t("ag.rn.go");
2340
+ $d("#ag-rn-cancel").textContent = t("ag.rn.cancel");
2341
+ input.value = r.displayName || "";
2342
+ input.placeholder = r.agent;
2343
+ const paintHelp = () => {
2344
+ const name = input.value.trim() || r.agent;
2345
+ help.textContent = t("ag.rn.help", {
2346
+ id: r.agent,
2347
+ peer: `${CLI}-${r.workspace}:${name}`,
2348
+ });
2349
+ };
2350
+ paintHelp();
2351
+ err.hidden = true;
2352
+ input.oninput = paintHelp;
2353
+ const close = () => {
2354
+ dlg.close?.();
2355
+ dlg.removeAttribute("open");
2356
+ opener?.focus?.();
2357
+ };
2358
+ $d("#ag-rn-cancel").onclick = close;
2359
+ $d("#ag-rn-form").onsubmit = async (e) => {
2360
+ e.preventDefault();
2361
+ const name = input.value.trim();
2362
+ const go = $d("#ag-rn-go");
2363
+ go.disabled = true;
2364
+ try {
2365
+ await api("POST", agentPath(r, "name"), {
2366
+ name: name && name !== r.agent ? name : null,
2367
+ });
2368
+ close();
2369
+ await refresh();
2370
+ paintLayout();
2371
+ } catch (x) {
2372
+ err.textContent = x?.message || String(x);
2373
+ err.hidden = false;
2374
+ } finally {
2375
+ go.disabled = false;
2376
+ }
2377
+ };
2378
+ if (typeof dlg.showModal === "function") dlg.showModal();
2379
+ else dlg.setAttribute("open", "");
2380
+ input.focus();
2381
+ input.select();
2382
+ }
2383
+
2129
2384
  /** AG-18: Change persona… for agent row `r` (its persona, model, name). */
2130
2385
  function openPersonaChange(r) {
2131
2386
  return openStart(false, {
@@ -2705,9 +2960,13 @@ export async function mountAgentsPage(root) {
2705
2960
  (x) => x.agent === agent && x.workspace === ws,
2706
2961
  );
2707
2962
  if (!r) return;
2708
- if (which === "resume" || which === "fresh" || which === "relaunch")
2963
+ if (which === "relaunch") void relaunchNow(r, act);
2964
+ else if (which === "rot-write") void writeHandoff(r, act);
2965
+ else if (which === "rot-keep") void keepConversation(r, act);
2966
+ else if (which === "resume" || which === "fresh")
2709
2967
  void restartRow(r, which, act);
2710
2968
  else if (which === "remove") void removeRow(r, act);
2969
+ else if (which === "rename") openRename(r, act);
2711
2970
  return;
2712
2971
  }
2713
2972
  const b = e.target.closest(".agr");
@@ -2738,8 +2997,20 @@ export async function mountAgentsPage(root) {
2738
2997
  const id = b.closest(".pane")?.dataset.id ?? b.dataset.id;
2739
2998
  const p = id && st.panes.get(id);
2740
2999
  if (!p) return;
3000
+ if (b.dataset.act === "where") {
3001
+ // D20: why it stops at logout (or what it lacks), for touch too.
3002
+ window.denShell?.toast?.(b.title);
3003
+ return;
3004
+ }
2741
3005
  if (b.dataset.act === "close") closePane(id);
2742
- else if (b.dataset.act === "stop") void stopAgent(p, b);
3006
+ else if (b.dataset.act === "relaunch" || b.dataset.act.startsWith("rot-")) {
3007
+ // D28: the callout's buttons in the pane bar.
3008
+ const r = rowOf(id);
3009
+ if (!r) return;
3010
+ if (b.dataset.act === "relaunch") void relaunchNow(r, b);
3011
+ else if (b.dataset.act === "rot-write") void writeHandoff(r, b);
3012
+ else if (b.dataset.act === "rot-keep") void keepConversation(r, b);
3013
+ } else if (b.dataset.act === "stop") void stopAgent(p, b);
2743
3014
  else if (b.dataset.act === "resume") {
2744
3015
  const r = rowOf(id);
2745
3016
  if (r) void restartRow(r, "resume", b);
@@ -2750,7 +3021,25 @@ export async function mountAgentsPage(root) {
2750
3021
  window.denShell?.openMenu?.(b, {
2751
3022
  label: t("ag.more", { id: r.agent }),
2752
3023
  items: [
2753
- { label: t("ag.pc.item"), onSelect: () => void openPersonaChange(r) },
3024
+ // D25: a visible Rename, for touch and keyboard (not only a
3025
+ // double-click on a tab).
3026
+ { label: t("ag.rn.item"), onSelect: () => openRename(r, b) },
3027
+ // D28: its rotation settings and where each comes from.
3028
+ {
3029
+ label: t("ag.rc.menu"),
3030
+ onSelect: () =>
3031
+ window.denShell?.toast?.(
3032
+ rotFromText(r.rotation) || t("ag.rc.noState"),
3033
+ ),
3034
+ },
3035
+ ...(r.personaChange
3036
+ ? [
3037
+ {
3038
+ label: t("ag.pc.item"),
3039
+ onSelect: () => void openPersonaChange(r),
3040
+ },
3041
+ ]
3042
+ : []),
2754
3043
  ],
2755
3044
  });
2756
3045
  }
@@ -233,6 +233,38 @@
233
233
  font: 12px/1.4 var(--sans);
234
234
  color: var(--faint);
235
235
  }
236
+ /* D28: the rotation callout: its message, its settings, its buttons. */
237
+ .agst .agcant[data-rot-msg] {
238
+ color: var(--warn-text, var(--text));
239
+ }
240
+ .agst .agfrom {
241
+ font-size: 11.5px;
242
+ }
243
+ .agnever {
244
+ display: inline-flex;
245
+ align-items: center;
246
+ gap: 4px;
247
+ font: 12px/1.4 var(--sans);
248
+ color: var(--muted);
249
+ }
250
+ /* The callout takes a line of its own under the bar's name and
251
+ buttons, so it never runs under Stop (375 px). */
252
+ .pane .bar2:has(.agrot) {
253
+ flex-wrap: wrap;
254
+ row-gap: 4px;
255
+ }
256
+ .bar2 .agrot {
257
+ display: flex;
258
+ flex-wrap: wrap;
259
+ align-items: center;
260
+ gap: 4px;
261
+ min-width: 0;
262
+ order: 10;
263
+ flex: 1 1 100%;
264
+ }
265
+ .bar2 .agrot .agnever {
266
+ display: none;
267
+ }
236
268
  .agst[data-review] > .agr {
237
269
  border-left-color: var(--warn);
238
270
  }
@@ -412,6 +444,16 @@
412
444
  flex: none;
413
445
  white-space: nowrap;
414
446
  }
447
+ /* D20: the launchd tag is a button (tap: why); it looks like a tag. */
448
+ button.tagx {
449
+ background: none;
450
+ cursor: pointer;
451
+ line-height: inherit;
452
+ }
453
+ button.tagx:focus-visible {
454
+ outline: 2px solid var(--accent);
455
+ outline-offset: 1px;
456
+ }
415
457
  .wslink {
416
458
  color: inherit;
417
459
  text-decoration: none;
@@ -771,6 +813,26 @@
771
813
  </div>
772
814
  </form>
773
815
  </dialog>
816
+ <!-- D25: Rename, from a pane's ⋯ or a stopped agent's row. -->
817
+ <dialog class="ag-dlg" id="ag-rn" aria-labelledby="ag-rn-h">
818
+ <form method="dialog" id="ag-rn-form" novalidate>
819
+ <h2 id="ag-rn-h">Rename</h2>
820
+ <label class="fld"
821
+ ><span id="ag-rn-t">Name</span>
822
+ <input id="ag-rn-name" maxlength="40" autocomplete="off" />
823
+ </label>
824
+ <p class="help" id="ag-rn-help"></p>
825
+ <p class="err" id="ag-rn-err" role="alert" hidden></p>
826
+ <div class="btns">
827
+ <button type="button" class="den-btn" id="ag-rn-cancel">
828
+ Cancel
829
+ </button>
830
+ <button type="submit" class="den-btn primary" id="ag-rn-go">
831
+ Rename
832
+ </button>
833
+ </div>
834
+ </form>
835
+ </dialog>
774
836
 
775
837
  <script type="module">
776
838
  import {
@@ -487,9 +487,50 @@
487
487
  "Su sesión rotó ({why}), pero no escribió handoff.md. Reanúdalo para que lo escriba, o empieza de cero.",
488
488
  "ag.rot.badHandoff":
489
489
  "Su sesión rotó ({why}), pero su handoff.md no está completo. Reanúdalo para arreglarlo, o empieza de cero.",
490
- "ag.relaunch": "Relanzar desde el traspaso",
491
- "ag.relaunchName": "Relanzar {id} desde el traspaso",
490
+ "ag.relaunch": "Relanzar ahora",
491
+ "ag.relaunchName": "Relanzar {id} ahora",
492
492
  "ag.relaunched": "{id} relanzado desde su traspaso",
493
+ "ag.rc.autoAsk":
494
+ "Toca rotar ({why}). Den le pide el traspaso cuando esté libre y luego lo relanza.",
495
+ "ag.rc.autoReady":
496
+ "Toca rotar ({why}). Su traspaso está listo: Den lo relanza cuando esté libre.",
497
+ "ag.rc.ready": "Toca rotar ({why}). Su traspaso está listo.",
498
+ "ag.rc.missing": "Toca rotar ({why}). Aún no tiene traspaso.",
499
+ "ag.rc.bad": "Toca rotar ({why}). Su traspaso no está completo.",
500
+ "ag.rc.fellBack":
501
+ "Toca rotar ({why}). No escribió el traspaso tras pedírselo 2 veces.",
502
+ "ag.rc.wanted":
503
+ "Toca rotar ({why}). Den lo relanza en cuanto escriba su traspaso.",
504
+ "ag.rc.write": "Escribir traspaso",
505
+ "ag.rc.writeName": "Pedir a {id} que escriba su traspaso",
506
+ "ag.rc.keep": "Mantener esta conversación",
507
+ "ag.rc.keepName": "Mantener la conversación de {id}",
508
+ "ag.rc.never": "No volver a preguntar para este agente",
509
+ "ag.rc.settings": "Ajustes",
510
+ "ag.rc.from":
511
+ "Rota tras {n} compactaciones ({src}); al tocar: {mode} ({src2})",
512
+ "ag.rc.fellBackBad":
513
+ "Toca rotar ({why}). Su traspaso sigue sin pasar la revisión tras pedírselo 2 veces.",
514
+ "ag.rc.from.one":
515
+ "Rota tras {n} compactación ({src}); al tocar: {mode} ({src2})",
516
+ "ag.rc.fromNever": "Nunca rota ({src}); al tocar: {mode} ({src2})",
517
+ "ag.rc.midTurn":
518
+ "{id} está a mitad de turno: pídelo de nuevo cuando termine",
519
+ "ag.rc.neverN": "nunca",
520
+ "ag.rc.mode.auto": "relanzar solo",
521
+ "ag.rc.mode.ask": "preguntar",
522
+ "ag.rc.mode.off": "nada",
523
+ "ag.rc.menu": "Ajustes de rotación",
524
+ "ag.rc.noState": "Aún no rota: cuenta desde su próximo inicio.",
525
+ "ag.rc.asked": "Se pidió a {id} su traspaso",
526
+ "ag.rc.askedRelaunch":
527
+ "Se pidió a {id} su traspaso. Den lo relanza en cuanto pase la revisión.",
528
+ "ag.rc.askedResumed":
529
+ "{id} reanudado para escribir su traspaso. Den lo relanza en cuanto pase la revisión.",
530
+ "ag.rc.kept": "Conversación de {id} mantenida: su cuenta vuelve a empezar",
531
+ "ag.rc.keptNever": "Conversación de {id} mantenida: ya no rotará",
532
+ "ag.rc.autoRelaunched":
533
+ "{id} relanzado desde su traspaso tras {why}. La conversación anterior {old} se conserva.",
493
534
  "ag.remove": "Quitar",
494
535
  "ag.removeName": "Quitar {id}",
495
536
  "ag.removed": "{id} quitado",
@@ -705,6 +746,7 @@
705
746
  "ver.pinned": "fijada {v}",
706
747
  "ver.usedLatest": "{n} ahora sigue la versión más reciente",
707
748
  "set.agents.personas": "Personas",
749
+ "set.agents.rotation": "Rotación",
708
750
  // The Agents tab (AG-4a-term, agents-page.js).
709
751
  "n.agents": "Agentes",
710
752
  "kind.agent": "Agente",
@@ -790,6 +832,20 @@
790
832
  "ag.hint.tile": "Un clic añade una sesión al mosaico, hasta 4.",
791
833
  "ag.renameTip": "Doble clic para cambiar el nombre",
792
834
  "ag.renameName": "Cambiar el nombre de {id}",
835
+ "ag.rn.item": "Cambiar el nombre…",
836
+ "ag.rn.title": "Cambiar el nombre de {id}",
837
+ "ag.rn.name": "Nombre",
838
+ "ag.rn.help":
839
+ "Lo muestran su pestaña, su fila y la cabecera del panel; el id {id} no cambia. Las demás sesiones lo ven como {peer} desde su próximo inicio o reanudación. Vacío: vuelve a {id}.",
840
+ "ag.rn.go": "Cambiar el nombre",
841
+ "ag.rn.cancel": "Cancelar",
842
+ "ag.rn.act": "Renombrar",
843
+ "ag.where.logout": "Se detiene al cerrar sesión",
844
+ "ag.where.logoutTip":
845
+ "Se ejecuta en la sesión de escritorio, así que cerrar sesión lo termina. Para que los agentes sigan: {cli} service install --mode always (opcional, aún no probado tras cerrar sesión)",
846
+ "ag.where.bg": "Sin GUI",
847
+ "ag.where.bgTip":
848
+ "Sigue ejecutándose tras cerrar sesión, fuera de la sesión de escritorio: sin el llavero de inicio de sesión ni apps con GUI. 1Password usa el token de la cuenta de servicio, los navegadores van sin interfaz y las carpetas protegidas necesitan Acceso total al disco ({cli} doctor).",
793
849
  "ag.rt.ready": "Inicia {name} {version} desde",
794
850
  "ag.rt.readyNoVer": "Inicia {name} desde",
795
851
  "ag.rt.notFound": "No se encontró {name}.",