@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.
- package/CHANGELOG.md +105 -0
- package/README.md +7 -7
- package/docs/distributions.md +128 -1
- package/package.json +1 -1
- package/src/shared/agents/den-claude.ts +38 -3
- package/src/shared/agents/guardrails/hooks.json +23 -0
- package/src/shared/agents/guardrails/instructions.md +23 -0
- package/src/shared/agents/guardrails/rotation-hook.ts +127 -0
- package/src/shared/agents/guardrails/stop-hook.ts +48 -2
- package/src/shared/agents/guardrails.ts +38 -7
- package/src/shared/agents/handoff.ts +118 -0
- package/src/shared/agents/records.ts +13 -1
- package/src/shared/agents/rotation.ts +371 -0
- package/src/shared/agents/runtime.ts +15 -0
- package/src/shared/den-config.ts +42 -1
- package/src/shared/git/gh-pr-state.ts +44 -0
- package/src/shared/usage/report.ts +117 -4
- package/src/shared/usage/transcript.ts +62 -5
- package/src/ui/cli/agent-ops.ts +196 -7
- package/src/ui/cli/agents-command.ts +75 -11
- package/src/ui/cli/usage-command.ts +95 -11
- package/src/ui/server/agent-routes.ts +19 -2
- package/src/ui/server/public/agents-page.js +78 -13
- package/src/ui/server/public/i18n/es.js +12 -0
- package/src/ui/server/public/i18n/ja.js +11 -0
- package/src/ui/server/public/shell.js +13 -0
- package/src/ui/server/route-classes.ts +6 -0
- package/src/ui/server/session-routes.ts +8 -1
package/src/ui/cli/agent-ops.ts
CHANGED
|
@@ -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
|
|
429
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
|
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 {
|
|
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.
|
|
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 =
|
|
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(
|
|
412
|
-
|
|
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(
|
|
483
|
+
else if (r.ok) d.out(`✓ the ${what} is complete and plain`);
|
|
425
484
|
else {
|
|
426
|
-
d.out(
|
|
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
|
}
|