@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
@@ -473,9 +473,46 @@
473
473
  "セッションは交代しました({why})が、handoff.md がありません。再開して書かせるか、新しく始めてください。",
474
474
  "ag.rot.badHandoff":
475
475
  "セッションは交代しました({why})が、handoff.md が不完全です。再開して直すか、新しく始めてください。",
476
- "ag.relaunch": "引き継ぎから再起動",
477
- "ag.relaunchName": "{id} を引き継ぎから再起動",
476
+ "ag.relaunch": "今すぐ再起動",
477
+ "ag.relaunchName": "{id} を今すぐ再起動",
478
478
  "ag.relaunched": "{id} を引き継ぎから再起動しました",
479
+ "ag.rc.autoAsk":
480
+ "交代の時期です({why})。待機中になったら Den が引き継ぎを書かせ、再起動します。",
481
+ "ag.rc.autoReady":
482
+ "交代の時期です({why})。引き継ぎは準備済み: 待機中になったら Den が再起動します。",
483
+ "ag.rc.ready": "交代の時期です({why})。引き継ぎは準備済みです。",
484
+ "ag.rc.missing": "交代の時期です({why})。引き継ぎがまだありません。",
485
+ "ag.rc.bad": "交代の時期です({why})。引き継ぎが不完全です。",
486
+ "ag.rc.fellBack":
487
+ "交代の時期です({why})。2 回頼んでも引き継ぎを書きませんでした。",
488
+ "ag.rc.wanted":
489
+ "交代の時期です({why})。引き継ぎが書かれたら Den が再起動します。",
490
+ "ag.rc.write": "引き継ぎを書かせる",
491
+ "ag.rc.writeName": "{id} に引き継ぎを書かせる",
492
+ "ag.rc.keep": "この会話を続ける",
493
+ "ag.rc.keepName": "{id} の会話を続ける",
494
+ "ag.rc.never": "このエージェントには今後たずねない",
495
+ "ag.rc.settings": "設定",
496
+ "ag.rc.from": "圧縮 {n} 回で交代({src})。時期が来たら: {mode}({src2})",
497
+ "ag.rc.fellBackBad":
498
+ "交代の時期です({why})。2 回頼んでも引き継ぎが確認を通りません。",
499
+ "ag.rc.fromNever": "交代しません({src})。時期が来たら: {mode}({src2})",
500
+ "ag.rc.midTurn": "{id} は応答中です: ターンが終わってから頼んでください",
501
+ "ag.rc.neverN": "しない",
502
+ "ag.rc.mode.auto": "自動で再起動",
503
+ "ag.rc.mode.ask": "たずねる",
504
+ "ag.rc.mode.off": "何もしない",
505
+ "ag.rc.menu": "交代の設定",
506
+ "ag.rc.noState": "まだ交代の記録がありません: 次の起動から数えます。",
507
+ "ag.rc.asked": "{id} に引き継ぎを頼みました",
508
+ "ag.rc.askedRelaunch":
509
+ "{id} に引き継ぎを頼みました。確認が通ったら Den が再起動します。",
510
+ "ag.rc.askedResumed":
511
+ "引き継ぎを書かせるため {id} を再開しました。確認が通ったら Den が再起動します。",
512
+ "ag.rc.kept": "{id} の会話を続けます: 回数はやり直しです",
513
+ "ag.rc.keptNever": "{id} の会話を続けます: 今後は交代しません",
514
+ "ag.rc.autoRelaunched":
515
+ "{why}のあと {id} を引き継ぎから再起動しました。前の会話 {old} は残っています。",
479
516
  "ag.remove": "削除",
480
517
  "ag.removeName": "{id} を削除",
481
518
  "ag.removed": "{id} を削除しました",
@@ -691,6 +728,7 @@
691
728
  "ver.pinned": "{v} に固定",
692
729
  "ver.usedLatest": "{n} は最新バージョンに追従します",
693
730
  "set.agents.personas": "ペルソナ",
731
+ "set.agents.rotation": "交代",
694
732
  // The Agents tab (AG-4a-term, agents-page.js).
695
733
  "n.agents": "エージェント",
696
734
  "kind.agent": "エージェント",
@@ -775,6 +813,20 @@
775
813
  "ag.hint.tile": "クリックでセッションをタイルに追加します(最大 4)。",
776
814
  "ag.renameTip": "ダブルクリックで名前を変更",
777
815
  "ag.renameName": "{id} の名前を変更",
816
+ "ag.rn.item": "名前を変更…",
817
+ "ag.rn.title": "{id} の名前を変更",
818
+ "ag.rn.name": "名前",
819
+ "ag.rn.help":
820
+ "タブ、行、ペインの見出しに表示されます。ID {id} は変わりません。ほかのセッションからは、次の開始または再開から {peer} として見えます。空にすると {id} に戻ります。",
821
+ "ag.rn.go": "名前を変更",
822
+ "ag.rn.cancel": "キャンセル",
823
+ "ag.rn.act": "名前を変更",
824
+ "ag.where.logout": "ログアウトで停止",
825
+ "ag.where.logoutTip":
826
+ "デスクトップのログインセッションで動いているため、ログアウトすると終了します。エージェントを動かし続けるには: {cli} service install --mode always (任意。ログアウトをまたいだ動作はまだ未検証)",
827
+ "ag.where.bg": "GUI なし",
828
+ "ag.where.bgTip":
829
+ "デスクトップセッションの外で、ログアウト後も動き続けます。ログインキーチェーンと GUI アプリは使えません。1Password はサービスアカウントのトークン、ブラウザはヘッドレス、保護されたフォルダにはフルディスクアクセスが必要です ({cli} doctor)。",
778
830
  "ag.rt.ready": "{name} {version} を起動:",
779
831
  "ag.rt.readyNoVer": "{name} を起動:",
780
832
  "ag.rt.notFound": "{name} が見つかりません。",
@@ -889,6 +889,28 @@ export const DEN_SETTINGS = [
889
889
  read: () => null,
890
890
  },
891
891
 
892
+ {
893
+ id: "agents.rotation",
894
+ category: "agents",
895
+ label: "Rotation",
896
+ description:
897
+ "When an agent's conversation has compacted this many times (or its pull request merged), Den starts it again from its handoff: a fresh conversation, the same name, persona, workspace and folder. Auto asks the agent for its handoff when it is idle, then relaunches it by itself; Ask shows buttons on the agent; Off does nothing. Overrides win over this install's value: agent, persona in a workspace, persona, workspace, service. __DEN_CLI__ agents rotate",
898
+ keywords: [
899
+ "rotation",
900
+ "rotate",
901
+ "relaunch",
902
+ "handoff",
903
+ "compaction",
904
+ "compactions",
905
+ "context",
906
+ "restart",
907
+ "agent",
908
+ ],
909
+ scope: "user",
910
+ control: { type: "custom", render: renderRotation },
911
+ read: () => null,
912
+ },
913
+
892
914
  {
893
915
  id: "agents.personas",
894
916
  category: "agents",
@@ -1253,6 +1275,188 @@ function renderRuntimes(host, ctx) {
1253
1275
  void load();
1254
1276
  }
1255
1277
 
1278
+ const ROT_SCOPES = [
1279
+ ["agent", "Agent", "agent id"],
1280
+ ["persona-workspace", "Persona in a workspace", "persona@workspace"],
1281
+ ["persona", "Persona", "persona id"],
1282
+ ["workspace", "Workspace", "workspace"],
1283
+ ["service", "Service", "service, as the workspace names it"],
1284
+ ];
1285
+ const ROT_KEYS = [
1286
+ ["after_compactions", "After compactions", "a number, or never"],
1287
+ ["on_due", "When due", "auto, ask or off"],
1288
+ ["on_pr_merged", "When its PR merges", "true or false"],
1289
+ ];
1290
+ const rotVal = (v) => (v === undefined ? "" : String(v));
1291
+
1292
+ /** A value typed in the overrides form, as the key takes it. */
1293
+ export function rotationValue(key, raw) {
1294
+ const v = String(raw ?? "").trim();
1295
+ if (key === "after_compactions")
1296
+ return v === "never" || v === "0"
1297
+ ? "never"
1298
+ : /^\d+$/.test(v)
1299
+ ? Number(v)
1300
+ : v;
1301
+ if (key === "on_pr_merged")
1302
+ return v === "true" ? true : v === "false" ? false : v;
1303
+ return v;
1304
+ }
1305
+
1306
+ /** The Rotation row's HTML (b: GET /api/agents/rotation). */
1307
+ export function rotationHtml(b) {
1308
+ const inherited = (k) =>
1309
+ b.org?.[k] !== undefined
1310
+ ? `org config: ${rotVal(b.org[k])}`
1311
+ : `default: ${rotVal(b.defaults?.[k])}`;
1312
+ const inst = b.install || {};
1313
+ const after = inst.after_compactions;
1314
+ const field = (k, control) =>
1315
+ `<div class="rot-f" data-rot-key="${k}"><label for="rot-${k}">${escR(ROT_KEYS.find((x) => x[0] === k)[1])}</label>${control}<span class="set-tag" data-rot-src>${escR(inst[k] !== undefined ? "this install" : inherited(k))}</span></div>`;
1316
+ const sel = (k, opts) =>
1317
+ `<select id="rot-${k}" data-focus-key="rot-${k}"><option value=""${inst[k] === undefined ? " selected" : ""}>inherit (${escR(inherited(k))})</option>` +
1318
+ opts
1319
+ .map(
1320
+ ([v, l]) =>
1321
+ `<option value="${escR(v)}"${rotVal(inst[k]) === v ? " selected" : ""}>${escR(l)}</option>`,
1322
+ )
1323
+ .join("") +
1324
+ `</select>`;
1325
+ const rows = [];
1326
+ for (const [scope, byTarget] of Object.entries(b.overrides || {}))
1327
+ for (const [target, vals] of Object.entries(byTarget || {}))
1328
+ for (const [k, v] of Object.entries(vals || {}))
1329
+ rows.push(
1330
+ `<tr data-rot-row><td>${escR((ROT_SCOPES.find((x) => x[0] === scope) || [scope, scope])[1])}</td><td class="den-mono">${escR(target)}</td><td>${escR(k)}</td><td class="den-mono">${escR(rotVal(v))}</td>` +
1331
+ `<td><button type="button" class="btn sm" data-rot-clear data-scope="${escR(scope)}" data-target="${escR(target)}" data-key="${escR(k)}" aria-label="Clear ${escR(k)} for ${escR(scope)} ${escR(target)}">Clear</button></td></tr>`,
1332
+ );
1333
+ return (
1334
+ `<div class="rot" data-rotation-settings>` +
1335
+ `<div class="rot-inst">` +
1336
+ field(
1337
+ "after_compactions",
1338
+ `<input type="text" id="rot-after_compactions" data-focus-key="rot-after_compactions" inputmode="numeric" spellcheck="false" autocomplete="off" placeholder="${escR(inherited("after_compactions"))}" value="${escR(after === undefined ? "" : rotVal(after))}"> <button type="button" class="btn sm" data-rot-save-after>Save</button>`,
1339
+ ) +
1340
+ field(
1341
+ "on_due",
1342
+ sel("on_due", [
1343
+ ["auto", "Auto"],
1344
+ ["ask", "Ask"],
1345
+ ["off", "Off"],
1346
+ ]),
1347
+ ) +
1348
+ field(
1349
+ "on_pr_merged",
1350
+ sel("on_pr_merged", [
1351
+ ["true", "Relaunch"],
1352
+ ["false", "Keep going"],
1353
+ ]),
1354
+ ) +
1355
+ `</div>` +
1356
+ `<div class="tablewrap"><table class="svc rot-ov"><caption>Overrides (the most specific wins)</caption><thead><tr><th scope="col">Scope</th><th scope="col">For</th><th scope="col">Setting</th><th scope="col">Value</th><th scope="col" aria-label="Actions"></th></tr></thead><tbody>` +
1357
+ (rows.length
1358
+ ? rows.join("")
1359
+ : `<tr><td colspan="5" class="set-note">No overrides: every agent uses this install's values.</td></tr>`) +
1360
+ `</tbody></table></div>` +
1361
+ `<form class="rot-add" data-rot-add>` +
1362
+ `<label for="rot-a-scope">Scope</label> <select id="rot-a-scope">${ROT_SCOPES.map(([v, l]) => `<option value="${v}">${escR(l)}</option>`).join("")}</select> ` +
1363
+ `<label for="rot-a-target">For</label> <input type="text" id="rot-a-target" class="den-mono" spellcheck="false" autocomplete="off" placeholder="agent id" required> ` +
1364
+ `<label for="rot-a-key">Setting</label> <select id="rot-a-key">${ROT_KEYS.map(([v, l]) => `<option value="${v}">${escR(l)}</option>`).join("")}</select> ` +
1365
+ `<label for="rot-a-value">Value</label> <input type="text" id="rot-a-value" class="den-mono" spellcheck="false" autocomplete="off" placeholder="a number, or never" required> ` +
1366
+ `<button type="submit" class="btn sm primary">Add override</button></form>` +
1367
+ `<div class="rt-err" role="alert" hidden></div>` +
1368
+ `</div>`
1369
+ );
1370
+ }
1371
+
1372
+ /** The Rotation row: this install's values, the overrides, + Add override. */
1373
+ function renderRotation(host, ctx) {
1374
+ const err = () => host.querySelector(".rt-err");
1375
+ const fail = (x) => {
1376
+ const e = err();
1377
+ if (e) {
1378
+ e.textContent = x?.message || "Not saved";
1379
+ e.hidden = false;
1380
+ }
1381
+ };
1382
+ const load = () => {
1383
+ if (ctx.shell?.configInvalid?.() === true) {
1384
+ host.innerHTML = `<div class="set-note">Fix the config first: the org's rotation is read from it.</div>`;
1385
+ return Promise.resolve();
1386
+ }
1387
+ return ctx.shell
1388
+ .api("GET", "/api/agents/rotation")
1389
+ .then((b) => {
1390
+ host.innerHTML = rotationHtml(b || {});
1391
+ })
1392
+ .catch((x) => {
1393
+ host.innerHTML = `<div class="set-note">${escR(x?.message || "The rotation settings did not load.")}</div>`;
1394
+ });
1395
+ };
1396
+ const put = async (body) => {
1397
+ try {
1398
+ await ctx.shell.api("PUT", "/api/agents/rotation", body);
1399
+ await load();
1400
+ } catch (x) {
1401
+ fail(x);
1402
+ }
1403
+ };
1404
+ host.addEventListener("change", (e) => {
1405
+ const k = e.target.closest("[data-rot-key]")?.dataset.rotKey;
1406
+ if (e.target.id === "rot-a-scope") {
1407
+ const s = ROT_SCOPES.find((x) => x[0] === e.target.value);
1408
+ const t = host.querySelector("#rot-a-target");
1409
+ if (s && t) t.placeholder = s[2];
1410
+ return;
1411
+ }
1412
+ if (e.target.id === "rot-a-key") {
1413
+ const s = ROT_KEYS.find((x) => x[0] === e.target.value);
1414
+ const v = host.querySelector("#rot-a-value");
1415
+ if (s && v) v.placeholder = s[2];
1416
+ return;
1417
+ }
1418
+ if (!k || k === "after_compactions" || e.target.tagName !== "SELECT")
1419
+ return;
1420
+ const v = e.target.value;
1421
+ void put({
1422
+ scope: "install",
1423
+ key: k,
1424
+ value: v === "" ? null : rotationValue(k, v),
1425
+ });
1426
+ });
1427
+ host.addEventListener("click", (e) => {
1428
+ const b = e.target.closest("button");
1429
+ if (!b) return;
1430
+ if (b.hasAttribute("data-rot-save-after")) {
1431
+ const v = host.querySelector("#rot-after_compactions")?.value.trim();
1432
+ void put({
1433
+ scope: "install",
1434
+ key: "after_compactions",
1435
+ value: v ? rotationValue("after_compactions", v) : null,
1436
+ });
1437
+ } else if (b.hasAttribute("data-rot-clear"))
1438
+ void put({
1439
+ scope: b.dataset.scope,
1440
+ target: b.dataset.target,
1441
+ key: b.dataset.key,
1442
+ value: null,
1443
+ });
1444
+ });
1445
+ host.addEventListener("submit", (e) => {
1446
+ const f = e.target.closest("[data-rot-add]");
1447
+ if (!f) return;
1448
+ e.preventDefault();
1449
+ const key = f.querySelector("#rot-a-key").value;
1450
+ void put({
1451
+ scope: f.querySelector("#rot-a-scope").value,
1452
+ target: f.querySelector("#rot-a-target").value.trim(),
1453
+ key,
1454
+ value: rotationValue(key, f.querySelector("#rot-a-value").value),
1455
+ });
1456
+ });
1457
+ void load();
1458
+ }
1459
+
1256
1460
  /** Register Den's categories and rows with the shell's SETTINGS registry. */
1257
1461
  export function registerDenSettings(shell) {
1258
1462
  for (const c of DEN_SETTING_CATEGORIES) shell.registerSettingsCategory(c);
@@ -384,10 +384,9 @@
384
384
  }
385
385
  .rt {
386
386
  display: grid;
387
- grid-template-columns: minmax(0, 1.2fr) minmax(0, 0.8fr) minmax(
388
- 0,
389
- 2fr
390
- ) auto;
387
+ grid-template-columns:
388
+ minmax(0, 1.2fr) minmax(0, 0.8fr) minmax(0, 2fr)
389
+ auto;
391
390
  align-items: center;
392
391
  gap: 4px 12px;
393
392
  padding: 8px 10px;
@@ -427,6 +426,46 @@
427
426
  .rt-err[hidden] {
428
427
  display: none;
429
428
  }
429
+ /* D28: Settings › Agents › Rotation. */
430
+ .rot-inst {
431
+ display: grid;
432
+ gap: 8px;
433
+ margin-bottom: 12px;
434
+ }
435
+ .rot-f,
436
+ .rot-add {
437
+ display: flex;
438
+ flex-wrap: wrap;
439
+ align-items: center;
440
+ gap: 6px;
441
+ min-width: 0;
442
+ }
443
+ .rot-f label {
444
+ flex: 0 0 150px;
445
+ }
446
+ .rot-f input,
447
+ .rot-f select,
448
+ .rot-add input,
449
+ .rot-add select {
450
+ flex: 1 1 140px;
451
+ min-width: 0;
452
+ max-width: 100%;
453
+ }
454
+ .rot-add {
455
+ margin-top: 10px;
456
+ }
457
+ .rot-ov caption {
458
+ text-align: left;
459
+ color: var(--muted);
460
+ font-size: 12.5px;
461
+ padding-bottom: 4px;
462
+ }
463
+ .rot-ov th,
464
+ .rot-ov td {
465
+ padding: 4px 10px 4px 0;
466
+ text-align: left;
467
+ overflow-wrap: anywhere;
468
+ }
430
469
  .rt-fl {
431
470
  flex-basis: 100%;
432
471
  color: var(--muted);
@@ -611,9 +611,48 @@
611
611
  "Its session rotated ({why}), but it wrote no handoff.md. Resume it to write one, or start fresh.",
612
612
  "ag.rot.badHandoff":
613
613
  "Its session rotated ({why}), but its handoff.md is not complete. Resume it to fix it, or start fresh.",
614
- "ag.relaunch": "Relaunch from handoff",
615
- "ag.relaunchName": "Relaunch {id} from handoff",
614
+ "ag.relaunch": "Relaunch now",
615
+ "ag.relaunchName": "Relaunch {id} now",
616
616
  "ag.relaunched": "Relaunched {id} from its handoff",
617
+ "ag.rc.autoAsk":
618
+ "Due ({why}). Den asks it for its handoff when it is idle, then relaunches it.",
619
+ "ag.rc.autoReady":
620
+ "Due ({why}). Its handoff is ready: Den relaunches it when it is idle.",
621
+ "ag.rc.ready": "Due ({why}). Its handoff is ready.",
622
+ "ag.rc.missing": "Due ({why}). It has no handoff yet.",
623
+ "ag.rc.bad": "Due ({why}). Its handoff is not complete.",
624
+ "ag.rc.fellBack": "Due ({why}). It wrote no handoff after 2 asks.",
625
+ "ag.rc.wanted":
626
+ "Due ({why}). Den relaunches it once its handoff is written.",
627
+ "ag.rc.write": "Write handoff",
628
+ "ag.rc.writeName": "Ask {id} to write its handoff",
629
+ "ag.rc.keep": "Keep this conversation",
630
+ "ag.rc.keepName": "Keep {id}'s conversation",
631
+ "ag.rc.never": "Don't ask again for this agent",
632
+ "ag.rc.settings": "Settings",
633
+ "ag.rc.from":
634
+ "Rotates after {n} compactions ({src}); when due: {mode} ({src2})",
635
+ "ag.rc.fellBackBad":
636
+ "Due ({why}). Its handoff still fails the check after 2 asks.",
637
+ "ag.rc.from.one":
638
+ "Rotates after {n} compaction ({src}); when due: {mode} ({src2})",
639
+ "ag.rc.fromNever": "Never rotates ({src}); when due: {mode} ({src2})",
640
+ "ag.rc.midTurn": "{id} is mid-turn: ask again when its turn ends",
641
+ "ag.rc.neverN": "never",
642
+ "ag.rc.mode.auto": "relaunch by itself",
643
+ "ag.rc.mode.ask": "ask",
644
+ "ag.rc.mode.off": "nothing",
645
+ "ag.rc.menu": "Rotation settings",
646
+ "ag.rc.noState": "No rotation yet: it counts from its next start.",
647
+ "ag.rc.asked": "Asked {id} for its handoff",
648
+ "ag.rc.askedRelaunch":
649
+ "Asked {id} for its handoff. Den relaunches it once it passes.",
650
+ "ag.rc.askedResumed":
651
+ "Resumed {id} to write its handoff. Den relaunches it once it passes.",
652
+ "ag.rc.kept": "Kept {id}'s conversation: its count starts again",
653
+ "ag.rc.keptNever": "Kept {id}'s conversation: it won't rotate again",
654
+ "ag.rc.autoRelaunched":
655
+ "Relaunched {id} from its handoff after {why}. The old conversation {old} is kept.",
617
656
  "ag.interrupted": "{n} agents were interrupted when {name} restarted",
618
657
  "ag.interrupted.one": "{n} agent was interrupted when {name} restarted",
619
658
  "ag.resumeAll": "Resume all",
@@ -815,6 +854,7 @@
815
854
  "ver.pinned": "pinned {v}",
816
855
  "ver.usedLatest": "{n} now tracks the latest version",
817
856
  "set.agents.personas": "Personas",
857
+ "set.agents.rotation": "Rotation",
818
858
  // The Agents tab (AG-4a-term, agents-page.js).
819
859
  "n.agents": "Agents",
820
860
  "kind.agent": "Agent",
@@ -898,6 +938,20 @@
898
938
  "ag.hint.tile": "Click adds a session as a tile, up to 4.",
899
939
  "ag.renameTip": "Double-click to rename",
900
940
  "ag.renameName": "Rename {id}",
941
+ "ag.rn.item": "Rename…",
942
+ "ag.rn.title": "Rename {id}",
943
+ "ag.rn.name": "Name",
944
+ "ag.rn.help":
945
+ "Its tab, row and pane header show it; the id {id} never changes. Other sessions see it as {peer} from its next start or resume. Empty: back to {id}.",
946
+ "ag.rn.go": "Rename",
947
+ "ag.rn.cancel": "Cancel",
948
+ "ag.rn.act": "Rename",
949
+ "ag.where.logout": "Stops at logout",
950
+ "ag.where.logoutTip":
951
+ "It runs in the desktop login session, so logging out ends it. To keep agents running: {cli} service install --mode always (opt-in, not yet proven across a logout)",
952
+ "ag.where.bg": "No GUI",
953
+ "ag.where.bgTip":
954
+ "It keeps running after logout, outside the desktop session: no login keychain and no GUI apps. 1Password uses the service-account token, browsers run headless, and protected folders need Full Disk Access ({cli} doctor).",
901
955
  "ag.rt.ready": "Starts {name} {version} from",
902
956
  "ag.rt.readyNoVer": "Starts {name} from",
903
957
  "ag.rt.notFound": "{name} not found.",
@@ -374,6 +374,25 @@ export const ROUTES: readonly RouteEntry[] = [
374
374
  "forgets every stopped agent of the workspaces named (AG-14.1b)",
375
375
  ),
376
376
  M(["POST"], "/api/agents/restore", "Undo: brings removed agents back"),
377
+ M(
378
+ ["POST"],
379
+ "/api/workspaces/:ws/agents/:id/keep",
380
+ "D28 Keep this conversation: resets its rotation count (and, asked, sets its override to never); a person's only",
381
+ ),
382
+ S(
383
+ ["POST"],
384
+ "/api/workspaces/:ws/agents/:id/handoff",
385
+ "D28 Write handoff: types the handoff prompt into the agent's running session",
386
+ ),
387
+ R(
388
+ "/api/agents/rotation",
389
+ "D28: rotation settings, the overrides and an agent's effective values",
390
+ ),
391
+ M(
392
+ ["PUT"],
393
+ "/api/agents/rotation",
394
+ "D28: sets or clears one rotation setting at one scope; a person's only",
395
+ ),
377
396
  S(
378
397
  ["POST"],
379
398
  "/api/workspaces/:ws/agents/:id/fresh",
@@ -504,6 +523,8 @@ export const REGEX_ROUTES: Readonly<Record<string, readonly string[]>> = {
504
523
  "/api/workspaces/:ws/agents/:id/name",
505
524
  "/api/workspaces/:ws/agents/:id/paths",
506
525
  "/api/workspaces/:ws/agents/:id/persona",
526
+ "/api/workspaces/:ws/agents/:id/keep",
527
+ "/api/workspaces/:ws/agents/:id/handoff",
507
528
  ],
508
529
  "agent-routes.ts:RUNTIME_PATH": ["/api/agents/runtimes/:id/path"],
509
530
  "agent-routes.ts:PERSONA_PATH": ["/api/personas/:id"],
@@ -50,7 +50,11 @@ import { revokedAgentIds } from "../../shared/actor-token.js";
50
50
  import { listRecords } from "../../shared/agents/records.js";
51
51
  import { softNewlineSeq } from "../../shared/agents/catalog.js";
52
52
  import { getRuntime } from "../../shared/agents/runtimes.js";
53
- import type { RotationInfo } from "../../shared/agents/rotation.js";
53
+ import type {
54
+ RotationEntry,
55
+ RotationInfo,
56
+ RotationPolicy,
57
+ } from "../../shared/agents/rotation.js";
54
58
  import {
55
59
  connectHost,
56
60
  noHost,
@@ -61,7 +65,7 @@ import { sessionPaths } from "../../shared/sessions/paths.js";
61
65
  import { loadPty, type PtyLoad } from "../../shared/sessions/pty.js";
62
66
  import type { SessionInfo } from "../../shared/sessions/types.js";
63
67
  import { agentRows, newerOf, noteEnded } from "../cli/agent-ops.js";
64
- import type { Override } from "../../shared/agents/records.js";
68
+ import type { AgentRecord, Override } from "../../shared/agents/records.js";
65
69
  import { state as sessionState } from "../cli/sessions-command.js";
66
70
  import { recordAction } from "./audit.js";
67
71
  import { sendJson } from "./http-helpers.js";
@@ -85,6 +89,8 @@ export interface SessionRouteDeps {
85
89
  pty?: () => PtyLoad;
86
90
  /** The agent ids with an approval waiting for the person (AG-6a, ON-3). */
87
91
  waitingAgents?: () => ReadonlySet<string>;
92
+ /** D28: each agent's effective rotation settings (one resolver per list). */
93
+ rotationPolicy?: () => (rec: AgentRecord) => RotationPolicy | undefined;
88
94
  }
89
95
 
90
96
  interface Resolved {
@@ -95,6 +101,7 @@ interface Resolved {
95
101
  workspaceExists: (ws: string) => boolean;
96
102
  pty: () => PtyLoad;
97
103
  waitingAgents: () => ReadonlySet<string>;
104
+ rotationPolicy?: SessionRouteDeps["rotationPolicy"];
98
105
  }
99
106
 
100
107
  /** A workspace name as `?ws=` may give it. */
@@ -125,6 +132,7 @@ function resolve(d: SessionRouteDeps = {}): Resolved {
125
132
  )),
126
133
  pty: d.pty ?? (() => loadPty()),
127
134
  waitingAgents: d.waitingAgents ?? (() => new Set<string>()),
135
+ ...(d.rotationPolicy ? { rotationPolicy: d.rotationPolicy } : {}),
128
136
  };
129
137
  }
130
138
 
@@ -143,6 +151,8 @@ export interface ListedSession {
143
151
  /** ON-6: the server agent's row (its workspace is the server scope). */
144
152
  server?: boolean;
145
153
  kind: "agent" | "session";
154
+ /** D20: the launchd session its host runs in (Aqua ends at logout). */
155
+ launchd?: string;
146
156
  /** The `<name>` part: an agent's id, a plain session's name. */
147
157
  label: string;
148
158
  agent?: string;
@@ -197,13 +207,16 @@ export interface ListedSession {
197
207
  * Relaunch from handoff is its action, not Resume.
198
208
  */
199
209
  rotation?: RotationInfo;
210
+ /** D28: the last relaunch from a handoff (the page toasts one Den did by itself). */
211
+ lastRotation?: RotationEntry;
200
212
  }
201
213
 
202
214
  /** The list, from the host's sessions and the agent records. */
203
215
  export function listedSessions(
204
216
  sessions: SessionInfo[],
205
217
  d: Pick<Resolved, "stateDir" | "dist" | "activeWorkspaces"> &
206
- Partial<Pick<Resolved, "waitingAgents">>,
218
+ Partial<Pick<Resolved, "waitingAgents">> &
219
+ Pick<SessionRouteDeps, "rotationPolicy">,
207
220
  /** A workspace asked for by name (`?ws=`), listed even when inactive. */
208
221
  also?: string,
209
222
  ): ListedSession[] {
@@ -224,7 +237,12 @@ export function listedSessions(
224
237
  const out: ListedSession[] = [];
225
238
  const agentSessions = new Set<string>();
226
239
  noteEnded(d.stateDir(), mine, records);
227
- for (const r of agentRows(mine, records, d.stateDir())) {
240
+ for (const r of agentRows(
241
+ mine,
242
+ records,
243
+ d.stateDir(),
244
+ d.rotationPolicy?.(),
245
+ )) {
228
246
  const rec = records.find((x) => x.id === r.agent)!;
229
247
  const s = r.session;
230
248
  if (s) agentSessions.add(s.ref.id);
@@ -251,6 +269,7 @@ export function listedSessions(
251
269
  state: r.state,
252
270
  exited: !s || s.exited === true,
253
271
  ...(s?.exitCode !== undefined ? { exitCode: s.exitCode } : {}),
272
+ ...(s?.launchd ? { launchd: s.launchd } : {}),
254
273
  ...(r.startedAt ? { startedAt: r.startedAt } : {}),
255
274
  attachable: Boolean(s),
256
275
  resumable: r.resumable,
@@ -259,7 +278,9 @@ export function listedSessions(
259
278
  ...(r.stoppedAt ? { stoppedAt: r.stoppedAt } : {}),
260
279
  ...(r.endedAt ? { endedAt: r.endedAt } : {}),
261
280
  ...(r.interruptedAt ? { interruptedAt: r.interruptedAt } : {}),
262
- ...(r.rotation?.due ? { rotation: r.rotation } : {}),
281
+ // D28: always (its effective settings show), due or not.
282
+ ...(r.rotation ? { rotation: r.rotation } : {}),
283
+ ...(r.lastRotation ? { lastRotation: r.lastRotation } : {}),
263
284
  ...(revoked.has(rec.id) && s && !s.exited ? { tokenRevoked: true } : {}),
264
285
  ...(waiting.has(rec.id) && s && !s.exited ? { waiting: true } : {}),
265
286
  });
@@ -276,6 +297,7 @@ export function listedSessions(
276
297
  state: sessionState(s),
277
298
  exited: s.exited === true,
278
299
  ...(s.exitCode !== undefined ? { exitCode: s.exitCode } : {}),
300
+ ...(s.launchd ? { launchd: s.launchd } : {}),
279
301
  startedAt: s.startedAt,
280
302
  attachable: true,
281
303
  });