@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.
@@ -104,6 +104,20 @@ import {
104
104
  type Runtime,
105
105
  } from "../../shared/agents/runtimes.js";
106
106
  import type { RuntimeDetect } from "../../shared/agents/runtime.js";
107
+ import {
108
+ archiveHandoff,
109
+ DEFAULT_ROTATE,
110
+ handoffFile,
111
+ handoffPrompt,
112
+ launchRotation,
113
+ readHandoff,
114
+ readRotation,
115
+ writeRotation,
116
+ reasonText,
117
+ rotateAfter,
118
+ rotationInfo,
119
+ type RotationInfo,
120
+ } from "../../shared/agents/rotation.js";
107
121
  import {
108
122
  binSettingKey,
109
123
  ensureInteractivePath,
@@ -371,6 +385,13 @@ export interface AgentRow {
371
385
  autoResume?: boolean;
372
386
  outcome?: AgentRecord["outcome"];
373
387
  usage?: AgentRecord["usage"];
388
+ /**
389
+ * Session rotation (den#4): its compactions, whether a rotation is due
390
+ * and why, and its handoff.md. `ready`: Relaunch from handoff, not Resume.
391
+ */
392
+ rotation?: RotationInfo;
393
+ /** How many times Den relaunched it from a handoff. */
394
+ rotations?: number;
374
395
  session?: SessionInfo;
375
396
  }
376
397
 
@@ -416,17 +437,29 @@ function canResume(rec: AgentRecord, st: string): boolean {
416
437
  return isStoppedState(st) && resumeRefusal(rec) === null;
417
438
  }
418
439
 
440
+ /** Why a rotated agent isn't resumed: its conversation ended there (den#4). */
441
+ export function rotatedRefusal(rec: AgentRecord, r: RotationInfo): string {
442
+ return `${rec.id} rotated (${reasonText(r.due!, r.compactions)}): relaunch it from its handoff`;
443
+ }
444
+
419
445
  export function agentRows(
420
446
  sessions: SessionInfo[],
421
447
  records: AgentRecord[] = [],
448
+ /** Den's state dir: each agent's rotation is read there (den#4). */
449
+ stateDir?: string,
422
450
  ): AgentRow[] {
423
451
  const bySession = new Map(records.map((r) => [r.session.id, r]));
424
452
  const rows: AgentRow[] = [];
425
453
  const shown = new Set<string>();
426
454
  const row = (rec: AgentRecord, s?: SessionInfo): AgentRow => {
427
455
  const st = rowState(rec, s);
428
- const resumable = canResume(rec, st);
429
- const why = isStoppedState(st) && !resumable ? resumeRefusal(rec) : null;
456
+ const rot = stateDir ? rotationInfo(stateDir, rec) : null;
457
+ // A rotated conversation is not resumed: it relaunches from its handoff.
458
+ const resumable = canResume(rec, st) && !rot?.ready;
459
+ const why =
460
+ isStoppedState(st) && !resumable
461
+ ? (resumeRefusal(rec) ?? (rot?.ready ? rotatedRefusal(rec, rot) : null))
462
+ : null;
430
463
  return {
431
464
  agent: rec.id,
432
465
  ...recordFields(rec),
@@ -439,6 +472,8 @@ export function agentRows(
439
472
  ...(rec.interruptedAt ? { interruptedAt: rec.interruptedAt } : {}),
440
473
  ...(rec.outcome ? { outcome: rec.outcome } : {}),
441
474
  ...(rec.usage ? { usage: rec.usage } : {}),
475
+ ...(rot ? { rotation: rot } : {}),
476
+ ...(rec.rotations?.length ? { rotations: rec.rotations.length } : {}),
442
477
  ...(s ? { session: contractInfo(s) } : {}),
443
478
  };
444
479
  };
@@ -514,7 +549,7 @@ export async function listAgents(
514
549
  const sessions = all.filter((s) => inWorkspace(s, d.dist, ws));
515
550
  const records = listRecords(d.stateDir()).filter((r) => r.workspace === ws);
516
551
  noteEnded(d.stateDir(), sessions, records);
517
- const rows = agentRows(sessions, records);
552
+ const rows = agentRows(sessions, records, d.stateDir());
518
553
  return opts.stopped ? rows.filter((r) => isStoppedState(r.state)) : rows;
519
554
  }
520
555
 
@@ -1002,6 +1037,10 @@ function launchInput(
1002
1037
  const dir = d.workspaceDir(ws);
1003
1038
  if (!fs.statSync(dir, { throwIfNoEntry: false })?.isDirectory())
1004
1039
  throw refused(`workspace ${ws}'s directory ${dir} does not exist`);
1040
+ // den#4: only Den's hooks count compactions; without them, no rotation.
1041
+ const after = persona.guardrails?.hooks
1042
+ ? rotateAfter(cfg.rotate ?? DEFAULT_ROTATE, persona)
1043
+ : 0;
1005
1044
  return {
1006
1045
  persona,
1007
1046
  workspace: { name: ws, dir },
@@ -1013,9 +1052,15 @@ function launchInput(
1013
1052
  stateDir: d.stateDir(),
1014
1053
  env: d.env,
1015
1054
  ...(bin ? { bin } : {}),
1055
+ rotateAfter: after,
1016
1056
  };
1017
1057
  }
1018
1058
 
1059
+ /** The rotation limit a launch counts against: 0 when its runtime can't relaunch. */
1060
+ function launchAfter(rt: Runtime, input: LaunchInput): number {
1061
+ return rt.firstPrompt ? (input.rotateAfter ?? 0) : 0;
1062
+ }
1063
+
1019
1064
  function ptyOrRefuse(d: AgentsResolved): void {
1020
1065
  const pty = d.pty();
1021
1066
  if (!pty.available)
@@ -1053,9 +1098,18 @@ export interface StartRequest {
1053
1098
  id?: string;
1054
1099
  }
1055
1100
 
1101
+ /** What a relaunch from handoff adds to a start (den#4). */
1102
+ interface StartMore {
1103
+ firstPrompt?: string;
1104
+ rotations?: AgentRecord["rotations"];
1105
+ /** Called once the old session is known ended, before the launch. */
1106
+ beforeLaunch?: () => void;
1107
+ }
1108
+
1056
1109
  export async function startAgent(
1057
1110
  d: AgentsResolved,
1058
1111
  req: StartRequest,
1112
+ more: StartMore = {},
1059
1113
  ): Promise<Started> {
1060
1114
  const where = "agents start";
1061
1115
  const ws = checkWorkspaceName(where, req.workspace);
@@ -1081,7 +1135,10 @@ export async function startAgent(
1081
1135
  `agent ${id} belongs to workspace ${prev.workspace}; give this one another --id`,
1082
1136
  );
1083
1137
  ptyOrRefuse(d);
1084
- const input = launchInput(d, cfg, persona, ws, id, model, det.path);
1138
+ const input = {
1139
+ ...launchInput(d, cfg, persona, ws, id, model, det.path),
1140
+ ...(more.firstPrompt ? { firstPrompt: more.firstPrompt } : {}),
1141
+ };
1085
1142
 
1086
1143
  return withClient(d, async (c) => {
1087
1144
  const sessions = await c.list();
@@ -1091,7 +1148,17 @@ export async function startAgent(
1091
1148
  `Attach: ${d.cli} sessions attach ${old.name}`,
1092
1149
  ]);
1093
1150
  const out = launchOrThrow(() => rt.launch(input));
1151
+ more.beforeLaunch?.();
1152
+ // A new conversation: a handoff left from the last one is kept aside,
1153
+ // so the next rotation needs its own (den#4).
1154
+ if (fs.existsSync(handoffFile(input.filesDir)))
1155
+ archiveHandoff(
1156
+ input.filesDir,
1157
+ `${new Date().toISOString().replace(/[:.]/g, "-")}-unused`,
1158
+ );
1094
1159
  writeRuntimeFiles(input.filesDir, out.files);
1160
+ // den#4: a new conversation's compactions count from 0.
1161
+ launchRotation(input.filesDir, out.resumeToken, launchAfter(rt, input));
1095
1162
  const { rec, info } = await startSession(
1096
1163
  d,
1097
1164
  c,
@@ -1116,7 +1183,7 @@ export async function startAgent(
1116
1183
  ...binDirOf(det),
1117
1184
  },
1118
1185
  old,
1119
- {},
1186
+ more.rotations?.length ? { rotations: more.rotations } : {},
1120
1187
  );
1121
1188
  const early = await exitedAtOnce(d, c, info);
1122
1189
  return { rec, info, warnings: out.warnings ?? [], early };
@@ -1228,6 +1295,20 @@ export async function resumeAgent(
1228
1295
  throw refused(why, [
1229
1296
  `Start fresh: ${d.cli} agents start -w ${rec.workspace} --persona ${rec.persona} --id ${rec.id}`,
1230
1297
  ]);
1298
+ // den#4: a rotated conversation ends there; its role relaunches from
1299
+ // the handoff, in a fresh one.
1300
+ const rot = rotationInfo(d.stateDir(), rec);
1301
+ if (rot?.ready)
1302
+ throw new AgentError(
1303
+ `agent ${rec.id} rotated after ${reasonText(rot.due!, rot.compactions)}: its conversation is not resumed`,
1304
+ {
1305
+ code: "rotated",
1306
+ hints: [
1307
+ `Relaunch from handoff: ${d.cli} agents relaunch ${rec.id}`,
1308
+ `Start fresh: ${d.cli} agents start -w ${rec.workspace} --persona ${rec.persona} --id ${rec.id}`,
1309
+ ],
1310
+ },
1311
+ );
1231
1312
  const cfg = agentsConfig(d);
1232
1313
  const grants = resumeGrants(rec, cfg);
1233
1314
  const persona = personas(d, cfg).get(rec.persona);
@@ -1277,6 +1358,8 @@ export async function resumeAgent(
1277
1358
  );
1278
1359
  if (!out) throw refused(`agent ${rec.id} has nothing to resume`);
1279
1360
  writeRuntimeFiles(input.filesDir, out.files);
1361
+ // den#4: the same conversation keeps its count.
1362
+ launchRotation(input.filesDir, token, launchAfter(rt, input));
1280
1363
  return out;
1281
1364
  };
1282
1365
  const { session: _s, startedAt: _t, resumedAt: _r, ...base } = rec;
@@ -1361,6 +1444,107 @@ export async function startFresh(
1361
1444
  });
1362
1445
  }
1363
1446
 
1447
+ // ── den#4: relaunch from handoff ────────────────────────────────────────────
1448
+
1449
+ export interface Relaunched extends Started {
1450
+ /** Why the last session rotated: "3 compactions", "PR merged". */
1451
+ why: string;
1452
+ /** Where its handoff was kept. */
1453
+ handoff: string;
1454
+ }
1455
+
1456
+ /**
1457
+ * Relaunch from handoff (den#4): once agent `id`'s session rotated (a
1458
+ * trigger fired and handoff.md passes the check), a fresh conversation
1459
+ * with the same persona, runtime, model, workspace, folder and grants,
1460
+ * whose first prompt is the handoff. A running session is stopped first.
1461
+ * The handoff moves to handoffs/ so the next rotation needs a new one.
1462
+ * Missing or failing: refused, saying so, with Start fresh and Resume.
1463
+ */
1464
+ export async function relaunchAgent(
1465
+ d: AgentsResolved,
1466
+ id: string,
1467
+ ws?: string,
1468
+ ): Promise<Relaunched> {
1469
+ const rec = agentRecord(d, id, ws);
1470
+ syncCatalog(d);
1471
+ const rt = runtimeFor(rec.runtime);
1472
+ const files = agentFilesDir(d.stateDir(), rec.id);
1473
+ const rot = rotationInfo(d.stateDir(), rec);
1474
+ const fresh = `Start fresh: ${d.cli} agents start -w ${rec.workspace} --persona ${rec.persona} --id ${rec.id}`;
1475
+ const resume = `Resume: ${d.cli} agents resume ${rec.id}`;
1476
+ if (!rt.firstPrompt)
1477
+ throw refused(`${rec.runtime} can't start a conversation from a handoff`, [
1478
+ fresh,
1479
+ ]);
1480
+ if (!rot?.due)
1481
+ throw refused(`agent ${rec.id} has not rotated: resume it instead`, [
1482
+ resume,
1483
+ ]);
1484
+ const h = readHandoff(files);
1485
+ if (h.status !== "ok")
1486
+ throw new AgentError(
1487
+ h.status === "missing"
1488
+ ? `agent ${rec.id} has no handoff.md yet, so it can't relaunch from one`
1489
+ : `agent ${rec.id}'s handoff.md is not ready: ${h.problems.join(" ")}`,
1490
+ { code: "no_handoff", hints: [fresh, resume] },
1491
+ );
1492
+ // The rotated session ends here, if it is still open.
1493
+ const s = sessionOf(await hostSessions(d), rec);
1494
+ if (s && !s.exited) {
1495
+ const stopped = await stopAgent(d, rec.id, ws);
1496
+ if (stopped.stillStopping)
1497
+ throw refused(`agent ${rec.id} is still stopping; try again`);
1498
+ }
1499
+ const now = new Date().toISOString();
1500
+ const why = reasonText(rot.due, rot.compactions);
1501
+ const entry = {
1502
+ at: now,
1503
+ reason: rot.due,
1504
+ compactions: rot.compactions,
1505
+ ...(rec.resumeToken ? { conversation: rec.resumeToken } : {}),
1506
+ };
1507
+ let kept = "";
1508
+ const before = readRotation(files);
1509
+ // The launch failed: the handoff and the rotation are as they were, for
1510
+ // the conversation the record now names, so Relaunch is offered again.
1511
+ const undo = (conversation?: string) => {
1512
+ if (kept && fs.existsSync(kept) && !fs.existsSync(handoffFile(files)))
1513
+ fs.renameSync(kept, handoffFile(files));
1514
+ if (before)
1515
+ writeRotation(files, {
1516
+ ...before,
1517
+ due: rot.due,
1518
+ ...(conversation ? { conversation } : {}),
1519
+ });
1520
+ };
1521
+ const started = await startAgent(
1522
+ d,
1523
+ {
1524
+ workspace: rec.workspace,
1525
+ persona: rec.persona,
1526
+ runtime: rec.runtime,
1527
+ ...(rec.model ? { model: rec.model } : {}),
1528
+ id: rec.id,
1529
+ },
1530
+ {
1531
+ firstPrompt: handoffPrompt(h.text!, rot.due, rot.compactions),
1532
+ rotations: [...(rec.rotations ?? []), entry],
1533
+ beforeLaunch: () => {
1534
+ kept = archiveHandoff(
1535
+ files,
1536
+ `${now.replace(/[:.]/g, "-")}-${rec.resumeToken ?? "conversation"}`,
1537
+ );
1538
+ },
1539
+ },
1540
+ ).catch((e: unknown) => {
1541
+ undo();
1542
+ throw e;
1543
+ });
1544
+ if (started.early) undo(started.rec.resumeToken);
1545
+ return { ...started, why, handoff: kept };
1546
+ }
1547
+
1364
1548
  /** The host's sessions, never starting a host (none running: none). */
1365
1549
  async function hostSessions(d: AgentsResolved): Promise<SessionInfo[]> {
1366
1550
  const c = await d.connectExisting();
@@ -1517,7 +1701,10 @@ export async function markInterrupted(
1517
1701
  if (!p || !(p.standing || p.resumeOnRestart)) continue;
1518
1702
  if (resumeRefusal(rec)) continue;
1519
1703
  try {
1520
- const r = await resumeAgent(d, rec.id);
1704
+ // den#4: a rotated one relaunches from its handoff instead.
1705
+ const r = rotationInfo(d.stateDir(), rec)?.ready
1706
+ ? await relaunchAgent(d, rec.id)
1707
+ : await resumeAgent(d, rec.id);
1521
1708
  if (r.early) found.failed.push({ agent: rec.id, why: "exited at once" });
1522
1709
  else found.resumed.push(rec.id);
1523
1710
  } catch (e) {
@@ -1547,7 +1734,9 @@ export async function resumeInterrupted(
1547
1734
  const resumed: Resumed[] = [];
1548
1735
  const failed: { agent: string; why: string }[] = [];
1549
1736
  for (const rec of recs) {
1550
- const why = resumeRefusal(rec);
1737
+ const rot = rotationInfo(d.stateDir(), rec);
1738
+ const why =
1739
+ resumeRefusal(rec) ?? (rot?.ready ? rotatedRefusal(rec, rot) : null);
1551
1740
  if (why) {
1552
1741
  failed.push({ agent: rec.id, why });
1553
1742
  continue;
@@ -43,6 +43,14 @@
43
43
  * handback-check <file|-> (den#5) the hand-back check (handback.ts) on a
44
44
  * file or stdin: each problem on its own line, exit 1 when there
45
45
  * is one, 0 when the hand-back is complete and plain.
46
+ * relaunch (den#4, session rotation) once an agent's session rotated
47
+ * (after agents.rotate.after_compactions compactions, or its pull
48
+ * request merged) and its handoff.md passes the check: a fresh
49
+ * conversation, same persona, workspace, folder and grants, whose
50
+ * first prompt is the handoff. `resume` on a rotated agent
51
+ * refuses (exit 1) and names relaunch.
52
+ * handoff-check <id|file|-> (den#4) the handoff check (handoff.ts), like
53
+ * handback-check.
46
54
  *
47
55
  * Nothing here takes a grant: grants come from the org config only. The
48
56
  * host enforces agents.max_concurrent on every start, counting agents only.
@@ -52,13 +60,20 @@ import { DEFAULT_RUNTIME_ID } from "../../shared/agents/runtimes.js";
52
60
  import { initLoginPath } from "../../shared/agents/runtime-search.js";
53
61
  import { HostError } from "../../shared/sessions/client.js";
54
62
  import { checkHandback } from "../../shared/agents/handback.js";
55
- import { listRecords } from "../../shared/agents/records.js";
63
+ import { checkHandoff } from "../../shared/agents/handoff.js";
64
+ import { handoffFile, reasonText } from "../../shared/agents/rotation.js";
65
+ import {
66
+ agentFilesDir,
67
+ listRecords,
68
+ readRecord,
69
+ } from "../../shared/agents/records.js";
56
70
  import {
57
71
  AgentError,
58
72
  listAgents,
59
73
  listPersonas as personaList,
60
74
  listRuntimes,
61
75
  loadAgentWorkspaces,
76
+ relaunchAgent,
62
77
  removeAgent,
63
78
  removeStopped,
64
79
  resolveAgentsDeps,
@@ -92,7 +107,7 @@ export {
92
107
 
93
108
  export const AGENTS_HELP = {
94
109
  usage:
95
- "agents <list|start|stop|resume|rm|personas|doctor|handback-check> ...",
110
+ "agents <list|start|stop|resume|relaunch|rm|personas|doctor|handback-check|handoff-check> ...",
96
111
  lines: [
97
112
  "Agents: a persona and a workspace, run by a runtime in a session that",
98
113
  "outlives the web server (see `{cli} sessions`).",
@@ -110,7 +125,13 @@ export const AGENTS_HELP = {
110
125
  " the same conversation, persona, workspace and folder, when its runtime",
111
126
  " can resume one and its session has ended; with nothing to resume yet,",
112
127
  " it starts fresh under the same conversation id. --all-interrupted: every",
113
- " agent interrupted when Den or the machine restarted",
128
+ " agent interrupted when Den or the machine restarted. A rotated agent",
129
+ " is not resumed: relaunch it",
130
+ "{cli} agents relaunch <id> [-w <ws>]",
131
+ " session rotation: once a session ended after agents.rotate.after_compactions",
132
+ " compactions (default 3) or its pull request merged, and its handoff.md",
133
+ " passes the check, a fresh conversation (same persona, workspace, folder,",
134
+ " grants) whose first prompt is the handoff",
114
135
  "{cli} agents rm <id> [-w <ws>]",
115
136
  "{cli} agents rm --stopped [-w <ws>]",
116
137
  " forget a stopped, interrupted or exited agent (never a running one);",
@@ -126,6 +147,10 @@ export const AGENTS_HELP = {
126
147
  " checks a hand-back to a person: URL, machine, branch, what changed, what",
127
148
  " to click and decisions are all there, every ID is explained, and each",
128
149
  " decision is a numbered yes/no question; exit 1 with what to fix",
150
+ "{cli} agents handoff-check <id|file|-> [--json]",
151
+ " checks an agent's handoff.md (or a file, or stdin): Objective, State, Open items, How to QA and Next step",
152
+ " are all there and filled in, and every ID is explained; exit 1 with what",
153
+ " to fix",
129
154
  "",
130
155
  "Runtimes: den-claude (default; Claude Code's claude), codex (OpenAI's Codex",
131
156
  "CLI), gemini (Google's Gemini CLI), grok (xAI's Grok Build), and generic (the",
@@ -181,7 +206,9 @@ async function list(args: string[], d: AgentsResolved): Promise<number> {
181
206
  r.agent,
182
207
  r.persona ?? "-",
183
208
  r.runtime ?? "-",
184
- r.state,
209
+ r.rotation?.due
210
+ ? `${r.state}, ${r.rotation.ready ? "ready to relaunch" : "handoff due"} (${reasonText(r.rotation.due, r.rotation.compactions)})`
211
+ : r.state,
185
212
  r.model ?? "-",
186
213
  r.session && !r.session.exited ? String(r.session.pid) : "-",
187
214
  r.session?.startedAt ?? "-",
@@ -307,6 +334,17 @@ async function resume(args: string[], d: AgentsResolved): Promise<number> {
307
334
  );
308
335
  }
309
336
 
337
+ /** `agents relaunch <id> [-w <ws>]` (den#4). */
338
+ async function relaunch(args: string[], d: AgentsResolved): Promise<number> {
339
+ const { id, ws } = idAndWorkspace("agents relaunch", args);
340
+ const s = await relaunchAgent(d, id, ws);
341
+ return report(
342
+ d,
343
+ s,
344
+ `relaunched agent ${s.rec.id} from its handoff (the last session ended after ${s.why}) as ${s.rec.sessionName} in ${s.rec.cwd}`,
345
+ );
346
+ }
347
+
310
348
  /** `agents resume --all-interrupted [-w <ws>]` (AG-14.3). */
311
349
  async function resumeAll(args: string[], d: AgentsResolved): Promise<number> {
312
350
  const where = "agents resume";
@@ -399,17 +437,37 @@ async function readFileOrStdin(src: string): Promise<string> {
399
437
  return text;
400
438
  }
401
439
 
402
- /** `agents handback-check <file|-> [--json]`. */
440
+ /** `agents handback-check <file|-> [--json]`, or handoff-check (den#4). */
403
441
  async function handbackCheck(
404
442
  args: string[],
405
443
  d: AgentsResolved,
406
444
  deps: AgentsDoctorDeps,
445
+ kind: "handback" | "handoff" = "handback",
407
446
  ): Promise<number> {
408
- const where = "agents handback-check";
447
+ const where = `agents ${kind}-check`;
409
448
  const p = parseArgs(where, args, { "--json": false });
410
449
  if (p.positional.length !== 1 || p.rest)
411
- throw new UsageError(`${where} takes one file, or - for stdin`);
412
- const src = p.positional[0];
450
+ throw new UsageError(
451
+ kind === "handoff"
452
+ ? `${where} takes an agent id, a file, or - for stdin`
453
+ : `${where} takes one file, or - for stdin`,
454
+ );
455
+ let src = p.positional[0];
456
+ // handoff-check <id>: that agent's handoff.md, when no such file is here.
457
+ if (
458
+ kind === "handoff" &&
459
+ src !== "-" &&
460
+ !fs.existsSync(src) &&
461
+ /^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(src) &&
462
+ readRecord(d.stateDir(), src)
463
+ ) {
464
+ const file = handoffFile(agentFilesDir(d.stateDir(), src));
465
+ if (!fs.existsSync(file)) {
466
+ d.err(`✗ ${where}: no handoff.md yet for agent ${src} (${file})`);
467
+ return 1;
468
+ }
469
+ src = file;
470
+ }
413
471
  let text: string;
414
472
  try {
415
473
  text = await (deps.readHandback ?? readFileOrStdin)(src);
@@ -419,11 +477,12 @@ async function handbackCheck(
419
477
  );
420
478
  return 1;
421
479
  }
422
- const r = checkHandback(text);
480
+ const r = kind === "handoff" ? checkHandoff(text) : checkHandback(text);
481
+ const what = kind === "handoff" ? "handoff" : "hand-back";
423
482
  if (p.flags["--json"]) d.out(JSON.stringify(r, null, 2));
424
- else if (r.ok) d.out("✓ the hand-back is complete and plain");
483
+ else if (r.ok) d.out(`✓ the ${what} is complete and plain`);
425
484
  else {
426
- d.out("✗ the hand-back is not ready:");
485
+ d.out(`✗ the ${what} is not ready:`);
427
486
  for (const l of r.problems) d.out(` - ${l}`);
428
487
  }
429
488
  return r.ok ? 0 : 1;
@@ -544,6 +603,9 @@ export async function cmdAgents(
544
603
  case "resume":
545
604
  await loadAgentWorkspaces(deps);
546
605
  return await resume(rest, d);
606
+ case "relaunch":
607
+ await loadAgentWorkspaces(deps);
608
+ return await relaunch(rest, d);
547
609
  case "rm":
548
610
  await loadAgentWorkspaces(deps);
549
611
  return await rm(rest, d);
@@ -555,6 +617,8 @@ export async function cmdAgents(
555
617
  return await doctor(rest, d, deps);
556
618
  case "handback-check":
557
619
  return await handbackCheck(rest, d, deps);
620
+ case "handoff-check":
621
+ return await handbackCheck(rest, d, deps, "handoff");
558
622
  default:
559
623
  throw new UsageError(`unknown agents command "${sub}"`);
560
624
  }