@monster-orange/den 0.4.6 → 0.4.8

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 (49) hide show
  1. package/CHANGELOG.md +160 -0
  2. package/README.md +7 -7
  3. package/docs/distributions.md +244 -1
  4. package/package.json +1 -1
  5. package/schemas/den.plugin.v1.json +3 -2
  6. package/skills/install-den/SKILL.md +2 -2
  7. package/src/shared/agents/den-claude.ts +38 -5
  8. package/src/shared/agents/guardrails/hooks.json +27 -0
  9. package/src/shared/agents/guardrails/instructions.md +39 -0
  10. package/src/shared/agents/guardrails/pretooluse.mjs +430 -0
  11. package/src/shared/agents/guardrails/stop-hook.ts +66 -0
  12. package/src/shared/agents/guardrails.ts +114 -0
  13. package/src/shared/agents/handback.ts +241 -0
  14. package/src/shared/agents/personas.ts +66 -7
  15. package/src/shared/agents/records.ts +114 -0
  16. package/src/shared/agents/runtime.ts +12 -0
  17. package/src/shared/den-config.ts +97 -6
  18. package/src/shared/dev-metrics/emit.ts +31 -4
  19. package/src/shared/dev-metrics/polling.ts +135 -0
  20. package/src/shared/engine.ts +14 -2
  21. package/src/shared/plugins/manifest.ts +3 -0
  22. package/src/shared/usage/paths.ts +16 -0
  23. package/src/shared/usage/report.ts +283 -0
  24. package/src/shared/usage/sources.ts +127 -0
  25. package/src/shared/usage/transcript.ts +262 -0
  26. package/src/ui/cli/agent-ops.ts +495 -53
  27. package/src/ui/cli/agents-command.ts +170 -14
  28. package/src/ui/cli/doctor-command.ts +20 -0
  29. package/src/ui/cli/flag-parser.ts +1 -0
  30. package/src/ui/cli/index.ts +119 -2
  31. package/src/ui/cli/usage-command.ts +256 -0
  32. package/src/ui/cli/web-command.ts +3 -1
  33. package/src/ui/mcp/api.ts +10 -1
  34. package/src/ui/mcp/server.ts +34 -0
  35. package/src/ui/mcp/tools.ts +50 -0
  36. package/src/ui/server/agent-resume-grant.ts +53 -0
  37. package/src/ui/server/agent-routes.ts +230 -9
  38. package/src/ui/server/audit.ts +68 -1
  39. package/src/ui/server/index.ts +16 -5
  40. package/src/ui/server/public/agents-page.js +333 -11
  41. package/src/ui/server/public/agents-section.js +17 -3
  42. package/src/ui/server/public/agents.html +44 -0
  43. package/src/ui/server/public/i18n/es.js +28 -0
  44. package/src/ui/server/public/i18n/ja.js +25 -0
  45. package/src/ui/server/public/shell.js +66 -0
  46. package/src/ui/server/request-guard.ts +21 -1
  47. package/src/ui/server/route-classes.ts +23 -0
  48. package/src/ui/server/session-routes.ts +52 -4
  49. package/src/ui/server/sudo-store.ts +36 -7
@@ -0,0 +1,241 @@
1
+ /**
2
+ * The hand-back check: is a hand-back to a person complete and plain?
3
+ * `<cli> agents handback-check <file|->` runs it, and so does the Stop hook
4
+ * Den gives default-pack personas (guardrails/stop-hook.ts). The template
5
+ * is in guardrails/instructions.md:
6
+ *
7
+ * ## Hand-back
8
+ * - URL: <the link to open>
9
+ * - Machine: <the machine it runs on>
10
+ * - Branch: <the branch>
11
+ * - What changed: <what is different now>
12
+ * - What to click: <the steps to see it>
13
+ * - Decisions:
14
+ * 1. <a yes/no question>?
15
+ *
16
+ * It rejects a hand-back when:
17
+ *
18
+ * 1. A part is missing or empty. A part is a line that starts with its
19
+ * label and a colon (`URL:`, `- **Branch:** …`) or a heading that is its
20
+ * label (`### What changed`). Its content is the rest of the line and
21
+ * the lines after it, up to the next part. URL must hold a link (`://`)
22
+ * or say `none` (work with no page), optionally with a reason.
23
+ * Decisions may say `none`.
24
+ * 2. It has a bare ID. An ID is `#<digits>` or 1-4 capitals, an optional
25
+ * `-`, digits and an optional `.<digits>` (`R12`, `AG-14`, `KB-4.1`).
26
+ * URLs, `inline code` and code blocks, and the URL and Branch parts are
27
+ * not scanned: they are literal. An ID is glossed (not bare) when:
28
+ * a. a parenthetical of at least two words follows it:
29
+ * `#5 (the hooks issue)`;
30
+ * b. a colon or dash, then at least two words, follows it:
31
+ * `R12: plain writing`, `R12 - plain writing`;
32
+ * c. it sits inside a parenthetical right after a word, and that
33
+ * parenthetical holds no other words: `plain writing (R12)`,
34
+ * `the issues (#5, #6)`;
35
+ * d. or the same ID was glossed earlier in the text.
36
+ * A word is a run of letters (an ID is not a word).
37
+ * 3. A decision isn't a numbered yes/no question. Each item under
38
+ * Decisions must be numbered (`1.` or `1)`), end in `?`, not start
39
+ * with a question word (what, which, how, why, when, where, who), and
40
+ * not offer a choice (` or `).
41
+ */
42
+
43
+ export const HANDBACK_PARTS = [
44
+ "URL",
45
+ "Machine",
46
+ "Branch",
47
+ "What changed",
48
+ "What to click",
49
+ "Decisions",
50
+ ] as const;
51
+ export type HandbackPart = (typeof HANDBACK_PARTS)[number];
52
+
53
+ export interface HandbackResult {
54
+ ok: boolean;
55
+ /** One line each, plain, naming what to fix. */
56
+ problems: string[];
57
+ }
58
+
59
+ /** The ID patterns the check looks for. */
60
+ export const ID_PATTERNS = [/#\d+/g, /\b[A-Z]{1,4}-?\d+(?:\.\d+)?\b/g];
61
+
62
+ const LABEL = new RegExp(
63
+ `^\\s*(?:[-*+]\\s+)?(#{1,6}\\s+)?(?:\\*\\*|__)?(${HANDBACK_PARTS.map((p) =>
64
+ p.replace(/ /g, "\\s+"),
65
+ ).join("|")})(?:\\*\\*|__)?\\s*(:)?(?:\\*\\*|__)?\\s*(.*)$`,
66
+ "i",
67
+ );
68
+
69
+ /** The hand-back's heading, which marks a message as one. */
70
+ export const HANDBACK_HEADING = /^\s*#{1,6}\s*hand-?\s?back\b/im;
71
+
72
+ /** Does this message present itself as a hand-back (its heading)? */
73
+ export function looksLikeHandback(text: string): boolean {
74
+ return HANDBACK_HEADING.test(text);
75
+ }
76
+
77
+ interface Parts {
78
+ found: Map<HandbackPart, string[]>;
79
+ /** Line numbers (0-based) of the URL and Branch parts' content. */
80
+ literal: Set<number>;
81
+ }
82
+
83
+ function canonical(label: string): HandbackPart {
84
+ const l = label.replace(/\s+/g, " ").toLowerCase();
85
+ return HANDBACK_PARTS.find((p) => p.toLowerCase() === l)!;
86
+ }
87
+
88
+ function splitParts(lines: string[]): Parts {
89
+ const found = new Map<HandbackPart, string[]>();
90
+ const literal = new Set<number>();
91
+ let cur: HandbackPart | null = null;
92
+ let fence = false;
93
+ lines.forEach((line, n) => {
94
+ if (/^\s*(```|~~~)/.test(line)) fence = !fence;
95
+ const m = fence ? null : LABEL.exec(line);
96
+ // A label needs a colon, or to be the whole heading.
97
+ if (m && (m[3] || (m[1] && !m[4].trim()))) {
98
+ cur = canonical(m[2]);
99
+ const body = m[4].trim();
100
+ found.set(cur, body ? [body] : []);
101
+ if (cur === "URL" || cur === "Branch") literal.add(n);
102
+ return;
103
+ }
104
+ if (/^\s*#{1,6}\s/.test(line)) {
105
+ cur = null;
106
+ return;
107
+ }
108
+ if (!cur) return;
109
+ if (line.trim()) found.get(cur)!.push(line.trim());
110
+ if (cur === "URL" || cur === "Branch") literal.add(n);
111
+ });
112
+ return { found, literal };
113
+ }
114
+
115
+ /** Text with URLs and code replaced by spaces (offsets kept). */
116
+ function prose(line: string): string {
117
+ const blank = (s: string) => " ".repeat(s.length);
118
+ return line
119
+ .replace(/`[^`]*`/g, blank)
120
+ .replace(/\b[a-z][a-z0-9+.-]*:\/\/\S+/gi, blank)
121
+ .replace(/\bwww\.\S+/gi, blank);
122
+ }
123
+
124
+ const WORD = /[A-Za-z][A-Za-z'’]*(?:-[A-Za-z]+)*/g;
125
+
126
+ /** Words in s: runs of letters, once the IDs are taken out. */
127
+ function wordCount(s: string): number {
128
+ let t = s;
129
+ for (const re of ID_PATTERNS) t = t.replace(new RegExp(re.source, "g"), " ");
130
+ return (t.match(WORD) ?? []).length;
131
+ }
132
+
133
+ /** Is the ID at [start, end) in this line glossed (rules a-c)? */
134
+ export function isGlossed(line: string, start: number, end: number): boolean {
135
+ const after = line.slice(end);
136
+ // a. "(two or more words)" right after.
137
+ const paren = /^\s*\(([^)]*)\)/.exec(after);
138
+ if (paren && wordCount(paren[1]) >= 2) return true;
139
+ // b. ": two words" or " - two words" right after.
140
+ const colon = /^\s*(?::|\s[-–—])\s*(.*)$/.exec(after);
141
+ if (colon && wordCount(colon[1].split(/[.;!?]/)[0]) >= 2) return true;
142
+ // c. inside "(…)" right after a word, the parenthetical only IDs.
143
+ const open = line.lastIndexOf("(", start);
144
+ const close = line.indexOf(")", end);
145
+ if (open >= 0 && close >= 0 && line.slice(open, start).indexOf(")") < 0) {
146
+ const inside = line.slice(open + 1, close);
147
+ const before = line.slice(0, open);
148
+ if (wordCount(inside) === 0 && /[A-Za-z][A-Za-z'’]*\s*$/.test(before))
149
+ return true;
150
+ }
151
+ return false;
152
+ }
153
+
154
+ /** Bare IDs in the text, in order, each once. */
155
+ export function bareIds(
156
+ text: string,
157
+ skipLines: Set<number> = new Set(),
158
+ ): string[] {
159
+ const glossed = new Set<string>();
160
+ const bare: string[] = [];
161
+ let fence = false;
162
+ text.split(/\r?\n/).forEach((raw, n) => {
163
+ if (/^\s*(```|~~~)/.test(raw)) {
164
+ fence = !fence;
165
+ return;
166
+ }
167
+ if (fence || skipLines.has(n)) return;
168
+ const line = prose(raw);
169
+ const hits: { id: string; start: number; end: number }[] = [];
170
+ for (const re of ID_PATTERNS)
171
+ for (const m of line.matchAll(new RegExp(re.source, "g")))
172
+ hits.push({ id: m[0], start: m.index!, end: m.index! + m[0].length });
173
+ hits.sort((a, b) => a.start - b.start);
174
+ for (const h of hits) {
175
+ if (glossed.has(h.id)) continue;
176
+ if (isGlossed(line, h.start, h.end)) glossed.add(h.id);
177
+ else if (!bare.includes(h.id)) bare.push(h.id);
178
+ }
179
+ });
180
+ return bare;
181
+ }
182
+
183
+ const QUESTION_WORD = /^(what|which|how|why|when|where|who|whom|whose)\b/i;
184
+
185
+ /** Problems with the Decisions part's items. */
186
+ function decisionProblems(items: string[]): string[] {
187
+ if (items.length === 1 && /^(none|no decisions?|nothing)\.?$/i.test(items[0]))
188
+ return [];
189
+ if (!items.length)
190
+ return [
191
+ "Decisions is empty. List each as a numbered yes/no question, or say none.",
192
+ ];
193
+ const out: string[] = [];
194
+ for (const it of items) {
195
+ const m = /^(\d+)[.)]\s+(.*)$/.exec(it);
196
+ const q = (m ? m[2] : it.replace(/^[-*+]\s+/, "")).trim();
197
+ const show = q.length > 60 ? `${q.slice(0, 57)}...` : q;
198
+ if (!m)
199
+ out.push(`Decision "${show}" is not numbered. Number it: 1., 2., …`);
200
+ else if (!q.endsWith("?"))
201
+ out.push(`Decision "${show}" is not a question. End it with "?".`);
202
+ else if (QUESTION_WORD.test(q) || /\sor\s/i.test(q))
203
+ out.push(
204
+ `Decision "${show}" is not a yes/no question. Ask it so yes or no answers it.`,
205
+ );
206
+ }
207
+ return out;
208
+ }
209
+
210
+ /** Check a hand-back. Pure: the text in, the problems out. */
211
+ export function checkHandback(text: string): HandbackResult {
212
+ const lines = String(text ?? "").split(/\r?\n/);
213
+ const { found, literal } = splitParts(lines);
214
+ const problems: string[] = [];
215
+ for (const part of HANDBACK_PARTS) {
216
+ const body = found.get(part);
217
+ if (body === undefined) {
218
+ problems.push(`Missing "${part}:". Add it.`);
219
+ continue;
220
+ }
221
+ if (part === "Decisions") {
222
+ problems.push(...decisionProblems(body));
223
+ continue;
224
+ }
225
+ if (!body.join(" ").trim())
226
+ problems.push(`"${part}:" is empty. Fill it in.`);
227
+ else if (
228
+ part === "URL" &&
229
+ !body.join(" ").includes("://") &&
230
+ !/^\s*none\b/i.test(body.join(" "))
231
+ )
232
+ problems.push(
233
+ `"URL:" has no link. Give the full address, with http:// or https://, or say none if this work has no page.`,
234
+ );
235
+ }
236
+ for (const id of bareIds(lines.join("\n"), literal))
237
+ problems.push(
238
+ `"${id}" is not explained. Say what it is, e.g. "${id} (what it is)".`,
239
+ );
240
+ return { ok: problems.length === 0, problems };
241
+ }
@@ -13,9 +13,12 @@
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
- * (den-claude's Claude Code permission mode; it checks the value). Any other key (a
19
+ * (den-claude's Claude Code permission mode; it checks the value), and
20
+ * `den_hooks` / `den_instructions` (true or false: this persona's
21
+ * guardrails, guardrails.ts, over its pack's). Any other key (a
19
22
  * Claude Code `color`) is ignored. A file without front matter (a pack's
20
23
  * README) is not a persona.
21
24
  */
@@ -24,6 +27,12 @@ import * as path from "path";
24
27
  import * as yaml from "yaml";
25
28
  import { WORKSPACE_NAME_RE } from "../agent-actor.js";
26
29
  import { enginePath } from "../engine.js";
30
+ import {
31
+ DEFAULT_PACK_GUARDRAILS,
32
+ PACK_GUARDRAILS,
33
+ sharedInstructions,
34
+ type Guardrails,
35
+ } from "./guardrails.js";
27
36
 
28
37
  export interface Persona {
29
38
  id: string;
@@ -37,6 +46,11 @@ export interface Persona {
37
46
  model?: string;
38
47
  /** Started with its workspace (planner, orchestrator). */
39
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;
40
54
  /** Tool hints. */
41
55
  tools: string[];
42
56
  /** What to read first (specs, the design system, workspace config). */
@@ -45,6 +59,13 @@ export interface Persona {
45
59
  argv?: string[];
46
60
  /** `permission_mode`: the runtime's permission mode (den-claude checks it). */
47
61
  permissionMode?: string;
62
+ /** `den_hooks` / `den_instructions` as its front matter sets them. */
63
+ guardrailsSet?: Partial<Guardrails>;
64
+ /**
65
+ * Den's guardrails for it (guardrails.ts), resolved by loadPersonas from
66
+ * its pack and its own front matter. Absent: none.
67
+ */
68
+ guardrails?: Guardrails;
48
69
  /** The file it came from. */
49
70
  source: string;
50
71
  }
@@ -120,6 +141,9 @@ export function parsePersona(text: string, file: string): Persona | null {
120
141
  const standing = f.standing ?? false;
121
142
  if (typeof standing !== "boolean")
122
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`);
123
147
  let argv: string[] | undefined;
124
148
  if (f.argv !== undefined && f.argv !== null) {
125
149
  if (
@@ -133,6 +157,17 @@ export function parsePersona(text: string, file: string): Persona | null {
133
157
  const model = optString(f, "model", file);
134
158
  const runtime = optString(f, "runtime", file);
135
159
  const permissionMode = optString(f, "permission_mode", file);
160
+ const guardrailsSet: Partial<Guardrails> = {};
161
+ for (const [key, k] of [
162
+ ["den_hooks", "hooks"],
163
+ ["den_instructions", "instructions"],
164
+ ] as const) {
165
+ const v = f[key];
166
+ if (v === undefined || v === null) continue;
167
+ if (typeof v !== "boolean")
168
+ throw new Error(`${file}: ${key} must be true or false`);
169
+ guardrailsSet[k] = v;
170
+ }
136
171
  return {
137
172
  id,
138
173
  role: optString(f, "role", file) ?? optString(f, "description", file) ?? "",
@@ -140,10 +175,12 @@ export function parsePersona(text: string, file: string): Persona | null {
140
175
  ...(runtime ? { runtime } : {}),
141
176
  ...(model && model !== "inherit" ? { model } : {}),
142
177
  standing,
178
+ ...(resumeOnRestart ? { resumeOnRestart } : {}),
143
179
  tools: stringList(f, "tools", file),
144
180
  context: stringList(f, "context", file),
145
181
  ...(argv ? { argv } : {}),
146
182
  ...(permissionMode ? { permissionMode } : {}),
183
+ ...(Object.keys(guardrailsSet).length ? { guardrailsSet } : {}),
147
184
  source: file,
148
185
  };
149
186
  }
@@ -183,23 +220,42 @@ export function resolvePackDirs(packs: string[], distRoot: string): string[] {
183
220
  });
184
221
  }
185
222
 
223
+ /** Each pack's guardrails (guardrails.ts), as agents: configures them. */
224
+ export interface PackGuardrails {
225
+ /** Den's default pack (default: both on). */
226
+ defaultPack?: Partial<Guardrails>;
227
+ /** Each of packDirs, in order (default: both off). */
228
+ packs?: (Partial<Guardrails> | undefined)[];
229
+ }
230
+
186
231
  /**
187
232
  * Every persona: Den's default pack, then each pack in order. A later
188
- * pack's persona replaces an earlier one of the same id.
233
+ * pack's persona replaces an earlier one of the same id. Each gets its
234
+ * guardrails: its own front matter's, else its pack's.
189
235
  */
190
236
  export function loadPersonas(
191
237
  packDirs: string[],
192
238
  defaultPack: string = DEFAULT_PACK_DIR,
239
+ guardrails: PackGuardrails = {},
193
240
  ): Map<string, Persona> {
194
241
  const all = new Map<string, Persona>();
195
- for (const dir of [defaultPack, ...packDirs])
196
- for (const p of readPack(dir)) all.set(p.id, p);
242
+ const packs: [string, Guardrails][] = [
243
+ [defaultPack, { ...DEFAULT_PACK_GUARDRAILS, ...guardrails.defaultPack }],
244
+ ...packDirs.map((dir, i): [string, Guardrails] => [
245
+ dir,
246
+ { ...PACK_GUARDRAILS, ...guardrails.packs?.[i] },
247
+ ]),
248
+ ];
249
+ for (const [dir, g] of packs)
250
+ for (const p of readPack(dir))
251
+ all.set(p.id, { ...p, guardrails: { ...g, ...p.guardrailsSet } });
197
252
  return all;
198
253
  }
199
254
 
200
255
  /**
201
256
  * The role prompt as an agent gets it: who and where it is, what to read
202
- * first, then the persona's own prompt. Runtime-neutral; a runtime only
257
+ * first, the persona's own prompt, then Den's shared instructions
258
+ * (guardrails/instructions.md) when its guardrails include them. Runtime-neutral; a runtime only
203
259
  * chooses how to pass it in.
204
260
  */
205
261
  export function rolePrompt(
@@ -214,5 +270,8 @@ export function rolePrompt(
214
270
  ];
215
271
  if (persona.context.length)
216
272
  lines.push(`Read first: ${persona.context.join("; ")}.`);
217
- return `${lines.join("\n")}\n\n${persona.prompt}`;
273
+ const shared = persona.guardrails?.instructions
274
+ ? `\n\n${sharedInstructions()}`
275
+ : "";
276
+ return `${lines.join("\n")}\n\n${persona.prompt}${shared}`;
218
277
  }
@@ -9,6 +9,11 @@
9
9
  * A record never holds the env or the argv: the role prompt, tokens and
10
10
  * anything else a launch passes stay out of it. An agent id is unique in a
11
11
  * distribution.
12
+ *
13
+ * AG-14: a removed agent's record moves to removed-agents/<id>.json, so
14
+ * Undo can bring it back; restoreRecord does, and purgeRemoved forgets
15
+ * the ones older than the undo window. The runtime's own conversation
16
+ * history is never touched.
12
17
  */
13
18
  import * as fs from "fs";
14
19
  import * as path from "path";
@@ -34,6 +39,54 @@ export interface AgentRecord {
34
39
  resumedAt?: string;
35
40
  /** The runtime's handle for resume (den-claude: the conversation id). */
36
41
  resumeToken?: string;
42
+ /**
43
+ * The runtime was launched on resumeToken at least once (0.4.8). A
44
+ * resume then always tries the runtime's own resume first, never a new
45
+ * conversation under the same id (which the runtime refuses once the
46
+ * conversation exists).
47
+ */
48
+ launched?: boolean;
49
+ /**
50
+ * The workspaces beyond its own its MCP served when it started
51
+ * (agents.<id>.grants then). A resume keeps them, never more than the
52
+ * config still allows (AG-14).
53
+ */
54
+ grants?: string[];
55
+ /** When its session was seen to end on its own (AG-14: "Exited"). */
56
+ endedAt?: string;
57
+ /** That session's exit code, when the host said it. */
58
+ exitCode?: number;
59
+ /**
60
+ * Set when its session was gone after Den or the machine restarted
61
+ * (AG-14.3: "Interrupted"); cleared by a resume or a start.
62
+ */
63
+ interruptedAt?: string;
64
+ /** What its run came to (den#6), filled in by `<cli> usage`. */
65
+ outcome?: AgentOutcome;
66
+ /** Its tokens, from the runtime's own transcript (den#6). */
67
+ usage?: AgentUsage;
68
+ }
69
+
70
+ /** What an agent's run came to (den#6). Read locally; never sent. */
71
+ export interface AgentOutcome {
72
+ /** The pull request it opened, when it opened one. */
73
+ prOpened?: string;
74
+ /** That pull request merged in the run. */
75
+ prMerged?: boolean;
76
+ /** The last test run it made passed (true) or failed (false). */
77
+ testsGreen?: boolean;
78
+ /** From its start to its last activity, in milliseconds. */
79
+ wallClockMs?: number;
80
+ }
81
+
82
+ /** An agent's token counts (den#6), summed over its transcripts. */
83
+ export interface AgentUsage {
84
+ calls: number;
85
+ input: number;
86
+ output: number;
87
+ cacheRead: number;
88
+ cacheWrite: number;
89
+ compactions: number;
37
90
  }
38
91
 
39
92
  /** An agent id: the same rules as a workspace or session name. */
@@ -85,6 +138,67 @@ export function readRecord(stateDir: string, id: string): AgentRecord | null {
85
138
  }
86
139
  }
87
140
 
141
+ /** Where removed records wait for Undo (AG-14.1b). */
142
+ export function removedDir(stateDir: string): string {
143
+ return path.join(stateDir, "removed-agents");
144
+ }
145
+
146
+ /** How long a removed record can be brought back. */
147
+ export const UNDO_KEEP_MS = 60 * 60 * 1000;
148
+
149
+ /**
150
+ * Forget agent `id` (its record moves aside for Undo). True when there
151
+ * was one. Its files and the runtime's history stay.
152
+ */
153
+ export function removeRecord(stateDir: string, id: string): boolean {
154
+ const from = recordFile(stateDir, id);
155
+ if (!fs.existsSync(from)) return false;
156
+ ensureDir(removedDir(stateDir));
157
+ const to = path.join(removedDir(stateDir), `${id}.json`);
158
+ fs.renameSync(from, to);
159
+ // Its mtime is when it was removed: purgeRemoved goes by it.
160
+ const now = new Date();
161
+ fs.utimesSync(to, now, now);
162
+ return true;
163
+ }
164
+
165
+ /**
166
+ * Bring a removed record back (Undo). False when there is none to bring
167
+ * back, or an agent of that id exists again.
168
+ */
169
+ export function restoreRecord(stateDir: string, id: string): boolean {
170
+ if (!AGENT_ID_RE.test(id)) return false;
171
+ const from = path.join(removedDir(stateDir), `${id}.json`);
172
+ const to = recordFile(stateDir, id);
173
+ if (!fs.existsSync(from) || fs.existsSync(to)) return false;
174
+ ensureDir(agentsDir(stateDir));
175
+ fs.renameSync(from, to);
176
+ return true;
177
+ }
178
+
179
+ /** Forget removed records older than `keepMs` for good. */
180
+ export function purgeRemoved(
181
+ stateDir: string,
182
+ keepMs = UNDO_KEEP_MS,
183
+ now = Date.now(),
184
+ ): void {
185
+ let names: string[];
186
+ try {
187
+ names = fs.readdirSync(removedDir(stateDir));
188
+ } catch {
189
+ return;
190
+ }
191
+ for (const n of names) {
192
+ if (!n.endsWith(".json") || !AGENT_ID_RE.test(n.slice(0, -5))) continue;
193
+ const f = path.join(removedDir(stateDir), n);
194
+ try {
195
+ if (now - fs.statSync(f).mtimeMs > keepMs) fs.unlinkSync(f);
196
+ } catch {
197
+ /* gone already */
198
+ }
199
+ }
200
+ }
201
+
88
202
  export function listRecords(stateDir: string): AgentRecord[] {
89
203
  let names: string[];
90
204
  try {
@@ -130,6 +130,18 @@ export interface Runtime {
130
130
  launch(input: LaunchInput): LaunchOutput;
131
131
  /** A new run that continues the last one; null when there is nothing to resume. */
132
132
  resume?(input: ResumeInput): Omit<LaunchOutput, "resumeToken"> | null;
133
+ /**
134
+ * Whether the runtime has a conversation for `token` on this machine
135
+ * (den-claude: its transcript file). Only ever a fast path: false may
136
+ * just mean it isn't written yet.
137
+ */
138
+ conversationExists?(token: string, env?: NodeJS.ProcessEnv): boolean;
139
+ /**
140
+ * What the runtime prints when it exits at once (0.4.8): `noConversation`
141
+ * when a resume finds nothing to continue, `idInUse` when a new
142
+ * conversation's id is taken. Den then tries the other way, once.
143
+ */
144
+ launchFailures?: { noConversation?: RegExp; idInUse?: RegExp };
133
145
  /** Where permission prompts surface (Q-AG-2). */
134
146
  approvals?: "terminal" | "hook";
135
147
  /**