@monster-orange/den 0.4.7 → 0.4.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/CHANGELOG.md +174 -0
  2. package/README.md +7 -7
  3. package/docs/distributions.md +243 -1
  4. package/package.json +1 -1
  5. package/schemas/den.plugin.v1.json +3 -2
  6. package/src/shared/agents/den-claude.ts +57 -3
  7. package/src/shared/agents/guardrails/hooks.json +23 -0
  8. package/src/shared/agents/guardrails/instructions.md +23 -0
  9. package/src/shared/agents/guardrails/rotation-hook.ts +127 -0
  10. package/src/shared/agents/guardrails/stop-hook.ts +48 -2
  11. package/src/shared/agents/guardrails.ts +38 -7
  12. package/src/shared/agents/handoff.ts +118 -0
  13. package/src/shared/agents/personas.ts +11 -1
  14. package/src/shared/agents/records.ts +122 -1
  15. package/src/shared/agents/rotation.ts +362 -0
  16. package/src/shared/agents/runtime.ts +27 -0
  17. package/src/shared/den-config.ts +72 -1
  18. package/src/shared/git/gh-pr-state.ts +32 -0
  19. package/src/shared/plugins/manifest.ts +3 -0
  20. package/src/shared/usage/paths.ts +16 -0
  21. package/src/shared/usage/report.ts +377 -0
  22. package/src/shared/usage/sources.ts +127 -0
  23. package/src/shared/usage/transcript.ts +319 -0
  24. package/src/ui/cli/agent-ops.ts +678 -51
  25. package/src/ui/cli/agents-command.ts +192 -22
  26. package/src/ui/cli/index.ts +13 -1
  27. package/src/ui/cli/usage-command.ts +323 -0
  28. package/src/ui/mcp/api.ts +10 -1
  29. package/src/ui/mcp/server.ts +34 -0
  30. package/src/ui/mcp/tools.ts +50 -0
  31. package/src/ui/server/agent-resume-grant.ts +53 -0
  32. package/src/ui/server/agent-routes.ts +247 -9
  33. package/src/ui/server/audit.ts +68 -1
  34. package/src/ui/server/index.ts +16 -5
  35. package/src/ui/server/public/agents-page.js +400 -13
  36. package/src/ui/server/public/agents-section.js +17 -3
  37. package/src/ui/server/public/agents.html +44 -0
  38. package/src/ui/server/public/i18n/es.js +40 -0
  39. package/src/ui/server/public/i18n/ja.js +36 -0
  40. package/src/ui/server/public/shell.js +79 -0
  41. package/src/ui/server/request-guard.ts +21 -1
  42. package/src/ui/server/route-classes.ts +29 -0
  43. package/src/ui/server/session-routes.ts +60 -5
  44. package/src/ui/server/sudo-store.ts +36 -7
@@ -22,6 +22,29 @@
22
22
  }
23
23
  ]
24
24
  }
25
+ ],
26
+ "PreCompact": [
27
+ {
28
+ "hooks": [
29
+ {
30
+ "type": "command",
31
+ "command": "{rotation}",
32
+ "timeout": 10
33
+ }
34
+ ]
35
+ }
36
+ ],
37
+ "SessionStart": [
38
+ {
39
+ "matcher": "compact|resume",
40
+ "hooks": [
41
+ {
42
+ "type": "command",
43
+ "command": "{rotation}",
44
+ "timeout": 10
45
+ }
46
+ ]
47
+ }
25
48
  ]
26
49
  }
27
50
  }
@@ -37,3 +37,26 @@ a numbered yes/no question is sent back to you to fix.
37
37
  Write "Decisions: none" when there are none, and "URL: none" (with a short
38
38
  reason) when the work has no page to open. Every message you end a turn
39
39
  with is read by a person, hand-back or not: explain each ID in it.
40
+
41
+ ### When your session rotates
42
+
43
+ A session ends when its pull request merges, or after a few compactions
44
+ (three unless your team set another number). Den tells you when that
45
+ happens. Then write a handoff to the file Den names (`handoff.md` in your
46
+ agent files folder) and end your turn. Den checks it, and relaunches your
47
+ role from it in a fresh conversation. Use these headings, each filled in:
48
+
49
+ ```
50
+ ## Objective
51
+ <what the work is for, in a sentence or two>
52
+ ## State
53
+ <what is done, and where it is: branch, pull request, files>
54
+ ## Open items
55
+ <what is left, one per line; or none>
56
+ ## How to QA
57
+ <the steps to see it working>
58
+ ## Next step
59
+ <the one thing to do first>
60
+ ```
61
+
62
+ Write it plainly. Explain every ID in it, as in a hand-back.
@@ -0,0 +1,127 @@
1
+ /**
2
+ * Den's session-rotation hook for Claude Code sessions it launches
3
+ * (den#4). One script for two events, run with the agent's own files dir
4
+ * and its limit:
5
+ *
6
+ * rotation-hook.ts --files <agent files dir> --after <n> (0: never rotates)
7
+ *
8
+ * Hook contract (https://code.claude.com/docs/en/hooks):
9
+ * - PreCompact: JSON on stdin with the common fields (`session_id`,
10
+ * `transcript_path`, `cwd`, `hook_event_name`) and `trigger` (manual or
11
+ * auto) and `custom_instructions`. Exit 0's stdout becomes instructions
12
+ * for the compaction summary. It runs before Claude Code decides to
13
+ * compact ("Not enough messages to compact" still runs it), so it never
14
+ * counts. When this compaction would make a rotation due, its stdout
15
+ * asks the summary to keep the instruction to write the handoff.
16
+ * - SessionStart, with `source` "compact" (after a compaction that
17
+ * happened) or "resume". "compact" counts the compaction in
18
+ * rotation.json (rotation.ts). While a rotation is due, it prints
19
+ * `{"hookSpecificOutput":{"hookEventName":"SessionStart","additionalContext":…}}`,
20
+ * which Claude Code adds to the context: write handoff.md, then end
21
+ * the turn.
22
+ *
23
+ * The parser is tolerant: a field it doesn't find is left out, never an
24
+ * error. Any error lets Claude Code carry on (exit 0, nothing printed):
25
+ * the hook never blocks a compaction.
26
+ */
27
+ import {
28
+ dueFor,
29
+ noteCompaction,
30
+ notePendingCompaction,
31
+ readHandoff,
32
+ readRotation,
33
+ rotationInstruction,
34
+ } from "../rotation.js";
35
+
36
+ export interface RotationHookArgs {
37
+ files: string;
38
+ after: number;
39
+ }
40
+
41
+ /** `--files <dir> --after <n>`; null when either is missing. */
42
+ export function parseHookArgs(
43
+ argv: readonly string[],
44
+ ): RotationHookArgs | null {
45
+ const at = (flag: string) => {
46
+ const i = argv.indexOf(flag);
47
+ return i >= 0 ? argv[i + 1] : undefined;
48
+ };
49
+ const files = at("--files");
50
+ const after = Number(at("--after") ?? "");
51
+ if (!files || !Number.isInteger(after) || after < 0) return null;
52
+ return { files, after };
53
+ }
54
+
55
+ /** The hook's stdout for one input ("" prints nothing). */
56
+ export function rotationHookOutput(
57
+ input: unknown,
58
+ args: RotationHookArgs,
59
+ now: Date = new Date(),
60
+ ): string {
61
+ if (!input || typeof input !== "object") return "";
62
+ const i = input as Record<string, unknown>;
63
+ const event = i.hook_event_name;
64
+ const conversation =
65
+ typeof i.session_id === "string" && i.session_id ? i.session_id : undefined;
66
+ if (event === "PreCompact" || (event === undefined && "trigger" in i)) {
67
+ // Not counted here: the compaction may not happen.
68
+ const prev = readRotation(args.files);
69
+ notePendingCompaction(args.files, conversation, i.trigger, args.after);
70
+ const counted =
71
+ prev &&
72
+ (!conversation ||
73
+ !prev.conversation ||
74
+ prev.conversation === conversation)
75
+ ? prev
76
+ : null;
77
+ const next = (counted?.compactions ?? 0) + 1;
78
+ const due =
79
+ dueFor(counted, conversation) ??
80
+ (args.after > 0 && next >= args.after ? "compactions" : undefined);
81
+ if (!due) return "";
82
+ return [
83
+ "Keep this instruction, word for word, at the top of the summary:",
84
+ rotationInstruction(
85
+ args.files,
86
+ due,
87
+ Math.max(next, counted?.compactions ?? 0),
88
+ ),
89
+ ].join("\n");
90
+ }
91
+ if (event === "SessionStart" || (event === undefined && "source" in i)) {
92
+ if (i.source !== "compact" && i.source !== "resume") return "";
93
+ const s =
94
+ i.source === "compact"
95
+ ? noteCompaction(args.files, i, args.after, now)
96
+ : readRotation(args.files);
97
+ const due = dueFor(s, conversation);
98
+ if (!due || readHandoff(args.files).status === "ok") return "";
99
+ return JSON.stringify({
100
+ hookSpecificOutput: {
101
+ hookEventName: "SessionStart",
102
+ additionalContext: rotationInstruction(args.files, due, s!.compactions),
103
+ },
104
+ });
105
+ }
106
+ return "";
107
+ }
108
+
109
+ async function main(): Promise<void> {
110
+ const args = parseHookArgs(process.argv.slice(2));
111
+ let text = "";
112
+ for await (const chunk of process.stdin) text += String(chunk);
113
+ if (!args) return;
114
+ let out = "";
115
+ try {
116
+ out = rotationHookOutput(JSON.parse(text), args);
117
+ } catch {
118
+ out = "";
119
+ }
120
+ if (out) process.stdout.write(`${out}\n`);
121
+ }
122
+
123
+ if (process.argv[1] && /rotation-hook\.[jt]s$/.test(process.argv[1]))
124
+ main().then(
125
+ () => process.exit(0),
126
+ () => process.exit(0),
127
+ );
@@ -13,17 +13,63 @@
13
13
  * When Claude is already continuing because of a stop hook
14
14
  * (stop_hook_active), it lets the turn end, so it never loops. Any error
15
15
  * lets the turn end too.
16
+ *
17
+ * Session rotation (den#4): run with `--files <agent files dir> --after
18
+ * <n>`, while a rotation is due (rotation.ts) and handoff.md is missing or
19
+ * fails the handoff check, it blocks once with what to write. Once the
20
+ * handoff passes, the turn ends as usual.
16
21
  */
17
22
  import { bareIds, checkHandback, looksLikeHandback } from "../handback.js";
23
+ import {
24
+ dueFor,
25
+ readHandoff,
26
+ readRotation,
27
+ rotationInstruction,
28
+ } from "../rotation.js";
29
+ import { parseHookArgs, type RotationHookArgs } from "./rotation-hook.js";
30
+
31
+ /** A due rotation's block, or "" (no rotation due, or the handoff passes). */
32
+ function rotationBlock(
33
+ input: { session_id?: unknown },
34
+ args: RotationHookArgs,
35
+ ): string {
36
+ const s = readRotation(args.files);
37
+ const conversation =
38
+ typeof input.session_id === "string" ? input.session_id : undefined;
39
+ const due = dueFor(s, conversation);
40
+ if (!due) return "";
41
+ const h = readHandoff(args.files);
42
+ if (h.status === "ok") return "";
43
+ return JSON.stringify({
44
+ decision: "block",
45
+ reason: [
46
+ rotationInstruction(args.files, due, s!.compactions),
47
+ ...(h.status === "invalid"
48
+ ? [
49
+ "Den checked the handoff there and it is not ready. Fix these:",
50
+ ...h.problems.map((p) => `- ${p}`),
51
+ ]
52
+ : []),
53
+ ].join("\n"),
54
+ });
55
+ }
18
56
 
19
57
  /** The hook's stdout for one Stop input ("" lets the turn end). */
20
- export function stopDecision(input: unknown): string {
58
+ export function stopDecision(
59
+ input: unknown,
60
+ rotation?: RotationHookArgs | null,
61
+ ): string {
21
62
  if (!input || typeof input !== "object") return "";
22
63
  const i = input as {
23
64
  stop_hook_active?: unknown;
24
65
  last_assistant_message?: unknown;
66
+ session_id?: unknown;
25
67
  };
26
68
  if (i.stop_hook_active === true) return "";
69
+ if (rotation) {
70
+ const block = rotationBlock(i, rotation);
71
+ if (block) return block;
72
+ }
27
73
  const msg = i.last_assistant_message;
28
74
  if (typeof msg !== "string") return "";
29
75
  // A hand-back is checked whole. Any other final message is what the
@@ -52,7 +98,7 @@ async function main(): Promise<void> {
52
98
  for await (const chunk of process.stdin) text += String(chunk);
53
99
  let out = "";
54
100
  try {
55
- out = stopDecision(JSON.parse(text));
101
+ out = stopDecision(JSON.parse(text), parseHookArgs(process.argv.slice(2)));
56
102
  } catch {
57
103
  out = "";
58
104
  }
@@ -7,10 +7,12 @@
7
7
  * role prompt (personas.ts rolePrompt), so every runtime
8
8
  * gets them.
9
9
  * hooks.json a Claude Code settings fragment: a PreToolUse hook on
10
- * Bash and Read (pretooluse.mjs) and a Stop hook
11
- * (stop-hook.ts, the hand-back check). den-claude merges
12
- * it into the agent's own --settings file; Den never
13
- * writes the user's ~/.claude.
10
+ * Bash and Read (pretooluse.mjs), a Stop hook
11
+ * (stop-hook.ts, the hand-back check), and PreCompact
12
+ * and SessionStart (compact, resume) hooks
13
+ * (rotation-hook.ts, session rotation: den#4). den-claude
14
+ * merges it into the agent's own --settings file; Den
15
+ * never writes the user's ~/.claude.
14
16
  *
15
17
  * On by default for Den's default pack, off for a distribution's packs.
16
18
  * A distribution turns them off or on per pack (agents.default_pack, a
@@ -68,27 +70,56 @@ export interface HookCommands {
68
70
  pretooluse: string;
69
71
  /** The Stop hook's command line. */
70
72
  stop: string;
73
+ /** The PreCompact and SessionStart hooks' command line (den#4). */
74
+ rotation: string;
75
+ }
76
+
77
+ /**
78
+ * Where an agent's rotation state lives and its limit (den#4): its files
79
+ * dir, and the compactions after which it rotates (0: never).
80
+ */
81
+ export interface RotationArgs {
82
+ files: string;
83
+ after: number;
71
84
  }
72
85
 
73
86
  /** The command lines Den's hooks run: this node, Den's own scripts. */
74
87
  export function hookCommands(
75
88
  dir: string = GUARDRAILS_DIR,
76
89
  node: string = process.execPath,
90
+ rotation?: RotationArgs,
77
91
  ): HookCommands {
92
+ const more = (c: EngineCommand): EngineCommand =>
93
+ rotation
94
+ ? {
95
+ command: c.command,
96
+ args: [
97
+ ...c.args,
98
+ "--files",
99
+ rotation.files,
100
+ "--after",
101
+ String(rotation.after),
102
+ ],
103
+ }
104
+ : c;
78
105
  return {
79
106
  pretooluse: commandLine({
80
107
  command: node,
81
108
  args: [path.join(dir, "pretooluse.mjs")],
82
109
  }),
83
110
  stop: commandLine(
84
- scriptEntrypoint("src/shared/agents/guardrails/stop-hook.ts"),
111
+ more(scriptEntrypoint("src/shared/agents/guardrails/stop-hook.ts")),
112
+ ),
113
+ rotation: commandLine(
114
+ more(scriptEntrypoint("src/shared/agents/guardrails/rotation-hook.ts")),
85
115
  ),
86
116
  };
87
117
  }
88
118
 
89
119
  /**
90
120
  * The `hooks` block of the agent's settings file: hooks.json with each
91
- * `{pretooluse}` / `{stop}` placeholder replaced by its command line.
121
+ * `{pretooluse}` / `{stop}` / `{rotation}` placeholder replaced by its
122
+ * command line.
92
123
  */
93
124
  export function guardrailHooks(
94
125
  dir: string = GUARDRAILS_DIR,
@@ -100,7 +131,7 @@ export function guardrailHooks(
100
131
  const fill = (v: unknown): unknown => {
101
132
  if (typeof v === "string")
102
133
  return v.replace(
103
- /\{(pretooluse|stop)\}/g,
134
+ /\{(pretooluse|stop|rotation)\}/g,
104
135
  (_, k: keyof HookCommands) => cmds[k],
105
136
  );
106
137
  if (Array.isArray(v)) return v.map(fill);
@@ -0,0 +1,118 @@
1
+ /**
2
+ * The handoff check (den#4, session rotation): is a handoff.md complete
3
+ * and plain? A session that rotates (after agents.rotate.after_compactions
4
+ * compactions, or once its pull request merged) writes one in its files
5
+ * dir; Den relaunches the role from it, in a fresh conversation whose
6
+ * first prompt is the handoff. `<cli> agents handoff-check <file|->` runs
7
+ * the check, and so does the Stop hook while a rotation is due.
8
+ *
9
+ * The template (guardrails/instructions.md):
10
+ *
11
+ * ## Objective
12
+ * <what the work is for, in a sentence or two>
13
+ * ## State
14
+ * <what is done and where it lives: branch, pull request, files>
15
+ * ## Open items
16
+ * <what is left, one per line; or none>
17
+ * ## How to QA
18
+ * <the steps to see it working>
19
+ * ## Next step
20
+ * <the one thing to do first>
21
+ *
22
+ * It rejects a handoff when:
23
+ *
24
+ * 1. A part is missing or empty. A part is a heading that is its label
25
+ * (`## Open items`) or a line that starts with its label and a colon
26
+ * (`Next step: open the PR`). Its content is the rest of the line and
27
+ * the lines after it, up to the next part or heading.
28
+ * 2. It has a bare ID: the hand-back check's rule (handback.ts bareIds).
29
+ * Code, links and code blocks are not scanned.
30
+ * 3. It is longer than HANDOFF_MAX_CHARS: it is the next session's first
31
+ * prompt, so it must stay short.
32
+ */
33
+ import { bareIds } from "./handback.js";
34
+
35
+ export const HANDOFF_PARTS = [
36
+ "Objective",
37
+ "State",
38
+ "Open items",
39
+ "How to QA",
40
+ "Next step",
41
+ ] as const;
42
+ export type HandoffPart = (typeof HANDOFF_PARTS)[number];
43
+
44
+ /** The longest handoff Den passes on (it becomes a first prompt). */
45
+ export const HANDOFF_MAX_CHARS = 20_000;
46
+
47
+ export interface HandoffResult {
48
+ ok: boolean;
49
+ /** One line each, plain, naming what to fix. */
50
+ problems: string[];
51
+ }
52
+
53
+ const LABEL = new RegExp(
54
+ `^\\s*(?:[-*+]\\s+)?(#{1,6}\\s+)?(?:\\*\\*|__)?(${HANDOFF_PARTS.map((p) =>
55
+ p.replace(/ /g, "\\s+"),
56
+ ).join("|")})(?:\\*\\*|__)?\\s*(:)?(?:\\*\\*|__)?\\s*(.*)$`,
57
+ "i",
58
+ );
59
+
60
+ function canonical(label: string): HandoffPart {
61
+ const l = label.replace(/\s+/g, " ").toLowerCase();
62
+ return HANDOFF_PARTS.find((p) => p.toLowerCase() === l)!;
63
+ }
64
+
65
+ /** Each part's content lines, by part. */
66
+ export function handoffParts(text: string): Map<HandoffPart, string[]> {
67
+ const found = new Map<HandoffPart, string[]>();
68
+ let cur: HandoffPart | null = null;
69
+ let fence = false;
70
+ for (const line of String(text ?? "").split(/\r?\n/)) {
71
+ const fenceLine = /^\s*(```|~~~)/.test(line);
72
+ if (fenceLine) fence = !fence;
73
+ const m = fence || fenceLine ? null : LABEL.exec(line);
74
+ // A label needs a colon, or to be the whole heading.
75
+ if (m && (m[3] || (m[1] && !m[4].trim()))) {
76
+ cur = canonical(m[2]);
77
+ const body = m[4].trim();
78
+ found.set(cur, body ? [body] : []);
79
+ continue;
80
+ }
81
+ if (!fence && /^\s*#{1,6}\s/.test(line)) {
82
+ // The document's own title, or another heading: no part's content.
83
+ cur = null;
84
+ continue;
85
+ }
86
+ if (cur && line.trim()) found.get(cur)!.push(line.trim());
87
+ }
88
+ return found;
89
+ }
90
+
91
+ /** Check a handoff. Pure: the text in, the problems out. */
92
+ export function checkHandoff(text: string): HandoffResult {
93
+ const s = String(text ?? "");
94
+ const problems: string[] = [];
95
+ if (!s.trim())
96
+ return {
97
+ ok: false,
98
+ problems: [
99
+ `The handoff is empty. Write ${HANDOFF_PARTS.map((p) => `"## ${p}"`).join(", ")}, each with its part filled in.`,
100
+ ],
101
+ };
102
+ const found = handoffParts(s);
103
+ for (const part of HANDOFF_PARTS) {
104
+ const body = found.get(part);
105
+ if (body === undefined) problems.push(`Missing "## ${part}". Add it.`);
106
+ else if (!body.join(" ").trim())
107
+ problems.push(`"## ${part}" is empty. Fill it in.`);
108
+ }
109
+ if (s.length > HANDOFF_MAX_CHARS)
110
+ problems.push(
111
+ `The handoff is ${s.length} characters long. Keep it under ${HANDOFF_MAX_CHARS}: it is the next session's first prompt.`,
112
+ );
113
+ for (const id of bareIds(s))
114
+ problems.push(
115
+ `"${id}" is not explained. Say what it is, e.g. "${id} (what it is)".`,
116
+ );
117
+ return { ok: problems.length === 0, problems };
118
+ }
@@ -13,7 +13,8 @@
13
13
  * tools "Read, Grep" or a list: hints, never a restriction Den applies
14
14
  *
15
15
  * Den's own keys sit beside them: `runtime` (default den-claude),
16
- * `standing` (starts with its workspace, §5), `context` (what to read
16
+ * `standing` (starts with its workspace, §5; resumes after a restart),
17
+ * `resume_on_restart` (AG-14.3: resumes after a restart too), `context` (what to read
17
18
  * first), `argv` (the generic runtime's template), and `permission_mode`
18
19
  * (den-claude's Claude Code permission mode; it checks the value), and
19
20
  * `den_hooks` / `den_instructions` (true or false: this persona's
@@ -45,6 +46,11 @@ export interface Persona {
45
46
  model?: string;
46
47
  /** Started with its workspace (planner, orchestrator). */
47
48
  standing: boolean;
49
+ /**
50
+ * `resume_on_restart: true` (AG-14.3): resumed on its own when Den or
51
+ * the machine restarted. Standing personas always are.
52
+ */
53
+ resumeOnRestart?: boolean;
48
54
  /** Tool hints. */
49
55
  tools: string[];
50
56
  /** What to read first (specs, the design system, workspace config). */
@@ -135,6 +141,9 @@ export function parsePersona(text: string, file: string): Persona | null {
135
141
  const standing = f.standing ?? false;
136
142
  if (typeof standing !== "boolean")
137
143
  throw new Error(`${file}: standing must be true or false`);
144
+ const resumeOnRestart = f.resume_on_restart ?? false;
145
+ if (typeof resumeOnRestart !== "boolean")
146
+ throw new Error(`${file}: resume_on_restart must be true or false`);
138
147
  let argv: string[] | undefined;
139
148
  if (f.argv !== undefined && f.argv !== null) {
140
149
  if (
@@ -166,6 +175,7 @@ export function parsePersona(text: string, file: string): Persona | null {
166
175
  ...(runtime ? { runtime } : {}),
167
176
  ...(model && model !== "inherit" ? { model } : {}),
168
177
  standing,
178
+ ...(resumeOnRestart ? { resumeOnRestart } : {}),
169
179
  tools: stringList(f, "tools", file),
170
180
  context: stringList(f, "context", file),
171
181
  ...(argv ? { argv } : {}),
@@ -4,16 +4,23 @@
4
4
  * same agents, and so an agent outlives the session it runs in.
5
5
  *
6
6
  * agents/<id>.json the record (0600, its directory 0700)
7
- * agent-files/<id>/ files its runtime wrote (0600): its MCP config
7
+ * agent-files/<id>/ files its runtime wrote (0600): its MCP config;
8
+ * and its rotation state and handoff.md (den#4)
8
9
  *
9
10
  * A record never holds the env or the argv: the role prompt, tokens and
10
11
  * anything else a launch passes stay out of it. An agent id is unique in a
11
12
  * distribution.
13
+ *
14
+ * AG-14: a removed agent's record moves to removed-agents/<id>.json, so
15
+ * Undo can bring it back; restoreRecord does, and purgeRemoved forgets
16
+ * the ones older than the undo window. The runtime's own conversation
17
+ * history is never touched.
12
18
  */
13
19
  import * as fs from "fs";
14
20
  import * as path from "path";
15
21
  import { WORKSPACE_NAME_RE } from "../agent-actor.js";
16
22
  import type { SessionRef } from "../sessions/types.js";
23
+ import type { RotationEntry } from "./rotation.js";
17
24
 
18
25
  export interface AgentRecord {
19
26
  id: string;
@@ -34,6 +41,59 @@ export interface AgentRecord {
34
41
  resumedAt?: string;
35
42
  /** The runtime's handle for resume (den-claude: the conversation id). */
36
43
  resumeToken?: string;
44
+ /**
45
+ * The runtime was launched on resumeToken at least once (0.4.8). A
46
+ * resume then always tries the runtime's own resume first, never a new
47
+ * conversation under the same id (which the runtime refuses once the
48
+ * conversation exists).
49
+ */
50
+ launched?: boolean;
51
+ /**
52
+ * The workspaces beyond its own its MCP served when it started
53
+ * (agents.<id>.grants then). A resume keeps them, never more than the
54
+ * config still allows (AG-14).
55
+ */
56
+ grants?: string[];
57
+ /** When its session was seen to end on its own (AG-14: "Exited"). */
58
+ endedAt?: string;
59
+ /** That session's exit code, when the host said it. */
60
+ exitCode?: number;
61
+ /**
62
+ * Set when its session was gone after Den or the machine restarted
63
+ * (AG-14.3: "Interrupted"); cleared by a resume or a start.
64
+ */
65
+ interruptedAt?: string;
66
+ /** What its run came to (den#6), filled in by `<cli> usage`. */
67
+ outcome?: AgentOutcome;
68
+ /** Its tokens, from the runtime's own transcript (den#6). */
69
+ usage?: AgentUsage;
70
+ /**
71
+ * Each time Den relaunched it from its handoff (den#4, session
72
+ * rotation), oldest first.
73
+ */
74
+ rotations?: RotationEntry[];
75
+ }
76
+
77
+ /** What an agent's run came to (den#6). Read locally; never sent. */
78
+ export interface AgentOutcome {
79
+ /** The pull request it opened, when it opened one. */
80
+ prOpened?: string;
81
+ /** That pull request merged in the run. */
82
+ prMerged?: boolean;
83
+ /** The last test run it made passed (true) or failed (false). */
84
+ testsGreen?: boolean;
85
+ /** From its start to its last activity, in milliseconds. */
86
+ wallClockMs?: number;
87
+ }
88
+
89
+ /** An agent's token counts (den#6), summed over its transcripts. */
90
+ export interface AgentUsage {
91
+ calls: number;
92
+ input: number;
93
+ output: number;
94
+ cacheRead: number;
95
+ cacheWrite: number;
96
+ compactions: number;
37
97
  }
38
98
 
39
99
  /** An agent id: the same rules as a workspace or session name. */
@@ -85,6 +145,67 @@ export function readRecord(stateDir: string, id: string): AgentRecord | null {
85
145
  }
86
146
  }
87
147
 
148
+ /** Where removed records wait for Undo (AG-14.1b). */
149
+ export function removedDir(stateDir: string): string {
150
+ return path.join(stateDir, "removed-agents");
151
+ }
152
+
153
+ /** How long a removed record can be brought back. */
154
+ export const UNDO_KEEP_MS = 60 * 60 * 1000;
155
+
156
+ /**
157
+ * Forget agent `id` (its record moves aside for Undo). True when there
158
+ * was one. Its files and the runtime's history stay.
159
+ */
160
+ export function removeRecord(stateDir: string, id: string): boolean {
161
+ const from = recordFile(stateDir, id);
162
+ if (!fs.existsSync(from)) return false;
163
+ ensureDir(removedDir(stateDir));
164
+ const to = path.join(removedDir(stateDir), `${id}.json`);
165
+ fs.renameSync(from, to);
166
+ // Its mtime is when it was removed: purgeRemoved goes by it.
167
+ const now = new Date();
168
+ fs.utimesSync(to, now, now);
169
+ return true;
170
+ }
171
+
172
+ /**
173
+ * Bring a removed record back (Undo). False when there is none to bring
174
+ * back, or an agent of that id exists again.
175
+ */
176
+ export function restoreRecord(stateDir: string, id: string): boolean {
177
+ if (!AGENT_ID_RE.test(id)) return false;
178
+ const from = path.join(removedDir(stateDir), `${id}.json`);
179
+ const to = recordFile(stateDir, id);
180
+ if (!fs.existsSync(from) || fs.existsSync(to)) return false;
181
+ ensureDir(agentsDir(stateDir));
182
+ fs.renameSync(from, to);
183
+ return true;
184
+ }
185
+
186
+ /** Forget removed records older than `keepMs` for good. */
187
+ export function purgeRemoved(
188
+ stateDir: string,
189
+ keepMs = UNDO_KEEP_MS,
190
+ now = Date.now(),
191
+ ): void {
192
+ let names: string[];
193
+ try {
194
+ names = fs.readdirSync(removedDir(stateDir));
195
+ } catch {
196
+ return;
197
+ }
198
+ for (const n of names) {
199
+ if (!n.endsWith(".json") || !AGENT_ID_RE.test(n.slice(0, -5))) continue;
200
+ const f = path.join(removedDir(stateDir), n);
201
+ try {
202
+ if (now - fs.statSync(f).mtimeMs > keepMs) fs.unlinkSync(f);
203
+ } catch {
204
+ /* gone already */
205
+ }
206
+ }
207
+ }
208
+
88
209
  export function listRecords(stateDir: string): AgentRecord[] {
89
210
  let names: string[];
90
211
  try {