@monster-orange/den 0.4.8 → 0.4.10

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.
@@ -1,8 +1,10 @@
1
1
  /**
2
2
  * `<cli> usage` (den#6): how agents spend tokens and time, read from the
3
3
  * runtimes' own transcripts on this machine (usage sources, plugin kind
4
- * `usage`). Nothing is sent anywhere: it reads local files and writes
5
- * local files.
4
+ * `usage`). None of it is sent anywhere: it reads local files and writes
5
+ * local files. The one question it asks outside: when an agent ran
6
+ * `gh pr merge`, `gh pr view <ref> --json number,state` confirms the merge
7
+ * (gh-pr-state.ts) before it counts.
6
8
  *
7
9
  * agents [-w <ws>] [--json] each agent's tokens and outcome (PR opened
8
10
  * or merged, tests green, wall-clock), joined
@@ -15,10 +17,15 @@
15
17
  import * as fs from "fs";
16
18
  import * as path from "path";
17
19
  import {
20
+ agentFilesDir,
18
21
  listRecords,
19
22
  readRecord,
20
23
  writeRecord,
21
24
  } from "../../shared/agents/records.js";
25
+ import { clearDue, markDue } from "../../shared/agents/rotation.js";
26
+ import { ghPrState } from "../../shared/git/gh-pr-state.js";
27
+ import type { PrStateCheck } from "../../shared/usage/report.js";
28
+ import { readAgentsConfig } from "../../shared/den-config.js";
22
29
  import { getStateDir } from "../../shared/paths.js";
23
30
  import { product } from "../../shared/product.js";
24
31
  import {
@@ -48,11 +55,14 @@ export const USAGE_HELP = {
48
55
  "",
49
56
  "{cli} usage agents [-w <ws>] [--json]",
50
57
  " each agent's tokens and outcome (pull request opened or merged, tests",
51
- " green, wall-clock), joined on its conversation id, and saved in its",
52
- " record; then the share of agents with both",
58
+ " green, wall-clock), compactions and rotations, joined on its",
59
+ " conversation id, and saved in its record; then the share of agents",
60
+ " with both. A merged pull request makes the agent's rotation due",
53
61
  "{cli} usage report [--days <n>] [--json]",
54
62
  " the last n days (default 7): tokens, context re-reads, polling, denials,",
55
- " tool errors, corrections, subagents on Opus; written to",
63
+ " tool errors, corrections, subagents on Opus, the longest session's context",
64
+ " per call, the share of re-reads from never-ending sessions (more",
65
+ " compactions than agents.rotate.after_compactions), rotations; written to",
56
66
  " <state dir>/usage/<date>.json and .md, compared with the last report",
57
67
  ],
58
68
  };
@@ -64,6 +74,37 @@ export interface UsageDeps {
64
74
  out?: (line: string) => void;
65
75
  err?: (line: string) => void;
66
76
  cli?: string;
77
+ /** agents.rotate (default: the org config's). */
78
+ rotateAfter?: () => { afterCompactions: number } | undefined;
79
+ /**
80
+ * A pull request's state, to confirm a `gh pr merge` the agent ran
81
+ * (default: `gh pr view <ref> --json number,state` in the agent's folder).
82
+ */
83
+ prState?: PrStateCheck;
84
+ }
85
+
86
+ /**
87
+ * The check, counting how often gh ran (`asked`) and how often it could not
88
+ * run because gh is not installed (`missing`); the summary says both.
89
+ */
90
+ function counted(
91
+ f: NonNullable<UsageDeps["prState"]>,
92
+ ): NonNullable<UsageDeps["prState"]> & { asked: number; missing: number } {
93
+ const g = Object.assign(
94
+ async (ref: string, o: { repo?: string; cwd: string }) => {
95
+ try {
96
+ const st = await f(ref, o);
97
+ g.asked++;
98
+ return st;
99
+ } catch (e) {
100
+ if ((e as NodeJS.ErrnoException)?.code === "ENOENT") g.missing++;
101
+ else g.asked++;
102
+ throw e;
103
+ }
104
+ },
105
+ { asked: 0, missing: 0 },
106
+ );
107
+ return g;
67
108
  }
68
109
 
69
110
  function resolve(d: UsageDeps) {
@@ -74,6 +115,8 @@ function resolve(d: UsageDeps) {
74
115
  out: d.out ?? ((l: string) => console.log(l)),
75
116
  err: d.err ?? ((l: string) => console.error(l)),
76
117
  cli: d.cli ?? product().cli,
118
+ rotateLimit: () => rotateLimit(d),
119
+ prState: counted(d.prState ?? ghPrState(d.env ?? process.env)),
77
120
  };
78
121
  }
79
122
 
@@ -92,7 +135,11 @@ const mins = (ms?: number) =>
92
135
  ? `${Math.round(ms / 60_000)}m`
93
136
  : `${(ms / 3_600_000).toFixed(1)}h`;
94
137
 
95
- /** Save each attributed row's usage and outcome in its agent record. */
138
+ /**
139
+ * Save each attributed row's usage and outcome in its agent record. An
140
+ * agent whose pull request merged has its rotation marked due (den#4): its
141
+ * Stop hook then asks it for its handoff.
142
+ */
96
143
  function save(stateDir: string, rows: readonly AgentUsageRow[]): void {
97
144
  for (const r of rows) {
98
145
  if (!r.usage) continue;
@@ -103,6 +150,23 @@ function save(stateDir: string, rows: readonly AgentUsageRow[]): void {
103
150
  usage: r.usage,
104
151
  ...(r.outcome ? { outcome: r.outcome } : {}),
105
152
  });
153
+ if (r.outcome?.prMerged)
154
+ markDue(agentFilesDir(stateDir, rec.id), "pr-merged", rec.resumeToken);
155
+ // A mark gh no longer confirms is dropped, and its rotation with it.
156
+ else if (rec.outcome?.prMerged)
157
+ clearDue(agentFilesDir(stateDir, rec.id), "pr-merged");
158
+ }
159
+ }
160
+
161
+ /** agents.rotate.after_compactions; the default when the config can't be read. */
162
+ function rotateLimit(d: UsageDeps): number {
163
+ try {
164
+ return (
165
+ (d.rotateAfter ?? (() => readAgentsConfig().rotate))()
166
+ ?.afterCompactions ?? 3
167
+ );
168
+ } catch {
169
+ return 3;
106
170
  }
107
171
  }
108
172
 
@@ -117,7 +181,10 @@ async function agents(
117
181
  const recs = listRecords(d.stateDir()).filter(
118
182
  (r) => !ws || r.workspace === ws,
119
183
  );
120
- const rows = await agentUsage(recs, { env: d.env });
184
+ const rows = await agentUsage(recs, {
185
+ env: d.env,
186
+ prState: d.prState,
187
+ });
121
188
  save(d.stateDir(), rows);
122
189
  const pct = attributedPct(rows);
123
190
  if (p.flags["--json"]) {
@@ -138,6 +205,8 @@ async function agents(
138
205
  "WALL",
139
206
  "TESTS",
140
207
  "PR",
208
+ "COMPACTIONS",
209
+ "ROTATIONS",
141
210
  ],
142
211
  ...rows.map((r) => [
143
212
  r.agent,
@@ -153,15 +222,27 @@ async function agents(
153
222
  : "red",
154
223
  r.outcome?.prOpened
155
224
  ? `${r.outcome.prOpened}${r.outcome.prMerged ? " (merged)" : ""}`
156
- : "-",
225
+ : r.outcome?.prMergeConfirmed
226
+ ? `${r.outcome.prMergeConfirmed} (merged)`
227
+ : "-",
228
+ r.compactions === undefined ? "-" : String(r.compactions),
229
+ String(r.rotations),
157
230
  ]),
158
231
  ]))
159
232
  d.out(l);
160
233
  for (const r of rows) if (r.why) d.out(` ${r.agent}: ${r.why}`);
161
234
  d.out("");
162
235
  d.out(
163
- `${pct}% of agents have their tokens and outcome (${rows.filter((r) => r.attributed).length} of ${rows.length}). Read locally; nothing was sent.`,
236
+ `${pct}% of agents have their tokens and outcome (${rows.filter((r) => r.attributed).length} of ${rows.length}). Read locally; none of this was sent.`,
164
237
  );
238
+ if (d.prState.asked)
239
+ d.out(
240
+ `To confirm a merge, it asked GitHub for the state of ${d.prState.asked} pull request${d.prState.asked === 1 ? "" : "s"} (gh pr view).`,
241
+ );
242
+ if (d.prState.missing)
243
+ d.out(
244
+ `Couldn't check ${d.prState.missing} merge${d.prState.missing === 1 ? "" : "s"}: gh is not installed.`,
245
+ );
165
246
  return 0;
166
247
  }
167
248
 
@@ -209,9 +290,12 @@ async function report(
209
290
  for (const src of usageSources())
210
291
  for (const f of src.files(since, d.env))
211
292
  stats.push(await src.mine(f, { since }));
212
- const rows = await agentUsage(listRecords(d.stateDir()), { env: d.env });
293
+ const rows = await agentUsage(listRecords(d.stateDir()), {
294
+ env: d.env,
295
+ prState: d.prState,
296
+ });
213
297
  save(d.stateDir(), rows);
214
- const m = weeklyMetrics(stats, rows);
298
+ const m = weeklyMetrics(stats, rows, d.rotateLimit());
215
299
  const date = now.toISOString().slice(0, 10);
216
300
  const dir = path.join(d.stateDir(), "usage");
217
301
  const prev = previous(dir, date);
@@ -18,6 +18,11 @@
18
18
  * POST /api/workspaces/<ws>/agents/<id>/resume the same conversation (AG-14)
19
19
  * POST /api/workspaces/<ws>/agents/<id>/fresh a new conversation, same
20
20
  * persona and workspace
21
+ * POST /api/workspaces/<ws>/agents/<id>/relaunch a rotated agent (den#4):
22
+ * a new conversation whose
23
+ * first prompt is its
24
+ * handoff.md; a resume of
25
+ * one is refused (409 rotated)
21
26
  * POST /api/workspaces/<ws>/agents/<id>/remove forget a stopped one (Undo:
22
27
  * /api/agents/restore)
23
28
  * POST /api/agents/clear-stopped { workspaces: [...] }: forget
@@ -74,6 +79,7 @@ import {
74
79
  resumeInterrupted,
75
80
  setRuntimePath,
76
81
  resolveAgentsDeps,
82
+ relaunchAgent,
77
83
  resumeAgent,
78
84
  startAgent,
79
85
  startFresh,
@@ -98,7 +104,7 @@ export interface AgentRouteDeps {
98
104
  }
99
105
 
100
106
  const AGENTS_PATH =
101
- /^\/api\/workspaces\/([^/]+)\/agents(?:\/([^/]+)\/(stop|resume|fresh|remove))?$/;
107
+ /^\/api\/workspaces\/([^/]+)\/agents(?:\/([^/]+)\/(stop|resume|fresh|relaunch|remove))?$/;
102
108
 
103
109
  const RUNTIME_PATH = /^\/api\/agents\/runtimes\/([^/]+)\/path$/;
104
110
  /** A start's body is a few short names. */
@@ -282,7 +288,13 @@ export async function handleAgentRoutes(
282
288
  return true;
283
289
  }
284
290
  const id = m[2] === undefined ? undefined : safeDecode(m[2]);
285
- const action = m[3] as "stop" | "resume" | "fresh" | "remove" | undefined;
291
+ const action = m[3] as
292
+ | "stop"
293
+ | "resume"
294
+ | "fresh"
295
+ | "relaunch"
296
+ | "remove"
297
+ | undefined;
286
298
 
287
299
  if (!action && method === "GET") {
288
300
  try {
@@ -364,6 +376,11 @@ export async function handleAgentRoutes(
364
376
  what = `start ${s.rec.id} fresh (${s.rec.persona}, ${s.rec.runtime}) in ${ws}`;
365
377
  audit(what, s.early ? "exited at once" : undefined);
366
378
  sendStarted(res, s);
379
+ } else if (verb === "relaunch") {
380
+ const s = await relaunchAgent(d, id!, ws);
381
+ what = `relaunch ${s.rec.id} from its handoff (${s.why}) in ${ws}`;
382
+ audit(what, s.early ? "exited at once" : undefined);
383
+ sendStarted(res, s);
367
384
  } else if (verb === "remove") {
368
385
  const removed = await removeAgent(d, id!, ws);
369
386
  audit(what);
@@ -40,6 +40,15 @@
40
40
  * shows only Start fresh, saying so. Clear stopped removes every stopped
41
41
  * agent in view. After a restart, one line says how many were interrupted,
42
42
  * with Resume all and Review.
43
+ *
44
+ * Session rotation (den#4): an agent whose session rotated (after
45
+ * agents.rotate.after_compactions compactions, or its pull request merged)
46
+ * and whose handoff.md passes the check reads "Ready to relaunch (3
47
+ * compactions)" or "(PR merged)", with Relaunch from handoff as its primary
48
+ * action, then Start fresh and Remove; Resume is not offered. With the
49
+ * handoff missing or failing the check, the row says so and keeps Resume
50
+ * and Start fresh. A running one says "Handoff due" or "Ready to relaunch"
51
+ * as a tag.
43
52
  */
44
53
  import { fillStartForm } from "/agent-start.js";
45
54
 
@@ -181,6 +190,23 @@ export function stopReason(r) {
181
190
  return stateText(r.state);
182
191
  }
183
192
 
193
+ /** Why it rotates, in words: "3 compactions", "PR merged" (den#4). */
194
+ function rotationWhy(rot) {
195
+ if (rot.due === "pr-merged") return t("ag.rot.merged");
196
+ return t(
197
+ rot.compactions === 1 ? "ag.rot.compactions.one" : "ag.rot.compactions",
198
+ {
199
+ n: rot.compactions,
200
+ },
201
+ );
202
+ }
203
+
204
+ /** Its rotation, when one is due: "ready" or "due" (den#4). */
205
+ function rotationOf(r) {
206
+ if (!r.rotation?.due) return "";
207
+ return r.rotation.ready ? "ready" : "due";
208
+ }
209
+
184
210
  /** A stopped agent's row: its button (it opens an ended pane) and actions. */
185
211
  function stoppedHtml(r) {
186
212
  const open = st.open.includes(r.id);
@@ -188,26 +214,47 @@ function stoppedHtml(r) {
188
214
  const nm = `${esc(name)}${r.persona && r.persona !== r.label ? ` <span class="id">${esc(r.label)}</span>` : ""}`;
189
215
  const meta = [r.runtime, r.cwd].filter(Boolean).join(" · ");
190
216
  const who = esc(r.agent);
217
+ const rot = rotationOf(r);
218
+ const relaunch =
219
+ rot === "ready"
220
+ ? `<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>`
221
+ : "";
191
222
  const resume = r.resumable
192
223
  ? `<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>`
193
224
  : "";
194
225
  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>`;
195
226
  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>`;
227
+ // A rotation due without a handoff that passes: say so (den#4).
228
+ const handoff =
229
+ rot === "due"
230
+ ? `<span class="agcant" data-handoff="${esc(r.rotation.handoff)}">${esc(
231
+ t(
232
+ r.rotation.handoff === "missing"
233
+ ? "ag.rot.noHandoff"
234
+ : "ag.rot.badHandoff",
235
+ { why: rotationWhy(r.rotation) },
236
+ ),
237
+ )}</span>`
238
+ : "";
196
239
  const cant =
197
- !r.resumable && r.noResume
240
+ !r.resumable && r.noResume && rot !== "ready"
198
241
  ? `<span class="agcant" data-no-resume>${esc(r.noResume.startsWith(`${r.runtime} `) ? t("ag.noResume", { runtime: r.runtime }) : r.noResume)}</span>`
199
242
  : "";
243
+ const stText =
244
+ rot === "ready"
245
+ ? t("ag.rot.ready", { why: rotationWhy(r.rotation) })
246
+ : stateText(r.state);
200
247
  return (
201
- `<div class="agst" data-agent="${who}" data-state="${esc(r.state)}" data-ws="${esc(r.workspace)}">` +
248
+ `<div class="agst" data-agent="${who}" data-state="${esc(r.state)}" data-ws="${esc(r.workspace)}"${rot ? ` data-rotation="${rot}"` : ""}>` +
202
249
  `<button type="button" class="agr" data-id="${esc(r.id)}" data-kind="agent" data-state="${esc(r.state)}"` +
203
250
  ` aria-pressed="${open}"${r.attachable ? "" : ' aria-disabled="true"'}` +
204
251
  ` aria-label="${esc(t("ag.openName", { id: `${r.workspace} / ${name}` }))}">` +
205
252
  `<span class="den-dot ${stateTone(r)}" aria-hidden="true"></span>` +
206
- `<span class="nm">${nm}</span><span class="st">${esc(stateText(r.state))}</span>` +
253
+ `<span class="nm">${nm}</span><span class="st">${esc(stText)}</span>` +
207
254
  (meta ? `<span class="meta">${esc(meta)}</span>` : "") +
208
255
  `<span class="why">${esc(stopReason(r))}</span>` +
209
256
  `</button>` +
210
- `<div class="agacts">${cant}${resume}${fresh}${remove}</div>` +
257
+ `<div class="agacts">${handoff}${cant}${relaunch}${resume}${fresh}${remove}</div>` +
211
258
  `</div>`
212
259
  );
213
260
  }
@@ -223,9 +270,21 @@ function rowHtml(r) {
223
270
  ? `<span class="wbdg">${esc(t("ag.waiting"))}</span>`
224
271
  : `<span class="st">${esc(stateText(r.state))}</span>`;
225
272
  const meta = [r.runtime, r.cwd].filter(Boolean).join(" · ");
226
- const tags = r.tokenRevoked
227
- ? `<span class="agtags"><span class="tagx warn" title="${esc(t("ag.revokedTip"))}">${esc(t("ag.revoked"))}</span></span>`
273
+ const rot = rotationOf(r);
274
+ const rotTag = rot
275
+ ? `<span class="tagx warn" data-rotation="${rot}">${esc(
276
+ t(rot === "ready" ? "ag.rot.ready" : "ag.rot.due", {
277
+ why: rotationWhy(r.rotation),
278
+ }),
279
+ )}</span>`
280
+ : "";
281
+ const revokedTag = r.tokenRevoked
282
+ ? `<span class="tagx warn" title="${esc(t("ag.revokedTip"))}">${esc(t("ag.revoked"))}</span>`
228
283
  : "";
284
+ const tags =
285
+ rotTag || revokedTag
286
+ ? `<span class="agtags">${revokedTag}${rotTag}</span>`
287
+ : "";
229
288
  return (
230
289
  `<button type="button" class="agr" data-id="${esc(r.id)}" data-kind="${esc(r.kind)}" data-state="${esc(r.state)}"` +
231
290
  ` aria-pressed="${open}"${r.attachable ? "" : ' aria-disabled="true"'}` +
@@ -760,18 +819,24 @@ async function stopAgent(p, button) {
760
819
  const agentPath = (r, verb) =>
761
820
  `/api/workspaces/${encodeURIComponent(r.workspace)}/agents/${encodeURIComponent(r.agent)}/${verb}`;
762
821
 
763
- /** Resume (the same conversation) or Start fresh; its pane opens on it. */
822
+ /**
823
+ * Resume (the same conversation), Start fresh, or Relaunch from handoff
824
+ * (den#4: a new conversation that starts from handoff.md); its pane opens
825
+ * on it.
826
+ */
764
827
  async function restartRow(r, verb, button) {
765
828
  if (button) button.disabled = true;
766
829
  try {
767
830
  const out = await api("POST", agentPath(r, verb));
768
831
  window.denShell?.toast?.(
769
832
  t(
770
- verb !== "resume"
771
- ? "ag.startedFresh"
772
- : out?.fresh
773
- ? "ag.resumedFresh"
774
- : "ag.resumed",
833
+ verb === "relaunch"
834
+ ? "ag.relaunched"
835
+ : verb !== "resume"
836
+ ? "ag.startedFresh"
837
+ : out?.fresh
838
+ ? "ag.resumedFresh"
839
+ : "ag.resumed",
775
840
  { id: r.agent },
776
841
  ),
777
842
  );
@@ -1093,7 +1158,7 @@ export async function mountAgentsPage(root) {
1093
1158
  (x) => x.agent === agent && x.workspace === ws,
1094
1159
  );
1095
1160
  if (!r) return;
1096
- if (which === "resume" || which === "fresh")
1161
+ if (which === "resume" || which === "fresh" || which === "relaunch")
1097
1162
  void restartRow(r, which, act);
1098
1163
  else if (which === "remove") void removeRow(r, act);
1099
1164
  return;
@@ -414,6 +414,18 @@
414
414
  "ag.fresh": "Empezar de cero",
415
415
  "ag.freshName": "Empezar {id} de cero",
416
416
  "ag.startedFresh": "{id} empezó de cero",
417
+ "ag.rot.ready": "Listo para relanzar ({why})",
418
+ "ag.rot.due": "Falta el traspaso ({why})",
419
+ "ag.rot.compactions": "{n} compactaciones",
420
+ "ag.rot.compactions.one": "{n} compactación",
421
+ "ag.rot.merged": "PR fusionado",
422
+ "ag.rot.noHandoff":
423
+ "Su sesión rotó ({why}), pero no escribió handoff.md. Reanúdalo para que lo escriba, o empieza de cero.",
424
+ "ag.rot.badHandoff":
425
+ "Su sesión rotó ({why}), pero su handoff.md no está completo. Reanúdalo para arreglarlo, o empieza de cero.",
426
+ "ag.relaunch": "Relanzar desde el traspaso",
427
+ "ag.relaunchName": "Relanzar {id} desde el traspaso",
428
+ "ag.relaunched": "{id} relanzado desde su traspaso",
417
429
  "ag.remove": "Quitar",
418
430
  "ag.removeName": "Quitar {id}",
419
431
  "ag.removed": "{id} quitado",
@@ -401,6 +401,17 @@
401
401
  "ag.fresh": "新しく始める",
402
402
  "ag.freshName": "{id} を新しく始める",
403
403
  "ag.startedFresh": "{id} を新しく始めました",
404
+ "ag.rot.ready": "再起動の準備ができました({why})",
405
+ "ag.rot.due": "引き継ぎが必要です({why})",
406
+ "ag.rot.compactions": "圧縮 {n} 回",
407
+ "ag.rot.merged": "PR がマージされました",
408
+ "ag.rot.noHandoff":
409
+ "セッションは交代しました({why})が、handoff.md がありません。再開して書かせるか、新しく始めてください。",
410
+ "ag.rot.badHandoff":
411
+ "セッションは交代しました({why})が、handoff.md が不完全です。再開して直すか、新しく始めてください。",
412
+ "ag.relaunch": "引き継ぎから再起動",
413
+ "ag.relaunchName": "{id} を引き継ぎから再起動",
414
+ "ag.relaunched": "{id} を引き継ぎから再起動しました",
404
415
  "ag.remove": "削除",
405
416
  "ag.removeName": "{id} を削除",
406
417
  "ag.removed": "{id} を削除しました",
@@ -534,6 +534,19 @@
534
534
  "ag.restored": "Back in the list: {ids}",
535
535
  "ag.clear": "Clear stopped",
536
536
  "ag.noResume": "{runtime} can't resume a conversation",
537
+ // Session rotation (den#4).
538
+ "ag.rot.ready": "Ready to relaunch ({why})",
539
+ "ag.rot.due": "Handoff due ({why})",
540
+ "ag.rot.compactions": "{n} compactions",
541
+ "ag.rot.compactions.one": "{n} compaction",
542
+ "ag.rot.merged": "PR merged",
543
+ "ag.rot.noHandoff":
544
+ "Its session rotated ({why}), but it wrote no handoff.md. Resume it to write one, or start fresh.",
545
+ "ag.rot.badHandoff":
546
+ "Its session rotated ({why}), but its handoff.md is not complete. Resume it to fix it, or start fresh.",
547
+ "ag.relaunch": "Relaunch from handoff",
548
+ "ag.relaunchName": "Relaunch {id} from handoff",
549
+ "ag.relaunched": "Relaunched {id} from its handoff",
537
550
  "ag.interrupted": "{n} agents were interrupted when {name} restarted",
538
551
  "ag.interrupted.one": "{n} agent was interrupted when {name} restarted",
539
552
  "ag.resumeAll": "Resume all",
@@ -318,6 +318,11 @@ export const ROUTES: readonly RouteEntry[] = [
318
318
  "/api/workspaces/:ws/agents/:id/fresh",
319
319
  "starts an agent afresh: a runtime in a PTY",
320
320
  ),
321
+ S(
322
+ ["POST"],
323
+ "/api/workspaces/:ws/agents/:id/relaunch",
324
+ "relaunches a rotated agent from its handoff: a runtime in a PTY (den#4)",
325
+ ),
321
326
  S(
322
327
  ["POST"],
323
328
  "/api/agents/resume-interrupted",
@@ -400,6 +405,7 @@ export const REGEX_ROUTES: Readonly<Record<string, readonly string[]>> = {
400
405
  "/api/workspaces/:ws/agents/:id/stop",
401
406
  "/api/workspaces/:ws/agents/:id/resume",
402
407
  "/api/workspaces/:ws/agents/:id/fresh",
408
+ "/api/workspaces/:ws/agents/:id/relaunch",
403
409
  "/api/workspaces/:ws/agents/:id/remove",
404
410
  ],
405
411
  "agent-routes.ts:RUNTIME_PATH": ["/api/agents/runtimes/:id/path"],
@@ -43,6 +43,7 @@ import { getStateDir } from "../../shared/paths.js";
43
43
  import { product } from "../../shared/product.js";
44
44
  import { revokedAgentIds } from "../../shared/actor-token.js";
45
45
  import { listRecords } from "../../shared/agents/records.js";
46
+ import type { RotationInfo } from "../../shared/agents/rotation.js";
46
47
  import {
47
48
  connectHost,
48
49
  noHost,
@@ -153,6 +154,11 @@ export interface ListedSession {
153
154
  tokenRevoked?: boolean;
154
155
  /** An approval is pending (AG-6a; only when the host knows). */
155
156
  waiting?: boolean;
157
+ /**
158
+ * Session rotation (den#4): why it rotates and its handoff.md. `ready`:
159
+ * Relaunch from handoff is its action, not Resume.
160
+ */
161
+ rotation?: RotationInfo;
156
162
  }
157
163
 
158
164
  /** The list, from the host's sessions and the agent records. */
@@ -176,7 +182,7 @@ export function listedSessions(
176
182
  const out: ListedSession[] = [];
177
183
  const agentSessions = new Set<string>();
178
184
  noteEnded(d.stateDir(), mine, records);
179
- for (const r of agentRows(mine, records)) {
185
+ for (const r of agentRows(mine, records, d.stateDir())) {
180
186
  const rec = records.find((x) => x.id === r.agent)!;
181
187
  const s = r.session;
182
188
  if (s) agentSessions.add(s.ref.id);
@@ -201,6 +207,7 @@ export function listedSessions(
201
207
  ...(r.stoppedAt ? { stoppedAt: r.stoppedAt } : {}),
202
208
  ...(r.endedAt ? { endedAt: r.endedAt } : {}),
203
209
  ...(r.interruptedAt ? { interruptedAt: r.interruptedAt } : {}),
210
+ ...(r.rotation?.due ? { rotation: r.rotation } : {}),
204
211
  ...(revoked.has(rec.id) && s && !s.exited ? { tokenRevoked: true } : {}),
205
212
  });
206
213
  }