@monster-orange/den 0.4.22 → 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.
@@ -99,8 +99,10 @@ import {
99
99
  import {
100
100
  readWorkspacesFile,
101
101
  conversationFolders,
102
+ listWorkspaceServices,
102
103
  resolveWorkspaceDir,
103
104
  } from "../../shared/environment-manager/workspaces.js";
105
+ import { readCatalog } from "../../shared/environment-manager/catalog.js";
104
106
  import { getStateDir } from "../../shared/paths.js";
105
107
  import { HostError } from "../../shared/sessions/client.js";
106
108
  import {
@@ -133,7 +135,13 @@ import {
133
135
  changePersona,
134
136
  listConversations,
135
137
  relaunchAgent,
136
- resumeAgent,
138
+ resumeOrRelaunch,
139
+ keepAgentConversation,
140
+ askForHandoff,
141
+ rotationSettings,
142
+ setRotationSetting,
143
+ rotationTick,
144
+ ROTATION_TICK_MS,
137
145
  startAgent,
138
146
  startFresh,
139
147
  stopAgent,
@@ -167,7 +175,7 @@ export interface AgentRouteDeps {
167
175
  }
168
176
 
169
177
  const AGENTS_PATH =
170
- /^\/api\/workspaces\/([^/]+)\/agents(?:\/([^/]+)\/(stop|resume|fresh|relaunch|remove|name|paths|persona))?$/;
178
+ /^\/api\/workspaces\/([^/]+)\/agents(?:\/([^/]+)\/(stop|resume|fresh|relaunch|remove|name|paths|persona|keep|handoff))?$/;
171
179
 
172
180
  const RUNTIME_PATH = /^\/api\/agents\/runtimes\/([^/]+)\/path$/;
173
181
  const PERSONA_PATH = /^\/api\/personas\/([^/]+)$/;
@@ -203,6 +211,15 @@ export function webDeps(over: AgentsDeps = {}): AgentsResolved {
203
211
  return conversationFolders(ws, cfg, getReposRoot());
204
212
  },
205
213
  }),
214
+ // D28: the service scope of rotation settings.
215
+ workspaceServices: (ws) => {
216
+ const cfg = readWorkspacesFile().workspaces[ws];
217
+ if (!cfg) return [];
218
+ return listWorkspaceServices(cfg, readCatalog(), ws).map((r) => ({
219
+ address: r.address,
220
+ path: r.repoPath,
221
+ }));
222
+ },
206
223
  stdout: NO_TTY,
207
224
  out: () => undefined,
208
225
  err: () => undefined,
@@ -406,6 +423,8 @@ export async function handleAgentRoutes(
406
423
  pathname === "/api/agents/clear-stopped" ||
407
424
  pathname === "/api/agents/restore" ||
408
425
  pathname === "/api/agents/resume-interrupted";
426
+ if (pathname === "/api/agents/rotation")
427
+ return rotationRoute(req, res, webDeps(deps.agents));
409
428
  if (
410
429
  pathname !== "/api/personas" &&
411
430
  pathname !== "/api/agents/runtimes" &&
@@ -490,6 +509,8 @@ export async function handleAgentRoutes(
490
509
  | "name"
491
510
  | "paths"
492
511
  | "persona"
512
+ | "keep"
513
+ | "handoff"
493
514
  | undefined;
494
515
 
495
516
  if (!action && method === "GET") {
@@ -571,6 +592,12 @@ export async function handleAgentRoutes(
571
592
  "only a person can change an agent's persona (an agent can't change its own or another's)",
572
593
  { status: 403 },
573
594
  );
595
+ // D28: an agent could keep itself from ever rotating.
596
+ if (verb === "keep" && (agent || actor))
597
+ throw new AgentError(
598
+ "only a person can keep an agent's conversation past its rotation",
599
+ { status: 403 },
600
+ );
574
601
  if (verb === "resume" && agent) {
575
602
  const why = agentResumeRefusal(agent, ws, id!, mcpActions());
576
603
  if (why) throw new AgentError(why, { status: 403 });
@@ -651,6 +678,38 @@ export async function handleAgentRoutes(
651
678
  what = `start ${s.rec.id} fresh (${s.rec.persona}, ${s.rec.runtime}) in ${ws}`;
652
679
  audit(what, s.early ? "exited at once" : undefined);
653
680
  sendStarted(res, s, d);
681
+ } else if (verb === "keep") {
682
+ const body = await jsonBody(req);
683
+ for (const k of Object.keys(body))
684
+ if (k !== "never")
685
+ throw new AgentError(`${k}: unknown field (keep takes never)`, {
686
+ usage: true,
687
+ });
688
+ if (body.never !== undefined && typeof body.never !== "boolean")
689
+ throw new AgentError("never must be true or false", { usage: true });
690
+ const rec = keepAgentConversation(d, id!, ws, {
691
+ never: body.never === true,
692
+ });
693
+ what = `keep ${rec.id}'s conversation in ${ws}${body.never ? " (never rotate)" : ""}`;
694
+ audit(what);
695
+ sendJson(res, { ok: true, agent: rec.id, never: body.never === true });
696
+ } else if (verb === "handoff") {
697
+ const body = await jsonBody(req);
698
+ for (const k of Object.keys(body))
699
+ if (k !== "relaunch")
700
+ throw new AgentError(`${k}: unknown field (handoff takes relaunch)`, {
701
+ usage: true,
702
+ });
703
+ const relaunch = body.relaunch === true;
704
+ const rec = await askForHandoff(d, id!, ws, undefined, { relaunch });
705
+ what = `ask ${rec.id} for its handoff in ${ws}${relaunch ? ", then relaunch" : ""}${rec.resumed ? " (resumed to write it)" : ""}`;
706
+ audit(what);
707
+ sendJson(res, {
708
+ ok: true,
709
+ agent: rec.id,
710
+ relaunch,
711
+ ...(rec.resumed ? { resumed: true } : {}),
712
+ });
654
713
  } else if (verb === "relaunch") {
655
714
  const s = await relaunchAgent(d, id!, ws);
656
715
  what = `relaunch ${s.rec.id} from its handoff (${s.why}) in ${ws}`;
@@ -732,9 +791,16 @@ export async function handleAgentRoutes(
732
791
  audit(what);
733
792
  sendJson(res, { ok: true, removed });
734
793
  } else {
735
- const s = await resumeAgent(d, id!, ws);
736
- audit(what);
737
- sendStarted(res, s, d);
794
+ // D28: a due agent under auto relaunches from its handoff instead.
795
+ const r = await resumeOrRelaunch(d, id!, ws);
796
+ if (r.kind === "relaunched") {
797
+ what = `relaunch ${r.s.rec.id} from its handoff (${r.s.why}) in ${ws}, on Resume`;
798
+ audit(what, r.s.early ? "exited at once" : undefined);
799
+ sendStarted(res, r.s, d, { relaunched: true });
800
+ } else {
801
+ audit(what);
802
+ sendStarted(res, r.s, d);
803
+ }
738
804
  }
739
805
  } catch (e) {
740
806
  audit(what, e instanceof Error ? e.message : String(e));
@@ -1209,8 +1275,86 @@ export function startAgentUpkeep(
1209
1275
  log(` ⚠ agents.remove_stopped_after: ${(e as Error).message}`);
1210
1276
  }
1211
1277
  };
1278
+ // D28: due agents under on_due auto, at idle: asked for the handoff,
1279
+ // then relaunched from it. One look at a time.
1280
+ let looking = false;
1281
+ const rotate = async () => {
1282
+ if (looking) return;
1283
+ looking = true;
1284
+ try {
1285
+ for (const e of await rotationTick(d)) {
1286
+ const who = `${e.agent} in ${e.workspace}`;
1287
+ if (e.kind === "relaunched") {
1288
+ const what = `relaunch ${who} from its handoff (${e.why}), by itself; the old conversation ${e.old ?? "?"} is kept`;
1289
+ recordAction(null, { verb: "agents", detail: what });
1290
+ log(`Relaunched ${who} from its handoff after ${e.why}`);
1291
+ } else if (e.kind === "asked")
1292
+ log(`Asked ${who} for its handoff (${e.asked} of 2)`);
1293
+ else if (e.kind === "fell-back")
1294
+ log(
1295
+ ` ⚠ ${who} wrote no handoff after 2 asks: its callout asks the person`,
1296
+ );
1297
+ else log(` ⚠ rotation of ${who}: ${e.why}`);
1298
+ }
1299
+ } catch (e) {
1300
+ log(` ⚠ agents.rotate: ${(e as Error).message}`);
1301
+ } finally {
1302
+ looking = false;
1303
+ }
1304
+ };
1212
1305
  setTimeout(() => {
1213
1306
  void restart().then(prune);
1214
1307
  }, deps.delayMs ?? 2000).unref();
1215
1308
  setInterval(() => void prune(), PRUNE_EVERY_MS).unref();
1309
+ setInterval(() => void rotate(), ROTATION_TICK_MS).unref();
1310
+ }
1311
+
1312
+ /**
1313
+ * D28, `/api/agents/rotation`. GET: the settings (install, overrides,
1314
+ * defaults) and, with `?ws=&agent=`, that agent's effective values and
1315
+ * where each comes from. PUT `{ scope, target?, key, value }` sets one
1316
+ * (value null clears it): a person's only.
1317
+ */
1318
+ async function rotationRoute(
1319
+ req: http.IncomingMessage,
1320
+ res: http.ServerResponse,
1321
+ d: AgentsResolved,
1322
+ ): Promise<boolean> {
1323
+ const method = req.method ?? "GET";
1324
+ try {
1325
+ if (method === "GET" || method === "HEAD") {
1326
+ const q = new URL(req.url ?? "/", "http://localhost").searchParams;
1327
+ const ws = q.get("ws") ?? undefined;
1328
+ const agent = q.get("agent") ?? undefined;
1329
+ if ((ws && !NAME_RE.test(ws)) || (agent && !NAME_RE.test(agent)))
1330
+ throw new AgentError("not a name", { usage: true });
1331
+ sendJson(res, rotationSettings(d, agent, ws));
1332
+ return true;
1333
+ }
1334
+ if (method !== "PUT") return methodNotAllowed(res, "GET, PUT");
1335
+ if (agentPrincipalOf(req) || auditActorOf(req))
1336
+ throw new AgentError("only a person can change rotation settings", {
1337
+ status: 403,
1338
+ });
1339
+ const body = await jsonBody(req);
1340
+ for (const k of Object.keys(body))
1341
+ if (!["scope", "target", "key", "value"].includes(k))
1342
+ throw new AgentError(
1343
+ `${k}: unknown field (takes scope, target, key, value)`,
1344
+ { usage: true },
1345
+ );
1346
+ const out = setRotationSetting(d, body);
1347
+ recordAction(
1348
+ null,
1349
+ {
1350
+ verb: "agents",
1351
+ detail: `rotation ${String(body.scope)}${body.target ? ` ${String(body.target)}` : ""} ${String(body.key)} = ${body.value === null ? "(unset)" : JSON.stringify(body.value)}`,
1352
+ },
1353
+ principalOf(req),
1354
+ );
1355
+ sendJson(res, { ok: true, ...out });
1356
+ } catch (e) {
1357
+ sendError(res, e);
1358
+ }
1359
+ return true;
1216
1360
  }
@@ -868,7 +868,12 @@ import {
868
868
  import { heldActions } from "./held-actions.js";
869
869
  import type { ApprovalQueue } from "./approvals.js";
870
870
  import { processPathRules } from "../cli/onboard-reads.js";
871
- import { handleAgentRoutes, startAgentUpkeep } from "./agent-routes.js";
871
+ import {
872
+ handleAgentRoutes,
873
+ startAgentUpkeep,
874
+ webDeps as agentWebDeps,
875
+ } from "./agent-routes.js";
876
+ import { policyResolver } from "../cli/agent-ops.js";
872
877
  import { handleServerAgentRoutes } from "./server-agent-routes.js";
873
878
  import { freshDen } from "../cli/server-agent-ops.js";
874
879
  import {
@@ -4748,7 +4753,10 @@ async function handleRequest(
4748
4753
  res,
4749
4754
  pathname,
4750
4755
  guard.ok && (!guard.beyond || !!guard.session || !!guard.principal),
4751
- { waitingAgents: waitingAgentIds },
4756
+ {
4757
+ waitingAgents: waitingAgentIds,
4758
+ rotationPolicy: () => policyResolver(agentWebDeps()),
4759
+ },
4752
4760
  )
4753
4761
  )
4754
4762
  return;
@@ -5579,9 +5587,9 @@ server.on("error", (err: NodeJS.ErrnoException) => {
5579
5587
  if (err.code === "EADDRINUSE") {
5580
5588
  console.error(
5581
5589
  `\n ✗ Port ${PORT} is already in use.\n` +
5582
- `\n Another ${product().cli} instance may be running. To fix:\n` +
5583
- `\n lsof -ti :${PORT} | xargs kill # kill whatever is on the port` +
5584
- `\n make web # then restart\n`,
5590
+ `\n Another ${product().cli} server may be running, or its service holds the port.` +
5591
+ `\n ${product().cli} service status # is the service running it?` +
5592
+ `\n ${product().cli} web restart # restarts the service, or the plain server\n`,
5585
5593
  );
5586
5594
  process.exit(1);
5587
5595
  }
@@ -368,10 +368,98 @@ function whereTag(r, button = false) {
368
368
 
369
369
  /** Its rotation, when one is due: "ready" or "due" (den#4). */
370
370
  function rotationOf(r) {
371
- 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 "";
372
373
  return r.rotation.ready ? "ready" : "due";
373
374
  }
374
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
+
375
463
  /** A stopped agent's row: its button (it opens an ended pane) and actions. */
376
464
  function stoppedHtml(r) {
377
465
  const open = st.open.includes(r.id);
@@ -380,10 +468,8 @@ function stoppedHtml(r) {
380
468
  const meta = [r.runtime, r.cwd].filter(Boolean).join(" · ");
381
469
  const who = esc(r.agent);
382
470
  const rot = rotationOf(r);
383
- const relaunch =
384
- rot === "ready"
385
- ? `<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>`
386
- : "";
471
+ // D28: a due agent's callout (unless on_due is off).
472
+ const callout = rotCalloutHtml(r);
387
473
  const resume = r.resumable
388
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>`
389
475
  : "";
@@ -391,18 +477,6 @@ function stoppedHtml(r) {
391
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>`;
392
478
  // D25: an agent with no pane open can be renamed too.
393
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>`;
394
- // A rotation due without a handoff that passes: say so (den#4).
395
- const handoff =
396
- rot === "due"
397
- ? `<span class="agcant" data-handoff="${esc(r.rotation.handoff)}">${esc(
398
- t(
399
- r.rotation.handoff === "missing"
400
- ? "ag.rot.noHandoff"
401
- : "ag.rot.badHandoff",
402
- { why: rotationWhy(r.rotation) },
403
- ),
404
- )}</span>`
405
- : "";
406
480
  const cant =
407
481
  !r.resumable && r.noResume && rot !== "ready"
408
482
  ? `<span class="agcant" data-no-resume>${esc(r.noResume.startsWith(`${r.runtime} `) ? t("ag.noResume", { runtime: r.runtime }) : r.noResume)}</span>`
@@ -421,7 +495,7 @@ function stoppedHtml(r) {
421
495
  (meta ? `<span class="meta">${esc(meta)}</span>` : "") +
422
496
  `<span class="why">${esc(stopReason(r))}</span>` +
423
497
  `</button>` +
424
- `<div class="agacts">${handoff}${cant}${relaunch}${resume}${fresh}${rename}${remove}</div>` +
498
+ `<div class="agacts">${callout}${cant}${resume}${fresh}${rename}${remove}</div>` +
425
499
  `</div>`
426
500
  );
427
501
  }
@@ -454,7 +528,7 @@ function rowHtml(r) {
454
528
  rotTag || revokedTag || ovTags || wTag
455
529
  ? `<span class="agtags">${revokedTag}${rotTag}${wTag}${ovTags}</span>`
456
530
  : "";
457
- return (
531
+ const button =
458
532
  `<button type="button" class="agr" data-id="${esc(r.id)}" data-kind="${esc(r.kind)}" data-state="${esc(r.state)}"` +
459
533
  ` aria-pressed="${open}"${r.attachable ? "" : ' aria-disabled="true"'}` +
460
534
  ` aria-label="${esc(t("ag.openName", { id: `${r.workspace} / ${displayName(r)}` }))}">` +
@@ -462,7 +536,16 @@ function rowHtml(r) {
462
536
  `<span class="nm">${nm}</span>${right}` +
463
537
  (meta ? `<span class="meta">${esc(meta)}</span>` : "") +
464
538
  tags +
465
- `</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>`
466
549
  );
467
550
  }
468
551
 
@@ -608,6 +691,7 @@ async function refresh() {
608
691
  } else if (r.ok) {
609
692
  st.data = await r.json();
610
693
  st.signedOut = false;
694
+ toastAutoRelaunches(st.data?.sessions ?? []);
611
695
  }
612
696
  } catch {
613
697
  /* the last list stays; the next ask may answer */
@@ -1011,6 +1095,9 @@ function paintBar(p) {
1011
1095
  ? `<span class="tagx warn" title="${esc(t("ag.revokedTip"))}">${esc(t("ag.revoked"))}</span>`
1012
1096
  : "") +
1013
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
+ : "") +
1014
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>`;
1015
1102
  // Tabs: the tab's × closes it; the pane's bar has none.
1016
1103
  const x = bar.querySelector('[data-act="close"]');
@@ -1518,6 +1605,93 @@ async function restartRow(r, verb, button) {
1518
1605
  }
1519
1606
  }
1520
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
+
1521
1695
  /** Undo: the removed agents come back (POST /api/agents/restore). */
1522
1696
  async function undoRemove(ids) {
1523
1697
  try {
@@ -2786,7 +2960,10 @@ export async function mountAgentsPage(root) {
2786
2960
  (x) => x.agent === agent && x.workspace === ws,
2787
2961
  );
2788
2962
  if (!r) return;
2789
- 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")
2790
2967
  void restartRow(r, which, act);
2791
2968
  else if (which === "remove") void removeRow(r, act);
2792
2969
  else if (which === "rename") openRename(r, act);
@@ -2826,7 +3003,14 @@ export async function mountAgentsPage(root) {
2826
3003
  return;
2827
3004
  }
2828
3005
  if (b.dataset.act === "close") closePane(id);
2829
- 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);
2830
3014
  else if (b.dataset.act === "resume") {
2831
3015
  const r = rowOf(id);
2832
3016
  if (r) void restartRow(r, "resume", b);
@@ -2840,6 +3024,14 @@ export async function mountAgentsPage(root) {
2840
3024
  // D25: a visible Rename, for touch and keyboard (not only a
2841
3025
  // double-click on a tab).
2842
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
+ },
2843
3035
  ...(r.personaChange
2844
3036
  ? [
2845
3037
  {
@@ -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
  }